mapService.js 167 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525
  1. /**
  2. * 地图服务:驾驶舱「中央一张图」实例生命周期的唯一所有者。
  3. *
  4. * 为什么要有这一层(ADR 0004 / 0005、票 02、票 27):
  5. * 页面组件不得直接持有底层地图实现,地图实例的创建/尺寸/销毁必须集中在一处,
  6. * 才能保证「切换一级模式或收放面板时不销毁、不重建」。
  7. *
  8. * 票 27 起内部接了真实引擎(Cesium):
  9. * · `mountMap(host)` 幂等,且只对**真 DOM 宿主要素**异步加载引擎(`viewer` 建在宿主内);
  10. * · `resizeMap()` 转调 `viewer.resize()`、`unmountMap()` 转调 `viewer.destroy()`;
  11. * · 引擎没加载完就销毁 / 销毁后才加载完,都不会泄漏实例;
  12. * · 底图未配置时如实标注「底图未配置」,引擎起不来时标注失败 —— 组件里的临时影像继续兜底,
  13. * 页面不白屏。
  14. * 票 03 起相机也归本层:三键契约的实现(domain/camera.js + cameraControl.js)在引擎就绪后挂上,
  15. * 对外只暴露 `setCameraFocus()` / `clearCameraFocus()` / `getCameraState()`,
  16. * 组件与列表不需要知道 Cesium 方言;契约见 ADR 0008。
  17. * 接入配方见决策地图调研:`.scratch/web-cockpit-decision-map/research/cesium-fes-vite.md`。
  18. *
  19. * 本模块不依赖 Vue;对宿主元素的 DOM 操作都有 `isDomHost` 守卫,
  20. * 因此仍可脱离浏览器直接断言(见 verify/t01-panel-catalog.mjs)。
  21. */
  22. import { ensureCesiumBaseUrl } from './cesiumAssets.js'
  23. import { getCockpitConfig, loadCockpitConfig } from './cockpitConfig.js'
  24. import { attachCameraInput, createCameraControl } from './cameraControl.js'
  25. import { attachPointPick } from './pointPick.js'
  26. import { formatCameraState, INITIAL_PANORAMA, RETURN_FLIGHT_MS, SETTLE_EPSILON } from '../domain/camera.js'
  27. import { BASEMAP_STATE, basemapStatusText, planBasemap } from '../domain/basemap.js'
  28. import { isAtHome, planHome, planIntro } from '../domain/viewPresets.js'
  29. import { isStaleLocate, planLocateView } from '../domain/pointLocate.js'
  30. import {
  31. planDeviceLead,
  32. planDeviceReactions,
  33. reactionStyle,
  34. } from '../domain/deviceCorrespondence.js'
  35. import {
  36. fanLayout,
  37. groupOverlappingDevices,
  38. OVERLAP_STATE,
  39. resolveOverlapPick,
  40. transitionOverlapState,
  41. } from '../domain/overlapDevices.js'
  42. import { MARKER_SIZE, planDeviceAsset } from '../domain/deviceAssets.js'
  43. // 批次 3:位置符号合成计划层(规格 10 的唯一新接缝)与母版合成渲染。
  44. import { planMarker, planToDataset } from '../domain/markerPlan.js'
  45. import { renderMarker } from './markerAssets.js'
  46. // 批次 3 第五轮(票 20):DOM overlay 渲染层(billboard 发糊的结构性解决)。
  47. import { mountMarkerOverlay, attachOverlayRenderer, unmountMarkerOverlay } from './markerOverlay.js'
  48. import { resolveAsset } from '../domain/runtimeAssets.js'
  49. import { DEVICE_LAYER_KIND } from '../domain/deviceLayer.js'
  50. import { deviceVisualForStatus } from '../domain/deviceStatus.js'
  51. import { isRenderable } from '../domain/coordinate.js'
  52. import { VIEW_MODES, planViewMode, viewHprFor } from '../domain/viewModes.js'
  53. import { TERRAIN_STATE, planOverlays, planTerrain, planTilesets, terrainStatusText } from '../domain/mapLayers.js'
  54. // 票 46:`planPointMarkers` 不再在服务层调用(模式过滤归 `domain/modeLayerPlan.js`),
  55. // 但 `summarizeMarkerStatuses` 仍需要 —— 计划没带 byStatus 时兜底现算,保证图例同源。
  56. import { markerSvg, pointLegend, summarizeMarkerStatuses, haloSvg, glowSvg } from '../domain/pointStatus.js'
  57. import { planPrimaryArea, primaryAreaStatusText } from '../domain/primaryArea.js'
  58. import {
  59. AREA_STATE,
  60. DEFAULT_AREA_STATE,
  61. planAreaLimits,
  62. transitionAreaState,
  63. } from '../domain/areaLimits.js'
  64. import {
  65. FOG_DEGRADE,
  66. FOG_PHASE,
  67. beginFogTransition,
  68. createFogLifecycle,
  69. enterAltitudeStrength,
  70. approachAltitudeFactor,
  71. fogAltitudeFactor,
  72. fogReadout,
  73. parseHexColor,
  74. planFog,
  75. planFogDegrade,
  76. stepFogLifecycle,
  77. } from '../domain/fog.js'
  78. // 一张图调色(科技蓝滤镜):计划归 domain/sceneGrade.js,引擎只照计划建处理级。
  79. import { planSceneGrade } from '../domain/sceneGrade.js'
  80. import { attachModelDiagnostics } from './modelDiagnostics.js'
  81. import {
  82. HANDOVER_BLOCK,
  83. HANDOVER_PHASE,
  84. createHandoverLifecycle,
  85. beginHandover,
  86. edgeOcclusionVerdict,
  87. handoverReadout,
  88. planHandover,
  89. revealGate,
  90. returnSyncVerdict,
  91. stepHandover,
  92. } from '../domain/imageryHandover.js'
  93. const ENGINE_TEXT = {
  94. unavailable: 'Cesium 引擎未启动(无宿主要素)',
  95. booting: 'Cesium 引擎加载中…',
  96. // ready 的措辞按底图计划生成(票 29):未配置时保持票 27 的原话。
  97. ready: 'Cesium 引擎已就绪 · 底图未配置(正式底图见票 21)',
  98. failed: 'Cesium 引擎初始化失败 · 中央区域回退临时底图',
  99. disabled: 'Cesium 引擎已由部署配置关闭 · 中央区域用临时底图',
  100. }
  101. // 状态浮标由服务层自绘并挂在宿主内:外壳与 MapCanvas 组件按票面要求不改。
  102. //
  103. // 票 25 修订:这两个节点**不再自绘浮标**——它们被 `MapInfoPanel` 搬进右下角/右上角的
  104. // 状态日志集合里(`MapCanvas` 的 `relocateEngineNode()`),所以样式改为**布局中性**:
  105. // 不自己定位、不限宽、**不截断**。
  106. // 原先的 `white-space:nowrap + overflow:hidden + text-overflow:ellipsis`
  107. // 会把「Cesium 引擎已就绪 · 主场景区 · 底图 · 地形 · 叠加影像 · 3D Tiles …」
  108. // 从中间切断(维护者 2026-10-10 走查反馈:「左右显示不完整,信息被部分切除」),
  109. // 而被切的恰恰是排障最需要的那一整句。两种形态现在只差**配色**,不再差布局。
  110. const STATUS_STYLE_BASE = [
  111. 'position:static',
  112. 'pointer-events:none',
  113. // 换行而不是省略:宁可多占一行,也不能少说一句。
  114. 'white-space:normal',
  115. 'overflow:visible',
  116. 'overflow-wrap:anywhere',
  117. 'line-height:1.45',
  118. 'letter-spacing:.03em',
  119. 'font-size:var(--fs-caption, 10.5px)',
  120. ].join(';')
  121. /** 调试形态:带边框底色,便于开发期定位。 */
  122. const STATUS_STYLE = `${STATUS_STYLE_BASE};padding:3px 8px;border:1px solid rgba(1,171,234,.42);background:rgba(0,20,72,.72);color:#bad4e0`
  123. /** 生产形态:无边框无底色、半透明小字,让位于 3D 场景(颜色语义仍然保留)。 */
  124. const STATUS_STYLE_QUIET = `${STATUS_STYLE_BASE};padding:0;color:rgba(186,212,224,.62)`
  125. /** 当前是否走「调试形态」:配置显式给布尔值就听配置,否则跟随构建模式(生产构建自动收敛)。 */
  126. function isUiDebug() { const mapConfig = getCockpitConfig().map || {}
  127. const configured = mapConfig.ui ? mapConfig.ui.debug : null
  128. if (typeof configured === 'boolean') return configured
  129. return !!(import.meta.env && import.meta.env.DEV)
  130. }
  131. /**
  132. * 票 47:设备状态动效的**解析后**选项(决策票 07 问题 6)。
  133. *
  134. * 与票 32 的 `isUiDebug()` 完全同构:配置显式给布尔值就听配置,
  135. * 否则跟随构建模式(开发构建开、生产构建关 —— 正式环境不该有动画压满屏)。
  136. * 门控判定本身仍在 domain(`planDeviceEffects`),这里只解析"该不该开、上限多少"。
  137. */
  138. export function resolveDeviceEffectsOptions() {
  139. const mapConfig = getCockpitConfig().map || {}
  140. const effects = mapConfig.effects || {}
  141. const configured = effects.enabled
  142. const enabled = typeof configured === 'boolean' ? configured : !!(import.meta.env && import.meta.env.DEV)
  143. const limit = Number.isInteger(effects.abnormalLimit) && effects.abnormalLimit >= 1 ? effects.abnormalLimit : 30
  144. return { enabled, abnormalLimit: limit }
  145. }
  146. let instance = null
  147. let sequence = 0
  148. let createdCount = 0
  149. let destroyedCount = 0
  150. let resizeCount = 0
  151. let lastResizeAt = ''
  152. /**
  153. * 「想要的」自转时长(秒;0 = 不要自转)。
  154. *
  155. * 与「正在转的循环」(`instance.spin`)分开:相机在飞行/阻尼时起不了转,
  156. * 但意图要留住,等落定后由 `resumeSpinIfWanted()` 接上。
  157. * **不能**用一个"暂停"粘滞开关代替 —— 那种开关没人负责清,就会把自转永久哑掉
  158. * (维护者 2026-09-30 报的「回引导态地球不再旋转」正是这个)。
  159. */
  160. let spinWanted = 0
  161. let engineState = 'idle' // idle | unavailable | booting | ready | failed | disabled
  162. let engineError = ''
  163. // 底图状态由配置计划决定(票 29):not-configured | natural-earth | xyz | tianditu | invalid
  164. let basemapState = BASEMAP_STATE.NOT_CONFIGURED
  165. let basemapText = basemapStatusText(null)
  166. // 票 21:地形与叠加影像的状态(none / ready / invalid),叠加层数单独记。
  167. let terrainState = TERRAIN_STATE.NONE
  168. let terrainText = terrainStatusText(null)
  169. let terrainError = ''
  170. let overlayCount = 0
  171. // 票 25:数据来源署名。Cesium 的署名容器已被换成离屏容器(cesiumEngine.js),
  172. // 所以**自有数据源的署名改由地图信息面板展示**,这里只负责从同一批计划里取。
  173. let dataSourceCredits = []
  174. // 票 21:3D Tiles / 倾斜摄影(入口先留好,本机数据缺根 tileset.json 时如实报失败)
  175. let tilesetReady = 0
  176. let tilesetFailed = []
  177. // 票 36:主场景区计划(限位与自建雾的开关与矩形都在这里)。**引擎未就绪时也能读** ——
  178. // 它由配置单方面决定,不依赖 Cesium;票 37/38 的入口直接消费它。
  179. // 初值就是「未配置」:还没读到配置时不得凭空开出一个区域。
  180. let primaryAreaPlan = planPrimaryArea({})
  181. let primaryAreaText = primaryAreaStatusText(primaryAreaPlan)
  182. // 配置是否已经读到(`applyPrimaryArea` 置 true)。初值 false 时**浮标不写主场景区那一句**:
  183. // 那时「未配置」只是本模块的初值,不是核实的结论 —— 写出去等于替部署宣布一个还没核实的事实。
  184. let primaryAreaResolved = false
  185. // 票 37:区域状态(与 filterStore.areaState 同值;服务层持有是为了给相机控件下限位)。
  186. // 初值 global:进区只能由「进入系统 / 归位」触发(ADR 0009 的显式模式锁),不随相机位置自动切。
  187. let areaState = DEFAULT_AREA_STATE
  188. // 票 37:当前生效的区域限位计划(只读出口给走查与排查)。
  189. let areaLimits = planAreaLimits({ plan: primaryAreaPlan, areaState, homeRange: null })
  190. // 票 21:当前视图条件(ground / pipeline),由 setViewMode() 维护。
  191. let viewMode = VIEW_MODES.GROUND
  192. // 票 09:点位标记的最近一次计划与结果(上图层数 + 跳过的脏数据数),供 dataset 与走查读。
  193. let markerPlan = { markers: [], blocked: [], byStatus: {}, total: 0 }
  194. let markerRendered = 0
  195. let markerSkipped = 0
  196. // 票 42:关联设备临时图层的最近一次计划与结果(与摘要标记**分开**计数,互不污染)。
  197. let deviceRows = []
  198. let deviceRendered = 0
  199. let deviceSkipped = 0
  200. // 票 45:当前图层的重叠聚合计划与交互状态(只持渲染状态,不写真实位置)。
  201. let overlapGroups = []
  202. let overlapMaxFan = 10
  203. let overlapPreviewGroup = ''
  204. let overlapExpandedGroup = ''
  205. let overlapState = OVERLAP_STATE.COLLAPSED
  206. let overlapCountValue = 0
  207. const mapFrameHandlers = new Set()
  208. /** 票 41:相机状态变化的订阅者(锚定跟随的第二条触发路径,见 `onStateChange` 处的注释)。 */
  209. const cameraStateHandlers = new Set()
  210. export function onMapFrame(handler) {
  211. if (typeof handler !== 'function') return () => {}
  212. mapFrameHandlers.add(handler)
  213. return () => mapFrameHandlers.delete(handler)
  214. }
  215. /** 订阅相机状态变化(返回退订函数)。与 `onMapFrame` 互补:后者只在真的渲染时触发。 */
  216. export function onCameraStateChange(handler) {
  217. if (typeof handler !== 'function') return () => {}
  218. cameraStateHandlers.add(handler)
  219. return () => cameraStateHandlers.delete(handler)
  220. }
  221. /** 通知一次相机状态变化(相机输入的 `onStateChange` 与程序化下发预设都走它)。 */
  222. function notifyCameraStateChange() {
  223. const state = instance && instance.cameraControl ? instance.cameraControl.getState() : null
  224. // 飞行落定后把"想要的"自转接上 —— 回引导态那条路靠它兑现(见 `spinWanted`)。
  225. resumeSpinIfWanted(state)
  226. for (const handler of cameraStateHandlers) handler(state)
  227. }
  228. export function getMapCanvasRect() {
  229. if (!instance || !instance.viewer) return null
  230. const rect = instance.viewer.canvas.getBoundingClientRect()
  231. return { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom }
  232. }
  233. export function hasMapMarker(kind, code, pointCode = '') {
  234. if (!instance || !instance.engine || !instance.viewer) return false
  235. return kind === DEVICE_LAYER_KIND
  236. ? instance.engine.hasDeviceMarker(instance.viewer, code, pointCode)
  237. : instance.engine.hasPointMarker(instance.viewer, kind, code)
  238. }
  239. export function getMapMarkerAnchor(kind, code, pointCode = '') {
  240. if (!instance || !instance.engine || !instance.viewer || !code) return null
  241. const anchor = kind === DEVICE_LAYER_KIND
  242. ? instance.engine.projectDeviceMarker(instance.viewer, code, pointCode)
  243. : instance.engine.projectPointMarker(instance.viewer, kind, code)
  244. // 展开环期间把详情窗让到环外,不让窗体截住设备之间的鼠标移动。
  245. const group = overlapGroups.find((item) => item.rows.some((row) => row.code === code))
  246. return anchor && kind === DEVICE_LAYER_KIND && group &&
  247. (overlapExpandedGroup === group.key || overlapPreviewGroup === group.key)
  248. ? { ...anchor, panelGap: 60 } : anchor
  249. }
  250. let pointLocateToken = 0
  251. /** Invalidate a pending terrain sample before another camera command wins. */
  252. export function cancelPointLocate() {
  253. pointLocateToken += 1
  254. }
  255. /** Full monitoring-point view, distinct from setCameraFocus (non-point targets keep its old semantics). */
  256. export async function locateMonitoringPoint(object, { tier = 'point', centerOnly = false } = {}) {
  257. const token = ++pointLocateToken
  258. const record = instance
  259. if (!isRenderable(object) || !Number.isFinite(object.longitude) || !Number.isFinite(object.latitude)
  260. || !record || !record.cameraControl || !record.engine || !record.viewer) return false
  261. let height = await record.engine.sampleTerrainHeight(record.viewer, object.longitude, object.latitude)
  262. if (!Number.isFinite(height)) height = record.engine.groundHeightAt(record.viewer, object.longitude, object.latitude)
  263. // Only an explicitly absent terrain source makes the ellipsoid (height 0) a truthful fallback.
  264. if (!Number.isFinite(height) && terrainState === TERRAIN_STATE.NONE) height = 0
  265. if (isStaleLocate(token, pointLocateToken) || instance !== record || !Number.isFinite(height)) return false
  266. const pivot = { lon: object.longitude, lat: object.latitude, height }
  267. return centerOnly
  268. ? setCameraFocus(pivot)
  269. : applyViewPreset(pivot, planLocateView(getCockpitConfig().map, tier))
  270. }
  271. /** 只有真元素才做 DOM / 引擎操作;单测传普通对象时整条引擎链路都不启动。 */
  272. function isDomHost(host) {
  273. return !!host && typeof host === 'object' && host.nodeType === 1 && typeof host.appendChild === 'function'
  274. }
  275. /** 开发态判定(天地图走仓库自带的 `/tianditu.map` 代理,生产直连)。 */
  276. function isDev() {
  277. return !!(import.meta.env && import.meta.env.DEV)
  278. }
  279. /** 把引擎状态写进宿主(dataset 给走查读,浮标给人读)。 */
  280. function paintEngineState(record) {
  281. if (!isDomHost(record.host)) return
  282. record.host.dataset.mapEngine = engineState
  283. record.host.dataset.mapBasemap = basemapState
  284. record.host.dataset.mapTerrain = terrainState
  285. record.host.dataset.mapTilesets = tilesetFailed.length ? 'invalid' : tilesetReady > 0 ? 'ready' : 'none'
  286. // 票 21:视图条件从启动起就可观测(默认 ground),不是只有切换过才有。
  287. record.host.dataset.mapViewMode = viewMode
  288. // 票 36:主场景区的**生效状态与原因**同样是启动就可观测的读数(不是只有配了才有):
  289. // `data-map-primary-area` = ready / missing / invalid,`-reason` 保留关闭原因。
  290. // 票 37/38 的验收与人工排查都读这两行,而不是去猜浮标中文。
  291. record.host.dataset.mapPrimaryArea = primaryAreaPlan.state
  292. record.host.dataset.mapPrimaryAreaReason = primaryAreaPlan.enabled ? '' : primaryAreaPlan.reason
  293. // 票 32:形态在创建时定;`data-ui-mode` 让走查/排查一眼看出当前是哪一种。
  294. // 注意**要能改**:浮标在 `booting` 就建出来了,那时配置还没加载完(拿不到 map.ui.debug),
  295. // 所以配置到手后必须按当前形态重刷一次样式 —— 否则生产形态永远退回 dev 的样子。
  296. const debug = isUiDebug()
  297. const mode = debug ? 'debug' : 'production'
  298. if (!record.statusNode) {
  299. const node = record.host.ownerDocument.createElement('div')
  300. node.className = 'map-engine-status'
  301. node.setAttribute('data-map-engine-status', '')
  302. node.style.cssText = debug ? STATUS_STYLE : STATUS_STYLE_QUIET
  303. node.setAttribute('data-ui-mode', mode)
  304. record.host.appendChild(node)
  305. record.statusNode = node
  306. } else if (record.statusNode.dataset.uiMode !== mode) {
  307. record.statusNode.style.cssText = debug ? STATUS_STYLE : STATUS_STYLE_QUIET
  308. record.statusNode.dataset.uiMode = mode
  309. }
  310. const failed = engineState === 'failed'
  311. // 票 36:主场景区排在最前。它是**全局作用域**(限位与自建雾),不是某一图层的属性;
  312. // 更要紧的是这根浮标是 `nowrap + ellipsis`(`max-width: min(78%, 620px)`),
  313. // 关闭路径那句比底图那句长得多 —— 排在后面就会被尾省略掉,
  314. // 而「限位与雾气关了没、为什么」恰恰是这一行最不能丢的信息。
  315. //
  316. // 但**配置读到之前不许写这一句**:那时计划还是初值「未配置」,
  317. // 写在浮标上等于替部署宣布了一个还没核实的结论(`primaryAreaResolved` 守住这条)。
  318. const parts = primaryAreaResolved ? [primaryAreaText, basemapText] : [basemapText]
  319. if (terrainState === TERRAIN_STATE.READY) parts.push(terrainText)
  320. if (terrainState === TERRAIN_STATE.INVALID) parts.push(`地形加载失败(${terrainError})· 回退椭球`)
  321. if (overlayCount > 0) parts.push(`叠加影像 ${overlayCount} 层`)
  322. if (tilesetReady > 0) parts.push(`3D Tiles ${tilesetReady} 个`)
  323. if (tilesetFailed.length) parts.push(`3D Tiles 加载失败(${tilesetFailed[0].error})`)
  324. // 引擎没就绪时(booting / failed / disabled / unavailable)原本只说引擎自己的话,
  325. // 于是「关了引擎的部署」在主场景区这件事上彻底失声 —— 而它正是最需要看到
  326. // 「限位与雾气关了」的那种部署。所以配置到手后,这条读数在**任何引擎状态下**都跟着说。
  327. const base = engineState === 'ready' ? `Cesium 引擎已就绪 · ${parts.join(' · ')}` : ENGINE_TEXT[engineState] || engineState
  328. record.statusNode.textContent = `${base}${primaryAreaResolved && engineState !== 'ready' ? ` · ${primaryAreaText}` : ''}${engineError ? `(${engineError})` : ''}`
  329. // 生产形态没有底色,靠字色传达「正常 / 故障」——颜色语义在任何形态下都不能丢。
  330. if (isUiDebug()) {
  331. record.statusNode.style.borderColor = failed ? 'rgba(255,59,82,.55)' : 'rgba(1,171,234,.42)'
  332. record.statusNode.style.color = failed ? '#ffc2c9' : '#bad4e0'
  333. } else {
  334. record.statusNode.style.color = failed ? 'rgba(255,139,150,.8)' : 'rgba(186,212,224,.5)'
  335. }
  336. }
  337. function setEngineState(record, state, error = '') {
  338. engineState = state
  339. engineError = error
  340. paintEngineState(record)
  341. }
  342. /** 相机状态写进宿主 dataset:走查、演示与人工排查都读这一行(票 03 要求交互可重复演示)。 */
  343. function paintCameraState(record, state) {
  344. if (!isDomHost(record.host)) return
  345. record.host.dataset.camera = formatCameraState(state)
  346. }
  347. /**
  348. * 在给定窗口上采一张地形高程网格(票 34 的建模用途):
  349. * 轻量建模要把体块坐在地形上,而模型是静态几何 —— 所以先在本地 DEM 上打网格,
  350. * 生成时按双线性插值给每栋楼定基准高程(不然县城地面一百多米,整片楼会被埋进山里)。
  351. *
  352. * @param {number} lon 中心经度
  353. * @param {number} lat 中心纬度
  354. * @param {number} dLon 经度半宽(度)
  355. * @param {number} dLat 纬度半宽(度)
  356. * @param {number} n 每边采样点数
  357. * @returns {Promise<Array<number|null>>} 行优先(先纬后经)的高程数组,单位米
  358. */
  359. export async function sampleTerrainGrid(lon, lat, dLon, dLat, n) {
  360. if (!instance || !instance.viewer || !instance.engine) return null
  361. const count = Math.max(2, Math.min(64, Math.floor(n)))
  362. const points = []
  363. for (let row = 0; row < count; row++) {
  364. for (let col = 0; col < count; col++) {
  365. points.push({
  366. lon: lon - dLon + (2 * dLon * col) / (count - 1),
  367. lat: lat - dLat + (2 * dLat * row) / (count - 1),
  368. })
  369. }
  370. }
  371. return instance.engine.sampleTerrainHeights(instance.viewer, points)
  372. }
  373. /**
  374. * 开发态把手:三类交互与「选中换中心」都能在控制台/走查里复现,不必点着鼠标盲试。
  375. * 只在 DEV 且浏览器里挂,离开 /cockpit 即摘掉;生产构建不含它。
  376. */
  377. function publishDevHook() {
  378. const env = import.meta.env
  379. if (!env || !env.DEV || typeof window === 'undefined') return
  380. window.__cockpitMap = {
  381. getCameraState,
  382. setCameraFocus,
  383. clearCameraFocus,
  384. // 手感是主观的:DEV 下现场拖调阻尼时间常数(毫秒,0 = 不做阻尼)。
  385. setCameraDamping: (tauMs) => (instance && instance.cameraControl ? instance.cameraControl.setDampingTau(tauMs) : null),
  386. /** 「返回全球」飞行时长(毫秒);退出快慢是主观的,做成可现场拖调(见 `RETURN_FLIGHT_MS`)。 */
  387. setReturnFlightMs: (ms) => (instance && instance.cameraControl ? instance.cameraControl.setReturnFlightMs(ms) : null),
  388. // 票 30:视角预设 / 归位 / 星球自转,走查与人工排查都用这几个入口。
  389. goHome: (options) => goHome(options),
  390. getHomeView: () => getHomeView(),
  391. isAtHome: () => atHome(),
  392. setSpin: (seconds) => setGlobeSpin(seconds),
  393. // 票 21:地面/管线视图切换(走查断言"切换不重置中心"用)。
  394. setViewMode: (mode, overrides) => setViewMode(mode, overrides),
  395. getViewMode: () => getViewMode(),
  396. // 票 36:主场景区(走查与人工排查读"配没配、矩形是哪一个",票 37/38 也读这一个出口)。
  397. getPrimaryAreaPlan: () => getPrimaryAreaPlan(),
  398. // 票 37:进区锁定与区域限位(走查驱动「进入 → 锁定 → 拉远被夹 → 返回全球」全过程)。
  399. getAreaState: () => getAreaState(),
  400. getAreaLimits: () => getAreaLimits(),
  401. enterArea: () => setAreaState('enter'),
  402. returnGlobal: () => setAreaState('returnGlobal'),
  403. /**
  404. * 强制渲染一帧。诊断用,也是**隐藏标签页**下取像素证据的唯一办法:
  405. * 浏览器在标签页不可见时冻结 rAF,Cesium 的默认渲染循环随之停摆,
  406. * 截图会拿到旧帧(实测相隔 3 秒的两张截图在地图区域逐字节相同)。
  407. */
  408. forceRender: () => {
  409. if (!instance || !instance.viewer) return false
  410. const viewer = instance.viewer
  411. let did = false
  412. // `scene.render()` 才是驱动 postProcessStages.update() 的那一步
  413. // (处理级的 `ready` 与 uniformMap 都在其中建立)。
  414. try {
  415. if (viewer.scene && typeof viewer.scene.render === 'function') {
  416. viewer.scene.render()
  417. did = true
  418. }
  419. } catch (error) {
  420. did = false
  421. }
  422. if (typeof viewer.render === 'function') {
  423. try {
  424. viewer.render()
  425. did = true
  426. } catch (error) {
  427. // 忽略:scene.render 已经跑过就够了
  428. }
  429. }
  430. return did
  431. },
  432. // 票 38:自建雾(走查与人工排查读"支持/可执行/强度/降级原因",并驱动降级与性能采样)。
  433. getFogState: () => getFogState(),
  434. getFogParams: () => getFogParams(),
  435. setFogTranslucentArtifacts: (observed) => setFogTranslucentArtifacts(observed),
  436. setFogUserDisabled: (disabled) => setFogUserDisabled(disabled),
  437. startFogPerfSample: (label) => startFogPerfSample(label),
  438. stopFogPerfSample: () => stopFogPerfSample(),
  439. // 票 39:精细影像交接(读数含"当前阶段 / alpha / 被什么挡住 / 边缘遮蔽率 / 瓦片信号性质")。
  440. getHandoverState: () => getHandoverState(),
  441. getHandoverParams: () => getHandoverParams(),
  442. /** 原型接管开关:原型挂载时让生产交接交出 alpha 写入权(票 39 = 隔离,不删除)。 */
  443. setHandoverPrototypeActive: (active) => setHandoverPrototypeActive(active),
  444. /** 立刻推一次交接(走查里不必等渲染帧;隐藏标签页下 rAF 会被冻结)。 */
  445. advanceHandover: () => advanceHandover(),
  446. /**
  447. * 把相机**直接放到指定椭球高度**(DEV 诊断用)。
  448. *
  449. * 为什么需要它:验证"雾随高度收口"必须能在**保持区内锁定态**的同时把相机送上天,
  450. * 而 `goHome` 只按 `map.home` 的预设走(改不了 range),`returnGlobal` 又会改区域状态。
  451. * 有了它才能精确复现"退出途中相机已在高空、而雾状态机仍是 locked"的那一刻。
  452. */
  453. setCameraAltitudeForTest: (altitudeM) => {
  454. if (!instance || !instance.engine || !instance.viewer) return null
  455. // Cesium 的 `Cartesian3` 只允许出现在引擎层(本仓唯一 `import 'cesium'` 的地方),
  456. // 所以"放到指定高度"这件事交给引擎做,服务层只转调。
  457. const applied = instance.engine.setCameraAltitude(instance.viewer, altitudeM)
  458. if (applied !== null) paintFog()
  459. return applied
  460. },
  461. /**
  462. * 场景侧诊断(排查"画面偏色 / 整屏发白"这类问题)。
  463. *
  464. * 为什么要有它:雾读数与交接读数只描述**我们自己的状态**,
  465. * 而"画面为什么是这个颜色"取决于**场景里还有谁在染色** ——
  466. * Cesium 内置雾、`globe.baseColor`、图层 alpha、大气。只看自己的读数会漏。
  467. */
  468. getSceneDiagnostics: () => {
  469. if (!instance || !instance.viewer || !instance.viewer.scene) return null
  470. const scene = instance.viewer.scene
  471. const layers = []
  472. for (let i = 0; i < scene.imageryLayers.length; i++) {
  473. const layer = scene.imageryLayers.get(i)
  474. layers.push({
  475. alpha: Number(layer.alpha),
  476. show: !!layer.show,
  477. hasRectangle: !!(layer.imageryProvider && layer.imageryProvider.rectangle),
  478. })
  479. }
  480. return {
  481. // Cesium 内置雾:**生产从没关过它**(原型当时显式关掉过),
  482. // 它会按 `groundAtmosphereColor` 给地形叠一层冷色调 —— 排查画面偏色时必须看它。
  483. builtinFog: {
  484. enabled: !!scene.fog.enabled,
  485. renderable: !!scene.fog.renderable,
  486. density: scene.fog.density,
  487. visualDensityScalar: scene.fog.visualDensityScalar,
  488. },
  489. globeBaseColor: scene.globe.baseColor ? scene.globe.baseColor.toCssHexString() : null,
  490. globeTranslucency: scene.globe.translucency
  491. ? { enabled: !!scene.globe.translucency.enabled, frontFaceAlpha: scene.globe.translucency.frontFaceAlpha }
  492. : null,
  493. imageryLayers: layers,
  494. cameraHeightM: cameraAltitude(),
  495. }
  496. },
  497. // 票 34:地形高程网格采样(轻量建模把体块坐到地形上用),只在 DEV 挂。
  498. sampleTerrainGrid: (lon, lat, dLon, dLat, n) => sampleTerrainGrid(lon, lat, dLon, dLat, n),
  499. // 票 34:3D Tiles 诊断(排查"根瓦片有没有被访问 / 内容为何没请求")。
  500. getTilesetDiagnostics: () =>
  501. instance && instance.engine ? instance.engine.readTilesetDiagnostics(instance.tilesetInstances || []) : [],
  502. getTilesetRegions: () => instance?.tilesetCulling?.regionSnapshot?.() || [],
  503. getTilesetLoading: () => instance?.tilesetCulling?.snapshot() || [],
  504. getLifecycleStats,
  505. projectCameraPivot: () => (instance && instance.cameraControl ? instance.cameraControl.projectPivot() : null),
  506. // 票 08:地图摘要的渐进加载(人工演示与走查驱动「取下一页 / 刷新换快照」)。
  507. // 状态归 stores/mapStore.js 的装载器,这里只是把它接到同一个 DEV 把手上。
  508. mapSummary: devSummaryHandle,
  509. // 票 09:点位标记与图例(人工演示「刷新后点位换色」也走这里)。
  510. getPointMarkers: () => getPointMarkerState(),
  511. clearPointMarkers: () => clearPointMarkers(),
  512. // 票 42:关联设备临时图层(走查读独立读数,验证它不进摘要口径)。
  513. nudgeOverlap: (pointCode) => nudgeOverlap(pointCode),
  514. clearOverlapNudge: () => clearOverlapNudge(),
  515. pickDeviceMarker: (x, y) => {
  516. if (!instance || !instance.engine || !instance.viewer) return null
  517. return instance.engine.pickDeviceMarkerAt(instance.viewer, x, y)
  518. },
  519. // 票 45:重叠聚合的只读状态与交互入口(聚合符号不写设备选中态)。
  520. getOverlapState: () => getOverlapState(),
  521. getOverlapTargets: () => getOverlapTargets(),
  522. // 票 45:只读回点位标记的窗口坐标(真实鼠标走查要点的位置;不触发任何拾取)。
  523. getPointMarkerTargets: () => (instance && instance.engine && instance.viewer && instance.engine.readPointMarkerTargets
  524. ? instance.engine.readPointMarkerTargets(instance.viewer)
  525. : []),
  526. toggleOverlap: (pointCode) => toggleOverlap(pointCode),
  527. collapseOverlap: (pointCode) => collapseOverlap(pointCode),
  528. // 票 42:关联监测设备临时图层(走查读独立读数,验证它不进摘要口径)。
  529. getAssociatedDevices: () => getAssociatedDeviceState(),
  530. // 票 47:设备状态动效的只读读数(计划 + 逐点当前帧属性)。
  531. getDeviceEffects: () => getDeviceEffectState(),
  532. clearAssociatedDevices: () => clearAssociatedDevices(),
  533. // 票 43:设备点的对应反应(走查驱动 hover/选中并读三个可断言字段)。
  534. //
  535. // **选中/hover 的写入不从这里走**:它们必须经 store(见外壳的
  536. // `publishDeviceSelectionHandle`),否则同一个"当前选中设备"会有两套真相
  537. // (服务层模块变量 + store 的响应式状态)—— 本票实现时真机走查抓到过这一幕:
  538. // 直接调服务层后 store 仍读空串,界面与引擎对"选中了谁"给出两个答案。
  539. getDeviceCorrespondence: () => getDeviceCorrespondenceState(),
  540. // 三维 hover 拾取必须碰引擎,所以留在服务层(它只回身份,不写选中态)。
  541. hoverDeviceAt: (x, y) => hoverDeviceAt(x, y),
  542. pickDeviceOrPoint: (x, y) => pickDeviceOrPointAt(x, y),
  543. applyDeviceReactions: (reactions) => {
  544. if (!instance || !instance.engine || !instance.viewer) return 0
  545. return instance.engine.applyDeviceReactions(instance.viewer, reactions, reactionStyle)
  546. },
  547. // 选中/hover 的**用户路径**(由外壳递进来的 store 方法;未挂载时为 undefined)。
  548. selectDevice: (code) => (devDeviceHandles && devDeviceHandles.selectDevice ? devDeviceHandles.selectDevice(code) : ''),
  549. hoverDevice: (code) => (devDeviceHandles && devDeviceHandles.hoverDevice ? devDeviceHandles.hoverDevice(code) : ''),
  550. backToPointLevel: () => (devDeviceHandles && devDeviceHandles.backToPointLevel ? devDeviceHandles.backToPointLevel() : ''),
  551. // 票 10:点位点选(人工演示与走查用屏幕坐标驱动同一条链路)。
  552. pickPoint: (x, y) => pickPointAt(x, y),
  553. getPicked: () => getPickedPoint(),
  554. // 票 41:统一选择入口由外壳挂载时递进来(`publishPointSelectionHandle`)。
  555. // 走的是与地图点选/台账卡片**同一个函数** —— DEV 把手不该另造第三条定位路径。
  556. selectMonitoringPoint: devSelectHandle,
  557. }
  558. }
  559. /** 票 08:地图摘要装载器的 DEV 把手(由 MapCanvas 挂载时递进来,离开时清掉)。 */
  560. let devSummaryHandle = null
  561. /** 票 41:统一选择入口的 DEV 把手(由外壳挂载时递进来;mapService 不能 import 它,否则循环依赖)。 */
  562. let devSelectHandle = null
  563. export function publishPointSelectionHandle(handle) {
  564. devSelectHandle = typeof handle === 'function' ? handle : null
  565. publishDevHook()
  566. }
  567. /**
  568. * 票 43:设备**选中/hover** 的 DEV 把手(由外壳递进来,与票 41 同一成例)。
  569. *
  570. * 为什么不让 mapService 直接暴露 `setSelectedDevice`:那样会出现两套选中真相
  571. * (服务层模块变量 vs store 的响应式状态)。走查必须能驱动"用户那条路径",
  572. * 而用户路径就是 store 的方法。
  573. */
  574. let devDeviceHandles = null
  575. export function publishDeviceSelectionHandle(handles) {
  576. devDeviceHandles = handles && typeof handles === 'object' ? handles : null
  577. publishDevHook()
  578. }
  579. /**
  580. * 接上地图摘要把手:`{ advance, refresh, getState }`(`stores/mapStore.js` 的入口)。
  581. * 传 null 表示摘掉(离开 /cockpit)。
  582. */
  583. export function publishSummaryHandle(handle) {
  584. devSummaryHandle = handle || null
  585. publishDevHook()
  586. if (handle) return
  587. if (typeof window !== 'undefined' && window.__cockpitMap) window.__cockpitMap.mapSummary = null
  588. }
  589. function clearDevHook() {
  590. if (typeof window !== 'undefined' && window.__cockpitMap) {
  591. delete window.__cockpitMap
  592. }
  593. }
  594. /**
  595. * 异步加载引擎。全部逻辑都在 try 内,避免出现未处理的 Promise 拒绝(那会算页面级报错)。
  596. * @param {object} record mountMap 建立的实例记录
  597. */
  598. async function bootEngine(record) {
  599. try {
  600. setEngineState(record, 'booting')
  601. // 票 04:引擎开关与静态资源基址都从配置服务读(默认开、默认按应用基址取 cesium/)。
  602. const configState = await loadCockpitConfig()
  603. if (instance !== record) return
  604. const mapConfig = (configState.config && configState.config.map) || {}
  605. // 票 36:主场景区先算 —— 它是**配置事实**,与引擎开不开、起没起来都无关。
  606. // 刻意放在「引擎已由配置关闭」这条早退之前:关了引擎的部署,主场景区的读数仍要如实。
  607. applyPrimaryArea(mapConfig)
  608. const cesiumConfig = (mapConfig && mapConfig.cesium) || {}
  609. if (cesiumConfig.enabled === false) {
  610. setEngineState(record, 'disabled')
  611. return
  612. }
  613. ensureCesiumBaseUrl(cesiumConfig.baseUrl || '')
  614. // 票 29:底图由配置计划决定;未配置 / 配置无效一律不挂底图(票 27:不伪造配准)。
  615. const basemapPlan = planBasemap(mapConfig, { dev: isDev() })
  616. basemapState = basemapPlan.state
  617. basemapText = basemapStatusText(basemapPlan)
  618. // 票 21:地形(本地/甲方 DEM)、叠加影像(本地精细切片)与 3D Tiles/倾斜摄影。
  619. const terrainPlan = planTerrain(mapConfig)
  620. const overlayPlans = planOverlays(mapConfig)
  621. const tilesetPlans = planTilesets(mapConfig)
  622. // 票 25:署名与图层取自**同一批计划**(不另开一份真相);没有 credit 的图层不硬造一条。
  623. dataSourceCredits = [
  624. basemapPlan.credit,
  625. ...overlayPlans.map((overlay) => overlay.credit),
  626. ...tilesetPlans.map((plan) => plan.credit),
  627. ].filter((credit) => typeof credit === 'string' && credit.trim())
  628. .map((credit) => credit.trim())
  629. terrainState = terrainPlan.ready ? TERRAIN_STATE.READY : TERRAIN_STATE.NONE
  630. terrainText = terrainStatusText(terrainPlan)
  631. terrainError = ''
  632. overlayCount = 0
  633. tilesetReady = 0
  634. tilesetFailed = []
  635. const engine = await import('./cesiumEngine.js')
  636. // 加载期间已经离开 /cockpit:不留下实例。
  637. if (instance !== record) return
  638. const viewer = engine.createViewer(record.host, basemapPlan)
  639. record.engine = engine
  640. record.viewer = viewer
  641. // 建 Viewer 是同步的,但状态可能在这一刻被销毁过 —— 补一次销毁。
  642. if (instance !== record) {
  643. engine.destroyViewer(viewer)
  644. return
  645. }
  646. // 叠加影像(本地精细切片)与地形:地形是异步拉 layer.json,失败只回退椭球不影响外壳。
  647. // 票 39:引擎返回**句柄**(不再只是层数)—— 交接要驱动它们的 alpha。
  648. const overlayHandleList = engine.applyOverlays(viewer, overlayPlans)
  649. overlayCount = overlayHandleList.length
  650. record.overlayHandleList = overlayHandleList
  651. if (terrainPlan.ready) {
  652. try {
  653. await engine.applyTerrain(viewer, terrainPlan)
  654. } catch (terrainFailure) {
  655. if (instance !== record) return
  656. terrainState = TERRAIN_STATE.INVALID
  657. terrainError = String((terrainFailure && terrainFailure.message) || terrainFailure)
  658. }
  659. }
  660. if (instance !== record) return
  661. // 3D Tiles / 倾斜摄影:逐项挂,单项失败只记状态(入口已留好,数据缺失时不白屏)。
  662. if (tilesetPlans.length) {
  663. const tilesets = await engine.applyTilesets(viewer.scene, tilesetPlans, (state) => {
  664. if (instance !== record) return
  665. tilesetReady = state.ready
  666. tilesetFailed = state.failed
  667. record.tilesetInstances = state.instances
  668. engine.applyViewModeLayers(viewer, state.instances, planViewMode(viewMode))
  669. paintEngineState(record)
  670. }, (controller) => { record.tilesetCulling = controller; controller?.setPipeline?.(viewMode === VIEW_MODES.PIPELINE) })
  671. if (instance !== record) {
  672. tilesets.culling?.detach()
  673. engine.destroyViewer(viewer)
  674. return
  675. }
  676. tilesetReady = tilesets.ready
  677. tilesetFailed = tilesets.failed
  678. // 票 21:留住实例,视图条件(管线视图压半透明)要按它改样式。
  679. record.tilesetInstances = tilesets.instances
  680. record.tilesetCulling = tilesets.culling
  681. }
  682. // 票 30:地形就绪后把「家」视角的高程换成实测地形高度 —— 否则相机按椭球 0 米定位,
  683. // 一拉近就落进山体(实测 range 120m 全屏黑)。采样失败保持 0,等价于旧行为。
  684. try {
  685. const homePlan = planHome(mapConfig)
  686. const sampled = await engine.sampleTerrainHeight(viewer, homePlan.lon, homePlan.lat)
  687. if (instance !== record) return
  688. if (Number.isFinite(sampled)) record.homeHeight = sampled
  689. } catch {
  690. // 采样失败不影响启动:保持椭球高度。
  691. }
  692. if (instance !== record) {
  693. engine.destroyViewer(viewer)
  694. return
  695. }
  696. record.engine = engine
  697. record.viewer = viewer
  698. record.detachMapFrame = viewer.scene.postRender.addEventListener(() => {
  699. for (const handler of mapFrameHandlers) handler()
  700. // 票 39:瓦片就绪是 getter(读它不便宜),且**只有真的渲染过**才知道队列空没空,
  701. // 所以放在 postRender 里刷;交接的推进也借此在"有帧"时才有机会前进。
  702. if (record !== instance) return
  703. refreshOverlayReadiness()
  704. // 票 38 修复(维护者 2026-09-29 报):相机高度一变就要重画雾。
  705. // 退出时状态机可能已经落定(不再逐帧推进),但相机还在飞 ——
  706. // 不在这里补画,高度收口就不会生效,退出仍会「整个地球变白」。
  707. //
  708. // 顺序有意:**先推进限速**(它按真实 dt 收敛,一帧只能走一步),
  709. // 再比较"系数变了吗、要不要重画"。反过来的话读数会用旧系数。
  710. if (fogHandle) {
  711. stepAltitudeSlew()
  712. if (fogAltitudeShown !== fogPaintedAltitudeFactor) paintFog()
  713. }
  714. advanceHandover()
  715. })
  716. // 加载期间的尺寸变化不能丢,补一次 resize。
  717. if (record.resizePending) {
  718. record.resizePending = false
  719. engine.resizeViewer(viewer)
  720. }
  721. // 票 03:相机契约落在服务层 —— 引擎就绪后才挂,默认输入已在 createViewer 里全关。
  722. const cameraEngine = engine.createCameraEngine(viewer)
  723. record.cameraControl = createCameraControl({
  724. engine: cameraEngine,
  725. onStateChange: (state) => {
  726. paintCameraState(record, state)
  727. // 飞行落定的那一帧走这里(`render()` → `notify()` → 本回调)——
  728. // 必须在这里把"想要的自转"接上,否则回引导态后地球永远不再转
  729. // (`notifyCameraStateChange` 只在下发预设时被调,不在逐帧路径上)。
  730. resumeSpinIfWanted(state)
  731. // 票 41:相机一变化就让锚定跟随(订阅方按需重算落位)。
  732. //
  733. // 为什么不能只靠 `scene.postRender`:渲染帧在**隐藏标签页**里不触发(rAF 被冻结,
  734. // 票 37 已记录同一环境事实),于是锚定会停在旧位置;相机状态变化是**由输入/飞行驱动**的,
  735. // 与是否正在渲染无关,是更可靠的触发源。两条路径都留着:渲染帧管逐帧跟随,这里管"变化即更新"。
  736. for (const handler of cameraStateHandlers) handler(state)
  737. },
  738. })
  739. if (isDev()) record.detachModelDiagnostics = attachModelDiagnostics(viewer.scene, record.host, () => ({
  740. viewMode, engineState, regions: record.tilesetCulling?.regionSnapshot?.() || [],
  741. tilesets: record.tilesetCulling?.snapshot?.() || [],
  742. camera: record.cameraControl?.getState?.() || null,
  743. }), { isMoving: () => record.cameraControl?.getState?.()?.settled === false })
  744. // 票 37:相机控件挂上后立刻把生效限位算一遍并下发 —— 进区状态可能在引擎就绪之前
  745. // 就已经定了(引导层先出现、配置先到手),这里补一次,免得第一次交互才生效。
  746. applyAreaLimits()
  747. paintAreaState()
  748. // 票 38:自建雾处理级(**唯一一个**,复用不重建)。进区状态可能已经定了,
  749. // 所以建完就按当前状态对一次门控,不等用户再点一次。
  750. if (ensureFogStage()) {
  751. retargetFogForArea(areaState)
  752. }
  753. paintFog()
  754. // 一张图调色(科技蓝滤镜):**在雾之后建级** —— 后处理按加入顺序执行,
  755. // 调色排在雾后面,雾出的白也被统一成同一冷蓝调。配置关掉时不建级。
  756. if (ensureGradeStage()) {
  757. paintGrade()
  758. }
  759. // 票 39:挂上精细层句柄并驱动一次交接 —— 进区状态可能在引擎就绪前就定了
  760. // (配置先到手),这里补一次,免得第一次进区才生效。
  761. // 顺序有意:**在雾之后** —— 闸门要读雾的强度,雾没建好时闸门必然不放行。
  762. attachHandoverLayers(engine, record.overlayHandleList || [])
  763. advanceHandover()
  764. record.detachCameraInput = attachCameraInput(cameraEngine.inputTarget(), record.cameraControl)
  765. record.host.addEventListener('pointerdown', cancelPointLocate)
  766. record.host.addEventListener('wheel', cancelPointLocate)
  767. record.detachLocateInput = () => {
  768. record.host.removeEventListener('pointerdown', cancelPointLocate)
  769. record.host.removeEventListener('wheel', cancelPointLocate)
  770. }
  771. // 票 10/43/23:先走设备/监测点优先链,只有没有业务点命中时才把点击交给局部增强。
  772. record.detachPickInput = attachPointPick(record.host, ({ x, y }) => {
  773. const resolved = pickDeviceOrPointAt(x, y)
  774. if (resolved.target !== 'none') return resolved
  775. return pickPipelineAt(x, y)
  776. })
  777. // 票 43:三维 hover 拾取(pointermove 观察型监听,与相机/点选互不干扰)。
  778. // 节流在这里做:pointermove 每秒可达上百次,每次都 scene.pick 会白烧 GPU/CPU。
  779. // 规格把"节流数值"留实现定,这里取 60ms(≈16fps 的 hover 反馈,肉眼连贯且开销可控)。
  780. const HOVER_THROTTLE_MS = 60
  781. let lastHoverAt = 0
  782. const onHoverMove = (event) => {
  783. const now = Date.now()
  784. if (now - lastHoverAt < HOVER_THROTTLE_MS) return
  785. lastHoverAt = now
  786. hoverDeviceAt(event.clientX, event.clientY)
  787. // 批次 3:hover 完整名称 tooltip(label 截断后的通用 hover 详情)。
  788. paintMarkerTooltip(event)
  789. }
  790. const onHoverLeave = () => {
  791. setHoveredDevice('')
  792. hideMarkerTooltip()
  793. if (overlapPreviewGroup) collapseOverlap(overlapPreviewGroup)
  794. }
  795. record.host.addEventListener('pointermove', onHoverMove)
  796. record.host.addEventListener('pointerleave', onHoverLeave)
  797. record.detachHoverInput = () => {
  798. record.host.removeEventListener('pointermove', onHoverMove)
  799. record.host.removeEventListener('pointerleave', onHoverLeave)
  800. }
  801. // 用当前真实相机反读弹簧臂参数,避免第一次交互时相机跳变。
  802. // 引导态视角的**契约就是 Cesium Viewer 的默认相机**(维护者 2026-09-30 裁定)——
  803. // 反读出来的 `{pivot, hpr}` 会被存成 `introView`,之后「闲置回引导」回到它。
  804. record.cameraControl.syncFromEngine()
  805. // 批次 3 第五轮(票 20):DOM overlay 渲染标记与标签——billboard 发糊与底板生硬的
  806. // 结构性解决。失败自动回退既有 billboard 链路(syncPointMarkersForPlan 不动)。
  807. if (mountMarkerOverlay(record.host)) {
  808. record.detachOverlay = attachOverlayRenderer(instance.viewer, () => {
  809. if (!markerPlan || !instance || !instance.viewer) return null
  810. const colors = toneColors()
  811. return {
  812. markers: markerPlan.markers,
  813. tones: colors,
  814. planOf: (marker) => planMarker({
  815. kind: marker.kind === 'unassociated-equipment' ? 'monitoring-device' : 'monitoring-point',
  816. status: marker.status,
  817. industry: marker.industry,
  818. tone: marker.tone,
  819. unassociated: marker.kind === 'unassociated-equipment',
  820. }),
  821. innerUrls: markerInnerUrls(),
  822. // 走相机引擎的 projectPivot(createCameraEngine 返回对象上的方法,SceneTransforms 包装)
  823. projectToWindow: (lon, lat) => {
  824. const at = cameraEngine.projectPivot({ lon, lat, height: 0 })
  825. if (!at) return null
  826. // overlay 节点挂在画布容器内(相对同一原点),去掉 host 的画布偏移
  827. const rect = record.host.getBoundingClientRect()
  828. const canvas = instance.viewer.canvas.getBoundingClientRect()
  829. return { x: at.x - (canvas.left - rect.left), y: at.y - (canvas.top - rect.top) }
  830. },
  831. }
  832. })
  833. }
  834. // 批次 3:overlay 的内层图标 URL 表(manifest 解析产物,语义名 → data URL)
  835. function markerInnerUrls() {
  836. const urls = {}
  837. for (const icon of ['gas', 'water', 'drainage', 'manhole']) {
  838. const asset = resolveAsset(`marker.inner.${icon}`)
  839. if (asset.url) urls[icon] = asset.url
  840. }
  841. return urls
  842. }
  843. // 引导视角基准 = 引擎挂载时反读到的 **Cesium Viewer 默认相机**
  844. // (维护者 2026-09-30 裁定:初始引导页就是这张"整个地球"的地球仪视角,
  845. // **不是** `INITIAL_PANORAMA`)。之后「闲置回引导」回到它。
  846. const introState = record.cameraControl.getState()
  847. if (introState && introState.shownPivot && introState.shownHpr) {
  848. introView = {
  849. pivot: { ...introState.shownPivot },
  850. hpr: { ...introState.shownHpr },
  851. }
  852. }
  853. // 票 30:进入引导期间让星球自转(进入系统时由 setGlobeSpin(0) 停)。
  854. const introConfig = planIntro(mapConfig)
  855. if (introConfig.enabled && introConfig.spinSeconds > 0) {
  856. setGlobeSpin(introConfig.spinSeconds)
  857. }
  858. setEngineState(record, 'ready')
  859. publishDevHook()
  860. } catch (error) {
  861. if (instance !== record) return
  862. setEngineState(record, 'failed', String((error && error.message) || error))
  863. }
  864. }
  865. /**
  866. * 挂载地图实例。幂等:已挂载时直接返回既有实例,绝不重建。
  867. * @param {Element} host 承载地图的宿主要素
  868. */
  869. export function mountMap(host) {
  870. if (instance) {
  871. return instance
  872. }
  873. sequence += 1
  874. createdCount += 1
  875. instance = {
  876. id: `cockpit-map-${sequence}`,
  877. host: host || null,
  878. createdAt: new Date().toISOString(),
  879. engine: null,
  880. viewer: null,
  881. statusNode: null,
  882. resizePending: false,
  883. cameraControl: null,
  884. detachCameraInput: null,
  885. // 票 10:点位点选的 DOM detach(与相机输入同一套生命周期)。
  886. detachPickInput: null,
  887. }
  888. const record = instance
  889. engineState = 'idle'
  890. engineError = ''
  891. if (isDomHost(record.host)) {
  892. bootEngine(record)
  893. } else {
  894. setEngineState(record, 'unavailable')
  895. }
  896. return record
  897. }
  898. export function getMapInstance() {
  899. return instance
  900. }
  901. /** 宿主尺寸变化时调用:引擎就绪后转调 `viewer.resize()`。 */
  902. export function resizeMap() {
  903. if (!instance) {
  904. return
  905. }
  906. resizeCount += 1
  907. lastResizeAt = new Date().toISOString()
  908. if (instance.viewer) {
  909. instance.engine.resizeViewer(instance.viewer)
  910. } else {
  911. // 引擎还在加载:记下来,等 boot 完成后补一次。
  912. instance.resizePending = true
  913. }
  914. }
  915. /** 只在离开 /cockpit 时销毁。 */
  916. export function unmountMap() {
  917. if (!instance) {
  918. return
  919. }
  920. const record = instance
  921. cancelPointLocate()
  922. instance = null
  923. destroyedCount += 1
  924. clearDevHook()
  925. // 票 30:先停自转循环,再拆其它资源(否则 rAF 会打到已销毁的 viewer 上)。
  926. stopSpin()
  927. if (record.statusNode) {
  928. record.statusNode.remove()
  929. record.statusNode = null
  930. }
  931. // 相机:先摘事件绑定再销毁实例,避免销毁后仍有输入打进来。
  932. if (record.detachLocateInput) {
  933. record.detachLocateInput()
  934. record.detachLocateInput = null
  935. }
  936. if (record.detachCameraInput) {
  937. record.detachCameraInput()
  938. record.detachCameraInput = null
  939. }
  940. // 票 10:点选监听同样先摘(它也会在销毁后读到空 viewer)。
  941. if (record.detachPickInput) {
  942. record.detachPickInput()
  943. record.detachPickInput = null
  944. }
  945. clearPipelinePickHandlers()
  946. // 票 43:hover 监听一并摘掉(与点选同一个"销毁后读到空 viewer"的坑)。
  947. if (record.detachHoverInput) {
  948. record.detachHoverInput()
  949. record.detachHoverInput = null
  950. }
  951. if (record.cameraControl) {
  952. record.cameraControl.dispose()
  953. record.cameraControl = null
  954. }
  955. // 票 38:自建雾处理级随地图销毁(规格:复用对象,离开地图时销毁)。
  956. // 顺序在 viewer 销毁**之前** —— `postProcessStages.remove` 需要场景还活着。
  957. stopFogLoop()
  958. stopFogPerfSampleListener()
  959. if (fogHandle) {
  960. try {
  961. fogHandle.destroy()
  962. } catch (error) {
  963. // 销毁失败不阻断离开页面(下面 viewer.destroy 会整体回收)。
  964. }
  965. fogHandle = null
  966. }
  967. // 一张图调色级随地图销毁(同一条纪律:先摘处理级,再销毁 viewer)。
  968. if (gradeHandle) {
  969. try {
  970. gradeHandle.destroy()
  971. } catch (error) {
  972. // 销毁失败不阻断离开页面(下面 viewer.destroy 会整体回收)。
  973. }
  974. gradeHandle = null
  975. }
  976. // 状态一并归零:下次挂载从"全球态无雾"重新走,不残留上一轮的强度。
  977. fogLifecycle = createFogLifecycle()
  978. fogExecutable = false
  979. fogEverEnabled = false
  980. fogCreationFailed = false
  981. if (isDomHost(record.host)) {
  982. delete record.host.dataset.camera
  983. // 调色读数一并收(宿主上不留上一轮的 on/off)。
  984. delete record.host.dataset.mapGrade
  985. // 票 10:选中态随地图一起收(宿主上不留上一轮的选中标记)。
  986. delete record.host.dataset.mapPicked
  987. delete record.host.dataset.mapPickedStatus
  988. // 票 14:强调态随地图一起收(宿主上不留上一轮的高亮计数)。
  989. delete record.host.dataset.mapEmphasis
  990. // 票 42:关联设备图层的读数一并收(否则下次进入会读到上一轮残留的数)。
  991. delete record.host.dataset.mapDevices
  992. delete record.host.dataset.mapDevicesPlan
  993. // 票 43:三个对应反应字段也一并收(不留上一轮的选中/hover 读数)。
  994. delete record.host.dataset.mapDevicePicked
  995. delete record.host.dataset.mapDeviceHovered
  996. delete record.host.dataset.mapPanelSelection
  997. }
  998. // 票 42:设备图层的计划/计数也归零(viewer 随后销毁,图层本身随之消失)。
  999. deviceRows = []
  1000. deviceRendered = 0
  1001. deviceSkipped = 0
  1002. // 票 43:选中/hover 同样归零。
  1003. selectedDeviceCode = ''
  1004. hoveredDeviceCode = ''
  1005. lastPicked = null
  1006. if (record.detachModelDiagnostics) { record.detachModelDiagnostics(); record.detachModelDiagnostics = null }
  1007. if (record.detachMapFrame) {
  1008. record.detachMapFrame()
  1009. record.detachMapFrame = null
  1010. }
  1011. if (record.tilesetCulling && typeof record.tilesetCulling.detach === 'function') {
  1012. record.tilesetCulling.detach()
  1013. record.tilesetCulling = null
  1014. }
  1015. if (record.viewer) {
  1016. if (record.pipeLayer) record.pipeLayer.clearPipeLayer(record.viewer)
  1017. record.engine.destroyViewer(record.viewer)
  1018. record.viewer = null
  1019. }
  1020. engineState = 'idle'
  1021. engineError = ''
  1022. }
  1023. /**
  1024. * 选中/取消选中相机中心(票 03 验收第 4、5 条):从无选中到选中时平滑换中心并保持 heading 与 range;
  1025. * 传 null 表示取消选中 —— 中心不回退,只有左键平移才把中心更新为地面拾取点。
  1026. * @param {{lon: number, lat: number, height?: number}|null} point 对象地表点
  1027. */
  1028. export function setCameraFocus(point) {
  1029. cancelPointLocate()
  1030. if (!instance || !instance.cameraControl) return false
  1031. return instance.cameraControl.setFocus(point || null)
  1032. }
  1033. export function clearCameraFocus() {
  1034. return setCameraFocus(null)
  1035. }
  1036. // ---- 票 30:视角预设(家)/ 快速归位 / 星球自转 ----
  1037. /** 当前生效的「家」视角(配置优先,缺项用默认)。
  1038. * 地形就绪后中心高程用**实测地形高度**(`record.homeHeight`),否则相机按椭球 0 米算会钻进山体。 */
  1039. export function getHomeView() {
  1040. const home = planHome((getCockpitConfig().map) || {})
  1041. if (instance && Number.isFinite(instance.homeHeight)) {
  1042. return { ...home, height: instance.homeHeight }
  1043. }
  1044. return home
  1045. }
  1046. /** 进入引导配置。 */
  1047. export function getIntroConfig() {
  1048. return planIntro((getCockpitConfig().map) || {})
  1049. }
  1050. /**
  1051. * 飞到「家」视角(快速归位)。`options.immediate` = 不走阻尼直接落位(进系统首帧用)。
  1052. * @returns {boolean} 是否已下发
  1053. */
  1054. export function goHome(options = {}) {
  1055. cancelPointLocate()
  1056. // 票 30:进入系统/归位都要先停自转 —— 否则自转循环每帧以 immediate 写中心,
  1057. // 会把这次飞行的目标一路冲掉(实测:点「进入」后镜头停在自转途中的经度上)。
  1058. stopSpin()
  1059. // 票 37:归位即进区(ADR 0009「点『进入系统』/『归位』进区并锁定」)。
  1060. // **先换限位再飞**:否则飞行目标 12,000 m 会被"区外"的宽上限放行、落地后才被夹,
  1061. // 画面会在到位瞬间跳一下。
  1062. setAreaState('enter')
  1063. const home = getHomeView()
  1064. return applyViewPreset(home, home, options)
  1065. }
  1066. /** `hpr` 是否已经是初始全景(引导态的角度)。用与「停稳」同一套阈值,不另立魔数。 */
  1067. function isAtPanorama(hpr) {
  1068. if (!hpr || !introView) return false
  1069. return Math.abs(hpr.heading - introView.hpr.heading) <= SETTLE_EPSILON.headingDeg
  1070. && Math.abs(hpr.pitch - introView.hpr.pitch) <= SETTLE_EPSILON.pitchDeg
  1071. && Math.abs(hpr.range - introView.hpr.range) <= SETTLE_EPSILON.rangeMeters
  1072. }
  1073. /**
  1074. * 引导态视角基准:**引擎挂载时反读到的 Cesium Viewer 默认相机**。
  1075. *
  1076. * 契约(维护者 2026-09-30 裁定):初始引导页看到的是 Cesium 默认相机给的那张
  1077. * "整个地球"的地球仪视角 —— **不是** `INITIAL_PANORAMA`(那是「返回全球」的目标,
  1078. * 俯角更小、距离更近,画面只能看到一部分球)。所以「闲置回引导」的目标取这份反读值,
  1079. * 三者各归各位:初始引导页 = 引导视角;返回全球 = INITIAL_PANORAMA;闲置回引导 = 引导视角。
  1080. */
  1081. let introView = null
  1082. /**
  1083. * 回引导态:把相机送回**引导视角**(引擎挂载时反读到的 Cesium 默认相机)。
  1084. *
  1085. * 维护者 2026-09-30 报障:「闲置回到引导状态时,摄像机角度没有回到引导状态,且地球也没有
  1086. * 重新开始旋转,除了中心出现『点击进入系统』外用户不一定能意识到进入引导状态了」。
  1087. * 修法两件:① 相机回到**引导视角**(本函数);② 自转恢复(`spinWanted` 意图 + 落定后接上)。
  1088. *
  1089. * 与 `setAreaState('returnGlobal')` 的分工(两者都要,别互相替代):
  1090. * · 本函数**不改区域状态** —— 回引导只在 `global` 态发生(`shouldReturnToIntro` 已把关);
  1091. * · 已经在引导视角上就**不排飞行**,直接返回 —— 否则自转要白等一个飞行时长才起得来。
  1092. * 中心沿用引导视角的中心(自转本来就会推进它)。
  1093. *
  1094. * @returns {boolean} 是否已下发(相机没接上或没有引导视角基准时 false,调用方可重试)
  1095. */
  1096. export function resetToIntroView() {
  1097. if (!instance || !instance.cameraControl) return false
  1098. // 引导视角基准还没取到(引擎刚挂、反读未完成)—— 不猜,等下一次调用。
  1099. if (!introView) return false
  1100. // 契约:闲置回引导**只在 global 态发生**。global 的限位就是放开的全局区间 ——
  1101. // 这里必须**先重算限位**再下发预设:若限位还停在区内的 max=12000,
  1102. // 引导视角的 range(约 3×10⁷)会被 `clampRange` 夹成 12000,
  1103. // 相机贴着县城上空、整屏被雾罩成一片绿(维护者真机复现)。
  1104. // `rebindAreaLimits()` 按**当前 areaState** 重算 —— global 时自然是放开的。
  1105. rebindAreaLimits()
  1106. const control = instance.cameraControl
  1107. const state = control.getState()
  1108. if (state && state.settled && isAtPanorama(state.shownHpr)) return true
  1109. // **中心保持在当前位置**(不回到挂载时的经度 —— 自转/拖动本来就会推进它);
  1110. // **角度/高度**回到引导视角基准(Cesium 默认相机)。
  1111. const pivot = (state && state.pivot) || introView.pivot
  1112. return applyViewPreset(pivot, introView.hpr, { flightMs: control.getState().returnFlightMs })
  1113. }
  1114. /**
  1115. * 内部共用:把预设下发给相机控件。
  1116. * @param {object} home planHome() 结果
  1117. * @param {object} hpr 预设的 heading/pitch/range
  1118. */
  1119. function applyViewPreset(home, hpr, options = {}) {
  1120. if (!instance || !instance.cameraControl) return false
  1121. const applied = instance.cameraControl.applyViewPreset(home, hpr, options)
  1122. if (applied) {
  1123. publishDevHook()
  1124. // 票 41:程序化下发视角(定位 / 归位 / 返回全球 / 自转)也要通知锚定跟随。
  1125. // 这条路径**不经过相机输入**,若只靠输入回调与渲染帧,隐藏标签页里(rAF 冻结)
  1126. // 锚定会停在旧位置 —— 真机走查实测:点「重新定位」后相机已回到点位、面板却仍显示隐藏。
  1127. notifyCameraStateChange()
  1128. }
  1129. return applied
  1130. }
  1131. /** 当前是否已停在「家」视角(归位按钮据此显示状态)。 */
  1132. export function atHome() {
  1133. if (!instance || !instance.cameraControl) return false
  1134. return isAtHome(instance.cameraControl.getState(), getHomeView())
  1135. }
  1136. // ---- 票 21:地面视图 / 管线视图(全局视图条件,语义见决策地图票 05)----
  1137. /**
  1138. * 切换地面/管线视图。
  1139. *
  1140. * 三件事,顺序有意为之:
  1141. * 1. **先记下**进管线前的地面视角(倾角 + 距离),回地面时按它还原;
  1142. * 2. 换相机的 pitch 上下限与预设(管线:pitch 抬到 -18°、弹簧臂压到 35%)——
  1143. * **只动 pitch 与 range,heading 与旋转中心原样带走**(决策地图硬约束:切换视图不重置中心);
  1144. * 3. 调图层可见性:管线视图把地形与 3D Tiles 压成半透明,地面视图还原。
  1145. *
  1146. * 三键分工不受影响:本函数不碰 `cameraControl` 的输入绑定,只改它的 limits 与目标 hpr。
  1147. * @param {string} mode `ground` | `pipeline`
  1148. * @param {object} [overrides] 配置覆盖(pitch / rangeFactor / 透明度)
  1149. * @returns {boolean} 是否生效(引擎未就绪时 false,调用方可稍后重试)
  1150. */
  1151. export function setViewMode(mode, overrides = {}) {
  1152. cancelPointLocate()
  1153. const plan = planViewMode(mode, overrides)
  1154. if (!instance || !instance.cameraControl) return false
  1155. const control = instance.cameraControl
  1156. const state = control.getState()
  1157. const hpr = state && state.hpr ? state.hpr : null
  1158. if (!hpr) return false
  1159. if (plan.mode === VIEW_MODES.PIPELINE) {
  1160. // 进管线:记下地面视角,供回地面时还原(只在第一次进管线时记,避免连续切换被覆盖)。
  1161. if (viewMode !== VIEW_MODES.PIPELINE) {
  1162. instance.groundHprBaseline = { pitch: hpr.pitch, range: hpr.range }
  1163. }
  1164. }
  1165. // limits 必须先换:否则回地面时 pitch 仍按管线的宽容范围夹,画面角度会不对。
  1166. control.setPitchLimits(plan.limits)
  1167. // 票 37:**先落模块级 `viewMode` 再算限位** —— `applyAreaLimits()` 是按当前视图模式
  1168. // 决定要不要叠管线区间 [400, 6000] 的。顺序反了会拿上一次的模式算,切到管线时区域上限
  1169. // 仍按地面算(读数与实测都不对)。这是本票实现时踩到的真顺序 bug。
  1170. viewMode = plan.mode
  1171. instance.tilesetCulling?.setPipeline?.(viewMode === VIEW_MODES.PIPELINE)
  1172. applyAreaLimits()
  1173. // 票 38:管线视图的降级上限跟着视图模式走(**默认不预关闭**,只有观察到透明错位才减弱)。
  1174. refreshFogDegrade()
  1175. paintFog()
  1176. const nextHpr = viewHprFor(hpr, plan, instance.groundHprBaseline)
  1177. control.setHpr(nextHpr)
  1178. if (instance.engine && instance.viewer) {
  1179. instance.engine.applyViewModeLayers(instance.viewer, instance.tilesetInstances || [], plan)
  1180. instance.pipeLayer?.setPipeViewMode(instance.viewer, plan.mode)
  1181. }
  1182. if (isDomHost(instance.host)) {
  1183. instance.host.dataset.mapViewMode = plan.mode
  1184. }
  1185. return true
  1186. }
  1187. /** 当前视图条件(ground / pipeline)。 */
  1188. export function getViewMode() {
  1189. return viewMode
  1190. }
  1191. // ---- 票 36:主场景区(限位与自建雾的开关与矩形)----
  1192. /**
  1193. * 重新计算主场景区计划并刷新读数。配置到手时调一次(`bootEngine`)。
  1194. *
  1195. * 为什么它不放进 `bootEngine` 的引擎链路里:本计划**不依赖引擎**——
  1196. * 票 37/38 只问「配没配、在哪儿」,引擎是死是活都不改变答案。所以引擎失败时这个读数照样有效。
  1197. */
  1198. function applyPrimaryArea(mapConfig) {
  1199. primaryAreaPlan = planPrimaryArea(mapConfig || {})
  1200. primaryAreaText = primaryAreaStatusText(primaryAreaPlan)
  1201. primaryAreaResolved = true
  1202. // 票 38:雾参数与主场景区一起算 —— 两者都由配置单方面决定,都不依赖引擎。
  1203. fogParams = planFog(mapConfig || {})
  1204. // 参数换了,限速中的高度系数要立刻按新参数对齐(否则会按两套参数各说各话)。
  1205. setAltitudeSlewImmediate()
  1206. fogReason = fogParams.enabled ? '' : '已由配置关闭自建雾(map.fog.enabled=false)'
  1207. // 票 39:交接参数同源(同样不依赖引擎)。
  1208. handoverParams = planHandover(mapConfig || {})
  1209. // 调色参数同源(同样不依赖引擎):配置一换,立即写进已建的处理级。
  1210. gradeParams = planSceneGrade(mapConfig || {})
  1211. paintGrade()
  1212. // 影像矩形缓存随配置失效(判"会露边的是影像边界"要用它)。
  1213. overlayBoundsResolved = false
  1214. overlayBoundsCache = null
  1215. if (instance) paintEngineState(instance)
  1216. }
  1217. /**
  1218. * 当前主场景区计划(票 37/38 的**唯一**入口)。
  1219. *
  1220. * 返回冻结的副本:消费方要按它开限位/开雾,但不能把这份计划改掉
  1221. * ——「谁开的、开在哪儿」必须只有配置一个来源。
  1222. * @returns {{state: string, enabled: boolean, bounds: number[]|null, reason: string, sizeText: string}}
  1223. */
  1224. export function getPrimaryAreaPlan() {
  1225. return { ...primaryAreaPlan, bounds: primaryAreaPlan.bounds ? primaryAreaPlan.bounds.slice() : null }
  1226. }
  1227. /**
  1228. * 底图措辞(票 25)。原为引擎浮标上的一句,现在由右下角信息面板展示。
  1229. * @returns {string} 人话措辞(如「底图:内置离线影像 Natural Earth II」)
  1230. */
  1231. export function getBasemapLabel() {
  1232. return basemapText
  1233. }
  1234. /**
  1235. * 数据来源署名(票 25)。
  1236. *
  1237. * Cesium 自带的署名容器已被换成**离屏容器**(`services/cesiumEngine.js`),因此
  1238. * 底图/叠加影像/3D Tiles 的 credit 改由地图信息面板展示。这是「隐藏引擎署名」之后
  1239. * 仍然必须保留的那部分来源标注——本部署不用 ion,但自有影像的署名不能一起丢。
  1240. * @returns {string[]} 去重前的署名条目(空数组 = 本部署没有声明任何 credit)
  1241. */
  1242. export function getDataSourceCredits() {
  1243. return [...dataSourceCredits]
  1244. }
  1245. // ---- 票 37:进区锁定与相机区域限位 ----
  1246. /**
  1247. * 把「主场景区计划 + 区域状态 + 视图模式」合成生效限位并下发给相机控件。
  1248. *
  1249. * 三处夹取的合成点(ADR 0009):区域上限 `home.range`、视图模式区间(管线 `[400, 6000]`)、
  1250. * 全局 `[120, 3×10⁷]`。**只在这里算一次**,相机控件不自己拼 —— 否则视图切换与进区各算一套,
  1251. * 「最终生效 = 三者交集」就没人保证了。
  1252. *
  1253. * 两件事分开:**算计划**与**推给相机**。计划是配置事实(引擎没起来也算得出来,读数照样要准),
  1254. * 推给相机需要实例。早先两者写在一起,导致"引擎就绪前进区"时 `getAreaLimits()` 还是旧的,
  1255. * 界面会显示 locked=false —— 而状态明明已经锁了。
  1256. */
  1257. function applyAreaLimits() {
  1258. const home = planHome(getCockpitConfig().map || {})
  1259. const isPipeline = viewMode === VIEW_MODES.PIPELINE
  1260. const viewPlan = planViewMode(viewMode)
  1261. // 地面视图不受视图模式区间约束(`planViewMode` 给的是管线区间;地面只认区域与全局)。
  1262. areaLimits = planAreaLimits({
  1263. plan: primaryAreaPlan,
  1264. areaState,
  1265. homeRange: home.range,
  1266. viewMin: isPipeline ? viewPlan.minRange : null,
  1267. viewMax: isPipeline ? viewPlan.maxRange : null,
  1268. })
  1269. if (!instance || !instance.cameraControl) return areaLimits
  1270. instance.cameraControl.setRangeLimits({
  1271. min: areaLimits.min,
  1272. max: areaLimits.max,
  1273. // 区域未启用时传 null = 不限中心(`area` 为空即"区外自由视角")。
  1274. area: areaLimits.locked ? { bounds: areaLimits.bounds, groundMargin: areaLimits.groundMargin } : null,
  1275. })
  1276. return areaLimits
  1277. }
  1278. /** 当前生效的区域限位(走查与人工排查读它)。 */
  1279. export function getAreaLimits() {
  1280. return { ...areaLimits }
  1281. }
  1282. /** 当前区域状态(`locked` / `global`)。 */
  1283. export function getAreaState() {
  1284. return areaState
  1285. }
  1286. /**
  1287. * 进区 / 返回全球(ADR 0009 的**显式模式锁**)。
  1288. *
  1289. * 判定不依赖相机位置(贴边不会抖),只有这两条路能改状态:
  1290. * · `enter`(进入系统 / 归位)→ 进区锁定。主场景区不可用时**进不去**(票 36 的关闭路径),
  1291. * 返回 `blocked` 原因,调用方据此如实提示,不假装锁上了;
  1292. * · `returnGlobal` → 解除限位并阻尼飞到程序初始态全景,之后完全自由。
  1293. *
  1294. * @param {string} action `enter` | `returnGlobal`
  1295. * @returns {{applied: boolean, state: string, blocked: string}}
  1296. */
  1297. export function setAreaState(action) {
  1298. const result = transitionAreaState(areaState, action, primaryAreaPlan)
  1299. if (result.blocked) {
  1300. return { applied: false, state: areaState, blocked: result.blocked }
  1301. }
  1302. areaState = result.state
  1303. if (instance) instance.areaState = areaState
  1304. // ── 票 39:进出的**顺序**是这张票的核心,两处都按决策 08 的第 3 条排 ──
  1305. //
  1306. // 进入:雾**先**形成保护,闸门(`advanceHandover`)才可能放行影像渐显。
  1307. // 所以先 `retargetFogForArea`(把雾往锁定态推),再 `advanceHandover`。
  1308. //
  1309. // 返回:这里**故意不立刻撤雾**。决策 08 要求「精细影像在失去保护前收起」,
  1310. // 一按返回就撤雾会让影像在无保护状态下露出矩形边。正确顺序是:
  1311. // ① 影像立刻开始收起(与镜头起飞**并行**,避免"收完→停顿→才飞");
  1312. // ② 雾留到影像确实不可见之后,由 `maybeDropFogAfterConceal()` 撤除。
  1313. // 没有可交接的精细层时(没配叠加影像 / 交接关闭)就退化成旧行为,立刻撤雾 ——
  1314. // 否则雾会永远撤不掉。
  1315. const handoverWillHandleFogDrop = areaState !== AREA_STATE.LOCKED && handoverHandlesFogDrop()
  1316. if (action === 'returnGlobal') {
  1317. // 先让交接进入"收起"(立刻排帧),雾暂不动。
  1318. // `force=true`:进出是离散动作,必须**这一次调用就定下**目标相位,
  1319. // 不能被稳态节流吞掉(否则按下返回后要等最多 120 ms 才开始收影像)。
  1320. advanceHandover(true)
  1321. }
  1322. if (!handoverWillHandleFogDrop) {
  1323. retargetFogForArea(areaState)
  1324. }
  1325. if (action === 'returnGlobal') {
  1326. cancelPointLocate()
  1327. // 解除限位后再飞:先松绑,飞行途中就不会被区域上限一路夹着(ADR 0009「解除限位 + 阻尼飞到全景」)。
  1328. rebindAreaLimits()
  1329. stopSpin()
  1330. // 回程序初始态全景(heading 0 / pitch −60 / range 20,000,000)。
  1331. // 中心沿用当前位置 —— 全景是"退到星球尺度",不是"换个地方"。
  1332. //
  1333. // `flightMs`:**按时长 + 慢起缓动**飞回去,而不是走阻尼。
  1334. // 目标 range 是 20,000,000 m、起点 12,000 m(跨 1.7e6 倍),阻尼的指数逼近
  1335. // 起步最快,头一帧就越过整条雾带(20–42 km,实算 0.15 ms)——
  1336. // 观感就是"退出太快、雾一瞬间就散了",且那时雾还满着 ⇒ 整个地球被涂成雾色。
  1337. // 详见 `RETURN_FLIGHT_MS` 的注释。
  1338. //
  1339. // 票 40:目标**角度/高度**改用**引导视角基准**(`introView` —— 引擎挂载时反读的
  1340. // Cesium Viewer 默认相机,维护者裁定初始引导页就是那张"整个地球"的视角,
  1341. // `INITIAL_PANORAMA` 俯角/距离都不对);**中心仍沿用当前位置** ——
  1342. // 全景是"退到星球尺度",不是"换个地方"。
  1343. const current = getCameraState()
  1344. const pivot = (current && current.pivot) || getHomeView()
  1345. const targetHpr = introView
  1346. ? introView.hpr
  1347. : INITIAL_PANORAMA
  1348. applyViewPreset(pivot, targetHpr, {
  1349. flightMs: instance && instance.cameraControl
  1350. ? instance.cameraControl.getState().returnFlightMs
  1351. : RETURN_FLIGHT_MS,
  1352. })
  1353. } else {
  1354. // 进区:先把限位换成区内,再夹一次当前值 —— 否则「进区时 range 已在 12 km 以外」
  1355. // 要等用户滚一次才被夹(决策票 03 记的 4.7× 突跳就是这么来的)。
  1356. rebindAreaLimits()
  1357. if (instance && instance.cameraControl) instance.cameraControl.clampToLimits()
  1358. // 雾已按新状态推进(上面 `retargetFogForArea`)——此刻闸门读到的强度就是最新的,
  1359. // 于是"雾没到位 → 不放行渐显"这条在**同一次调用内**就生效,不留一帧窗口。
  1360. // `force=true`:进区是离散动作,同样不该被稳态节流吞掉。
  1361. advanceHandover(true)
  1362. }
  1363. paintAreaState()
  1364. publishDevHook()
  1365. return { applied: true, state: areaState, blocked: '' }
  1366. }
  1367. /** 限位变化后重新下发(进区 / 返回全球 / 切视图模式都要走它)。 */
  1368. function rebindAreaLimits() {
  1369. applyAreaLimits()
  1370. }
  1371. /** 区域状态写进宿主 dataset(走查断言面;人工排查也读它)。 */
  1372. function paintAreaState() {
  1373. if (!instance || !isDomHost(instance.host)) return
  1374. instance.host.dataset.mapAreaState = areaState
  1375. instance.host.dataset.mapAreaLocked = areaLimits.locked ? 'yes' : 'no'
  1376. instance.host.dataset.mapRangeMax = String(Math.round(areaLimits.max))
  1377. }
  1378. // ---- 票 38:区内视距雾气 ----
  1379. /**
  1380. * 雾的策略与生命周期**唯一所有者**(规格:引擎只负责一遍全屏 pass,决定"什么时候多浓"的在这里)。
  1381. *
  1382. * 状态分两层,与决策票 07 的措辞严格对应:
  1383. * · `supported` = 当前环境**有没有**深度能力(`Context.depthTexture`,布尔能力标志);
  1384. * · `executable` = 处理级**是否可执行**(`stage.ready`);
  1385. * · `strength` = 当前强度。
  1386. * **三者都不等于"矩形边缘已被遮住"** —— 对外读数里 `coversEdges` 恒为 false,
  1387. * 那是票 39 的交接判断,不在这里承诺。
  1388. */
  1389. let fogParams = planFog({})
  1390. let fogLifecycle = createFogLifecycle()
  1391. let fogHandle = null
  1392. let fogSupported = true
  1393. let fogExecutable = false
  1394. let fogReason = ''
  1395. // ---- 一张图调色(科技蓝滤镜):常驻全屏处理级,无进出生命周期,只有配置一个来源。----
  1396. let gradeParams = planSceneGrade({})
  1397. let gradeHandle = null
  1398. /**
  1399. * 是否曾经真正启用过 / 是否**曾经**观测到 `ready === true`(shader 编译并跑通过一遍)。
  1400. *
  1401. * 为什么不能拿"当前 ready"当判据:它**随渲染活动变化** —— 处理级在 `update()` 里被置 true,
  1402. * 一旦不再渲染(隐藏标签页、强度回 0 关掉处理级),下一次读就可能落回 false。
  1403. * 本票真机验证时实测到过这一幕:同一个 stage 先 `executable:true`(跑通了),
  1404. * 标签页转入隐藏后读数又变 false。若把它当"能不能用",读数会在「正常 ↔ 失败」间来回跳。
  1405. * 声明**必须放在 `paintFog` 之前**(它在渲染循环里就会写这两个标志)。
  1406. */
  1407. let fogEverEnabled = false
  1408. let fogEverReady = false
  1409. /**
  1410. * 是否**确凿地失败过**(创建抛错)。
  1411. *
  1412. * 这是 `failed` 状态的**唯一**来源。刻意不用"有没有 handle""此刻 ready 吗"这类**间接推断**:
  1413. * · 引擎还没起来时没有 handle,那不是故障(只是在启动);
  1414. * · 隐藏标签页不渲染时 `ready` 会落回 false,那也不是故障。
  1415. * 两者若被当成失败,界面就会在最不该报警的时刻报"视距雾不可用"(本票真机验证时撞上过)。
  1416. * 失败只由确凿证据判定:创建抛错,或环境不支持深度(后者走 `supported` → `unsupported`)。
  1417. */
  1418. let fogCreationFailed = false
  1419. let fogDegrade = { level: FOG_DEGRADE.NONE, strengthCap: 1, reason: '' }
  1420. /** 是否观察到管线视图下透明对象被错误雾化(由 DEV 把手/走查置位,默认未观察到)。 */
  1421. let fogArtifacts = false
  1422. /** 用户是否显式暂关(管线可读性优先时的人工开关)。 */
  1423. let fogUserDisabled = false
  1424. let fogFrameHandle = null
  1425. /** 性能采样(人工触发;不改动相机/参数)。 */
  1426. let fogPerfSample = null
  1427. let fogPerfResults = []
  1428. /** `scene.postRender` 的移除函数(采样结束时调)。 */
  1429. let fogPerfRemoveListener = null
  1430. /**
  1431. * 上一次 `paintFog()` 写进引擎时用到的**高度系数**。
  1432. *
  1433. * 用途:相机在飞行(进区/返回)时高度持续变化,而 `paintFog()` 只在
  1434. * 状态机推进(entering/exiting)期间才被逐帧调用;一旦状态机落定(locked/global),
  1435. * 高度变化就没人重画了 —— 那样"退出时高度把雾压到 0"就不会生效。
  1436. * 渲染循环因此比较这个值,变了才重画(不是每帧无脑重画)。
  1437. */
  1438. let fogPaintedAltitudeFactor = 1
  1439. /**
  1440. * **限速后的**高度系数(真正参与写入强度的那个值)。
  1441. *
  1442. * 为什么不直接用 `fogAltitudeFactor()` 的瞬时值:返回全球时 `range` 跨 1.7e6 倍,
  1443. * 雾带 20–42 km 只占其中 0.0001% —— 相机头一帧就越过整条带,瞬时系数会从 1 直接跳到 0,
  1444. * 观感是"雾一瞬间就散了"(维护者 2026-09-29 第二次反馈)。这里按时间限速收敛,
  1445. * 使画面上的变化不可能快于 `altitudeSlewMs`(见 `approachAltitudeFactor`)。
  1446. */
  1447. let fogAltitudeShown = 1
  1448. /** 上一次推进高度系数的时刻(`engineNow()`);0 = 还没推过(首帧 dt 按 0 算)。 */
  1449. let fogSlewAt = 0
  1450. /** 读当前相机椭球高度(米);取不到返回 null。 */
  1451. function cameraAltitude() {
  1452. if (!instance || !instance.viewer) return null
  1453. try {
  1454. const carto = instance.viewer.scene.globe.ellipsoid.cartesianToCartographic(instance.viewer.camera.positionWC)
  1455. return carto && Number.isFinite(carto.height) ? carto.height : null
  1456. } catch (error) {
  1457. return null
  1458. }
  1459. }
  1460. /**
  1461. * 把当前强度写进引擎 uniform 并刷新读数。
  1462. * **只改 uniform,不重建 stage**(规格:复用处理级对象,不在每次进出时 remove/destroy)。
  1463. */
  1464. /**
  1465. * **真正写进引擎的雾强度**(唯一口径)。
  1466. *
  1467. * 三层相乘,顺序即语义:
  1468. * 1. `fogLifecycle.strength` —— 状态机按进出时间推进出来的强度;
  1469. * 2. `fogDegrade.strengthCap` —— 降级上限(管线视图透明错位等);
  1470. * 3. `fogAltitudeShown` —— **相机高度收口**(见 `domain/fog.js`),且**按时间限速**。
  1471. *
  1472. * 为什么后两层都作用在"写入值"而不改进状态机:状态机只管进出时序,
  1473. * 保持它干净,恢复时不会因为中途被压低而错位。
  1474. *
  1475. * 为什么**必须**有这一个函数:交接闸门要判"雾保护是否成立",
  1476. * 它必须看**画面真的被雾罩住的强度**,而不是状态机那个数 ——
  1477. * 否则相机还在 60 km 高空(高度系数 0、画面毫无雾)时就被判成"保护成立"而揭示精细层,
  1478. * 矩形边缘当场露出来。
  1479. *
  1480. * 为什么第 3 层用**限速后的**系数而不是瞬时值:返回全球时 `range` 从 12 km 变到
  1481. * 20,000 km,相机头一帧就越过整条雾带(20–42 km),瞬时系数会让雾
  1482. * "一瞬间消失"(维护者 2026-09-29 第二次现场反馈)。限速后无论相机多快,
  1483. * 画面上的雾都至少花 `altitudeSlewMs` 变化。
  1484. */
  1485. function fogEffectiveStrength() {
  1486. const base = Math.min(fogLifecycle.strength, fogDegrade.strengthCap)
  1487. return Math.max(0, base * fogAltitudeShown)
  1488. }
  1489. /**
  1490. * 推进高度系数的限速(每个渲染帧一次)。
  1491. *
  1492. * 与 `paintFog` **分开**:`paintFog` 会被很多路径调用(配置变更、进出动作、
  1493. * 读数刷新),若在它里面推进时间轴,同一帧被调多次就会把时间走快。
  1494. * 所以推进只挂在唯一的渲染帧钩子上(见 `postRender`)。
  1495. */
  1496. function stepAltitudeSlew() {
  1497. const now = engineNow()
  1498. const dt = fogSlewAt === 0 ? 0 : now - fogSlewAt
  1499. fogSlewAt = now
  1500. const target = fogAltitudeFactor(cameraAltitude(), fogParams)
  1501. fogAltitudeShown = approachAltitudeFactor(fogAltitudeShown, target, dt, fogParams)
  1502. return fogAltitudeShown
  1503. }
  1504. /**
  1505. * 参数(雾端点 / 高度带 / 限速时长)变更后把系数**立刻对齐**到新参数。
  1506. *
  1507. * 为什么不能只靠渲染帧的那一步步限速:配置一变,`fogParams` 换了,
  1508. * 系数却还按**旧参数**慢慢爬,读数与画面会短暂地"按两套参数各说各话"。
  1509. * 对齐是一次性修正,不是绕过限速 —— 之后的每次变化仍受 `altitudeSlewMs` 约束。
  1510. */
  1511. function setAltitudeSlewImmediate() {
  1512. fogAltitudeShown = fogAltitudeFactor(cameraAltitude(), fogParams)
  1513. fogSlewAt = 0
  1514. fogPaintedAltitudeFactor = fogAltitudeShown
  1515. return fogAltitudeShown
  1516. }
  1517. function paintFog() {
  1518. if (!instance || !isDomHost(instance.host)) return
  1519. const colorRgb = parseHexColor(fogParams.color)
  1520. const effective = fogEffectiveStrength()
  1521. // 记下这一帧用到的系数,供"相机飞行时按需重画"判断(见渲染循环)。
  1522. fogPaintedAltitudeFactor = fogAltitudeShown
  1523. if (fogHandle) {
  1524. instance.engine.applyFogUniforms(fogHandle, {
  1525. strength: effective,
  1526. colorRgb,
  1527. startDistance: fogParams.startDistance,
  1528. endDistance: fogParams.endDistance,
  1529. })
  1530. // `ready` 只在启用后才有意义(见 `ensureFogStage` 的注释)。一旦强度 > 0 就记下"启用过",
  1531. // 并**记住曾经 ready 过**(之后隐藏标签页会让 ready 落回 false,但不该改口说"没跑通过")。
  1532. if (effective > 0) fogEverEnabled = true
  1533. fogExecutable = instance.engine.fogStageExecutable(fogHandle)
  1534. if (fogExecutable) fogEverReady = true
  1535. }
  1536. const readout = fogReadout({
  1537. state: fogLifecycle,
  1538. degrade: fogDegrade,
  1539. params: fogParams,
  1540. supported: fogSupported,
  1541. // **待命不是故障**:没启用过就不把 executable=false 当成 failed。
  1542. executable: fogExecutableForReadout(),
  1543. reason: fogReason,
  1544. })
  1545. readout.strength = Number(effective.toFixed(4))
  1546. const host = instance.host
  1547. host.dataset.mapFog = readout.state
  1548. host.dataset.mapFogPhase = readout.phase
  1549. host.dataset.mapFogStrength = String(readout.strength)
  1550. host.dataset.mapFogDegrade = readout.degrade
  1551. host.dataset.mapFogStart = String(Math.round(readout.startDistance))
  1552. host.dataset.mapFogEnd = String(Math.round(readout.endDistance))
  1553. host.dataset.mapFogReason = readout.reason
  1554. // 显式写出来,防止日后有人把它当成"已遮缝"来读(规格禁止这种承诺)。
  1555. host.dataset.mapFogCoversEdges = 'no'
  1556. host.dataset.mapFogStages = String(instance.viewer && instance.engine.hasFogStage(instance.viewer) ? 1 : 0)
  1557. }
  1558. /** 按当前降级选择重算上限(视图模式变化 / 观察到透明错位 / 用户暂关时调)。 */
  1559. function refreshFogDegrade() {
  1560. fogDegrade = planFogDegrade({
  1561. viewMode,
  1562. translucentArtifacts: fogArtifacts,
  1563. userDisabled: fogUserDisabled,
  1564. supported: fogSupported,
  1565. })
  1566. return fogDegrade
  1567. }
  1568. /**
  1569. * 立刻把强度推到某个值(进/出的**起点**由状态机给,这里只负责一步到位)。
  1570. * 用于"取消进出":立刻改成收起,不把正在变亮的过程放完(决策 08 第 1 轮 Q3=A)。
  1571. */
  1572. function settleFog(phase) {
  1573. if (phase === FOG_PHASE.LOCKED) fogLifecycle = { ...fogLifecycle, phase: FOG_PHASE.LOCKED, strength: 1 }
  1574. else fogLifecycle = { ...fogLifecycle, phase: FOG_PHASE.GLOBAL, strength: 0 }
  1575. stopFogLoop()
  1576. paintFog()
  1577. }
  1578. /** 每帧推进(只在 entering/exiting 期间排帧;落定即停,不留常驻 rAF)。 */
  1579. function tickFog() {
  1580. fogFrameHandle = null
  1581. if (fogLifecycle.phase !== FOG_PHASE.ENTERING && fogLifecycle.phase !== FOG_PHASE.EXITING) return
  1582. const now = engineNow()
  1583. const before = fogLifecycle
  1584. fogLifecycle = stepFogLifecycle(fogLifecycle, now, fogParams)
  1585. paintFog()
  1586. if (fogLifecycle.phase === FOG_PHASE.ENTERING || fogLifecycle.phase === FOG_PHASE.EXITING) {
  1587. scheduleFogFrame()
  1588. } else if (before.phase !== fogLifecycle.phase) {
  1589. // 落定:把最终读数再刷一次(强度精确到 0/1)。
  1590. paintFog()
  1591. }
  1592. }
  1593. function engineNow() {
  1594. return typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now()
  1595. }
  1596. function scheduleFogFrame() {
  1597. if (fogFrameHandle !== null) return
  1598. if (typeof requestAnimationFrame !== 'function') return
  1599. fogFrameHandle = requestAnimationFrame(tickFog)
  1600. }
  1601. function stopFogLoop() {
  1602. if (fogFrameHandle !== null && typeof cancelAnimationFrame === 'function') {
  1603. cancelAnimationFrame(fogFrameHandle)
  1604. }
  1605. fogFrameHandle = null
  1606. }
  1607. /**
  1608. * 进入 / 返回时的雾门控转移(票 37 的 `setAreaState` 会调它)。
  1609. *
  1610. * 规格:`locked` 期间生效;纯全球/引导态关闭。进入途中按**相机高度**给早期强度输入,
  1611. * 但那只是强度输入、**不是**"可揭示精细影像"的凭据(票 39 不得据此放行影像渐显)。
  1612. *
  1613. * ── 「过期操作不得生效」是怎么保证的(规格要求的一条,值得写清楚)──
  1614. * 不是靠"回调里比 token",而是**构造性**的:每次推进都由 `tickFog` 重新读模块级的
  1615. * `fogLifecycle`,**没有任何一处捕获旧状态的闭包**。于是"进 → 立刻返回 → 再进"这种快速反向,
  1616. * 下一帧读到的必然是最新状态,旧的那轮淡出**在结构上就没有机会**把新进入关掉。
  1617. * `beginFogTransition` 仍递增 `token` 供读数与断言使用(`domain/fog.js` 有 `isStaleFogToken`
  1618. * 作显式判据,服务层不重复造一套)。
  1619. */
  1620. function retargetFogForArea(nextAreaState) {
  1621. if (!fogParams.enabled) {
  1622. settleFog(FOG_PHASE.GLOBAL)
  1623. fogReason = '已由配置关闭自建雾(map.fog.enabled=false)'
  1624. paintFog()
  1625. return
  1626. }
  1627. refreshFogDegrade()
  1628. if (nextAreaState === AREA_STATE.LOCKED) {
  1629. // 进区:先把高度驱动的早期强度作为起点(飞入途中就已经有雾,不是落地才出现)。
  1630. const altitude = cameraAltitude()
  1631. const seed = Number.isFinite(altitude) ? enterAltitudeStrength(altitude, fogParams) : 0
  1632. fogLifecycle = beginFogTransition(
  1633. { ...fogLifecycle, strength: Math.max(fogLifecycle.strength, seed) },
  1634. 'enter',
  1635. engineNow(),
  1636. fogParams,
  1637. )
  1638. paintFog()
  1639. scheduleFogFrame()
  1640. return
  1641. }
  1642. // 返回全球:淡出(基准 0.8 s;决策 08 要求"比原型稍快",时长可配)。
  1643. fogLifecycle = beginFogTransition(fogLifecycle, 'exit', engineNow(), fogParams)
  1644. paintFog()
  1645. scheduleFogFrame()
  1646. }
  1647. /**
  1648. * 在场景上建**唯一**一个自建雾处理级(票 38)。幂等:同一 viewer 只会有一个。
  1649. *
  1650. * ── 一个必须讲清楚的 Cesium 事实(本票真机验证时抓到的坑)──
  1651. * `PostProcessStage.ready` **只有在处理级被 `enabled` 时的那次 update 里才会置 true**
  1652. * (源码:`_ready = false` 在构造里,置 true 发生在 `update()` 内的 uniform/纹理就绪分支)。
  1653. * 所以「强度 0 → stage.enabled=false → ready 永远是 false」。
  1654. * 如果拿 `ready` 直接当"能不能用"的判据,就会把**一个正常待命的雾**报成 `failed`
  1655. * ——本票第一版正是这样:区外读数一直是 `state=failed`,而实际只是还没启用。
  1656. *
  1657. * 因此这里分开两件事:
  1658. * · `supported` = 环境有没有深度能力(**这才是"能不能用"**,与 enabled 无关);
  1659. * · `executable`(= `ready`)= "**已经跑过一遍并且成功了**",只在启用后才有意义。
  1660. * 读数里 `executable=false` 且从未启用过时**不报 failed**,那是"待命"。
  1661. */
  1662. function ensureFogStage() {
  1663. if (!instance || !instance.engine || !instance.viewer) return false
  1664. if (fogHandle) return true
  1665. if (!fogParams.enabled) {
  1666. fogSupported = true
  1667. fogExecutable = false
  1668. fogReason = '已由配置关闭自建雾(map.fog.enabled=false)'
  1669. return false
  1670. }
  1671. try {
  1672. fogHandle = instance.engine.createFogStage(instance.viewer, fogParams)
  1673. fogSupported = fogHandle.supported !== false
  1674. fogExecutable = instance.engine.fogStageExecutable(fogHandle)
  1675. if (!fogSupported) {
  1676. fogReason = '当前环境不支持深度纹理,自建雾整级禁用(业务可用优先)'
  1677. } else {
  1678. // 注意:这里**不**因为 ready=false 而写 failed/reason —— 待命不是故障(见上方注释)。
  1679. fogReason = ''
  1680. }
  1681. refreshFogDegrade()
  1682. return true
  1683. } catch (error) {
  1684. // **确凿证据**:创建抛错 ⇒ 这才是 failed 的唯一来源。
  1685. fogHandle = null
  1686. fogSupported = false
  1687. fogCreationFailed = true
  1688. fogExecutable = false
  1689. fogReason = `自建雾创建失败:${String((error && error.message) || error)}`
  1690. refreshFogDegrade()
  1691. return false
  1692. }
  1693. }
  1694. // ---- 一张图调色(科技蓝滤镜):建级与写 uniform。与雾同一条「复用不重建」纪律。----
  1695. /** 在场景上建**唯一**一个调色处理级;配置关掉时不建(幂等)。 */
  1696. function ensureGradeStage() {
  1697. if (!instance || !instance.engine || !instance.viewer) return false
  1698. if (gradeHandle) return true
  1699. if (!gradeParams.enabled) return false
  1700. try {
  1701. gradeHandle = instance.engine.createSceneGradeStage(instance.viewer, {
  1702. ...gradeParams,
  1703. // 引擎吃 0..1 的 rgb;hex 解析沿用雾的同一份 parseHexColor。
  1704. tintRgb: parseHexColor(gradeParams.tint) || undefined,
  1705. })
  1706. return true
  1707. } catch (error) {
  1708. // 建失败只是这层滤镜缺席,不挡业务(与雾的失败立场一致)。
  1709. gradeHandle = null
  1710. console.warn('[mapService] 场景调色处理级创建失败,已跳过', error)
  1711. return false
  1712. }
  1713. }
  1714. /** 把当前计划写进 uniform 与 dataset 读数(走查可读 `data-map-grade`)。 */
  1715. function paintGrade() {
  1716. // 配置把 enabled 翻开而处理级还没建(启动时关、后来配置翻开):这里补建(幂等)。
  1717. if (!gradeHandle && gradeParams.enabled) ensureGradeStage()
  1718. if (gradeHandle && instance && instance.engine) {
  1719. instance.engine.applySceneGradeUniforms(gradeHandle, {
  1720. ...gradeParams,
  1721. tintRgb: parseHexColor(gradeParams.tint) || undefined,
  1722. })
  1723. }
  1724. if (instance && isDomHost(instance.host)) {
  1725. // 读数三态:on(在跑)/ off(配置关)/ pending(配置要但没建成 —— 引擎未就绪或建级失败)。
  1726. instance.host.dataset.mapGrade = !gradeParams.enabled ? 'off' : gradeHandle ? 'on' : 'pending'
  1727. }
  1728. }
  1729. /**
  1730. * 「可执行」的**唯一**口径(防止散在各调用点各写一遍而漂移)。
  1731. *
  1732. * 语义=**没有确凿的失败证据**(而不是"此刻 ready"):
  1733. * · 深度不支持 → false(`supported` 会报 unsupported);
  1734. * · 创建抛错 → false(`fogCreationFailed`,`fogReason` 写明原因);
  1735. * · 其余 → true。包括"引擎还没起来(无 handle)"与"强度 0 待命(ready 未置位)"
  1736. * —— 这两种都是**正常过程**,报 failed 会在最不该报警的时刻误报。
  1737. *
  1738. * 由此**不需要**"等几秒判失败"的宽限期,也不需要看 `ready`:
  1739. * 隐藏标签页不渲染是正常状态,据它判失败就是把正常情况报成故障(真机验证时撞上过)。
  1740. * 是否"已经跑通过"另由 `fogRanOnceForReadout()` 如实给出,供排查区分待命与跑过。
  1741. */
  1742. function fogExecutableForReadout() {
  1743. return fogSupported && !fogCreationFailed
  1744. }
  1745. /** 是否"曾经跑通过一遍"(与 `executable` 分开报,便于排查与走查区分待命/跑过)。 */
  1746. function fogRanOnceForReadout() {
  1747. return fogEverReady
  1748. }
  1749. /** 当前雾的完整读数(走查与人工排查读它)。 */
  1750. export function getFogState() {
  1751. const readout = fogReadout({
  1752. state: fogLifecycle,
  1753. degrade: fogDegrade,
  1754. params: fogParams,
  1755. supported: fogSupported,
  1756. executable: fogExecutableForReadout(),
  1757. reason: fogReason,
  1758. })
  1759. return {
  1760. ...readout,
  1761. params: { ...fogParams },
  1762. /** 是否已把处理级挂上场景(**不是**"画面已遮住")。 */
  1763. staged: !!(fogHandle && instance && instance.engine && instance.viewer && instance.engine.hasFogStage(instance.viewer)),
  1764. /**
  1765. * 是否**曾经跑通过一遍**(`ready` 曾为 true)。
  1766. * 与 `executable` 分开报:隐藏标签页不渲染时当前 `ready` 会落回 false,
  1767. * 但"跑通过"这个事实不该改口 —— 排查时两者一起看才分得清「待命」与「跑过」。
  1768. */
  1769. ranOnce: fogRanOnceForReadout(),
  1770. /**
  1771. * DEV 诊断:对比"服务层以为写进去的强度"与"Cesium 真正喂给 shader 的强度"。
  1772. * 两者不一致 ⇒ uniform 通道没接对(常量快照 vs 函数每帧取)。
  1773. * 本票踩着这个坑:读数说强度 1,画面却什么都没有。
  1774. */
  1775. uniformDiagnostics: fogHandle && typeof fogHandle.readUniformDiagnostics === 'function' ? fogHandle.readUniformDiagnostics() : null,
  1776. perfResults: fogPerfResults.slice(),
  1777. }
  1778. }
  1779. /** 当前雾参数计划(配置 + 规格默认的合成结果)。 */
  1780. export function getFogParams() {
  1781. return { ...fogParams }
  1782. }
  1783. /**
  1784. * 人工/走查用:声明"是否观察到管线视图透明对象被错误雾化"。
  1785. * 规格:**正常情况不预先关闭**,只有真的观察到才减弱;相机限位不受影响。
  1786. */
  1787. export function setFogTranslucentArtifacts(observed) {
  1788. fogArtifacts = observed === true
  1789. refreshFogDegrade()
  1790. paintFog()
  1791. return { ...fogDegrade }
  1792. }
  1793. /** 人工/走查用:显式暂关/恢复自建雾(可读性优先开关)。 */
  1794. export function setFogUserDisabled(disabled) {
  1795. fogUserDisabled = disabled === true
  1796. refreshFogDegrade()
  1797. paintFog()
  1798. return { ...fogDegrade }
  1799. }
  1800. /**
  1801. * 性能采样:在**固定视角与参数**下收集帧间隔,算 P95。
  1802. * 与原型一样明确标注:这是**渲染事件间隔**,不是 GPU 时间,也不能单独判定达标。
  1803. *
  1804. * 帧源用 `scene.postRender`(每渲染一帧一次),而不是 rAF —— rAF 在标签页不可见时会停,
  1805. * 采样会静默收集不到数据;postRender 只在真的渲染时触发,与"帧耗时"语义更贴。
  1806. */
  1807. export function startFogPerfSample(label = '') {
  1808. stopFogPerfSampleListener()
  1809. fogPerfSample = { label, at: engineNow(), previous: null, frames: [] }
  1810. if (instance && instance.viewer && instance.viewer.scene && instance.viewer.scene.postRender) {
  1811. fogPerfRemoveListener = instance.viewer.scene.postRender.addEventListener(noteFogFrame)
  1812. }
  1813. return { started: true, label, listening: fogPerfRemoveListener !== null }
  1814. }
  1815. function stopFogPerfSampleListener() {
  1816. if (typeof fogPerfRemoveListener === 'function') {
  1817. try {
  1818. fogPerfRemoveListener()
  1819. } catch (error) {
  1820. // 场景已销毁时移除监听会抛:忽略即可,下面会重建。
  1821. }
  1822. }
  1823. fogPerfRemoveListener = null
  1824. }
  1825. export function stopFogPerfSample() {
  1826. const result = collectFogPerfSample()
  1827. stopFogPerfSampleListener()
  1828. fogPerfSample = null
  1829. return result
  1830. }
  1831. function collectFogPerfSample() {
  1832. if (!fogPerfSample) return null
  1833. const frames = fogPerfSample.frames.slice().sort((a, b) => a - b)
  1834. const p95 = frames.length ? frames[Math.ceil(frames.length * 0.95) - 1] : null
  1835. const result = {
  1836. label: fogPerfSample.label,
  1837. count: frames.length,
  1838. p95FrameIntervalMs: p95,
  1839. strength: Number(fogLifecycle.strength.toFixed(3)),
  1840. fogState: fogReadout({ state: fogLifecycle, degrade: fogDegrade, params: fogParams, supported: fogSupported, executable: fogExecutableForReadout() }).state,
  1841. note: '渲染事件间隔,不是 GPU 时间;不能单独判定 ≤15% 达标',
  1842. }
  1843. fogPerfResults.push(result)
  1844. if (fogPerfResults.length > 8) fogPerfResults.shift()
  1845. return result
  1846. }
  1847. /** 帧计数(由宿主每帧调;只在采样中累计)。 */
  1848. function noteFogFrame() {
  1849. if (!fogPerfSample) return
  1850. const now = engineNow()
  1851. if (fogPerfSample.previous !== null) fogPerfSample.frames.push(now - fogPerfSample.previous)
  1852. fogPerfSample.previous = now
  1853. }
  1854. // ---- 票 39:粗→精影像交接 ----
  1855. /**
  1856. * 精细影像交接的**策略与生命周期唯一所有者**(规格:引擎只负责"把 alpha 写进图层",
  1857. * 决定"什么时候该亮多少"的在这里)。
  1858. *
  1859. * 交接方向由决策票 08 固化:**全球/引导态隐藏精细层 → 雾先形成保护 → 再渐显 → 返回反向交接**。
  1860. * 本模块不复制一套影像状态机到别处,`domain/imageryHandover.js` 只做纯计算。
  1861. *
  1862. * ── 为什么"隐藏"用 alpha=0 而不是 show=false(关键实现事实)──
  1863. * 决策 08 第 1 轮 Q4=A:隐藏期间**可继续预取**瓦片,看不见不等于停止加载。
  1864. * 查过 Cesium 源码:`GlobeSurfaceTile.js:691` 按 **`layer.show`** 决定要不要建影像骨架,
  1865. * **不看 `alpha`**。所以 `alpha=0 + show=true` 才是"看不见但继续加载";
  1866. * 若用 `show=false`,瓦片会真的停掉,揭示瞬间才开始下载 → 正好造成"先空白再跳方块"。
  1867. */
  1868. let handoverParams = planHandover({})
  1869. let handoverLifecycle = createHandoverLifecycle()
  1870. let handoverBlock = HANDOVER_BLOCK.NONE
  1871. let handoverNote = ''
  1872. /** 精细层句柄(引擎侧 `applyOverlays` 返回)+ 瓦片就绪/失败读数。 */
  1873. let overlayHandles = []
  1874. let overlayReadiness = null
  1875. /**
  1876. * 交接帧循环句柄。
  1877. * 与雾那条 rAF 分开:雾的推进是"时间→强度",交接还有"瓦片就绪与否"这一外部条件,
  1878. * 两者节奏不同,合成一条会互相拖累(且雾落定后就不该再排帧)。
  1879. */
  1880. let handoverFrameHandle = null
  1881. /** 上一次写进宿主 dataset 的交接读数(避免每帧重写同样的值,见 `paintHandover`)。 */
  1882. let handoverPainted = {}
  1883. /**
  1884. * 精细影像覆盖矩形缓存。
  1885. *
  1886. * 为什么不每次现读:`currentEdgeVerdict()` 在渲染循环里被调,而
  1887. * `getCockpitConfig()` + 遍历 overlays 每帧跑一遍纯属浪费;影像矩形是**配置事实**,
  1888. * 引擎 boot 时定下就不变(换配置要重挂地图)。`applyPrimaryArea` 时失效重算。
  1889. */
  1890. let overlayBoundsCache = null
  1891. let overlayBoundsResolved = false
  1892. /** 稳态节流:下一次允许重新评估闸门的时间戳(见 `advanceHandover`)。 */
  1893. const HANDOVER_STEADY_EVAL_MS = 120
  1894. let handoverNextEvalAt = 0
  1895. /** 本帧算过的边缘判据(`advanceHandover` 写、`paintHandover` 复用,避免每帧算两遍)。 */
  1896. let handoverEdgeCache = null
  1897. /** 当前生效的交接参数(走查与人工排查读它)。 */
  1898. export function getHandoverParams() {
  1899. return { ...handoverParams }
  1900. }
  1901. /** 交接是否已由配置关闭。 */
  1902. export function handoverEnabled() {
  1903. return handoverParams.enabled !== false
  1904. }
  1905. /** 精细层当前是否有可交接的层(没配叠加影像时交接无事可做)。 */
  1906. export function hasOverlayLayers() {
  1907. return overlayHandles.length > 0
  1908. }
  1909. /**
  1910. * 把交接读数写进宿主 dataset(走查断言面;人工排查也读它)。
  1911. *
  1912. * 刻意**不**写任何"已遮边"的布尔承诺:本票能给的是"精细层 alpha / 阶段 / 被什么挡住 /
  1913. * 瓦片信号是什么性质",「矩形边是否真被遮住」归维护者目视(票 38 的同款纪律)。
  1914. */
  1915. function paintHandover(force = false) {
  1916. if (!instance || !isDomHost(instance.host)) return
  1917. const readout = handoverReadout({
  1918. state: handoverLifecycle,
  1919. block: handoverBlock,
  1920. note: handoverNote,
  1921. edge: handoverEdgeCache || currentEdgeVerdict(),
  1922. tiles: overlayReadiness ? overlayReadiness.state : null,
  1923. degrade: !fogSupported || !fogExecutableForReadout(),
  1924. enabled: handoverEnabled(),
  1925. })
  1926. // ── 为什么要把写入 diff 掉(性能预算 ≤15%,本函数在渲染循环里被调)──
  1927. // 交接读数每帧都刷会让每帧产生 8 次 DOM 属性写入 + 字符串拼接;稳态下这些值**根本不变**。
  1928. // 只在值真的变了才写,稳态帧的这部分开销降到一次对象比较。
  1929. const next = {
  1930. mapHandover: readout.phase,
  1931. mapHandoverAlpha: String(readout.alpha),
  1932. mapHandoverBlock: readout.block,
  1933. mapHandoverNote: readout.note,
  1934. mapHandoverLayers: String(overlayHandles.length),
  1935. mapHandoverEdgeKm: readout.edgeDistanceM === null ? '' : String(Math.round(readout.edgeDistanceM / 1000)),
  1936. mapHandoverDegrade: readout.degraded ? 'yes' : 'no',
  1937. // 显式写出来:瓦片信号是**代理**,不证明目标视野逐像素就绪(决策票 08 的警告)。
  1938. mapHandoverTilesProxy: 'yes',
  1939. }
  1940. const host = instance.host
  1941. for (const key of Object.keys(next)) {
  1942. if (force || handoverPainted[key] !== next[key]) {
  1943. host.dataset[key] = next[key]
  1944. handoverPainted[key] = next[key]
  1945. }
  1946. }
  1947. }
  1948. /** 当前相机位置(经纬度,度);取不到返回 null。 */
  1949. function currentCameraLonLat() {
  1950. if (!instance || !instance.viewer) return null
  1951. try {
  1952. const carto = instance.viewer.scene.globe.ellipsoid.cartesianToCartographic(instance.viewer.camera.positionWC)
  1953. if (!carto) return null
  1954. // 不依赖引擎导出的 `Math`:弧度换角度是纯算术,自己算,免得 engine 没暴露这个符号。
  1955. const toDeg = (rad) => (rad * 180) / Math.PI
  1956. return { lon: toDeg(carto.longitude), lat: toDeg(carto.latitude) }
  1957. } catch (error) {
  1958. return null
  1959. }
  1960. }
  1961. /** 边缘保护判据(把当前相机与影像矩形喂给纯函数)。 */
  1962. function currentEdgeVerdict() {
  1963. if (!primaryAreaPlan || !primaryAreaPlan.enabled) return null
  1964. const camera = currentCameraLonLat()
  1965. if (!camera) return null
  1966. // 用**影像矩形**(精细层覆盖范围)判,而不是主场景区矩形 —— 会露边的是影像边界。
  1967. const bounds = overlayBounds()
  1968. if (!bounds) return null
  1969. return edgeOcclusionVerdict(camera, bounds, fogParams, handoverParams.minEdgeOcclusion)
  1970. }
  1971. /** 精细影像的覆盖矩形(取第一个配了 bounds 的叠加层;没配就没有"边"可言)。 */
  1972. function overlayBounds() {
  1973. if (overlayBoundsResolved) return overlayBoundsCache
  1974. const source = (getCockpitConfig().map && getCockpitConfig().map.overlays) || []
  1975. overlayBoundsCache = null
  1976. for (const item of source) {
  1977. if (Array.isArray(item && item.bounds) && item.bounds.length === 4) {
  1978. overlayBoundsCache = item.bounds
  1979. break
  1980. }
  1981. }
  1982. overlayBoundsResolved = true
  1983. return overlayBoundsCache
  1984. }
  1985. /**
  1986. * 把交接状态对应的 alpha 写进精细层 —— **alpha 的唯一写入点**。
  1987. *
  1988. * 生产与 DEV 原型不得同时驱动 alpha(会互相打架)。因此这里先问一句
  1989. * "原型是否正在接管",是则让位(见 `setHandoverPrototypeActive`)。
  1990. */
  1991. function applyHandoverAlpha() {
  1992. if (handoverPrototypeActive) return 0
  1993. // 交接被配置关掉 = 回到"精细层常显"(票 39 之前的行为):把 alpha 还到 1。
  1994. // 否则"关掉交接"反而会让精细层永远隐形 —— 那是最糟的失败模式。
  1995. const target = handoverEnabled() ? handoverLifecycle.alpha : 1
  1996. let written = 0
  1997. for (const handle of overlayHandles) {
  1998. if (handle && typeof handle.setAlpha === 'function' && handle.alive()) written = handle.setAlpha(target)
  1999. }
  2000. if (written === 0 && overlayHandles.length > 0) written = target
  2001. return written
  2002. }
  2003. /**
  2004. * DEV 原型接管开关(**票 39 = 隔离,不删除**)。
  2005. *
  2006. * 维护者 2026-09-28 裁定:39 只负责让生产交接成为 alpha 的唯一写入方、且原型不得干扰生产;
  2007. * 删除与归档(存 throwaway 分支、留分支指针)归票 40。两者都写同一批 `layer.alpha`,
  2008. * 同时跑必然互相覆盖 —— 所以用这个开关显式让位,而不是靠"谁后跑谁赢"。
  2009. */
  2010. let handoverPrototypeActive = false
  2011. /** 原型挂载时置 true(让生产交接交出 alpha 写入权);卸载时置 false 并重画一次。 */
  2012. export function setHandoverPrototypeActive(active) {
  2013. handoverPrototypeActive = !!active
  2014. if (!handoverPrototypeActive) {
  2015. applyHandoverAlpha()
  2016. paintHandover()
  2017. }
  2018. return handoverPrototypeActive
  2019. }
  2020. /** 当前 alpha 是否由原型接管(读数/走查用)。 */
  2021. export function isHandoverPrototypeActive() {
  2022. return handoverPrototypeActive
  2023. }
  2024. /**
  2025. * 推进交接一帧(**每渲染帧调一次**,由 `postRender` 驱动)。
  2026. *
  2027. * ── 为什么不再自带一条 rAF 循环(本票实现中删掉的一个多余机制)──
  2028. * 第一版仿照雾的做法又排了一条 `requestAnimationFrame` 链。但 `advanceHandover` 已经挂在
  2029. * `scene.postRender` 上**每渲染帧**被调(见 `bootEngine`),再排 rAF 是**同一件事做两遍**:
  2030. * 多一条循环、多一处取消时机要维护,而且两者节奏可能错开(rAF 与渲染帧并不同步)。
  2031. * 更实际的坏处是**一条隐 bug**:rAF 在标签页隐藏时被冻结,于是"隐藏标签页里推进交接"
  2032. * 只能靠手动调 `advanceHandover()`;把推进并进 `advanceHandover` 之后,
  2033. * 手动调一次就能推一帧(可断言、可走查),不必依赖 rAF 是否还活着。
  2034. * 雾那条 rAF 保留:它的推进是纯时间驱动,与"有没有渲染帧"无关。
  2035. *
  2036. * ── 「过期操作不得生效」怎么保证 ──
  2037. * 与雾同款、**构造性**的:每帧都重新读模块级的 `handoverLifecycle`,
  2038. * **没有任何一处捕获旧状态的闭包**。于是"进 → 立刻返回 → 再进"这种快速反向,
  2039. * 下一帧读到的必然是最新状态,旧的那轮推进在结构上就没有机会把新目标覆盖掉。
  2040. */
  2041. function stepHandoverFrame() {
  2042. if (handoverLifecycle.phase !== HANDOVER_PHASE.ENTERING && handoverLifecycle.phase !== HANDOVER_PHASE.RETURNING) return false
  2043. const before = handoverLifecycle
  2044. handoverLifecycle = stepHandover(handoverLifecycle, engineNow(), handoverParams)
  2045. applyHandoverAlpha()
  2046. // 返回途中:精细层**确实收起后**才允许撤雾(决策 08 第 3 条:影像先收起、保护不断档)。
  2047. if (before.phase === HANDOVER_PHASE.RETURNING) {
  2048. const sync = returnSyncVerdict({
  2049. phase: handoverLifecycle.phase,
  2050. alpha: handoverLifecycle.alpha,
  2051. areaState,
  2052. fogStrength: fogLifecycle.strength,
  2053. })
  2054. if (sync.mayDropFog) maybeDropFogAfterConceal()
  2055. }
  2056. return true
  2057. }
  2058. function stopHandoverLoop() {
  2059. if (handoverFrameHandle !== null && typeof cancelAnimationFrame === 'function') {
  2060. cancelAnimationFrame(handoverFrameHandle)
  2061. }
  2062. handoverFrameHandle = null
  2063. }
  2064. /**
  2065. * 返回全球时:精细层收起后才真正撤雾。
  2066. *
  2067. * 决策 08 第 3 条同时要求两件看似矛盾的事:
  2068. * · 「影像在失去保护前收起」→ 所以**不能**一按返回就撤雾;
  2069. * · 「不能影像收完→停顿→镜头才飞走」→ 所以**也不能**等影像收完才开始飞。
  2070. * 解法是把两者**并行**:按下返回的那一帧就同时(a)开始收影像、(b)开始飞镜头;
  2071. * 雾则延后到"影像确实不可见"再淡出。本函数负责 (b) 之后的这步"延后撤雾"。
  2072. */
  2073. function maybeDropFogAfterConceal() {
  2074. if (!fogSupported || !fogParams.enabled) return false
  2075. if (fogLifecycle.phase !== FOG_PHASE.LOCKED) return false
  2076. fogLifecycle = beginFogTransition(fogLifecycle, 'exit', engineNow(), fogParams)
  2077. paintFog()
  2078. scheduleFogFrame()
  2079. return true
  2080. }
  2081. /** 便捷谓词:本次返回是否由交接负责"延后撤雾"(见 `setAreaState` 的顺序注释)。 */
  2082. function handoverHandlesFogDrop() {
  2083. return handoverEnabled() && overlayHandles.length > 0
  2084. }
  2085. /**
  2086. * 哪些挡板属于"硬失败",要**撤回**已经在渐显的精细层(而不只是拦住下一次渐显)。
  2087. *
  2088. * 刻意**不含** `NO_FOG`:规格明确"雾失败且精细层已经可见时,不突然藏起来,
  2089. * 同时把遮缝承诺改成降级"(业务可用优先)。若把雾也当硬失败,
  2090. * 雾一抖就会把影像闪没 —— 那比"没有遮缝保护"更伤业务可用性。
  2091. */
  2092. function isHardRevealBlock(block) {
  2093. return block === HANDOVER_BLOCK.TILES_FAILED || block === HANDOVER_BLOCK.EDGE_RISK
  2094. }
  2095. /**
  2096. * 推进交接(由外壳的刷新轮询或进出动作调用)。
  2097. *
  2098. * 幂等:已经在目标阶段就不重开一轮(重复点"进入"不该把渐显推倒重来)。
  2099. */
  2100. function advanceHandover(force = false) {
  2101. if (!overlayHandles.length) {
  2102. paintHandover()
  2103. return handoverLifecycle
  2104. }
  2105. if (!handoverEnabled()) {
  2106. // 交接关闭:精细层常显(`applyHandoverAlpha` 会把 alpha 还到 1)。
  2107. handoverBlock = HANDOVER_BLOCK.NONE
  2108. handoverNote = '交接已由配置关闭(精细层常显)'
  2109. applyHandoverAlpha()
  2110. paintHandover()
  2111. return handoverLifecycle
  2112. }
  2113. // ── 稳态节流(性能预算 ≤15%)──
  2114. // 本函数在 `postRender` 里**每帧**被调。判闸门要读相机位置
  2115. // (`cartesianToCartographic` **每次分配新对象**)并算到矩形四边的距离,
  2116. // 而稳态下这些值**根本没变**,每帧重算纯属浪费。所以:
  2117. // · 过渡中(entering/returning):**每帧**推进 —— alpha 要平滑,不能跳;
  2118. // · 稳态(global/revealed):最多每 `HANDOVER_STEADY_EVAL_MS` 评一次"条件变了没"。
  2119. const transitioning = handoverLifecycle.phase === HANDOVER_PHASE.ENTERING
  2120. || handoverLifecycle.phase === HANDOVER_PHASE.RETURNING
  2121. const now = engineNow()
  2122. if (!force && !transitioning && now < handoverNextEvalAt) return handoverLifecycle
  2123. handoverNextEvalAt = now + HANDOVER_STEADY_EVAL_MS
  2124. // 边缘判据这一帧只算一次,`paintHandover` 复用这份(别在渲染循环里算两遍)。
  2125. const edge = currentEdgeVerdict()
  2126. handoverEdgeCache = edge
  2127. const gate = revealGate({
  2128. enabled: true,
  2129. areaState,
  2130. // **必须用"真正写进引擎的强度"**(含相机高度收口),不能只用状态机那个数:
  2131. // 相机还在 60 km 高空时高度系数为 0、画面其实毫无雾,
  2132. // 若拿状态机的 1 去判"保护成立",就会在高空揭示精细层、矩形边缘当场露出来。
  2133. fogStrength: fogEffectiveStrength(),
  2134. // 「可执行」沿用票 38 的统一口径(不是"此刻 ready",那会随渲染活动跳)。
  2135. fogExecutable: fogExecutableForReadout(),
  2136. fogSupported,
  2137. edgeOk: (edge || { ok: true }).ok,
  2138. tilesLoaded: overlayReadiness ? overlayReadiness.state.loaded : false,
  2139. tilesFailed: overlayReadiness ? overlayReadiness.state.failed > 0 : false,
  2140. params: handoverParams,
  2141. })
  2142. handoverBlock = gate.block
  2143. handoverNote = gate.note
  2144. // 过渡中:**先推进这一帧**(alpha 才平滑),再评闸门。
  2145. // 顺序有意:先走时间轴,再判断"条件还成立吗" —— 反过来的话,
  2146. // 停止条件(瓦片失败/边缘风险)变化时会先被拦住、把正在收的影像卡在半透明。
  2147. if (transitioning) stepHandoverFrame()
  2148. if (areaState === AREA_STATE.LOCKED && gate.allowed) {
  2149. handoverLifecycle = beginHandover(handoverLifecycle, 'reveal', engineNow())
  2150. } else if (areaState === AREA_STATE.LOCKED && transitioning && isHardRevealBlock(gate.block)) {
  2151. // **渐显途中**条件转硬失败(瓦片挂了 / 相机退到边缘危险区)→ 立刻反向收起。
  2152. // 规格:"瓦片失败 → 保持隐藏、继续用粗底图";继续把坏瓦片亮出来正是
  2153. // "先空白再跳方块"那类缺陷。注意**刻意不含 NO_FOG**:
  2154. // 规格明确"雾失败且精细层已经可见时,**不**突然藏起来"(业务可用优先),
  2155. // 所以雾这一路只影响"要不要开始渐显",不影响"已经在渐显的要不要退回去"。
  2156. handoverLifecycle = beginHandover(handoverLifecycle, 'conceal', engineNow())
  2157. } else if (areaState !== AREA_STATE.LOCKED && handoverLifecycle.phase !== HANDOVER_PHASE.GLOBAL) {
  2158. // 返回全球:立刻开始收影像(不等待、不拖延),雾由 `maybeDropFogAfterConceal` 延后撤。
  2159. handoverLifecycle = beginHandover(handoverLifecycle, 'conceal', engineNow())
  2160. } else if (areaState !== AREA_STATE.LOCKED && handoverHandlesFogDrop()) {
  2161. // **已经在 global 相位**(精细层本来就没揭示,例如刚启动就点返回):
  2162. // `beginHandover` 是幂等空操作、没有过渡帧可推,于是没人去撤雾 ——
  2163. // 那样雾会永远留在锁定强度上。这里显式补一次"是否该撤雾"的判断。
  2164. const sync = returnSyncVerdict({
  2165. phase: handoverLifecycle.phase,
  2166. alpha: handoverLifecycle.alpha,
  2167. areaState,
  2168. fogStrength: fogLifecycle.strength,
  2169. })
  2170. if (sync.mayDropFog) maybeDropFogAfterConceal()
  2171. }
  2172. applyHandoverAlpha()
  2173. paintHandover()
  2174. return handoverLifecycle
  2175. }
  2176. /**
  2177. * 挂上精细层句柄并开始驱动交接(引擎 boot 完成后由 `bootEngine` 调)。
  2178. * 幂等:重复调用只更新引用,不重复挂监听。
  2179. */
  2180. function attachHandoverLayers(engine, handles) {
  2181. overlayHandles = Array.isArray(handles) ? handles : []
  2182. if (overlayReadiness) overlayReadiness.detach()
  2183. overlayReadiness = engine.createOverlayReadiness(overlayHandles)
  2184. // 初始即"隐藏但继续加载":alpha=0、show 保持 true(见本节约首注释)。
  2185. applyHandoverAlpha()
  2186. paintHandover()
  2187. }
  2188. /**
  2189. * 交接读数(走查与人工排查读它)。**不提供任何"已遮边"承诺。**
  2190. */
  2191. export function getHandoverState() {
  2192. // 走查/人工排查读的是**当前**真实值,所以这里**不复用**稳态缓存
  2193. // (`handoverEdgeCache` 可能最多滞后一个节流窗口)。缓存只服务渲染循环那一路。
  2194. const readout = handoverReadout({
  2195. state: handoverLifecycle,
  2196. block: handoverBlock,
  2197. note: handoverNote,
  2198. edge: currentEdgeVerdict(),
  2199. tiles: overlayReadiness ? overlayReadiness.state : null,
  2200. degrade: !fogSupported || !fogExecutableForReadout(),
  2201. enabled: handoverEnabled(),
  2202. })
  2203. return {
  2204. ...readout,
  2205. layers: overlayHandles.length,
  2206. /** 原型是否正在接管 alpha(生产交接已让位)。 */
  2207. prototypeActive: handoverPrototypeActive,
  2208. /** 当前**实际**写在图层上的 alpha(与状态机读数对照,抓"读数对、图层没变")。 */
  2209. appliedAlpha: overlayHandles.length && overlayHandles[0] && typeof overlayHandles[0].alpha === 'number' ? overlayHandles[0].alpha : null,
  2210. tilesFailed: overlayReadiness ? overlayReadiness.state.failed : 0,
  2211. tilesLastError: overlayReadiness ? overlayReadiness.state.lastError : '',
  2212. params: { ...handoverParams },
  2213. /**
  2214. * 精细层的**真实图层状态**(不是我们的状态机读数)。
  2215. *
  2216. * 为什么要有它(票 38 的教训):读数全对、图层没变,是这类功能最难查的失败模式。
  2217. * 这里直接回读 `ImageryLayer`:`alpha` 必须等于状态机推的值,
  2218. * 而 `show` **必须仍为 true** —— 那正是"隐藏期间继续预取瓦片"的实现依据
  2219. * (`GlobeSurfaceTile` 按 `show` 决定要不要建影像骨架,不看 `alpha`)。
  2220. */
  2221. layerProbe: overlayHandles.map((handle) => ({
  2222. show: !!(handle && handle.layer && handle.layer.show),
  2223. alpha: handle && typeof handle.alpha === 'number' ? Number(handle.alpha.toFixed(4)) : null,
  2224. })),
  2225. }
  2226. }
  2227. /** 刷新瓦片就绪读数(渲染循环里调;`globe.tilesLoaded` 是 getter,不便宜)。约 6Hz 节流。 */
  2228. let overlayReadinessLastAt = 0
  2229. function refreshOverlayReadiness() {
  2230. if (!overlayReadiness || !instance || !instance.viewer) return
  2231. const now = performance.now()
  2232. if (now - overlayReadinessLastAt < 160) return
  2233. overlayReadinessLastAt = now
  2234. overlayReadiness.refresh(instance.viewer.scene.globe)
  2235. }
  2236. // ---- 票 09:点位状态标记与图例 ----
  2237. /**
  2238. * 语义色的**唯一取法**:从 `.cockpit-shell` 的计算样式里读 `--lv1..--lv4 / --neutral`。
  2239. *
  2240. * 为什么绕这一道而不是在 JS 里写色值(票 06 的单一口径):色值只在 `styles/tokens.css`
  2241. * 声明一份,设计公司换视觉资源时改那一个文件即可;地图标记(SVG 图片)偏偏读不到 CSS 变量,
  2242. * 所以由服务层把「当前生效的颜色」读出来交给 domain 的 `markerSvg()` 烘焙进图片。
  2243. * 读不到(脱浏览器单测、样式未挂)时回退到与 token 同值的兜底色,标记不会变成透明。
  2244. */
  2245. const TONE_FALLBACK = {
  2246. lv1: '#ff3b52',
  2247. lv2: '#ffb020',
  2248. lv3: '#01abea',
  2249. lv4: '#27d17a',
  2250. neutral: '#5680b0',
  2251. 'point-offline': '#94a3b8',
  2252. 'point-empty': '#c4d5e8',
  2253. 'point-unknown': '#a970ff',
  2254. 'point-unassociated': '#ff8a48',
  2255. }
  2256. function toneColors() {
  2257. const colors = { ...TONE_FALLBACK }
  2258. if (typeof document === 'undefined' || !document.querySelector) return colors
  2259. const shell = document.querySelector('.cockpit-shell')
  2260. if (!shell || typeof getComputedStyle !== 'function') return colors
  2261. const styles = getComputedStyle(shell)
  2262. for (const tone of Object.keys(TONE_FALLBACK)) {
  2263. const value = styles.getPropertyValue(`--${tone}`)
  2264. if (value && value.trim()) colors[tone] = value.trim()
  2265. }
  2266. return colors
  2267. }
  2268. /**
  2269. * 同步**模式图层计划**给出的点位标记到地图上(票 09 验收第 1、4 条;票 46 改为消费计划)。
  2270. *
  2271. * 票 46 起,入参是 `domain/modeLayerPlan.js` 的输出(`{ markers, byStatus }`),
  2272. * **不再是原始 objects** —— 模式可见性的裁决已在计划层收口,服务层不再重判
  2273. * (规格 18:引擎和组件只消费计划,不做第二次过滤)。
  2274. *
  2275. * 引擎没就绪时只更新计划(图例仍能显示),返回 false;调用方(`MapCanvas`)会在就绪后重试。
  2276. * @param {{markers: Array, byStatus?: object}} plan 模式图层计划的标记段
  2277. * @param {object} [legendSource] 可选的图例行(默认由计划现算),便于界面直接复用同一份
  2278. * @returns {boolean} 是否已画到地图上
  2279. */
  2280. export function syncPointMarkersForPlan(plan, legendSource) {
  2281. const markers = (plan && Array.isArray(plan.markers)) ? plan.markers : []
  2282. markerPlan = {
  2283. markers,
  2284. blocked: [],
  2285. byStatus: (plan && plan.byStatus) || summarizeMarkerStatuses(markers),
  2286. total: markers.length,
  2287. }
  2288. if (!instance || !instance.engine || !instance.viewer) {
  2289. paintMarkerState()
  2290. return false
  2291. }
  2292. const colors = toneColors()
  2293. const result = instance.engine.syncPointMarkers(
  2294. instance.viewer,
  2295. markerPlan.markers,
  2296. (marker) => colors[marker.tone] || colors.neutral,
  2297. // 票 44:监测点尺寸也取自**同一个尺寸层级定义**(`MARKER_SIZE.point`),
  2298. // 这样「点始终大于设备」只在一处成立,不会两边各写一个字面量而悄悄反转。
  2299. (shape, color) => markerSvg(shape, color, MARKER_SIZE.point),
  2300. // 批次 3:合成计划层——每个标记按 (kind/status/industry) 产出双层水滴合成计划,
  2301. // 由 `renderMarker` 异步烘焙成 billboard 位图;首帧走 `markerFallbackSvg` 同步兜底。
  2302. (marker) => planMarker({ kind: 'monitoring-point', status: marker.status, industry: marker.industry, tone: marker.tone }),
  2303. (marker) => colors[marker.tone] || colors.neutral,
  2304. )
  2305. markerRendered = result.rendered
  2306. markerSkipped = result.skipped
  2307. paintMarkerState()
  2308. return true
  2309. }
  2310. /**
  2311. * 同步**设施点位与管线图层**(票 24)。
  2312. *
  2313. * 与摘要标记分开是结构性的:本函数只写 `cockpit-pipelines` 那个独立数据源,
  2314. * 摘要标记序列与图例一个字节都不动 —— 管线是**设施**、不是监测点,
  2315. * 两者的拾取身份、统计口径与坐标纪律都不同(见 `domain/pipeNetwork.js` 文件头)。
  2316. *
  2317. * 图层过滤只由 `domain/modeLayerPlan.js` 决定并随计划给出,本函数**不再筛一次**:
  2318. * 计划里给什么就画什么(`hidden` 体现为空数组,于是自动撤下)。
  2319. * @param {object} plan `planModeLayers()` 的结果
  2320. * @returns {Promise<boolean>} 是否已画到地图上
  2321. */
  2322. export async function syncPipeLayerForPlan(plan) {
  2323. const layer = {
  2324. pipes: plan && Array.isArray(plan.pipelines) ? plan.pipelines : [],
  2325. components: plan && Array.isArray(plan.facilities) ? plan.facilities : [],
  2326. }
  2327. if (!instance || !instance.viewer) return false
  2328. const record = instance
  2329. const engine = await import('./cesiumPipeLayer.js')
  2330. if (instance !== record || !record.viewer) return false
  2331. record.pipeLayer = engine
  2332. engine.setPipeViewMode(record.viewer, viewMode)
  2333. await engine.syncPipeLayer(record.viewer, layer, { onModelChange: (state) => {
  2334. if (instance !== record || !record.host) return
  2335. const host = record.host
  2336. if (host.dataset) {
  2337. host.dataset.mapPipeModelBatches = String(state.batches)
  2338. host.dataset.mapPipeModelInstances = String(state.instances)
  2339. host.dataset.mapPipeModelFallback = String(state.fallback)
  2340. host.dataset.mapPipeModelPending = String(state.pending)
  2341. host.dataset.mapPipeModelFailed = String(state.failed)
  2342. host.dataset.mapPipeModelKinds = JSON.stringify(state.byKind)
  2343. host.dataset.mapPipeVirtualDepth = 'category-default'
  2344. }
  2345. host.dispatchEvent(new CustomEvent('cockpit-pipe-model-state', { detail: state }))
  2346. } })
  2347. if (instance !== record) return false
  2348. const host = record.host
  2349. if (host && host.dataset) {
  2350. host.dataset.mapPipes = String(layer.pipes.length)
  2351. host.dataset.mapPipeComponents = String(layer.components.length)
  2352. }
  2353. return true
  2354. }
  2355. /**
  2356. * 同步**关联监测设备临时图层**(票 42)。
  2357. *
  2358. * 与摘要标记分开是结构性的:本函数只写 `DEVICE_MARKER_SOURCE` 那个独立数据源,
  2359. * 摘要的 `markerPlan`/`data-map-markers*` 一个字节都不动(规格:关联设备不写回摘要序列、
  2360. * 不分页、不进摘要图例与计数)。设备图例另有一套互斥计数(`domain/deviceLayer.js`)。
  2361. *
  2362. * 形状/色值仍走既有单一来源:形状来自传入的计划(票 44 会接管设备形状词表),
  2363. * 色值仍由 `toneColors()` 从 token 读 —— 本层不写任何色值。
  2364. *
  2365. * @param {Array} devices **已判定可上图**的领域行(`placement === 'drawn'`)
  2366. * @returns {boolean} 是否已画到地图上(引擎没就绪时只记计划,返回 false)
  2367. */
  2368. /** 应用单个重叠组呈现:collapsed 隐藏逐台实体显示聚合符号;expanded 只写 billboard pixelOffset。 */
  2369. function applyOverlapPresentation(groupKey, state, preview = false) {
  2370. const group = overlapGroups.find((item) => item.key === groupKey && item.k > 1)
  2371. if (!group || !instance || !instance.engine || !instance.viewer) return false
  2372. const selectedCode = group.rows.some((row) => row.code === selectedDeviceCode) ? selectedDeviceCode : ''
  2373. const fanGroup = selectedCode
  2374. ? { ...group, rows: group.rows.filter((row) => row.code !== selectedCode), maxFan: Math.max(1, group.maxFan - 1) }
  2375. : group
  2376. const fan = selectedCode && group.maxFan <= 1 ? [] : fanLayout(fanGroup, { center: { x: 0, y: 0 }, radius: 48 })
  2377. const offsets = Object.fromEntries(fan.map((item) => [item.deviceCode, { x: item.x, y: item.y }]))
  2378. instance.engine.setOverlapPresentation(instance.viewer, {
  2379. groupKey, state, preview, offsets,
  2380. selectedCode: group.rows.some((row) => row.code === selectedDeviceCode) ? selectedDeviceCode : '',
  2381. })
  2382. overlapState = state
  2383. overlapCountValue = group.k
  2384. paintOverlapState()
  2385. return true
  2386. }
  2387. function paintOverlapState() {
  2388. if (!instance || !isDomHost(instance.host)) return
  2389. const selectedGroup = overlapGroups.find((item) => item.k > 1 && item.rows.some((row) => row.code === selectedDeviceCode))
  2390. const groupKey = overlapExpandedGroup || overlapPreviewGroup || (selectedGroup && selectedGroup.key)
  2391. const group = overlapGroups.find((item) => item.key === groupKey && item.k > 1)
  2392. const degraded = overlapGroups.some((item) => item.k > 1 && item.degraded)
  2393. instance.host.dataset.mapOverlapState = group
  2394. ? (group.degraded ? OVERLAP_STATE.DEGRADED : overlapState)
  2395. : (degraded ? OVERLAP_STATE.DEGRADED : OVERLAP_STATE.COLLAPSED)
  2396. instance.host.dataset.mapOverlapCount = String(group ? group.k : overlapCountValue || 0)
  2397. }
  2398. function collapseOverlap(value = '') {
  2399. const selectedGroup = overlapGroups.find((item) => item.k > 1 && item.rows.some((row) => row.code === selectedDeviceCode))
  2400. const target = resolveOverlapGroupKey(value || overlapExpandedGroup || overlapPreviewGroup || (selectedGroup && selectedGroup.key))
  2401. const selectedCode = selectedGroup && selectedGroup.key === target ? selectedDeviceCode : ''
  2402. if (target && instance && instance.engine && instance.viewer) {
  2403. instance.engine.setOverlapPresentation(instance.viewer, {
  2404. groupKey: target, state: selectedCode ? 'selected' : 'collapsed', selectedCode,
  2405. })
  2406. }
  2407. overlapPreviewGroup = ''
  2408. overlapExpandedGroup = ''
  2409. overlapState = selectedGroup ? OVERLAP_STATE.SELECTED : transitionOverlapState(overlapState, 'close')
  2410. overlapCountValue = selectedGroup ? selectedGroup.k : 0
  2411. paintOverlapState()
  2412. }
  2413. function resolveOverlapGroupKey(value) {
  2414. if (!value) return ''
  2415. const found = overlapGroups.find((item) => item.key === value || item.pointCode === value)
  2416. return found ? found.key : value
  2417. }
  2418. export function getOverlapTargets() {
  2419. if (!instance || !instance.engine || !instance.viewer) return []
  2420. return instance.engine.readOverlapTargets(instance.viewer)
  2421. }
  2422. export function nudgeOverlap(pointCode) {
  2423. const groupKey = resolveOverlapGroupKey(pointCode)
  2424. const group = overlapGroups.find((item) => item.key === groupKey && item.k > 1)
  2425. if (!group || !instance || !instance.engine || !instance.viewer) return false
  2426. return instance.engine.nudgeOverlap(instance.viewer, groupKey)
  2427. }
  2428. function toggleOverlap(value) {
  2429. const groupKey = resolveOverlapGroupKey(value)
  2430. if (!groupKey) return getOverlapState()
  2431. if (overlapExpandedGroup === groupKey) collapseOverlap(groupKey)
  2432. else expandOverlap(groupKey, true)
  2433. return getOverlapState()
  2434. }
  2435. function expandOverlap(groupKey, sticky) {
  2436. if (!groupKey || !overlapGroups.some((item) => item.key === groupKey && item.k > 1)) return false
  2437. if (overlapExpandedGroup && overlapExpandedGroup !== groupKey) collapseOverlap(overlapExpandedGroup)
  2438. overlapPreviewGroup = sticky ? '' : groupKey
  2439. overlapExpandedGroup = sticky ? groupKey : ''
  2440. applyOverlapPresentation(groupKey, OVERLAP_STATE.EXPANDED, !sticky)
  2441. return true
  2442. }
  2443. export function syncAssociatedDevices(devices, options = {}) {
  2444. deviceRows = Array.isArray(devices) ? [...devices] : []
  2445. overlapMaxFan = Number.isInteger(options.maxFan) && options.maxFan > 0 ? options.maxFan : 10
  2446. overlapGroups = Array.isArray(options.overlapGroups)
  2447. ? options.overlapGroups
  2448. : groupOverlappingDevices(deviceRows, { maxFan: overlapMaxFan })
  2449. overlapPreviewGroup = ''
  2450. overlapExpandedGroup = ''
  2451. overlapState = OVERLAP_STATE.COLLAPSED
  2452. overlapCountValue = overlapGroups.reduce((max, group) => Math.max(max, group.k || 0), 0)
  2453. if (!instance || !instance.engine || !instance.viewer) {
  2454. paintDeviceState()
  2455. paintOverlapState()
  2456. return false
  2457. }
  2458. const colors = toneColors()
  2459. const planned = deviceRows.map((device) => {
  2460. if (device.assetMode !== undefined) return { ...device, tone: device.tone || deviceVisualTone(device) }
  2461. const asset = planDeviceAsset({ device, color: colors[deviceVisualTone(device)] || colors.neutral })
  2462. return {
  2463. ...device,
  2464. shape: asset.shape,
  2465. size: asset.size,
  2466. assetMode: asset.mode,
  2467. assetReason: asset.reason,
  2468. assetReasonText: asset.reasonText,
  2469. visualKey: asset.visualKey,
  2470. tone: deviceVisualTone(device),
  2471. }
  2472. })
  2473. const result = instance.engine.syncAssociatedDevices(
  2474. instance.viewer,
  2475. planned,
  2476. (device) => colors[device.tone] || colors.neutral,
  2477. (shape, color, size) => markerSvg(shape, color, size),
  2478. overlapGroups,
  2479. // 批次 3:关联监测设备走实心母版 + 内层行业图标(规格 10),尖端锚定。
  2480. (device) => planMarker({ kind: 'monitoring-device', status: device.status, industry: device.industry, tone: device.tone }),
  2481. (device) => colors[device.tone] || colors.neutral,
  2482. )
  2483. deviceRows = planned
  2484. deviceRendered = result.rendered
  2485. deviceSkipped = result.skipped
  2486. // 票 47:动效计划随图层一起下发(门控判定在 domain,本层只搬运)。
  2487. deviceEffectsPlan = options.effects || null
  2488. const selectedGroup = overlapGroups.find((group) => group.k > 1 && group.rows.some((row) => row.code === selectedDeviceCode))
  2489. if (selectedGroup) applyOverlapPresentation(selectedGroup.key, OVERLAP_STATE.SELECTED)
  2490. applyDeviceEffectsToEngine()
  2491. paintDeviceState()
  2492. paintOverlapState()
  2493. return true
  2494. }
  2495. /**
  2496. * 票 47:把动效计划交给引擎,并把**降级/开关**写成宿主只读字段(走查断言面)。
  2497. *
  2498. * 关掉特效 ≠ 关掉状态显示:这里只控制"动效",静态色/形/文字仍由 `applyDeviceReactions` 与
  2499. * 实体属性表达(决策票 07 问题 6 的原句)。
  2500. */
  2501. function applyDeviceEffectsToEngine() {
  2502. if (!instance || !instance.engine || !instance.viewer) {
  2503. paintDeviceEffects()
  2504. return false
  2505. }
  2506. // 光环实体的色值必须由**服务层烘焙**后下传(引擎读不到 CSS 变量,研究笔记第 6 条):
  2507. // 这里复用与设备图标同一份 `toneColors()` 与 `markerSvg()`。
  2508. const colors = toneColors()
  2509. const running = instance.engine.applyDeviceEffects(instance.viewer, deviceEffectsPlan, {
  2510. selectedCode: selectedDeviceCode,
  2511. devices: deviceRows,
  2512. colorOf: (device) => colors[deviceVisualTone(device)] || colors.neutral,
  2513. imageOf: (shape, color, size) => markerSvg(shape, color, size),
  2514. // 科技感光环/辉光用**能量场图片**(径向渐变柔和边缘),与图标同一份烘焙色值。
  2515. fxImageOf: (kind, color, size) => (kind === 'glow' ? glowSvg(color, size) : haloSvg(color, size)),
  2516. })
  2517. paintDeviceEffects()
  2518. return running
  2519. }
  2520. /** 动效读数的宿主字段:`on|off|degraded` + 异常台数 + 参与动效台数。 */
  2521. function paintDeviceEffects() {
  2522. if (!instance || !isDomHost(instance.host)) return
  2523. const host = instance.host
  2524. const plan = deviceEffectsPlan
  2525. if (!plan || plan.enabled !== true) host.dataset.mapDeviceEffects = 'off'
  2526. else if (plan.degraded) host.dataset.mapDeviceEffects = 'degraded'
  2527. else host.dataset.mapDeviceEffects = 'on'
  2528. host.dataset.mapDeviceEffectsAbnormal = String(plan ? plan.abnormalCount : 0)
  2529. host.dataset.mapDeviceEffectsAnimated = String(plan && plan.animated ? plan.animated.length : 0)
  2530. }
  2531. /**
  2532. * 设备行 → 色键(票 44:色键来自**设备独立词表**,不借用点位表)。
  2533. *
  2534. * 单独成函数是为了让"设备颜色只表达设备状态"这条在服务层也一眼可见 ——
  2535. * 服务层不认识业务状态名,只问设备词表要一个色键。
  2536. */
  2537. function deviceVisualTone(device) {
  2538. return deviceVisualForStatus(device && device.status).tone
  2539. }
  2540. /** 清空关联设备临时图层(关闭 / 换点 / 刷新 / 快照失效 / 离开页面)。 */
  2541. export function clearAssociatedDevices() {
  2542. if (instance && instance.engine && instance.viewer) {
  2543. instance.engine.clearAssociatedDevices(instance.viewer)
  2544. }
  2545. deviceRows = []
  2546. deviceRendered = 0
  2547. deviceSkipped = 0
  2548. overlapGroups = []
  2549. overlapPreviewGroup = ''
  2550. overlapExpandedGroup = ''
  2551. overlapState = OVERLAP_STATE.COLLAPSED
  2552. overlapCountValue = 0
  2553. paintOverlapState()
  2554. // 票 43:图层撤下时**一并清掉选中与 hover**(否则会留着指向已不存在设备的强化读数,
  2555. // 走查与界面都会读到一个不存在的选中态)。
  2556. selectedDeviceCode = ''
  2557. hoveredDeviceCode = ''
  2558. // 票 47:动效随图层一起消失(计划清空 + 引擎侧已在 clearAssociatedDevices 里停循环)。
  2559. deviceEffectsPlan = null
  2560. paintDeviceEffects()
  2561. paintDeviceReactions()
  2562. paintDeviceState()
  2563. }
  2564. /** 当前关联设备图层的只读读数(走查与人工排查读它)。 */
  2565. export function getAssociatedDeviceState() {
  2566. let onMap = deviceRendered
  2567. let entities = []
  2568. if (instance && instance.engine && instance.viewer) {
  2569. entities = instance.engine.readAssociatedDevices(instance.viewer)
  2570. onMap = entities.filter((entity) => entity.deviceCode && entity.visible).length
  2571. }
  2572. return {
  2573. plan: [...deviceRows],
  2574. onMap,
  2575. skipped: deviceSkipped,
  2576. entities,
  2577. overlap: getOverlapState(),
  2578. }
  2579. }
  2580. export function clearOverlapNudge() {
  2581. if (instance && instance.engine && instance.viewer) instance.engine.clearOverlapNudge(instance.viewer)
  2582. }
  2583. /**
  2584. * 票 47:动效的只读读数 —— 计划(门控/降级/异常台数)+ 引擎里每台设备的**当前帧属性**。
  2585. *
  2586. * 走查的"真在跑"证据来自后者:对同一设备隔帧读两次,断言 `scale`/`alpha`(脉冲/闪烁)
  2587. * 或 `rotation`(旋转环)确实变了,而正常/离线设备**完全不变**。
  2588. */
  2589. export function getDeviceEffectState() {
  2590. const frames = instance && instance.engine && instance.viewer && instance.engine.readDeviceEffects
  2591. ? instance.engine.readDeviceEffects(instance.viewer)
  2592. : { tick: 0, running: false, devices: [] }
  2593. return { plan: deviceEffectsPlan, ...frames }
  2594. }
  2595. export function getOverlapState() {
  2596. const selectedGroup = overlapGroups.find((item) => item.k > 1 && item.rows.some((row) => row.code === selectedDeviceCode))
  2597. const groupKey = overlapExpandedGroup || overlapPreviewGroup || (selectedGroup && selectedGroup.key)
  2598. const group = overlapGroups.find((item) => item.key === groupKey && item.k > 1)
  2599. const degraded = overlapGroups.some((item) => item.k > 1 && item.degraded)
  2600. return {
  2601. state: group ? (group.degraded ? OVERLAP_STATE.DEGRADED : overlapState) : (degraded ? OVERLAP_STATE.DEGRADED : OVERLAP_STATE.COLLAPSED),
  2602. count: group ? group.k : overlapCountValue || 0,
  2603. pointCode: group ? group.pointCode : '',
  2604. groupKey: groupKey || '',
  2605. expanded: Boolean(overlapExpandedGroup),
  2606. selectedCode: selectedGroup ? selectedDeviceCode : '',
  2607. preview: Boolean(overlapPreviewGroup),
  2608. maxFan: overlapMaxFan,
  2609. }
  2610. }
  2611. /**
  2612. * 面板选择层级写进宿主 dataset(`data-map-panel-selection` = `none|point|equipment`)。
  2613. * 由外壳在详情层级变化时调它 —— 服务层不认识 Vue,所以层级值由调用方给。
  2614. */
  2615. export function setPanelSelectionLevel(level) {
  2616. if (level === 'point' || level === 'none') setSelectedDevice('')
  2617. if (!instance || !isDomHost(instance.host)) return
  2618. instance.host.dataset.mapPanelSelection = level || 'none'
  2619. }
  2620. /**
  2621. * 清空设备选中与 hover(图层撤下 / 关闭 / 换点 / 离开页面都走它)。
  2622. *
  2623. * 一并清引线:留着一条指向已撤下设备的线,是"归属指示"里最误导的一种残留。
  2624. */
  2625. export function clearDeviceSelection() {
  2626. const selectedGroup = overlapGroups.find((group) => group.rows.some((row) => row.code === selectedDeviceCode))
  2627. selectedDeviceCode = ''
  2628. hoveredDeviceCode = ''
  2629. collapseOverlap(selectedGroup ? selectedGroup.key : '')
  2630. if (instance && instance.engine && instance.viewer) {
  2631. instance.engine.clearDeviceLead(instance.viewer)
  2632. instance.engine.applyDeviceReactions(instance.viewer, [], reactionStyle)
  2633. }
  2634. paintDeviceReactions()
  2635. }
  2636. /** 当前选中/hover 的只读读数(走查与排查读它)。 */
  2637. export function getDeviceCorrespondenceState() {
  2638. return {
  2639. selected: selectedDeviceCode,
  2640. hovered: hoveredDeviceCode,
  2641. panelSelection: instance && isDomHost(instance.host) ? instance.host.dataset.mapPanelSelection || '' : '',
  2642. }
  2643. }
  2644. /** 设备图层读数写进宿主 dataset(走查读这一行;摘要那几个 `data-map-markers*` 不动)。 */
  2645. function paintDeviceState() {
  2646. if (!instance || !isDomHost(instance.host)) return
  2647. instance.host.dataset.mapDevices = String(deviceRendered)
  2648. instance.host.dataset.mapDevicesPlan = String(deviceRows.length)
  2649. }
  2650. // ---- 票 43:设备点的对应反应(hover / 选中 / 弱化 / 引线 / 拾取优先)----
  2651. /** 当前选中设备(`deviceCode`)与三维 hover 设备(`deviceCode`)。 */
  2652. let selectedDeviceCode = ''
  2653. let hoveredDeviceCode = ''
  2654. /**
  2655. * 票 47:当前设备图层的**动效计划**(`domain/deviceEffects.js` 的输出)。
  2656. * 由 store 计算后随 `syncAssociatedDevices` 传进来 —— 门控与降级的判定在 domain,
  2657. * 本层只负责把它交给引擎并暴露可断言读数。
  2658. */
  2659. let deviceEffectsPlan = null
  2660. /**
  2661. * 把反应档位写进引擎,并刷新三个可断言字段。
  2662. *
  2663. * 三个字段(规格点名的可断言口径,走查对断言不对图):
  2664. * · `data-map-device-picked` = 当前选中设备 `deviceCode`
  2665. * · `data-map-panel-selection` = `none|point|equipment`
  2666. * · `data-map-device-hovered` = 当前三维 hover 设备 `deviceCode`
  2667. * **不动既有 `data-map-picked`**(票 10 走查在用)。
  2668. */
  2669. function paintDeviceReactions() {
  2670. if (!instance || !isDomHost(instance.host)) return
  2671. const host = instance.host
  2672. host.dataset.mapDevicePicked = selectedDeviceCode || ''
  2673. host.dataset.mapDeviceHovered = hoveredDeviceCode || ''
  2674. }
  2675. /**
  2676. * 按当前选中/hover 重算每台设备的反应并写进引擎(纯判定在 domain,本层只搬运)。
  2677. * @param {object} [options] `{ devices }` 覆盖设备行(默认用图层计划)
  2678. */
  2679. export function syncDeviceReactions(options = {}) {
  2680. const devices = Array.isArray(options.devices) ? options.devices : deviceRows
  2681. const plan = planDeviceReactions({ devices, selectedCode: selectedDeviceCode, hoveredCode: hoveredDeviceCode })
  2682. if (instance && instance.engine && instance.viewer) {
  2683. instance.engine.applyDeviceReactions(instance.viewer, plan.reactions, reactionStyle)
  2684. // 票 47:反应量变了要重算动效 —— 动效是**乘在反应量基准上**的,
  2685. // 不重算的话选中强化的幅度会停在旧的基准上。
  2686. applyDeviceEffectsToEngine()
  2687. syncDeviceLeadForSelection(devices)
  2688. }
  2689. paintDeviceReactions()
  2690. return plan
  2691. }
  2692. /**
  2693. * 选中设备的归属引线:从设备点连到**监测点标记中心**(票 04/12 的锚点,归属不换)。
  2694. * 重合时按规格**退化不画**(`planDeviceLead().draw === false`)。
  2695. */
  2696. function syncDeviceLeadForSelection(devices) {
  2697. const list = Array.isArray(devices) ? devices : []
  2698. const selected = list.find((item) => item && item.code === selectedDeviceCode)
  2699. const pointMarker = markerPlan.markers.find((item) => item.kind === 'monitoring-point' && item.code === (selected && selected.pointCode))
  2700. if (!selected || !pointMarker) {
  2701. instance.engine.clearDeviceLead(instance.viewer)
  2702. return false
  2703. }
  2704. const lead = planDeviceLead({
  2705. deviceLon: selected.longitude,
  2706. deviceLat: selected.latitude,
  2707. pointLon: pointMarker.longitude,
  2708. pointLat: pointMarker.latitude,
  2709. })
  2710. if (!lead.draw) {
  2711. instance.engine.clearDeviceLead(instance.viewer)
  2712. return false
  2713. }
  2714. return instance.engine.syncDeviceLead(instance.viewer, {
  2715. deviceLon: selected.longitude,
  2716. deviceLat: selected.latitude,
  2717. pointLon: pointMarker.longitude,
  2718. pointLat: pointMarker.latitude,
  2719. deviceCode: selected.code,
  2720. pointCode: selected.pointCode,
  2721. })
  2722. }
  2723. /**
  2724. * 选中/取消选中一台设备(**面板与三维共用这一个入口**)。
  2725. * @param {string} deviceCode 设备编码;空串=取消设备选中
  2726. */
  2727. export function setSelectedDevice(deviceCode) {
  2728. const next = typeof deviceCode === 'string' ? deviceCode.trim() : ''
  2729. const previousGroup = overlapGroups.find((group) => group.k > 1 && group.rows.some((row) => row.code === selectedDeviceCode))
  2730. const activeGroup = overlapExpandedGroup || overlapPreviewGroup
  2731. selectedDeviceCode = next
  2732. const selectedGroup = overlapGroups.find((group) => group.k > 1 && group.rows.some((row) => row.code === next))
  2733. if (activeGroup && (!previousGroup || activeGroup !== previousGroup.key)) collapseOverlap(activeGroup)
  2734. if (previousGroup) collapseOverlap(previousGroup.key)
  2735. else collapseOverlap()
  2736. if (selectedGroup) applyOverlapPresentation(selectedGroup.key, OVERLAP_STATE.SELECTED)
  2737. syncDeviceReactions()
  2738. return selectedDeviceCode
  2739. }
  2740. /** 当前选中设备编码(只读)。 */
  2741. export function getSelectedDevice() {
  2742. return selectedDeviceCode
  2743. }
  2744. /**
  2745. * 三维 hover 一台设备(pointermove 拾取调用;空串=离开设备)。
  2746. *
  2747. * 只改 hover 档,**不动选中态**(规格:hover 是瞬态,选中是持久态,互不干扰)。
  2748. */
  2749. export function setHoveredDevice(deviceCode) {
  2750. const next = typeof deviceCode === 'string' ? deviceCode.trim() : ''
  2751. if (next === hoveredDeviceCode) return hoveredDeviceCode
  2752. hoveredDeviceCode = next
  2753. syncDeviceReactions()
  2754. return hoveredDeviceCode
  2755. }
  2756. /** 当前三维 hover 设备编码(只读)。 */
  2757. export function getHoveredDevice() {
  2758. return hoveredDeviceCode
  2759. }
  2760. const pipelinePickHandlers = new Set()
  2761. /** 管线/构件点选订阅;回调只收到业务身份,不收到 Cesium 实体。 */
  2762. export function onPipelinePick(handler) {
  2763. if (typeof handler !== 'function') return () => {}
  2764. pipelinePickHandlers.add(handler)
  2765. return () => pipelinePickHandlers.delete(handler)
  2766. }
  2767. export function clearPipelinePickHandlers() {
  2768. pipelinePickHandlers.clear()
  2769. }
  2770. if (!instance || !instance.engine || !instance.viewer || typeof instance.engine.pickPipeObjectAt !== 'function') {
  2771. return { target: 'none', pick: null }
  2772. }
  2773. const pick = instance.engine.pickPipeObjectAt(instance.viewer, clientX, clientY)
  2774. if (!pick) return { target: 'none', pick: null }
  2775. const result = { target: 'pipeline', pick }
  2776. for (const handler of [...pipelinePickHandlers]) handler(pick)
  2777. return result
  2778. }
  2779. /**
  2780. * 点选链路(票 43/23):**先设备点、再监测点**(规格:正常单点设备点优先、监测点兜底)。
  2781. *
  2782. * 优先级判定在 `domain/deviceCorrespondence.js` 的 `resolvePickOrder()`(可脱浏览器断言),
  2783. * 本层只负责"两次引擎拾取 + 按结论派发",不自己写 if-else 顺序。
  2784. * @returns {{target: string, pick: object|null}}
  2785. */
  2786. export function pickDeviceOrPointAt(clientX, clientY) {
  2787. if (!instance || !instance.engine || !instance.viewer) return { target: 'none', pick: null }
  2788. const deviceHit = instance.engine.pickDeviceMarkerAt(instance.viewer, clientX, clientY)
  2789. const overlapHit = deviceHit && deviceHit.kind === 'monitoring-device-overlap' ? deviceHit : null
  2790. const actualDeviceHit = deviceHit && deviceHit.kind === 'monitoring-device' ? deviceHit : null
  2791. const pointHit = deviceHit ? null : instance.engine.pickPointMarkerAt(instance.viewer, clientX, clientY)
  2792. if (overlapHit || (actualDeviceHit && actualDeviceHit.overlapEntry)) {
  2793. const entry = overlapHit || actualDeviceHit
  2794. expandOverlap(entry.overlapGroup, true)
  2795. return resolveOverlapPick({ aggregateHit: entry })
  2796. }
  2797. const resolved = resolveOverlapPick({ deviceHit: actualDeviceHit, pointHit })
  2798. if (resolved.target === 'device') {
  2799. // 统一选择入口负责选中与呈现;不提前写 selectedDeviceCode,避免 store 的幂等检查吞掉更新。
  2800. lastPicked = resolved.pick
  2801. for (const handler of [...pointPickHandlers]) handler(resolved.pick)
  2802. } else if (resolved.target === 'point') {
  2803. if (overlapExpandedGroup) collapseOverlap(overlapExpandedGroup)
  2804. lastPicked = resolved.pick
  2805. paintPicked(instance, resolved.pick)
  2806. for (const handler of [...pointPickHandlers]) handler(resolved.pick)
  2807. } else {
  2808. // 点空白处:粘性展开明确收起。
  2809. if (overlapExpandedGroup || overlapPreviewGroup) collapseOverlap()
  2810. }
  2811. return resolved
  2812. }
  2813. /**
  2814. * 订阅设备 hover(三维 pointermove 用)。
  2815. *
  2816. * 与点选订阅分开:hover 是**高频**事件,订阅方(面板高亮)需要自己做节流,
  2817. * 服务层只负责"拾到谁就说谁"(不在这一层锁节流数值 —— 规格把节流交实现/原型定)。
  2818. */
  2819. const deviceHoverHandlers = new Set()
  2820. export function onDeviceHover(handler) {
  2821. if (typeof handler !== 'function') return () => {}
  2822. deviceHoverHandlers.add(handler)
  2823. return () => deviceHoverHandlers.delete(handler)
  2824. }
  2825. /**
  2826. * 三维 hover 拾取(由宿主 pointermove 驱动)。
  2827. * @returns {string} 当前 hover 的设备编码(空串=没 hover 到设备)
  2828. */
  2829. export function hoverDeviceAt(clientX, clientY) {
  2830. if (!instance || !instance.engine || !instance.viewer) return ''
  2831. const hit = instance.engine.pickDeviceMarkerForHover(instance.viewer, clientX, clientY)
  2832. if (hit && (hit.kind === 'monitoring-device-overlap' || hit.overlapEntry)) {
  2833. if (overlapExpandedGroup === hit.overlapGroup) return ''
  2834. if (overlapPreviewGroup !== hit.overlapGroup) {
  2835. if (overlapPreviewGroup) collapseOverlap(overlapPreviewGroup)
  2836. expandOverlap(hit.overlapGroup, false)
  2837. }
  2838. nudgeOverlap(hit.overlapGroup)
  2839. return '' // 聚合入口不虚构设备身份;选中设备入口保留其真实编码。
  2840. }
  2841. // 从聚合入口移到预览子点时保留预览,确保子点可点击。
  2842. if (overlapPreviewGroup && (!hit || hit.overlapGroup !== overlapPreviewGroup)) collapseOverlap(overlapPreviewGroup)
  2843. const code = hit ? hit.code : ''
  2844. if (code === hoveredDeviceCode) return code
  2845. hoveredDeviceCode = code
  2846. syncDeviceReactions()
  2847. for (const handler of [...deviceHoverHandlers]) handler(hit)
  2848. return code
  2849. }
  2850. // ---- 批次 3:hover 完整名称 tooltip(label 截断后的通用 hover 详情)----
  2851. // 原生 DOM 层实现(不用 Cesium InfoBox:viewer 创建时 infoBox:false):一个绝对定位 div,
  2852. // hover 到点位/设备标记时显示完整名称,跟随鼠标;离开即隐藏。样式内联(无 scoped CSS 可挂)。
  2853. let tooltipNode = null
  2854. function ensureTooltipNode(record) {
  2855. if (tooltipNode && tooltipNode.isConnected) return tooltipNode
  2856. tooltipNode = record.host.ownerDocument.createElement('div')
  2857. tooltipNode.setAttribute('data-map-marker-tooltip', '')
  2858. tooltipNode.style.cssText = [
  2859. 'position:fixed', 'z-index:30', 'pointer-events:none',
  2860. 'max-width:320px', 'padding:5px 9px',
  2861. 'background:rgba(2,9,27,0.88)', 'border:1px solid rgba(1,171,234,0.55)',
  2862. 'border-radius:4px', 'color:#d7ecff',
  2863. 'font:600 12px "Microsoft YaHei UI","Microsoft YaHei","PingFang SC","Noto Sans SC",sans-serif',
  2864. 'white-space:nowrap', 'overflow:hidden', 'text-overflow:ellipsis',
  2865. ].join(';')
  2866. record.host.ownerDocument.body.appendChild(tooltipNode)
  2867. return tooltipNode
  2868. }
  2869. /** hover 移动:拾取点位/设备标记,显示完整名称(无命中即隐藏)。 */
  2870. function paintMarkerTooltip(event) {
  2871. if (!instance || !instance.engine || !instance.viewer) return
  2872. const point = instance.engine.pickPointMarkerForHover(instance.viewer, event.clientX, event.clientY)
  2873. const device = point ? null : instance.engine.pickDeviceMarkerForHover(instance.viewer, event.clientX, event.clientY)
  2874. const name = (point && point.name) || (device && device.name) || ''
  2875. if (!name) {
  2876. hideMarkerTooltip()
  2877. return
  2878. }
  2879. const node = ensureTooltipNode(instance)
  2880. node.textContent = name
  2881. node.style.display = 'block'
  2882. node.style.left = `${event.clientX + 14}px`
  2883. node.style.top = `${event.clientY + 10}px`
  2884. }
  2885. /** hover 离开 / 无命中:隐藏 tooltip。 */
  2886. function hideMarkerTooltip() {
  2887. if (tooltipNode && tooltipNode.style) tooltipNode.style.display = 'none'
  2888. }
  2889. /**
  2890. * 清空地图上的点位标记(离开 /cockpit 用)。
  2891. *
  2892. * **计划一起清掉**:留着计数而地图上没有标记,会让「图例与标记一致」这条验收在自己的
  2893. * 收尾路径上失真。地图上什么都没有时,图例就该是 0。
  2894. */
  2895. export function clearPointMarkers() {
  2896. if (instance && instance.engine && instance.viewer) {
  2897. instance.engine.clearPointMarkers(instance.viewer)
  2898. }
  2899. markerPlan = { markers: [], blocked: [], byStatus: {}, total: 0 }
  2900. markerRendered = 0
  2901. markerSkipped = 0
  2902. paintMarkerState()
  2903. }
  2904. /**
  2905. * 强调一批编码(票 14):设备码先解析成已上图的点位码,再连**设备码与管线码一起**交给引擎。
  2906. *
  2907. * 为什么把设备码也一起传:管线的关联键是设备码(管线不挂在点位上),
  2908. * 所以「高亮关联管线」只能按设备码匹配;点位码则用于强调那个点位标记。
  2909. * 引擎只调已存在实体的显示参数 —— 事件不新开图层;还没落地时(票 22 之前)返回 0,
  2910. * 调用方如实说「尚未出现在地图上」,不伪造成成功。
  2911. * @param {string|Array<string>} codes
  2912. * @returns {number} 实际被强调的实体数(0 = 目标还不在图上 / 引擎没就绪)
  2913. */
  2914. export function highlightMapObject(codes) {
  2915. const wanted = (Array.isArray(codes) ? codes : [codes]).map((item) => String(item || '').trim()).filter(Boolean)
  2916. // 设备码 → 已上图的点位码(点位标记按点位码建;设备码只作匹配线索)。
  2917. const resolved = wanted.flatMap((code) => {
  2918. const byPoint = markerPlan.markers.find((item) => item.code === code)
  2919. if (byPoint) return [code]
  2920. const byDevice = markerPlan.markers.find((item) => Array.isArray(item.deviceCodes) && item.deviceCodes.includes(code))
  2921. // 点位上图了就把点位码一起带上(标记按点位码画),设备码仍保留给管线匹配。
  2922. return byDevice ? [code, byDevice.code] : [code]
  2923. })
  2924. const matched =
  2925. instance && instance.engine && instance.viewer && resolved.length
  2926. ? instance.engine.highlightEntities(instance.viewer, resolved)
  2927. : 0
  2928. // 强调结果写进宿主 dataset(`data-map-emphasis` = 当前被强调的实体数):
  2929. // 既给走查一个**可观察的信号**(收起详情后必须回到 0),也方便人工排查"到底亮没亮"。
  2930. // 引擎没就绪时同样写 0 —— 否则会留着上一轮的旧值,让"还原"看起来没发生。
  2931. if (instance && isDomHost(instance.host)) {
  2932. instance.host.dataset.mapEmphasis = String(matched)
  2933. }
  2934. return matched
  2935. }
  2936. /**
  2937. * 当前点位标记的计划与计数(纯数据,界面与走查唯一读它)。
  2938. * 图例由 `pointLegend()` 从同一份 `byStatus` 生成 —— 这是「图例与标记一致」的落点。
  2939. * @returns {{legend: Array, byStatus: object, markers: Array, blocked: Array, onMap: number, skipped: number}}
  2940. */
  2941. export function getPointMarkerState() {
  2942. // 引擎就绪时以**真实图层内容**为准(走查与人工排查读的是地图上真有什么),
  2943. // 未就绪时退回计划数(图例仍要能显示)。
  2944. let onMap = markerRendered
  2945. let shapes = [...new Set(markerPlan.markers.map((item) => item.shape))].sort()
  2946. let markerImages = {}
  2947. // 实体口径与计划口径**分开给**:`byStatus` 是计划(图例由它生成),
  2948. // `entityByStatus` 是从 Cesium 实体上真读回来的。走查拿后者跟图例 DOM 对照,
  2949. // 才是「图例 vs 地图」的交叉验证;若两边都读 byStatus,那条断言就是自己跟自己比(恒真)。
  2950. let entityByStatus = {}
  2951. if (instance && instance.engine && instance.viewer) {
  2952. const actual = instance.engine.readPointMarkers(instance.viewer)
  2953. onMap = actual.count
  2954. shapes = actual.shapes
  2955. // 每个视觉键对应的**真实 billboard 图片**:走查据此证明「不同状态颜色确实不同」
  2956. // 与「图例形状=标记形状」,而不是拿两份 JS 数据互相比(那是自己跟自己比)。
  2957. markerImages = actual.images
  2958. entityByStatus = { ...actual.byStatus }
  2959. }
  2960. return {
  2961. legend: pointLegend(markerPlan.byStatus),
  2962. byStatus: { ...markerPlan.byStatus },
  2963. entityByStatus,
  2964. markers: [...markerPlan.markers],
  2965. blocked: [...markerPlan.blocked],
  2966. onMap,
  2967. skipped: markerSkipped,
  2968. shapes,
  2969. markerImages,
  2970. }
  2971. }
  2972. /**
  2973. * 标记状态写进宿主 dataset(走查读这一行,不读中文,也不读图):
  2974. * `data-map-markers` = 已画到地图上的标记数
  2975. * `data-map-markers-plan` = 本次计划要画的标记数(坐标准入通过)
  2976. * `data-map-markers-status` = 状态分布,`ALARM:1,WARNING:1` 形式
  2977. * `data-map-marker-shapes` = 实际用到的形状名(证明「不只靠颜色」有形状这一半)
  2978. */
  2979. function paintMarkerState() {
  2980. if (!instance || !isDomHost(instance.host)) return
  2981. const host = instance.host
  2982. const keys = Object.keys(markerPlan.byStatus).sort()
  2983. host.dataset.mapMarkers = String(markerRendered)
  2984. host.dataset.mapMarkersPlan = String(markerPlan.total)
  2985. host.dataset.mapMarkersStatus = keys.map((key) => `${key}:${markerPlan.byStatus[key]}`).join(',')
  2986. host.dataset.mapMarkerShapes = [...new Set(markerPlan.markers.map((item) => item.shape))].sort().join(',')
  2987. }
  2988. // ---- 票 10:点位点选(点地图上的标记打开两级详情) ----
  2989. /**
  2990. * 点选订阅者(MapCanvas 挂载时订阅、离开时退订)。
  2991. *
  2992. * 为什么是订阅而不是让服务层直接调 store:本模块不依赖 Vue、也不认识 pinia
  2993. * (票 02 的边界),所以「拾到谁」由服务层说,「打开什么」由订阅者决定。
  2994. * 订阅者可以有几个(详情浮层、将来的联动面板),互不知情。
  2995. */
  2996. const pointPickHandlers = new Set()
  2997. /** 最近一次拾到的标记(DEV 把手与走查读它)。 */
  2998. let lastPicked = null
  2999. /**
  3000. * 订阅点位点选。返回退订函数(组件 `onBeforeUnmount` 调它)。
  3001. * @param {(picked: {kind: string, code: string, status: string}) => void} handler
  3002. */
  3003. export function onPointPick(handler) {
  3004. if (typeof handler !== 'function') return () => {}
  3005. pointPickHandlers.add(handler)
  3006. return () => {
  3007. pointPickHandlers.delete(handler)
  3008. }
  3009. }
  3010. /** 选中标记写进宿主 dataset:`data-map-picked` = `kind|code`(走查读它,不读中文)。 */
  3011. function paintPicked(record, picked) {
  3012. if (!isDomHost(record && record.host)) return
  3013. record.host.dataset.mapPicked = picked ? `${picked.kind}|${picked.code}` : ''
  3014. record.host.dataset.mapPickedStatus = picked ? picked.status || '' : ''
  3015. }
  3016. /**
  3017. * 窗口坐标 → 点位标记 → 通知订阅者(票 10)。
  3018. *
  3019. * 引擎未就绪、没拾到标记、拾到的不是点位标记:都返回 `null` 且**不改选中态**
  3020. * (点空白处不该把已经打开的详情莫名其妙地收掉,那是「关闭」按钮的事)。
  3021. * @param {number} clientX 窗口坐标(与 `getBoundingClientRect()` 同系)
  3022. * @param {number} clientY
  3023. * @returns {{kind: string, code: string, status: string}|null}
  3024. */
  3025. export function pickPointAt(clientX, clientY) {
  3026. if (!instance || !instance.engine || !instance.viewer) return null
  3027. const picked = instance.engine.pickPointMarkerAt(instance.viewer, clientX, clientY)
  3028. if (!picked) return null
  3029. lastPicked = picked
  3030. paintPicked(instance, picked)
  3031. for (const handler of [...pointPickHandlers]) handler(picked)
  3032. return picked
  3033. }
  3034. /** 最近一次拾到的点位标记(没有就是 null)。 */
  3035. export function getPickedPoint() {
  3036. return lastPicked
  3037. }
  3038. /**
  3039. * 星球自转(引导页用):`seconds` = 转一圈的秒数,0 = 停止。
  3040. *
  3041. * 实现要点:**不能直接转相机**(`camera.rotate`)—— 票 03 的 `cameraControl` 每帧都会用
  3042. * `lookAtTransform(中心, HeadingPitchRange)` 重写相机,直接转会被立刻抹掉(实测:经度纹丝不动)。
  3043. * 所以自转走同一条路:按时间推进**中心点的经度**,再以 `immediate` 套用预设,让相机跟着中心绕地轴转。
  3044. * @returns {boolean} 是否生效;相机还没接上时返回 false(调用方可以稍后重试)
  3045. */
  3046. /** 兼容旧调用:`setGlobeSpin(0)` 也会停。 */
  3047. export function setGlobeSpin(seconds) {
  3048. const spinSeconds = Number(seconds)
  3049. if (!Number.isFinite(spinSeconds) || spinSeconds <= 0) {
  3050. stopSpin()
  3051. return true
  3052. }
  3053. // 先记下**意图**:相机没接上、或正在飞行/阻尼时,本次起不来 —— 等落定后由
  3054. // `resumeSpinIfWanted()` 自动接上。这样"点单"不会丢,也不会去冲掉飞行目标。
  3055. spinWanted = spinSeconds
  3056. if (!instance || !instance.cameraControl) return false
  3057. const state = instance.cameraControl.getState()
  3058. // 自转与飞行互斥:自转每帧以 `immediate` 写中心,飞行中起转会把目标冲掉。
  3059. // 判据用**相机是否真的停稳**,不用粘滞开关 —— 粘滞开关没人清,就会永久哑掉自转。
  3060. if (state && !state.settled) return false
  3061. startSpin(instance, spinSeconds)
  3062. return true
  3063. }
  3064. /**
  3065. * 相机落定后把"想要的"自转接上(由 `notifyCameraStateChange` 调,飞行最后一帧必到)。
  3066. *
  3067. * 为什么需要它:`setGlobeSpin` 在飞行中只能返回 false(不能起转),
  3068. * 而调用方(外壳回引导态)不会一直重试 —— 意图得由服务层自己兑现。
  3069. */
  3070. function resumeSpinIfWanted(state) {
  3071. if (spinWanted <= 0) return
  3072. if (!instance || !instance.cameraControl) return
  3073. if (instance.spin) return
  3074. if (state && !state.settled) return
  3075. startSpin(instance, spinWanted)
  3076. }
  3077. function startSpin(record, spinSeconds) {
  3078. // 只清"正在转的循环",**不清意图** —— 否则下面刚设的 spinWanted 会被自己抹掉。
  3079. cancelSpinLoop()
  3080. record.spin = { spinSeconds, lastAt: Date.now(), raf: null }
  3081. const step = () => {
  3082. // 自转随时可能被「进入系统/归位」打断:只要 spin 被清掉就立刻退出这一帧循环,
  3083. // 绝不能再用 immediate 去覆盖飞行目标。
  3084. if (!record.spin || instance !== record || !record.cameraControl) return
  3085. const now = Date.now()
  3086. const dt = Math.min(now - record.spin.lastAt, 100)
  3087. record.spin.lastAt = now
  3088. // 绕地轴推进中心经度:一圈 2π ÷ spinSeconds;负号 = 自西向东(与真实自转同向)。
  3089. const state = record.cameraControl.getState()
  3090. const pivot = state && state.pivot ? state.pivot : null
  3091. if (pivot) {
  3092. const deltaLon = (-360 * dt) / (record.spin.spinSeconds * 1000)
  3093. record.cameraControl.applyViewPreset(
  3094. { lon: pivot.lon + deltaLon, lat: pivot.lat, height: pivot.height || 0 },
  3095. state.hpr,
  3096. { immediate: true },
  3097. )
  3098. }
  3099. record.spin.raf = requestAnimationFrame(step)
  3100. }
  3101. record.spin.raf = requestAnimationFrame(step)
  3102. }
  3103. /** 只停"正在转的循环",不动意图(`startSpin` 重启时用)。 */
  3104. function cancelSpinLoop() {
  3105. if (instance && instance.spin) {
  3106. if (instance.spin.raf !== null) cancelAnimationFrame(instance.spin.raf)
  3107. instance.spin = null
  3108. }
  3109. }
  3110. /** 停自转**并放弃意图**:进入系统/归位/返回全球都不该再自己转起来。 */
  3111. function stopSpin() {
  3112. spinWanted = 0
  3113. cancelSpinLoop()
  3114. }
  3115. /** 当前相机状态快照(中心、弹簧臂参数、是否在拖动/飞行);引擎未就绪时为 null。 */
  3116. export function getCameraState() {
  3117. return instance && instance.cameraControl ? instance.cameraControl.getState() : null
  3118. }
  3119. /** 可观测的生命周期计数:走查与单测据此断言「只创建一次、未重建」。 */
  3120. export function getLifecycleStats() {
  3121. return {
  3122. created: createdCount,
  3123. destroyed: destroyedCount,
  3124. resize: resizeCount,
  3125. lastResizeAt,
  3126. mounted: !!instance,
  3127. instanceId: instance ? instance.id : '',
  3128. // 票 27 追加:引擎状态与底图状态(上面 6 个字段语义不变);票 29 起底图状态来自配置计划。
  3129. engine: engineState,
  3130. engineError,
  3131. basemap: basemapState,
  3132. // 票 21 追加:地形与叠加影像状态。
  3133. terrain: terrainState,
  3134. terrainError,
  3135. overlays: overlayCount,
  3136. // 票 21 追加:3D Tiles / 倾斜摄影(入口已留好,本机数据缺根 tileset.json 时如实报失败)。
  3137. tilesets: tilesetReady,
  3138. tilesetErrors: tilesetFailed.map((item) => item.error),
  3139. // 票 30 追加:自转是否在跑(引导页状态,走查与排查都看它)。
  3140. spin: !!(instance && instance.spin),
  3141. // 票 40 追加:**想要**的自转时长(秒;0 = 不要)。与 `spin` 分开看才能分辨
  3142. // 「意图立着但相机还没落定」和「压根没想转」—— 回引导态报障的定位就靠这一对。
  3143. spinWanted,
  3144. // 票 36 追加:主场景区状态与原因(票 37/38 能否开限位/开雾的唯一依据)。
  3145. primaryArea: primaryAreaPlan.state,
  3146. primaryAreaReason: primaryAreaPlan.reason,
  3147. primaryAreaBounds: primaryAreaPlan.bounds ? primaryAreaPlan.bounds.slice() : null,
  3148. // 票 37 追加:区域状态与生效限位(走查断言「进区后上限=home.range」用)。
  3149. areaState,
  3150. areaLocked: areaLimits.locked,
  3151. rangeMax: areaLimits.max,
  3152. rangeMin: areaLimits.min,
  3153. // 票 38 追加:自建雾的支持/可执行/强度/降级(**不等于"边缘已遮住"**)。
  3154. fog: fogReadout({
  3155. state: fogLifecycle,
  3156. degrade: fogDegrade,
  3157. params: fogParams,
  3158. supported: fogSupported,
  3159. executable: fogExecutableForReadout(),
  3160. reason: fogReason,
  3161. }),
  3162. // 票 03 追加:相机契约是否已挂上(三键输入、中心规则都在 cameraControl 内)。
  3163. camera: instance && instance.cameraControl ? 'attached' : 'none',
  3164. // 票 09 追加:点位标记的计划数 / 实际画到地图上的数(引擎没就绪时只有计划)。
  3165. markers: markerPlan.total,
  3166. markersOnMap: markerRendered,
  3167. markerStatus: { ...markerPlan.byStatus },
  3168. }
  3169. }
  3170. /** 仅测试/走查用:重置模块内计数,避免同一进程内多次断言互相污染。 */
  3171. export function resetLifecycleStatsForTest() {
  3172. if (instance) {
  3173. if (instance.detachCameraInput) instance.detachCameraInput()
  3174. if (instance.cameraControl) instance.cameraControl.dispose()
  3175. clearDevHook()
  3176. }
  3177. instance = null
  3178. cancelPointLocate()
  3179. mapFrameHandlers.clear()
  3180. cameraStateHandlers.clear()
  3181. sequence = 0
  3182. createdCount = 0
  3183. destroyedCount = 0
  3184. resizeCount = 0
  3185. lastResizeAt = ''
  3186. engineState = 'idle'
  3187. engineError = ''
  3188. basemapState = BASEMAP_STATE.NOT_CONFIGURED
  3189. basemapText = basemapStatusText(null)
  3190. terrainState = TERRAIN_STATE.NONE
  3191. terrainText = terrainStatusText(null)
  3192. terrainError = ''
  3193. overlayCount = 0
  3194. tilesetReady = 0
  3195. tilesetFailed = []
  3196. viewMode = VIEW_MODES.GROUND
  3197. // 票 36:重置回「未配置」——不得让上一轮的矩形留在读数里(同一进程复跑断言会互相污染)。
  3198. primaryAreaPlan = planPrimaryArea({})
  3199. primaryAreaText = primaryAreaStatusText(primaryAreaPlan)
  3200. primaryAreaResolved = false
  3201. // 票 37:区域状态与限位一并归零(否则同一进程里第二次断言会读到上一轮的锁定态)。
  3202. areaState = DEFAULT_AREA_STATE
  3203. areaLimits = planAreaLimits({ plan: primaryAreaPlan, areaState, homeRange: null })
  3204. // 票 38:雾状态一并归零(同一进程复跑断言不得互相污染)。
  3205. stopFogLoop()
  3206. fogHandle = null
  3207. // 调色状态一并归零:下次挂载按(重算后的)配置重建,不残留上一轮的处理级。
  3208. gradeHandle = null
  3209. gradeParams = planSceneGrade({})
  3210. fogLifecycle = createFogLifecycle()
  3211. fogSupported = true
  3212. fogExecutable = false
  3213. fogEverEnabled = false
  3214. fogCreationFailed = false
  3215. fogReason = ''
  3216. fogDegrade = { level: FOG_DEGRADE.NONE, strengthCap: 1, reason: '' }
  3217. fogArtifacts = false
  3218. // 限速的高度系数也要归零(否则下次挂载会从上一次的中间值起步)。
  3219. fogAltitudeShown = 1
  3220. fogSlewAt = 0
  3221. fogPaintedAltitudeFactor = 1
  3222. // 票 39:交接状态一并归零(同一进程复跑断言不得互相污染)。
  3223. // 注意**保留** `handoverParams`(配置决定的,与实例无关,由 applyPrimaryArea 重算);
  3224. // 归零的是"本次实例的运行态"。
  3225. stopHandoverLoop()
  3226. if (overlayReadiness) overlayReadiness.detach()
  3227. overlayReadiness = null
  3228. overlayHandles = []
  3229. handoverLifecycle = createHandoverLifecycle()
  3230. handoverBlock = HANDOVER_BLOCK.NONE
  3231. handoverNote = ''
  3232. handoverPrototypeActive = false
  3233. handoverPainted = {}
  3234. handoverNextEvalAt = 0
  3235. handoverEdgeCache = null
  3236. fogUserDisabled = false
  3237. fogPerfSample = null
  3238. fogPerfResults = []
  3239. markerPlan = { markers: [], blocked: [], byStatus: {}, total: 0 }
  3240. markerRendered = 0
  3241. markerSkipped = 0
  3242. // 票 42:设备图层状态同样归零(同一进程复跑断言不得互相污染)。
  3243. deviceRows = []
  3244. deviceRendered = 0
  3245. deviceSkipped = 0
  3246. // 票 45:聚合组与屏幕展开状态一并归零。
  3247. overlapGroups = []
  3248. overlapMaxFan = 10
  3249. overlapPreviewGroup = ''
  3250. overlapExpandedGroup = ''
  3251. overlapState = OVERLAP_STATE.COLLAPSED
  3252. overlapCountValue = 0
  3253. }