cesiumEngine.js 134 KB

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