cesiumEngine.js 140 KB

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