| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525 |
- /**
- * 地图服务:驾驶舱「中央一张图」实例生命周期的唯一所有者。
- *
- * 为什么要有这一层(ADR 0004 / 0005、票 02、票 27):
- * 页面组件不得直接持有底层地图实现,地图实例的创建/尺寸/销毁必须集中在一处,
- * 才能保证「切换一级模式或收放面板时不销毁、不重建」。
- *
- * 票 27 起内部接了真实引擎(Cesium):
- * · `mountMap(host)` 幂等,且只对**真 DOM 宿主要素**异步加载引擎(`viewer` 建在宿主内);
- * · `resizeMap()` 转调 `viewer.resize()`、`unmountMap()` 转调 `viewer.destroy()`;
- * · 引擎没加载完就销毁 / 销毁后才加载完,都不会泄漏实例;
- * · 底图未配置时如实标注「底图未配置」,引擎起不来时标注失败 —— 组件里的临时影像继续兜底,
- * 页面不白屏。
- * 票 03 起相机也归本层:三键契约的实现(domain/camera.js + cameraControl.js)在引擎就绪后挂上,
- * 对外只暴露 `setCameraFocus()` / `clearCameraFocus()` / `getCameraState()`,
- * 组件与列表不需要知道 Cesium 方言;契约见 ADR 0008。
- * 接入配方见决策地图调研:`.scratch/web-cockpit-decision-map/research/cesium-fes-vite.md`。
- *
- * 本模块不依赖 Vue;对宿主元素的 DOM 操作都有 `isDomHost` 守卫,
- * 因此仍可脱离浏览器直接断言(见 verify/t01-panel-catalog.mjs)。
- */
- import { ensureCesiumBaseUrl } from './cesiumAssets.js'
- import { getCockpitConfig, loadCockpitConfig } from './cockpitConfig.js'
- import { attachCameraInput, createCameraControl } from './cameraControl.js'
- import { attachPointPick } from './pointPick.js'
- import { formatCameraState, INITIAL_PANORAMA, RETURN_FLIGHT_MS, SETTLE_EPSILON } from '../domain/camera.js'
- import { BASEMAP_STATE, basemapStatusText, planBasemap } from '../domain/basemap.js'
- import { isAtHome, planHome, planIntro } from '../domain/viewPresets.js'
- import { isStaleLocate, planLocateView } from '../domain/pointLocate.js'
- import {
- planDeviceLead,
- planDeviceReactions,
- reactionStyle,
- } from '../domain/deviceCorrespondence.js'
- import {
- fanLayout,
- groupOverlappingDevices,
- OVERLAP_STATE,
- resolveOverlapPick,
- transitionOverlapState,
- } from '../domain/overlapDevices.js'
- import { MARKER_SIZE, planDeviceAsset } from '../domain/deviceAssets.js'
- // 批次 3:位置符号合成计划层(规格 10 的唯一新接缝)与母版合成渲染。
- import { planMarker, planToDataset } from '../domain/markerPlan.js'
- import { renderMarker } from './markerAssets.js'
- // 批次 3 第五轮(票 20):DOM overlay 渲染层(billboard 发糊的结构性解决)。
- import { mountMarkerOverlay, attachOverlayRenderer, unmountMarkerOverlay } from './markerOverlay.js'
- import { resolveAsset } from '../domain/runtimeAssets.js'
- import { DEVICE_LAYER_KIND } from '../domain/deviceLayer.js'
- import { deviceVisualForStatus } from '../domain/deviceStatus.js'
- import { isRenderable } from '../domain/coordinate.js'
- import { VIEW_MODES, planViewMode, viewHprFor } from '../domain/viewModes.js'
- import { TERRAIN_STATE, planOverlays, planTerrain, planTilesets, terrainStatusText } from '../domain/mapLayers.js'
- // 票 46:`planPointMarkers` 不再在服务层调用(模式过滤归 `domain/modeLayerPlan.js`),
- // 但 `summarizeMarkerStatuses` 仍需要 —— 计划没带 byStatus 时兜底现算,保证图例同源。
- import { markerSvg, pointLegend, summarizeMarkerStatuses, haloSvg, glowSvg } from '../domain/pointStatus.js'
- import { planPrimaryArea, primaryAreaStatusText } from '../domain/primaryArea.js'
- import {
- AREA_STATE,
- DEFAULT_AREA_STATE,
- planAreaLimits,
- transitionAreaState,
- } from '../domain/areaLimits.js'
- import {
- FOG_DEGRADE,
- FOG_PHASE,
- beginFogTransition,
- createFogLifecycle,
- enterAltitudeStrength,
- approachAltitudeFactor,
- fogAltitudeFactor,
- fogReadout,
- parseHexColor,
- planFog,
- planFogDegrade,
- stepFogLifecycle,
- } from '../domain/fog.js'
- // 一张图调色(科技蓝滤镜):计划归 domain/sceneGrade.js,引擎只照计划建处理级。
- import { planSceneGrade } from '../domain/sceneGrade.js'
- import { attachModelDiagnostics } from './modelDiagnostics.js'
- import {
- HANDOVER_BLOCK,
- HANDOVER_PHASE,
- createHandoverLifecycle,
- beginHandover,
- edgeOcclusionVerdict,
- handoverReadout,
- planHandover,
- revealGate,
- returnSyncVerdict,
- stepHandover,
- } from '../domain/imageryHandover.js'
- const ENGINE_TEXT = {
- unavailable: 'Cesium 引擎未启动(无宿主要素)',
- booting: 'Cesium 引擎加载中…',
- // ready 的措辞按底图计划生成(票 29):未配置时保持票 27 的原话。
- ready: 'Cesium 引擎已就绪 · 底图未配置(正式底图见票 21)',
- failed: 'Cesium 引擎初始化失败 · 中央区域回退临时底图',
- disabled: 'Cesium 引擎已由部署配置关闭 · 中央区域用临时底图',
- }
- // 状态浮标由服务层自绘并挂在宿主内:外壳与 MapCanvas 组件按票面要求不改。
- //
- // 票 25 修订:这两个节点**不再自绘浮标**——它们被 `MapInfoPanel` 搬进右下角/右上角的
- // 状态日志集合里(`MapCanvas` 的 `relocateEngineNode()`),所以样式改为**布局中性**:
- // 不自己定位、不限宽、**不截断**。
- // 原先的 `white-space:nowrap + overflow:hidden + text-overflow:ellipsis`
- // 会把「Cesium 引擎已就绪 · 主场景区 · 底图 · 地形 · 叠加影像 · 3D Tiles …」
- // 从中间切断(维护者 2026-10-10 走查反馈:「左右显示不完整,信息被部分切除」),
- // 而被切的恰恰是排障最需要的那一整句。两种形态现在只差**配色**,不再差布局。
- const STATUS_STYLE_BASE = [
- 'position:static',
- 'pointer-events:none',
- // 换行而不是省略:宁可多占一行,也不能少说一句。
- 'white-space:normal',
- 'overflow:visible',
- 'overflow-wrap:anywhere',
- 'line-height:1.45',
- 'letter-spacing:.03em',
- 'font-size:var(--fs-caption, 10.5px)',
- ].join(';')
- /** 调试形态:带边框底色,便于开发期定位。 */
- 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`
- /** 生产形态:无边框无底色、半透明小字,让位于 3D 场景(颜色语义仍然保留)。 */
- const STATUS_STYLE_QUIET = `${STATUS_STYLE_BASE};padding:0;color:rgba(186,212,224,.62)`
- /** 当前是否走「调试形态」:配置显式给布尔值就听配置,否则跟随构建模式(生产构建自动收敛)。 */
- function isUiDebug() { const mapConfig = getCockpitConfig().map || {}
- const configured = mapConfig.ui ? mapConfig.ui.debug : null
- if (typeof configured === 'boolean') return configured
- return !!(import.meta.env && import.meta.env.DEV)
- }
- /**
- * 票 47:设备状态动效的**解析后**选项(决策票 07 问题 6)。
- *
- * 与票 32 的 `isUiDebug()` 完全同构:配置显式给布尔值就听配置,
- * 否则跟随构建模式(开发构建开、生产构建关 —— 正式环境不该有动画压满屏)。
- * 门控判定本身仍在 domain(`planDeviceEffects`),这里只解析"该不该开、上限多少"。
- */
- export function resolveDeviceEffectsOptions() {
- const mapConfig = getCockpitConfig().map || {}
- const effects = mapConfig.effects || {}
- const configured = effects.enabled
- const enabled = typeof configured === 'boolean' ? configured : !!(import.meta.env && import.meta.env.DEV)
- const limit = Number.isInteger(effects.abnormalLimit) && effects.abnormalLimit >= 1 ? effects.abnormalLimit : 30
- return { enabled, abnormalLimit: limit }
- }
- let instance = null
- let sequence = 0
- let createdCount = 0
- let destroyedCount = 0
- let resizeCount = 0
- let lastResizeAt = ''
- /**
- * 「想要的」自转时长(秒;0 = 不要自转)。
- *
- * 与「正在转的循环」(`instance.spin`)分开:相机在飞行/阻尼时起不了转,
- * 但意图要留住,等落定后由 `resumeSpinIfWanted()` 接上。
- * **不能**用一个"暂停"粘滞开关代替 —— 那种开关没人负责清,就会把自转永久哑掉
- * (维护者 2026-09-30 报的「回引导态地球不再旋转」正是这个)。
- */
- let spinWanted = 0
- let engineState = 'idle' // idle | unavailable | booting | ready | failed | disabled
- let engineError = ''
- // 底图状态由配置计划决定(票 29):not-configured | natural-earth | xyz | tianditu | invalid
- let basemapState = BASEMAP_STATE.NOT_CONFIGURED
- let basemapText = basemapStatusText(null)
- // 票 21:地形与叠加影像的状态(none / ready / invalid),叠加层数单独记。
- let terrainState = TERRAIN_STATE.NONE
- let terrainText = terrainStatusText(null)
- let terrainError = ''
- let overlayCount = 0
- // 票 25:数据来源署名。Cesium 的署名容器已被换成离屏容器(cesiumEngine.js),
- // 所以**自有数据源的署名改由地图信息面板展示**,这里只负责从同一批计划里取。
- let dataSourceCredits = []
- // 票 21:3D Tiles / 倾斜摄影(入口先留好,本机数据缺根 tileset.json 时如实报失败)
- let tilesetReady = 0
- let tilesetFailed = []
- // 票 36:主场景区计划(限位与自建雾的开关与矩形都在这里)。**引擎未就绪时也能读** ——
- // 它由配置单方面决定,不依赖 Cesium;票 37/38 的入口直接消费它。
- // 初值就是「未配置」:还没读到配置时不得凭空开出一个区域。
- let primaryAreaPlan = planPrimaryArea({})
- let primaryAreaText = primaryAreaStatusText(primaryAreaPlan)
- // 配置是否已经读到(`applyPrimaryArea` 置 true)。初值 false 时**浮标不写主场景区那一句**:
- // 那时「未配置」只是本模块的初值,不是核实的结论 —— 写出去等于替部署宣布一个还没核实的事实。
- let primaryAreaResolved = false
- // 票 37:区域状态(与 filterStore.areaState 同值;服务层持有是为了给相机控件下限位)。
- // 初值 global:进区只能由「进入系统 / 归位」触发(ADR 0009 的显式模式锁),不随相机位置自动切。
- let areaState = DEFAULT_AREA_STATE
- // 票 37:当前生效的区域限位计划(只读出口给走查与排查)。
- let areaLimits = planAreaLimits({ plan: primaryAreaPlan, areaState, homeRange: null })
- // 票 21:当前视图条件(ground / pipeline),由 setViewMode() 维护。
- let viewMode = VIEW_MODES.GROUND
- // 票 09:点位标记的最近一次计划与结果(上图层数 + 跳过的脏数据数),供 dataset 与走查读。
- let markerPlan = { markers: [], blocked: [], byStatus: {}, total: 0 }
- let markerRendered = 0
- let markerSkipped = 0
- // 票 42:关联设备临时图层的最近一次计划与结果(与摘要标记**分开**计数,互不污染)。
- let deviceRows = []
- let deviceRendered = 0
- let deviceSkipped = 0
- // 票 45:当前图层的重叠聚合计划与交互状态(只持渲染状态,不写真实位置)。
- let overlapGroups = []
- let overlapMaxFan = 10
- let overlapPreviewGroup = ''
- let overlapExpandedGroup = ''
- let overlapState = OVERLAP_STATE.COLLAPSED
- let overlapCountValue = 0
- const mapFrameHandlers = new Set()
- /** 票 41:相机状态变化的订阅者(锚定跟随的第二条触发路径,见 `onStateChange` 处的注释)。 */
- const cameraStateHandlers = new Set()
- export function onMapFrame(handler) {
- if (typeof handler !== 'function') return () => {}
- mapFrameHandlers.add(handler)
- return () => mapFrameHandlers.delete(handler)
- }
- /** 订阅相机状态变化(返回退订函数)。与 `onMapFrame` 互补:后者只在真的渲染时触发。 */
- export function onCameraStateChange(handler) {
- if (typeof handler !== 'function') return () => {}
- cameraStateHandlers.add(handler)
- return () => cameraStateHandlers.delete(handler)
- }
- /** 通知一次相机状态变化(相机输入的 `onStateChange` 与程序化下发预设都走它)。 */
- function notifyCameraStateChange() {
- const state = instance && instance.cameraControl ? instance.cameraControl.getState() : null
- // 飞行落定后把"想要的"自转接上 —— 回引导态那条路靠它兑现(见 `spinWanted`)。
- resumeSpinIfWanted(state)
- for (const handler of cameraStateHandlers) handler(state)
- }
- export function getMapCanvasRect() {
- if (!instance || !instance.viewer) return null
- const rect = instance.viewer.canvas.getBoundingClientRect()
- return { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom }
- }
- export function hasMapMarker(kind, code, pointCode = '') {
- if (!instance || !instance.engine || !instance.viewer) return false
- return kind === DEVICE_LAYER_KIND
- ? instance.engine.hasDeviceMarker(instance.viewer, code, pointCode)
- : instance.engine.hasPointMarker(instance.viewer, kind, code)
- }
- export function getMapMarkerAnchor(kind, code, pointCode = '') {
- if (!instance || !instance.engine || !instance.viewer || !code) return null
- const anchor = kind === DEVICE_LAYER_KIND
- ? instance.engine.projectDeviceMarker(instance.viewer, code, pointCode)
- : instance.engine.projectPointMarker(instance.viewer, kind, code)
- // 展开环期间把详情窗让到环外,不让窗体截住设备之间的鼠标移动。
- const group = overlapGroups.find((item) => item.rows.some((row) => row.code === code))
- return anchor && kind === DEVICE_LAYER_KIND && group &&
- (overlapExpandedGroup === group.key || overlapPreviewGroup === group.key)
- ? { ...anchor, panelGap: 60 } : anchor
- }
- let pointLocateToken = 0
- /** Invalidate a pending terrain sample before another camera command wins. */
- export function cancelPointLocate() {
- pointLocateToken += 1
- }
- /** Full monitoring-point view, distinct from setCameraFocus (non-point targets keep its old semantics). */
- export async function locateMonitoringPoint(object, { tier = 'point', centerOnly = false } = {}) {
- const token = ++pointLocateToken
- const record = instance
- if (!isRenderable(object) || !Number.isFinite(object.longitude) || !Number.isFinite(object.latitude)
- || !record || !record.cameraControl || !record.engine || !record.viewer) return false
- let height = await record.engine.sampleTerrainHeight(record.viewer, object.longitude, object.latitude)
- if (!Number.isFinite(height)) height = record.engine.groundHeightAt(record.viewer, object.longitude, object.latitude)
- // Only an explicitly absent terrain source makes the ellipsoid (height 0) a truthful fallback.
- if (!Number.isFinite(height) && terrainState === TERRAIN_STATE.NONE) height = 0
- if (isStaleLocate(token, pointLocateToken) || instance !== record || !Number.isFinite(height)) return false
- const pivot = { lon: object.longitude, lat: object.latitude, height }
- return centerOnly
- ? setCameraFocus(pivot)
- : applyViewPreset(pivot, planLocateView(getCockpitConfig().map, tier))
- }
- /** 只有真元素才做 DOM / 引擎操作;单测传普通对象时整条引擎链路都不启动。 */
- function isDomHost(host) {
- return !!host && typeof host === 'object' && host.nodeType === 1 && typeof host.appendChild === 'function'
- }
- /** 开发态判定(天地图走仓库自带的 `/tianditu.map` 代理,生产直连)。 */
- function isDev() {
- return !!(import.meta.env && import.meta.env.DEV)
- }
- /** 把引擎状态写进宿主(dataset 给走查读,浮标给人读)。 */
- function paintEngineState(record) {
- if (!isDomHost(record.host)) return
- record.host.dataset.mapEngine = engineState
- record.host.dataset.mapBasemap = basemapState
- record.host.dataset.mapTerrain = terrainState
- record.host.dataset.mapTilesets = tilesetFailed.length ? 'invalid' : tilesetReady > 0 ? 'ready' : 'none'
- // 票 21:视图条件从启动起就可观测(默认 ground),不是只有切换过才有。
- record.host.dataset.mapViewMode = viewMode
- // 票 36:主场景区的**生效状态与原因**同样是启动就可观测的读数(不是只有配了才有):
- // `data-map-primary-area` = ready / missing / invalid,`-reason` 保留关闭原因。
- // 票 37/38 的验收与人工排查都读这两行,而不是去猜浮标中文。
- record.host.dataset.mapPrimaryArea = primaryAreaPlan.state
- record.host.dataset.mapPrimaryAreaReason = primaryAreaPlan.enabled ? '' : primaryAreaPlan.reason
- // 票 32:形态在创建时定;`data-ui-mode` 让走查/排查一眼看出当前是哪一种。
- // 注意**要能改**:浮标在 `booting` 就建出来了,那时配置还没加载完(拿不到 map.ui.debug),
- // 所以配置到手后必须按当前形态重刷一次样式 —— 否则生产形态永远退回 dev 的样子。
- const debug = isUiDebug()
- const mode = debug ? 'debug' : 'production'
- if (!record.statusNode) {
- const node = record.host.ownerDocument.createElement('div')
- node.className = 'map-engine-status'
- node.setAttribute('data-map-engine-status', '')
- node.style.cssText = debug ? STATUS_STYLE : STATUS_STYLE_QUIET
- node.setAttribute('data-ui-mode', mode)
- record.host.appendChild(node)
- record.statusNode = node
- } else if (record.statusNode.dataset.uiMode !== mode) {
- record.statusNode.style.cssText = debug ? STATUS_STYLE : STATUS_STYLE_QUIET
- record.statusNode.dataset.uiMode = mode
- }
- const failed = engineState === 'failed'
- // 票 36:主场景区排在最前。它是**全局作用域**(限位与自建雾),不是某一图层的属性;
- // 更要紧的是这根浮标是 `nowrap + ellipsis`(`max-width: min(78%, 620px)`),
- // 关闭路径那句比底图那句长得多 —— 排在后面就会被尾省略掉,
- // 而「限位与雾气关了没、为什么」恰恰是这一行最不能丢的信息。
- //
- // 但**配置读到之前不许写这一句**:那时计划还是初值「未配置」,
- // 写在浮标上等于替部署宣布了一个还没核实的结论(`primaryAreaResolved` 守住这条)。
- const parts = primaryAreaResolved ? [primaryAreaText, basemapText] : [basemapText]
- if (terrainState === TERRAIN_STATE.READY) parts.push(terrainText)
- if (terrainState === TERRAIN_STATE.INVALID) parts.push(`地形加载失败(${terrainError})· 回退椭球`)
- if (overlayCount > 0) parts.push(`叠加影像 ${overlayCount} 层`)
- if (tilesetReady > 0) parts.push(`3D Tiles ${tilesetReady} 个`)
- if (tilesetFailed.length) parts.push(`3D Tiles 加载失败(${tilesetFailed[0].error})`)
- // 引擎没就绪时(booting / failed / disabled / unavailable)原本只说引擎自己的话,
- // 于是「关了引擎的部署」在主场景区这件事上彻底失声 —— 而它正是最需要看到
- // 「限位与雾气关了」的那种部署。所以配置到手后,这条读数在**任何引擎状态下**都跟着说。
- const base = engineState === 'ready' ? `Cesium 引擎已就绪 · ${parts.join(' · ')}` : ENGINE_TEXT[engineState] || engineState
- record.statusNode.textContent = `${base}${primaryAreaResolved && engineState !== 'ready' ? ` · ${primaryAreaText}` : ''}${engineError ? `(${engineError})` : ''}`
- // 生产形态没有底色,靠字色传达「正常 / 故障」——颜色语义在任何形态下都不能丢。
- if (isUiDebug()) {
- record.statusNode.style.borderColor = failed ? 'rgba(255,59,82,.55)' : 'rgba(1,171,234,.42)'
- record.statusNode.style.color = failed ? '#ffc2c9' : '#bad4e0'
- } else {
- record.statusNode.style.color = failed ? 'rgba(255,139,150,.8)' : 'rgba(186,212,224,.5)'
- }
- }
- function setEngineState(record, state, error = '') {
- engineState = state
- engineError = error
- paintEngineState(record)
- }
- /** 相机状态写进宿主 dataset:走查、演示与人工排查都读这一行(票 03 要求交互可重复演示)。 */
- function paintCameraState(record, state) {
- if (!isDomHost(record.host)) return
- record.host.dataset.camera = formatCameraState(state)
- }
- /**
- * 在给定窗口上采一张地形高程网格(票 34 的建模用途):
- * 轻量建模要把体块坐在地形上,而模型是静态几何 —— 所以先在本地 DEM 上打网格,
- * 生成时按双线性插值给每栋楼定基准高程(不然县城地面一百多米,整片楼会被埋进山里)。
- *
- * @param {number} lon 中心经度
- * @param {number} lat 中心纬度
- * @param {number} dLon 经度半宽(度)
- * @param {number} dLat 纬度半宽(度)
- * @param {number} n 每边采样点数
- * @returns {Promise<Array<number|null>>} 行优先(先纬后经)的高程数组,单位米
- */
- export async function sampleTerrainGrid(lon, lat, dLon, dLat, n) {
- if (!instance || !instance.viewer || !instance.engine) return null
- const count = Math.max(2, Math.min(64, Math.floor(n)))
- const points = []
- for (let row = 0; row < count; row++) {
- for (let col = 0; col < count; col++) {
- points.push({
- lon: lon - dLon + (2 * dLon * col) / (count - 1),
- lat: lat - dLat + (2 * dLat * row) / (count - 1),
- })
- }
- }
- return instance.engine.sampleTerrainHeights(instance.viewer, points)
- }
- /**
- * 开发态把手:三类交互与「选中换中心」都能在控制台/走查里复现,不必点着鼠标盲试。
- * 只在 DEV 且浏览器里挂,离开 /cockpit 即摘掉;生产构建不含它。
- */
- function publishDevHook() {
- const env = import.meta.env
- if (!env || !env.DEV || typeof window === 'undefined') return
- window.__cockpitMap = {
- getCameraState,
- setCameraFocus,
- clearCameraFocus,
- // 手感是主观的:DEV 下现场拖调阻尼时间常数(毫秒,0 = 不做阻尼)。
- setCameraDamping: (tauMs) => (instance && instance.cameraControl ? instance.cameraControl.setDampingTau(tauMs) : null),
- /** 「返回全球」飞行时长(毫秒);退出快慢是主观的,做成可现场拖调(见 `RETURN_FLIGHT_MS`)。 */
- setReturnFlightMs: (ms) => (instance && instance.cameraControl ? instance.cameraControl.setReturnFlightMs(ms) : null),
- // 票 30:视角预设 / 归位 / 星球自转,走查与人工排查都用这几个入口。
- goHome: (options) => goHome(options),
- getHomeView: () => getHomeView(),
- isAtHome: () => atHome(),
- setSpin: (seconds) => setGlobeSpin(seconds),
- // 票 21:地面/管线视图切换(走查断言"切换不重置中心"用)。
- setViewMode: (mode, overrides) => setViewMode(mode, overrides),
- getViewMode: () => getViewMode(),
- // 票 36:主场景区(走查与人工排查读"配没配、矩形是哪一个",票 37/38 也读这一个出口)。
- getPrimaryAreaPlan: () => getPrimaryAreaPlan(),
- // 票 37:进区锁定与区域限位(走查驱动「进入 → 锁定 → 拉远被夹 → 返回全球」全过程)。
- getAreaState: () => getAreaState(),
- getAreaLimits: () => getAreaLimits(),
- enterArea: () => setAreaState('enter'),
- returnGlobal: () => setAreaState('returnGlobal'),
- /**
- * 强制渲染一帧。诊断用,也是**隐藏标签页**下取像素证据的唯一办法:
- * 浏览器在标签页不可见时冻结 rAF,Cesium 的默认渲染循环随之停摆,
- * 截图会拿到旧帧(实测相隔 3 秒的两张截图在地图区域逐字节相同)。
- */
- forceRender: () => {
- if (!instance || !instance.viewer) return false
- const viewer = instance.viewer
- let did = false
- // `scene.render()` 才是驱动 postProcessStages.update() 的那一步
- // (处理级的 `ready` 与 uniformMap 都在其中建立)。
- try {
- if (viewer.scene && typeof viewer.scene.render === 'function') {
- viewer.scene.render()
- did = true
- }
- } catch (error) {
- did = false
- }
- if (typeof viewer.render === 'function') {
- try {
- viewer.render()
- did = true
- } catch (error) {
- // 忽略:scene.render 已经跑过就够了
- }
- }
- return did
- },
- // 票 38:自建雾(走查与人工排查读"支持/可执行/强度/降级原因",并驱动降级与性能采样)。
- getFogState: () => getFogState(),
- getFogParams: () => getFogParams(),
- setFogTranslucentArtifacts: (observed) => setFogTranslucentArtifacts(observed),
- setFogUserDisabled: (disabled) => setFogUserDisabled(disabled),
- startFogPerfSample: (label) => startFogPerfSample(label),
- stopFogPerfSample: () => stopFogPerfSample(),
- // 票 39:精细影像交接(读数含"当前阶段 / alpha / 被什么挡住 / 边缘遮蔽率 / 瓦片信号性质")。
- getHandoverState: () => getHandoverState(),
- getHandoverParams: () => getHandoverParams(),
- /** 原型接管开关:原型挂载时让生产交接交出 alpha 写入权(票 39 = 隔离,不删除)。 */
- setHandoverPrototypeActive: (active) => setHandoverPrototypeActive(active),
- /** 立刻推一次交接(走查里不必等渲染帧;隐藏标签页下 rAF 会被冻结)。 */
- advanceHandover: () => advanceHandover(),
- /**
- * 把相机**直接放到指定椭球高度**(DEV 诊断用)。
- *
- * 为什么需要它:验证"雾随高度收口"必须能在**保持区内锁定态**的同时把相机送上天,
- * 而 `goHome` 只按 `map.home` 的预设走(改不了 range),`returnGlobal` 又会改区域状态。
- * 有了它才能精确复现"退出途中相机已在高空、而雾状态机仍是 locked"的那一刻。
- */
- setCameraAltitudeForTest: (altitudeM) => {
- if (!instance || !instance.engine || !instance.viewer) return null
- // Cesium 的 `Cartesian3` 只允许出现在引擎层(本仓唯一 `import 'cesium'` 的地方),
- // 所以"放到指定高度"这件事交给引擎做,服务层只转调。
- const applied = instance.engine.setCameraAltitude(instance.viewer, altitudeM)
- if (applied !== null) paintFog()
- return applied
- },
- /**
- * 场景侧诊断(排查"画面偏色 / 整屏发白"这类问题)。
- *
- * 为什么要有它:雾读数与交接读数只描述**我们自己的状态**,
- * 而"画面为什么是这个颜色"取决于**场景里还有谁在染色** ——
- * Cesium 内置雾、`globe.baseColor`、图层 alpha、大气。只看自己的读数会漏。
- */
- getSceneDiagnostics: () => {
- if (!instance || !instance.viewer || !instance.viewer.scene) return null
- const scene = instance.viewer.scene
- const layers = []
- for (let i = 0; i < scene.imageryLayers.length; i++) {
- const layer = scene.imageryLayers.get(i)
- layers.push({
- alpha: Number(layer.alpha),
- show: !!layer.show,
- hasRectangle: !!(layer.imageryProvider && layer.imageryProvider.rectangle),
- })
- }
- return {
- // Cesium 内置雾:**生产从没关过它**(原型当时显式关掉过),
- // 它会按 `groundAtmosphereColor` 给地形叠一层冷色调 —— 排查画面偏色时必须看它。
- builtinFog: {
- enabled: !!scene.fog.enabled,
- renderable: !!scene.fog.renderable,
- density: scene.fog.density,
- visualDensityScalar: scene.fog.visualDensityScalar,
- },
- globeBaseColor: scene.globe.baseColor ? scene.globe.baseColor.toCssHexString() : null,
- globeTranslucency: scene.globe.translucency
- ? { enabled: !!scene.globe.translucency.enabled, frontFaceAlpha: scene.globe.translucency.frontFaceAlpha }
- : null,
- imageryLayers: layers,
- cameraHeightM: cameraAltitude(),
- }
- },
- // 票 34:地形高程网格采样(轻量建模把体块坐到地形上用),只在 DEV 挂。
- sampleTerrainGrid: (lon, lat, dLon, dLat, n) => sampleTerrainGrid(lon, lat, dLon, dLat, n),
- // 票 34:3D Tiles 诊断(排查"根瓦片有没有被访问 / 内容为何没请求")。
- getTilesetDiagnostics: () =>
- instance && instance.engine ? instance.engine.readTilesetDiagnostics(instance.tilesetInstances || []) : [],
- getTilesetRegions: () => instance?.tilesetCulling?.regionSnapshot?.() || [],
- getTilesetLoading: () => instance?.tilesetCulling?.snapshot() || [],
- getLifecycleStats,
- projectCameraPivot: () => (instance && instance.cameraControl ? instance.cameraControl.projectPivot() : null),
- // 票 08:地图摘要的渐进加载(人工演示与走查驱动「取下一页 / 刷新换快照」)。
- // 状态归 stores/mapStore.js 的装载器,这里只是把它接到同一个 DEV 把手上。
- mapSummary: devSummaryHandle,
- // 票 09:点位标记与图例(人工演示「刷新后点位换色」也走这里)。
- getPointMarkers: () => getPointMarkerState(),
- clearPointMarkers: () => clearPointMarkers(),
- // 票 42:关联设备临时图层(走查读独立读数,验证它不进摘要口径)。
- nudgeOverlap: (pointCode) => nudgeOverlap(pointCode),
- clearOverlapNudge: () => clearOverlapNudge(),
- pickDeviceMarker: (x, y) => {
- if (!instance || !instance.engine || !instance.viewer) return null
- return instance.engine.pickDeviceMarkerAt(instance.viewer, x, y)
- },
- // 票 45:重叠聚合的只读状态与交互入口(聚合符号不写设备选中态)。
- getOverlapState: () => getOverlapState(),
- getOverlapTargets: () => getOverlapTargets(),
- // 票 45:只读回点位标记的窗口坐标(真实鼠标走查要点的位置;不触发任何拾取)。
- getPointMarkerTargets: () => (instance && instance.engine && instance.viewer && instance.engine.readPointMarkerTargets
- ? instance.engine.readPointMarkerTargets(instance.viewer)
- : []),
- toggleOverlap: (pointCode) => toggleOverlap(pointCode),
- collapseOverlap: (pointCode) => collapseOverlap(pointCode),
- // 票 42:关联监测设备临时图层(走查读独立读数,验证它不进摘要口径)。
- getAssociatedDevices: () => getAssociatedDeviceState(),
- // 票 47:设备状态动效的只读读数(计划 + 逐点当前帧属性)。
- getDeviceEffects: () => getDeviceEffectState(),
- clearAssociatedDevices: () => clearAssociatedDevices(),
- // 票 43:设备点的对应反应(走查驱动 hover/选中并读三个可断言字段)。
- //
- // **选中/hover 的写入不从这里走**:它们必须经 store(见外壳的
- // `publishDeviceSelectionHandle`),否则同一个"当前选中设备"会有两套真相
- // (服务层模块变量 + store 的响应式状态)—— 本票实现时真机走查抓到过这一幕:
- // 直接调服务层后 store 仍读空串,界面与引擎对"选中了谁"给出两个答案。
- getDeviceCorrespondence: () => getDeviceCorrespondenceState(),
- // 三维 hover 拾取必须碰引擎,所以留在服务层(它只回身份,不写选中态)。
- hoverDeviceAt: (x, y) => hoverDeviceAt(x, y),
- pickDeviceOrPoint: (x, y) => pickDeviceOrPointAt(x, y),
- applyDeviceReactions: (reactions) => {
- if (!instance || !instance.engine || !instance.viewer) return 0
- return instance.engine.applyDeviceReactions(instance.viewer, reactions, reactionStyle)
- },
- // 选中/hover 的**用户路径**(由外壳递进来的 store 方法;未挂载时为 undefined)。
- selectDevice: (code) => (devDeviceHandles && devDeviceHandles.selectDevice ? devDeviceHandles.selectDevice(code) : ''),
- hoverDevice: (code) => (devDeviceHandles && devDeviceHandles.hoverDevice ? devDeviceHandles.hoverDevice(code) : ''),
- backToPointLevel: () => (devDeviceHandles && devDeviceHandles.backToPointLevel ? devDeviceHandles.backToPointLevel() : ''),
- // 票 10:点位点选(人工演示与走查用屏幕坐标驱动同一条链路)。
- pickPoint: (x, y) => pickPointAt(x, y),
- getPicked: () => getPickedPoint(),
- // 票 41:统一选择入口由外壳挂载时递进来(`publishPointSelectionHandle`)。
- // 走的是与地图点选/台账卡片**同一个函数** —— DEV 把手不该另造第三条定位路径。
- selectMonitoringPoint: devSelectHandle,
- }
- }
- /** 票 08:地图摘要装载器的 DEV 把手(由 MapCanvas 挂载时递进来,离开时清掉)。 */
- let devSummaryHandle = null
- /** 票 41:统一选择入口的 DEV 把手(由外壳挂载时递进来;mapService 不能 import 它,否则循环依赖)。 */
- let devSelectHandle = null
- export function publishPointSelectionHandle(handle) {
- devSelectHandle = typeof handle === 'function' ? handle : null
- publishDevHook()
- }
- /**
- * 票 43:设备**选中/hover** 的 DEV 把手(由外壳递进来,与票 41 同一成例)。
- *
- * 为什么不让 mapService 直接暴露 `setSelectedDevice`:那样会出现两套选中真相
- * (服务层模块变量 vs store 的响应式状态)。走查必须能驱动"用户那条路径",
- * 而用户路径就是 store 的方法。
- */
- let devDeviceHandles = null
- export function publishDeviceSelectionHandle(handles) {
- devDeviceHandles = handles && typeof handles === 'object' ? handles : null
- publishDevHook()
- }
- /**
- * 接上地图摘要把手:`{ advance, refresh, getState }`(`stores/mapStore.js` 的入口)。
- * 传 null 表示摘掉(离开 /cockpit)。
- */
- export function publishSummaryHandle(handle) {
- devSummaryHandle = handle || null
- publishDevHook()
- if (handle) return
- if (typeof window !== 'undefined' && window.__cockpitMap) window.__cockpitMap.mapSummary = null
- }
- function clearDevHook() {
- if (typeof window !== 'undefined' && window.__cockpitMap) {
- delete window.__cockpitMap
- }
- }
- /**
- * 异步加载引擎。全部逻辑都在 try 内,避免出现未处理的 Promise 拒绝(那会算页面级报错)。
- * @param {object} record mountMap 建立的实例记录
- */
- async function bootEngine(record) {
- try {
- setEngineState(record, 'booting')
- // 票 04:引擎开关与静态资源基址都从配置服务读(默认开、默认按应用基址取 cesium/)。
- const configState = await loadCockpitConfig()
- if (instance !== record) return
- const mapConfig = (configState.config && configState.config.map) || {}
- // 票 36:主场景区先算 —— 它是**配置事实**,与引擎开不开、起没起来都无关。
- // 刻意放在「引擎已由配置关闭」这条早退之前:关了引擎的部署,主场景区的读数仍要如实。
- applyPrimaryArea(mapConfig)
- const cesiumConfig = (mapConfig && mapConfig.cesium) || {}
- if (cesiumConfig.enabled === false) {
- setEngineState(record, 'disabled')
- return
- }
- ensureCesiumBaseUrl(cesiumConfig.baseUrl || '')
- // 票 29:底图由配置计划决定;未配置 / 配置无效一律不挂底图(票 27:不伪造配准)。
- const basemapPlan = planBasemap(mapConfig, { dev: isDev() })
- basemapState = basemapPlan.state
- basemapText = basemapStatusText(basemapPlan)
- // 票 21:地形(本地/甲方 DEM)、叠加影像(本地精细切片)与 3D Tiles/倾斜摄影。
- const terrainPlan = planTerrain(mapConfig)
- const overlayPlans = planOverlays(mapConfig)
- const tilesetPlans = planTilesets(mapConfig)
- // 票 25:署名与图层取自**同一批计划**(不另开一份真相);没有 credit 的图层不硬造一条。
- dataSourceCredits = [
- basemapPlan.credit,
- ...overlayPlans.map((overlay) => overlay.credit),
- ...tilesetPlans.map((plan) => plan.credit),
- ].filter((credit) => typeof credit === 'string' && credit.trim())
- .map((credit) => credit.trim())
- terrainState = terrainPlan.ready ? TERRAIN_STATE.READY : TERRAIN_STATE.NONE
- terrainText = terrainStatusText(terrainPlan)
- terrainError = ''
- overlayCount = 0
- tilesetReady = 0
- tilesetFailed = []
- const engine = await import('./cesiumEngine.js')
- // 加载期间已经离开 /cockpit:不留下实例。
- if (instance !== record) return
- const viewer = engine.createViewer(record.host, basemapPlan)
- record.engine = engine
- record.viewer = viewer
- // 建 Viewer 是同步的,但状态可能在这一刻被销毁过 —— 补一次销毁。
- if (instance !== record) {
- engine.destroyViewer(viewer)
- return
- }
- // 叠加影像(本地精细切片)与地形:地形是异步拉 layer.json,失败只回退椭球不影响外壳。
- // 票 39:引擎返回**句柄**(不再只是层数)—— 交接要驱动它们的 alpha。
- const overlayHandleList = engine.applyOverlays(viewer, overlayPlans)
- overlayCount = overlayHandleList.length
- record.overlayHandleList = overlayHandleList
- if (terrainPlan.ready) {
- try {
- await engine.applyTerrain(viewer, terrainPlan)
- } catch (terrainFailure) {
- if (instance !== record) return
- terrainState = TERRAIN_STATE.INVALID
- terrainError = String((terrainFailure && terrainFailure.message) || terrainFailure)
- }
- }
- if (instance !== record) return
- // 3D Tiles / 倾斜摄影:逐项挂,单项失败只记状态(入口已留好,数据缺失时不白屏)。
- if (tilesetPlans.length) {
- const tilesets = await engine.applyTilesets(viewer.scene, tilesetPlans, (state) => {
- if (instance !== record) return
- tilesetReady = state.ready
- tilesetFailed = state.failed
- record.tilesetInstances = state.instances
- engine.applyViewModeLayers(viewer, state.instances, planViewMode(viewMode))
- paintEngineState(record)
- }, (controller) => { record.tilesetCulling = controller; controller?.setPipeline?.(viewMode === VIEW_MODES.PIPELINE) })
- if (instance !== record) {
- tilesets.culling?.detach()
- engine.destroyViewer(viewer)
- return
- }
- tilesetReady = tilesets.ready
- tilesetFailed = tilesets.failed
- // 票 21:留住实例,视图条件(管线视图压半透明)要按它改样式。
- record.tilesetInstances = tilesets.instances
- record.tilesetCulling = tilesets.culling
- }
- // 票 30:地形就绪后把「家」视角的高程换成实测地形高度 —— 否则相机按椭球 0 米定位,
- // 一拉近就落进山体(实测 range 120m 全屏黑)。采样失败保持 0,等价于旧行为。
- try {
- const homePlan = planHome(mapConfig)
- const sampled = await engine.sampleTerrainHeight(viewer, homePlan.lon, homePlan.lat)
- if (instance !== record) return
- if (Number.isFinite(sampled)) record.homeHeight = sampled
- } catch {
- // 采样失败不影响启动:保持椭球高度。
- }
- if (instance !== record) {
- engine.destroyViewer(viewer)
- return
- }
- record.engine = engine
- record.viewer = viewer
- record.detachMapFrame = viewer.scene.postRender.addEventListener(() => {
- for (const handler of mapFrameHandlers) handler()
- // 票 39:瓦片就绪是 getter(读它不便宜),且**只有真的渲染过**才知道队列空没空,
- // 所以放在 postRender 里刷;交接的推进也借此在"有帧"时才有机会前进。
- if (record !== instance) return
- refreshOverlayReadiness()
- // 票 38 修复(维护者 2026-09-29 报):相机高度一变就要重画雾。
- // 退出时状态机可能已经落定(不再逐帧推进),但相机还在飞 ——
- // 不在这里补画,高度收口就不会生效,退出仍会「整个地球变白」。
- //
- // 顺序有意:**先推进限速**(它按真实 dt 收敛,一帧只能走一步),
- // 再比较"系数变了吗、要不要重画"。反过来的话读数会用旧系数。
- if (fogHandle) {
- stepAltitudeSlew()
- if (fogAltitudeShown !== fogPaintedAltitudeFactor) paintFog()
- }
- advanceHandover()
- })
- // 加载期间的尺寸变化不能丢,补一次 resize。
- if (record.resizePending) {
- record.resizePending = false
- engine.resizeViewer(viewer)
- }
- // 票 03:相机契约落在服务层 —— 引擎就绪后才挂,默认输入已在 createViewer 里全关。
- const cameraEngine = engine.createCameraEngine(viewer)
- record.cameraControl = createCameraControl({
- engine: cameraEngine,
- onStateChange: (state) => {
- paintCameraState(record, state)
- // 飞行落定的那一帧走这里(`render()` → `notify()` → 本回调)——
- // 必须在这里把"想要的自转"接上,否则回引导态后地球永远不再转
- // (`notifyCameraStateChange` 只在下发预设时被调,不在逐帧路径上)。
- resumeSpinIfWanted(state)
- // 票 41:相机一变化就让锚定跟随(订阅方按需重算落位)。
- //
- // 为什么不能只靠 `scene.postRender`:渲染帧在**隐藏标签页**里不触发(rAF 被冻结,
- // 票 37 已记录同一环境事实),于是锚定会停在旧位置;相机状态变化是**由输入/飞行驱动**的,
- // 与是否正在渲染无关,是更可靠的触发源。两条路径都留着:渲染帧管逐帧跟随,这里管"变化即更新"。
- for (const handler of cameraStateHandlers) handler(state)
- },
- })
- if (isDev()) record.detachModelDiagnostics = attachModelDiagnostics(viewer.scene, record.host, () => ({
- viewMode, engineState, regions: record.tilesetCulling?.regionSnapshot?.() || [],
- tilesets: record.tilesetCulling?.snapshot?.() || [],
- camera: record.cameraControl?.getState?.() || null,
- }), { isMoving: () => record.cameraControl?.getState?.()?.settled === false })
- // 票 37:相机控件挂上后立刻把生效限位算一遍并下发 —— 进区状态可能在引擎就绪之前
- // 就已经定了(引导层先出现、配置先到手),这里补一次,免得第一次交互才生效。
- applyAreaLimits()
- paintAreaState()
- // 票 38:自建雾处理级(**唯一一个**,复用不重建)。进区状态可能已经定了,
- // 所以建完就按当前状态对一次门控,不等用户再点一次。
- if (ensureFogStage()) {
- retargetFogForArea(areaState)
- }
- paintFog()
- // 一张图调色(科技蓝滤镜):**在雾之后建级** —— 后处理按加入顺序执行,
- // 调色排在雾后面,雾出的白也被统一成同一冷蓝调。配置关掉时不建级。
- if (ensureGradeStage()) {
- paintGrade()
- }
- // 票 39:挂上精细层句柄并驱动一次交接 —— 进区状态可能在引擎就绪前就定了
- // (配置先到手),这里补一次,免得第一次进区才生效。
- // 顺序有意:**在雾之后** —— 闸门要读雾的强度,雾没建好时闸门必然不放行。
- attachHandoverLayers(engine, record.overlayHandleList || [])
- advanceHandover()
- record.detachCameraInput = attachCameraInput(cameraEngine.inputTarget(), record.cameraControl)
- record.host.addEventListener('pointerdown', cancelPointLocate)
- record.host.addEventListener('wheel', cancelPointLocate)
- record.detachLocateInput = () => {
- record.host.removeEventListener('pointerdown', cancelPointLocate)
- record.host.removeEventListener('wheel', cancelPointLocate)
- }
- // 票 10/43/23:先走设备/监测点优先链,只有没有业务点命中时才把点击交给局部增强。
- record.detachPickInput = attachPointPick(record.host, ({ x, y }) => {
- const resolved = pickDeviceOrPointAt(x, y)
- if (resolved.target !== 'none') return resolved
- return pickPipelineAt(x, y)
- })
- // 票 43:三维 hover 拾取(pointermove 观察型监听,与相机/点选互不干扰)。
- // 节流在这里做:pointermove 每秒可达上百次,每次都 scene.pick 会白烧 GPU/CPU。
- // 规格把"节流数值"留实现定,这里取 60ms(≈16fps 的 hover 反馈,肉眼连贯且开销可控)。
- const HOVER_THROTTLE_MS = 60
- let lastHoverAt = 0
- const onHoverMove = (event) => {
- const now = Date.now()
- if (now - lastHoverAt < HOVER_THROTTLE_MS) return
- lastHoverAt = now
- hoverDeviceAt(event.clientX, event.clientY)
- // 批次 3:hover 完整名称 tooltip(label 截断后的通用 hover 详情)。
- paintMarkerTooltip(event)
- }
- const onHoverLeave = () => {
- setHoveredDevice('')
- hideMarkerTooltip()
- if (overlapPreviewGroup) collapseOverlap(overlapPreviewGroup)
- }
- record.host.addEventListener('pointermove', onHoverMove)
- record.host.addEventListener('pointerleave', onHoverLeave)
- record.detachHoverInput = () => {
- record.host.removeEventListener('pointermove', onHoverMove)
- record.host.removeEventListener('pointerleave', onHoverLeave)
- }
- // 用当前真实相机反读弹簧臂参数,避免第一次交互时相机跳变。
- // 引导态视角的**契约就是 Cesium Viewer 的默认相机**(维护者 2026-09-30 裁定)——
- // 反读出来的 `{pivot, hpr}` 会被存成 `introView`,之后「闲置回引导」回到它。
- record.cameraControl.syncFromEngine()
- // 批次 3 第五轮(票 20):DOM overlay 渲染标记与标签——billboard 发糊与底板生硬的
- // 结构性解决。失败自动回退既有 billboard 链路(syncPointMarkersForPlan 不动)。
- if (mountMarkerOverlay(record.host)) {
- record.detachOverlay = attachOverlayRenderer(instance.viewer, () => {
- if (!markerPlan || !instance || !instance.viewer) return null
- const colors = toneColors()
- return {
- markers: markerPlan.markers,
- tones: colors,
- planOf: (marker) => planMarker({
- kind: marker.kind === 'unassociated-equipment' ? 'monitoring-device' : 'monitoring-point',
- status: marker.status,
- industry: marker.industry,
- tone: marker.tone,
- unassociated: marker.kind === 'unassociated-equipment',
- }),
- innerUrls: markerInnerUrls(),
- // 走相机引擎的 projectPivot(createCameraEngine 返回对象上的方法,SceneTransforms 包装)
- projectToWindow: (lon, lat) => {
- const at = cameraEngine.projectPivot({ lon, lat, height: 0 })
- if (!at) return null
- // overlay 节点挂在画布容器内(相对同一原点),去掉 host 的画布偏移
- const rect = record.host.getBoundingClientRect()
- const canvas = instance.viewer.canvas.getBoundingClientRect()
- return { x: at.x - (canvas.left - rect.left), y: at.y - (canvas.top - rect.top) }
- },
- }
- })
- }
- // 批次 3:overlay 的内层图标 URL 表(manifest 解析产物,语义名 → data URL)
- function markerInnerUrls() {
- const urls = {}
- for (const icon of ['gas', 'water', 'drainage', 'manhole']) {
- const asset = resolveAsset(`marker.inner.${icon}`)
- if (asset.url) urls[icon] = asset.url
- }
- return urls
- }
- // 引导视角基准 = 引擎挂载时反读到的 **Cesium Viewer 默认相机**
- // (维护者 2026-09-30 裁定:初始引导页就是这张"整个地球"的地球仪视角,
- // **不是** `INITIAL_PANORAMA`)。之后「闲置回引导」回到它。
- const introState = record.cameraControl.getState()
- if (introState && introState.shownPivot && introState.shownHpr) {
- introView = {
- pivot: { ...introState.shownPivot },
- hpr: { ...introState.shownHpr },
- }
- }
- // 票 30:进入引导期间让星球自转(进入系统时由 setGlobeSpin(0) 停)。
- const introConfig = planIntro(mapConfig)
- if (introConfig.enabled && introConfig.spinSeconds > 0) {
- setGlobeSpin(introConfig.spinSeconds)
- }
- setEngineState(record, 'ready')
- publishDevHook()
- } catch (error) {
- if (instance !== record) return
- setEngineState(record, 'failed', String((error && error.message) || error))
- }
- }
- /**
- * 挂载地图实例。幂等:已挂载时直接返回既有实例,绝不重建。
- * @param {Element} host 承载地图的宿主要素
- */
- export function mountMap(host) {
- if (instance) {
- return instance
- }
- sequence += 1
- createdCount += 1
- instance = {
- id: `cockpit-map-${sequence}`,
- host: host || null,
- createdAt: new Date().toISOString(),
- engine: null,
- viewer: null,
- statusNode: null,
- resizePending: false,
- cameraControl: null,
- detachCameraInput: null,
- // 票 10:点位点选的 DOM detach(与相机输入同一套生命周期)。
- detachPickInput: null,
- }
- const record = instance
- engineState = 'idle'
- engineError = ''
- if (isDomHost(record.host)) {
- bootEngine(record)
- } else {
- setEngineState(record, 'unavailable')
- }
- return record
- }
- export function getMapInstance() {
- return instance
- }
- /** 宿主尺寸变化时调用:引擎就绪后转调 `viewer.resize()`。 */
- export function resizeMap() {
- if (!instance) {
- return
- }
- resizeCount += 1
- lastResizeAt = new Date().toISOString()
- if (instance.viewer) {
- instance.engine.resizeViewer(instance.viewer)
- } else {
- // 引擎还在加载:记下来,等 boot 完成后补一次。
- instance.resizePending = true
- }
- }
- /** 只在离开 /cockpit 时销毁。 */
- export function unmountMap() {
- if (!instance) {
- return
- }
- const record = instance
- cancelPointLocate()
- instance = null
- destroyedCount += 1
- clearDevHook()
- // 票 30:先停自转循环,再拆其它资源(否则 rAF 会打到已销毁的 viewer 上)。
- stopSpin()
- if (record.statusNode) {
- record.statusNode.remove()
- record.statusNode = null
- }
- // 相机:先摘事件绑定再销毁实例,避免销毁后仍有输入打进来。
- if (record.detachLocateInput) {
- record.detachLocateInput()
- record.detachLocateInput = null
- }
- if (record.detachCameraInput) {
- record.detachCameraInput()
- record.detachCameraInput = null
- }
- // 票 10:点选监听同样先摘(它也会在销毁后读到空 viewer)。
- if (record.detachPickInput) {
- record.detachPickInput()
- record.detachPickInput = null
- }
- clearPipelinePickHandlers()
- // 票 43:hover 监听一并摘掉(与点选同一个"销毁后读到空 viewer"的坑)。
- if (record.detachHoverInput) {
- record.detachHoverInput()
- record.detachHoverInput = null
- }
- if (record.cameraControl) {
- record.cameraControl.dispose()
- record.cameraControl = null
- }
- // 票 38:自建雾处理级随地图销毁(规格:复用对象,离开地图时销毁)。
- // 顺序在 viewer 销毁**之前** —— `postProcessStages.remove` 需要场景还活着。
- stopFogLoop()
- stopFogPerfSampleListener()
- if (fogHandle) {
- try {
- fogHandle.destroy()
- } catch (error) {
- // 销毁失败不阻断离开页面(下面 viewer.destroy 会整体回收)。
- }
- fogHandle = null
- }
- // 一张图调色级随地图销毁(同一条纪律:先摘处理级,再销毁 viewer)。
- if (gradeHandle) {
- try {
- gradeHandle.destroy()
- } catch (error) {
- // 销毁失败不阻断离开页面(下面 viewer.destroy 会整体回收)。
- }
- gradeHandle = null
- }
- // 状态一并归零:下次挂载从"全球态无雾"重新走,不残留上一轮的强度。
- fogLifecycle = createFogLifecycle()
- fogExecutable = false
- fogEverEnabled = false
- fogCreationFailed = false
- if (isDomHost(record.host)) {
- delete record.host.dataset.camera
- // 调色读数一并收(宿主上不留上一轮的 on/off)。
- delete record.host.dataset.mapGrade
- // 票 10:选中态随地图一起收(宿主上不留上一轮的选中标记)。
- delete record.host.dataset.mapPicked
- delete record.host.dataset.mapPickedStatus
- // 票 14:强调态随地图一起收(宿主上不留上一轮的高亮计数)。
- delete record.host.dataset.mapEmphasis
- // 票 42:关联设备图层的读数一并收(否则下次进入会读到上一轮残留的数)。
- delete record.host.dataset.mapDevices
- delete record.host.dataset.mapDevicesPlan
- // 票 43:三个对应反应字段也一并收(不留上一轮的选中/hover 读数)。
- delete record.host.dataset.mapDevicePicked
- delete record.host.dataset.mapDeviceHovered
- delete record.host.dataset.mapPanelSelection
- }
- // 票 42:设备图层的计划/计数也归零(viewer 随后销毁,图层本身随之消失)。
- deviceRows = []
- deviceRendered = 0
- deviceSkipped = 0
- // 票 43:选中/hover 同样归零。
- selectedDeviceCode = ''
- hoveredDeviceCode = ''
- lastPicked = null
- if (record.detachModelDiagnostics) { record.detachModelDiagnostics(); record.detachModelDiagnostics = null }
- if (record.detachMapFrame) {
- record.detachMapFrame()
- record.detachMapFrame = null
- }
- if (record.tilesetCulling && typeof record.tilesetCulling.detach === 'function') {
- record.tilesetCulling.detach()
- record.tilesetCulling = null
- }
- if (record.viewer) {
- if (record.pipeLayer) record.pipeLayer.clearPipeLayer(record.viewer)
- record.engine.destroyViewer(record.viewer)
- record.viewer = null
- }
- engineState = 'idle'
- engineError = ''
- }
- /**
- * 选中/取消选中相机中心(票 03 验收第 4、5 条):从无选中到选中时平滑换中心并保持 heading 与 range;
- * 传 null 表示取消选中 —— 中心不回退,只有左键平移才把中心更新为地面拾取点。
- * @param {{lon: number, lat: number, height?: number}|null} point 对象地表点
- */
- export function setCameraFocus(point) {
- cancelPointLocate()
- if (!instance || !instance.cameraControl) return false
- return instance.cameraControl.setFocus(point || null)
- }
- export function clearCameraFocus() {
- return setCameraFocus(null)
- }
- // ---- 票 30:视角预设(家)/ 快速归位 / 星球自转 ----
- /** 当前生效的「家」视角(配置优先,缺项用默认)。
- * 地形就绪后中心高程用**实测地形高度**(`record.homeHeight`),否则相机按椭球 0 米算会钻进山体。 */
- export function getHomeView() {
- const home = planHome((getCockpitConfig().map) || {})
- if (instance && Number.isFinite(instance.homeHeight)) {
- return { ...home, height: instance.homeHeight }
- }
- return home
- }
- /** 进入引导配置。 */
- export function getIntroConfig() {
- return planIntro((getCockpitConfig().map) || {})
- }
- /**
- * 飞到「家」视角(快速归位)。`options.immediate` = 不走阻尼直接落位(进系统首帧用)。
- * @returns {boolean} 是否已下发
- */
- export function goHome(options = {}) {
- cancelPointLocate()
- // 票 30:进入系统/归位都要先停自转 —— 否则自转循环每帧以 immediate 写中心,
- // 会把这次飞行的目标一路冲掉(实测:点「进入」后镜头停在自转途中的经度上)。
- stopSpin()
- // 票 37:归位即进区(ADR 0009「点『进入系统』/『归位』进区并锁定」)。
- // **先换限位再飞**:否则飞行目标 12,000 m 会被"区外"的宽上限放行、落地后才被夹,
- // 画面会在到位瞬间跳一下。
- setAreaState('enter')
- const home = getHomeView()
- return applyViewPreset(home, home, options)
- }
- /** `hpr` 是否已经是初始全景(引导态的角度)。用与「停稳」同一套阈值,不另立魔数。 */
- function isAtPanorama(hpr) {
- if (!hpr || !introView) return false
- return Math.abs(hpr.heading - introView.hpr.heading) <= SETTLE_EPSILON.headingDeg
- && Math.abs(hpr.pitch - introView.hpr.pitch) <= SETTLE_EPSILON.pitchDeg
- && Math.abs(hpr.range - introView.hpr.range) <= SETTLE_EPSILON.rangeMeters
- }
- /**
- * 引导态视角基准:**引擎挂载时反读到的 Cesium Viewer 默认相机**。
- *
- * 契约(维护者 2026-09-30 裁定):初始引导页看到的是 Cesium 默认相机给的那张
- * "整个地球"的地球仪视角 —— **不是** `INITIAL_PANORAMA`(那是「返回全球」的目标,
- * 俯角更小、距离更近,画面只能看到一部分球)。所以「闲置回引导」的目标取这份反读值,
- * 三者各归各位:初始引导页 = 引导视角;返回全球 = INITIAL_PANORAMA;闲置回引导 = 引导视角。
- */
- let introView = null
- /**
- * 回引导态:把相机送回**引导视角**(引擎挂载时反读到的 Cesium 默认相机)。
- *
- * 维护者 2026-09-30 报障:「闲置回到引导状态时,摄像机角度没有回到引导状态,且地球也没有
- * 重新开始旋转,除了中心出现『点击进入系统』外用户不一定能意识到进入引导状态了」。
- * 修法两件:① 相机回到**引导视角**(本函数);② 自转恢复(`spinWanted` 意图 + 落定后接上)。
- *
- * 与 `setAreaState('returnGlobal')` 的分工(两者都要,别互相替代):
- * · 本函数**不改区域状态** —— 回引导只在 `global` 态发生(`shouldReturnToIntro` 已把关);
- * · 已经在引导视角上就**不排飞行**,直接返回 —— 否则自转要白等一个飞行时长才起得来。
- * 中心沿用引导视角的中心(自转本来就会推进它)。
- *
- * @returns {boolean} 是否已下发(相机没接上或没有引导视角基准时 false,调用方可重试)
- */
- export function resetToIntroView() {
- if (!instance || !instance.cameraControl) return false
- // 引导视角基准还没取到(引擎刚挂、反读未完成)—— 不猜,等下一次调用。
- if (!introView) return false
- // 契约:闲置回引导**只在 global 态发生**。global 的限位就是放开的全局区间 ——
- // 这里必须**先重算限位**再下发预设:若限位还停在区内的 max=12000,
- // 引导视角的 range(约 3×10⁷)会被 `clampRange` 夹成 12000,
- // 相机贴着县城上空、整屏被雾罩成一片绿(维护者真机复现)。
- // `rebindAreaLimits()` 按**当前 areaState** 重算 —— global 时自然是放开的。
- rebindAreaLimits()
- const control = instance.cameraControl
- const state = control.getState()
- if (state && state.settled && isAtPanorama(state.shownHpr)) return true
- // **中心保持在当前位置**(不回到挂载时的经度 —— 自转/拖动本来就会推进它);
- // **角度/高度**回到引导视角基准(Cesium 默认相机)。
- const pivot = (state && state.pivot) || introView.pivot
- return applyViewPreset(pivot, introView.hpr, { flightMs: control.getState().returnFlightMs })
- }
- /**
- * 内部共用:把预设下发给相机控件。
- * @param {object} home planHome() 结果
- * @param {object} hpr 预设的 heading/pitch/range
- */
- function applyViewPreset(home, hpr, options = {}) {
- if (!instance || !instance.cameraControl) return false
- const applied = instance.cameraControl.applyViewPreset(home, hpr, options)
- if (applied) {
- publishDevHook()
- // 票 41:程序化下发视角(定位 / 归位 / 返回全球 / 自转)也要通知锚定跟随。
- // 这条路径**不经过相机输入**,若只靠输入回调与渲染帧,隐藏标签页里(rAF 冻结)
- // 锚定会停在旧位置 —— 真机走查实测:点「重新定位」后相机已回到点位、面板却仍显示隐藏。
- notifyCameraStateChange()
- }
- return applied
- }
- /** 当前是否已停在「家」视角(归位按钮据此显示状态)。 */
- export function atHome() {
- if (!instance || !instance.cameraControl) return false
- return isAtHome(instance.cameraControl.getState(), getHomeView())
- }
- // ---- 票 21:地面视图 / 管线视图(全局视图条件,语义见决策地图票 05)----
- /**
- * 切换地面/管线视图。
- *
- * 三件事,顺序有意为之:
- * 1. **先记下**进管线前的地面视角(倾角 + 距离),回地面时按它还原;
- * 2. 换相机的 pitch 上下限与预设(管线:pitch 抬到 -18°、弹簧臂压到 35%)——
- * **只动 pitch 与 range,heading 与旋转中心原样带走**(决策地图硬约束:切换视图不重置中心);
- * 3. 调图层可见性:管线视图把地形与 3D Tiles 压成半透明,地面视图还原。
- *
- * 三键分工不受影响:本函数不碰 `cameraControl` 的输入绑定,只改它的 limits 与目标 hpr。
- * @param {string} mode `ground` | `pipeline`
- * @param {object} [overrides] 配置覆盖(pitch / rangeFactor / 透明度)
- * @returns {boolean} 是否生效(引擎未就绪时 false,调用方可稍后重试)
- */
- export function setViewMode(mode, overrides = {}) {
- cancelPointLocate()
- const plan = planViewMode(mode, overrides)
- if (!instance || !instance.cameraControl) return false
- const control = instance.cameraControl
- const state = control.getState()
- const hpr = state && state.hpr ? state.hpr : null
- if (!hpr) return false
- if (plan.mode === VIEW_MODES.PIPELINE) {
- // 进管线:记下地面视角,供回地面时还原(只在第一次进管线时记,避免连续切换被覆盖)。
- if (viewMode !== VIEW_MODES.PIPELINE) {
- instance.groundHprBaseline = { pitch: hpr.pitch, range: hpr.range }
- }
- }
- // limits 必须先换:否则回地面时 pitch 仍按管线的宽容范围夹,画面角度会不对。
- control.setPitchLimits(plan.limits)
- // 票 37:**先落模块级 `viewMode` 再算限位** —— `applyAreaLimits()` 是按当前视图模式
- // 决定要不要叠管线区间 [400, 6000] 的。顺序反了会拿上一次的模式算,切到管线时区域上限
- // 仍按地面算(读数与实测都不对)。这是本票实现时踩到的真顺序 bug。
- viewMode = plan.mode
- instance.tilesetCulling?.setPipeline?.(viewMode === VIEW_MODES.PIPELINE)
- applyAreaLimits()
- // 票 38:管线视图的降级上限跟着视图模式走(**默认不预关闭**,只有观察到透明错位才减弱)。
- refreshFogDegrade()
- paintFog()
- const nextHpr = viewHprFor(hpr, plan, instance.groundHprBaseline)
- control.setHpr(nextHpr)
- if (instance.engine && instance.viewer) {
- instance.engine.applyViewModeLayers(instance.viewer, instance.tilesetInstances || [], plan)
- instance.pipeLayer?.setPipeViewMode(instance.viewer, plan.mode)
- }
- if (isDomHost(instance.host)) {
- instance.host.dataset.mapViewMode = plan.mode
- }
- return true
- }
- /** 当前视图条件(ground / pipeline)。 */
- export function getViewMode() {
- return viewMode
- }
- // ---- 票 36:主场景区(限位与自建雾的开关与矩形)----
- /**
- * 重新计算主场景区计划并刷新读数。配置到手时调一次(`bootEngine`)。
- *
- * 为什么它不放进 `bootEngine` 的引擎链路里:本计划**不依赖引擎**——
- * 票 37/38 只问「配没配、在哪儿」,引擎是死是活都不改变答案。所以引擎失败时这个读数照样有效。
- */
- function applyPrimaryArea(mapConfig) {
- primaryAreaPlan = planPrimaryArea(mapConfig || {})
- primaryAreaText = primaryAreaStatusText(primaryAreaPlan)
- primaryAreaResolved = true
- // 票 38:雾参数与主场景区一起算 —— 两者都由配置单方面决定,都不依赖引擎。
- fogParams = planFog(mapConfig || {})
- // 参数换了,限速中的高度系数要立刻按新参数对齐(否则会按两套参数各说各话)。
- setAltitudeSlewImmediate()
- fogReason = fogParams.enabled ? '' : '已由配置关闭自建雾(map.fog.enabled=false)'
- // 票 39:交接参数同源(同样不依赖引擎)。
- handoverParams = planHandover(mapConfig || {})
- // 调色参数同源(同样不依赖引擎):配置一换,立即写进已建的处理级。
- gradeParams = planSceneGrade(mapConfig || {})
- paintGrade()
- // 影像矩形缓存随配置失效(判"会露边的是影像边界"要用它)。
- overlayBoundsResolved = false
- overlayBoundsCache = null
- if (instance) paintEngineState(instance)
- }
- /**
- * 当前主场景区计划(票 37/38 的**唯一**入口)。
- *
- * 返回冻结的副本:消费方要按它开限位/开雾,但不能把这份计划改掉
- * ——「谁开的、开在哪儿」必须只有配置一个来源。
- * @returns {{state: string, enabled: boolean, bounds: number[]|null, reason: string, sizeText: string}}
- */
- export function getPrimaryAreaPlan() {
- return { ...primaryAreaPlan, bounds: primaryAreaPlan.bounds ? primaryAreaPlan.bounds.slice() : null }
- }
- /**
- * 底图措辞(票 25)。原为引擎浮标上的一句,现在由右下角信息面板展示。
- * @returns {string} 人话措辞(如「底图:内置离线影像 Natural Earth II」)
- */
- export function getBasemapLabel() {
- return basemapText
- }
- /**
- * 数据来源署名(票 25)。
- *
- * Cesium 自带的署名容器已被换成**离屏容器**(`services/cesiumEngine.js`),因此
- * 底图/叠加影像/3D Tiles 的 credit 改由地图信息面板展示。这是「隐藏引擎署名」之后
- * 仍然必须保留的那部分来源标注——本部署不用 ion,但自有影像的署名不能一起丢。
- * @returns {string[]} 去重前的署名条目(空数组 = 本部署没有声明任何 credit)
- */
- export function getDataSourceCredits() {
- return [...dataSourceCredits]
- }
- // ---- 票 37:进区锁定与相机区域限位 ----
- /**
- * 把「主场景区计划 + 区域状态 + 视图模式」合成生效限位并下发给相机控件。
- *
- * 三处夹取的合成点(ADR 0009):区域上限 `home.range`、视图模式区间(管线 `[400, 6000]`)、
- * 全局 `[120, 3×10⁷]`。**只在这里算一次**,相机控件不自己拼 —— 否则视图切换与进区各算一套,
- * 「最终生效 = 三者交集」就没人保证了。
- *
- * 两件事分开:**算计划**与**推给相机**。计划是配置事实(引擎没起来也算得出来,读数照样要准),
- * 推给相机需要实例。早先两者写在一起,导致"引擎就绪前进区"时 `getAreaLimits()` 还是旧的,
- * 界面会显示 locked=false —— 而状态明明已经锁了。
- */
- function applyAreaLimits() {
- const home = planHome(getCockpitConfig().map || {})
- const isPipeline = viewMode === VIEW_MODES.PIPELINE
- const viewPlan = planViewMode(viewMode)
- // 地面视图不受视图模式区间约束(`planViewMode` 给的是管线区间;地面只认区域与全局)。
- areaLimits = planAreaLimits({
- plan: primaryAreaPlan,
- areaState,
- homeRange: home.range,
- viewMin: isPipeline ? viewPlan.minRange : null,
- viewMax: isPipeline ? viewPlan.maxRange : null,
- })
- if (!instance || !instance.cameraControl) return areaLimits
- instance.cameraControl.setRangeLimits({
- min: areaLimits.min,
- max: areaLimits.max,
- // 区域未启用时传 null = 不限中心(`area` 为空即"区外自由视角")。
- area: areaLimits.locked ? { bounds: areaLimits.bounds, groundMargin: areaLimits.groundMargin } : null,
- })
- return areaLimits
- }
- /** 当前生效的区域限位(走查与人工排查读它)。 */
- export function getAreaLimits() {
- return { ...areaLimits }
- }
- /** 当前区域状态(`locked` / `global`)。 */
- export function getAreaState() {
- return areaState
- }
- /**
- * 进区 / 返回全球(ADR 0009 的**显式模式锁**)。
- *
- * 判定不依赖相机位置(贴边不会抖),只有这两条路能改状态:
- * · `enter`(进入系统 / 归位)→ 进区锁定。主场景区不可用时**进不去**(票 36 的关闭路径),
- * 返回 `blocked` 原因,调用方据此如实提示,不假装锁上了;
- * · `returnGlobal` → 解除限位并阻尼飞到程序初始态全景,之后完全自由。
- *
- * @param {string} action `enter` | `returnGlobal`
- * @returns {{applied: boolean, state: string, blocked: string}}
- */
- export function setAreaState(action) {
- const result = transitionAreaState(areaState, action, primaryAreaPlan)
- if (result.blocked) {
- return { applied: false, state: areaState, blocked: result.blocked }
- }
- areaState = result.state
- if (instance) instance.areaState = areaState
- // ── 票 39:进出的**顺序**是这张票的核心,两处都按决策 08 的第 3 条排 ──
- //
- // 进入:雾**先**形成保护,闸门(`advanceHandover`)才可能放行影像渐显。
- // 所以先 `retargetFogForArea`(把雾往锁定态推),再 `advanceHandover`。
- //
- // 返回:这里**故意不立刻撤雾**。决策 08 要求「精细影像在失去保护前收起」,
- // 一按返回就撤雾会让影像在无保护状态下露出矩形边。正确顺序是:
- // ① 影像立刻开始收起(与镜头起飞**并行**,避免"收完→停顿→才飞");
- // ② 雾留到影像确实不可见之后,由 `maybeDropFogAfterConceal()` 撤除。
- // 没有可交接的精细层时(没配叠加影像 / 交接关闭)就退化成旧行为,立刻撤雾 ——
- // 否则雾会永远撤不掉。
- const handoverWillHandleFogDrop = areaState !== AREA_STATE.LOCKED && handoverHandlesFogDrop()
- if (action === 'returnGlobal') {
- // 先让交接进入"收起"(立刻排帧),雾暂不动。
- // `force=true`:进出是离散动作,必须**这一次调用就定下**目标相位,
- // 不能被稳态节流吞掉(否则按下返回后要等最多 120 ms 才开始收影像)。
- advanceHandover(true)
- }
- if (!handoverWillHandleFogDrop) {
- retargetFogForArea(areaState)
- }
- if (action === 'returnGlobal') {
- cancelPointLocate()
- // 解除限位后再飞:先松绑,飞行途中就不会被区域上限一路夹着(ADR 0009「解除限位 + 阻尼飞到全景」)。
- rebindAreaLimits()
- stopSpin()
- // 回程序初始态全景(heading 0 / pitch −60 / range 20,000,000)。
- // 中心沿用当前位置 —— 全景是"退到星球尺度",不是"换个地方"。
- //
- // `flightMs`:**按时长 + 慢起缓动**飞回去,而不是走阻尼。
- // 目标 range 是 20,000,000 m、起点 12,000 m(跨 1.7e6 倍),阻尼的指数逼近
- // 起步最快,头一帧就越过整条雾带(20–42 km,实算 0.15 ms)——
- // 观感就是"退出太快、雾一瞬间就散了",且那时雾还满着 ⇒ 整个地球被涂成雾色。
- // 详见 `RETURN_FLIGHT_MS` 的注释。
- //
- // 票 40:目标**角度/高度**改用**引导视角基准**(`introView` —— 引擎挂载时反读的
- // Cesium Viewer 默认相机,维护者裁定初始引导页就是那张"整个地球"的视角,
- // `INITIAL_PANORAMA` 俯角/距离都不对);**中心仍沿用当前位置** ——
- // 全景是"退到星球尺度",不是"换个地方"。
- const current = getCameraState()
- const pivot = (current && current.pivot) || getHomeView()
- const targetHpr = introView
- ? introView.hpr
- : INITIAL_PANORAMA
- applyViewPreset(pivot, targetHpr, {
- flightMs: instance && instance.cameraControl
- ? instance.cameraControl.getState().returnFlightMs
- : RETURN_FLIGHT_MS,
- })
- } else {
- // 进区:先把限位换成区内,再夹一次当前值 —— 否则「进区时 range 已在 12 km 以外」
- // 要等用户滚一次才被夹(决策票 03 记的 4.7× 突跳就是这么来的)。
- rebindAreaLimits()
- if (instance && instance.cameraControl) instance.cameraControl.clampToLimits()
- // 雾已按新状态推进(上面 `retargetFogForArea`)——此刻闸门读到的强度就是最新的,
- // 于是"雾没到位 → 不放行渐显"这条在**同一次调用内**就生效,不留一帧窗口。
- // `force=true`:进区是离散动作,同样不该被稳态节流吞掉。
- advanceHandover(true)
- }
- paintAreaState()
- publishDevHook()
- return { applied: true, state: areaState, blocked: '' }
- }
- /** 限位变化后重新下发(进区 / 返回全球 / 切视图模式都要走它)。 */
- function rebindAreaLimits() {
- applyAreaLimits()
- }
- /** 区域状态写进宿主 dataset(走查断言面;人工排查也读它)。 */
- function paintAreaState() {
- if (!instance || !isDomHost(instance.host)) return
- instance.host.dataset.mapAreaState = areaState
- instance.host.dataset.mapAreaLocked = areaLimits.locked ? 'yes' : 'no'
- instance.host.dataset.mapRangeMax = String(Math.round(areaLimits.max))
- }
- // ---- 票 38:区内视距雾气 ----
- /**
- * 雾的策略与生命周期**唯一所有者**(规格:引擎只负责一遍全屏 pass,决定"什么时候多浓"的在这里)。
- *
- * 状态分两层,与决策票 07 的措辞严格对应:
- * · `supported` = 当前环境**有没有**深度能力(`Context.depthTexture`,布尔能力标志);
- * · `executable` = 处理级**是否可执行**(`stage.ready`);
- * · `strength` = 当前强度。
- * **三者都不等于"矩形边缘已被遮住"** —— 对外读数里 `coversEdges` 恒为 false,
- * 那是票 39 的交接判断,不在这里承诺。
- */
- let fogParams = planFog({})
- let fogLifecycle = createFogLifecycle()
- let fogHandle = null
- let fogSupported = true
- let fogExecutable = false
- let fogReason = ''
- // ---- 一张图调色(科技蓝滤镜):常驻全屏处理级,无进出生命周期,只有配置一个来源。----
- let gradeParams = planSceneGrade({})
- let gradeHandle = null
- /**
- * 是否曾经真正启用过 / 是否**曾经**观测到 `ready === true`(shader 编译并跑通过一遍)。
- *
- * 为什么不能拿"当前 ready"当判据:它**随渲染活动变化** —— 处理级在 `update()` 里被置 true,
- * 一旦不再渲染(隐藏标签页、强度回 0 关掉处理级),下一次读就可能落回 false。
- * 本票真机验证时实测到过这一幕:同一个 stage 先 `executable:true`(跑通了),
- * 标签页转入隐藏后读数又变 false。若把它当"能不能用",读数会在「正常 ↔ 失败」间来回跳。
- * 声明**必须放在 `paintFog` 之前**(它在渲染循环里就会写这两个标志)。
- */
- let fogEverEnabled = false
- let fogEverReady = false
- /**
- * 是否**确凿地失败过**(创建抛错)。
- *
- * 这是 `failed` 状态的**唯一**来源。刻意不用"有没有 handle""此刻 ready 吗"这类**间接推断**:
- * · 引擎还没起来时没有 handle,那不是故障(只是在启动);
- * · 隐藏标签页不渲染时 `ready` 会落回 false,那也不是故障。
- * 两者若被当成失败,界面就会在最不该报警的时刻报"视距雾不可用"(本票真机验证时撞上过)。
- * 失败只由确凿证据判定:创建抛错,或环境不支持深度(后者走 `supported` → `unsupported`)。
- */
- let fogCreationFailed = false
- let fogDegrade = { level: FOG_DEGRADE.NONE, strengthCap: 1, reason: '' }
- /** 是否观察到管线视图下透明对象被错误雾化(由 DEV 把手/走查置位,默认未观察到)。 */
- let fogArtifacts = false
- /** 用户是否显式暂关(管线可读性优先时的人工开关)。 */
- let fogUserDisabled = false
- let fogFrameHandle = null
- /** 性能采样(人工触发;不改动相机/参数)。 */
- let fogPerfSample = null
- let fogPerfResults = []
- /** `scene.postRender` 的移除函数(采样结束时调)。 */
- let fogPerfRemoveListener = null
- /**
- * 上一次 `paintFog()` 写进引擎时用到的**高度系数**。
- *
- * 用途:相机在飞行(进区/返回)时高度持续变化,而 `paintFog()` 只在
- * 状态机推进(entering/exiting)期间才被逐帧调用;一旦状态机落定(locked/global),
- * 高度变化就没人重画了 —— 那样"退出时高度把雾压到 0"就不会生效。
- * 渲染循环因此比较这个值,变了才重画(不是每帧无脑重画)。
- */
- let fogPaintedAltitudeFactor = 1
- /**
- * **限速后的**高度系数(真正参与写入强度的那个值)。
- *
- * 为什么不直接用 `fogAltitudeFactor()` 的瞬时值:返回全球时 `range` 跨 1.7e6 倍,
- * 雾带 20–42 km 只占其中 0.0001% —— 相机头一帧就越过整条带,瞬时系数会从 1 直接跳到 0,
- * 观感是"雾一瞬间就散了"(维护者 2026-09-29 第二次反馈)。这里按时间限速收敛,
- * 使画面上的变化不可能快于 `altitudeSlewMs`(见 `approachAltitudeFactor`)。
- */
- let fogAltitudeShown = 1
- /** 上一次推进高度系数的时刻(`engineNow()`);0 = 还没推过(首帧 dt 按 0 算)。 */
- let fogSlewAt = 0
- /** 读当前相机椭球高度(米);取不到返回 null。 */
- function cameraAltitude() {
- if (!instance || !instance.viewer) return null
- try {
- const carto = instance.viewer.scene.globe.ellipsoid.cartesianToCartographic(instance.viewer.camera.positionWC)
- return carto && Number.isFinite(carto.height) ? carto.height : null
- } catch (error) {
- return null
- }
- }
- /**
- * 把当前强度写进引擎 uniform 并刷新读数。
- * **只改 uniform,不重建 stage**(规格:复用处理级对象,不在每次进出时 remove/destroy)。
- */
- /**
- * **真正写进引擎的雾强度**(唯一口径)。
- *
- * 三层相乘,顺序即语义:
- * 1. `fogLifecycle.strength` —— 状态机按进出时间推进出来的强度;
- * 2. `fogDegrade.strengthCap` —— 降级上限(管线视图透明错位等);
- * 3. `fogAltitudeShown` —— **相机高度收口**(见 `domain/fog.js`),且**按时间限速**。
- *
- * 为什么后两层都作用在"写入值"而不改进状态机:状态机只管进出时序,
- * 保持它干净,恢复时不会因为中途被压低而错位。
- *
- * 为什么**必须**有这一个函数:交接闸门要判"雾保护是否成立",
- * 它必须看**画面真的被雾罩住的强度**,而不是状态机那个数 ——
- * 否则相机还在 60 km 高空(高度系数 0、画面毫无雾)时就被判成"保护成立"而揭示精细层,
- * 矩形边缘当场露出来。
- *
- * 为什么第 3 层用**限速后的**系数而不是瞬时值:返回全球时 `range` 从 12 km 变到
- * 20,000 km,相机头一帧就越过整条雾带(20–42 km),瞬时系数会让雾
- * "一瞬间消失"(维护者 2026-09-29 第二次现场反馈)。限速后无论相机多快,
- * 画面上的雾都至少花 `altitudeSlewMs` 变化。
- */
- function fogEffectiveStrength() {
- const base = Math.min(fogLifecycle.strength, fogDegrade.strengthCap)
- return Math.max(0, base * fogAltitudeShown)
- }
- /**
- * 推进高度系数的限速(每个渲染帧一次)。
- *
- * 与 `paintFog` **分开**:`paintFog` 会被很多路径调用(配置变更、进出动作、
- * 读数刷新),若在它里面推进时间轴,同一帧被调多次就会把时间走快。
- * 所以推进只挂在唯一的渲染帧钩子上(见 `postRender`)。
- */
- function stepAltitudeSlew() {
- const now = engineNow()
- const dt = fogSlewAt === 0 ? 0 : now - fogSlewAt
- fogSlewAt = now
- const target = fogAltitudeFactor(cameraAltitude(), fogParams)
- fogAltitudeShown = approachAltitudeFactor(fogAltitudeShown, target, dt, fogParams)
- return fogAltitudeShown
- }
- /**
- * 参数(雾端点 / 高度带 / 限速时长)变更后把系数**立刻对齐**到新参数。
- *
- * 为什么不能只靠渲染帧的那一步步限速:配置一变,`fogParams` 换了,
- * 系数却还按**旧参数**慢慢爬,读数与画面会短暂地"按两套参数各说各话"。
- * 对齐是一次性修正,不是绕过限速 —— 之后的每次变化仍受 `altitudeSlewMs` 约束。
- */
- function setAltitudeSlewImmediate() {
- fogAltitudeShown = fogAltitudeFactor(cameraAltitude(), fogParams)
- fogSlewAt = 0
- fogPaintedAltitudeFactor = fogAltitudeShown
- return fogAltitudeShown
- }
- function paintFog() {
- if (!instance || !isDomHost(instance.host)) return
- const colorRgb = parseHexColor(fogParams.color)
- const effective = fogEffectiveStrength()
- // 记下这一帧用到的系数,供"相机飞行时按需重画"判断(见渲染循环)。
- fogPaintedAltitudeFactor = fogAltitudeShown
- if (fogHandle) {
- instance.engine.applyFogUniforms(fogHandle, {
- strength: effective,
- colorRgb,
- startDistance: fogParams.startDistance,
- endDistance: fogParams.endDistance,
- })
- // `ready` 只在启用后才有意义(见 `ensureFogStage` 的注释)。一旦强度 > 0 就记下"启用过",
- // 并**记住曾经 ready 过**(之后隐藏标签页会让 ready 落回 false,但不该改口说"没跑通过")。
- if (effective > 0) fogEverEnabled = true
- fogExecutable = instance.engine.fogStageExecutable(fogHandle)
- if (fogExecutable) fogEverReady = true
- }
- const readout = fogReadout({
- state: fogLifecycle,
- degrade: fogDegrade,
- params: fogParams,
- supported: fogSupported,
- // **待命不是故障**:没启用过就不把 executable=false 当成 failed。
- executable: fogExecutableForReadout(),
- reason: fogReason,
- })
- readout.strength = Number(effective.toFixed(4))
- const host = instance.host
- host.dataset.mapFog = readout.state
- host.dataset.mapFogPhase = readout.phase
- host.dataset.mapFogStrength = String(readout.strength)
- host.dataset.mapFogDegrade = readout.degrade
- host.dataset.mapFogStart = String(Math.round(readout.startDistance))
- host.dataset.mapFogEnd = String(Math.round(readout.endDistance))
- host.dataset.mapFogReason = readout.reason
- // 显式写出来,防止日后有人把它当成"已遮缝"来读(规格禁止这种承诺)。
- host.dataset.mapFogCoversEdges = 'no'
- host.dataset.mapFogStages = String(instance.viewer && instance.engine.hasFogStage(instance.viewer) ? 1 : 0)
- }
- /** 按当前降级选择重算上限(视图模式变化 / 观察到透明错位 / 用户暂关时调)。 */
- function refreshFogDegrade() {
- fogDegrade = planFogDegrade({
- viewMode,
- translucentArtifacts: fogArtifacts,
- userDisabled: fogUserDisabled,
- supported: fogSupported,
- })
- return fogDegrade
- }
- /**
- * 立刻把强度推到某个值(进/出的**起点**由状态机给,这里只负责一步到位)。
- * 用于"取消进出":立刻改成收起,不把正在变亮的过程放完(决策 08 第 1 轮 Q3=A)。
- */
- function settleFog(phase) {
- if (phase === FOG_PHASE.LOCKED) fogLifecycle = { ...fogLifecycle, phase: FOG_PHASE.LOCKED, strength: 1 }
- else fogLifecycle = { ...fogLifecycle, phase: FOG_PHASE.GLOBAL, strength: 0 }
- stopFogLoop()
- paintFog()
- }
- /** 每帧推进(只在 entering/exiting 期间排帧;落定即停,不留常驻 rAF)。 */
- function tickFog() {
- fogFrameHandle = null
- if (fogLifecycle.phase !== FOG_PHASE.ENTERING && fogLifecycle.phase !== FOG_PHASE.EXITING) return
- const now = engineNow()
- const before = fogLifecycle
- fogLifecycle = stepFogLifecycle(fogLifecycle, now, fogParams)
- paintFog()
- if (fogLifecycle.phase === FOG_PHASE.ENTERING || fogLifecycle.phase === FOG_PHASE.EXITING) {
- scheduleFogFrame()
- } else if (before.phase !== fogLifecycle.phase) {
- // 落定:把最终读数再刷一次(强度精确到 0/1)。
- paintFog()
- }
- }
- function engineNow() {
- return typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now()
- }
- function scheduleFogFrame() {
- if (fogFrameHandle !== null) return
- if (typeof requestAnimationFrame !== 'function') return
- fogFrameHandle = requestAnimationFrame(tickFog)
- }
- function stopFogLoop() {
- if (fogFrameHandle !== null && typeof cancelAnimationFrame === 'function') {
- cancelAnimationFrame(fogFrameHandle)
- }
- fogFrameHandle = null
- }
- /**
- * 进入 / 返回时的雾门控转移(票 37 的 `setAreaState` 会调它)。
- *
- * 规格:`locked` 期间生效;纯全球/引导态关闭。进入途中按**相机高度**给早期强度输入,
- * 但那只是强度输入、**不是**"可揭示精细影像"的凭据(票 39 不得据此放行影像渐显)。
- *
- * ── 「过期操作不得生效」是怎么保证的(规格要求的一条,值得写清楚)──
- * 不是靠"回调里比 token",而是**构造性**的:每次推进都由 `tickFog` 重新读模块级的
- * `fogLifecycle`,**没有任何一处捕获旧状态的闭包**。于是"进 → 立刻返回 → 再进"这种快速反向,
- * 下一帧读到的必然是最新状态,旧的那轮淡出**在结构上就没有机会**把新进入关掉。
- * `beginFogTransition` 仍递增 `token` 供读数与断言使用(`domain/fog.js` 有 `isStaleFogToken`
- * 作显式判据,服务层不重复造一套)。
- */
- function retargetFogForArea(nextAreaState) {
- if (!fogParams.enabled) {
- settleFog(FOG_PHASE.GLOBAL)
- fogReason = '已由配置关闭自建雾(map.fog.enabled=false)'
- paintFog()
- return
- }
- refreshFogDegrade()
- if (nextAreaState === AREA_STATE.LOCKED) {
- // 进区:先把高度驱动的早期强度作为起点(飞入途中就已经有雾,不是落地才出现)。
- const altitude = cameraAltitude()
- const seed = Number.isFinite(altitude) ? enterAltitudeStrength(altitude, fogParams) : 0
- fogLifecycle = beginFogTransition(
- { ...fogLifecycle, strength: Math.max(fogLifecycle.strength, seed) },
- 'enter',
- engineNow(),
- fogParams,
- )
- paintFog()
- scheduleFogFrame()
- return
- }
- // 返回全球:淡出(基准 0.8 s;决策 08 要求"比原型稍快",时长可配)。
- fogLifecycle = beginFogTransition(fogLifecycle, 'exit', engineNow(), fogParams)
- paintFog()
- scheduleFogFrame()
- }
- /**
- * 在场景上建**唯一**一个自建雾处理级(票 38)。幂等:同一 viewer 只会有一个。
- *
- * ── 一个必须讲清楚的 Cesium 事实(本票真机验证时抓到的坑)──
- * `PostProcessStage.ready` **只有在处理级被 `enabled` 时的那次 update 里才会置 true**
- * (源码:`_ready = false` 在构造里,置 true 发生在 `update()` 内的 uniform/纹理就绪分支)。
- * 所以「强度 0 → stage.enabled=false → ready 永远是 false」。
- * 如果拿 `ready` 直接当"能不能用"的判据,就会把**一个正常待命的雾**报成 `failed`
- * ——本票第一版正是这样:区外读数一直是 `state=failed`,而实际只是还没启用。
- *
- * 因此这里分开两件事:
- * · `supported` = 环境有没有深度能力(**这才是"能不能用"**,与 enabled 无关);
- * · `executable`(= `ready`)= "**已经跑过一遍并且成功了**",只在启用后才有意义。
- * 读数里 `executable=false` 且从未启用过时**不报 failed**,那是"待命"。
- */
- function ensureFogStage() {
- if (!instance || !instance.engine || !instance.viewer) return false
- if (fogHandle) return true
- if (!fogParams.enabled) {
- fogSupported = true
- fogExecutable = false
- fogReason = '已由配置关闭自建雾(map.fog.enabled=false)'
- return false
- }
- try {
- fogHandle = instance.engine.createFogStage(instance.viewer, fogParams)
- fogSupported = fogHandle.supported !== false
- fogExecutable = instance.engine.fogStageExecutable(fogHandle)
- if (!fogSupported) {
- fogReason = '当前环境不支持深度纹理,自建雾整级禁用(业务可用优先)'
- } else {
- // 注意:这里**不**因为 ready=false 而写 failed/reason —— 待命不是故障(见上方注释)。
- fogReason = ''
- }
- refreshFogDegrade()
- return true
- } catch (error) {
- // **确凿证据**:创建抛错 ⇒ 这才是 failed 的唯一来源。
- fogHandle = null
- fogSupported = false
- fogCreationFailed = true
- fogExecutable = false
- fogReason = `自建雾创建失败:${String((error && error.message) || error)}`
- refreshFogDegrade()
- return false
- }
- }
- // ---- 一张图调色(科技蓝滤镜):建级与写 uniform。与雾同一条「复用不重建」纪律。----
- /** 在场景上建**唯一**一个调色处理级;配置关掉时不建(幂等)。 */
- function ensureGradeStage() {
- if (!instance || !instance.engine || !instance.viewer) return false
- if (gradeHandle) return true
- if (!gradeParams.enabled) return false
- try {
- gradeHandle = instance.engine.createSceneGradeStage(instance.viewer, {
- ...gradeParams,
- // 引擎吃 0..1 的 rgb;hex 解析沿用雾的同一份 parseHexColor。
- tintRgb: parseHexColor(gradeParams.tint) || undefined,
- })
- return true
- } catch (error) {
- // 建失败只是这层滤镜缺席,不挡业务(与雾的失败立场一致)。
- gradeHandle = null
- console.warn('[mapService] 场景调色处理级创建失败,已跳过', error)
- return false
- }
- }
- /** 把当前计划写进 uniform 与 dataset 读数(走查可读 `data-map-grade`)。 */
- function paintGrade() {
- // 配置把 enabled 翻开而处理级还没建(启动时关、后来配置翻开):这里补建(幂等)。
- if (!gradeHandle && gradeParams.enabled) ensureGradeStage()
- if (gradeHandle && instance && instance.engine) {
- instance.engine.applySceneGradeUniforms(gradeHandle, {
- ...gradeParams,
- tintRgb: parseHexColor(gradeParams.tint) || undefined,
- })
- }
- if (instance && isDomHost(instance.host)) {
- // 读数三态:on(在跑)/ off(配置关)/ pending(配置要但没建成 —— 引擎未就绪或建级失败)。
- instance.host.dataset.mapGrade = !gradeParams.enabled ? 'off' : gradeHandle ? 'on' : 'pending'
- }
- }
- /**
- * 「可执行」的**唯一**口径(防止散在各调用点各写一遍而漂移)。
- *
- * 语义=**没有确凿的失败证据**(而不是"此刻 ready"):
- * · 深度不支持 → false(`supported` 会报 unsupported);
- * · 创建抛错 → false(`fogCreationFailed`,`fogReason` 写明原因);
- * · 其余 → true。包括"引擎还没起来(无 handle)"与"强度 0 待命(ready 未置位)"
- * —— 这两种都是**正常过程**,报 failed 会在最不该报警的时刻误报。
- *
- * 由此**不需要**"等几秒判失败"的宽限期,也不需要看 `ready`:
- * 隐藏标签页不渲染是正常状态,据它判失败就是把正常情况报成故障(真机验证时撞上过)。
- * 是否"已经跑通过"另由 `fogRanOnceForReadout()` 如实给出,供排查区分待命与跑过。
- */
- function fogExecutableForReadout() {
- return fogSupported && !fogCreationFailed
- }
- /** 是否"曾经跑通过一遍"(与 `executable` 分开报,便于排查与走查区分待命/跑过)。 */
- function fogRanOnceForReadout() {
- return fogEverReady
- }
- /** 当前雾的完整读数(走查与人工排查读它)。 */
- export function getFogState() {
- const readout = fogReadout({
- state: fogLifecycle,
- degrade: fogDegrade,
- params: fogParams,
- supported: fogSupported,
- executable: fogExecutableForReadout(),
- reason: fogReason,
- })
- return {
- ...readout,
- params: { ...fogParams },
- /** 是否已把处理级挂上场景(**不是**"画面已遮住")。 */
- staged: !!(fogHandle && instance && instance.engine && instance.viewer && instance.engine.hasFogStage(instance.viewer)),
- /**
- * 是否**曾经跑通过一遍**(`ready` 曾为 true)。
- * 与 `executable` 分开报:隐藏标签页不渲染时当前 `ready` 会落回 false,
- * 但"跑通过"这个事实不该改口 —— 排查时两者一起看才分得清「待命」与「跑过」。
- */
- ranOnce: fogRanOnceForReadout(),
- /**
- * DEV 诊断:对比"服务层以为写进去的强度"与"Cesium 真正喂给 shader 的强度"。
- * 两者不一致 ⇒ uniform 通道没接对(常量快照 vs 函数每帧取)。
- * 本票踩着这个坑:读数说强度 1,画面却什么都没有。
- */
- uniformDiagnostics: fogHandle && typeof fogHandle.readUniformDiagnostics === 'function' ? fogHandle.readUniformDiagnostics() : null,
- perfResults: fogPerfResults.slice(),
- }
- }
- /** 当前雾参数计划(配置 + 规格默认的合成结果)。 */
- export function getFogParams() {
- return { ...fogParams }
- }
- /**
- * 人工/走查用:声明"是否观察到管线视图透明对象被错误雾化"。
- * 规格:**正常情况不预先关闭**,只有真的观察到才减弱;相机限位不受影响。
- */
- export function setFogTranslucentArtifacts(observed) {
- fogArtifacts = observed === true
- refreshFogDegrade()
- paintFog()
- return { ...fogDegrade }
- }
- /** 人工/走查用:显式暂关/恢复自建雾(可读性优先开关)。 */
- export function setFogUserDisabled(disabled) {
- fogUserDisabled = disabled === true
- refreshFogDegrade()
- paintFog()
- return { ...fogDegrade }
- }
- /**
- * 性能采样:在**固定视角与参数**下收集帧间隔,算 P95。
- * 与原型一样明确标注:这是**渲染事件间隔**,不是 GPU 时间,也不能单独判定达标。
- *
- * 帧源用 `scene.postRender`(每渲染一帧一次),而不是 rAF —— rAF 在标签页不可见时会停,
- * 采样会静默收集不到数据;postRender 只在真的渲染时触发,与"帧耗时"语义更贴。
- */
- export function startFogPerfSample(label = '') {
- stopFogPerfSampleListener()
- fogPerfSample = { label, at: engineNow(), previous: null, frames: [] }
- if (instance && instance.viewer && instance.viewer.scene && instance.viewer.scene.postRender) {
- fogPerfRemoveListener = instance.viewer.scene.postRender.addEventListener(noteFogFrame)
- }
- return { started: true, label, listening: fogPerfRemoveListener !== null }
- }
- function stopFogPerfSampleListener() {
- if (typeof fogPerfRemoveListener === 'function') {
- try {
- fogPerfRemoveListener()
- } catch (error) {
- // 场景已销毁时移除监听会抛:忽略即可,下面会重建。
- }
- }
- fogPerfRemoveListener = null
- }
- export function stopFogPerfSample() {
- const result = collectFogPerfSample()
- stopFogPerfSampleListener()
- fogPerfSample = null
- return result
- }
- function collectFogPerfSample() {
- if (!fogPerfSample) return null
- const frames = fogPerfSample.frames.slice().sort((a, b) => a - b)
- const p95 = frames.length ? frames[Math.ceil(frames.length * 0.95) - 1] : null
- const result = {
- label: fogPerfSample.label,
- count: frames.length,
- p95FrameIntervalMs: p95,
- strength: Number(fogLifecycle.strength.toFixed(3)),
- fogState: fogReadout({ state: fogLifecycle, degrade: fogDegrade, params: fogParams, supported: fogSupported, executable: fogExecutableForReadout() }).state,
- note: '渲染事件间隔,不是 GPU 时间;不能单独判定 ≤15% 达标',
- }
- fogPerfResults.push(result)
- if (fogPerfResults.length > 8) fogPerfResults.shift()
- return result
- }
- /** 帧计数(由宿主每帧调;只在采样中累计)。 */
- function noteFogFrame() {
- if (!fogPerfSample) return
- const now = engineNow()
- if (fogPerfSample.previous !== null) fogPerfSample.frames.push(now - fogPerfSample.previous)
- fogPerfSample.previous = now
- }
- // ---- 票 39:粗→精影像交接 ----
- /**
- * 精细影像交接的**策略与生命周期唯一所有者**(规格:引擎只负责"把 alpha 写进图层",
- * 决定"什么时候该亮多少"的在这里)。
- *
- * 交接方向由决策票 08 固化:**全球/引导态隐藏精细层 → 雾先形成保护 → 再渐显 → 返回反向交接**。
- * 本模块不复制一套影像状态机到别处,`domain/imageryHandover.js` 只做纯计算。
- *
- * ── 为什么"隐藏"用 alpha=0 而不是 show=false(关键实现事实)──
- * 决策 08 第 1 轮 Q4=A:隐藏期间**可继续预取**瓦片,看不见不等于停止加载。
- * 查过 Cesium 源码:`GlobeSurfaceTile.js:691` 按 **`layer.show`** 决定要不要建影像骨架,
- * **不看 `alpha`**。所以 `alpha=0 + show=true` 才是"看不见但继续加载";
- * 若用 `show=false`,瓦片会真的停掉,揭示瞬间才开始下载 → 正好造成"先空白再跳方块"。
- */
- let handoverParams = planHandover({})
- let handoverLifecycle = createHandoverLifecycle()
- let handoverBlock = HANDOVER_BLOCK.NONE
- let handoverNote = ''
- /** 精细层句柄(引擎侧 `applyOverlays` 返回)+ 瓦片就绪/失败读数。 */
- let overlayHandles = []
- let overlayReadiness = null
- /**
- * 交接帧循环句柄。
- * 与雾那条 rAF 分开:雾的推进是"时间→强度",交接还有"瓦片就绪与否"这一外部条件,
- * 两者节奏不同,合成一条会互相拖累(且雾落定后就不该再排帧)。
- */
- let handoverFrameHandle = null
- /** 上一次写进宿主 dataset 的交接读数(避免每帧重写同样的值,见 `paintHandover`)。 */
- let handoverPainted = {}
- /**
- * 精细影像覆盖矩形缓存。
- *
- * 为什么不每次现读:`currentEdgeVerdict()` 在渲染循环里被调,而
- * `getCockpitConfig()` + 遍历 overlays 每帧跑一遍纯属浪费;影像矩形是**配置事实**,
- * 引擎 boot 时定下就不变(换配置要重挂地图)。`applyPrimaryArea` 时失效重算。
- */
- let overlayBoundsCache = null
- let overlayBoundsResolved = false
- /** 稳态节流:下一次允许重新评估闸门的时间戳(见 `advanceHandover`)。 */
- const HANDOVER_STEADY_EVAL_MS = 120
- let handoverNextEvalAt = 0
- /** 本帧算过的边缘判据(`advanceHandover` 写、`paintHandover` 复用,避免每帧算两遍)。 */
- let handoverEdgeCache = null
- /** 当前生效的交接参数(走查与人工排查读它)。 */
- export function getHandoverParams() {
- return { ...handoverParams }
- }
- /** 交接是否已由配置关闭。 */
- export function handoverEnabled() {
- return handoverParams.enabled !== false
- }
- /** 精细层当前是否有可交接的层(没配叠加影像时交接无事可做)。 */
- export function hasOverlayLayers() {
- return overlayHandles.length > 0
- }
- /**
- * 把交接读数写进宿主 dataset(走查断言面;人工排查也读它)。
- *
- * 刻意**不**写任何"已遮边"的布尔承诺:本票能给的是"精细层 alpha / 阶段 / 被什么挡住 /
- * 瓦片信号是什么性质",「矩形边是否真被遮住」归维护者目视(票 38 的同款纪律)。
- */
- function paintHandover(force = false) {
- if (!instance || !isDomHost(instance.host)) return
- const readout = handoverReadout({
- state: handoverLifecycle,
- block: handoverBlock,
- note: handoverNote,
- edge: handoverEdgeCache || currentEdgeVerdict(),
- tiles: overlayReadiness ? overlayReadiness.state : null,
- degrade: !fogSupported || !fogExecutableForReadout(),
- enabled: handoverEnabled(),
- })
- // ── 为什么要把写入 diff 掉(性能预算 ≤15%,本函数在渲染循环里被调)──
- // 交接读数每帧都刷会让每帧产生 8 次 DOM 属性写入 + 字符串拼接;稳态下这些值**根本不变**。
- // 只在值真的变了才写,稳态帧的这部分开销降到一次对象比较。
- const next = {
- mapHandover: readout.phase,
- mapHandoverAlpha: String(readout.alpha),
- mapHandoverBlock: readout.block,
- mapHandoverNote: readout.note,
- mapHandoverLayers: String(overlayHandles.length),
- mapHandoverEdgeKm: readout.edgeDistanceM === null ? '' : String(Math.round(readout.edgeDistanceM / 1000)),
- mapHandoverDegrade: readout.degraded ? 'yes' : 'no',
- // 显式写出来:瓦片信号是**代理**,不证明目标视野逐像素就绪(决策票 08 的警告)。
- mapHandoverTilesProxy: 'yes',
- }
- const host = instance.host
- for (const key of Object.keys(next)) {
- if (force || handoverPainted[key] !== next[key]) {
- host.dataset[key] = next[key]
- handoverPainted[key] = next[key]
- }
- }
- }
- /** 当前相机位置(经纬度,度);取不到返回 null。 */
- function currentCameraLonLat() {
- if (!instance || !instance.viewer) return null
- try {
- const carto = instance.viewer.scene.globe.ellipsoid.cartesianToCartographic(instance.viewer.camera.positionWC)
- if (!carto) return null
- // 不依赖引擎导出的 `Math`:弧度换角度是纯算术,自己算,免得 engine 没暴露这个符号。
- const toDeg = (rad) => (rad * 180) / Math.PI
- return { lon: toDeg(carto.longitude), lat: toDeg(carto.latitude) }
- } catch (error) {
- return null
- }
- }
- /** 边缘保护判据(把当前相机与影像矩形喂给纯函数)。 */
- function currentEdgeVerdict() {
- if (!primaryAreaPlan || !primaryAreaPlan.enabled) return null
- const camera = currentCameraLonLat()
- if (!camera) return null
- // 用**影像矩形**(精细层覆盖范围)判,而不是主场景区矩形 —— 会露边的是影像边界。
- const bounds = overlayBounds()
- if (!bounds) return null
- return edgeOcclusionVerdict(camera, bounds, fogParams, handoverParams.minEdgeOcclusion)
- }
- /** 精细影像的覆盖矩形(取第一个配了 bounds 的叠加层;没配就没有"边"可言)。 */
- function overlayBounds() {
- if (overlayBoundsResolved) return overlayBoundsCache
- const source = (getCockpitConfig().map && getCockpitConfig().map.overlays) || []
- overlayBoundsCache = null
- for (const item of source) {
- if (Array.isArray(item && item.bounds) && item.bounds.length === 4) {
- overlayBoundsCache = item.bounds
- break
- }
- }
- overlayBoundsResolved = true
- return overlayBoundsCache
- }
- /**
- * 把交接状态对应的 alpha 写进精细层 —— **alpha 的唯一写入点**。
- *
- * 生产与 DEV 原型不得同时驱动 alpha(会互相打架)。因此这里先问一句
- * "原型是否正在接管",是则让位(见 `setHandoverPrototypeActive`)。
- */
- function applyHandoverAlpha() {
- if (handoverPrototypeActive) return 0
- // 交接被配置关掉 = 回到"精细层常显"(票 39 之前的行为):把 alpha 还到 1。
- // 否则"关掉交接"反而会让精细层永远隐形 —— 那是最糟的失败模式。
- const target = handoverEnabled() ? handoverLifecycle.alpha : 1
- let written = 0
- for (const handle of overlayHandles) {
- if (handle && typeof handle.setAlpha === 'function' && handle.alive()) written = handle.setAlpha(target)
- }
- if (written === 0 && overlayHandles.length > 0) written = target
- return written
- }
- /**
- * DEV 原型接管开关(**票 39 = 隔离,不删除**)。
- *
- * 维护者 2026-09-28 裁定:39 只负责让生产交接成为 alpha 的唯一写入方、且原型不得干扰生产;
- * 删除与归档(存 throwaway 分支、留分支指针)归票 40。两者都写同一批 `layer.alpha`,
- * 同时跑必然互相覆盖 —— 所以用这个开关显式让位,而不是靠"谁后跑谁赢"。
- */
- let handoverPrototypeActive = false
- /** 原型挂载时置 true(让生产交接交出 alpha 写入权);卸载时置 false 并重画一次。 */
- export function setHandoverPrototypeActive(active) {
- handoverPrototypeActive = !!active
- if (!handoverPrototypeActive) {
- applyHandoverAlpha()
- paintHandover()
- }
- return handoverPrototypeActive
- }
- /** 当前 alpha 是否由原型接管(读数/走查用)。 */
- export function isHandoverPrototypeActive() {
- return handoverPrototypeActive
- }
- /**
- * 推进交接一帧(**每渲染帧调一次**,由 `postRender` 驱动)。
- *
- * ── 为什么不再自带一条 rAF 循环(本票实现中删掉的一个多余机制)──
- * 第一版仿照雾的做法又排了一条 `requestAnimationFrame` 链。但 `advanceHandover` 已经挂在
- * `scene.postRender` 上**每渲染帧**被调(见 `bootEngine`),再排 rAF 是**同一件事做两遍**:
- * 多一条循环、多一处取消时机要维护,而且两者节奏可能错开(rAF 与渲染帧并不同步)。
- * 更实际的坏处是**一条隐 bug**:rAF 在标签页隐藏时被冻结,于是"隐藏标签页里推进交接"
- * 只能靠手动调 `advanceHandover()`;把推进并进 `advanceHandover` 之后,
- * 手动调一次就能推一帧(可断言、可走查),不必依赖 rAF 是否还活着。
- * 雾那条 rAF 保留:它的推进是纯时间驱动,与"有没有渲染帧"无关。
- *
- * ── 「过期操作不得生效」怎么保证 ──
- * 与雾同款、**构造性**的:每帧都重新读模块级的 `handoverLifecycle`,
- * **没有任何一处捕获旧状态的闭包**。于是"进 → 立刻返回 → 再进"这种快速反向,
- * 下一帧读到的必然是最新状态,旧的那轮推进在结构上就没有机会把新目标覆盖掉。
- */
- function stepHandoverFrame() {
- if (handoverLifecycle.phase !== HANDOVER_PHASE.ENTERING && handoverLifecycle.phase !== HANDOVER_PHASE.RETURNING) return false
- const before = handoverLifecycle
- handoverLifecycle = stepHandover(handoverLifecycle, engineNow(), handoverParams)
- applyHandoverAlpha()
- // 返回途中:精细层**确实收起后**才允许撤雾(决策 08 第 3 条:影像先收起、保护不断档)。
- if (before.phase === HANDOVER_PHASE.RETURNING) {
- const sync = returnSyncVerdict({
- phase: handoverLifecycle.phase,
- alpha: handoverLifecycle.alpha,
- areaState,
- fogStrength: fogLifecycle.strength,
- })
- if (sync.mayDropFog) maybeDropFogAfterConceal()
- }
- return true
- }
- function stopHandoverLoop() {
- if (handoverFrameHandle !== null && typeof cancelAnimationFrame === 'function') {
- cancelAnimationFrame(handoverFrameHandle)
- }
- handoverFrameHandle = null
- }
- /**
- * 返回全球时:精细层收起后才真正撤雾。
- *
- * 决策 08 第 3 条同时要求两件看似矛盾的事:
- * · 「影像在失去保护前收起」→ 所以**不能**一按返回就撤雾;
- * · 「不能影像收完→停顿→镜头才飞走」→ 所以**也不能**等影像收完才开始飞。
- * 解法是把两者**并行**:按下返回的那一帧就同时(a)开始收影像、(b)开始飞镜头;
- * 雾则延后到"影像确实不可见"再淡出。本函数负责 (b) 之后的这步"延后撤雾"。
- */
- function maybeDropFogAfterConceal() {
- if (!fogSupported || !fogParams.enabled) return false
- if (fogLifecycle.phase !== FOG_PHASE.LOCKED) return false
- fogLifecycle = beginFogTransition(fogLifecycle, 'exit', engineNow(), fogParams)
- paintFog()
- scheduleFogFrame()
- return true
- }
- /** 便捷谓词:本次返回是否由交接负责"延后撤雾"(见 `setAreaState` 的顺序注释)。 */
- function handoverHandlesFogDrop() {
- return handoverEnabled() && overlayHandles.length > 0
- }
- /**
- * 哪些挡板属于"硬失败",要**撤回**已经在渐显的精细层(而不只是拦住下一次渐显)。
- *
- * 刻意**不含** `NO_FOG`:规格明确"雾失败且精细层已经可见时,不突然藏起来,
- * 同时把遮缝承诺改成降级"(业务可用优先)。若把雾也当硬失败,
- * 雾一抖就会把影像闪没 —— 那比"没有遮缝保护"更伤业务可用性。
- */
- function isHardRevealBlock(block) {
- return block === HANDOVER_BLOCK.TILES_FAILED || block === HANDOVER_BLOCK.EDGE_RISK
- }
- /**
- * 推进交接(由外壳的刷新轮询或进出动作调用)。
- *
- * 幂等:已经在目标阶段就不重开一轮(重复点"进入"不该把渐显推倒重来)。
- */
- function advanceHandover(force = false) {
- if (!overlayHandles.length) {
- paintHandover()
- return handoverLifecycle
- }
- if (!handoverEnabled()) {
- // 交接关闭:精细层常显(`applyHandoverAlpha` 会把 alpha 还到 1)。
- handoverBlock = HANDOVER_BLOCK.NONE
- handoverNote = '交接已由配置关闭(精细层常显)'
- applyHandoverAlpha()
- paintHandover()
- return handoverLifecycle
- }
- // ── 稳态节流(性能预算 ≤15%)──
- // 本函数在 `postRender` 里**每帧**被调。判闸门要读相机位置
- // (`cartesianToCartographic` **每次分配新对象**)并算到矩形四边的距离,
- // 而稳态下这些值**根本没变**,每帧重算纯属浪费。所以:
- // · 过渡中(entering/returning):**每帧**推进 —— alpha 要平滑,不能跳;
- // · 稳态(global/revealed):最多每 `HANDOVER_STEADY_EVAL_MS` 评一次"条件变了没"。
- const transitioning = handoverLifecycle.phase === HANDOVER_PHASE.ENTERING
- || handoverLifecycle.phase === HANDOVER_PHASE.RETURNING
- const now = engineNow()
- if (!force && !transitioning && now < handoverNextEvalAt) return handoverLifecycle
- handoverNextEvalAt = now + HANDOVER_STEADY_EVAL_MS
- // 边缘判据这一帧只算一次,`paintHandover` 复用这份(别在渲染循环里算两遍)。
- const edge = currentEdgeVerdict()
- handoverEdgeCache = edge
- const gate = revealGate({
- enabled: true,
- areaState,
- // **必须用"真正写进引擎的强度"**(含相机高度收口),不能只用状态机那个数:
- // 相机还在 60 km 高空时高度系数为 0、画面其实毫无雾,
- // 若拿状态机的 1 去判"保护成立",就会在高空揭示精细层、矩形边缘当场露出来。
- fogStrength: fogEffectiveStrength(),
- // 「可执行」沿用票 38 的统一口径(不是"此刻 ready",那会随渲染活动跳)。
- fogExecutable: fogExecutableForReadout(),
- fogSupported,
- edgeOk: (edge || { ok: true }).ok,
- tilesLoaded: overlayReadiness ? overlayReadiness.state.loaded : false,
- tilesFailed: overlayReadiness ? overlayReadiness.state.failed > 0 : false,
- params: handoverParams,
- })
- handoverBlock = gate.block
- handoverNote = gate.note
- // 过渡中:**先推进这一帧**(alpha 才平滑),再评闸门。
- // 顺序有意:先走时间轴,再判断"条件还成立吗" —— 反过来的话,
- // 停止条件(瓦片失败/边缘风险)变化时会先被拦住、把正在收的影像卡在半透明。
- if (transitioning) stepHandoverFrame()
- if (areaState === AREA_STATE.LOCKED && gate.allowed) {
- handoverLifecycle = beginHandover(handoverLifecycle, 'reveal', engineNow())
- } else if (areaState === AREA_STATE.LOCKED && transitioning && isHardRevealBlock(gate.block)) {
- // **渐显途中**条件转硬失败(瓦片挂了 / 相机退到边缘危险区)→ 立刻反向收起。
- // 规格:"瓦片失败 → 保持隐藏、继续用粗底图";继续把坏瓦片亮出来正是
- // "先空白再跳方块"那类缺陷。注意**刻意不含 NO_FOG**:
- // 规格明确"雾失败且精细层已经可见时,**不**突然藏起来"(业务可用优先),
- // 所以雾这一路只影响"要不要开始渐显",不影响"已经在渐显的要不要退回去"。
- handoverLifecycle = beginHandover(handoverLifecycle, 'conceal', engineNow())
- } else if (areaState !== AREA_STATE.LOCKED && handoverLifecycle.phase !== HANDOVER_PHASE.GLOBAL) {
- // 返回全球:立刻开始收影像(不等待、不拖延),雾由 `maybeDropFogAfterConceal` 延后撤。
- handoverLifecycle = beginHandover(handoverLifecycle, 'conceal', engineNow())
- } else if (areaState !== AREA_STATE.LOCKED && handoverHandlesFogDrop()) {
- // **已经在 global 相位**(精细层本来就没揭示,例如刚启动就点返回):
- // `beginHandover` 是幂等空操作、没有过渡帧可推,于是没人去撤雾 ——
- // 那样雾会永远留在锁定强度上。这里显式补一次"是否该撤雾"的判断。
- const sync = returnSyncVerdict({
- phase: handoverLifecycle.phase,
- alpha: handoverLifecycle.alpha,
- areaState,
- fogStrength: fogLifecycle.strength,
- })
- if (sync.mayDropFog) maybeDropFogAfterConceal()
- }
- applyHandoverAlpha()
- paintHandover()
- return handoverLifecycle
- }
- /**
- * 挂上精细层句柄并开始驱动交接(引擎 boot 完成后由 `bootEngine` 调)。
- * 幂等:重复调用只更新引用,不重复挂监听。
- */
- function attachHandoverLayers(engine, handles) {
- overlayHandles = Array.isArray(handles) ? handles : []
- if (overlayReadiness) overlayReadiness.detach()
- overlayReadiness = engine.createOverlayReadiness(overlayHandles)
- // 初始即"隐藏但继续加载":alpha=0、show 保持 true(见本节约首注释)。
- applyHandoverAlpha()
- paintHandover()
- }
- /**
- * 交接读数(走查与人工排查读它)。**不提供任何"已遮边"承诺。**
- */
- export function getHandoverState() {
- // 走查/人工排查读的是**当前**真实值,所以这里**不复用**稳态缓存
- // (`handoverEdgeCache` 可能最多滞后一个节流窗口)。缓存只服务渲染循环那一路。
- const readout = handoverReadout({
- state: handoverLifecycle,
- block: handoverBlock,
- note: handoverNote,
- edge: currentEdgeVerdict(),
- tiles: overlayReadiness ? overlayReadiness.state : null,
- degrade: !fogSupported || !fogExecutableForReadout(),
- enabled: handoverEnabled(),
- })
- return {
- ...readout,
- layers: overlayHandles.length,
- /** 原型是否正在接管 alpha(生产交接已让位)。 */
- prototypeActive: handoverPrototypeActive,
- /** 当前**实际**写在图层上的 alpha(与状态机读数对照,抓"读数对、图层没变")。 */
- appliedAlpha: overlayHandles.length && overlayHandles[0] && typeof overlayHandles[0].alpha === 'number' ? overlayHandles[0].alpha : null,
- tilesFailed: overlayReadiness ? overlayReadiness.state.failed : 0,
- tilesLastError: overlayReadiness ? overlayReadiness.state.lastError : '',
- params: { ...handoverParams },
- /**
- * 精细层的**真实图层状态**(不是我们的状态机读数)。
- *
- * 为什么要有它(票 38 的教训):读数全对、图层没变,是这类功能最难查的失败模式。
- * 这里直接回读 `ImageryLayer`:`alpha` 必须等于状态机推的值,
- * 而 `show` **必须仍为 true** —— 那正是"隐藏期间继续预取瓦片"的实现依据
- * (`GlobeSurfaceTile` 按 `show` 决定要不要建影像骨架,不看 `alpha`)。
- */
- layerProbe: overlayHandles.map((handle) => ({
- show: !!(handle && handle.layer && handle.layer.show),
- alpha: handle && typeof handle.alpha === 'number' ? Number(handle.alpha.toFixed(4)) : null,
- })),
- }
- }
- /** 刷新瓦片就绪读数(渲染循环里调;`globe.tilesLoaded` 是 getter,不便宜)。约 6Hz 节流。 */
- let overlayReadinessLastAt = 0
- function refreshOverlayReadiness() {
- if (!overlayReadiness || !instance || !instance.viewer) return
- const now = performance.now()
- if (now - overlayReadinessLastAt < 160) return
- overlayReadinessLastAt = now
- overlayReadiness.refresh(instance.viewer.scene.globe)
- }
- // ---- 票 09:点位状态标记与图例 ----
- /**
- * 语义色的**唯一取法**:从 `.cockpit-shell` 的计算样式里读 `--lv1..--lv4 / --neutral`。
- *
- * 为什么绕这一道而不是在 JS 里写色值(票 06 的单一口径):色值只在 `styles/tokens.css`
- * 声明一份,设计公司换视觉资源时改那一个文件即可;地图标记(SVG 图片)偏偏读不到 CSS 变量,
- * 所以由服务层把「当前生效的颜色」读出来交给 domain 的 `markerSvg()` 烘焙进图片。
- * 读不到(脱浏览器单测、样式未挂)时回退到与 token 同值的兜底色,标记不会变成透明。
- */
- const TONE_FALLBACK = {
- lv1: '#ff3b52',
- lv2: '#ffb020',
- lv3: '#01abea',
- lv4: '#27d17a',
- neutral: '#5680b0',
- 'point-offline': '#94a3b8',
- 'point-empty': '#c4d5e8',
- 'point-unknown': '#a970ff',
- 'point-unassociated': '#ff8a48',
- }
- function toneColors() {
- const colors = { ...TONE_FALLBACK }
- if (typeof document === 'undefined' || !document.querySelector) return colors
- const shell = document.querySelector('.cockpit-shell')
- if (!shell || typeof getComputedStyle !== 'function') return colors
- const styles = getComputedStyle(shell)
- for (const tone of Object.keys(TONE_FALLBACK)) {
- const value = styles.getPropertyValue(`--${tone}`)
- if (value && value.trim()) colors[tone] = value.trim()
- }
- return colors
- }
- /**
- * 同步**模式图层计划**给出的点位标记到地图上(票 09 验收第 1、4 条;票 46 改为消费计划)。
- *
- * 票 46 起,入参是 `domain/modeLayerPlan.js` 的输出(`{ markers, byStatus }`),
- * **不再是原始 objects** —— 模式可见性的裁决已在计划层收口,服务层不再重判
- * (规格 18:引擎和组件只消费计划,不做第二次过滤)。
- *
- * 引擎没就绪时只更新计划(图例仍能显示),返回 false;调用方(`MapCanvas`)会在就绪后重试。
- * @param {{markers: Array, byStatus?: object}} plan 模式图层计划的标记段
- * @param {object} [legendSource] 可选的图例行(默认由计划现算),便于界面直接复用同一份
- * @returns {boolean} 是否已画到地图上
- */
- export function syncPointMarkersForPlan(plan, legendSource) {
- const markers = (plan && Array.isArray(plan.markers)) ? plan.markers : []
- markerPlan = {
- markers,
- blocked: [],
- byStatus: (plan && plan.byStatus) || summarizeMarkerStatuses(markers),
- total: markers.length,
- }
- if (!instance || !instance.engine || !instance.viewer) {
- paintMarkerState()
- return false
- }
- const colors = toneColors()
- const result = instance.engine.syncPointMarkers(
- instance.viewer,
- markerPlan.markers,
- (marker) => colors[marker.tone] || colors.neutral,
- // 票 44:监测点尺寸也取自**同一个尺寸层级定义**(`MARKER_SIZE.point`),
- // 这样「点始终大于设备」只在一处成立,不会两边各写一个字面量而悄悄反转。
- (shape, color) => markerSvg(shape, color, MARKER_SIZE.point),
- // 批次 3:合成计划层——每个标记按 (kind/status/industry) 产出双层水滴合成计划,
- // 由 `renderMarker` 异步烘焙成 billboard 位图;首帧走 `markerFallbackSvg` 同步兜底。
- (marker) => planMarker({ kind: 'monitoring-point', status: marker.status, industry: marker.industry, tone: marker.tone }),
- (marker) => colors[marker.tone] || colors.neutral,
- )
- markerRendered = result.rendered
- markerSkipped = result.skipped
- paintMarkerState()
- return true
- }
- /**
- * 同步**设施点位与管线图层**(票 24)。
- *
- * 与摘要标记分开是结构性的:本函数只写 `cockpit-pipelines` 那个独立数据源,
- * 摘要标记序列与图例一个字节都不动 —— 管线是**设施**、不是监测点,
- * 两者的拾取身份、统计口径与坐标纪律都不同(见 `domain/pipeNetwork.js` 文件头)。
- *
- * 图层过滤只由 `domain/modeLayerPlan.js` 决定并随计划给出,本函数**不再筛一次**:
- * 计划里给什么就画什么(`hidden` 体现为空数组,于是自动撤下)。
- * @param {object} plan `planModeLayers()` 的结果
- * @returns {Promise<boolean>} 是否已画到地图上
- */
- export async function syncPipeLayerForPlan(plan) {
- const layer = {
- pipes: plan && Array.isArray(plan.pipelines) ? plan.pipelines : [],
- components: plan && Array.isArray(plan.facilities) ? plan.facilities : [],
- }
- if (!instance || !instance.viewer) return false
- const record = instance
- const engine = await import('./cesiumPipeLayer.js')
- if (instance !== record || !record.viewer) return false
- record.pipeLayer = engine
- engine.setPipeViewMode(record.viewer, viewMode)
- await engine.syncPipeLayer(record.viewer, layer, { onModelChange: (state) => {
- if (instance !== record || !record.host) return
- const host = record.host
- if (host.dataset) {
- host.dataset.mapPipeModelBatches = String(state.batches)
- host.dataset.mapPipeModelInstances = String(state.instances)
- host.dataset.mapPipeModelFallback = String(state.fallback)
- host.dataset.mapPipeModelPending = String(state.pending)
- host.dataset.mapPipeModelFailed = String(state.failed)
- host.dataset.mapPipeModelKinds = JSON.stringify(state.byKind)
- host.dataset.mapPipeVirtualDepth = 'category-default'
- }
- host.dispatchEvent(new CustomEvent('cockpit-pipe-model-state', { detail: state }))
- } })
- if (instance !== record) return false
- const host = record.host
- if (host && host.dataset) {
- host.dataset.mapPipes = String(layer.pipes.length)
- host.dataset.mapPipeComponents = String(layer.components.length)
- }
- return true
- }
- /**
- * 同步**关联监测设备临时图层**(票 42)。
- *
- * 与摘要标记分开是结构性的:本函数只写 `DEVICE_MARKER_SOURCE` 那个独立数据源,
- * 摘要的 `markerPlan`/`data-map-markers*` 一个字节都不动(规格:关联设备不写回摘要序列、
- * 不分页、不进摘要图例与计数)。设备图例另有一套互斥计数(`domain/deviceLayer.js`)。
- *
- * 形状/色值仍走既有单一来源:形状来自传入的计划(票 44 会接管设备形状词表),
- * 色值仍由 `toneColors()` 从 token 读 —— 本层不写任何色值。
- *
- * @param {Array} devices **已判定可上图**的领域行(`placement === 'drawn'`)
- * @returns {boolean} 是否已画到地图上(引擎没就绪时只记计划,返回 false)
- */
- /** 应用单个重叠组呈现:collapsed 隐藏逐台实体显示聚合符号;expanded 只写 billboard pixelOffset。 */
- function applyOverlapPresentation(groupKey, state, preview = false) {
- const group = overlapGroups.find((item) => item.key === groupKey && item.k > 1)
- if (!group || !instance || !instance.engine || !instance.viewer) return false
- const selectedCode = group.rows.some((row) => row.code === selectedDeviceCode) ? selectedDeviceCode : ''
- const fanGroup = selectedCode
- ? { ...group, rows: group.rows.filter((row) => row.code !== selectedCode), maxFan: Math.max(1, group.maxFan - 1) }
- : group
- const fan = selectedCode && group.maxFan <= 1 ? [] : fanLayout(fanGroup, { center: { x: 0, y: 0 }, radius: 48 })
- const offsets = Object.fromEntries(fan.map((item) => [item.deviceCode, { x: item.x, y: item.y }]))
- instance.engine.setOverlapPresentation(instance.viewer, {
- groupKey, state, preview, offsets,
- selectedCode: group.rows.some((row) => row.code === selectedDeviceCode) ? selectedDeviceCode : '',
- })
- overlapState = state
- overlapCountValue = group.k
- paintOverlapState()
- return true
- }
- function paintOverlapState() {
- if (!instance || !isDomHost(instance.host)) return
- const selectedGroup = overlapGroups.find((item) => item.k > 1 && item.rows.some((row) => row.code === selectedDeviceCode))
- const groupKey = overlapExpandedGroup || overlapPreviewGroup || (selectedGroup && selectedGroup.key)
- const group = overlapGroups.find((item) => item.key === groupKey && item.k > 1)
- const degraded = overlapGroups.some((item) => item.k > 1 && item.degraded)
- instance.host.dataset.mapOverlapState = group
- ? (group.degraded ? OVERLAP_STATE.DEGRADED : overlapState)
- : (degraded ? OVERLAP_STATE.DEGRADED : OVERLAP_STATE.COLLAPSED)
- instance.host.dataset.mapOverlapCount = String(group ? group.k : overlapCountValue || 0)
- }
- function collapseOverlap(value = '') {
- const selectedGroup = overlapGroups.find((item) => item.k > 1 && item.rows.some((row) => row.code === selectedDeviceCode))
- const target = resolveOverlapGroupKey(value || overlapExpandedGroup || overlapPreviewGroup || (selectedGroup && selectedGroup.key))
- const selectedCode = selectedGroup && selectedGroup.key === target ? selectedDeviceCode : ''
- if (target && instance && instance.engine && instance.viewer) {
- instance.engine.setOverlapPresentation(instance.viewer, {
- groupKey: target, state: selectedCode ? 'selected' : 'collapsed', selectedCode,
- })
- }
- overlapPreviewGroup = ''
- overlapExpandedGroup = ''
- overlapState = selectedGroup ? OVERLAP_STATE.SELECTED : transitionOverlapState(overlapState, 'close')
- overlapCountValue = selectedGroup ? selectedGroup.k : 0
- paintOverlapState()
- }
- function resolveOverlapGroupKey(value) {
- if (!value) return ''
- const found = overlapGroups.find((item) => item.key === value || item.pointCode === value)
- return found ? found.key : value
- }
- export function getOverlapTargets() {
- if (!instance || !instance.engine || !instance.viewer) return []
- return instance.engine.readOverlapTargets(instance.viewer)
- }
- export function nudgeOverlap(pointCode) {
- const groupKey = resolveOverlapGroupKey(pointCode)
- const group = overlapGroups.find((item) => item.key === groupKey && item.k > 1)
- if (!group || !instance || !instance.engine || !instance.viewer) return false
- return instance.engine.nudgeOverlap(instance.viewer, groupKey)
- }
- function toggleOverlap(value) {
- const groupKey = resolveOverlapGroupKey(value)
- if (!groupKey) return getOverlapState()
- if (overlapExpandedGroup === groupKey) collapseOverlap(groupKey)
- else expandOverlap(groupKey, true)
- return getOverlapState()
- }
- function expandOverlap(groupKey, sticky) {
- if (!groupKey || !overlapGroups.some((item) => item.key === groupKey && item.k > 1)) return false
- if (overlapExpandedGroup && overlapExpandedGroup !== groupKey) collapseOverlap(overlapExpandedGroup)
- overlapPreviewGroup = sticky ? '' : groupKey
- overlapExpandedGroup = sticky ? groupKey : ''
- applyOverlapPresentation(groupKey, OVERLAP_STATE.EXPANDED, !sticky)
- return true
- }
- export function syncAssociatedDevices(devices, options = {}) {
- deviceRows = Array.isArray(devices) ? [...devices] : []
- overlapMaxFan = Number.isInteger(options.maxFan) && options.maxFan > 0 ? options.maxFan : 10
- overlapGroups = Array.isArray(options.overlapGroups)
- ? options.overlapGroups
- : groupOverlappingDevices(deviceRows, { maxFan: overlapMaxFan })
- overlapPreviewGroup = ''
- overlapExpandedGroup = ''
- overlapState = OVERLAP_STATE.COLLAPSED
- overlapCountValue = overlapGroups.reduce((max, group) => Math.max(max, group.k || 0), 0)
- if (!instance || !instance.engine || !instance.viewer) {
- paintDeviceState()
- paintOverlapState()
- return false
- }
- const colors = toneColors()
- const planned = deviceRows.map((device) => {
- if (device.assetMode !== undefined) return { ...device, tone: device.tone || deviceVisualTone(device) }
- const asset = planDeviceAsset({ device, color: colors[deviceVisualTone(device)] || colors.neutral })
- return {
- ...device,
- shape: asset.shape,
- size: asset.size,
- assetMode: asset.mode,
- assetReason: asset.reason,
- assetReasonText: asset.reasonText,
- visualKey: asset.visualKey,
- tone: deviceVisualTone(device),
- }
- })
- const result = instance.engine.syncAssociatedDevices(
- instance.viewer,
- planned,
- (device) => colors[device.tone] || colors.neutral,
- (shape, color, size) => markerSvg(shape, color, size),
- overlapGroups,
- // 批次 3:关联监测设备走实心母版 + 内层行业图标(规格 10),尖端锚定。
- (device) => planMarker({ kind: 'monitoring-device', status: device.status, industry: device.industry, tone: device.tone }),
- (device) => colors[device.tone] || colors.neutral,
- )
- deviceRows = planned
- deviceRendered = result.rendered
- deviceSkipped = result.skipped
- // 票 47:动效计划随图层一起下发(门控判定在 domain,本层只搬运)。
- deviceEffectsPlan = options.effects || null
- const selectedGroup = overlapGroups.find((group) => group.k > 1 && group.rows.some((row) => row.code === selectedDeviceCode))
- if (selectedGroup) applyOverlapPresentation(selectedGroup.key, OVERLAP_STATE.SELECTED)
- applyDeviceEffectsToEngine()
- paintDeviceState()
- paintOverlapState()
- return true
- }
- /**
- * 票 47:把动效计划交给引擎,并把**降级/开关**写成宿主只读字段(走查断言面)。
- *
- * 关掉特效 ≠ 关掉状态显示:这里只控制"动效",静态色/形/文字仍由 `applyDeviceReactions` 与
- * 实体属性表达(决策票 07 问题 6 的原句)。
- */
- function applyDeviceEffectsToEngine() {
- if (!instance || !instance.engine || !instance.viewer) {
- paintDeviceEffects()
- return false
- }
- // 光环实体的色值必须由**服务层烘焙**后下传(引擎读不到 CSS 变量,研究笔记第 6 条):
- // 这里复用与设备图标同一份 `toneColors()` 与 `markerSvg()`。
- const colors = toneColors()
- const running = instance.engine.applyDeviceEffects(instance.viewer, deviceEffectsPlan, {
- selectedCode: selectedDeviceCode,
- devices: deviceRows,
- colorOf: (device) => colors[deviceVisualTone(device)] || colors.neutral,
- imageOf: (shape, color, size) => markerSvg(shape, color, size),
- // 科技感光环/辉光用**能量场图片**(径向渐变柔和边缘),与图标同一份烘焙色值。
- fxImageOf: (kind, color, size) => (kind === 'glow' ? glowSvg(color, size) : haloSvg(color, size)),
- })
- paintDeviceEffects()
- return running
- }
- /** 动效读数的宿主字段:`on|off|degraded` + 异常台数 + 参与动效台数。 */
- function paintDeviceEffects() {
- if (!instance || !isDomHost(instance.host)) return
- const host = instance.host
- const plan = deviceEffectsPlan
- if (!plan || plan.enabled !== true) host.dataset.mapDeviceEffects = 'off'
- else if (plan.degraded) host.dataset.mapDeviceEffects = 'degraded'
- else host.dataset.mapDeviceEffects = 'on'
- host.dataset.mapDeviceEffectsAbnormal = String(plan ? plan.abnormalCount : 0)
- host.dataset.mapDeviceEffectsAnimated = String(plan && plan.animated ? plan.animated.length : 0)
- }
- /**
- * 设备行 → 色键(票 44:色键来自**设备独立词表**,不借用点位表)。
- *
- * 单独成函数是为了让"设备颜色只表达设备状态"这条在服务层也一眼可见 ——
- * 服务层不认识业务状态名,只问设备词表要一个色键。
- */
- function deviceVisualTone(device) {
- return deviceVisualForStatus(device && device.status).tone
- }
- /** 清空关联设备临时图层(关闭 / 换点 / 刷新 / 快照失效 / 离开页面)。 */
- export function clearAssociatedDevices() {
- if (instance && instance.engine && instance.viewer) {
- instance.engine.clearAssociatedDevices(instance.viewer)
- }
- deviceRows = []
- deviceRendered = 0
- deviceSkipped = 0
- overlapGroups = []
- overlapPreviewGroup = ''
- overlapExpandedGroup = ''
- overlapState = OVERLAP_STATE.COLLAPSED
- overlapCountValue = 0
- paintOverlapState()
- // 票 43:图层撤下时**一并清掉选中与 hover**(否则会留着指向已不存在设备的强化读数,
- // 走查与界面都会读到一个不存在的选中态)。
- selectedDeviceCode = ''
- hoveredDeviceCode = ''
- // 票 47:动效随图层一起消失(计划清空 + 引擎侧已在 clearAssociatedDevices 里停循环)。
- deviceEffectsPlan = null
- paintDeviceEffects()
- paintDeviceReactions()
- paintDeviceState()
- }
- /** 当前关联设备图层的只读读数(走查与人工排查读它)。 */
- export function getAssociatedDeviceState() {
- let onMap = deviceRendered
- let entities = []
- if (instance && instance.engine && instance.viewer) {
- entities = instance.engine.readAssociatedDevices(instance.viewer)
- onMap = entities.filter((entity) => entity.deviceCode && entity.visible).length
- }
- return {
- plan: [...deviceRows],
- onMap,
- skipped: deviceSkipped,
- entities,
- overlap: getOverlapState(),
- }
- }
- export function clearOverlapNudge() {
- if (instance && instance.engine && instance.viewer) instance.engine.clearOverlapNudge(instance.viewer)
- }
- /**
- * 票 47:动效的只读读数 —— 计划(门控/降级/异常台数)+ 引擎里每台设备的**当前帧属性**。
- *
- * 走查的"真在跑"证据来自后者:对同一设备隔帧读两次,断言 `scale`/`alpha`(脉冲/闪烁)
- * 或 `rotation`(旋转环)确实变了,而正常/离线设备**完全不变**。
- */
- export function getDeviceEffectState() {
- const frames = instance && instance.engine && instance.viewer && instance.engine.readDeviceEffects
- ? instance.engine.readDeviceEffects(instance.viewer)
- : { tick: 0, running: false, devices: [] }
- return { plan: deviceEffectsPlan, ...frames }
- }
- export function getOverlapState() {
- const selectedGroup = overlapGroups.find((item) => item.k > 1 && item.rows.some((row) => row.code === selectedDeviceCode))
- const groupKey = overlapExpandedGroup || overlapPreviewGroup || (selectedGroup && selectedGroup.key)
- const group = overlapGroups.find((item) => item.key === groupKey && item.k > 1)
- const degraded = overlapGroups.some((item) => item.k > 1 && item.degraded)
- return {
- state: group ? (group.degraded ? OVERLAP_STATE.DEGRADED : overlapState) : (degraded ? OVERLAP_STATE.DEGRADED : OVERLAP_STATE.COLLAPSED),
- count: group ? group.k : overlapCountValue || 0,
- pointCode: group ? group.pointCode : '',
- groupKey: groupKey || '',
- expanded: Boolean(overlapExpandedGroup),
- selectedCode: selectedGroup ? selectedDeviceCode : '',
- preview: Boolean(overlapPreviewGroup),
- maxFan: overlapMaxFan,
- }
- }
- /**
- * 面板选择层级写进宿主 dataset(`data-map-panel-selection` = `none|point|equipment`)。
- * 由外壳在详情层级变化时调它 —— 服务层不认识 Vue,所以层级值由调用方给。
- */
- export function setPanelSelectionLevel(level) {
- if (level === 'point' || level === 'none') setSelectedDevice('')
- if (!instance || !isDomHost(instance.host)) return
- instance.host.dataset.mapPanelSelection = level || 'none'
- }
- /**
- * 清空设备选中与 hover(图层撤下 / 关闭 / 换点 / 离开页面都走它)。
- *
- * 一并清引线:留着一条指向已撤下设备的线,是"归属指示"里最误导的一种残留。
- */
- export function clearDeviceSelection() {
- const selectedGroup = overlapGroups.find((group) => group.rows.some((row) => row.code === selectedDeviceCode))
- selectedDeviceCode = ''
- hoveredDeviceCode = ''
- collapseOverlap(selectedGroup ? selectedGroup.key : '')
- if (instance && instance.engine && instance.viewer) {
- instance.engine.clearDeviceLead(instance.viewer)
- instance.engine.applyDeviceReactions(instance.viewer, [], reactionStyle)
- }
- paintDeviceReactions()
- }
- /** 当前选中/hover 的只读读数(走查与排查读它)。 */
- export function getDeviceCorrespondenceState() {
- return {
- selected: selectedDeviceCode,
- hovered: hoveredDeviceCode,
- panelSelection: instance && isDomHost(instance.host) ? instance.host.dataset.mapPanelSelection || '' : '',
- }
- }
- /** 设备图层读数写进宿主 dataset(走查读这一行;摘要那几个 `data-map-markers*` 不动)。 */
- function paintDeviceState() {
- if (!instance || !isDomHost(instance.host)) return
- instance.host.dataset.mapDevices = String(deviceRendered)
- instance.host.dataset.mapDevicesPlan = String(deviceRows.length)
- }
- // ---- 票 43:设备点的对应反应(hover / 选中 / 弱化 / 引线 / 拾取优先)----
- /** 当前选中设备(`deviceCode`)与三维 hover 设备(`deviceCode`)。 */
- let selectedDeviceCode = ''
- let hoveredDeviceCode = ''
- /**
- * 票 47:当前设备图层的**动效计划**(`domain/deviceEffects.js` 的输出)。
- * 由 store 计算后随 `syncAssociatedDevices` 传进来 —— 门控与降级的判定在 domain,
- * 本层只负责把它交给引擎并暴露可断言读数。
- */
- let deviceEffectsPlan = null
- /**
- * 把反应档位写进引擎,并刷新三个可断言字段。
- *
- * 三个字段(规格点名的可断言口径,走查对断言不对图):
- * · `data-map-device-picked` = 当前选中设备 `deviceCode`
- * · `data-map-panel-selection` = `none|point|equipment`
- * · `data-map-device-hovered` = 当前三维 hover 设备 `deviceCode`
- * **不动既有 `data-map-picked`**(票 10 走查在用)。
- */
- function paintDeviceReactions() {
- if (!instance || !isDomHost(instance.host)) return
- const host = instance.host
- host.dataset.mapDevicePicked = selectedDeviceCode || ''
- host.dataset.mapDeviceHovered = hoveredDeviceCode || ''
- }
- /**
- * 按当前选中/hover 重算每台设备的反应并写进引擎(纯判定在 domain,本层只搬运)。
- * @param {object} [options] `{ devices }` 覆盖设备行(默认用图层计划)
- */
- export function syncDeviceReactions(options = {}) {
- const devices = Array.isArray(options.devices) ? options.devices : deviceRows
- const plan = planDeviceReactions({ devices, selectedCode: selectedDeviceCode, hoveredCode: hoveredDeviceCode })
- if (instance && instance.engine && instance.viewer) {
- instance.engine.applyDeviceReactions(instance.viewer, plan.reactions, reactionStyle)
- // 票 47:反应量变了要重算动效 —— 动效是**乘在反应量基准上**的,
- // 不重算的话选中强化的幅度会停在旧的基准上。
- applyDeviceEffectsToEngine()
- syncDeviceLeadForSelection(devices)
- }
- paintDeviceReactions()
- return plan
- }
- /**
- * 选中设备的归属引线:从设备点连到**监测点标记中心**(票 04/12 的锚点,归属不换)。
- * 重合时按规格**退化不画**(`planDeviceLead().draw === false`)。
- */
- function syncDeviceLeadForSelection(devices) {
- const list = Array.isArray(devices) ? devices : []
- const selected = list.find((item) => item && item.code === selectedDeviceCode)
- const pointMarker = markerPlan.markers.find((item) => item.kind === 'monitoring-point' && item.code === (selected && selected.pointCode))
- if (!selected || !pointMarker) {
- instance.engine.clearDeviceLead(instance.viewer)
- return false
- }
- const lead = planDeviceLead({
- deviceLon: selected.longitude,
- deviceLat: selected.latitude,
- pointLon: pointMarker.longitude,
- pointLat: pointMarker.latitude,
- })
- if (!lead.draw) {
- instance.engine.clearDeviceLead(instance.viewer)
- return false
- }
- return instance.engine.syncDeviceLead(instance.viewer, {
- deviceLon: selected.longitude,
- deviceLat: selected.latitude,
- pointLon: pointMarker.longitude,
- pointLat: pointMarker.latitude,
- deviceCode: selected.code,
- pointCode: selected.pointCode,
- })
- }
- /**
- * 选中/取消选中一台设备(**面板与三维共用这一个入口**)。
- * @param {string} deviceCode 设备编码;空串=取消设备选中
- */
- export function setSelectedDevice(deviceCode) {
- const next = typeof deviceCode === 'string' ? deviceCode.trim() : ''
- const previousGroup = overlapGroups.find((group) => group.k > 1 && group.rows.some((row) => row.code === selectedDeviceCode))
- const activeGroup = overlapExpandedGroup || overlapPreviewGroup
- selectedDeviceCode = next
- const selectedGroup = overlapGroups.find((group) => group.k > 1 && group.rows.some((row) => row.code === next))
- if (activeGroup && (!previousGroup || activeGroup !== previousGroup.key)) collapseOverlap(activeGroup)
- if (previousGroup) collapseOverlap(previousGroup.key)
- else collapseOverlap()
- if (selectedGroup) applyOverlapPresentation(selectedGroup.key, OVERLAP_STATE.SELECTED)
- syncDeviceReactions()
- return selectedDeviceCode
- }
- /** 当前选中设备编码(只读)。 */
- export function getSelectedDevice() {
- return selectedDeviceCode
- }
- /**
- * 三维 hover 一台设备(pointermove 拾取调用;空串=离开设备)。
- *
- * 只改 hover 档,**不动选中态**(规格:hover 是瞬态,选中是持久态,互不干扰)。
- */
- export function setHoveredDevice(deviceCode) {
- const next = typeof deviceCode === 'string' ? deviceCode.trim() : ''
- if (next === hoveredDeviceCode) return hoveredDeviceCode
- hoveredDeviceCode = next
- syncDeviceReactions()
- return hoveredDeviceCode
- }
- /** 当前三维 hover 设备编码(只读)。 */
- export function getHoveredDevice() {
- return hoveredDeviceCode
- }
- const pipelinePickHandlers = new Set()
- /** 管线/构件点选订阅;回调只收到业务身份,不收到 Cesium 实体。 */
- export function onPipelinePick(handler) {
- if (typeof handler !== 'function') return () => {}
- pipelinePickHandlers.add(handler)
- return () => pipelinePickHandlers.delete(handler)
- }
- export function clearPipelinePickHandlers() {
- pipelinePickHandlers.clear()
- }
- if (!instance || !instance.engine || !instance.viewer || typeof instance.engine.pickPipeObjectAt !== 'function') {
- return { target: 'none', pick: null }
- }
- const pick = instance.engine.pickPipeObjectAt(instance.viewer, clientX, clientY)
- if (!pick) return { target: 'none', pick: null }
- const result = { target: 'pipeline', pick }
- for (const handler of [...pipelinePickHandlers]) handler(pick)
- return result
- }
- /**
- * 点选链路(票 43/23):**先设备点、再监测点**(规格:正常单点设备点优先、监测点兜底)。
- *
- * 优先级判定在 `domain/deviceCorrespondence.js` 的 `resolvePickOrder()`(可脱浏览器断言),
- * 本层只负责"两次引擎拾取 + 按结论派发",不自己写 if-else 顺序。
- * @returns {{target: string, pick: object|null}}
- */
- export function pickDeviceOrPointAt(clientX, clientY) {
- if (!instance || !instance.engine || !instance.viewer) return { target: 'none', pick: null }
- const deviceHit = instance.engine.pickDeviceMarkerAt(instance.viewer, clientX, clientY)
- const overlapHit = deviceHit && deviceHit.kind === 'monitoring-device-overlap' ? deviceHit : null
- const actualDeviceHit = deviceHit && deviceHit.kind === 'monitoring-device' ? deviceHit : null
- const pointHit = deviceHit ? null : instance.engine.pickPointMarkerAt(instance.viewer, clientX, clientY)
- if (overlapHit || (actualDeviceHit && actualDeviceHit.overlapEntry)) {
- const entry = overlapHit || actualDeviceHit
- expandOverlap(entry.overlapGroup, true)
- return resolveOverlapPick({ aggregateHit: entry })
- }
- const resolved = resolveOverlapPick({ deviceHit: actualDeviceHit, pointHit })
- if (resolved.target === 'device') {
- // 统一选择入口负责选中与呈现;不提前写 selectedDeviceCode,避免 store 的幂等检查吞掉更新。
- lastPicked = resolved.pick
- for (const handler of [...pointPickHandlers]) handler(resolved.pick)
- } else if (resolved.target === 'point') {
- if (overlapExpandedGroup) collapseOverlap(overlapExpandedGroup)
- lastPicked = resolved.pick
- paintPicked(instance, resolved.pick)
- for (const handler of [...pointPickHandlers]) handler(resolved.pick)
- } else {
- // 点空白处:粘性展开明确收起。
- if (overlapExpandedGroup || overlapPreviewGroup) collapseOverlap()
- }
- return resolved
- }
- /**
- * 订阅设备 hover(三维 pointermove 用)。
- *
- * 与点选订阅分开:hover 是**高频**事件,订阅方(面板高亮)需要自己做节流,
- * 服务层只负责"拾到谁就说谁"(不在这一层锁节流数值 —— 规格把节流交实现/原型定)。
- */
- const deviceHoverHandlers = new Set()
- export function onDeviceHover(handler) {
- if (typeof handler !== 'function') return () => {}
- deviceHoverHandlers.add(handler)
- return () => deviceHoverHandlers.delete(handler)
- }
- /**
- * 三维 hover 拾取(由宿主 pointermove 驱动)。
- * @returns {string} 当前 hover 的设备编码(空串=没 hover 到设备)
- */
- export function hoverDeviceAt(clientX, clientY) {
- if (!instance || !instance.engine || !instance.viewer) return ''
- const hit = instance.engine.pickDeviceMarkerForHover(instance.viewer, clientX, clientY)
- if (hit && (hit.kind === 'monitoring-device-overlap' || hit.overlapEntry)) {
- if (overlapExpandedGroup === hit.overlapGroup) return ''
- if (overlapPreviewGroup !== hit.overlapGroup) {
- if (overlapPreviewGroup) collapseOverlap(overlapPreviewGroup)
- expandOverlap(hit.overlapGroup, false)
- }
- nudgeOverlap(hit.overlapGroup)
- return '' // 聚合入口不虚构设备身份;选中设备入口保留其真实编码。
- }
- // 从聚合入口移到预览子点时保留预览,确保子点可点击。
- if (overlapPreviewGroup && (!hit || hit.overlapGroup !== overlapPreviewGroup)) collapseOverlap(overlapPreviewGroup)
- const code = hit ? hit.code : ''
- if (code === hoveredDeviceCode) return code
- hoveredDeviceCode = code
- syncDeviceReactions()
- for (const handler of [...deviceHoverHandlers]) handler(hit)
- return code
- }
- // ---- 批次 3:hover 完整名称 tooltip(label 截断后的通用 hover 详情)----
- // 原生 DOM 层实现(不用 Cesium InfoBox:viewer 创建时 infoBox:false):一个绝对定位 div,
- // hover 到点位/设备标记时显示完整名称,跟随鼠标;离开即隐藏。样式内联(无 scoped CSS 可挂)。
- let tooltipNode = null
- function ensureTooltipNode(record) {
- if (tooltipNode && tooltipNode.isConnected) return tooltipNode
- tooltipNode = record.host.ownerDocument.createElement('div')
- tooltipNode.setAttribute('data-map-marker-tooltip', '')
- tooltipNode.style.cssText = [
- 'position:fixed', 'z-index:30', 'pointer-events:none',
- 'max-width:320px', 'padding:5px 9px',
- 'background:rgba(2,9,27,0.88)', 'border:1px solid rgba(1,171,234,0.55)',
- 'border-radius:4px', 'color:#d7ecff',
- 'font:600 12px "Microsoft YaHei UI","Microsoft YaHei","PingFang SC","Noto Sans SC",sans-serif',
- 'white-space:nowrap', 'overflow:hidden', 'text-overflow:ellipsis',
- ].join(';')
- record.host.ownerDocument.body.appendChild(tooltipNode)
- return tooltipNode
- }
- /** hover 移动:拾取点位/设备标记,显示完整名称(无命中即隐藏)。 */
- function paintMarkerTooltip(event) {
- if (!instance || !instance.engine || !instance.viewer) return
- const point = instance.engine.pickPointMarkerForHover(instance.viewer, event.clientX, event.clientY)
- const device = point ? null : instance.engine.pickDeviceMarkerForHover(instance.viewer, event.clientX, event.clientY)
- const name = (point && point.name) || (device && device.name) || ''
- if (!name) {
- hideMarkerTooltip()
- return
- }
- const node = ensureTooltipNode(instance)
- node.textContent = name
- node.style.display = 'block'
- node.style.left = `${event.clientX + 14}px`
- node.style.top = `${event.clientY + 10}px`
- }
- /** hover 离开 / 无命中:隐藏 tooltip。 */
- function hideMarkerTooltip() {
- if (tooltipNode && tooltipNode.style) tooltipNode.style.display = 'none'
- }
- /**
- * 清空地图上的点位标记(离开 /cockpit 用)。
- *
- * **计划一起清掉**:留着计数而地图上没有标记,会让「图例与标记一致」这条验收在自己的
- * 收尾路径上失真。地图上什么都没有时,图例就该是 0。
- */
- export function clearPointMarkers() {
- if (instance && instance.engine && instance.viewer) {
- instance.engine.clearPointMarkers(instance.viewer)
- }
- markerPlan = { markers: [], blocked: [], byStatus: {}, total: 0 }
- markerRendered = 0
- markerSkipped = 0
- paintMarkerState()
- }
- /**
- * 强调一批编码(票 14):设备码先解析成已上图的点位码,再连**设备码与管线码一起**交给引擎。
- *
- * 为什么把设备码也一起传:管线的关联键是设备码(管线不挂在点位上),
- * 所以「高亮关联管线」只能按设备码匹配;点位码则用于强调那个点位标记。
- * 引擎只调已存在实体的显示参数 —— 事件不新开图层;还没落地时(票 22 之前)返回 0,
- * 调用方如实说「尚未出现在地图上」,不伪造成成功。
- * @param {string|Array<string>} codes
- * @returns {number} 实际被强调的实体数(0 = 目标还不在图上 / 引擎没就绪)
- */
- export function highlightMapObject(codes) {
- const wanted = (Array.isArray(codes) ? codes : [codes]).map((item) => String(item || '').trim()).filter(Boolean)
- // 设备码 → 已上图的点位码(点位标记按点位码建;设备码只作匹配线索)。
- const resolved = wanted.flatMap((code) => {
- const byPoint = markerPlan.markers.find((item) => item.code === code)
- if (byPoint) return [code]
- const byDevice = markerPlan.markers.find((item) => Array.isArray(item.deviceCodes) && item.deviceCodes.includes(code))
- // 点位上图了就把点位码一起带上(标记按点位码画),设备码仍保留给管线匹配。
- return byDevice ? [code, byDevice.code] : [code]
- })
- const matched =
- instance && instance.engine && instance.viewer && resolved.length
- ? instance.engine.highlightEntities(instance.viewer, resolved)
- : 0
- // 强调结果写进宿主 dataset(`data-map-emphasis` = 当前被强调的实体数):
- // 既给走查一个**可观察的信号**(收起详情后必须回到 0),也方便人工排查"到底亮没亮"。
- // 引擎没就绪时同样写 0 —— 否则会留着上一轮的旧值,让"还原"看起来没发生。
- if (instance && isDomHost(instance.host)) {
- instance.host.dataset.mapEmphasis = String(matched)
- }
- return matched
- }
- /**
- * 当前点位标记的计划与计数(纯数据,界面与走查唯一读它)。
- * 图例由 `pointLegend()` 从同一份 `byStatus` 生成 —— 这是「图例与标记一致」的落点。
- * @returns {{legend: Array, byStatus: object, markers: Array, blocked: Array, onMap: number, skipped: number}}
- */
- export function getPointMarkerState() {
- // 引擎就绪时以**真实图层内容**为准(走查与人工排查读的是地图上真有什么),
- // 未就绪时退回计划数(图例仍要能显示)。
- let onMap = markerRendered
- let shapes = [...new Set(markerPlan.markers.map((item) => item.shape))].sort()
- let markerImages = {}
- // 实体口径与计划口径**分开给**:`byStatus` 是计划(图例由它生成),
- // `entityByStatus` 是从 Cesium 实体上真读回来的。走查拿后者跟图例 DOM 对照,
- // 才是「图例 vs 地图」的交叉验证;若两边都读 byStatus,那条断言就是自己跟自己比(恒真)。
- let entityByStatus = {}
- if (instance && instance.engine && instance.viewer) {
- const actual = instance.engine.readPointMarkers(instance.viewer)
- onMap = actual.count
- shapes = actual.shapes
- // 每个视觉键对应的**真实 billboard 图片**:走查据此证明「不同状态颜色确实不同」
- // 与「图例形状=标记形状」,而不是拿两份 JS 数据互相比(那是自己跟自己比)。
- markerImages = actual.images
- entityByStatus = { ...actual.byStatus }
- }
- return {
- legend: pointLegend(markerPlan.byStatus),
- byStatus: { ...markerPlan.byStatus },
- entityByStatus,
- markers: [...markerPlan.markers],
- blocked: [...markerPlan.blocked],
- onMap,
- skipped: markerSkipped,
- shapes,
- markerImages,
- }
- }
- /**
- * 标记状态写进宿主 dataset(走查读这一行,不读中文,也不读图):
- * `data-map-markers` = 已画到地图上的标记数
- * `data-map-markers-plan` = 本次计划要画的标记数(坐标准入通过)
- * `data-map-markers-status` = 状态分布,`ALARM:1,WARNING:1` 形式
- * `data-map-marker-shapes` = 实际用到的形状名(证明「不只靠颜色」有形状这一半)
- */
- function paintMarkerState() {
- if (!instance || !isDomHost(instance.host)) return
- const host = instance.host
- const keys = Object.keys(markerPlan.byStatus).sort()
- host.dataset.mapMarkers = String(markerRendered)
- host.dataset.mapMarkersPlan = String(markerPlan.total)
- host.dataset.mapMarkersStatus = keys.map((key) => `${key}:${markerPlan.byStatus[key]}`).join(',')
- host.dataset.mapMarkerShapes = [...new Set(markerPlan.markers.map((item) => item.shape))].sort().join(',')
- }
- // ---- 票 10:点位点选(点地图上的标记打开两级详情) ----
- /**
- * 点选订阅者(MapCanvas 挂载时订阅、离开时退订)。
- *
- * 为什么是订阅而不是让服务层直接调 store:本模块不依赖 Vue、也不认识 pinia
- * (票 02 的边界),所以「拾到谁」由服务层说,「打开什么」由订阅者决定。
- * 订阅者可以有几个(详情浮层、将来的联动面板),互不知情。
- */
- const pointPickHandlers = new Set()
- /** 最近一次拾到的标记(DEV 把手与走查读它)。 */
- let lastPicked = null
- /**
- * 订阅点位点选。返回退订函数(组件 `onBeforeUnmount` 调它)。
- * @param {(picked: {kind: string, code: string, status: string}) => void} handler
- */
- export function onPointPick(handler) {
- if (typeof handler !== 'function') return () => {}
- pointPickHandlers.add(handler)
- return () => {
- pointPickHandlers.delete(handler)
- }
- }
- /** 选中标记写进宿主 dataset:`data-map-picked` = `kind|code`(走查读它,不读中文)。 */
- function paintPicked(record, picked) {
- if (!isDomHost(record && record.host)) return
- record.host.dataset.mapPicked = picked ? `${picked.kind}|${picked.code}` : ''
- record.host.dataset.mapPickedStatus = picked ? picked.status || '' : ''
- }
- /**
- * 窗口坐标 → 点位标记 → 通知订阅者(票 10)。
- *
- * 引擎未就绪、没拾到标记、拾到的不是点位标记:都返回 `null` 且**不改选中态**
- * (点空白处不该把已经打开的详情莫名其妙地收掉,那是「关闭」按钮的事)。
- * @param {number} clientX 窗口坐标(与 `getBoundingClientRect()` 同系)
- * @param {number} clientY
- * @returns {{kind: string, code: string, status: string}|null}
- */
- export function pickPointAt(clientX, clientY) {
- if (!instance || !instance.engine || !instance.viewer) return null
- const picked = instance.engine.pickPointMarkerAt(instance.viewer, clientX, clientY)
- if (!picked) return null
- lastPicked = picked
- paintPicked(instance, picked)
- for (const handler of [...pointPickHandlers]) handler(picked)
- return picked
- }
- /** 最近一次拾到的点位标记(没有就是 null)。 */
- export function getPickedPoint() {
- return lastPicked
- }
- /**
- * 星球自转(引导页用):`seconds` = 转一圈的秒数,0 = 停止。
- *
- * 实现要点:**不能直接转相机**(`camera.rotate`)—— 票 03 的 `cameraControl` 每帧都会用
- * `lookAtTransform(中心, HeadingPitchRange)` 重写相机,直接转会被立刻抹掉(实测:经度纹丝不动)。
- * 所以自转走同一条路:按时间推进**中心点的经度**,再以 `immediate` 套用预设,让相机跟着中心绕地轴转。
- * @returns {boolean} 是否生效;相机还没接上时返回 false(调用方可以稍后重试)
- */
- /** 兼容旧调用:`setGlobeSpin(0)` 也会停。 */
- export function setGlobeSpin(seconds) {
- const spinSeconds = Number(seconds)
- if (!Number.isFinite(spinSeconds) || spinSeconds <= 0) {
- stopSpin()
- return true
- }
- // 先记下**意图**:相机没接上、或正在飞行/阻尼时,本次起不来 —— 等落定后由
- // `resumeSpinIfWanted()` 自动接上。这样"点单"不会丢,也不会去冲掉飞行目标。
- spinWanted = spinSeconds
- if (!instance || !instance.cameraControl) return false
- const state = instance.cameraControl.getState()
- // 自转与飞行互斥:自转每帧以 `immediate` 写中心,飞行中起转会把目标冲掉。
- // 判据用**相机是否真的停稳**,不用粘滞开关 —— 粘滞开关没人清,就会永久哑掉自转。
- if (state && !state.settled) return false
- startSpin(instance, spinSeconds)
- return true
- }
- /**
- * 相机落定后把"想要的"自转接上(由 `notifyCameraStateChange` 调,飞行最后一帧必到)。
- *
- * 为什么需要它:`setGlobeSpin` 在飞行中只能返回 false(不能起转),
- * 而调用方(外壳回引导态)不会一直重试 —— 意图得由服务层自己兑现。
- */
- function resumeSpinIfWanted(state) {
- if (spinWanted <= 0) return
- if (!instance || !instance.cameraControl) return
- if (instance.spin) return
- if (state && !state.settled) return
- startSpin(instance, spinWanted)
- }
- function startSpin(record, spinSeconds) {
- // 只清"正在转的循环",**不清意图** —— 否则下面刚设的 spinWanted 会被自己抹掉。
- cancelSpinLoop()
- record.spin = { spinSeconds, lastAt: Date.now(), raf: null }
- const step = () => {
- // 自转随时可能被「进入系统/归位」打断:只要 spin 被清掉就立刻退出这一帧循环,
- // 绝不能再用 immediate 去覆盖飞行目标。
- if (!record.spin || instance !== record || !record.cameraControl) return
- const now = Date.now()
- const dt = Math.min(now - record.spin.lastAt, 100)
- record.spin.lastAt = now
- // 绕地轴推进中心经度:一圈 2π ÷ spinSeconds;负号 = 自西向东(与真实自转同向)。
- const state = record.cameraControl.getState()
- const pivot = state && state.pivot ? state.pivot : null
- if (pivot) {
- const deltaLon = (-360 * dt) / (record.spin.spinSeconds * 1000)
- record.cameraControl.applyViewPreset(
- { lon: pivot.lon + deltaLon, lat: pivot.lat, height: pivot.height || 0 },
- state.hpr,
- { immediate: true },
- )
- }
- record.spin.raf = requestAnimationFrame(step)
- }
- record.spin.raf = requestAnimationFrame(step)
- }
- /** 只停"正在转的循环",不动意图(`startSpin` 重启时用)。 */
- function cancelSpinLoop() {
- if (instance && instance.spin) {
- if (instance.spin.raf !== null) cancelAnimationFrame(instance.spin.raf)
- instance.spin = null
- }
- }
- /** 停自转**并放弃意图**:进入系统/归位/返回全球都不该再自己转起来。 */
- function stopSpin() {
- spinWanted = 0
- cancelSpinLoop()
- }
- /** 当前相机状态快照(中心、弹簧臂参数、是否在拖动/飞行);引擎未就绪时为 null。 */
- export function getCameraState() {
- return instance && instance.cameraControl ? instance.cameraControl.getState() : null
- }
- /** 可观测的生命周期计数:走查与单测据此断言「只创建一次、未重建」。 */
- export function getLifecycleStats() {
- return {
- created: createdCount,
- destroyed: destroyedCount,
- resize: resizeCount,
- lastResizeAt,
- mounted: !!instance,
- instanceId: instance ? instance.id : '',
- // 票 27 追加:引擎状态与底图状态(上面 6 个字段语义不变);票 29 起底图状态来自配置计划。
- engine: engineState,
- engineError,
- basemap: basemapState,
- // 票 21 追加:地形与叠加影像状态。
- terrain: terrainState,
- terrainError,
- overlays: overlayCount,
- // 票 21 追加:3D Tiles / 倾斜摄影(入口已留好,本机数据缺根 tileset.json 时如实报失败)。
- tilesets: tilesetReady,
- tilesetErrors: tilesetFailed.map((item) => item.error),
- // 票 30 追加:自转是否在跑(引导页状态,走查与排查都看它)。
- spin: !!(instance && instance.spin),
- // 票 40 追加:**想要**的自转时长(秒;0 = 不要)。与 `spin` 分开看才能分辨
- // 「意图立着但相机还没落定」和「压根没想转」—— 回引导态报障的定位就靠这一对。
- spinWanted,
- // 票 36 追加:主场景区状态与原因(票 37/38 能否开限位/开雾的唯一依据)。
- primaryArea: primaryAreaPlan.state,
- primaryAreaReason: primaryAreaPlan.reason,
- primaryAreaBounds: primaryAreaPlan.bounds ? primaryAreaPlan.bounds.slice() : null,
- // 票 37 追加:区域状态与生效限位(走查断言「进区后上限=home.range」用)。
- areaState,
- areaLocked: areaLimits.locked,
- rangeMax: areaLimits.max,
- rangeMin: areaLimits.min,
- // 票 38 追加:自建雾的支持/可执行/强度/降级(**不等于"边缘已遮住"**)。
- fog: fogReadout({
- state: fogLifecycle,
- degrade: fogDegrade,
- params: fogParams,
- supported: fogSupported,
- executable: fogExecutableForReadout(),
- reason: fogReason,
- }),
- // 票 03 追加:相机契约是否已挂上(三键输入、中心规则都在 cameraControl 内)。
- camera: instance && instance.cameraControl ? 'attached' : 'none',
- // 票 09 追加:点位标记的计划数 / 实际画到地图上的数(引擎没就绪时只有计划)。
- markers: markerPlan.total,
- markersOnMap: markerRendered,
- markerStatus: { ...markerPlan.byStatus },
- }
- }
- /** 仅测试/走查用:重置模块内计数,避免同一进程内多次断言互相污染。 */
- export function resetLifecycleStatsForTest() {
- if (instance) {
- if (instance.detachCameraInput) instance.detachCameraInput()
- if (instance.cameraControl) instance.cameraControl.dispose()
- clearDevHook()
- }
- instance = null
- cancelPointLocate()
- mapFrameHandlers.clear()
- cameraStateHandlers.clear()
- sequence = 0
- createdCount = 0
- destroyedCount = 0
- resizeCount = 0
- lastResizeAt = ''
- engineState = 'idle'
- engineError = ''
- basemapState = BASEMAP_STATE.NOT_CONFIGURED
- basemapText = basemapStatusText(null)
- terrainState = TERRAIN_STATE.NONE
- terrainText = terrainStatusText(null)
- terrainError = ''
- overlayCount = 0
- tilesetReady = 0
- tilesetFailed = []
- viewMode = VIEW_MODES.GROUND
- // 票 36:重置回「未配置」——不得让上一轮的矩形留在读数里(同一进程复跑断言会互相污染)。
- primaryAreaPlan = planPrimaryArea({})
- primaryAreaText = primaryAreaStatusText(primaryAreaPlan)
- primaryAreaResolved = false
- // 票 37:区域状态与限位一并归零(否则同一进程里第二次断言会读到上一轮的锁定态)。
- areaState = DEFAULT_AREA_STATE
- areaLimits = planAreaLimits({ plan: primaryAreaPlan, areaState, homeRange: null })
- // 票 38:雾状态一并归零(同一进程复跑断言不得互相污染)。
- stopFogLoop()
- fogHandle = null
- // 调色状态一并归零:下次挂载按(重算后的)配置重建,不残留上一轮的处理级。
- gradeHandle = null
- gradeParams = planSceneGrade({})
- fogLifecycle = createFogLifecycle()
- fogSupported = true
- fogExecutable = false
- fogEverEnabled = false
- fogCreationFailed = false
- fogReason = ''
- fogDegrade = { level: FOG_DEGRADE.NONE, strengthCap: 1, reason: '' }
- fogArtifacts = false
- // 限速的高度系数也要归零(否则下次挂载会从上一次的中间值起步)。
- fogAltitudeShown = 1
- fogSlewAt = 0
- fogPaintedAltitudeFactor = 1
- // 票 39:交接状态一并归零(同一进程复跑断言不得互相污染)。
- // 注意**保留** `handoverParams`(配置决定的,与实例无关,由 applyPrimaryArea 重算);
- // 归零的是"本次实例的运行态"。
- stopHandoverLoop()
- if (overlayReadiness) overlayReadiness.detach()
- overlayReadiness = null
- overlayHandles = []
- handoverLifecycle = createHandoverLifecycle()
- handoverBlock = HANDOVER_BLOCK.NONE
- handoverNote = ''
- handoverPrototypeActive = false
- handoverPainted = {}
- handoverNextEvalAt = 0
- handoverEdgeCache = null
- fogUserDisabled = false
- fogPerfSample = null
- fogPerfResults = []
- markerPlan = { markers: [], blocked: [], byStatus: {}, total: 0 }
- markerRendered = 0
- markerSkipped = 0
- // 票 42:设备图层状态同样归零(同一进程复跑断言不得互相污染)。
- deviceRows = []
- deviceRendered = 0
- deviceSkipped = 0
- // 票 45:聚合组与屏幕展开状态一并归零。
- overlapGroups = []
- overlapMaxFan = 10
- overlapPreviewGroup = ''
- overlapExpandedGroup = ''
- overlapState = OVERLAP_STATE.COLLAPSED
- overlapCountValue = 0
- }
|