cesiumEngine.js 120 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407
  1. /**
  2. * Cesium 引擎适配层(票 27):本仓库里唯一 `import 'cesium'` 的地方。
  3. *
  4. * 只在 `mapService.mountMap()` 完成 `window.CESIUM_BASE_URL` 注入之后被动态 import,
  5. * 因此静态 import 提升不会踩到时序(见 services/cesiumAssets.js)。
  6. *
  7. * 本模块负责两件事:
  8. * 1. 「把引擎接到宿主上」(票 27):唯一 `Viewer`、自带控件与默认相机输入全关、
  9. * 不碰需要账号的能力;底图按 `domain/basemap.js` 的计划挂(票 29),
  10. * 没配就不挂(票 27 的「底图未配置不伪造配准」);
  11. * 2. 「把相机契约接到引擎上」(票 03):`createCameraEngine()` 把三键契约需要的原语
  12. * (弹簧臂 `lookAtTransform(东北天局部系, HeadingPitchRange)`、屏幕中心地面拾取、
  13. * 中心点投影)包成 services/cameraControl.js 的引擎接口 —— 相机语义本身不在本文件,
  14. * 这里只做 Cesium 方言的翻译。
  15. */
  16. import {
  17. Cartesian2,
  18. BoundingSphere,
  19. Intersect,
  20. Cartesian3,
  21. Cartographic,
  22. Cesium3DTileset,
  23. Cesium3DTileStyle,
  24. CesiumTerrainProvider,
  25. Color,
  26. CustomDataSource,
  27. DirectionalLight,
  28. Ellipsoid,
  29. EllipsoidalOccluder,
  30. ImageBasedLighting,
  31. HeightReference,
  32. HeadingPitchRange,
  33. ImageryLayer,
  34. LabelStyle,
  35. Math as CesiumMath,
  36. Matrix3,
  37. Matrix4,
  38. OrientedBoundingBox,
  39. PostProcessStage,
  40. Rectangle,
  41. RequestScheduler,
  42. SceneTransforms,
  43. TileMapServiceImageryProvider,
  44. Transforms,
  45. UrlTemplateImageryProvider,
  46. VerticalOrigin,
  47. Viewer,
  48. buildModuleUrl,
  49. sampleTerrainMostDetailed,
  50. } from 'cesium'
  51. import 'cesium/Build/Cesium/Widgets/widgets.css'
  52. // 强调的**判定**在 domain(纯函数,可脱浏览器断言);本层只把结论写成显示参数。
  53. import { EMPHASIS_STYLE, shouldEmphasize } from '../domain/mapEmphasis.js'
  54. import { MAP_LABEL_STYLE, mapLabelText } from '../domain/mapLabelStyle.js'
  55. // 批次 3 第四轮:标签省略走 domain/ellipsis.js 的共用契约(与 DOM 侧 EllipsisText 同语义)。
  56. import { ellipsisForMapLabel } from '../domain/ellipsis.js'
  57. import { createTilesetLoadingController } from './tilesetLoading.js'
  58. import { tilesetOptions } from './tilesetOptions.js'
  59. import { createRegionLoadingController, contentAdmission } from './regionLoading.js'
  60. import { removeSceneTileset } from './tilesetCleanup.js'
  61. const regionControllers = new WeakMap()
  62. // 票 47:动效的**相位与映射**同样在 domain(纯函数,可跨时间采样断言);
  63. // 本层只把相位写成 billboard 的 scale/alpha/rotation。
  64. import { DEVICE_EFFECT, RING_LAYERS, blinkStrengthAt, glowScaleAt, glowStrengthAt, hasGlow, pulseStrengthAt, ringEntityCount, ringFrameAt, spinAngleAt } from '../domain/deviceEffects.js'
  65. // 场景调色的兜底色直接取 domain 的默认(不再手抄一份 #2fa8ff 的浮点,两处会漂)。
  66. // 注意:GRADE_DEFAULTS 来自 sceneGrade;parseHexColor 的 owner 是 fog.js ——
  67. // sceneGrade 自己也只是导入它,从这里导 parseHexColor 会是 ESM 链接错误(引擎整个起不来)。
  68. import { GRADE_DEFAULTS } from '../domain/sceneGrade.js'
  69. import { parseHexColor } from '../domain/fog.js'
  70. // 批次 3:合成计划宿主读数 + 母版合成渲染。
  71. import { planToDataset } from '../domain/markerPlan.js'
  72. import { renderMarker } from './markerAssets.js'
  73. const DEG = Math.PI / 180
  74. const VIEWER_OPTIONS = {
  75. // 自带控件全关(导航/时间轴/拾取框等)。
  76. animation: false,
  77. timeline: false,
  78. geocoder: false,
  79. homeButton: false,
  80. sceneModePicker: false,
  81. baseLayerPicker: false,
  82. navigationHelpButton: false,
  83. navigationInstructionsInitiallyVisible: false,
  84. fullscreenButton: false,
  85. infoBox: false,
  86. selectionIndicator: false,
  87. vrButton: false,
  88. // 相机静止、无新瓦片时减少重复绘制;按需渲染不等于解码/GPU 上传自动分帧。
  89. // maximumRenderTimeChange 是模拟时钟变化阈值,不是墙钟时间的固定渲染间隔。
  90. requestRenderMode: true,
  91. maximumRenderTimeChange: 0.5,
  92. }
  93. /** 按底图计划建 imagery layer;未配置或计划无效时返回 false = 不配底图(票 27:不伪造地理配准)。 */
  94. function createBaseLayer(plan) {
  95. if (!plan || !plan.ready) return false
  96. if (plan.kind === 'tms') {
  97. // Cesium 自带的离线 TMS(随 Assets 目录进产物,不需要账号或外网)。
  98. return ImageryLayer.fromProviderAsync(TileMapServiceImageryProvider.fromUrl(buildModuleUrl(plan.url)))
  99. }
  100. if (plan.kind === 'xyz') {
  101. const options = { url: plan.url }
  102. if (plan.subdomains && plan.subdomains.length) options.subdomains = plan.subdomains
  103. if (Number.isInteger(plan.minimumLevel)) options.minimumLevel = plan.minimumLevel
  104. if (Number.isInteger(plan.maximumLevel)) options.maximumLevel = plan.maximumLevel
  105. if (plan.credit) options.credit = plan.credit
  106. return new ImageryLayer(new UrlTemplateImageryProvider(options))
  107. }
  108. return false
  109. }
  110. /**
  111. * 在宿主要素上创建 Viewer。
  112. *
  113. * **署名容器(票 25)**:`creditContainer` **不是开关,是「放哪个容器」**——
  114. * Cesium 的 Viewer 构造写死 `defined(options.creditContainer) ? options.creditContainer : bottomContainer`,
  115. * 随后 CesiumWidget **两条路径都会**建 CreditDisplay
  116. * (`creditContainer.appendChild(innerCreditContainer)`)。
  117. * 所以传 `false`/`''` 既关不掉、还会让 `getElement()` 拿到 null 而崩。
  118. * 真正能生效的做法是**自己给一个不进 DOM 的容器**:Cesium 照旧把署名写进去,
  119. * 但它不在文档流里,屏幕上不出现,destroy 时 Cesium 也会自行摘掉内部节点。
  120. *
  121. * 合法性前提(本部署已核实,勿轻易推翻):地形走 `CesiumTerrainProvider.fromUrl(本地 URL)`、
  122. * 影像走内置 TMS 与 `UrlTemplateImageryProvider`、3D Tiles 走 `Cesium3DTileset.fromUrl(本地 URL)`,
  123. * **全链路无 Cesium ion 服务与 ion 资产**。ion 署名义务只在用到 ion 资产时成立,
  124. * 故此处隐藏署名不构成违约;自有数据源署名由地图信息面板在业务界面另行展示。
  125. *
  126. * @param {Element} host 宿主要素(`.map-canvas`)
  127. * @param {object} [basemapPlan] `domain/basemap.js` 的底图计划(票 29);缺省 = 不配底图
  128. * @returns {Viewer}
  129. */
  130. export function createViewer(host, basemapPlan) {
  131. // 票 25:离屏署名容器 —— 故意不 appendChild,署名因此不上屏。
  132. const creditContainer = document.createElement('div')
  133. const viewer = new Viewer(host, { ...VIEWER_OPTIONS, creditContainer, baseLayer: createBaseLayer(basemapPlan) })
  134. const controller = viewer.scene.screenSpaceCameraController
  135. controller.enableRotate = false
  136. controller.enableZoom = false
  137. controller.enableTilt = false
  138. controller.enableLook = false
  139. controller.enableTranslate = false
  140. // 球体底色用驾驶舱底色:没配底图时看到的就是这个球,配了底图会被影像盖住。
  141. viewer.scene.globe.baseColor = Color.fromCssColorString('#04121f')
  142. // 地面视图必须保留地形深度,否则埋在地表以下的 3D Tiles 也会覆盖到地形之上。
  143. // 回归:tools/city-massing/verify-occlusion.mjs 用实际渲染像素检查遮挡。
  144. viewer.scene.globe.depthTestAgainstTerrain = true
  145. return viewer
  146. }
  147. export function resizeViewer(viewer) {
  148. viewer.resize()
  149. }
  150. /** 叠加影像(票 21):本地精细切片画在底图之上,URL 模板里可用 `{reverseY}` 表示 TMS 的 y。 */
  151. function createOverlayProvider(overlay) {
  152. const options = { url: overlay.url }
  153. if (Number.isInteger(overlay.minimumLevel)) options.minimumLevel = overlay.minimumLevel
  154. if (Number.isInteger(overlay.maximumLevel)) options.maximumLevel = overlay.maximumLevel
  155. if (overlay.bounds) options.rectangle = Rectangle.fromDegrees(overlay.bounds[0], overlay.bounds[1], overlay.bounds[2], overlay.bounds[3])
  156. if (overlay.credit) options.credit = overlay.credit
  157. return new UrlTemplateImageryProvider(options)
  158. }
  159. /**
  160. * 批量采样真实地表高程(票 34 的建模用途):一次请求把整张网格取回来,
  161. * 比逐点调 `sampleTerrainHeight()` 快得多(逐点会重复拉同一批瓦片)。
  162. * @param {Array<{lon: number, lat: number}>} points
  163. * @returns {Promise<Array<number|null>>} 与入参等长;无地形或取不到的点为 null
  164. */
  165. export async function sampleTerrainHeights(viewer, points) {
  166. const list = Array.isArray(points) ? points : []
  167. if (!viewer || !viewer.terrainProvider || !list.length) return list.map(() => null)
  168. try {
  169. const cartos = list.map((p) => Cartographic.fromDegrees(p.lon, p.lat))
  170. const sampled = await sampleTerrainMostDetailed(viewer.terrainProvider, cartos)
  171. return sampled.map((c) => (c && Number.isFinite(c.height) ? c.height : null))
  172. } catch {
  173. return list.map(() => null)
  174. }
  175. }
  176. /**
  177. * 采样一点的**真实地表高程**(票 30):地形感知的中心点/预设视角靠它把高度从「椭球 0 米」
  178. * 换成实际地形高度,否则相机高度按 0 算,一拉近就落进山体(实测 range 120m 全黑)。
  179. * @returns {Promise<number|null>} 米;没有地形或采样失败返回 null
  180. */
  181. export async function sampleTerrainHeight(viewer, lon, lat) {
  182. if (!viewer || !viewer.terrainProvider) return null
  183. try {
  184. const carto = Cartographic.fromDegrees(lon, lat)
  185. const [sampled] = await sampleTerrainMostDetailed(viewer.terrainProvider, [carto])
  186. const height = sampled && sampled.height
  187. return Number.isFinite(height) ? height : null
  188. } catch {
  189. return null
  190. }
  191. }
  192. /**
  193. * 挂地形(票 21)。DEM 是**量化网格**:`CesiumTerrainProvider.fromUrl(<layer.json 所在目录>)`,
  194. * `layer.json` 里的 `scheme: "tms"` 由 Cesium 自己认,服务端不需要翻 y。
  195. * @returns {Promise<string>} `'ready'`;失败时抛错,由调用方记成失败态(保留椭球兜底)
  196. */
  197. export async function applyTerrain(viewer, terrainPlan) {
  198. const provider = await CesiumTerrainProvider.fromUrl(terrainPlan.url, {
  199. requestVertexNormals: terrainPlan.requestVertexNormals !== false,
  200. })
  201. viewer.terrainProvider = provider
  202. return 'ready'
  203. }
  204. /**
  205. * 叠加影像句柄(票 39):把「精细层」包成一个只暴露必需动作的对象,供交接状态机驱动。
  206. *
  207. * ── 两个必须讲清楚的 Cesium 事实(本票实现依据,都查过源码)──
  208. *
  209. * 1. **隐藏期间仍要继续预取瓦片**(决策 08 第 1 轮 Q4=A「看不见不等于停止加载」)。
  210. * 瓦片是否被请求由 `GlobeSurfaceTile` 按 **`layer.show`** 决定
  211. * (`Source/Scene/GlobeSurfaceTile.js:691` `if (layer.show) { layer._createTileImagerySkeletons(...) }`),
  212. * **不看 `alpha`**。所以"隐藏精细层"的正确做法是 **`alpha = 0` 且保持 `show = true`** ——
  213. * 用 `show = false` 会真的停掉预取,等揭示时才开始下载,正好造成"先空白再跳方块"。
  214. *
  215. * 2. **alpha 只允许一处写**。本模块提供 `setAlpha()` 作为唯一入口,
  216. * 服务层与原型都不得直接摸 `layer.alpha`(原型若同时写会互相打架,见 mapService 的隔离注释)。
  217. */
  218. function createOverlayHandle(layer) {
  219. return {
  220. layer,
  221. /** 该层的原始 alpha(销毁/还原时用)。 */
  222. initialAlpha: Number.isFinite(layer.alpha) ? layer.alpha : 1,
  223. /** 当前由交接写入的 alpha(读数与断言用;不代表"画面已遮住")。 */
  224. alpha: Number.isFinite(layer.alpha) ? layer.alpha : 1,
  225. /**
  226. * 写 alpha(**唯一入口**)。
  227. *
  228. * 刻意**不**动 `show`:`alpha = 0` 即不可见,但 `show` 保持 true 让瓦片继续预取
  229. * (见上方事实 1)。UI 不可见 ≠ 停止加载。
  230. */
  231. setAlpha(value) {
  232. const next = Math.max(0, Math.min(1, Number.isFinite(value) ? value : 0))
  233. this.alpha = next
  234. layer.alpha = next
  235. return next
  236. },
  237. /** 当前层对象是否还有效(viewer 销毁后不要再碰)。 */
  238. alive() {
  239. return !!layer && typeof layer.alpha === 'number'
  240. },
  241. }
  242. }
  243. /**
  244. * 把相机**直接放到指定椭球高度**(DEV 诊断用;保持当前经纬度与姿态)。
  245. *
  246. * 为什么放在引擎层:`Cartesian3` 只允许出现在本仓唯一 `import 'cesium'` 的地方(ADR 0004),
  247. * 服务层不碰 Cesium 类型。
  248. *
  249. * 为什么不能直接赋值:`camera.positionWC` / `positionCartographic` 在
  250. * `Source/Scene/Camera.js` 里**只有 getter**(内部由 `_position` 推导),
  251. * 赋值不会生效 —— 必须走 `setView()` 这个受支持入口。
  252. *
  253. * @returns {number|null} 实际下发的椭球高度(米);失败返回 null
  254. */
  255. export function setCameraAltitude(viewer, altitudeM) {
  256. if (!viewer || !viewer.camera || !viewer.scene || !viewer.scene.globe) return null
  257. if (!Number.isFinite(altitudeM)) return null
  258. try {
  259. const ellipsoid = viewer.scene.globe.ellipsoid
  260. const carto = ellipsoid.cartesianToCartographic(viewer.camera.positionWC)
  261. if (!carto) return null
  262. const camera = viewer.camera
  263. camera.setView({
  264. destination: Cartesian3.fromRadians(carto.longitude, carto.latitude, altitudeM),
  265. orientation: { heading: camera.heading, pitch: camera.pitch, roll: camera.roll },
  266. })
  267. return Math.round(altitudeM)
  268. } catch (error) {
  269. return null
  270. }
  271. }
  272. /** 按配置顺序叠加影像层,返回**句柄数组**(票 39 起需要驱动 alpha,故不再只返回层数)。 */
  273. export function applyOverlays(viewer, overlays) {
  274. const handles = []
  275. for (const overlay of overlays || []) {
  276. const layer = new ImageryLayer(createOverlayProvider(overlay))
  277. viewer.imageryLayers.add(layer)
  278. handles.push(createOverlayHandle(layer))
  279. }
  280. return handles
  281. }
  282. /**
  283. * 目标视野的瓦片就绪读数(**如实标注它是代理信号**)。
  284. *
  285. * `globe.tilesLoaded` = **地形与影像加载队列为空**(`Source/Scene/Globe.js:422` 的 getter),
  286. * 它**不证明**目标视野逐像素就绪 —— 决策票 08 明确警告过这一点。所以这里连同
  287. * "它是代理"这个事实一起返回,免得下游把它当充分证明。
  288. *
  289. * 瓦片失败:Cesium 在**没有任何 errorEvent 监听者**时会把错误直接抛到窗口
  290. * (`ImageryLayer.js:1708` `handleError`),所以本层挂一个监听者把失败**收进读数**,
  291. * 而不是让它变成页面级报错(走查的「无页面级报错」会因此误伤)。
  292. */
  293. export function createOverlayReadiness(handles = []) {
  294. const state = { loaded: false, failed: 0, lastError: '' }
  295. const removers = []
  296. for (const handle of handles) {
  297. const layer = handle && handle.layer
  298. if (!layer || !layer.errorEvent || typeof layer.errorEvent.addEventListener !== 'function') continue
  299. // 注意:Cesium 的 `Event.addEventListener` **返回一个"移除函数"**,不是 id
  300. // (`Source/Core/Event.js` 末尾 `return function () { event.removeEventListener(...) }`)。
  301. const remove = layer.errorEvent.addEventListener((error) => {
  302. state.failed += 1
  303. state.lastError = String((error && error.message) || error || '瓦片加载失败')
  304. })
  305. if (typeof remove === 'function') removers.push(remove)
  306. }
  307. return {
  308. state,
  309. /** 由服务层在渲染循环里刷新(`globe.tilesLoaded` 是 getter,读它不便宜,故不在这里轮询)。 */
  310. refresh(globe) {
  311. state.loaded = !!(globe && globe.tilesLoaded)
  312. return state
  313. },
  314. detach() {
  315. for (const remove of removers) {
  316. try {
  317. remove()
  318. } catch {
  319. // 场景已销毁:忽略
  320. }
  321. }
  322. removers.length = 0
  323. },
  324. }
  325. }
  326. /**
  327. * 3D Tiles 的公共诊断读数:排查「tileset.json 拉到了、内容却没请求」时,
  328. * 使用 Cesium 1.145 的公开 `tilesLoaded`、内存和 tile 事件结果;不读取内部 statistics/ready/contentState。
  329. * `visited`/`selected` 保留为 null,因为 Cesium 没有公开的稳定逐帧字段。
  330. */
  331. export function readTilesetDiagnostics(tilesets) {
  332. const list = Array.isArray(tilesets) ? tilesets : []
  333. return list.map((tileset) => {
  334. const root = tileset.root || {}
  335. return {
  336. visited: null,
  337. selected: null,
  338. contentReady: root ? Number(Boolean(root.content)) : 0,
  339. tilesLoaded: Boolean(tileset.tilesLoaded),
  340. memoryBytes: tileset.totalMemoryUsageInBytes || 0,
  341. rootHasContent: Boolean(root && root.content),
  342. rootChildren: Array.isArray(root && root.children) ? root.children.length : 0,
  343. maximumScreenSpaceError: tileset.maximumScreenSpaceError,
  344. }
  345. })
  346. }
  347. /**
  348. * 挂 3D Tiles / 倾斜摄影(票 21):每个条目一个 tileset。
  349. * 单项失败不影响其它项;返回 `{ ready, failed, instances }` 供上层如实报状态
  350. * (页面不白屏、不静默),并把实例交出去给视图条件改透明度。
  351. */
  352. function applyBuildingLighting(scene, tileset) {
  353. // Derive a stable local daylight from this dataset's geographic bounds; do not key lighting to the camera.
  354. const origin = tileset.boundingSphere?.center
  355. if (!origin) return
  356. const frame = Transforms.eastNorthUpToFixedFrame(origin)
  357. const direction = Matrix4.multiplyByPointAsVector(frame, new Cartesian3(-0.4, -0.6, -1), new Cartesian3())
  358. Cartesian3.normalize(direction, direction)
  359. scene.light = new DirectionalLight({ direction, intensity: 2.4 })
  360. tileset.imageBasedLighting = new ImageBasedLighting({
  361. imageBasedLightingFactor: new Cartesian2(1, 1),
  362. sphericalHarmonicCoefficients: [new Cartesian3(0.65, 0.65, 0.65), ...Array.from({ length: 8 }, () => new Cartesian3(0, 0, 0))],
  363. })
  364. }
  365. function boundsFromTilesetRoot(root) {
  366. const box = root && root.boundingVolume && root.boundingVolume.box
  367. if (!Array.isArray(box) || box.length < 12) return null
  368. const transform = Array.isArray(root.transform) && root.transform.length === 16
  369. ? Matrix4.fromArray(root.transform)
  370. : Matrix4.IDENTITY
  371. const center = Matrix4.multiplyByPoint(transform, new Cartesian3(box[0], box[1], box[2]), new Cartesian3())
  372. const radii = [
  373. Matrix4.multiplyByPointAsVector(transform, new Cartesian3(box[3], box[4], box[5]), new Cartesian3()),
  374. Matrix4.multiplyByPointAsVector(transform, new Cartesian3(box[6], box[7], box[8]), new Cartesian3()),
  375. Matrix4.multiplyByPointAsVector(transform, new Cartesian3(box[9], box[10], box[11]), new Cartesian3()),
  376. ]
  377. const halfAxes = Matrix3.fromArray(radii.flatMap((axis) => [axis.x, axis.y, axis.z]))
  378. return new OrientedBoundingBox(center, halfAxes)
  379. }
  380. async function readTilesetRootBounds(plan, signal) {
  381. const response = await fetch(plan.url, { cache: 'no-store', signal })
  382. if (!response.ok) throw new Error(`tileset metadata ${response.status}: ${plan.url}`)
  383. const json = await response.json()
  384. const bounds = boundsFromTilesetRoot(json.root)
  385. if (!bounds) throw new Error(`tileset root bounding box missing: ${plan.url}`)
  386. return bounds
  387. }
  388. function createTilesetCullingController(scene, plans, instances, failed, onChange) {
  389. const camera = scene && scene.camera
  390. if (!camera || !plans.length) return null
  391. const xyz = (value) => [value.x, value.y, value.z]
  392. const regionHandles = []
  393. const regionAbort = new AbortController()
  394. let regionPipeline = false
  395. async function initializeRegions(model, plan) {
  396. if (!plan.regionManifest) return
  397. const response = await fetch(plan.regionManifest, { cache: 'no-store', signal: regionAbort.signal })
  398. if (!response.ok) throw new Error('region metadata ' + response.status)
  399. const manifest = await response.json()
  400. if (model.isDestroyed() || regionAbort.signal.aborted) return
  401. const url = new URL(plan.regionManifest, document.baseURI)
  402. const transform = Matrix4.fromArray(manifest.transform)
  403. const regions = manifest.regions.map(r => {
  404. const spheres = (r.admissionBounds || [r.bounds]).map(volume => {
  405. const s = volume.sphere || [...volume.box.slice(0, 3), Math.hypot(volume.box[3], volume.box[7], volume.box[11])]
  406. return new BoundingSphere(Matrix4.multiplyByPoint(transform, new Cartesian3(...s.slice(0, 3)), new Cartesian3()), s[3])
  407. })
  408. return { ...r, url: new URL(r.url, url).href, admissionSpheres: spheres,
  409. expectedContentUrls: r.contentUrls.map(p => new URL(p, new URL(r.url, url)).href),
  410. expectedPreviewContentUrls: (r.previewContentUrls || []).map(p => new URL(p, new URL(r.url, url)).href),
  411. } })
  412. const localInstances = []
  413. let cullingVolume, coarseRendered = false
  414. const region = createRegionLoadingController({ regions,
  415. canStart: () => coarseRendered && model.tilesLoaded && model.show && !model.isDestroyed(),
  416. beforeRefresh: () => { cullingVolume = camera.frustum.computeCullingVolume(camera.positionWC, camera.directionWC, camera.upWC) },
  417. onFailure: (r, error) => { failed.push({ url: error.url || r.url, error: String(error.message || error) }); onChange?.({ ready: instances.length, instances, failed }) },
  418. distance: r => {
  419. const result = contentAdmission(r.admissionSpheres,
  420. sphere => cullingVolume.computeVisibility(sphere) !== Intersect.OUTSIDE,
  421. sphere => Cartesian3.distance(camera.positionWC, sphere.center))
  422. r.centerDistance = result.centerDistance
  423. return result.distance
  424. },
  425. load: r => Cesium3DTileset.fromUrl(r.url, { ...tilesetOptions(plan), loadSiblings: true }),
  426. add: (fine) => { scene.primitives.add(fine); localInstances.push(fine); instances.push(fine); onChange?.({ ready: instances.length, instances, failed }) },
  427. remove: fine => { const i = instances.indexOf(fine); if (i >= 0) instances.splice(i, 1); const j = localInstances.indexOf(fine); if (j >= 0) localInstances.splice(j, 1);
  428. removeSceneTileset(scene, fine) },
  429. requestRender: () => scene.requestRender(),
  430. })
  431. region.setPipeline(regionPipeline)
  432. const unmask = model.tileVisible.addEventListener(tile => {
  433. if (tile.content?.trianglesLength > 0) coarseRendered = true
  434. const c = tile.content
  435. if (c?.featuresLength === 1) { const feature = c.getFeature(0); feature.show = !region.detailReady(feature.getProperty('region')) }
  436. })
  437. const detachFrame = scene.postRender.addEventListener(region.onRender)
  438. regionControllers.set(model, region)
  439. regionHandles.push({ region, detachFrame, unmask })
  440. region.refresh()
  441. scene.requestRender()
  442. }
  443. const controller = createTilesetLoadingController({
  444. plans, instances, failed, onChange,
  445. prepareBounds: readTilesetRootBounds,
  446. readCamera: () => ({ position: xyz(camera.positionWC), direction: xyz(camera.directionWC), up: xyz(camera.upWC) }),
  447. distanceTo: (bounds) => Math.sqrt(bounds.distanceSquaredTo(camera.positionWC)),
  448. load: (plan) => Cesium3DTileset.fromUrl(plan.url, tilesetOptions(plan)),
  449. add: (instance, plan) => {
  450. if (plan.lighting === true) applyBuildingLighting(scene, instance)
  451. scene.primitives.add(instance)
  452. if (plan.regionManifest) void initializeRegions(instance, plan).catch(error => {
  453. if (regionAbort.signal.aborted) return
  454. failed.push({ url: plan.regionManifest, error: String(error.message || error) }); onChange?.({ ready: instances.length, instances, failed })
  455. })
  456. },
  457. remove: (instance) => {
  458. removeSceneTileset(scene, instance)
  459. },
  460. requestRender: () => scene.requestRender(),
  461. })
  462. const detachCamera = camera.changed.addEventListener(controller.refresh)
  463. const detachRender = scene.postRender.addEventListener(controller.onRender)
  464. let detached = false
  465. return {
  466. ...controller,
  467. setPipeline(value) { regionPipeline = !!value; for (const h of regionHandles) h.region.setPipeline(regionPipeline) },
  468. regionSnapshot() { return regionHandles.flatMap(h => h.region.snapshot()) },
  469. detach() {
  470. if (detached) return
  471. detached = true
  472. regionAbort.abort()
  473. for (const h of regionHandles) { h.detachFrame(); h.unmask(); h.region.detach() }
  474. detachCamera(); detachRender(); controller.detach()
  475. },
  476. }
  477. }
  478. /**
  479. * 3D Tiles 懒加载:启动只读根 tileset.json 的包围盒,eager 批次立即加载,
  480. * 其余批次只有相机进入 loadDistance 才初始化;离开较远区域后销毁非 eager 批次。
  481. * 已加载 tileset 仍由 Cesium 做 tile 级视锥剔除和内部 LOD。
  482. */
  483. export async function applyTilesets(scene, tilesets, onChange, onController) {
  484. // 网络节流只限制请求,不能代替 GLB 解码/上传完成的批次闸门。
  485. RequestScheduler.maximumRequests = 10
  486. RequestScheduler.maximumRequestsPerServer = 5
  487. const instances = []
  488. const failed = []
  489. const culling = createTilesetCullingController(scene, tilesets || [], instances, failed, onChange)
  490. if (onController) onController(culling)
  491. if (culling) await culling.prepare()
  492. return { ready: instances.length, failed, instances, culling }
  493. }
  494. /**
  495. * 视图条件对图层的影响(票 21):管线视图把地形与 3D Tiles 压成半透明(保留空间参照,
  496. * 不完全隐藏),地面视图还原为完全不透明。
  497. *
  498. * 地形用 Cesium 的 `globe.translucency`(前台/背面 alpha 一起设,避免只透一半);
  499. * 3D Tiles 用 `Cesium3DTileStyle` 的 color alpha。两者都只改显示,不动数据,也不碰相机。
  500. * @param {object} viewer
  501. * @param {Array} tilesets 已挂上的 tileset 实例(applyTilesets 的 instances)
  502. * @param {object} plan `domain/viewModes.js` 的 planViewMode()
  503. */
  504. export function applyViewModeLayers(viewer, tilesets, plan) {
  505. if (!viewer || !plan) return
  506. const globe = viewer.scene && viewer.scene.globe
  507. if (globe) {
  508. // 始终参与真实地形深度;管线透视只由显式 translucency 开关控制。
  509. globe.depthTestAgainstTerrain = true
  510. const alpha = Number.isFinite(plan.terrainAlpha) ? plan.terrainAlpha : 1
  511. globe.translucency.enabled = alpha < 1
  512. globe.translucency.frontFaceAlpha = alpha
  513. globe.translucency.backFaceAlpha = alpha
  514. }
  515. const alpha = Number.isFinite(plan.tilesetAlpha) ? plan.tilesetAlpha : 1
  516. for (const tileset of tilesets || []) {
  517. regionControllers.get(tileset)?.setPipeline(plan.mode === 'pipeline')
  518. try {
  519. // 地面视图还原 = 清掉样式(而不是设成 white/1,省得留一份无意义样式)。
  520. tileset.style = alpha < 1 ? new Cesium3DTileStyle({ color: `color('white', ${alpha})` }) : undefined
  521. } catch {
  522. // 单个 tileset 不支持 style 时忽略,不影响其它图层。
  523. }
  524. }
  525. }
  526. export function destroyViewer(viewer) {
  527. if (!viewer.isDestroyed()) viewer.destroy()
  528. }
  529. // ================================================================ 自建视距雾(票 38)
  530. /** 自建雾处理级的名字(唯一一个)。 */
  531. export const FOG_STAGE_NAME = 'cockpit-area-fog'
  532. /**
  533. * 世界射线距离雾的片元着色器(**B 方案:只处理有有效几何深度的地物**)。
  534. *
  535. * 契约来源 `cockpit-primary-area-guardrails/spec-fog.md`(决策票 07 固化 B 方案)。三条硬约束:
  536. *
  537. * 1. **无有效深度 ≠ 天空**,但本级**不重绘它**:`rawDepth >= 1.0` 时整段返回原色
  538. * (不套雾色、不做地平线渐变)。依据决策票 07「采用 B:仅地物距离雾……无有效几何深度背景保持原颜色」,
  539. * 以及其只读复核「深度清为 1.0,天空盒与大气不写深度」「无深度值也可能对应没写自身深度的透明内容」。
  540. * 2. **只反解一次对数深度**:`czm_windowToEyeCoordinates(gl_FragCoord.xy, rawDepth)` 这个重载
  541. * **内部自己解 log**(`windowToEyeCoordinates.glsl`),喂给它的是**原始** `texture(depthTexture).r`。
  542. * 先 `czm_reverseLogDepth` 再传进去会解两次 —— 复核里点名的坑。
  543. * 沿视线的距离取重建位置的长度,不取 `-z`;并按 `gl_FragCoord.w` 做齐次除法。
  544. * 3. **两端语义精确**:0 处全清、`endDistance` 处全遮蔽。曲线形状(smoothstep + 保端点光学厚度)
  545. * 在 `domain/fog.js` 的 `fogAttenuation` 里算好,这里只做 `mix`,**GLSL 不重写一份数学**
  546. * (两处各写一份必然漂移,而端点语义是本票的验收点)。
  547. *
  548. * 与原型 `prototypes/fog-prototype-stage.js`(**已归档到 `throwaway/cockpit-prototypes` 分支**,
  549. * 票 40 收口)的区别:去掉只有决策票 07 才有的 C 方案分支
  550. * (地平线染色与太阳暖色,已裁定不采用),并改用 CPU 侧算好的强度 uniform。
  551. */
  552. const FOG_FRAGMENT_SHADER = `
  553. uniform sampler2D colorTexture;
  554. uniform sampler2D depthTexture;
  555. uniform vec3 fogColor;
  556. uniform float effectStrength;
  557. uniform float startDistance;
  558. uniform float endDistance;
  559. in vec2 v_textureCoordinates;
  560. // 世界射线距离 → 不透明度。与 domain/fog.js 的 fogAttenuation 同一形状,
  561. // 但它必须在 GPU 上逐片元算,所以这里保留一份等价实现;**端点断言在 CPU 侧**。
  562. float fogAlpha(float distanceM) {
  563. if (distanceM <= startDistance) return 0.0;
  564. if (distanceM >= endDistance) return 1.0;
  565. float t = (distanceM - startDistance) / (endDistance - startDistance);
  566. float smoothT = t * t * (3.0 - 2.0 * t);
  567. return (1.0 - exp(-3.0 * smoothT)) / (1.0 - exp(-3.0));
  568. }
  569. void main() {
  570. vec4 base = texture(colorTexture, v_textureCoordinates);
  571. float rawDepth = texture(depthTexture, v_textureCoordinates).r;
  572. // 无有效几何深度:**保留原色**(B 方案)。这一支也不做"天空分类"的臆断。
  573. if (rawDepth >= 1.0) {
  574. out_FragColor = base;
  575. return;
  576. }
  577. // 原始深度交给这个重载,它内部自己解 log;再补一次齐次除法。
  578. vec4 eye = czm_windowToEyeCoordinates(gl_FragCoord.xy, rawDepth);
  579. vec3 eyePos = eye.xyz / eye.w;
  580. float distanceM = length(eyePos);
  581. float alpha = clamp(fogAlpha(distanceM) * effectStrength, 0.0, 1.0);
  582. out_FragColor = vec4(mix(base.rgb, fogColor, alpha), base.a);
  583. }`
  584. /**
  585. * 在场景上建**唯一**一个自建雾处理级(票 38)。幂等:同一 viewer 只会有一个。
  586. *
  587. * 三条实现要点,每条都对着决策票 07 的只读复核:
  588. * · **能力探测交给引擎**:stage 声明了 `depthTexture` 而设备不支持时,
  589. * `PostProcessStage` 自己会整级禁用(`_isSupported`);我们只如实读回 `stage.ready`
  590. * 与 `scene.context.depthTexture`,不自己造探测逻辑。
  591. * · **复用对象**:进出与调强度都只改 uniform,不 remove/add(`PostProcessStageCollection.remove`
  592. * 会 destroy,对象不可复用)。只有离开地图才 `remove` + 释放。
  593. * · **禁用不等于零开销**:规格明确「禁用后引擎仍可能准备/清理后处理 framebuffer」,
  594. * 所以对外措辞只说"禁用",不承诺"完全没有 GPU 开销"。
  595. *
  596. * @param {Viewer} viewer
  597. * @param {object} params `domain/fog.js` 的 `planFog()` 结果
  598. * @returns {{stage: PostProcessStage, uniforms: object, supported: boolean, destroy: () => void}}
  599. */
  600. export function createFogStage(viewer, params) {
  601. const scene = viewer.scene
  602. /**
  603. * 雾的动态 uniform **必须用函数传**(不能用常量)。
  604. *
  605. * 依据(Cesium 1.145 `PostProcessStage` 源码,本票实测确认):
  606. * · 构造时 `_actualUniforms[name] = uniforms[name]` 会**快照一份**常量值;
  607. * · 之后每帧喂给 shader 的是 `_actualUniforms`(`getUniformMapFunction` 读它),
  608. * 而 `stage._uniforms` 在构造末尾被**换成另一个对象**。
  609. * 所以「传常量 + 事后改那个常量对象」**永远不会生效** —— 改了原始对象,
  610. * 快照仍是初值,shader 拿到的始终是初值。症状极具迷惑性:
  611. * 读数、dataset、`stage.enabled` 全都说"已启用、强度 1",而**画面上什么都没有**。
  612. *
  613. * 这也正是可丢弃原型 `prototypes/fog-prototype-stage.js`(**已归档到
  614. * `throwaway/cockpit-prototypes` 分支**,票 40 收口)用 `() => params.amount`
  615. * 这种写法能跑通的原因 —— 函数形式的 uniform 每帧现取,天然支持动态改值。
  616. */
  617. const state = {
  618. fogColor: new Cartesian3(1, 1, 1),
  619. effectStrength: 0,
  620. startDistance: params.startDistance,
  621. endDistance: params.endDistance,
  622. }
  623. const stage = scene.postProcessStages.add(
  624. new PostProcessStage({
  625. name: FOG_STAGE_NAME,
  626. fragmentShader: FOG_FRAGMENT_SHADER,
  627. uniforms: {
  628. fogColor: () => state.fogColor,
  629. effectStrength: () => state.effectStrength,
  630. startDistance: () => state.startDistance,
  631. endDistance: () => state.endDistance,
  632. },
  633. }),
  634. )
  635. // 先禁用:强度为 0 时不该有任何一次全屏 pass(首次启用由宿主显式打开)。
  636. stage.enabled = false
  637. return {
  638. stage,
  639. state,
  640. /** 深度能力:`Context.depthTexture` 是**布尔能力标志**(不是纹理对象,且不在 d.ts 里)。 */
  641. supported: scene.context ? scene.context.depthTexture === true : false,
  642. /**
  643. * DEV 诊断:对比"我们以为写进去的强度"与"Cesium 真正喂给 shader 的强度"。
  644. *
  645. * 读的是 `stage._uniformMap[name]()` —— 那是每帧实际取值的那条路径,
  646. * 也是唯一能证伪"值到底有没有送进 shader"的地方。两者不一致就说明 uniform 通道没接对
  647. * (常量快照坑:直接写原始对象绕过了 setter,`_actualUniforms` 永远是初值)。
  648. */
  649. readUniformDiagnostics() {
  650. let delivered = null
  651. let storedKind = null
  652. let storedValue = null
  653. try {
  654. const uniformMap = stage._uniformMap
  655. delivered = uniformMap && typeof uniformMap.effectStrength === 'function' ? uniformMap.effectStrength() : null
  656. } catch (error) {
  657. delivered = null
  658. }
  659. try {
  660. // `_actualUniforms` 在**构造时**就写好了(不等渲染),所以这一项在隐藏标签页里也可读。
  661. // 它是判定"动态 uniform 有没有接对"的决定性证据:
  662. // · 存的是 `function` → 每帧现取,改 state 立刻生效(正确形态);
  663. // · 存的是 `number` → 构造时快照,事后改原始对象**永远不生效**(本票的坑)。
  664. const stored = stage._actualUniforms ? stage._actualUniforms.effectStrength : undefined
  665. storedKind = typeof stored
  666. storedValue = typeof stored === 'number' ? stored : null
  667. } catch (error) {
  668. storedKind = null
  669. }
  670. return {
  671. intended: state.effectStrength,
  672. deliveredToShader: delivered,
  673. matches: delivered === state.effectStrength,
  674. /** `'function'` = 动态(对);`'number'` = 常量快照(错,改了不生效)。 */
  675. storedKind,
  676. storedValue,
  677. enabled: stage.enabled,
  678. ready: stage.ready === true,
  679. }
  680. },
  681. destroy() {
  682. if (scene.isDestroyed && scene.isDestroyed()) return
  683. if (stage.isDestroyed && stage.isDestroyed()) return
  684. scene.postProcessStages.remove(stage)
  685. },
  686. }
  687. }
  688. /** 把强度与颜色写进 uniform(**只改 dynamic state,不重建 stage**)。 */
  689. export function applyFogUniforms(handle, { strength, colorRgb, startDistance, endDistance }) {
  690. if (!handle || !handle.state) return
  691. const state = handle.state
  692. if (Number.isFinite(strength)) state.effectStrength = Math.min(1, Math.max(0, strength))
  693. if (colorRgb) state.fogColor = new Cartesian3(colorRgb.r, colorRgb.g, colorRgb.b)
  694. if (Number.isFinite(startDistance)) state.startDistance = startDistance
  695. if (Number.isFinite(endDistance)) state.endDistance = endDistance
  696. // 开关与强度同源:强度为 0 就没有必要跑这一遍全屏 pass。
  697. if (handle.stage) handle.stage.enabled = state.effectStrength > 0
  698. }
  699. /** 该处理级是否已可执行(**不等于**画面已被遮住 —— 规格明确区分这两件事)。 */
  700. export function fogStageExecutable(handle) {
  701. if (!handle || !handle.stage) return false
  702. if (handle.stage.isDestroyed && handle.stage.isDestroyed()) return false
  703. return handle.stage.ready === true
  704. }
  705. /** 当前是否有自建雾处理级挂在场景上(排查与走查读数)。 */
  706. export function hasFogStage(viewer) {
  707. if (!viewer || !viewer.scene || !viewer.scene.postProcessStages) return false
  708. return viewer.scene.postProcessStages.length > 0
  709. }
  710. // ================================================================ 场景调色(科技蓝滤镜)
  711. const GRADE_STAGE_NAME = 'cockpit-scene-grade'
  712. /**
  713. * 全屏调色 fragment shader:降饱和 → 对比度 → 亮度 → 阴影偏蓝。
  714. * 数学与 `domain/sceneGrade.js` 的 `gradeColor()` **同序同式**(那边是本式的逐位镜像,
  715. * 逻辑闸门在 domain 上断言的结论才对引擎侧成立)。不做 hue-rotate 双色 ——
  716. * 状态点的红/绿是第一线索(票 14),色相顺序不能动。
  717. */
  718. const GRADE_FRAGMENT_SHADER = `
  719. uniform sampler2D colorTexture;
  720. uniform float gradeSaturation;
  721. uniform float gradeBrightness;
  722. uniform float gradeContrast;
  723. uniform vec3 gradeTint;
  724. uniform float gradeTintStrength;
  725. in vec2 v_textureCoordinates;
  726. void main(void)
  727. {
  728. vec4 base = texture(colorTexture, v_textureCoordinates);
  729. float luma = dot(base.rgb, vec3(0.2126, 0.7152, 0.0722));
  730. vec3 color = mix(vec3(luma), base.rgb, gradeSaturation);
  731. color = clamp((color - 0.5) * gradeContrast + 0.5, 0.0, 1.0);
  732. color = clamp(color * gradeBrightness, 0.0, 1.0);
  733. float tintWeight = clamp(gradeTintStrength * (1.0 - luma), 0.0, 1.0);
  734. color = mix(color, gradeTint, tintWeight);
  735. out_FragColor = vec4(color, base.a);
  736. }`
  737. /**
  738. * 在场景上建**唯一**一个调色处理级。常驻(不做进出生命周期):enabled 随配置。
  739. *
  740. * uniform 一律**函数形态**(票 38 实测的坑:常量会被 PostProcessStage 在构造时快照,
  741. * 事后改原始对象永远不生效;函数每帧现取,改 state 立即生效)。
  742. * 不需要深度纹理能力(雾才要),所以没有 support 探测 —— 建失败走 try/catch 如实上抛。
  743. *
  744. * @param {Viewer} viewer
  745. * @param {object} params `domain/sceneGrade.js` 的 `planSceneGrade()` 结果
  746. */
  747. export function createSceneGradeStage(viewer, params) {
  748. const scene = viewer.scene
  749. const tint = params.tintRgb || parseHexColor(GRADE_DEFAULTS.tint)
  750. const state = {
  751. saturation: params.saturation,
  752. brightness: params.brightness,
  753. contrast: params.contrast,
  754. tint: new Cartesian3(tint.r, tint.g, tint.b),
  755. tintStrength: params.tintStrength,
  756. }
  757. const stage = scene.postProcessStages.add(
  758. new PostProcessStage({
  759. name: GRADE_STAGE_NAME,
  760. fragmentShader: GRADE_FRAGMENT_SHADER,
  761. uniforms: {
  762. gradeSaturation: () => state.saturation,
  763. gradeBrightness: () => state.brightness,
  764. gradeContrast: () => state.contrast,
  765. gradeTint: () => state.tint,
  766. gradeTintStrength: () => state.tintStrength,
  767. },
  768. }),
  769. )
  770. stage.enabled = params.enabled !== false
  771. return {
  772. stage,
  773. state,
  774. destroy() {
  775. if (scene.isDestroyed && scene.isDestroyed()) return
  776. if (stage.isDestroyed && stage.isDestroyed()) return
  777. scene.postProcessStages.remove(stage)
  778. },
  779. }
  780. }
  781. /** 把调色参数写进 uniform(**只改 dynamic state,不重建 stage**;与 applyFogUniforms 同纪律)。 */
  782. export function applySceneGradeUniforms(handle, { saturation, brightness, contrast, tintRgb, tintStrength, enabled }) {
  783. if (!handle || !handle.state) return
  784. const state = handle.state
  785. if (Number.isFinite(saturation)) state.saturation = saturation
  786. if (Number.isFinite(brightness)) state.brightness = brightness
  787. if (Number.isFinite(contrast)) state.contrast = contrast
  788. if (tintRgb) state.tint = new Cartesian3(tintRgb.r, tintRgb.g, tintRgb.b)
  789. if (Number.isFinite(tintStrength)) state.tintStrength = tintStrength
  790. if (handle.stage) handle.stage.enabled = enabled !== false
  791. }
  792. // ================================================================ 点位标记(票 09)
  793. /**
  794. * 点位标记的数据源名:**唯一一个**,重新同步时整份替换而不是叠加
  795. * (否则翻一页就多画一批,地图上会出现已经不存在于当前快照的点)。
  796. */
  797. export const POINT_MARKER_SOURCE = 'cockpit-point-markers'
  798. /** 管线数据源名(票 22 交付;票 14 的高亮已经按它扫,落地当天即生效,不需要再改高亮)。 */
  799. export const PIPELINE_SOURCE = 'cockpit-pipelines'
  800. /**
  801. * 关联监测设备图层的**独立**数据源名(票 42)。
  802. *
  803. * 与 `POINT_MARKER_SOURCE` 分开是**结构性**的:关联设备只进这个临时图层,
  804. * 永远不写回摘要标记计划(规格:摘要分页、摘要图例、`data-map-markers*` 都不含它)。
  805. */
  806. export const DEVICE_MARKER_SOURCE = 'cockpit-associated-device-markers'
  807. /**
  808. * 建/取点位标记的数据源(幂等)。
  809. *
  810. * **必须自己缓存实例,不能只靠 `dataSources.getByName()`**:
  811. * Cesium 的 `DataSourceCollection.add()` 是**异步**的(`Promise.resolve(ds).then(...)` 里才 push
  812. * 进 `_dataSources`)。同一个 tick 里「先写后读」会走成这样:
  813. * 1. `syncPointMarkers()` 建了数据源 A(还没注册进集合)并把 8 个标记加进 A;
  814. * 2. 紧接着 `readPointMarkers()` 调 `getByName()` —— 集合里还没有 A,于是**又建了一个空数据源 B**,
  815. * 读回来 0 个标记。
  816. * 症状极具迷惑性:模块计数与 dataset 都说 8,组件渲染出来的却是「点位标记 0」,
  817. * 而晚一点的 DEV 把手调用(那时 A 已注册)又读回 8 —— 同一个页面两套答案。
  818. * 这是视觉探针截图里「图例有计数、标记写 0」的真正原因(走查没抓到,因为它读的是 dataset 与 DEV 把手)。
  819. *
  820. * 用 WeakMap 按 viewer 缓存:viewer 销毁后条目自然消失,不会漏实例,也不会在集合里留空数据源。
  821. */
  822. const markerSources = new WeakMap()
  823. function pointMarkerSource(viewer) {
  824. const cached = markerSources.get(viewer)
  825. if (cached) return cached
  826. // 兜一道:热重载等情况下集合里可能已经有同名数据源,优先复用它。
  827. const existing = viewer.dataSources.getByName(POINT_MARKER_SOURCE)[0]
  828. if (existing) {
  829. markerSources.set(viewer, existing)
  830. return existing
  831. }
  832. const source = new CustomDataSource(POINT_MARKER_SOURCE)
  833. viewer.dataSources.add(source)
  834. markerSources.set(viewer, source)
  835. return source
  836. }
  837. /** 地图文字:浅亮字面、状态色细描边,名称与聚合计数共用。
  838. * 批次 3 走查修订:位置符号标签加**深色底板**(配色对齐 UI 面板深蓝系)提升可读;
  839. * `pixelOffset` 可覆盖默认偏移(BOTTOM 锚定的标记需要把标签顶到图标上方)。
  840. * `maxWidthPx` 限宽(文字可明显超出图标),超宽截断走 **domain/ellipsis.js 的共用契约**
  841. * (`ellipsisForMapLabel`——与 DOM 侧 EllipsisText 同一份省略语义,媒介不同实现各异)。
  842. * 完整名称走通用 hover:截断的 label 只管「认得出」,全名由 hover tooltip(DOM 层)
  843. * 与实体 description 承载,不塞进标签文本。 */
  844. function mapTextLabel(text, color, kind = 'name', show = true, pixelOffset = null, maxWidthPx = 0) {
  845. const style = MAP_LABEL_STYLE[kind]
  846. const tone = Color.fromCssColorString(color) || Color.WHITE
  847. // 限宽截断走共用契约(字宽估算的字号从本标签的 font 里取)。
  848. const fontMatch = /(\d+(?:\.\d+)?)px/.exec(style.font)
  849. const fontSize = fontMatch ? Number(fontMatch[1]) : 12
  850. const { shown } = ellipsisForMapLabel(text, maxWidthPx, fontSize)
  851. return {
  852. text: mapLabelText(shown, kind),
  853. font: style.font,
  854. fillColor: Color.WHITE,
  855. outlineColor: tone.withAlpha(0.65),
  856. outlineWidth: style.outlineWidth,
  857. style: LabelStyle.FILL_AND_OUTLINE,
  858. pixelOffset: pixelOffset || new Cartesian2(0, style.offsetY),
  859. heightReference: HeightReference.CLAMP_TO_GROUND,
  860. verticalOrigin: VerticalOrigin.BOTTOM,
  861. // 深色底板:label 与地图影像同色系时不再糊在一起(走查反馈)。
  862. // 底板配色对齐 UI 面板风格(tokens 的面板深蓝 + 青色线):Cesium 底板不支持渐变/圆角,
  863. // 用「深蓝实底 + 面板线色」近似 cockpit 面板观感;文字描边已弱化(底板承担对比度)。
  864. showBackground: true,
  865. backgroundColor: Color.fromCssColorString('#03203c').withAlpha(0.78),
  866. // 走查第五轮:文字到框边的距离收窄(4→3),底板更贴字。
  867. backgroundPadding: new Cartesian2(3, 3),
  868. show,
  869. }
  870. }
  871. /**
  872. * 点位标记的**差量同步**(票 33)。
  873. *
  874. * 以前这里是 `removeAll()` 整层重建(票 26 诊断:与管线层全量重赋值同批发生,
  875. * 刷新期标记同步闪烁)。现在按 `point|${key}` 索引 + 内容指纹做增删改:
  876. * · 指纹未变 → 一个属性都不写(实体原样留在图上,不闪);
  877. * · 指纹变了 / 坐标失效 / 从计划里消失 → 只动那一个实体;
  878. * · 重新上图仍用**同一个实体 id**,拾取与读数路径(`readPointMarkers` 等)不变。
  879. * 指纹取自标记计划输入(非 Cesium 对象),除 key 外一切影响渲染的字段都进来。
  880. *
  881. * @param {Viewer} viewer
  882. * @param {Array} markers `domain/pointStatus.js` 的 `planPointMarkers().markers`
  883. * @param {(marker: object) => string} colorOf 取该标记的 CSS 颜色(服务层从 token 读,引擎不写色值)
  884. * @param {(shape: string, color: string) => string} imageOf 形状+颜色 → 图片 URL(domain/markerSvg)
  885. * @returns {{rendered: number, skipped: number, unchanged: number}}
  886. */
  887. const markerEntityIndex = new WeakMap() // viewer → Map(`point|${key}` → { entity, fp })
  888. function markerFingerprint(marker) {
  889. return JSON.stringify([
  890. marker.key, marker.longitude, marker.latitude, marker.status,
  891. marker.tone, marker.shape, marker.visualKey, marker.code,
  892. marker.name || '', marker.deviceCodes || [],
  893. ])
  894. }
  895. export function syncPointMarkers(viewer, markers, colorOf, imageOf, planOf, tintOf) {
  896. const source = pointMarkerSource(viewer)
  897. let index = markerEntityIndex.get(viewer)
  898. if (!index) {
  899. index = new Map()
  900. markerEntityIndex.set(viewer, index)
  901. }
  902. const wanted = new Set()
  903. let rendered = 0
  904. let skipped = 0
  905. let unchanged = 0
  906. for (const marker of markers || []) {
  907. const id = `point|${marker.key}`
  908. // 经纬度缺失或非有限值不上图:计划层已按 `renderable` 过滤,
  909. // 这里再挡一道,免得脏数据把整批 entity 建崩(一条坏数据不该让其余点位消失)。
  910. if (!Number.isFinite(marker.longitude) || !Number.isFinite(marker.latitude)) {
  911. skipped += 1
  912. // 票 33:曾经上过图、现在坐标失效的,按差量撤下——不留在图上冒充仍可渲染。
  913. const stale = index.get(id)
  914. if (stale) {
  915. source.entities.remove(stale.entity)
  916. index.delete(id)
  917. }
  918. continue
  919. }
  920. wanted.add(id)
  921. const fp = markerFingerprint(marker)
  922. const record = index.get(id)
  923. if (record && record.fp === fp) {
  924. unchanged += 1
  925. continue
  926. }
  927. const color = colorOf(marker)
  928. if (record) source.entities.remove(record.entity)
  929. // 批次 3:合成计划(若有)决定母版/内层/锚点/降饱和与最终尺寸;尖端锚定。
  930. // 未传 planOf(单测/旧调用方)时保持中心锚定的旧 billboard 行为。
  931. // 第三轮:billboard 绘制尺寸用 `plan.draw`(含描边余量),本体锚点比例按 pad 重算——
  932. // 尖端在含 pad 画布里的相对位置 = (pad + 本体高 × 1) / 绘制高(BOTTOM 对齐的是画布底)。
  933. const plan = planOf ? planOf(marker) : null
  934. const size = plan ? plan.draw.width : 18
  935. const height = plan ? plan.draw.height : 18
  936. const entity = source.entities.add({
  937. id,
  938. position: Cartesian3.fromDegrees(marker.longitude, marker.latitude),
  939. billboard: {
  940. // 首帧用同步兜底(纯色水滴形),异步合成完成后替换为双层合成位图。
  941. image: plan ? imageOf(marker.shape, color) : imageOf(marker.shape, color),
  942. // 像素尺寸:宽度基准(点 18 / 设备 14),高按母版比例。
  943. width: size,
  944. height,
  945. scale: 1,
  946. // 贴地:有地形时点位落在地表而不是椭球面。
  947. heightReference: HeightReference.CLAMP_TO_GROUND,
  948. // 批次 3:尖端锚定——含 pad 画布的底边比尖端低 pad 屏显值,pixelOffset 下移补偿,
  949. // 使水滴尖(而非描边外沿)落在真实坐标。
  950. verticalOrigin: plan ? VerticalOrigin.BOTTOM : VerticalOrigin.CENTER,
  951. pixelOffset: plan ? new Cartesian2(0, plan.draw.pad) : new Cartesian2(0, 0),
  952. disableDepthTestDistance: Number.POSITIVE_INFINITY,
  953. },
  954. label: marker.name ? {
  955. // 批次 3:BOTTOM 锚定下标签顶到图标上方(计划层统一产出偏移),修「标签与图标重叠」。
  956. // 首帧用原生 label(截断文本),异步**底板位图**完成后整体替换——
  957. // 圆角 + 青色装饰边的底板 Cesium 原生画不出(走查反馈「硬得不行」)。
  958. ...mapTextLabel(marker.name, color, 'name', true, plan ? new Cartesian2(0, plan.labelOffsetY) : null, plan ? plan.labelMaxWidth : 0),
  959. disableDepthTestDistance: Number.POSITIVE_INFINITY,
  960. } : undefined,
  961. // 完整名称给通用 hover/拾取链路(走查反馈「hover 时显示详情」)。
  962. description: marker.name ? `<table class="cesium-infoBox-defaultTable"><tr><td>名称</td><td>${marker.name}</td></tr><tr><td>编码</td><td>${marker.code}</td></tr><tr><td>状态</td><td>${marker.label || marker.status}</td></tr></table>` : undefined,
  963. // 供走查/人工排查读状态:不依赖中文字符串,也不依赖读图。
  964. properties: {
  965. pointCode: marker.code,
  966. // 批次 3:hover tooltip 用完整名称(label 只放截断文本)。
  967. pointName: marker.name || '',
  968. deviceCodes: marker.deviceCodes || [],
  969. pointKind: marker.kind,
  970. pointStatus: marker.status,
  971. visualKey: marker.visualKey,
  972. shape: marker.shape,
  973. tone: marker.tone,
  974. // 批次 3:合成计划读数(规格 10 宿主观测接缝)。
  975. ...(plan ? planToDataset(plan) : {}),
  976. },
  977. })
  978. index.set(id, { entity, fp })
  979. rendered += 1
  980. // 异步合成:完成后替换 billboard 图片(指纹含计划读数,避免重复合成)。
  981. if (plan && planOf) {
  982. renderMarker(plan, { tintHex: tintOf ? tintOf(marker) : color })
  983. .then((dataUrl) => {
  984. // 实体可能已被差量撤下:只在仍在图上时替换。
  985. if (index.get(id) && entity.billboard) {
  986. entity.billboard.image = dataUrl
  987. }
  988. })
  989. .catch(() => {
  990. // 合成失败保留同步兜底图(降级可见),不阻塞其余标记。
  991. })
  992. // 批次 3 第五轮曾试「底板烘位图替换 label」——维护者复核**文字消失**(Cesium label
  993. // 的 image/text 互斥渲染 + text=' ' 导致零宽),已**回退为原生 label 底板**:
  994. // 文字可见性优先于底板美化;圆角/装饰边等 Cesium 引擎限制如实登记(走查遗留)。
  995. }
  996. }
  997. // 本轮不该出现的(被计划裁掉的):逐个撤下,不再整层 removeAll。
  998. for (const [id, record] of [...index.entries()]) {
  999. if (wanted.has(id)) continue
  1000. source.entities.remove(record.entity)
  1001. index.delete(id)
  1002. }
  1003. return { rendered, skipped, unchanged }
  1004. }
  1005. /**
  1006. * 只读回点位标记的**真实图层内容**(走查据此断言「标记真的画在地图上」,
  1007. * 而不是只信计划算出来的数)。
  1008. *
  1009. * 除了计数与状态分布,还回每个视觉键对应的 billboard 图片 URL:走查用它证明
  1010. * 「每个状态的颜色确实不同、形状确实是形状」——这两件事只有从引擎实体上读才算证据。
  1011. */
  1012. export function readPointMarkers(viewer) {
  1013. const source = pointMarkerSource(viewer)
  1014. const byStatus = {}
  1015. const shapes = new Set()
  1016. const images = {}
  1017. for (const entity of source.entities.values) {
  1018. const properties = entity.properties || {}
  1019. const get = (key) => (properties[key] ? properties[key].getValue() : '')
  1020. const visualKey = get('visualKey') || 'UNKNOWN'
  1021. const shape = get('shape') || ''
  1022. byStatus[visualKey] = (byStatus[visualKey] || 0) + 1
  1023. if (shape) shapes.add(shape)
  1024. // 同一视觉键只留一份图片,够判「不同状态图片不同」。
  1025. if (!images[visualKey]) {
  1026. const billboard = entity.billboard
  1027. images[visualKey] = {
  1028. shape,
  1029. tone: get('tone'),
  1030. image: billboard ? billboard.image.getValue() : '',
  1031. }
  1032. }
  1033. }
  1034. return { count: source.entities.values.length, byStatus, shapes: [...shapes].filter(Boolean).sort(), images }
  1035. }
  1036. /**
  1037. * 只读回点位标记的**窗口坐标**(票 45 走查用:真实鼠标要点的位置)。
  1038. *
  1039. * 与 `readOverlapTargets` 同一条纪律:只回投影后的屏幕坐标与身份,不出 Cesium 实体。
  1040. * 用**逻辑可见性** `entity.show`(`isShowing` 是渲染态,headless 下会差一帧)。
  1041. */
  1042. export function readPointMarkerTargets(viewer) {
  1043. const source = pointMarkerSource(viewer)
  1044. const rect = viewer && viewer.canvas ? viewer.canvas.getBoundingClientRect() : null
  1045. if (!rect || !viewer.scene) return []
  1046. const ellipsoid = viewer.scene.globe && viewer.scene.globe.ellipsoid
  1047. return source.entities.values
  1048. .map((entity) => {
  1049. const properties = entity.properties || {}
  1050. const get = (key) => (properties[key] && properties[key].getValue ? properties[key].getValue() : '')
  1051. const position = entity.position && entity.position.getValue ? entity.position.getValue(viewer.clock.currentTime) : null
  1052. if (!position) return null
  1053. // 点位标记是 `HeightReference.CLAMP_TO_GROUND` **贴地形**渲染的;
  1054. // 直接投影 `entity.position`(椭球零高度)会与画面上看到的标记差出一段地形高度,
  1055. // 真实鼠标按那个坐标点会**打不中**(票 45 真实路径走查抓到)。
  1056. // 这里与渲染同口径:先取地形高度,再投影。
  1057. let world = position
  1058. if (ellipsoid) {
  1059. const carto = ellipsoid.cartesianToCartographic(position)
  1060. if (carto) {
  1061. let height = 0
  1062. const sampled = viewer.scene.globe.getHeight(carto)
  1063. if (Number.isFinite(sampled)) height = sampled
  1064. world = Cartesian3.fromRadians(carto.longitude, carto.latitude, height)
  1065. }
  1066. }
  1067. const projected = SceneTransforms.worldToWindowCoordinates(viewer.scene, world)
  1068. if (!projected || !Number.isFinite(projected.x) || !Number.isFinite(projected.y)) return null
  1069. return {
  1070. x: projected.x + rect.left,
  1071. y: projected.y + rect.top,
  1072. code: get('pointCode'),
  1073. status: get('pointStatus'),
  1074. visible: entity.show !== false,
  1075. }
  1076. })
  1077. .filter(Boolean)
  1078. }
  1079. /** 清空点位标记(快照失效/刷新前用:旧快照的点不得留在图上)。 */
  1080. export function clearPointMarkers(viewer) {
  1081. pointMarkerSource(viewer).entities.removeAll()
  1082. // 票 33:差量索引一起作废——实体已整层清掉,留指纹会让下次同步误判「未变」。
  1083. markerEntityIndex.delete(viewer)
  1084. }
  1085. // ================================================================ 关联监测设备图层(票 42)
  1086. const deviceSources = new WeakMap()
  1087. /** 建/取关联设备图层的数据源(幂等,与点位标记同款:必须自己缓存,理由见上方注释)。 */
  1088. function deviceMarkerSource(viewer) {
  1089. const cached = deviceSources.get(viewer)
  1090. if (cached) return cached
  1091. const existing = viewer.dataSources.getByName(DEVICE_MARKER_SOURCE)[0]
  1092. if (existing) {
  1093. deviceSources.set(viewer, existing)
  1094. return existing
  1095. }
  1096. const source = new CustomDataSource(DEVICE_MARKER_SOURCE)
  1097. viewer.dataSources.add(source)
  1098. deviceSources.set(viewer, source)
  1099. return source
  1100. }
  1101. /**
  1102. * 把**当前选中点位的关联设备**同步到独立临时图层(票 42)。
  1103. *
  1104. * 三件事是规格硬要求,都体现在签名与实体属性上:
  1105. * 1. **整份替换**(`removeAll` 后重建):关闭/换点/刷新时旧图层立即撤下,不会与新的混在一起;
  1106. * 2. **独立身份**:实体 id 与属性都带 `deviceCode` **和** `monitoringPointCode`,
  1107. * 绝不写 `pointCode` —— 设备不冒充监测点(票 05 的结构性保证);
  1108. * 3. **尺寸小于监测点**(14 vs 18):层级靠尺寸区分,具体形状/资产接缝归票 44。
  1109. *
  1110. * 调用方只传**已判定可上图**的行(`domain/deviceLayer.js` 的 `placement === 'drawn'`);
  1111. * 这里再挡一道经纬度,避免一条脏数据把整批 entity 建崩。
  1112. *
  1113. * @param {Viewer} viewer
  1114. * @param {Array} devices 领域行(含 `code`/`pointCode`/`longitude`/`latitude`/`status`)
  1115. * @param {(device: object) => string} colorOf 取该设备的 CSS 颜色(色值仍由服务层从 token 读)
  1116. * @param {(shape: string, color: string) => string} imageOf 形状+颜色 → 图片 URL
  1117. * @returns {{rendered: number, skipped: number}}
  1118. */
  1119. export function syncAssociatedDevices(viewer, devices, colorOf, imageOf, overlapGroups = [], planOf, tintOf) {
  1120. const source = deviceMarkerSource(viewer)
  1121. source.entities.removeAll()
  1122. // 票 47:实体是**整份重建**的,动效基准与循环必须一起复位
  1123. // (否则基准表里留着上一批实体的 id,动效会写到不存在的对象上)。
  1124. deviceBaseStyle.clear()
  1125. stopDeviceEffects()
  1126. let rendered = 0
  1127. let skipped = 0
  1128. const groupsByCode = new Map()
  1129. for (const group of Array.isArray(overlapGroups) ? overlapGroups : []) {
  1130. if (!group || group.k < 2) continue
  1131. for (const row of group.rows || []) groupsByCode.set(row.code, group)
  1132. }
  1133. for (const device of devices || []) {
  1134. if (!Number.isFinite(device.longitude) || !Number.isFinite(device.latitude)) {
  1135. skipped += 1
  1136. continue
  1137. }
  1138. const color = colorOf(device)
  1139. // 票 44:尺寸与形状都来自**单一资产接缝**(`domain/deviceAssets.js` 的计划),
  1140. // 引擎不认识"设备该多大",也不写死尺寸字面量。
  1141. const size = Number.isFinite(device.size) ? device.size : 14
  1142. const group = groupsByCode.get(device.code)
  1143. // 批次 3:关联设备走实心母版合成计划(第三轮:绘制尺寸含描边余量 `plan.draw`)。
  1144. const plan = planOf ? planOf(device) : null
  1145. const drawW = plan ? plan.draw.width : size
  1146. const drawH = plan ? plan.draw.height : size
  1147. const entity = source.entities.add({
  1148. id: `device|${device.pointCode}|${device.code}`,
  1149. position: Cartesian3.fromDegrees(device.longitude, device.latitude),
  1150. show: !group,
  1151. billboard: {
  1152. image: imageOf(device.shape || 'circle', color, size),
  1153. width: drawW,
  1154. height: drawH,
  1155. scale: 1,
  1156. // 第三轮:含 pad 画布的底边比尖端低 pad 屏显值,下移补偿(尖端落真实坐标)。
  1157. pixelOffset: plan ? new Cartesian2(0, plan.draw.pad) : new Cartesian2(0, 0),
  1158. heightReference: HeightReference.CLAMP_TO_GROUND,
  1159. verticalOrigin: plan ? VerticalOrigin.BOTTOM : VerticalOrigin.CENTER,
  1160. },
  1161. label: group ? mapTextLabel(`聚合 ${group.k} 台${group.visual.label ? ` · 组内${group.visual.label}` : ''}`, color, 'count', false, plan ? new Cartesian2(0, plan.labelOffsetY) : null, plan ? plan.labelMaxWidth : 0) : undefined,
  1162. properties: {
  1163. deviceCode: device.code,
  1164. // 批次 3:hover tooltip 用完整名称(label 只放截断文本)。
  1165. deviceName: device.name || '',
  1166. monitoringPointCode: device.pointCode,
  1167. deviceStatus: device.status || '',
  1168. deviceShape: device.shape || 'circle',
  1169. assetMode: device.assetMode || '',
  1170. assetReason: device.assetReason || '',
  1171. assetReasonText: device.assetReasonText || '',
  1172. overlapGroup: group ? group.key : '',
  1173. overlapPointCode: group ? group.pointCode : '',
  1174. overlapAggregate: false,
  1175. overlapEntry: false,
  1176. overlapCount: group ? group.k : 0,
  1177. overlapFanIndex: -1,
  1178. // 批次 3:合成计划读数(规格 10 宿主观测接缝)。
  1179. ...(plan ? planToDataset(plan) : {}),
  1180. },
  1181. })
  1182. rendered += 1
  1183. // 异步合成:完成后替换 billboard 图片(失败保留同步兜底)。
  1184. if (plan) {
  1185. renderMarker(plan, { tintHex: tintOf ? tintOf(device) : color })
  1186. .then((dataUrl) => {
  1187. if (entity.billboard) entity.billboard.image = dataUrl
  1188. })
  1189. .catch(() => {})
  1190. }
  1191. }
  1192. // 聚合符号没有 deviceCode:拾取它只能返回 overlap action,不能冒充任一台设备。
  1193. for (const group of Array.isArray(overlapGroups) ? overlapGroups : []) {
  1194. if (!group || group.k < 2 || !group.rows.length) continue
  1195. const representative = group.rows.find((row) => row.status === (group.visual && group.visual.status)) || group.rows[0]
  1196. const color = colorOf(representative)
  1197. const size = Number.isFinite(representative.size) ? representative.size : 14
  1198. source.entities.add({
  1199. id: `overlap|${group.pointCode}|${group.key}`,
  1200. position: Cartesian3.fromDegrees(representative.longitude, representative.latitude),
  1201. billboard: {
  1202. image: imageOf(group.visual.shape, color, size),
  1203. width: size * 1.5,
  1204. height: size * 1.5,
  1205. scale: 1,
  1206. pixelOffset: new Cartesian2(0, 0),
  1207. heightReference: HeightReference.CLAMP_TO_GROUND,
  1208. verticalOrigin: VerticalOrigin.CENTER,
  1209. },
  1210. label: mapTextLabel(`聚合 ${group.k} 台${group.visual.label ? ` · 组内${group.visual.label}` : ''}`, color, 'count'),
  1211. properties: {
  1212. overlapAggregate: true,
  1213. overlapGroup: group.key,
  1214. overlapPointCode: group.pointCode,
  1215. overlapCount: group.k,
  1216. overlapState: group.state,
  1217. overlapLabel: group.label,
  1218. deviceStatus: group.visual.status,
  1219. overlapEffectCode: representative.code,
  1220. // 不能含 `deviceCode` / `monitoringPointCode`:聚合符号不冒充设备身份。
  1221. },
  1222. })
  1223. }
  1224. return { rendered: devices ? devices.length : 0, skipped }
  1225. }
  1226. /**
  1227. * 展开/预览重叠设备:只改 billboard `pixelOffset`(屏幕渲染层);实体 position 与属性经纬度不动。
  1228. * `offsets` 是 `{[deviceCode]: {x,y}}`;collapsed 时恢复为 0。
  1229. */
  1230. export function nudgeOverlap(viewer, groupKey) {
  1231. const source = deviceMarkerSource(viewer)
  1232. let touched = 0
  1233. for (const entity of source.entities.values) {
  1234. const properties = entity.properties || {}
  1235. const group = properties.overlapGroup && properties.overlapGroup.getValue ? properties.overlapGroup.getValue() : ''
  1236. const aggregate = properties.overlapAggregate && properties.overlapAggregate.getValue ? properties.overlapAggregate.getValue() : false
  1237. const entry = properties.overlapEntry && properties.overlapEntry.getValue ? properties.overlapEntry.getValue() : false
  1238. if (group !== groupKey || (!aggregate && !entry)) continue
  1239. if (entity.billboard) {
  1240. entity.billboard.scale = entry ? 1.6 : 1.2
  1241. entity.billboard.outlineWidth = 2
  1242. entity.billboard.outlineColor = Color.WHITE.withAlpha(0.85)
  1243. }
  1244. touched += 1
  1245. }
  1246. return touched
  1247. }
  1248. /** 清空重叠聚合面板hover弱提示。 */
  1249. export function clearOverlapNudge(viewer) {
  1250. const source = deviceMarkerSource(viewer)
  1251. for (const entity of source.entities.values) {
  1252. const properties = entity.properties || {}
  1253. const aggregate = properties.overlapAggregate && properties.overlapAggregate.getValue ? properties.overlapAggregate.getValue() : false
  1254. if (aggregate && entity.billboard) {
  1255. entity.billboard.scale = 1
  1256. entity.billboard.outlineWidth = 0
  1257. }
  1258. }
  1259. }
  1260. export function setOverlapPresentation(viewer, { groupKey = '', state = 'collapsed', preview = false, offsets = {}, selectedCode = '' } = {}) {
  1261. const source = deviceMarkerSource(viewer)
  1262. let touched = 0
  1263. for (const entity of source.entities.values) {
  1264. const properties = entity.properties || {}
  1265. const read = (key) => properties[key] && properties[key].getValue ? properties[key].getValue() : ''
  1266. if (!groupKey || read('overlapGroup') !== groupKey) continue
  1267. const aggregate = read('overlapAggregate') === true
  1268. const showFan = state === 'expanded'
  1269. if (aggregate) {
  1270. // 选中设备接替中心入口;展开时保留聚合入口并压暗,计数始终可读。
  1271. entity.show = !selectedCode
  1272. if (entity.billboard) {
  1273. entity.billboard.color = Color.WHITE.withAlpha(showFan && !preview ? 0.35 : 1)
  1274. entity.billboard.scale = preview ? 1.2 : 1
  1275. entity.billboard.outlineWidth = preview ? 2 : 0
  1276. entity.billboard.outlineColor = Color.WHITE
  1277. }
  1278. } else {
  1279. const code = read('deviceCode')
  1280. const selected = Boolean(selectedCode && code === selectedCode)
  1281. const offset = selected ? { x: 0, y: 0 } : offsets[code]
  1282. entity.show = selected || (showFan && Boolean(offset))
  1283. if (entity.billboard) {
  1284. entity.billboard.pixelOffset = offset
  1285. ? new Cartesian2(offset.x, offset.y)
  1286. : new Cartesian2(0, 0)
  1287. }
  1288. if (entity.label) entity.label.show = selected
  1289. if (properties.overlapEntry && properties.overlapEntry.setValue) properties.overlapEntry.setValue(selected)
  1290. else if (properties.addProperty) properties.addProperty('overlapEntry', selected)
  1291. }
  1292. touched += 1
  1293. }
  1294. if (viewer.scene && viewer.scene.requestRender) viewer.scene.requestRender()
  1295. return touched
  1296. }
  1297. /**
  1298. * 票 47:关联设备图层的每帧投影读数(DOM overlay 消费)。
  1299. *
  1300. * 只回**当前可见**的设备实体:折叠聚合下的个体 `show=false` 不进 DOM(聚合符号按票面
  1301. * Out of Scope 留在 billboard);展开扇形后的个体带着 pixelOffset 回来,DOM 与 billboard
  1302. * 同位同量。坐标 = 窗口坐标(与 `projectPivot` 同系);`scale`/`alpha` 读的是实体上
  1303. * **当前真值**(选中 1.6 / hover 1.3 / 弱化 0.75 / 强调 1.55 都含在内)——
  1304. * DOM 镜像它才不会出现「比 billboard 小一圈」。
  1305. */
  1306. export function projectDeviceMarkers(viewer) {
  1307. const source = deviceMarkerSource(viewer)
  1308. if (!viewer || !viewer.scene || !viewer.canvas || !source || source.show === false) return []
  1309. const out = []
  1310. for (const entity of source.entities.values) {
  1311. const properties = entity.properties || {}
  1312. const read = (key) => (properties[key] && properties[key].getValue ? properties[key].getValue() : '')
  1313. const code = read('deviceCode')
  1314. if (!code || entity.show === false) continue
  1315. // projectMarkerEntity 已含画布原点(窗口坐标)与 pixelOffset(尖端落点)——直接用,
  1316. // 调用方只减宿主原点(与 projectToWindow 的口径只差 host 一层)。
  1317. const projected = projectMarkerEntity(viewer, entity)
  1318. if (!projected) continue
  1319. const color = entity.billboard && entity.billboard.color ? entity.billboard.color.getValue() : null
  1320. out.push({
  1321. key: `device|${read('monitoringPointCode')}|${code}`,
  1322. code,
  1323. pointCode: read('monitoringPointCode'),
  1324. x: projected.x,
  1325. y: projected.y,
  1326. scale: entity.billboard && entity.billboard.scale ? Number(entity.billboard.scale.getValue()) || 1 : 1,
  1327. alpha: color && Number.isFinite(color.alpha) ? color.alpha : 1,
  1328. })
  1329. }
  1330. return out
  1331. }
  1332. /**
  1333. * 票 47:点位标记(监测点/未关联设备)的每帧缩放读数。
  1334. * 强调(`highlightEntities` 的 1.55)写在 billboard.scale 上,DOM 要镜像同一个量,
  1335. * 否则面板联动强调时两层尺寸不一致(维护者走查抓到的「小了一圈」)。
  1336. * @returns {Map<string, number>} `kind|code` → scale(没实体 = 不在图上,DOM 自行隐藏)
  1337. */
  1338. export function readPointMarkerScales(viewer) {
  1339. const scales = new Map()
  1340. const source = (viewer && markerSources.get(viewer)) || (viewer && viewer.dataSources ? viewer.dataSources.getByName(POINT_MARKER_SOURCE)[0] : null)
  1341. if (!source) return scales
  1342. for (const entity of source.entities.values) {
  1343. const properties = entity.properties || {}
  1344. const read = (key) => (properties[key] && properties[key].getValue ? properties[key].getValue() : '')
  1345. const code = read('pointCode')
  1346. if (!code) continue
  1347. const kind = read('pointKind') || 'monitoring-point'
  1348. scales.set(`${kind}|${code}`, entity.billboard && entity.billboard.scale ? Number(entity.billboard.scale.getValue()) || 1 : 1)
  1349. }
  1350. return scales
  1351. }
  1352. /** 只读回关联设备图层的真实内容(走查据此断言「设备真的画在独立图层上」,以及「画成的样子来自接缝」)。 */
  1353. export function readAssociatedDevices(viewer) {
  1354. const source = deviceMarkerSource(viewer)
  1355. return source.entities.values.map((entity) => {
  1356. const properties = entity.properties || {}
  1357. const get = (key) => (properties[key] ? properties[key].getValue() : '')
  1358. // 票 44:实体上**已经**带着接缝的结论(形状/资产模式/降级原因),原读取器却把它们丢掉了 ——
  1359. // 于是「地图上真画的那台设备是什么形状、什么资产模式」在引擎侧读不到,走查只能退回去读
  1360. // 计划行(那是"打算画什么",不是"画成了什么")。这里如实读回来。
  1361. // 尺寸取 **billboard 的真实宽高**,而不是再读一个属性:这样读到的是真画上去的尺寸。
  1362. const size = entity.billboard && entity.billboard.width ? entity.billboard.width.getValue() : null
  1363. return {
  1364. id: entity.id,
  1365. deviceCode: get('deviceCode'),
  1366. monitoringPointCode: get('monitoringPointCode'),
  1367. deviceStatus: get('deviceStatus'),
  1368. shape: get('deviceShape'),
  1369. size,
  1370. assetMode: get('assetMode'),
  1371. assetReason: get('assetReason'),
  1372. assetReasonText: get('assetReasonText'),
  1373. visible: entity.isShowing === true,
  1374. overlapAggregate: get('overlapAggregate') === true,
  1375. overlapEntry: get('overlapEntry') === true,
  1376. overlapGroup: get('overlapGroup'),
  1377. overlapPointCode: get('overlapPointCode'),
  1378. overlapCount: get('overlapCount'),
  1379. labelText: entity.label && entity.label.text ? entity.label.text.getValue() : '',
  1380. pixelOffset: entity.billboard && entity.billboard.pixelOffset
  1381. ? entity.billboard.pixelOffset.getValue()
  1382. : null,
  1383. }
  1384. })
  1385. }
  1386. /** 清空关联设备图层(关闭 / 换点 / 快照失效 / 离开页面都走它)。 */
  1387. /**
  1388. * 票 45:重叠实体的只读窗口坐标(真实 Cesium 投影 + pixelOffset)。
  1389. * 走查用它把真实鼠标送到聚合符号/展开子点;只读渲染坐标,不写设备经纬度。
  1390. */
  1391. export function readOverlapTargets(viewer) {
  1392. const source = deviceMarkerSource(viewer)
  1393. const rect = viewer && viewer.canvas ? viewer.canvas.getBoundingClientRect() : null
  1394. if (!rect || !viewer.scene) return []
  1395. return source.entities.values
  1396. .filter((entity) => entity.properties && entity.properties.overlapGroup)
  1397. .map((entity) => {
  1398. const projected = projectMarkerEntity(viewer, entity)
  1399. if (!projected) return null
  1400. const properties = entity.properties || {}
  1401. const get = (key) => properties[key] && properties[key].getValue ? properties[key].getValue() : ''
  1402. return {
  1403. x: projected.x,
  1404. y: projected.y,
  1405. // 用**逻辑可见性** `entity.show`(Cesium 里 `isShowing` 是渲染态,headless 下
  1406. // `show` 改变后可能要等一帧才同步;拾取判据不能依赖渲染帧,否则展开态设备
  1407. // 会被误滤掉而聚合却可见 —— 真机走查抓到过这一帧)。
  1408. visible: entity.show !== false,
  1409. aggregate: get('overlapAggregate') === true,
  1410. deviceCode: get('deviceCode'),
  1411. pointCode: get('monitoringPointCode') || get('overlapPointCode'),
  1412. status: get('deviceStatus'),
  1413. overlapEntry: get('overlapEntry') === true,
  1414. overlapGroup: get('overlapGroup'),
  1415. }
  1416. })
  1417. .filter(Boolean)
  1418. }
  1419. export function clearAssociatedDevices(viewer) {
  1420. // 票 47:图层撤下 ⇒ 特效随图层一起消失(决策票 07:不需要单独的消失逻辑,
  1421. // 因为特效就挂在图层上)。这里显式停循环,避免空转写帧。
  1422. stopDeviceEffects()
  1423. deviceBaseStyle.clear()
  1424. deviceMarkerSource(viewer).entities.removeAll()
  1425. }
  1426. /**
  1427. * 屏幕坐标 → **设备标记身份**(票 42 的独立拾取入口;票 43 会在此基础上做双向联动)。
  1428. *
  1429. * 与 `pickPointMarkerAt()` 同一条纪律:只认本图层上带 `deviceCode` 的实体,
  1430. * 回**身份**不回 Cesium 实体,且**设备优先于监测点**(票 15 的拾取优先级)。
  1431. */
  1432. function overlapTargetAt(viewer, windowX, windowY) {
  1433. const targets = readOverlapTargets(viewer)
  1434. let nearest = null
  1435. let distance = Infinity
  1436. for (const target of targets.filter((item) => item.visible)) {
  1437. const current = Math.hypot(target.x - windowX, target.y - windowY)
  1438. if (current <= 24 && current < distance) {
  1439. nearest = target
  1440. distance = current
  1441. }
  1442. }
  1443. if (!nearest) return null
  1444. if (nearest.aggregate) return { kind: 'monitoring-device-overlap', code: '', pointCode: '', overlapGroup: nearest.overlapGroup }
  1445. if (nearest.deviceCode) return {
  1446. kind: 'monitoring-device', code: nearest.deviceCode, pointCode: nearest.pointCode, status: nearest.status,
  1447. overlapEntry: nearest.overlapEntry, overlapGroup: nearest.overlapGroup,
  1448. }
  1449. return null
  1450. }
  1451. export function pickPipeObjectAt(viewer, windowX, windowY) {
  1452. if (!viewer || !viewer.scene || !viewer.canvas || typeof viewer.scene.pick !== 'function') return null
  1453. const rect = viewer.canvas.getBoundingClientRect()
  1454. const picked = viewer.scene.pick(new Cartesian2(windowX - rect.left, windowY - rect.top))
  1455. const source = pipelineSource(viewer)
  1456. const id = picked && picked.id
  1457. if (source && id && source.entities.values.includes(id)) {
  1458. const properties = id.properties || {}
  1459. const read = (key) => properties[key] && properties[key].getValue ? properties[key].getValue() : ''
  1460. const kind = read('kind')
  1461. const code = read('code')
  1462. if (kind === 'pipe' && code) return { kind: 'pipeline', code, objectKind: 'pipe' }
  1463. if (kind === 'component' && code) return { kind: 'pipeline', code, objectKind: 'component' }
  1464. }
  1465. // Instanced glTF batches expose the batch id and instanceId through public pick data.
  1466. // Resolve only that instance; never report the first item in a batch as the hit.
  1467. if (id && id.kind === 'pipe-model-batch' && Number.isInteger(picked.instanceId)) {
  1468. const instance = Array.isArray(id.instances) ? id.instances[picked.instanceId] : ''
  1469. const match = /^(pipe|component):(.*)$/.exec(String(instance || ''))
  1470. if (match) return { kind: 'pipeline', code: match[2], objectKind: match[1] === 'pipe' ? 'pipe' : 'component' }
  1471. }
  1472. return null
  1473. }
  1474. /** 设备、监测点之外的第三层拾取入口:只返回管段/构件身份。 */
  1475. /** 仅穿过本管线图层;其它场景对象仍按 Cesium 的前后顺序遮挡。 */
  1476. function pickThroughPipelines(viewer, position) {
  1477. const scene = viewer.scene
  1478. const picked = scene.pick(position)
  1479. const source = pipelineSource(viewer)
  1480. const isPipeline = (hit) => {
  1481. const id = hit && hit.id
  1482. return Boolean(id && (id.kind === 'pipe-model-batch' || (source && source.entities.values.includes(id))))
  1483. }
  1484. if (!isPipeline(picked)) return picked
  1485. // 普通拾取不受影响;只有最上层是管线时才执行多层拾取。
  1486. if (typeof scene.drillPick !== 'function') return null
  1487. return scene.drillPick(position).find((hit) => !isPipeline(hit)) || null
  1488. }
  1489. export function pickDeviceMarkerAt(viewer, windowX, windowY) {
  1490. if (!viewer || !viewer.scene || !viewer.canvas || typeof viewer.scene.pick !== 'function') return null
  1491. // 票45:展开后的设备拥有屏幕pixelOffset;先按公开投影读数做近邻身份判定,
  1492. // 避免同屏监测点/地形顶层对象让普通scene.pick吞掉真实设备。仍保持普通拾取语义。
  1493. const projectedHit = overlapTargetAt(viewer, windowX, windowY)
  1494. if (projectedHit) return projectedHit
  1495. const rect = viewer.canvas.getBoundingClientRect()
  1496. const picked = pickThroughPipelines(viewer, new Cartesian2(windowX - rect.left, windowY - rect.top))
  1497. const entity = picked && picked.id
  1498. const properties = entity && entity.properties
  1499. if (properties) {
  1500. const read = (key) => (properties[key] ? properties[key].getValue() : '')
  1501. if (read('overlapAggregate') === true) {
  1502. return {
  1503. kind: 'monitoring-device-overlap',
  1504. code: '',
  1505. pointCode: read('overlapPointCode'),
  1506. overlapGroup: read('overlapGroup'),
  1507. overlapCount: read('overlapCount'),
  1508. }
  1509. }
  1510. const code = read('deviceCode')
  1511. if (code) return {
  1512. kind: 'monitoring-device', code, pointCode: read('monitoringPointCode'), status: read('deviceStatus'),
  1513. name: read('deviceName') || '',
  1514. overlapEntry: read('overlapEntry') === true, overlapGroup: read('overlapGroup'),
  1515. }
  1516. }
  1517. // 重叠聚合与监测点同屏时,scene.pick 可能只返回顶层点位;
  1518. // 用聚合实体投影做近邻补偿,仍是普通拾取、不改真实位置。
  1519. return overlapTargetAt(viewer, windowX, windowY)
  1520. }
  1521. // ================================================================ 设备点的对应反应(票 43)
  1522. /**
  1523. * 把「每台设备的反应档位」写进显示参数(票 43 的反应语言)。
  1524. *
  1525. * 三条纪律都体现在这里:
  1526. * 1. **不用颜色**:只改 `scale` 与 `color.alpha`(弱化是压暗,不是换色)——
  1527. * 色键语义仍只属于设备状态(票 44/07);
  1528. * 2. **外环**用 billboard 的 `outlineWidth`/`outlineColor`(Cesium 公开属性),
  1529. * 不用新建实体、不用着色器 —— 关掉时写回 0;
  1530. * 3. **每个实体都过一遍**:命中与未命中都要写回值,否则上次的强化会一直留在图上
  1531. * (与票 14 的 `highlightEntities` 同一条教训)。
  1532. *
  1533. * @param {Viewer} viewer
  1534. * @param {Array} reactions `domain/deviceCorrespondence.js` 的 `planDeviceReactions().reactions`
  1535. * @param {(reaction: string) => object} styleOf 档位 → `{ scale, alpha, ring }`
  1536. * @returns {number} 本次被写入的实体数(0 = 图层为空 / 引擎没就绪)
  1537. */
  1538. export function applyDeviceReactions(viewer, reactions, styleOf) {
  1539. const source = deviceSources.get(viewer) || (viewer && viewer.dataSources ? viewer.dataSources.getByName(DEVICE_MARKER_SOURCE)[0] : null)
  1540. if (!source) return 0
  1541. const byId = new Map()
  1542. for (const item of reactions || []) {
  1543. if (item && item.code) byId.set(`device|${item.pointCode}|${item.code}`, item.reaction)
  1544. }
  1545. let written = 0
  1546. for (const entity of source.entities.values) {
  1547. // 图层里没有对应反应的实体(理论上不该有)也按常态写回,避免残留强化。
  1548. const reaction = byId.get(entity.id) || 'normal'
  1549. const style = styleOf(reaction) || { scale: 1, alpha: 1, ring: false }
  1550. const billboard = entity.billboard
  1551. if (!billboard || (entity.properties && (entity.properties.effectFx ||
  1552. (entity.properties.overlapAggregate && entity.properties.overlapAggregate.getValue() === true)))) continue
  1553. const isDevice = entity.properties && entity.properties.deviceCode
  1554. billboard.scale = isDevice ? Math.max(1, style.scale) : style.scale
  1555. // 设备中心保持不透明;选中/hover 仍用放大与外围特效区分。
  1556. billboard.color = Color.WHITE.withAlpha(isDevice ? 1 : style.alpha)
  1557. // 选中外环:Cesium billboard 自带的描边,不用新建实体。
  1558. billboard.outlineWidth = style.ring ? 3 : 0
  1559. billboard.outlineColor = Color.WHITE
  1560. // 票 47:记下**基准**(动效在此之上做乘性修饰,不覆盖反应量)。
  1561. deviceBaseStyle.set(entity.id, { scale: style.scale, alpha: style.alpha })
  1562. written += 1
  1563. }
  1564. return written
  1565. }
  1566. /**
  1567. * 票 47:设备状态**动效**驱动器(决策票 07 的三个动效 + 门控 + 降级)。
  1568. *
  1569. * 中心符号保持不透明和稳定形状;选中/hover 的尺寸强化仍保留。
  1570. * 扩张、闪烁、渐隐和旋转只作用于外围光环,不再与中心弱化透明度相乘。
  1571. * 场景按需渲染:存在动态设备时 requestRender,关闭或降级时取消动画循环。
  1572. *
  1573. * ── 载体是场景内 billboard,**不是后处理级** ──
  1574. * 决策票 07 已裁定:特效走 billboard ⇒「先特效、后雾」自动成立,不占 `PostProcessStageCollection`、
  1575. * 与雾无排序冲突,也不新增穿雾例外。
  1576. */
  1577. const deviceEffectState = {
  1578. viewer: null,
  1579. plan: null,
  1580. selectedCode: '',
  1581. raf: 0,
  1582. tick: 0,
  1583. }
  1584. /** 反应量写下的**基准** scale/alpha(动效在其上做乘性修饰)。键=实体 id。 */
  1585. const deviceBaseStyle = new Map()
  1586. /**
  1587. * 票 47(科技感方向):光环实体池。
  1588. *
  1589. * ── 为什么用**独立 billboard 实体**而不是改设备图标本身 ──
  1590. * 决策票 07 的参考图要的是「多层同心扩散环 + 中心辉光 + 能量描边」;
  1591. * 设备图标只有一个 billboard,塞不下多层环。所以另建一层光环实体(`fx|` 前缀):
  1592. * · **不带 `deviceCode`** ⇒ 按研究笔记第 7 条,它不参与设备拾取判定,不会挡住设备点;
  1593. * · 位置跟设备同坐标,随图层一起重建/销毁(特效挂在图层上);
  1594. * · 每台最多 `RING_LAYERS` 个环 + 1 个辉光,同处一个数据源(一次 draw call 量级)。
  1595. */
  1596. const RING_ENTITY_PREFIX = 'fx|'
  1597. function ringEntitiesOf(source, code) {
  1598. return source.entities.values.filter((entity) =>
  1599. typeof entity.id === 'string' &&
  1600. entity.id.startsWith(RING_ENTITY_PREFIX) &&
  1601. entity.id.includes(`|${code}|`))
  1602. }
  1603. /** 建/更新某台设备的光环实体(层数固定:缺则建、数量不符则整组重建)。 */
  1604. function ensureRingEntities(source, device, color, fxImageOf) {
  1605. const size = Number.isFinite(device.size) ? device.size : 14
  1606. // 层数与"要不要辉光"都问 domain(引擎不认识业务状态名,只认"几层环")。
  1607. const wanted = ringEntityCount(device.status)
  1608. if (!wanted) return []
  1609. const existing = ringEntitiesOf(source, device.code)
  1610. if (existing.length === wanted) return existing
  1611. for (const entity of existing) source.entities.remove(entity)
  1612. const created = []
  1613. for (let layer = 0; layer < RING_LAYERS; layer += 1) {
  1614. created.push(source.entities.add({
  1615. id: `${RING_ENTITY_PREFIX}${device.pointCode}|${device.code}|ring${layer}`,
  1616. position: Cartesian3.fromDegrees(device.longitude, device.latitude),
  1617. billboard: {
  1618. // 环用**能量场图片**(径向渐变柔和边缘):比 2px 描边圆更像特效,
  1619. // 放大时边缘自然扩散,不会一放大就细成一根线(首版真机截图抓到的问题)。
  1620. image: fxImageOf('ring', color, size),
  1621. width: size,
  1622. height: size,
  1623. scale: 1,
  1624. color: Color.WHITE.withAlpha(0),
  1625. heightReference: HeightReference.CLAMP_TO_GROUND,
  1626. verticalOrigin: VerticalOrigin.CENTER,
  1627. },
  1628. properties: {
  1629. effectFx: true,
  1630. effectFxKind: 'ring',
  1631. effectFxLayer: layer,
  1632. effectFxFor: device.code,
  1633. effectFxStatus: device.status,
  1634. effectFxDisplayGroup: '',
  1635. // 刻意**不写 deviceCode / monitoringPointCode**:光环不冒充设备、不吃拾取。
  1636. },
  1637. }))
  1638. }
  1639. if (hasGlow(device.status)) {
  1640. created.push(source.entities.add({
  1641. id: `${RING_ENTITY_PREFIX}${device.pointCode}|${device.code}|glow`,
  1642. position: Cartesian3.fromDegrees(device.longitude, device.latitude),
  1643. billboard: {
  1644. image: fxImageOf('glow', color, size),
  1645. width: size,
  1646. height: size,
  1647. scale: 1,
  1648. color: Color.WHITE.withAlpha(0),
  1649. heightReference: HeightReference.CLAMP_TO_GROUND,
  1650. verticalOrigin: VerticalOrigin.CENTER,
  1651. },
  1652. properties: {
  1653. effectFx: true,
  1654. effectFxKind: 'glow',
  1655. effectFxLayer: -1,
  1656. effectFxFor: device.code,
  1657. effectFxStatus: device.status,
  1658. effectFxDisplayGroup: '',
  1659. },
  1660. }))
  1661. }
  1662. return created
  1663. }
  1664. /** 清掉某台(或全部)光环实体。 */
  1665. function clearRingEntities(source, code = '') {
  1666. for (const entity of source.entities.values.slice()) {
  1667. if (typeof entity.id !== 'string' || !entity.id.startsWith(RING_ENTITY_PREFIX)) continue
  1668. if (code && !entity.id.includes(`|${code}|`)) continue
  1669. source.entities.remove(entity)
  1670. }
  1671. }
  1672. function baseStyleOf(entityId) {
  1673. return deviceBaseStyle.get(entityId) || { scale: 1, alpha: 1 }
  1674. }
  1675. function animatedByCode(plan) {
  1676. const map = new Map()
  1677. for (const item of (plan && plan.animated) || []) map.set(item.code, item.effect)
  1678. return map
  1679. }
  1680. function writeEffectFrame() {
  1681. const viewer = deviceEffectState.viewer
  1682. const source = viewer && (deviceSources.get(viewer) || (viewer.dataSources ? viewer.dataSources.getByName(DEVICE_MARKER_SOURCE)[0] : null))
  1683. if (!viewer || !source) return
  1684. const plan = deviceEffectState.plan
  1685. const animated = animatedByCode(plan)
  1686. const now = Date.now()
  1687. for (const entity of source.entities.values) {
  1688. const billboard = entity.billboard
  1689. if (!billboard) continue
  1690. const properties = entity.properties || {}
  1691. const code = properties.deviceCode && properties.deviceCode.getValue ? properties.deviceCode.getValue() : ''
  1692. if (!code) continue
  1693. const base = baseStyleOf(entity.id)
  1694. // 中心符号负责识别,外围光环负责动画;不把闪烁与弱化透明度乘到中心上。
  1695. billboard.scale = Math.max(1, base.scale)
  1696. billboard.color = Color.WHITE.withAlpha(1)
  1697. billboard.rotation = 0
  1698. }
  1699. // ── 科技感光环层(多层同心扩散环 + 中心辉光)──
  1700. // 光环实体不带 deviceCode,所以**不参与上面的设备图标循环**,也不吃拾取。
  1701. const selectedBoost = 1.25
  1702. const owners = new Map(source.entities.values
  1703. .filter((entity) => entity.properties && entity.properties.deviceCode)
  1704. .map((entity) => [entity.properties.deviceCode.getValue(), entity]))
  1705. const aggregates = new Map(source.entities.values
  1706. .filter((entity) => entity.properties && entity.properties.overlapAggregate && entity.properties.overlapAggregate.getValue() === true)
  1707. .map((entity) => [entity.properties.overlapGroup.getValue(), entity]))
  1708. const entries = new Map([...owners.values()]
  1709. .filter((entity) => entity.show !== false && entity.properties.overlapEntry && entity.properties.overlapEntry.getValue() === true)
  1710. .map((entity) => [entity.properties.overlapGroup.getValue(), entity]))
  1711. for (const entity of source.entities.values) {
  1712. const billboard = entity.billboard
  1713. const properties = entity.properties || {}
  1714. const isFx = properties.effectFx && properties.effectFx.getValue && properties.effectFx.getValue() === true
  1715. if (!billboard || !isFx) continue
  1716. const forCode = properties.effectFxFor && properties.effectFxFor.getValue ? properties.effectFxFor.getValue() : ''
  1717. const status = properties.effectFxStatus && properties.effectFxStatus.getValue ? properties.effectFxStatus.getValue() : ''
  1718. const kind = properties.effectFxKind && properties.effectFxKind.getValue ? properties.effectFxKind.getValue() : ''
  1719. const layer = properties.effectFxLayer && properties.effectFxLayer.getValue ? properties.effectFxLayer.getValue() : 0
  1720. const effect = forCode ? animated.get(forCode) : ''
  1721. const owner = owners.get(forCode)
  1722. const groupKey = owner && owner.properties.overlapGroup ? owner.properties.overlapGroup.getValue() : ''
  1723. const aggregate = aggregates.get(groupKey)
  1724. const entry = entries.get(groupKey)
  1725. const entryHasGroupEffect = entry && aggregate && animated.has(entry.properties.deviceCode.getValue()) &&
  1726. entry.properties.deviceStatus.getValue() === aggregate.properties.deviceStatus.getValue()
  1727. // 聚合最高状态的光环借用当前入口;设备来源身份和状态不变。
  1728. const representsGroup = !entryHasGroupEffect && owner && owner.show === false && aggregate && aggregate.properties.overlapEffectCode &&
  1729. aggregate.properties.overlapEffectCode.getValue() === forCode
  1730. const displayOwner = representsGroup ? (entry || aggregate) : owner
  1731. entity.show = Boolean(effect && source.show !== false && displayOwner && displayOwner.show !== false)
  1732. if (properties.effectFxDisplayGroup) properties.effectFxDisplayGroup.setValue(representsGroup ? groupKey : '')
  1733. if (displayOwner && displayOwner.position) entity.position = displayOwner.position
  1734. billboard.pixelOffset = displayOwner && displayOwner.billboard ? displayOwner.billboard.pixelOffset : new Cartesian2(0, 0)
  1735. if (!effect) {
  1736. // 无动效(正常/离线/未知、特效关闭或降级):光环**完全透明**,不留残影。
  1737. billboard.scale = 1
  1738. billboard.color = Color.WHITE.withAlpha(0)
  1739. continue
  1740. }
  1741. const isSelected = deviceEffectState.selectedCode && forCode === deviceEffectState.selectedCode
  1742. const boost = isSelected ? selectedBoost : 1
  1743. if (kind === 'glow') {
  1744. const strength = glowStrengthAt(now)
  1745. billboard.scale = glowScaleAt(now) * boost
  1746. billboard.color = Color.WHITE.withAlpha(0.32 * strength * boost)
  1747. continue
  1748. }
  1749. // 多层环:每层相位错开、由内向外扩张渐隐。
  1750. const frame = ringFrameAt(status, now)
  1751. const ring = frame.rings[layer] || { scale: 1, alpha: 0 }
  1752. billboard.scale = ring.scale * boost
  1753. const flicker = effect === DEVICE_EFFECT.BLINK ? blinkStrengthAt(now) : 1
  1754. billboard.color = Color.WHITE.withAlpha(Math.min(1, ring.alpha * boost * flicker))
  1755. billboard.rotation = effect === DEVICE_EFFECT.SPIN ? spinAngleAt(now) : 0
  1756. }
  1757. if (animated.size && viewer.scene && viewer.scene.requestRender) viewer.scene.requestRender()
  1758. deviceEffectState.tick += 1
  1759. }
  1760. function ensureEffectLoop() {
  1761. if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') return
  1762. if (deviceEffectState.raf) return
  1763. const step = () => {
  1764. if (!deviceEffectState.raf) return
  1765. writeEffectFrame()
  1766. deviceEffectState.raf = window.requestAnimationFrame(step)
  1767. }
  1768. deviceEffectState.raf = window.requestAnimationFrame(step)
  1769. }
  1770. /**
  1771. * 应用动效计划(由服务层在设备图层同步后调用)。
  1772. * @param {Viewer} viewer
  1773. * @param {object} plan `domain/deviceEffects.js` 的 `planDeviceEffects()` 输出
  1774. * @param {object} [options] `{ selectedCode }` 选中设备额外强化
  1775. * @returns {boolean} 是否启动了动效
  1776. */
  1777. export function applyDeviceEffects(viewer, plan, options = {}) {
  1778. deviceEffectState.viewer = viewer || null
  1779. deviceEffectState.plan = plan || null
  1780. deviceEffectState.selectedCode = (options && options.selectedCode) || ''
  1781. const running = Boolean(viewer && plan && plan.enabled === true && !plan.degraded && plan.animated && plan.animated.length)
  1782. // 光环实体按计划重建:参与的设备建环,其余(含降级/关闭时全部)清掉 —— 降级后动画全停,
  1783. // 光环也必须一起消失,否则"停了动画却还留着环"会被读成"还在报警"。
  1784. const source = viewer && (deviceSources.get(viewer) || (viewer.dataSources ? viewer.dataSources.getByName(DEVICE_MARKER_SOURCE)[0] : null))
  1785. if (source) {
  1786. if (running && options && options.devices) {
  1787. const animatedCodes = new Set((plan.animated || []).map((item) => item.code))
  1788. for (const device of options.devices) {
  1789. if (!animatedCodes.has(device.code)) { clearRingEntities(source, device.code); continue }
  1790. ensureRingEntities(
  1791. source,
  1792. device,
  1793. options.colorOf ? options.colorOf(device) : Color.WHITE,
  1794. options.fxImageOf || (() => ''),
  1795. )
  1796. }
  1797. // 已不在本批设备里的光环(换点/换状态)也要清。
  1798. const known = new Set(options.devices.map((item) => item.code))
  1799. for (const entity of source.entities.values.slice()) {
  1800. const isFx = entity.properties && entity.properties.effectFx && entity.properties.effectFx.getValue && entity.properties.effectFx.getValue() === true
  1801. if (!isFx) continue
  1802. const forCode = entity.properties.effectFxFor && entity.properties.effectFxFor.getValue ? entity.properties.effectFxFor.getValue() : ''
  1803. if (!known.has(forCode) || !animatedCodes.has(forCode)) source.entities.remove(entity)
  1804. }
  1805. } else {
  1806. clearRingEntities(source)
  1807. }
  1808. }
  1809. if (running) ensureEffectLoop()
  1810. else if (deviceEffectState.raf && typeof window !== 'undefined') {
  1811. window.cancelAnimationFrame(deviceEffectState.raf)
  1812. deviceEffectState.raf = 0
  1813. }
  1814. // 中心符号保持可读;停用时外围动画复位。
  1815. writeEffectFrame()
  1816. return running
  1817. }
  1818. /** 停止动效并写回基准(图层撤下 / 详情关闭 / 特效被关掉)。 */
  1819. export function stopDeviceEffects() {
  1820. if (typeof window !== 'undefined' && deviceEffectState.raf && typeof window.cancelAnimationFrame === 'function') {
  1821. window.cancelAnimationFrame(deviceEffectState.raf)
  1822. }
  1823. deviceEffectState.raf = 0
  1824. deviceEffectState.plan = null
  1825. deviceEffectState.selectedCode = ''
  1826. writeEffectFrame()
  1827. }
  1828. /**
  1829. * 只读读回每台设备的**动效状态与当前帧属性**(走查的"真在跑"证据)。
  1830. *
  1831. * 三层证据的第 2 层(场景图跨帧快照):走查对同一设备**隔帧读两次**,
  1832. * 断言 `scale`/`alpha`(脉冲/闪烁)或 `rotation`(旋转环)确实变了,
  1833. * 而正常/离线的设备**完全不变**。这样就不是"拿两份 JS 数据互比"的恒真断言。
  1834. */
  1835. export function readDeviceEffects(viewer) {
  1836. const source = viewer && (deviceSources.get(viewer) || (viewer.dataSources ? viewer.dataSources.getByName(DEVICE_MARKER_SOURCE)[0] : null))
  1837. if (!source) return { tick: deviceEffectState.tick, devices: [] }
  1838. const animated = animatedByCode(deviceEffectState.plan)
  1839. const devices = []
  1840. for (const entity of source.entities.values) {
  1841. const billboard = entity.billboard
  1842. if (!billboard) continue
  1843. const properties = entity.properties || {}
  1844. const get = (key) => (properties[key] && properties[key].getValue ? properties[key].getValue() : '')
  1845. const code = get('deviceCode')
  1846. if (!code) continue
  1847. const color = billboard.color && billboard.color.getValue ? billboard.color.getValue() : null
  1848. devices.push({
  1849. code,
  1850. status: get('deviceStatus'),
  1851. effect: animated.get(code) || '',
  1852. animated: animated.has(code),
  1853. scale: billboard.scale && billboard.scale.getValue ? billboard.scale.getValue() : billboard.scale,
  1854. alpha: color ? color.alpha : null,
  1855. rotation: billboard.rotation && billboard.rotation.getValue ? billboard.rotation.getValue() : (billboard.rotation || 0),
  1856. })
  1857. }
  1858. // 科技感光环的逐层读数(多层同心环 + 辉光):走查据此断言"环真的在扩张渐隐"。
  1859. const fx = []
  1860. for (const entity of source.entities.values) {
  1861. const billboard = entity.billboard
  1862. if (!billboard) continue
  1863. const properties = entity.properties || {}
  1864. const get = (key) => (properties[key] && properties[key].getValue ? properties[key].getValue() : '')
  1865. if (get('effectFx') !== true) continue
  1866. const color = billboard.color && billboard.color.getValue ? billboard.color.getValue() : null
  1867. fx.push({
  1868. for: get('effectFxFor'),
  1869. displayGroup: get('effectFxDisplayGroup'),
  1870. visible: source.show !== false && entity.show !== false && entity.isShowing !== false,
  1871. pixelOffset: billboard.pixelOffset && billboard.pixelOffset.getValue ? billboard.pixelOffset.getValue() : billboard.pixelOffset,
  1872. kind: get('effectFxKind'),
  1873. layer: get('effectFxLayer'),
  1874. status: get('effectFxStatus'),
  1875. scale: billboard.scale && billboard.scale.getValue ? billboard.scale.getValue() : billboard.scale,
  1876. alpha: color ? color.alpha : null,
  1877. rotation: billboard.rotation && billboard.rotation.getValue ? billboard.rotation.getValue() : (billboard.rotation || 0),
  1878. })
  1879. }
  1880. return {
  1881. tick: deviceEffectState.tick,
  1882. running: deviceEffectState.raf !== 0,
  1883. degraded: Boolean(deviceEffectState.plan && deviceEffectState.plan.degraded),
  1884. enabled: Boolean(deviceEffectState.plan && deviceEffectState.plan.enabled),
  1885. ringLayers: RING_LAYERS,
  1886. devices,
  1887. fx,
  1888. }
  1889. }
  1890. /**
  1891. * 选中设备的**归属引线**:从设备点连到监测点标记中心(票 04/12 的面板锚点)。
  1892. *
  1893. * 用一条独立 polyline 实体画在同一个数据源上(`lead|...` 前缀,不参与设备拾取判定)。
  1894. * 重合时调用方不该调本函数(`planDeviceLead().draw === false`),所以这里只负责画。
  1895. */
  1896. export function syncDeviceLead(viewer, { deviceLon, deviceLat, pointLon, pointLat, deviceCode, pointCode } = {}) {
  1897. const source = deviceMarkerSource(viewer)
  1898. // 先清掉上一条引线(同一时刻最多一条:只画**当前选中**那台的归属)。
  1899. for (const entity of source.entities.values.slice()) {
  1900. if (typeof entity.id === 'string' && entity.id.startsWith('lead|')) source.entities.remove(entity)
  1901. }
  1902. if (![deviceLon, deviceLat, pointLon, pointLat].every(Number.isFinite)) return false
  1903. source.entities.add({
  1904. id: `lead|${pointCode}|${deviceCode}`,
  1905. polyline: {
  1906. positions: Cartesian3.fromDegreesArray([deviceLon, deviceLat, pointLon, pointLat]),
  1907. width: 2,
  1908. // 引线是**归属指示**,不用色键(那是设备状态语义);用中性白并压半透明。
  1909. material: Color.WHITE.withAlpha(0.7),
  1910. // 贴地线会随地形起伏扭动,引线要的是"直连"语义,所以不贴地。
  1911. clampToGround: false,
  1912. },
  1913. })
  1914. return true
  1915. }
  1916. /** 清空选中引线(取消选中 / 返回点位 / 图层撤下)。 */
  1917. export function clearDeviceLead(viewer) {
  1918. const source = deviceSources.get(viewer) || (viewer && viewer.dataSources ? viewer.dataSources.getByName(DEVICE_MARKER_SOURCE)[0] : null)
  1919. if (!source) return false
  1920. for (const entity of source.entities.values.slice()) {
  1921. if (typeof entity.id === 'string' && entity.id.startsWith('lead|')) source.entities.remove(entity)
  1922. }
  1923. return true
  1924. }
  1925. /**
  1926. * 屏幕坐标 → 设备标记身份(**hover 用**,与 `pickDeviceMarkerAt` 同一条判据)。
  1927. *
  1928. * 单独一个函数只是为了让 hover 的调用点读起来是"我在做 hover 拾取",
  1929. * 实现完全复用点选那一个(**不另写一套判据** —— 两套迟早会分歧)。
  1930. */
  1931. export function pickDeviceMarkerForHover(viewer, windowX, windowY) {
  1932. return pickDeviceMarkerAt(viewer, windowX, windowY)
  1933. }
  1934. /**
  1935. * 强调一批编码对应的地图实体(票 14:点事件详情时高亮关联设备与关联管线)。
  1936. *
  1937. * 只调**已存在实体**的显示参数,不新增实体 —— 事件因此永远不会变成独立地图图层
  1938. * (票 13 的硬纪律)。两处来源都扫:
  1939. * 1. 点位标记数据源(票 09):按实体属性 `pointCode` / `deviceCodes` 匹配;
  1940. * 2. 管线数据源(**票 22 才交付**):按 `pipelineCode` / `deviceCode` 匹配。
  1941. * 管线层还没落地时这一处自然扫到 0 个,不需要开关,也不会报错 ——
  1942. * 票 22 落地的管线只要带这两个属性之一,本函数就自动把它一起高亮。
  1943. *
  1944. * **传空数组 = 还原全部**(详情收起时用):初值存在 `emphasisBase` 里,还原是把
  1945. * 上次记下的原值写回去,而不是"猜一个默认宽度" —— 否则收起详情后点位标记会一直胖着、
  1946. * 管线的线宽也回不到原样。
  1947. *
  1948. * @param {Viewer} viewer
  1949. * @param {Array<string>} codes 要高亮的编码(点位码 / 设备码 / 管线码);空数组表示还原
  1950. * @returns {number} 本次被强调的实体数(0 = 目标还不在图上,调用方据此说人话)
  1951. */
  1952. export function highlightEntities(viewer, codes) {
  1953. if (!viewer || !viewer.dataSources) return 0
  1954. const wanted = new Set((Array.isArray(codes) ? codes : [codes]).map((item) => String(item || '').trim()).filter(Boolean))
  1955. let matched = 0
  1956. const devices = deviceSources.get(viewer) || viewer.dataSources.getByName(DEVICE_MARKER_SOURCE)[0]
  1957. for (const source of [pointMarkerSource(viewer), pipelineSource(viewer), devices]) {
  1958. if (!source) continue
  1959. for (const entity of source.entities.values) {
  1960. const properties = (entity && entity.properties) || {}
  1961. const read = (key) => (properties[key] && properties[key].getValue ? properties[key].getValue() : '')
  1962. const list = read('deviceCodes')
  1963. const candidates = [
  1964. read('pointCode'),
  1965. read('pipelineCode'),
  1966. read('deviceCode'),
  1967. ...(Array.isArray(list) ? list : []),
  1968. ]
  1969. // 每个实体都过判定:命中与**未命中**都要写回值,后者即"还原"。
  1970. const hit = shouldEmphasize(candidates, wanted)
  1971. if (hit) matched += 1
  1972. const base = hit ? rememberEmphasisBase(entity) : restoreEmphasisBase(entity)
  1973. if (entity.billboard) entity.billboard.scale = hit ? EMPHASIS_STYLE.markerScale : base.billboardScale
  1974. if (entity.polyline) entity.polyline.width = hit ? EMPHASIS_STYLE.pipelineWidth : base.polylineWidth
  1975. }
  1976. }
  1977. return matched
  1978. }
  1979. /** 强调前的原值(按实体缓存:还原要有据可依,不能猜默认值)。 */
  1980. const emphasisBase = new WeakMap()
  1981. function rememberEmphasisBase(entity) {
  1982. if (!emphasisBase.has(entity)) {
  1983. emphasisBase.set(entity, {
  1984. billboardScale: entity.billboard ? entity.billboard.scale : 1,
  1985. polylineWidth: entity.polyline ? entity.polyline.width : undefined,
  1986. })
  1987. }
  1988. return emphasisBase.get(entity)
  1989. }
  1990. function restoreEmphasisBase(entity) {
  1991. const base = emphasisBase.get(entity)
  1992. emphasisBase.delete(entity)
  1993. return base || { billboardScale: 1, polylineWidth: undefined }
  1994. }
  1995. /** 管线数据源(票 22 交付时才存在;没有就回 null,不当成错误)。 */
  1996. export function pipelineSource(viewer) {
  1997. if (!viewer || !viewer.dataSources) return null
  1998. return viewer.dataSources.getByName(PIPELINE_SOURCE)[0] || null
  1999. }
  2000. /** Currently loaded terrain height; null means no reliable surface sample yet. */
  2001. export function groundHeightAt(viewer, lon, lat) {
  2002. const globe = viewer && viewer.scene && viewer.scene.globe
  2003. if (!globe || !Number.isFinite(lon) || !Number.isFinite(lat)) return null
  2004. const height = globe.getHeight(Cartographic.fromDegrees(lon, lat))
  2005. return Number.isFinite(height) ? height : null
  2006. }
  2007. export function hasPointMarker(viewer, kind, code) {
  2008. if (!viewer || !code) return false
  2009. const source = markerSources.get(viewer) || viewer.dataSources.getByName(POINT_MARKER_SOURCE)[0]
  2010. return !!(source && source.show && source.entities.getById(`point|${kind}|${code}`))
  2011. }
  2012. /**
  2013. * 把**渲染中的**贴地标记中心投到窗口坐标(票 41 的锚定锚点)。
  2014. *
  2015. * 锚点必须与**眼睛看到的那个标记**一致,所以高度取值顺序照 Cesium 画 billboard 的实际情况:
  2016. * 1. 地形已有该处瓦片 → `globe.getHeight()` 给的实测高程(贴地后的真实中心);
  2017. * 2. 瓦片还没加载/无地形 → **椭球面**(Cesium 的 `CLAMP_TO_GROUND` 在无地形数据时正是画在椭球面上)。
  2018. *
  2019. * 第 2 条是**真机走查抓到的缺陷**:早先写成「配了地形就必须取到高度,取不到回 null」,
  2020. * 于是相机一飞到尚未加载瓦片的区域,`getHeight` 返回 undefined,锚定被误判成"标记离屏"、
  2021. * 整块面板消失 —— 而画面上那个标记明明还看得见。锚点判据不能比"可见性"本身更严。
  2022. *
  2023. * 可见性只用 Cesium **公开**判据:相机前方(`directionWC` 点积)+ 地球遮挡(`EllipsoidalOccluder`),
  2024. * 再交给 `SceneTransforms` 投影;不做第二套相机矩阵手算。
  2025. */
  2026. export function projectPointMarker(viewer, kind, code) {
  2027. if (!viewer || !viewer.scene || !viewer.canvas || !code) return null
  2028. const source = markerSources.get(viewer) || viewer.dataSources.getByName(POINT_MARKER_SOURCE)[0]
  2029. if (!source || !source.show) return null
  2030. const entity = source.entities.getById(`point|${kind}|${code}`)
  2031. return projectMarkerEntity(viewer, entity)
  2032. }
  2033. /** 关联设备锚向实际绘制的设备实体,不回退到父监测点。 */
  2034. export function hasDeviceMarker(viewer, code, pointCode = '') {
  2035. return Boolean(findDeviceMarker(viewer, code, pointCode))
  2036. }
  2037. export function projectDeviceMarker(viewer, code, pointCode = '') {
  2038. return projectMarkerEntity(viewer, findDeviceMarker(viewer, code, pointCode))
  2039. }
  2040. function findDeviceMarker(viewer, code, pointCode) {
  2041. if (!viewer || !code) return null
  2042. const source = deviceSources.get(viewer) || viewer.dataSources.getByName(DEVICE_MARKER_SOURCE)[0]
  2043. if (!source || !source.show) return null
  2044. return source.entities.values.find((entity) => {
  2045. const properties = entity.properties || {}
  2046. return properties.deviceCode && properties.deviceCode.getValue() === code
  2047. && (!pointCode || (properties.monitoringPointCode && properties.monitoringPointCode.getValue() === pointCode))
  2048. && entity.show !== false
  2049. }) || null
  2050. }
  2051. /** 贴地投影与屏幕分离共用一份口径,返回视觉中心及当前图标尺寸。 */
  2052. function projectMarkerEntity(viewer, entity) {
  2053. if (!viewer || !viewer.scene || !viewer.canvas || !entity || !entity.isShowing || !entity.position || !entity.billboard) return null
  2054. const original = entity.position.getValue(viewer.clock.currentTime)
  2055. if (!original) return null
  2056. const ellipsoid = viewer.scene.globe.ellipsoid
  2057. const carto = ellipsoid.cartesianToCartographic(original)
  2058. if (!carto) return null
  2059. let height = 0
  2060. const sampled = viewer.scene.globe.getHeight(carto)
  2061. if (Number.isFinite(sampled)) height = sampled
  2062. const world = Cartesian3.fromRadians(carto.longitude, carto.latitude, height)
  2063. const camera = viewer.camera
  2064. const toward = Cartesian3.subtract(world, camera.positionWC, new Cartesian3())
  2065. const occluder = new EllipsoidalOccluder(ellipsoid, camera.positionWC)
  2066. if (Cartesian3.dot(toward, camera.directionWC) <= 0 || !occluder.isPointVisible(world)) return null
  2067. const at = SceneTransforms.worldToWindowCoordinates(viewer.scene, world)
  2068. if (!at || !Number.isFinite(at.x) || !Number.isFinite(at.y)) return null
  2069. const rect = viewer.canvas.getBoundingClientRect()
  2070. const time = viewer.clock.currentTime
  2071. const read = (property, fallback) => property && property.getValue ? property.getValue(time) : fallback
  2072. const offset = read(entity.billboard.pixelOffset, { x: 0, y: 0 })
  2073. const scale = read(entity.billboard.scale, 1)
  2074. const iconSize = Math.max(read(entity.billboard.width, 18), read(entity.billboard.height, 18)) * scale
  2075. return { x: at.x + rect.left + offset.x, y: at.y + rect.top + offset.y, iconSize }
  2076. }
  2077. /**
  2078. * 屏幕坐标 → **点位标记身份**(票 10 的地图点选)。
  2079. *
  2080. * 三件事必须说清:
  2081. * 1. 只认点位标记数据源上的实体 —— 拾到地形、影像、3D Tiles 或空白一律回 `null`
  2082. * (不是「拾到什么就打开详情」);判据是实体属性里有 `pointCode`;
  2083. * 2. 坐标要吃**画布坐标**,而事件给的是窗口坐标:`scene.pick` 用的是画布内部坐标系,
  2084. * 所以先减去画布原点(票 03 的 `projectPivot()` 踩过同一个坑,那里是加、这里是减);
  2085. * 3. 回的是**标记身份**而不是 Cesium 实体:引擎对象不出这一层,上层只认 kind/code/status
  2086. * (与 `syncPointMarkers` 只认 shape/tone 是同一条纪律)。
  2087. *
  2088. * @returns {{kind: string, code: string, status: string}|null}
  2089. */
  2090. export function pickPointMarkerAt(viewer, windowX, windowY) {
  2091. if (!viewer || !viewer.scene || !viewer.canvas || typeof viewer.scene.pick !== 'function') return null
  2092. const rect = viewer.canvas.getBoundingClientRect()
  2093. const picked = pickThroughPipelines(viewer, new Cartesian2(windowX - rect.left, windowY - rect.top))
  2094. const entity = picked && picked.id
  2095. const properties = entity && entity.properties
  2096. if (!properties || !properties.pointCode) return null
  2097. const read = (key) => (properties[key] ? properties[key].getValue() : '')
  2098. const code = read('pointCode')
  2099. if (!code) return null
  2100. return { kind: read('pointKind'), code, status: read('pointStatus'), name: read('pointName') || '' }
  2101. }
  2102. /**
  2103. * 点位标记 hover 拾取(批次 3:给「hover 显示完整名称」的通用 tooltip 链路用)。
  2104. * 与 `pickPointMarkerAt` 同一实体源,但**额外带 name** 且不参与点选竞态。
  2105. */
  2106. export function pickPointMarkerForHover(viewer, windowX, windowY) {
  2107. return pickPointMarkerAt(viewer, windowX, windowY)
  2108. }
  2109. /**
  2110. * 经纬度 → 画布窗口坐标(批次 3 第五轮,DOM overlay 的 3D 定位入口)。
  2111. * @returns {{x: number, y: number}|null} 相对画布左上角;未就绪/不可投影返回 null
  2112. */
  2113. export function projectPointToWindow(viewer, longitude, latitude) {
  2114. if (!viewer || !viewer.scene) return null
  2115. const world = Cartesian3.fromDegrees(longitude, latitude, 0)
  2116. const at = SceneTransforms.worldToWindowCoordinates(viewer.scene, world)
  2117. if (!at || !Number.isFinite(at.x) || !Number.isFinite(at.y)) return null
  2118. return { x: at.x, y: at.y }
  2119. }
  2120. /**
  2121. * 相机契约的 Cesium 适配(票 03)。返回 services/cameraControl.js 期望的引擎接口;
  2122. * 相机语义(三键怎么算、中心是谁)在 domain/camera.js 与 cameraControl.js,本函数只翻译方言。
  2123. * @param {Viewer} viewer
  2124. */
  2125. export function createCameraEngine(viewer) {
  2126. const scene = viewer.scene
  2127. const camera = viewer.camera
  2128. let frameHandle = null
  2129. /**
  2130. * 经纬高 → 该点东北天局部系(弹簧臂原语要求中心是局部地表点)。
  2131. *
  2132. * 极点保护:`eastNorthUpToFixedFrame` 内部用 `east = up × north` 求东向,
  2133. * 纬度到 ±90 时 up 与 north 共线、叉积退化为零向量,局部系不再是正交基,
  2134. * `lookAtTransform` 拿它当参照就会出现「整个地球乱转」。
  2135. * 域层已把中心纬度夹在 ±POLAR_LAT_LIMIT 内(见 domain/camera.js),
  2136. * 这里是引擎侧的第二道防线:任何调用方直接喂端点纬度也不会把参照系搞坏。
  2137. */
  2138. function frameAt(pivot) {
  2139. const lat = Math.min(89.9, Math.max(-89.9, pivot.lat))
  2140. return Transforms.eastNorthUpToFixedFrame(Cartesian3.fromDegrees(pivot.lon, lat, pivot.height || 0))
  2141. }
  2142. return {
  2143. applyView(pivot, hpr) {
  2144. camera.lookAtTransform(
  2145. frameAt(pivot),
  2146. new HeadingPitchRange(hpr.heading * DEG, hpr.pitch * DEG, hpr.range),
  2147. )
  2148. },
  2149. /** 反读当前相机相对该中心的弹簧臂参数,用于挂载时对齐真实相机(避免首次交互跳变)。 */
  2150. readView(pivot) {
  2151. const inverse = Matrix4.inverseTransformation(frameAt(pivot), new Matrix4())
  2152. const local = Matrix4.multiplyByPoint(inverse, camera.positionWC, new Cartesian3())
  2153. const range = Cartesian3.magnitude(local)
  2154. if (!(range > 0)) return { heading: 0, pitch: -60, range: 1 }
  2155. const heading = CesiumMath.toDegrees(Math.atan2(local.x, local.y))
  2156. const pitch = -CesiumMath.toDegrees(Math.asin(Math.min(1, Math.max(-1, local.z / range))))
  2157. return { heading: (heading + 360) % 360, pitch, range }
  2158. },
  2159. /**
  2160. * 屏幕中心的地面拾取点(票 21/30 改进):**优先按地形拾取**,拿不到高度再退回椭球。
  2161. *
  2162. * 为什么必须按地形:纯 `pickEllipsoid` 给的是椭球面交点(高程 0),而沅陵县城地面高程
  2163. * 两百多米起。用它当中心,弹簧臂 range 一降到几百米,相机就落进山体内部 —— 实测 range 120 m
  2164. * 时整屏全黑(票 21 走查记录)。地形就绪后用 `globe.getHeight` 把中心抬到真实地表,
  2165. * 相机高度随之正确,近景才不会钻进山里。
  2166. */
  2167. pickGroundAtCenter() {
  2168. const canvas = scene.canvas
  2169. const at = new Cartesian2(canvas.clientWidth / 2, canvas.clientHeight / 2)
  2170. const ellipsoid = (scene.globe && scene.globe.ellipsoid) || Ellipsoid.WGS84
  2171. const hit = camera.pickEllipsoid(at, ellipsoid)
  2172. if (!hit) return null
  2173. const carto = ellipsoid.cartesianToCartographic(hit)
  2174. const lon = CesiumMath.toDegrees(carto.longitude)
  2175. const lat = CesiumMath.toDegrees(carto.latitude)
  2176. // 地形就绪时把中心抬到真实地表;取样不到就保持 0(等价于旧行为,不会更糟)。
  2177. let height = 0
  2178. const globe = scene.globe
  2179. if (globe && typeof globe.getHeight === 'function') {
  2180. const sampled = globe.getHeight(carto)
  2181. if (Number.isFinite(sampled)) height = sampled
  2182. }
  2183. return { lon, lat, height }
  2184. },
  2185. /**
  2186. * 中心点投影到**窗口坐标**(与 `getBoundingClientRect()` 同一坐标系)。
  2187. *
  2188. * 实测 `SceneTransforms.worldToWindowCoordinates()` 返回的是**相对画布左上角**的值
  2189. * (画布在壳里下移了一段,直接用会与 `getBoundingClientRect()` 差出一个画布原点偏移,
  2190. * 走查里表现为「中心点没落在画布中心」的假失败)。这里显式加上画布原点,
  2191. * 让消费方拿到的就是窗口坐标,别再靠语义猜。
  2192. */
  2193. projectPivot(pivot) {
  2194. const at = SceneTransforms.worldToWindowCoordinates(scene, Cartesian3.fromDegrees(pivot.lon, pivot.lat, pivot.height || 0))
  2195. if (!at) return null
  2196. const rect = viewer.canvas.getBoundingClientRect()
  2197. return { x: at.x + rect.left, y: at.y + rect.top }
  2198. },
  2199. /**
  2200. * **相机正下方**的地形高度(米),取不到返回 null(票 37 的防入地输入)。
  2201. *
  2202. * 为什么是「相机正下方」而不是「pivot 下方」:防入地要保证的是**相机自己**别钻进山体。
  2203. * 相机在 pivot 沿 heading/pitch 退开 range 的位置上,两者可能隔着几百米、翻过一个山脊,
  2204. * 用 pivot 的地形判断会在斜坡上误判。
  2205. */
  2206. groundHeightBelowCamera() {
  2207. const globe = scene.globe
  2208. if (!globe || typeof globe.getHeight !== 'function') return null
  2209. try {
  2210. const carto = globe.ellipsoid.cartesianToCartographic(camera.positionWC)
  2211. if (!carto) return null
  2212. const sampled = globe.getHeight(carto)
  2213. return Number.isFinite(sampled) ? sampled : null
  2214. } catch (error) {
  2215. return null
  2216. }
  2217. },
  2218. /** 相机当前**绝对**高度(米,椭球高),取不到返回 null。防入地的另一半输入。 */
  2219. cameraHeight() {
  2220. try {
  2221. const carto = scene.globe.ellipsoid.cartesianToCartographic(camera.positionWC)
  2222. return carto && Number.isFinite(carto.height) ? carto.height : null
  2223. } catch (error) {
  2224. return null
  2225. }
  2226. },
  2227. inputTarget() {
  2228. return viewer.canvas
  2229. },
  2230. viewport() {
  2231. return { width: scene.canvas.clientWidth, height: scene.canvas.clientHeight }
  2232. },
  2233. fovY() {
  2234. return camera.frustum.fovy || 60 * DEG
  2235. },
  2236. now() {
  2237. return typeof performance !== 'undefined' ? performance.now() : Date.now()
  2238. },
  2239. requestFrame(callback) {
  2240. frameHandle = requestAnimationFrame(callback)
  2241. },
  2242. }
  2243. }