mapService.js 242 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625362636273628362936303631363236333634363536363637363836393640364136423643364436453646364736483649365036513652365336543655365636573658365936603661366236633664366536663667366836693670367136723673367436753676367736783679368036813682368336843685368636873688368936903691369236933694369536963697369836993700370137023703370437053706370737083709371037113712371337143715371637173718371937203721372237233724372537263727372837293730373137323733373437353736373737383739374037413742374337443745374637473748374937503751375237533754375537563757375837593760376137623763376437653766376737683769377037713772377337743775377637773778377937803781378237833784378537863787378837893790379137923793379437953796379737983799380038013802380338043805380638073808380938103811381238133814381538163817381838193820382138223823382438253826382738283829383038313832383338343835383638373838383938403841384238433844384538463847384838493850385138523853385438553856385738583859386038613862386338643865386638673868386938703871387238733874387538763877387838793880388138823883388438853886388738883889389038913892389338943895389638973898389939003901390239033904390539063907390839093910391139123913391439153916391739183919392039213922392339243925392639273928392939303931393239333934393539363937393839393940394139423943394439453946394739483949395039513952395339543955395639573958395939603961396239633964396539663967396839693970397139723973397439753976397739783979398039813982398339843985398639873988398939903991399239933994399539963997399839994000400140024003400440054006400740084009401040114012401340144015401640174018401940204021402240234024402540264027402840294030403140324033403440354036403740384039404040414042404340444045404640474048404940504051405240534054405540564057405840594060406140624063406440654066406740684069407040714072407340744075407640774078407940804081408240834084408540864087408840894090409140924093409440954096409740984099410041014102410341044105410641074108410941104111411241134114411541164117411841194120412141224123412441254126412741284129413041314132413341344135413641374138413941404141414241434144414541464147414841494150415141524153415441554156415741584159416041614162416341644165416641674168416941704171417241734174417541764177417841794180418141824183418441854186418741884189419041914192419341944195419641974198419942004201420242034204420542064207420842094210421142124213421442154216421742184219422042214222422342244225422642274228422942304231423242334234423542364237423842394240424142424243424442454246424742484249425042514252425342544255425642574258425942604261426242634264426542664267426842694270427142724273427442754276427742784279428042814282428342844285428642874288428942904291429242934294429542964297429842994300430143024303430443054306430743084309431043114312431343144315431643174318431943204321432243234324432543264327432843294330433143324333433443354336433743384339434043414342434343444345434643474348434943504351435243534354435543564357435843594360436143624363436443654366436743684369437043714372437343744375437643774378437943804381438243834384438543864387438843894390439143924393439443954396439743984399440044014402440344044405440644074408440944104411441244134414441544164417441844194420442144224423442444254426442744284429443044314432443344344435443644374438443944404441444244434444444544464447444844494450445144524453445444554456445744584459446044614462446344644465446644674468446944704471447244734474447544764477447844794480448144824483448444854486448744884489449044914492449344944495449644974498449945004501450245034504450545064507450845094510451145124513451445154516451745184519452045214522452345244525452645274528452945304531453245334534453545364537453845394540454145424543454445454546454745484549455045514552455345544555455645574558455945604561456245634564456545664567456845694570457145724573457445754576457745784579458045814582458345844585458645874588458945904591459245934594459545964597459845994600460146024603460446054606460746084609461046114612461346144615461646174618461946204621462246234624462546264627462846294630463146324633463446354636463746384639464046414642464346444645464646474648464946504651465246534654465546564657465846594660466146624663466446654666466746684669467046714672467346744675467646774678467946804681468246834684468546864687468846894690469146924693469446954696469746984699470047014702470347044705470647074708470947104711471247134714471547164717471847194720472147224723472447254726472747284729473047314732473347344735473647374738473947404741474247434744474547464747474847494750475147524753
  1. /**
  2. * 地图服务:驾驶舱「中央一张图」实例生命周期的唯一所有者。
  3. *
  4. * 为什么要有这一层(ADR 0004 / 0005、票 02、票 27):
  5. * 页面组件不得直接持有底层地图实现,地图实例的创建/尺寸/销毁必须集中在一处,
  6. * 才能保证「切换一级模式或收放面板时不销毁、不重建」。
  7. *
  8. * 票 27 起内部接了真实引擎(Cesium):
  9. * · `mountMap(host)` 幂等,且只对**真 DOM 宿主要素**异步加载引擎(`viewer` 建在宿主内);
  10. * · `resizeMap()` 转调 `viewer.resize()`、`unmountMap()` 转调 `viewer.destroy()`;
  11. * · 引擎没加载完就销毁 / 销毁后才加载完,都不会泄漏实例;
  12. * · 底图未配置时如实标注「底图未配置」,引擎起不来时标注失败 —— 组件里的临时影像继续兜底,
  13. * 页面不白屏。
  14. * 票 03 起相机也归本层:三键契约的实现(domain/camera.js + cameraControl.js)在引擎就绪后挂上,
  15. * 对外只暴露 `setCameraFocus()` / `clearCameraFocus()` / `getCameraState()`,
  16. * 组件与列表不需要知道 Cesium 方言;契约见 ADR 0008。
  17. * 接入配方见决策地图调研:`.scratch/web-cockpit-decision-map/research/cesium-fes-vite.md`。
  18. *
  19. * 本模块不依赖 Vue;对宿主元素的 DOM 操作都有 `isDomHost` 守卫,
  20. * 因此仍可脱离浏览器直接断言(见 verify/t01-panel-catalog.mjs)。
  21. */
  22. import { ensureCesiumBaseUrl } from './cesiumAssets.js'
  23. import { getCockpitConfig, loadCockpitConfig } from './cockpitConfig.js'
  24. import { attachCameraInput, createCameraControl } from './cameraControl.js'
  25. import { attachPointPick } from './pointPick.js'
  26. import { attachSceneFocus } from './sceneInputFocus.js'
  27. import { formatCameraState, INITIAL_PANORAMA, RETURN_FLIGHT_MS, SETTLE_EPSILON } from '../domain/camera.js'
  28. import { BASEMAP_STATE, basemapStatusText, planBasemap } from '../domain/basemap.js'
  29. import { isAtHome, planHome, planIntro } from '../domain/viewPresets.js'
  30. import { isStaleLocate, planLocateView, planTerrainLocateTarget, TERRAIN_LOCATE_REASON } from '../domain/pointLocate.js'
  31. import {
  32. planDeviceLead,
  33. planDeviceReactions,
  34. reactionStyle,
  35. } from '../domain/deviceCorrespondence.js'
  36. import {
  37. fanLayout,
  38. groupOverlappingDevices,
  39. OVERLAP_STATE,
  40. resolveOverlapPick,
  41. transitionOverlapState,
  42. } from '../domain/overlapDevices.js'
  43. import { planDeviceAsset } from '../domain/deviceAssets.js'
  44. // 批次 3:位置符号合成计划层(规格 10 的唯一新接缝)与母版合成渲染。
  45. import { planMarker, planToDataset } from '../domain/markerPlan.js'
  46. // 票 101:管线构件点位标记行(符号/标签/行业色)由领域层单源给出,服务层不拼视觉。
  47. import { pipeComponentMarkers } from '../domain/pipeNetwork.js'
  48. // 票 82:标记几何单源(本体宽 / pad / 绘制宽),服务层不再持有尺寸字面量。
  49. import { MARKER_BODY_WIDTH, markerDevKnobs, markerMetricsFor, resetMarkerDevKnobs, setMarkerDevKnobs } from '../domain/markerMetrics.js'
  50. // 票 76(C3):强调量由服务层直接产出给 DOM —— 判据复用引擎同一份 domain 单源,
  51. // 不再回读被隐藏的 billboard 实体 `scale`。
  52. import { EMPHASIS_STYLE, shouldEmphasize } from '../domain/mapEmphasis.js'
  53. import { renderMarker } from './markerAssets.js'
  54. // 批次 3 第五轮(票 20):DOM overlay 渲染层(billboard 发糊的结构性解决)。
  55. import { mountMarkerOverlay, attachOverlayRenderer, unmountMarkerOverlay, isMarkerOverlayVisible, isMarkerOverlayMounted, refreshMarkerFx } from './markerOverlay.js'
  56. import { DEVICE_LAYER_KIND } from '../domain/deviceLayer.js'
  57. import { deviceVisualForStatus } from '../domain/deviceStatus.js'
  58. // 票 45:摘要标记(监测点/未关联设备)动效计划 —— 与设备动效同一映射与门控。
  59. import { abnormalObjectCount, combineEffectPlans, planDeviceEffects } from '../domain/deviceEffects.js'
  60. import { isRenderable } from '../domain/coordinate.js'
  61. import { VIEW_MODES, planViewMode, viewHprFor } from '../domain/viewModes.js'
  62. import { TERRAIN_STATE, planOverlays, planTerrain, planTilesets, terrainStatusText } from '../domain/mapLayers.js'
  63. // 票 46:`planPointMarkers` 不再在服务层调用(模式过滤归 `domain/modeLayerPlan.js`),
  64. // 但 `summarizeMarkerStatuses` 仍需要 —— 计划没带 byStatus 时兜底现算,保证图例同源。
  65. import { markerSvg, pointLegend, summarizeMarkerStatuses } from '../domain/pointStatus.js'
  66. import { planPrimaryArea, primaryAreaStatusText } from '../domain/primaryArea.js'
  67. import {
  68. AREA_STATE,
  69. DEFAULT_AREA_STATE,
  70. planAreaLimits,
  71. transitionAreaState,
  72. } from '../domain/areaLimits.js'
  73. import {
  74. FOG_DEGRADE,
  75. FOG_PHASE,
  76. beginFogTransition,
  77. createFogLifecycle,
  78. enterAltitudeStrength,
  79. approachAltitudeFactor,
  80. fogAltitudeFactor,
  81. fogReadout,
  82. parseHexColor,
  83. planFog,
  84. planFogDegrade,
  85. stepFogLifecycle,
  86. } from '../domain/fog.js'
  87. // 一张图调色(科技蓝滤镜):计划归 domain/sceneGrade.js,引擎只照计划建处理级。
  88. import { planSceneGrade } from '../domain/sceneGrade.js'
  89. import { attachModelDiagnostics } from './modelDiagnostics.js'
  90. import { tierAtDistance } from './tilesetOptions.js'
  91. // 票 98:读数包稳定引用(外壳 500ms 轮询写 ref;返回新对象会白触发整壳重渲染)。
  92. import { createStableReadout } from '../domain/readoutCache.js'
  93. import {
  94. HANDOVER_BLOCK,
  95. HANDOVER_PHASE,
  96. createHandoverLifecycle,
  97. beginHandover,
  98. edgeOcclusionVerdict,
  99. handoverReadout,
  100. planHandover,
  101. revealGate,
  102. returnSyncVerdict,
  103. stepHandover,
  104. } from '../domain/imageryHandover.js'
  105. const ENGINE_TEXT = {
  106. unavailable: 'Cesium 引擎未启动(无宿主要素)',
  107. booting: 'Cesium 引擎加载中…',
  108. // ready 的措辞按底图计划生成(票 29):未配置时保持票 27 的原话。
  109. ready: 'Cesium 引擎已就绪 · 底图未配置(正式底图见票 21)',
  110. failed: 'Cesium 引擎初始化失败 · 中央区域回退临时底图',
  111. disabled: 'Cesium 引擎已由部署配置关闭 · 中央区域用临时底图',
  112. }
  113. // 状态浮标由服务层自绘并挂在宿主内:外壳与 MapCanvas 组件按票面要求不改。
  114. //
  115. // 票 25 修订:这两个节点**不再自绘浮标**——它们被 `MapInfoPanel` 搬进右下角/右上角的
  116. // 状态日志集合里(`MapCanvas` 的 `relocateEngineNode()`),所以样式改为**布局中性**:
  117. // 不自己定位、不限宽、**不截断**。
  118. // 原先的 `white-space:nowrap + overflow:hidden + text-overflow:ellipsis`
  119. // 会把「Cesium 引擎已就绪 · 主场景区 · 底图 · 地形 · 叠加影像 · 3D Tiles …」
  120. // 从中间切断(维护者 2026-10-10 走查反馈:「左右显示不完整,信息被部分切除」),
  121. // 而被切的恰恰是排障最需要的那一整句。两种形态现在只差**配色**,不再差布局。
  122. const STATUS_STYLE_BASE = [
  123. 'position:static',
  124. 'pointer-events:none',
  125. // 换行而不是省略:宁可多占一行,也不能少说一句。
  126. 'white-space:normal',
  127. 'overflow:visible',
  128. 'overflow-wrap:anywhere',
  129. 'line-height:1.45',
  130. 'letter-spacing:.03em',
  131. 'font-size:var(--fs-caption, 10.5px)',
  132. ].join(';')
  133. /** 调试形态:带边框底色,便于开发期定位。 */
  134. 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`
  135. /** 生产形态:无边框无底色、半透明小字,让位于 3D 场景(颜色语义仍然保留)。 */
  136. const STATUS_STYLE_QUIET = `${STATUS_STYLE_BASE};padding:0;color:rgba(186,212,224,.62)`
  137. /** 当前是否走「调试形态」:配置显式给布尔值就听配置,否则跟随构建模式(生产构建自动收敛)。 */
  138. function isUiDebug() { const mapConfig = getCockpitConfig().map || {}
  139. const configured = mapConfig.ui ? mapConfig.ui.debug : null
  140. if (typeof configured === 'boolean') return configured
  141. return !!(import.meta.env && import.meta.env.DEV)
  142. }
  143. /**
  144. * 票 47:设备状态动效的**解析后**选项(决策票 07 问题 6)。
  145. *
  146. * 配置显式给布尔值就听配置;未配置时默认开启(正式运行也必须能看到告警反馈)。
  147. * 只有部署明确设置 `map.effects.enabled=false` 才关闭。门控判定本身仍在 domain
  148. *(`planDeviceEffects`),这里只解析"该不该开、上限多少"。
  149. */
  150. export function resolveDeviceEffectsOptions() {
  151. const mapConfig = getCockpitConfig().map || {}
  152. const effects = mapConfig.effects || {}
  153. const configured = effects.enabled
  154. const enabled = typeof configured === 'boolean' ? configured : true
  155. const limit = Number.isInteger(effects.abnormalLimit) && effects.abnormalLimit >= 1 ? effects.abnormalLimit : 30
  156. return { enabled, abnormalLimit: limit }
  157. }
  158. let instance = null
  159. /**
  160. * **仅测试用**:注入/清空最小实例面(结点行为闸门 `t76-four-states-behavior` 用)。
  161. *
  162. * 为什么需要:`getMapMarkerAnchor()` 在引擎未就绪时直接回 null,于是「面板让位量」这条
  163. * 纯规则在 node 里无法验证(前几轮都是靠走查才发现让位没生效)。注入假实例后,
  164. * 让位规则变成可断言的纯函数行为。生产代码不调用它。
  165. */
  166. export function __setInstanceForTest(fake) {
  167. instance = fake || null
  168. }
  169. let sequence = 0
  170. let createdCount = 0
  171. let destroyedCount = 0
  172. let resizeCount = 0
  173. let lastResizeAt = ''
  174. /**
  175. * 「想要的」自转时长(秒;0 = 不要自转)。
  176. *
  177. * 与「正在转的循环」(`instance.spin`)分开:相机在飞行/阻尼时起不了转,
  178. * 但意图要留住,等落定后由 `resumeSpinIfWanted()` 接上。
  179. * **不能**用一个"暂停"粘滞开关代替 —— 那种开关没人负责清,就会把自转永久哑掉
  180. * (维护者 2026-09-30 报的「回引导态地球不再旋转」正是这个)。
  181. */
  182. let spinWanted = 0
  183. let engineState = 'idle' // idle | unavailable | booting | ready | failed | disabled
  184. let engineError = ''
  185. // 底图状态由配置计划决定(票 29):not-configured | natural-earth | xyz | tianditu | invalid
  186. let basemapState = BASEMAP_STATE.NOT_CONFIGURED
  187. let basemapText = basemapStatusText(null)
  188. // 票 21:地形与叠加影像的状态(none / ready / invalid),叠加层数单独记。
  189. let terrainState = TERRAIN_STATE.NONE
  190. let terrainText = terrainStatusText(null)
  191. let terrainError = ''
  192. let overlayCount = 0
  193. // 票 25:数据来源署名。Cesium 的署名容器已被换成离屏容器(cesiumEngine.js),
  194. // 所以**自有数据源的署名改由地图信息面板展示**,这里只负责从同一批计划里取。
  195. let dataSourceCredits = []
  196. // 票 21:3D Tiles / 倾斜摄影(入口先留好,本机数据缺根 tileset.json 时如实报失败)
  197. let tilesetReady = 0
  198. let tilesetFailed = []
  199. // 票 36:主场景区计划(限位与自建雾的开关与矩形都在这里)。**引擎未就绪时也能读** ——
  200. // 它由配置单方面决定,不依赖 Cesium;票 37/38 的入口直接消费它。
  201. // 初值就是「未配置」:还没读到配置时不得凭空开出一个区域。
  202. let primaryAreaPlan = planPrimaryArea({})
  203. let primaryAreaText = primaryAreaStatusText(primaryAreaPlan)
  204. // 配置是否已经读到(`applyPrimaryArea` 置 true)。初值 false 时**浮标不写主场景区那一句**:
  205. // 那时「未配置」只是本模块的初值,不是核实的结论 —— 写出去等于替部署宣布一个还没核实的事实。
  206. let primaryAreaResolved = false
  207. // 票 37:区域状态(与 filterStore.areaState 同值;服务层持有是为了给相机控件下限位)。
  208. // 初值 global:进区只能由「进入系统 / 归位」触发(ADR 0009 的显式模式锁),不随相机位置自动切。
  209. let areaState = DEFAULT_AREA_STATE
  210. // 票 37:当前生效的区域限位计划(只读出口给走查与排查)。
  211. let areaLimits = planAreaLimits({ plan: primaryAreaPlan, areaState, homeRange: null })
  212. // 票 21:当前视图条件(ground / pipeline),由 setViewMode() 维护。
  213. let viewMode = VIEW_MODES.GROUND
  214. // 票 09:点位标记的最近一次计划与结果(上图层数 + 跳过的脏数据数),供 dataset 与走查读。
  215. let markerPlan = { markers: [], blocked: [], byStatus: {}, total: 0 }
  216. // 票 45:摘要标记(监测点/未关联设备)的动效计划(planDeviceEffects 的输出,形状同设备动效计划)。
  217. let markerEffectsPlan = null
  218. // 票 85:跨图层合计降级状态;摘要、关联设备和设施共用阈值与收敛系数。
  219. let combinedEffectState = { enabled: false, limit: 30, abnormalCount: 0, animatedCount: 0, degraded: false, converge: null }
  220. let markerRendered = 0
  221. let markerSkipped = 0
  222. // 票 42:关联设备临时图层的最近一次计划与结果(与摘要标记**分开**计数,互不污染)。
  223. let deviceRows = []
  224. // 票 68:设施点(水厂/泵站/水源地/用水户)的最近一次计划行。与摘要标记/关联设备**分开**持有:
  225. // 设施点不进摘要序列、不进设备图例,也不参与设备的重叠判定;但状态特效与设备**同一套**
  226. // (维护者 2026-11-06:预警/报警/工单的特效设施点也要有,视觉与设备一致)。
  227. let facilityMarkerRows = []
  228. // 设施点动效计划:与设备动效**同一份** `planDeviceEffects()` 输出(开关/降级/工单不计异常同一条纪律)。
  229. let facilityEffectsPlan = null
  230. let selectedFacilityKey = ''
  231. let facilitySelectionIdentity = null
  232. // 票 101:管线构件点位(阀门/窨井/特殊管点等)的最近一次标记行。同样独立持有:
  233. // 构件点击走**管线拾取链**(局部增强),不进设施详情,也不进设备/设施图例。
  234. let pipeComponentRows = []
  235. /**
  236. * DOM overlay 每帧取数的复用槽(票 35 卡顿整改)。
  237. *
  238. * `attachOverlayRenderer` 的回调**每个渲染帧**都会跑一次;原实现每次重建
  239. * `Map(deviceRows)`、`Map(plan.animated)` 与逐标记 `planMarker()` 的对象。
  240. * 这里只在输入引用变化时重建,帧内全部复用。
  241. */
  242. const overlayFrameCache = {
  243. rows: null, byCode: new Map(), effectOf: () => '',
  244. // 票 61 第十九轮:hostRect 缓存(见挂载处的注释 —— 不每帧强制布局)。
  245. rect: null, rectKey: '', rectAt: 0,
  246. // 票 84:关联设备层的门控查询(与摘要标记同一条门控;三类对象都按自身状态挂环)。
  247. deviceEffectOf: () => '',
  248. // 维护者 2026-11-06:设施点与设备同一套状态特效,门控查询按设施点 key 建表。
  249. facilityEffectOf: () => '',
  250. // 票 85:跨图层合计门控与收敛系数(降级时环仍画,只是更小更淡)。
  251. combinedRef: null, converge: null,
  252. // 静态帧闸门:镜头位姿/宿主矩形/输入计划都没变时,66ms 内的重复帧直接跳过。
  253. pose: '', rectKey: '', at: 0, lastPlan: null, lastRows: null, lastFacilityRows: null, lastComponentRows: null, lastViewMode: '', lastEffects: null,
  254. lastDeviceEffects: null, lastCombined: null,
  255. }
  256. const overlayPlanMarkerCache = new Map()
  257. // 票 68:设施点合成计划的记忆化包装(离散组合只有 type|status|tone 三类,数量极小)。
  258. const overlayFacilityPlanCache = new Map()
  259. // 票 101:构件点合成计划(离散组合只有 componentKind|industry 两类)。
  260. const overlayComponentPlanCache = new Map()
  261. function overlayComponentPlanMarkerCached(marker) {
  262. const key = `${marker.componentKind || ''}|${marker.industry || ''}`
  263. let plan = overlayComponentPlanCache.get(key)
  264. if (!plan) {
  265. plan = planMarker({ kind: 'pipe-component', componentKind: marker.componentKind, industry: marker.industry })
  266. if (overlayComponentPlanCache.size > 64) overlayComponentPlanCache.clear()
  267. overlayComponentPlanCache.set(key, plan)
  268. }
  269. return plan
  270. }
  271. /**
  272. * 设施点合成计划(票 68):走 `planMarker` 的新 kind,**不复用** `overlayPlanMarkerCached` ——
  273. * 后者把「非监测点」强转成监测设备,设施点会丢设施类型图标并被降成设备档。
  274. */
  275. function overlayFacilityPlanMarkerCached(marker) {
  276. const key = `${marker.type || ''}|${marker.status ?? ''}|${marker.tone || ''}`
  277. let plan = overlayFacilityPlanCache.get(key)
  278. if (!plan) {
  279. plan = planMarker({ kind: 'facility-point', status: marker.status, facilityType: marker.type, tone: marker.tone })
  280. if (overlayFacilityPlanCache.size > 64) overlayFacilityPlanCache.clear()
  281. overlayFacilityPlanCache.set(key, plan)
  282. }
  283. return plan
  284. }
  285. /** `planMarker` 的记忆化包装:输入是少量离散组合(kind|status|industry|tone|unassociated)。 */
  286. function overlayPlanMarkerCached(marker) {
  287. const kind = marker.kind === 'monitoring-point' ? 'monitoring-point' : 'monitoring-device'
  288. const unassociated = marker.kind === 'unassociated-equipment'
  289. const key = `${kind}|${marker.status || ''}|${marker.industry || ''}|${marker.tone || ''}|${unassociated ? 1 : 0}`
  290. let plan = overlayPlanMarkerCache.get(key)
  291. if (!plan) {
  292. plan = planMarker({ kind, status: marker.status, industry: marker.industry, tone: marker.tone, unassociated })
  293. // 组合数很小;再大也只是重算一遍,不会无限增长。
  294. if (overlayPlanMarkerCache.size > 512) overlayPlanMarkerCache.clear()
  295. overlayPlanMarkerCache.set(key, plan)
  296. }
  297. return plan
  298. }
  299. let deviceRendered = 0
  300. let deviceSkipped = 0
  301. // 票 45:当前图层的重叠聚合计划与交互状态(只持渲染状态,不写真实位置)。
  302. let overlapGroups = []
  303. let overlapMaxFan = 10
  304. let overlapPreviewGroup = ''
  305. let overlapExpandedGroup = ''
  306. let overlapState = OVERLAP_STATE.COLLAPSED
  307. let overlapCountValue = 0
  308. const mapFrameHandlers = new Set()
  309. /** 票 41:相机状态变化的订阅者(锚定跟随的第二条触发路径,见 `onStateChange` 处的注释)。 */
  310. const cameraStateHandlers = new Set()
  311. export function onMapFrame(handler) {
  312. if (typeof handler !== 'function') return () => {}
  313. mapFrameHandlers.add(handler)
  314. return () => mapFrameHandlers.delete(handler)
  315. }
  316. /** 订阅相机状态变化(返回退订函数)。与 `onMapFrame` 互补:后者只在真的渲染时触发。 */
  317. export function onCameraStateChange(handler) {
  318. if (typeof handler !== 'function') return () => {}
  319. cameraStateHandlers.add(handler)
  320. return () => cameraStateHandlers.delete(handler)
  321. }
  322. /** 通知一次相机状态变化(相机输入的 `onStateChange` 与程序化下发预设都走它)。 */
  323. function notifyCameraStateChange() {
  324. const state = instance && instance.cameraControl ? instance.cameraControl.getState() : null
  325. // 飞行落定后把"想要的"自转接上 —— 回引导态那条路靠它兑现(见 `spinWanted`)。
  326. resumeSpinIfWanted(state)
  327. for (const handler of cameraStateHandlers) handler(state)
  328. }
  329. export function getMapCanvasRect() {
  330. if (!instance || !instance.viewer) return null
  331. const rect = instance.viewer.canvas.getBoundingClientRect()
  332. return { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom }
  333. }
  334. export function hasMapMarker(kind, code, pointCode = '', key = '') {
  335. if (!instance || !instance.engine || !instance.viewer) return false
  336. if (kind === 'facility-point') return Boolean(facilityMarkerFor(code, key))
  337. return kind === DEVICE_LAYER_KIND
  338. ? instance.engine.hasDeviceMarker(instance.viewer, code, pointCode)
  339. : instance.engine.hasPointMarker(instance.viewer, kind, code)
  340. }
  341. export function getMapMarkerAnchor(kind, code, pointCode = '', key = '') {
  342. if (!instance || !instance.engine || !instance.viewer || (!code && !(kind === 'facility-point' && key))) return null
  343. if (kind === 'facility-point') {
  344. const marker = facilityMarkerFor(code, key)
  345. const at = marker && instance.projectMapPoint?.(marker.longitude, marker.latitude)
  346. if (!at) return null
  347. const plan = overlayFacilityPlanMarkerCached(marker)
  348. return { ...at, iconSize: Math.max(plan.draw.width, plan.draw.height), panelGap: 12 }
  349. }
  350. const anchor = kind === DEVICE_LAYER_KIND
  351. ? instance.engine.projectDeviceMarker(instance.viewer, code, pointCode)
  352. : instance.engine.projectPointMarker(instance.viewer, kind, code)
  353. if (!anchor) return anchor
  354. // 票 76(2026-10-26 走查补充①):面板让位的**确定性**规则。
  355. // 旧实现只在「展开或预览」时给 panelGap:60,且不区分态 —— 于是
  356. // ② 被选择态(pinned)有时让、有时不让(取决于 preview 是否恰好还在),走查表现为「偶尔做到」。
  357. // 现在按四态给固定让位量,并由 iconSize 反映标记实际外接尺寸,solvePointAnchor 据此算距离。
  358. const group = overlapGroups.find((item) => item.rows.some((row) => row.code === code))
  359. if (kind !== DEVICE_LAYER_KIND || !group) return anchor
  360. const isPinned = overlapExpandedGroup === group.key
  361. const isPreview = overlapPreviewGroup === group.key
  362. // 票 76(走查 2026-10-26):③ 设备常态也必须让位。
  363. // 用户原话:「面板让位在聚合点状态下工作正常;在设备点下依然只在 hover 时让位」——
  364. // 因为 ③ 的 `overlapExpandedGroup` 为空(不固定),旧判据直接返回原始 anchor。
  365. // 但 ③ 的中心已经是设备点(可能带特效环),面板仍然会压住它,所以同样要让。
  366. const isCenter = Boolean(selectedDeviceCode && code === selectedDeviceCode)
  367. if (!isPinned && !isPreview && !isCenter) return anchor
  368. // 让位量按「环/标记的实际占地」分档:固定态最大,预览次之,设备常态(环不固定但中心有特效)居中。
  369. if (isPinned) return { ...anchor, panelGap: 84, iconSize: 40 }
  370. if (isPreview) return { ...anchor, panelGap: 60, iconSize: 32 }
  371. return { ...anchor, panelGap: 64, iconSize: 34 }
  372. }
  373. let pointLocateToken = 0
  374. /** Invalidate a pending terrain sample before another camera command wins. */
  375. export function cancelPointLocate() {
  376. pointLocateToken += 1
  377. }
  378. /**
  379. * 定位一个可上图对象:相机中心落到**采样地表**,整段视角(或仅换中心)到位。
  380. *
  381. * 票 53 的语义收口(三种失败各有原因码,界面才说得出是哪一种):
  382. * · `MAP` —— 引擎/相机还没就绪(稍后重试即可);
  383. * · `COORDINATE` —— 对象不可上图或经纬度缺失;
  384. * · `TERRAIN` / `CAMERA` —— 地形采样取不到可靠高程,或该视角构造不出「相机高于地表」,
  385. * 由 `planTerrainLocateTarget` 纯函数守卫裁决(不再用椭球 0 或旧高度伪造成功)。
  386. * 迟到采样/实例被换走时作废,不覆盖新目标(`isStaleLocate` 的竞态纪律)。
  387. *
  388. * 票 101:抽成 `runTerrainLocate(共享管线)`,准入判据由调用方给 ——
  389. * 点位/设备只认后端 `renderable`(票 07),设施点按票 68 口径「有坐标即可上图」。
  390. */
  391. async function runTerrainLocate(object, { tier = 'point', centerOnly = false, onFailure = null, isCurrent = null } = {}, admitted) {
  392. const token = ++pointLocateToken
  393. const record = instance
  394. const fail = (reason) => {
  395. if (typeof onFailure === 'function') onFailure(reason)
  396. return false
  397. }
  398. if (!record || !record.cameraControl || !record.engine || !record.viewer) return fail(TERRAIN_LOCATE_REASON.MAP)
  399. if (!admitted(object)) {
  400. return fail(TERRAIN_LOCATE_REASON.COORDINATE)
  401. }
  402. let height = await record.engine.sampleTerrainHeight(record.viewer, object.longitude, object.latitude)
  403. // 在读取旧 Viewer 或提交相机副作用之前,确认原选择与实例仍然有效。
  404. if (isStaleLocate(token, pointLocateToken) || instance !== record) return fail(TERRAIN_LOCATE_REASON.MAP)
  405. if (isCurrent && !isCurrent()) return false
  406. if (!Number.isFinite(height)) height = record.engine.groundHeightAt(record.viewer, object.longitude, object.latitude)
  407. const view = planLocateView(getCockpitConfig().map, tier)
  408. // Terrain is required for point/device locating. Height 0 is never a success fallback.
  409. const targetPlan = planTerrainLocateTarget(object, height, view)
  410. if (!targetPlan.ok) return fail(targetPlan.reason)
  411. const applied = centerOnly
  412. ? setCameraFocus(targetPlan.target)
  413. : applyViewPreset(targetPlan.target, view)
  414. // 相机控件自己没吃下指令(与地形无关):归「地图未就绪」口径,别替用户猜成地形问题。
  415. if (applied !== true) return fail(TERRAIN_LOCATE_REASON.MAP)
  416. return applied
  417. }
  418. /**
  419. * 点位/设备定位:准入只认后端 `renderable`(票 07 的唯一判据,不由前端重算范围)。
  420. */
  421. export async function locateMonitoringPoint(object, options = {}) {
  422. return runTerrainLocate(object, options, (item) =>
  423. isRenderable(item) && Number.isFinite(item.longitude) && Number.isFinite(item.latitude))
  424. }
  425. /**
  426. * 票 101:设施点的定位跳转 —— 与点位/设备**同一条**地形感知飞行管线(同一份
  427. * 地形采样 / 视角计划 / 相机指令 / 竞态令牌),唯一差别是准入判据:
  428. * 设施坐标来自设施档案(票 68 的既定口径「有坐标即可上图」,档案坐标不经
  429. * 几何图层那套 `coordinateValidation`),所以这里只要求经纬度是有限数字。
  430. * @param {object} row 设施点标记行(`facilityPointMarkers()` 的输出,带 longitude/latitude)
  431. */
  432. export async function locateFacilityPoint(row, options = {}) {
  433. return runTerrainLocate(row, options, (item) =>
  434. Number.isFinite(item && item.longitude) && Number.isFinite(item.latitude))
  435. }
  436. /** 只有真元素才做 DOM / 引擎操作;单测传普通对象时整条引擎链路都不启动。 */
  437. function isDomHost(host) {
  438. return !!host && typeof host === 'object' && host.nodeType === 1 && typeof host.appendChild === 'function'
  439. }
  440. /** 开发态判定(天地图走仓库自带的 `/tianditu.map` 代理,生产直连)。 */
  441. function isDev() {
  442. return !!(import.meta.env && import.meta.env.DEV)
  443. }
  444. /** 把引擎状态写进宿主(dataset 给走查读,浮标给人读)。 */
  445. function paintEngineState(record) {
  446. if (!isDomHost(record.host)) return
  447. record.host.dataset.mapEngine = engineState
  448. record.host.dataset.mapBasemap = basemapState
  449. record.host.dataset.mapTerrain = terrainState
  450. record.host.dataset.mapTilesets = tilesetFailed.length ? 'invalid' : tilesetReady > 0 ? 'ready' : 'none'
  451. // 票 21:视图条件从启动起就可观测(默认 ground),不是只有切换过才有。
  452. record.host.dataset.mapViewMode = viewMode
  453. // 票 36:主场景区的**生效状态与原因**同样是启动就可观测的读数(不是只有配了才有):
  454. // `data-map-primary-area` = ready / missing / invalid,`-reason` 保留关闭原因。
  455. // 票 37/38 的验收与人工排查都读这两行,而不是去猜浮标中文。
  456. record.host.dataset.mapPrimaryArea = primaryAreaPlan.state
  457. record.host.dataset.mapPrimaryAreaReason = primaryAreaPlan.enabled ? '' : primaryAreaPlan.reason
  458. // 票 32:形态在创建时定;`data-ui-mode` 让走查/排查一眼看出当前是哪一种。
  459. // 注意**要能改**:浮标在 `booting` 就建出来了,那时配置还没加载完(拿不到 map.ui.debug),
  460. // 所以配置到手后必须按当前形态重刷一次样式 —— 否则生产形态永远退回 dev 的样子。
  461. const debug = isUiDebug()
  462. const mode = debug ? 'debug' : 'production'
  463. if (!record.statusNode) {
  464. const node = record.host.ownerDocument.createElement('div')
  465. node.className = 'map-engine-status'
  466. node.setAttribute('data-map-engine-status', '')
  467. node.style.cssText = debug ? STATUS_STYLE : STATUS_STYLE_QUIET
  468. node.setAttribute('data-ui-mode', mode)
  469. record.host.appendChild(node)
  470. record.statusNode = node
  471. } else if (record.statusNode.dataset.uiMode !== mode) {
  472. record.statusNode.style.cssText = debug ? STATUS_STYLE : STATUS_STYLE_QUIET
  473. record.statusNode.dataset.uiMode = mode
  474. }
  475. const failed = engineState === 'failed'
  476. // 票 36:主场景区排在最前。它是**全局作用域**(限位与自建雾),不是某一图层的属性;
  477. // 更要紧的是这根浮标是 `nowrap + ellipsis`(`max-width: min(78%, 620px)`),
  478. // 关闭路径那句比底图那句长得多 —— 排在后面就会被尾省略掉,
  479. // 而「限位与雾气关了没、为什么」恰恰是这一行最不能丢的信息。
  480. //
  481. // 但**配置读到之前不许写这一句**:那时计划还是初值「未配置」,
  482. // 写在浮标上等于替部署宣布了一个还没核实的结论(`primaryAreaResolved` 守住这条)。
  483. const parts = primaryAreaResolved ? [primaryAreaText, basemapText] : [basemapText]
  484. if (terrainState === TERRAIN_STATE.READY) parts.push(terrainText)
  485. if (terrainState === TERRAIN_STATE.INVALID) parts.push(`地形加载失败(${terrainError})· 回退椭球`)
  486. if (overlayCount > 0) parts.push(`叠加影像 ${overlayCount} 层`)
  487. if (tilesetReady > 0) parts.push(`3D Tiles ${tilesetReady} 个`)
  488. if (tilesetFailed.length) parts.push(`3D Tiles 加载失败(${tilesetFailed[0].error})`)
  489. // 引擎没就绪时(booting / failed / disabled / unavailable)原本只说引擎自己的话,
  490. // 于是「关了引擎的部署」在主场景区这件事上彻底失声 —— 而它正是最需要看到
  491. // 「限位与雾气关了」的那种部署。所以配置到手后,这条读数在**任何引擎状态下**都跟着说。
  492. const base = engineState === 'ready' ? `Cesium 引擎已就绪 · ${parts.join(' · ')}` : ENGINE_TEXT[engineState] || engineState
  493. record.statusNode.textContent = `${base}${primaryAreaResolved && engineState !== 'ready' ? ` · ${primaryAreaText}` : ''}${engineError ? `(${engineError})` : ''}`
  494. // 生产形态没有底色,靠字色传达「正常 / 故障」——颜色语义在任何形态下都不能丢。
  495. if (isUiDebug()) {
  496. record.statusNode.style.borderColor = failed ? 'rgba(255,59,82,.55)' : 'rgba(1,171,234,.42)'
  497. record.statusNode.style.color = failed ? '#ffc2c9' : '#bad4e0'
  498. } else {
  499. record.statusNode.style.color = failed ? 'rgba(255,139,150,.8)' : 'rgba(186,212,224,.5)'
  500. }
  501. }
  502. function setEngineState(record, state, error = '') {
  503. engineState = state
  504. engineError = error
  505. paintEngineState(record)
  506. }
  507. /** 相机状态写进宿主 dataset:走查、演示与人工排查都读这一行(票 03 要求交互可重复演示)。 */
  508. function paintCameraState(record, state) {
  509. if (!isDomHost(record.host)) return
  510. record.host.dataset.camera = formatCameraState(state)
  511. }
  512. /**
  513. * 在给定窗口上采一张地形高程网格(票 34 的建模用途):
  514. * 轻量建模要把体块坐在地形上,而模型是静态几何 —— 所以先在本地 DEM 上打网格,
  515. * 生成时按双线性插值给每栋楼定基准高程(不然县城地面一百多米,整片楼会被埋进山里)。
  516. *
  517. * @param {number} lon 中心经度
  518. * @param {number} lat 中心纬度
  519. * @param {number} dLon 经度半宽(度)
  520. * @param {number} dLat 纬度半宽(度)
  521. * @param {number} n 每边采样点数
  522. * @returns {Promise<Array<number|null>>} 行优先(先纬后经)的高程数组,单位米
  523. */
  524. export async function sampleTerrainGrid(lon, lat, dLon, dLat, n) {
  525. if (!instance || !instance.viewer || !instance.engine) return null
  526. const count = Math.max(2, Math.min(64, Math.floor(n)))
  527. const points = []
  528. for (let row = 0; row < count; row++) {
  529. for (let col = 0; col < count; col++) {
  530. points.push({
  531. lon: lon - dLon + (2 * dLon * col) / (count - 1),
  532. lat: lat - dLat + (2 * dLat * row) / (count - 1),
  533. })
  534. }
  535. }
  536. return instance.engine.sampleTerrainHeights(instance.viewer, points)
  537. }
  538. /**
  539. * 开发态把手:三类交互与「选中换中心」都能在控制台/走查里复现,不必点着鼠标盲试。
  540. * 只在 DEV 且浏览器里挂,离开 /cockpit 即摘掉;生产构建不含它。
  541. */
  542. /**
  543. * 走查专用:`?frame=lon,lat,range[,pitch]` —— 引擎就绪后按精确距离落位。
  544. *
  545. * 五档 LOD 的可见差异只在特定距离带里成立(preview 600-1600m / medium 250-600m / fine <250m),
  546. * 靠滚轮凑距离既不可复现也无法做对比证据。只跑一次,且只在 DEV 生效。
  547. */
  548. /** 解析 `?frame=lon,lat,range[,pitch]`(只解析一次,结果缓存)。 */
  549. let frameParamSpec = null
  550. let frameParamParsed = false
  551. function readFrameParam() {
  552. if (frameParamParsed) return frameParamSpec
  553. frameParamParsed = true
  554. if (typeof window === 'undefined' || !window.location) return null
  555. const search = String(window.location.search || '')
  556. const hashQuery = String(window.location.hash || '').split('?')[1] || ''
  557. const raw = new URLSearchParams(search).get('frame')
  558. || new URLSearchParams(hashQuery).get('frame')
  559. || ''
  560. const parts = raw.split(',').map(Number)
  561. if (parts.length < 3 || !parts.slice(0, 3).every(Number.isFinite)) return null
  562. frameParamSpec = { lon: parts[0], lat: parts[1], range: parts[2], pitch: Number.isFinite(parts[3]) ? parts[3] : -35 }
  563. return frameParamSpec
  564. }
  565. /**
  566. * 走查专用:按精确距离落位(票 59 第二十五轮实机验收)。
  567. *
  568. * 五档 LOD 的可见差异只在特定距离带成立(preview 600-1600m / medium 250-600m / fine <250m),
  569. * 滚轮凑距离不可复现、也做不出可对比的证据。**每次 publishDevHook 都尝试**(幂等):
  570. * 引擎可能晚于首次调用就绪,成功一次后置位。仅 DEV。
  571. */
  572. let frameParamApplied = false
  573. // 落位之后**再补几次**:应用启动阶段还会 goHome / 进系统飞行把相机推回全球,
  574. // 只落位一次会被覆盖(实机走查踩到:截图仍是全球视角)。
  575. let frameParamPending = 0
  576. function applyFrameParamOnce() {
  577. const spec = readFrameParam()
  578. if (!spec) { frameParamApplied = true; return }
  579. if (!instance || !instance.cameraControl) return
  580. if (frameParamApplied && frameParamPending <= 0) return
  581. const ok = instance.cameraControl.applyViewPreset(
  582. { lon: spec.lon, lat: spec.lat, height: 0 },
  583. { range: spec.range, pitch: spec.pitch, heading: 0 },
  584. { immediate: true },
  585. )
  586. if (ok) {
  587. frameParamApplied = true
  588. // 再来 4 次(覆盖启动期的 home / 飞行落位),之后彻底停手。
  589. if (frameParamPending <= 0) frameParamPending = 4
  590. else frameParamPending -= 1
  591. }
  592. }
  593. function publishDevHook() {
  594. const env = import.meta.env
  595. if (!env || !env.DEV || typeof window === 'undefined') return
  596. // 走查专用:`?frame=lon,lat,range[,pitch]` 直接落位到指定距离(票 59 第二十五轮实机验收)。
  597. // 五档 LOD 的差异只在特定距离带看得出来,滚轮凑距离不可复现。仅 DEV,不参与正式路径。
  598. // eslint-disable-next-line no-use-before-define
  599. applyFrameParamOnce()
  600. window.__cockpitMap = {
  601. getCameraState,
  602. setCameraFocus,
  603. clearCameraFocus,
  604. /**
  605. * 走查专用:把相机按**精确距离**放到某点上方(票 59 第二十五轮实机验收用)。
  606. *
  607. * 为什么需要它:五档 LOD 的差异只在**特定距离带**里才看得出来(preview 600–1600m、
  608. * medium 250–600m、fine <250m)。靠滚轮凑距离不可复现,必须能精确指定 range。
  609. * 只在 DEV 存在(与其他走查把手同一纪律),不参与正式路径。
  610. *
  611. * @param {{lon:number, lat:number, range:number, pitch?:number, heading?:number}} spec
  612. */
  613. frameAt: (spec) => {
  614. if (!instance || !instance.cameraControl || !spec) return false
  615. const pivot = { lon: Number(spec.lon), lat: Number(spec.lat), height: Number(spec.height) || 0 }
  616. const hpr = {
  617. range: Number(spec.range),
  618. pitch: Number.isFinite(spec.pitch) ? spec.pitch : -35,
  619. heading: Number.isFinite(spec.heading) ? spec.heading : 0,
  620. }
  621. return instance.cameraControl.applyViewPreset(pivot, hpr, { immediate: true })
  622. },
  623. // 手感是主观的:DEV 下现场拖调阻尼时间常数(毫秒,0 = 不做阻尼)。
  624. setCameraDamping: (tauMs) => (instance && instance.cameraControl ? instance.cameraControl.setDampingTau(tauMs) : null),
  625. /** 「返回全球」飞行时长(毫秒);退出快慢是主观的,做成可现场拖调(见 `RETURN_FLIGHT_MS`)。 */
  626. setReturnFlightMs: (ms) => (instance && instance.cameraControl ? instance.cameraControl.setReturnFlightMs(ms) : null),
  627. // 票 30:视角预设 / 归位 / 星球自转,走查与人工排查都用这几个入口。
  628. goHome: (options) => goHome(options),
  629. getHomeView: () => getHomeView(),
  630. isAtHome: () => atHome(),
  631. setSpin: (seconds) => setGlobeSpin(seconds),
  632. // 票 21:地面/管线视图切换(走查断言"切换不重置中心"用)。
  633. setViewMode: (mode, overrides) => setViewMode(mode, overrides),
  634. getViewMode: () => getViewMode(),
  635. // 票 76(C6):A/B 显隐把手已退役(?overlay=0 与 set/toggle 那组 DEV 把手一并摘除),
  636. // 只留只读读数给走查确认 overlay 在位。
  637. getMarkerOverlay: () => isMarkerOverlayVisible(),
  638. // 票 83:读 `data-*` 读数前的**可用性自检入口**(overlay 未挂载/不可见时读数无效,必须判红不判绿)。
  639. isMarkerOverlayMounted: () => isMarkerOverlayMounted(),
  640. isMarkerOverlayVisible: () => isMarkerOverlayVisible(),
  641. // 票 86:六个 DEV/验收旋钮(点位档/设备档/环峰值/选中/alpha 下限/可见带占比)。
  642. // **只作用于渲染**,不改业务;默认值 = 单源声明,正式路径不会因为旋钮存在而改变。
  643. // 改完清几何缓存并重建环样式(关键帧与带型都按新参数重生成)。
  644. getMarkerDevKnobs: () => markerDevKnobs(),
  645. setMarkerDevKnobs: (partial) => {
  646. const next = setMarkerDevKnobs(partial)
  647. overlayPlanMarkerCache.clear()
  648. refreshMarkerFx()
  649. return next
  650. },
  651. resetMarkerDevKnobs: () => {
  652. const next = resetMarkerDevKnobs()
  653. overlayPlanMarkerCache.clear()
  654. refreshMarkerFx()
  655. return next
  656. },
  657. // 票 86:场景 bloom 画质开关(**默认关闭**;告警可读性不依赖它,纯观感对比用)。
  658. setSceneBloom: (enabled) => (instance && instance.engine ? instance.engine.setSceneBloom(instance.viewer, enabled === true) : false),
  659. getSceneBloom: () => (instance && instance.engine ? instance.engine.getSceneBloom() : false),
  660. // 票 36:主场景区(走查与人工排查读"配没配、矩形是哪一个",票 37/38 也读这一个出口)。
  661. getPrimaryAreaPlan: () => getPrimaryAreaPlan(),
  662. // 票 37:进区锁定与区域限位(走查驱动「进入 → 锁定 → 拉远被夹 → 返回全球」全过程)。
  663. getAreaState: () => getAreaState(),
  664. getAreaLimits: () => getAreaLimits(),
  665. enterArea: () => setAreaState('enter'),
  666. returnGlobal: () => setAreaState('returnGlobal'),
  667. /**
  668. * 强制渲染一帧。诊断用,也是**隐藏标签页**下取像素证据的唯一办法:
  669. * 浏览器在标签页不可见时冻结 rAF,Cesium 的默认渲染循环随之停摆,
  670. * 截图会拿到旧帧(实测相隔 3 秒的两张截图在地图区域逐字节相同)。
  671. */
  672. forceRender: () => {
  673. if (!instance || !instance.viewer) return false
  674. const viewer = instance.viewer
  675. let did = false
  676. // `scene.render()` 才是驱动 postProcessStages.update() 的那一步
  677. // (处理级的 `ready` 与 uniformMap 都在其中建立)。
  678. try {
  679. if (viewer.scene && typeof viewer.scene.render === 'function') {
  680. viewer.scene.render()
  681. did = true
  682. }
  683. } catch (error) {
  684. did = false
  685. }
  686. if (typeof viewer.render === 'function') {
  687. try {
  688. viewer.render()
  689. did = true
  690. } catch (error) {
  691. // 忽略:scene.render 已经跑过就够了
  692. }
  693. }
  694. return did
  695. },
  696. // 票 38:自建雾(走查与人工排查读"支持/可执行/强度/降级原因",并驱动降级与性能采样)。
  697. getFogState: () => getFogState(),
  698. getFogParams: () => getFogParams(),
  699. setFogTranslucentArtifacts: (observed) => setFogTranslucentArtifacts(observed),
  700. setFogUserDisabled: (disabled) => setFogUserDisabled(disabled),
  701. startFogPerfSample: (label) => startFogPerfSample(label),
  702. stopFogPerfSample: () => stopFogPerfSample(),
  703. // 票 39:精细影像交接(读数含"当前阶段 / alpha / 被什么挡住 / 边缘遮蔽率 / 瓦片信号性质")。
  704. getHandoverState: () => getHandoverState(),
  705. getHandoverParams: () => getHandoverParams(),
  706. /** 原型接管开关:原型挂载时让生产交接交出 alpha 写入权(票 39 = 隔离,不删除)。 */
  707. setHandoverPrototypeActive: (active) => setHandoverPrototypeActive(active),
  708. /** 立刻推一次交接(走查里不必等渲染帧;隐藏标签页下 rAF 会被冻结)。 */
  709. advanceHandover: () => advanceHandover(),
  710. /**
  711. * 把相机**直接放到指定椭球高度**(DEV 诊断用)。
  712. *
  713. * 为什么需要它:验证"雾随高度收口"必须能在**保持区内锁定态**的同时把相机送上天,
  714. * 而 `goHome` 只按 `map.home` 的预设走(改不了 range),`returnGlobal` 又会改区域状态。
  715. * 有了它才能精确复现"退出途中相机已在高空、而雾状态机仍是 locked"的那一刻。
  716. */
  717. setCameraAltitudeForTest: (altitudeM) => {
  718. if (!instance || !instance.engine || !instance.viewer) return null
  719. // Cesium 的 `Cartesian3` 只允许出现在引擎层(本仓唯一 `import 'cesium'` 的地方),
  720. // 所以"放到指定高度"这件事交给引擎做,服务层只转调。
  721. const applied = instance.engine.setCameraAltitude(instance.viewer, altitudeM)
  722. if (applied !== null) paintFog()
  723. return applied
  724. },
  725. /**
  726. * 场景侧诊断(排查"画面偏色 / 整屏发白"这类问题)。
  727. *
  728. * 为什么要有它:雾读数与交接读数只描述**我们自己的状态**,
  729. * 而"画面为什么是这个颜色"取决于**场景里还有谁在染色** ——
  730. * Cesium 内置雾、`globe.baseColor`、图层 alpha、大气。只看自己的读数会漏。
  731. */
  732. getSceneDiagnostics: () => {
  733. if (!instance || !instance.viewer || !instance.viewer.scene) return null
  734. const scene = instance.viewer.scene
  735. const layers = []
  736. for (let i = 0; i < scene.imageryLayers.length; i++) {
  737. const layer = scene.imageryLayers.get(i)
  738. layers.push({
  739. alpha: Number(layer.alpha),
  740. show: !!layer.show,
  741. hasRectangle: !!(layer.imageryProvider && layer.imageryProvider.rectangle),
  742. })
  743. }
  744. return {
  745. // Cesium 内置雾:**生产从没关过它**(原型当时显式关掉过),
  746. // 它会按 `groundAtmosphereColor` 给地形叠一层冷色调 —— 排查画面偏色时必须看它。
  747. builtinFog: {
  748. enabled: !!scene.fog.enabled,
  749. renderable: !!scene.fog.renderable,
  750. density: scene.fog.density,
  751. visualDensityScalar: scene.fog.visualDensityScalar,
  752. },
  753. globeBaseColor: scene.globe.baseColor ? scene.globe.baseColor.toCssHexString() : null,
  754. globeTranslucency: scene.globe.translucency
  755. ? { enabled: !!scene.globe.translucency.enabled, frontFaceAlpha: scene.globe.translucency.frontFaceAlpha }
  756. : null,
  757. imageryLayers: layers,
  758. cameraHeightM: cameraAltitude(),
  759. }
  760. },
  761. // 票 34:地形高程网格采样(轻量建模把体块坐到地形上用),只在 DEV 挂。
  762. sampleTerrainGrid: (lon, lat, dLon, dLat, n) => sampleTerrainGrid(lon, lat, dLon, dLat, n),
  763. // 票 34:3D Tiles 诊断(排查"根瓦片有没有被访问 / 内容为何没请求")。
  764. getTilesetDiagnostics: () =>
  765. instance && instance.engine ? instance.engine.readTilesetDiagnostics(instance.tilesetInstances || []) : [],
  766. getTilesetRegions: () => instance?.tilesetCulling?.regionSnapshot?.() || [],
  767. /** 票 61:细模覆盖前沿(覆盖半径 / 在动或静止 / 是否吃满预算)。 */
  768. getDetailFrontier: () => instance?.tilesetCulling?.frontierSnapshot?.() || [],
  769. getTilesetLoading: () => instance?.tilesetCulling?.snapshot() || [],
  770. getLifecycleStats,
  771. projectCameraPivot: () => (instance && instance.cameraControl ? instance.cameraControl.projectPivot() : null),
  772. // 票 08:地图摘要的渐进加载(人工演示与走查驱动「取下一页 / 刷新换快照」)。
  773. // 状态归 stores/mapStore.js 的装载器,这里只是把它接到同一个 DEV 把手上。
  774. mapSummary: devSummaryHandle,
  775. // 票 09:点位标记与图例(人工演示「刷新后点位换色」也走这里)。
  776. getPointMarkers: () => getPointMarkerState(),
  777. clearPointMarkers: () => clearPointMarkers(),
  778. // 票 42:关联设备临时图层(走查读独立读数,验证它不进摘要口径)。
  779. nudgeOverlap: (pointCode) => nudgeOverlap(pointCode),
  780. clearOverlapNudge: () => clearOverlapNudge(),
  781. // 票 76(C6):坐标 → **DOM 节点身份**(走查仍按屏幕坐标驱动同一条链路;不再走 Cesium 拾取)。
  782. pickDeviceMarker: (x, y) => domMarkerAt(x, y),
  783. // 票 45:重叠聚合的只读状态与交互入口(聚合符号不写设备选中态)。
  784. getOverlapState: () => getOverlapState(),
  785. getOverlapTargets: () => getOverlapTargets(),
  786. // 票 45:只读回点位标记的窗口坐标(真实鼠标走查要点的位置;不触发任何拾取)。
  787. getPointMarkerTargets: () => (instance && instance.engine && instance.viewer && instance.engine.readPointMarkerTargets
  788. ? instance.engine.readPointMarkerTargets(instance.viewer)
  789. : []),
  790. toggleOverlap: (pointCode) => toggleOverlap(pointCode),
  791. collapseOverlap: (pointCode) => collapseOverlap(pointCode),
  792. // 票 42:关联监测设备临时图层(走查读独立读数,验证它不进摘要口径)。
  793. getAssociatedDevices: () => getAssociatedDeviceState(),
  794. // 票 47:设备状态动效的只读读数(计划 + 逐点当前帧属性)。
  795. getDeviceEffects: () => getDeviceEffectState(),
  796. clearAssociatedDevices: () => clearAssociatedDevices(),
  797. // 票 43:设备点的对应反应(走查驱动 hover/选中并读三个可断言字段)。
  798. //
  799. // **选中/hover 的写入不从这里走**:它们必须经 store(见外壳的
  800. // `publishDeviceSelectionHandle`),否则同一个"当前选中设备"会有两套真相
  801. // (服务层模块变量 + store 的响应式状态)—— 本票实现时真机走查抓到过这一幕:
  802. // 直接调服务层后 store 仍读空串,界面与引擎对"选中了谁"给出两个答案。
  803. getDeviceCorrespondence: () => getDeviceCorrespondenceState(),
  804. // 三维 hover 拾取必须碰引擎,所以留在服务层(它只回身份,不写选中态)。
  805. hoverDeviceAt: (x, y) => hoverDeviceAt(x, y),
  806. pickDeviceOrPoint: (x, y) => pickDeviceOrPointAt(x, y),
  807. applyDeviceReactions: (reactions) => {
  808. if (!instance || !instance.engine || !instance.viewer) return 0
  809. return instance.engine.applyDeviceReactions(instance.viewer, reactions, reactionStyle)
  810. },
  811. // 选中/hover 的**用户路径**(由外壳递进来的 store 方法;未挂载时为 undefined)。
  812. selectDevice: (code) => (devDeviceHandles && devDeviceHandles.selectDevice ? devDeviceHandles.selectDevice(code) : ''),
  813. hoverDevice: (code) => (devDeviceHandles && devDeviceHandles.hoverDevice ? devDeviceHandles.hoverDevice(code) : ''),
  814. backToPointLevel: () => (devDeviceHandles && devDeviceHandles.backToPointLevel ? devDeviceHandles.backToPointLevel() : ''),
  815. // 票 10:点位点选(人工演示与走查用屏幕坐标驱动同一条链路)。
  816. pickPoint: (x, y) => pickPointAt(x, y),
  817. getPicked: () => getPickedPoint(),
  818. // 票 41:统一选择入口由外壳挂载时递进来(`publishPointSelectionHandle`)。
  819. // 走的是与地图点选/台账卡片**同一个函数** —— DEV 把手不该另造第三条定位路径。
  820. selectMonitoringPoint: devSelectHandle,
  821. }
  822. }
  823. /** 票 08:地图摘要装载器的 DEV 把手(由 MapCanvas 挂载时递进来,离开时清掉)。 */
  824. let devSummaryHandle = null
  825. /** 票 41:统一选择入口的 DEV 把手(由外壳挂载时递进来;mapService 不能 import 它,否则循环依赖)。 */
  826. let devSelectHandle = null
  827. export function publishPointSelectionHandle(handle) {
  828. devSelectHandle = typeof handle === 'function' ? handle : null
  829. publishDevHook()
  830. }
  831. /**
  832. * 票 43:设备**选中/hover** 的 DEV 把手(由外壳递进来,与票 41 同一成例)。
  833. *
  834. * 为什么不让 mapService 直接暴露 `setSelectedDevice`:那样会出现两套选中真相
  835. * (服务层模块变量 vs store 的响应式状态)。走查必须能驱动"用户那条路径",
  836. * 而用户路径就是 store 的方法。
  837. */
  838. let devDeviceHandles = null
  839. export function publishDeviceSelectionHandle(handles) {
  840. devDeviceHandles = handles && typeof handles === 'object' ? handles : null
  841. publishDevHook()
  842. }
  843. /**
  844. * 接上地图摘要把手:`{ advance, refresh, getState }`(`stores/mapStore.js` 的入口)。
  845. * 传 null 表示摘掉(离开 /cockpit)。
  846. */
  847. export function publishSummaryHandle(handle) {
  848. devSummaryHandle = handle || null
  849. publishDevHook()
  850. if (handle) return
  851. if (typeof window !== 'undefined' && window.__cockpitMap) window.__cockpitMap.mapSummary = null
  852. }
  853. function clearDevHook() {
  854. if (typeof window !== 'undefined' && window.__cockpitMap) {
  855. delete window.__cockpitMap
  856. }
  857. }
  858. /**
  859. * 异步加载引擎。全部逻辑都在 try 内,避免出现未处理的 Promise 拒绝(那会算页面级报错)。
  860. * @param {object} record mountMap 建立的实例记录
  861. */
  862. async function bootEngine(record) {
  863. try {
  864. setEngineState(record, 'booting')
  865. // 票 04:引擎开关与静态资源基址都从配置服务读(默认开、默认按应用基址取 cesium/)。
  866. const configState = await loadCockpitConfig()
  867. if (instance !== record) return
  868. const mapConfig = (configState.config && configState.config.map) || {}
  869. // 票 36:主场景区先算 —— 它是**配置事实**,与引擎开不开、起没起来都无关。
  870. // 刻意放在「引擎已由配置关闭」这条早退之前:关了引擎的部署,主场景区的读数仍要如实。
  871. applyPrimaryArea(mapConfig)
  872. const cesiumConfig = (mapConfig && mapConfig.cesium) || {}
  873. if (cesiumConfig.enabled === false) {
  874. setEngineState(record, 'disabled')
  875. return
  876. }
  877. ensureCesiumBaseUrl(cesiumConfig.baseUrl || '')
  878. // 票 29:底图由配置计划决定;未配置 / 配置无效一律不挂底图(票 27:不伪造配准)。
  879. const basemapPlan = planBasemap(mapConfig, { dev: isDev() })
  880. basemapState = basemapPlan.state
  881. basemapText = basemapStatusText(basemapPlan)
  882. // 票 21:地形(本地/甲方 DEM)、叠加影像(本地精细切片)与 3D Tiles/倾斜摄影。
  883. const terrainPlan = planTerrain(mapConfig)
  884. const overlayPlans = planOverlays(mapConfig)
  885. const tilesetPlans = planTilesets(mapConfig)
  886. // 票 25:署名与图层取自**同一批计划**(不另开一份真相);没有 credit 的图层不硬造一条。
  887. dataSourceCredits = [
  888. basemapPlan.credit,
  889. ...overlayPlans.map((overlay) => overlay.credit),
  890. ...tilesetPlans.map((plan) => plan.credit),
  891. ].filter((credit) => typeof credit === 'string' && credit.trim())
  892. .map((credit) => credit.trim())
  893. terrainState = terrainPlan.ready ? TERRAIN_STATE.READY : TERRAIN_STATE.NONE
  894. terrainText = terrainStatusText(terrainPlan)
  895. terrainError = ''
  896. overlayCount = 0
  897. tilesetReady = 0
  898. tilesetFailed = []
  899. const engine = await import('./cesiumEngine.js')
  900. // 加载期间已经离开 /cockpit:不留下实例。
  901. if (instance !== record) return
  902. const viewer = engine.createViewer(record.host, basemapPlan)
  903. // 票 35:渲染开销档(msaa/分辨率/大气/地形 SSE/并发…)。
  904. // 票 59 修正:`map.performance` 过去被归一化白名单静默丢弃,配置写了也不生效;
  905. // 现在它真的会落地 —— 未配置即代码默认档(已降载),配置了可逐项覆盖/回退。
  906. if (typeof engine.applyPerformanceOptions === 'function') {
  907. engine.applyPerformanceOptions(viewer, (mapConfig && mapConfig.performance) || {})
  908. }
  909. // 票 61:细模覆盖档(覆盖到多远、驻留预算)由 map.detail 给;未配置即默认档。
  910. if (typeof engine.applyDetailOptions === 'function') {
  911. engine.applyDetailOptions((mapConfig && mapConfig.detail) || {})
  912. }
  913. record.engine = engine
  914. record.viewer = viewer
  915. // 建 Viewer 是同步的,但状态可能在这一刻被销毁过 —— 补一次销毁。
  916. if (instance !== record) {
  917. engine.destroyViewer(viewer)
  918. return
  919. }
  920. // 叠加影像(本地精细切片)与地形:地形是异步拉 layer.json,失败只回退椭球不影响外壳。
  921. // 票 39:引擎返回**句柄**(不再只是层数)—— 交接要驱动它们的 alpha。
  922. const overlayHandleList = engine.applyOverlays(viewer, overlayPlans)
  923. overlayCount = overlayHandleList.length
  924. record.overlayHandleList = overlayHandleList
  925. if (terrainPlan.ready) {
  926. try {
  927. await engine.applyTerrain(viewer, terrainPlan)
  928. } catch (terrainFailure) {
  929. if (instance !== record) return
  930. terrainState = TERRAIN_STATE.INVALID
  931. terrainError = String((terrainFailure && terrainFailure.message) || terrainFailure)
  932. }
  933. }
  934. if (instance !== record) return
  935. // 3D Tiles / 倾斜摄影:逐项挂,单项失败只记状态(入口已留好,数据缺失时不白屏)。
  936. if (tilesetPlans.length) {
  937. const tilesets = await engine.applyTilesets(viewer.scene, tilesetPlans, (state) => {
  938. if (instance !== record) return
  939. tilesetReady = state.ready
  940. tilesetFailed = state.failed
  941. record.tilesetInstances = state.instances
  942. engine.applyViewModeLayers(viewer, state.instances, planViewMode(viewMode))
  943. paintEngineState(record)
  944. }, (controller) => { record.tilesetCulling = controller; controller?.setPipeline?.(viewMode === VIEW_MODES.PIPELINE) })
  945. if (instance !== record) {
  946. tilesets.culling?.detach()
  947. engine.destroyViewer(viewer)
  948. return
  949. }
  950. tilesetReady = tilesets.ready
  951. tilesetFailed = tilesets.failed
  952. // 票 21:留住实例,视图条件(管线视图压半透明)要按它改样式。
  953. record.tilesetInstances = tilesets.instances
  954. record.tilesetCulling = tilesets.culling
  955. }
  956. // 票 30:地形就绪后把「家」视角的高程换成实测地形高度 —— 否则相机按椭球 0 米定位,
  957. // 一拉近就落进山体(实测 range 120m 全屏黑)。采样失败保持 0,等价于旧行为。
  958. try {
  959. const homePlan = planHome(mapConfig)
  960. const sampled = await engine.sampleTerrainHeight(viewer, homePlan.lon, homePlan.lat)
  961. if (instance !== record) return
  962. if (Number.isFinite(sampled)) record.homeHeight = sampled
  963. } catch {
  964. // 采样失败不影响启动:保持椭球高度。
  965. }
  966. if (instance !== record) {
  967. engine.destroyViewer(viewer)
  968. return
  969. }
  970. record.engine = engine
  971. record.viewer = viewer
  972. record.detachMapFrame = viewer.scene.postRender.addEventListener(() => {
  973. for (const handler of mapFrameHandlers) handler()
  974. // 票 61 第十九轮:记帧间隔 + 自适应分辨率(低速机器上按实测帧间隔在 0.55~基准档 间微调,
  975. // 撑不住才降、撑得住就回升 —— 画质只在需要时让位)。
  976. try {
  977. const nowMs = typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now()
  978. // 只有**相机在动**(连续 rAF 节奏)的帧间隔才代表帧预算;静止期的帧是按需渲染。
  979. // 真机走查实测:不区分时 p95 被按需空档抬到 399ms,自适应分辨率永久钉在下限 0.55。
  980. const movingNow = record.cameraControl?.getState?.()?.settled === false
  981. if (record.lastFrameAt) engine.noteFrameInterval(nowMs - record.lastFrameAt, { moving: movingNow, viewer })
  982. record.lastFrameAt = nowMs
  983. engine.tickAdaptiveResolution(viewer)
  984. } catch { /* 自适应失败不影响渲染 */ }
  985. // 票 39:瓦片就绪是 getter(读它不便宜),且**只有真的渲染过**才知道队列空没空,
  986. // 所以放在 postRender 里刷;交接的推进也借此在"有帧"时才有机会前进。
  987. if (record !== instance) return
  988. refreshOverlayReadiness()
  989. // 票 38 修复(维护者 2026-09-29 报):相机高度一变就要重画雾。
  990. // 退出时状态机可能已经落定(不再逐帧推进),但相机还在飞 ——
  991. // 不在这里补画,高度收口就不会生效,退出仍会「整个地球变白」。
  992. //
  993. // 顺序有意:**先推进限速**(它按真实 dt 收敛,一帧只能走一步),
  994. // 再比较"系数变了吗、要不要重画"。反过来的话读数会用旧系数。
  995. if (fogHandle) {
  996. stepAltitudeSlew()
  997. // 票 35 卡顿整改:高度收口每帧都在变,但 `paintFog` 会写一批 dataset 属性
  998. // (可能命中样式选择器)。进出过渡期仍逐帧画(强度要平滑),稳态按 ~10Hz 落笔。
  999. if (fogAltitudeShown !== fogPaintedAltitudeFactor) {
  1000. const transitioning = fogLifecycle.phase === FOG_PHASE.ENTERING || fogLifecycle.phase === FOG_PHASE.EXITING
  1001. const now = engineNow()
  1002. if (transitioning || now - fogAltitudePaintAt >= 100) {
  1003. fogAltitudePaintAt = now
  1004. paintFog()
  1005. }
  1006. }
  1007. }
  1008. advanceHandover()
  1009. // 票 35 第五轮:镜头落定后把排队中的场景落地(摘要/设备图层)补上。
  1010. // 放在渲染帧里执行,天然与"正在渲染的那一帧"错峰,不会卡住正在飞的镜头。
  1011. if (hasDeferredSceneApply() && !cameraInMotion()) flushSceneApply()
  1012. })
  1013. // 加载期间的尺寸变化不能丢,补一次 resize。
  1014. if (record.resizePending) {
  1015. record.resizePending = false
  1016. engine.resizeViewer(viewer)
  1017. }
  1018. // 票 03:相机契约落在服务层 —— 引擎就绪后才挂,默认输入已在 createViewer 里全关。
  1019. const cameraEngine = engine.createCameraEngine(viewer)
  1020. // 设施锚点和 DOM 标记同帧共用地形投影,不读上一帧节点位置。
  1021. record.projectMapPoint = (lon, lat) => {
  1022. const ground = engine.groundHeightAt(viewer, lon, lat)
  1023. return cameraEngine.projectPivot({ lon, lat, height: Number.isFinite(ground) ? ground : 0 }, { visibleOnly: true })
  1024. }
  1025. record.cameraControl = createCameraControl({
  1026. engine: cameraEngine,
  1027. onStateChange: (state) => {
  1028. paintCameraState(record, state)
  1029. // 飞行落定的那一帧走这里(`render()` → `notify()` → 本回调)——
  1030. // 必须在这里把"想要的自转"接上,否则回引导态后地球永远不再转
  1031. // (`notifyCameraStateChange` 只在下发预设时被调,不在逐帧路径上)。
  1032. resumeSpinIfWanted(state)
  1033. // 票 41:相机一变化就让锚定跟随(订阅方按需重算落位)。
  1034. //
  1035. // 为什么不能只靠 `scene.postRender`:渲染帧在**隐藏标签页**里不触发(rAF 被冻结,
  1036. // 票 37 已记录同一环境事实),于是锚定会停在旧位置;相机状态变化是**由输入/飞行驱动**的,
  1037. // 与是否正在渲染无关,是更可靠的触发源。两条路径都留着:渲染帧管逐帧跟随,这里管"变化即更新"。
  1038. // 票 61 第十四轮:移动期自适应降档(近景填充率是卡顿主因)。
  1039. // 静止必须恢复满档,否则画质会永久停在降档状态。
  1040. // 票 103(维护者实机量化 2026-11-03):管线视图**不参与**运动降档 ——
  1041. // 降到 0.55 的缓冲会把远景管线糊成一层浅色剪影(0.55 运动帧浅色像素
  1042. // 均值 3.13%、纯白 0.21%;0.75 静止帧 1.68%、纯白 0),即报告的
  1043. // 「整条管线剪影虚影,停止瞬间跳回并闪烁」。地面视图保留降档性能档。
  1044. if (state && typeof state.settled === 'boolean' && viewer) {
  1045. const motion = state.settled === false && getViewMode() !== 'pipeline'
  1046. try { engine.applyMotionResolution(viewer, motion) } catch { /* 忽略 */ }
  1047. }
  1048. for (const handler of cameraStateHandlers) handler(state)
  1049. },
  1050. })
  1051. if (isDev()) record.detachModelDiagnostics = attachModelDiagnostics(viewer.scene, record.host, () => ({
  1052. viewMode, engineState,
  1053. // 票 61 第十六轮:每个区域标出「按五级层级表它该属于哪一级」,
  1054. // 复看时能直接看出中景是不是真的走了中模、而不是全精模。
  1055. regions: (record.tilesetCulling?.regionSnapshot?.() || []).map((region) => ({
  1056. ...region,
  1057. tier: tierAtDistance(Number.isFinite(region.distance) ? region.distance : Infinity).name,
  1058. })),
  1059. tilesets: record.tilesetCulling?.snapshot?.() || [],
  1060. // 票 61:细模覆盖前沿 —— 直接回答"现在覆盖到多远、为什么停在这"。
  1061. detailFrontier: record.tilesetCulling?.frontierSnapshot?.() || [],
  1062. // 票 61 第十九轮:自适应分辨率读数(scale / 样本数 / 帧间隔 p95)。
  1063. adaptiveResolution: engine.adaptiveResolutionStatus?.(viewer) || null,
  1064. camera: record.cameraControl?.getState?.() || null,
  1065. }), {
  1066. isMoving: () => record.cameraControl?.getState?.()?.settled === false,
  1067. pilotView: async (range) => {
  1068. const lon = 110.399905, lat = 28.459147
  1069. const height = await engine.sampleTerrainHeight(viewer, lon, lat)
  1070. if (!Number.isFinite(height) || instance !== record) throw new Error('试点地形高程未就绪')
  1071. setGlobeSpin(0)
  1072. const applied = record.cameraControl.applyViewPreset({ lon, lat, height }, { range, pitch: -40, heading: 0 }, { immediate: true })
  1073. viewer.scene.requestRender()
  1074. return { applied, lon, lat, height, actual: record.cameraControl.getState() }
  1075. },
  1076. })
  1077. // 票 37:相机控件挂上后立刻把生效限位算一遍并下发 —— 进区状态可能在引擎就绪之前
  1078. // 就已经定了(引导层先出现、配置先到手),这里补一次,免得第一次交互才生效。
  1079. applyAreaLimits()
  1080. paintAreaState()
  1081. // 票 38:自建雾处理级(**唯一一个**,复用不重建)。进区状态可能已经定了,
  1082. // 所以建完就按当前状态对一次门控,不等用户再点一次。
  1083. if (ensureFogStage()) {
  1084. retargetFogForArea(areaState)
  1085. }
  1086. paintFog()
  1087. // 一张图调色(科技蓝滤镜):**在雾之后建级** —— 后处理按加入顺序执行,
  1088. // 调色排在雾后面,雾出的白也被统一成同一冷蓝调。配置关掉时不建级。
  1089. if (ensureGradeStage()) {
  1090. paintGrade()
  1091. }
  1092. // 票 39:挂上精细层句柄并驱动一次交接 —— 进区状态可能在引擎就绪前就定了
  1093. // (配置先到手),这里补一次,免得第一次进区才生效。
  1094. // 顺序有意:**在雾之后** —— 闸门要读雾的强度,雾没建好时闸门必然不放行。
  1095. attachHandoverLayers(engine, record.overlayHandleList || [])
  1096. advanceHandover()
  1097. record.detachCameraInput = attachCameraInput(cameraEngine.inputTarget(), record.cameraControl)
  1098. record.host.addEventListener('pointerdown', cancelPointLocate)
  1099. record.host.addEventListener('wheel', cancelPointLocate)
  1100. record.detachLocateInput = () => {
  1101. record.host.removeEventListener('pointerdown', cancelPointLocate)
  1102. record.host.removeEventListener('wheel', cancelPointLocate)
  1103. }
  1104. // 票 76(C6):标记点选已**全部**由 overlay 节点的 click 触发器承担(票 47 裁定的显式触发器);
  1105. // 画布这一跳只剩两件事:管线(Cesium 几何,不属标记层)与「点空白收起粘性展开」。
  1106. // 曾经的 billboard 命中盒(GPU 拾取)不再参与任何交互路径。
  1107. record.detachPickInput = attachPointPick(record.host, ({ x, y }) => {
  1108. const pipeline = pickPipelineAt(x, y)
  1109. if (pipeline.target !== 'none') return pipeline
  1110. return dispatchPickResult({ target: 'none', pick: null })
  1111. })
  1112. // 票 43:三维 hover 拾取(pointermove 观察型监听,与相机/点选互不干扰)。
  1113. // 节流在这里做:pointermove 每秒可达上百次,每次都 scene.pick 会白烧 GPU/CPU。
  1114. // 规格把"节流数值"留实现定,这里取 60ms(≈16fps 的 hover 反馈,肉眼连贯且开销可控)。
  1115. const HOVER_THROTTLE_MS = 60
  1116. let lastHoverAt = 0
  1117. const onHoverMove = (event) => {
  1118. const now = Date.now()
  1119. if (now - lastHoverAt < HOVER_THROTTLE_MS) return
  1120. lastHoverAt = now
  1121. // 票 76(C6)修正:DOM hover 判定就在宿主这一层做 —— overlay 宿主的 `pointerleave` 不可靠
  1122. // (宿主 `pointer-events:none`,离开节点后事件目标是 canvas,宿主监听收不到),
  1123. // 用 overlay 侧通知会**残留** hover 态(鼠标已离开图标仍出 tooltip / 高亮)。这里按事件目标判定。
  1124. const domNode = event.target && event.target.closest
  1125. ? event.target.closest('.marker-overlay-node')
  1126. : null
  1127. setDomHoverTarget(
  1128. domNode ? (domNode.dataset.markerOverlayKind || '') : '',
  1129. domNode ? (domNode.dataset.markerOverlayCode || '') : '',
  1130. )
  1131. hoverDeviceAt(event.clientX, event.clientY)
  1132. // 批次 3:hover 完整名称 tooltip(label 截断后的通用 hover 详情)。
  1133. paintMarkerTooltip(event)
  1134. }
  1135. const onHoverLeave = () => {
  1136. setHoveredDevice('')
  1137. hideMarkerTooltip()
  1138. if (overlapPreviewGroup) collapseOverlap(overlapPreviewGroup)
  1139. }
  1140. record.host.addEventListener('pointermove', onHoverMove)
  1141. record.host.addEventListener('pointerleave', onHoverLeave)
  1142. record.detachHoverInput = () => {
  1143. record.host.removeEventListener('pointermove', onHoverMove)
  1144. record.host.removeEventListener('pointerleave', onHoverLeave)
  1145. }
  1146. // 用当前真实相机反读弹簧臂参数,避免第一次交互时相机跳变。
  1147. // 引导态视角的**契约就是 Cesium Viewer 的默认相机**(维护者 2026-09-30 裁定)——
  1148. // 反读出来的 `{pivot, hpr}` 会被存成 `introView`,之后「闲置回引导」回到它。
  1149. record.cameraControl.syncFromEngine()
  1150. // 批次 3 第五轮(票 47):DOM overlay 渲染标记与标签——外层自绘 SVG(矢量,任何 DPR
  1151. // 都锐利)+ 内层行业素材(原生分辨率去色重染)+ 真 DOM 标签。billboard 链路保留
  1152. // (聚合/特效/Canvas 拾取仍依赖;内层素材失败时只有母版 = 降级可见)。维护者裁定:
  1153. // DOM 完整复刻 billboard 功能后才替换它。
  1154. // 票 76(C6):`?overlay=0` 对照开关已退役 —— 载体终裁 DOM 胜出,billboard 视觉不再渲染,
  1155. // 也不再有「关掉 DOM 露出 billboard」的第二态(那种态下点选只能靠 GPU 命中盒)。
  1156. if (mountMarkerOverlay(record.host, {
  1157. // 票 76(C6):hover 身份由 overlay 按**节点身份**回传(DOM 是唯一命中盒)。
  1158. onHover: hit => setDomHoverTarget(hit?.kind || '', hit?.code || ''),
  1159. // 票 76(C6):点选**唯一**入口 —— 节点身份 → `pickMarkerByIdentity` → `dispatchPickResult`。
  1160. // GPU 拾取兜底已退役:billboard 不再渲染,命中盒与视觉同一份几何(DOM 节点),
  1161. // 不存在「DOM 认不出、坐标能拾到」的第三种情况。
  1162. onPick: ({ kind, code, key }) => pickMarkerByIdentity(kind, code, key),
  1163. })) {
  1164. record.detachOverlay = attachOverlayRenderer(instance.viewer, () => {
  1165. if (!markerPlan || !instance || !instance.viewer) return null
  1166. // overlay 未生效时不建帧状态(挂载失败时退回 billboard 视觉)。
  1167. if (!isMarkerOverlayVisible()) return null
  1168. const colors = toneColors()
  1169. // 票 35 卡顿整改:镜头没动、计划没换、宿主没挪时,DOM 层不需要每帧重投几百个实体。
  1170. // 静止时按 ~15Hz 补一次(状态强调变化仍能被跟上),动镜头时保持逐帧。
  1171. const viewer = instance.viewer
  1172. const cam = viewer.camera
  1173. const cp = cam.positionWC
  1174. const cd = cam.directionWC
  1175. const pose = `${cp.x.toFixed(1)},${cp.y.toFixed(1)},${cp.z.toFixed(1)}|${cd.x.toFixed(4)},${cd.y.toFixed(4)},${cd.z.toFixed(4)}`
  1176. const nowMs = typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now()
  1177. // 不要每帧 getBoundingClientRect():它是强制同步布局,跳帧检查本身会变成帧率成本。
  1178. // 布局只在窗口尺寸变化时才会动 —— 镜头动时顺带读一次 + 每 500ms 兜底校对。
  1179. let hostRect = overlayFrameCache.rect
  1180. if (pose !== overlayFrameCache.pose || nowMs - (overlayFrameCache.rectAt || 0) > 500) {
  1181. hostRect = record.host.getBoundingClientRect()
  1182. overlayFrameCache.rect = hostRect
  1183. overlayFrameCache.rectKey = [hostRect.left, hostRect.top, hostRect.width, hostRect.height].join(',')
  1184. overlayFrameCache.rectAt = nowMs
  1185. }
  1186. const rectKey = overlayFrameCache.rectKey || `${hostRect.left},${hostRect.top},${hostRect.width},${hostRect.height}`
  1187. const inputsChanged = overlayFrameCache.lastPlan !== markerPlan
  1188. || overlayFrameCache.lastRows !== deviceRows
  1189. || overlayFrameCache.lastFacilityRows !== facilityMarkerRows
  1190. || overlayFrameCache.lastFacilitySelection !== selectedFacilityKey
  1191. || overlayFrameCache.lastComponentRows !== pipeComponentRows
  1192. || overlayFrameCache.lastViewMode !== viewMode
  1193. || overlayFrameCache.lastEffects !== markerEffectsPlan
  1194. || overlayFrameCache.lastDeviceEffects !== deviceEffectsPlan
  1195. || overlayFrameCache.lastCombined !== combinedEffectState
  1196. // 维护者反馈:限到 30Hz 后 DOM 图标"不跟手、观感卡顿"。**镜头一旦在动就必须逐帧同步**
  1197. // (billboard 是 GPU 逐帧的,DOM 慢半拍就会明显脱离)。
  1198. // 只有"镜头没动且输入没变"时跳过重复帧;但静态期仍保留 ~8Hz 兜底同步,
  1199. // 否则 hover/选中改写的 billboard.scale 在镜头不动时永远映射不到 DOM(会「小一圈」)。
  1200. const staticStale = nowMs - overlayFrameCache.at > 120
  1201. if (!inputsChanged && !staticStale
  1202. && pose === overlayFrameCache.pose && rectKey === overlayFrameCache.rectKey) return null
  1203. overlayFrameCache.pose = pose
  1204. overlayFrameCache.rectKey = rectKey
  1205. overlayFrameCache.at = nowMs
  1206. overlayFrameCache.lastPlan = markerPlan
  1207. overlayFrameCache.lastRows = deviceRows
  1208. overlayFrameCache.lastFacilityRows = facilityMarkerRows
  1209. overlayFrameCache.lastFacilitySelection = selectedFacilityKey
  1210. overlayFrameCache.lastComponentRows = pipeComponentRows
  1211. overlayFrameCache.lastViewMode = viewMode
  1212. overlayFrameCache.lastEffects = markerEffectsPlan
  1213. overlayFrameCache.lastDeviceEffects = deviceEffectsPlan
  1214. overlayFrameCache.lastCombined = combinedEffectState
  1215. // 票 35 卡顿整改:下面三样原先每帧重建(Map/Map/闭包 + 逐标记 planMarker),
  1216. // 现在按"输入引用变没变"复用 —— 计划与设备行只在业务数据变化时才换引用。
  1217. if (overlayFrameCache.rows !== deviceRows) {
  1218. overlayFrameCache.rows = deviceRows
  1219. overlayFrameCache.byCode = new Map(deviceRows.map((row) => [row.code, row]))
  1220. }
  1221. // 票 84/85:门控按**跨图层合计**状态重建(降级时不再清空动效键,只把 converge 下传)。
  1222. if (overlayFrameCache.combinedRef !== combinedEffectState) {
  1223. overlayFrameCache.combinedRef = combinedEffectState
  1224. // 票 85:降级**不取消**动效 —— 仍按 enabled 放行,收敛系数由 converge 下传;
  1225. // 若这里再判 degraded,环会直接消失(2026-10-27 走查实测缺陷)。
  1226. const gated = combinedEffectState.enabled === true
  1227. const markerAnimated = gated && markerEffectsPlan
  1228. ? new Map((markerEffectsPlan.animated || []).map((row) => [row.code, row.effect]))
  1229. : new Map()
  1230. const deviceAnimated = gated && deviceEffectsPlan
  1231. ? new Map((deviceEffectsPlan.animated || []).map((row) => [row.code, row.effect]))
  1232. : new Map()
  1233. // 维护者 2026-11-06:设施点动效键也按同一条门控建表(行键=设施点 key)。
  1234. const facilityAnimated = gated && facilityEffectsPlan
  1235. ? new Map((facilityEffectsPlan.animated || []).map((row) => [row.code, row.effect]))
  1236. : new Map()
  1237. overlayFrameCache.effectOf = (marker) => markerAnimated.get(`${marker.kind}|${marker.code}`) || ''
  1238. overlayFrameCache.deviceEffectOf = (device) => deviceAnimated.get(device.code) || ''
  1239. overlayFrameCache.facilityEffectOf = (facility) => facilityAnimated.get(facility.key || `facility-point|${facility.code}`) || ''
  1240. overlayFrameCache.converge = combinedEffectState.converge
  1241. }
  1242. return {
  1243. markers: markerPlan.markers,
  1244. // 票 68:设施点计划行(DOM overlay 第四条循环消费;计划/引用一变就重建帧状态)。
  1245. facilities: facilityMarkerRows,
  1246. facilityPlanOf: overlayFacilityPlanMarkerCached,
  1247. selectedFacilityKey,
  1248. // 票 101:管线构件点位行 + 地面视图可见性(管线视图交给 3D 模型/Cesium 兜底点)。
  1249. pipeComponents: pipeComponentRows,
  1250. pipeComponentsVisible: viewMode === 'ground',
  1251. componentPlanOf: overlayComponentPlanMarkerCached,
  1252. // 票 76(C1):重叠聚合符号每帧投影进 DOM(票 47 遗留:聚合此前只活在 billboard)。
  1253. // 只有 group 身份/计数/组内状态词,没有设备身份 —— 聚合符号不冒充设备。
  1254. aggregates: instance.engine.projectOverlapSymbols(instance.viewer),
  1255. // 票 47 维护者反馈②:关联设备图层每帧投影进 DOM(此前只渲染摘要标记计划,
  1256. // 关联监测点的设备整体缺 DOM 形态)。行内 scale/alpha 是 billboard 实体
  1257. // 真值(选中/hover/弱化反应量),DOM 镜像同一量防两层尺寸/浓度分离。
  1258. // 业务字段(名称/状态/行业/tone)从图层计划行并表——引擎投影只认实体身份。
  1259. devices: (() => {
  1260. const byCode = overlayFrameCache.byCode
  1261. return engine.projectDeviceMarkers(instance.viewer).map((row) => {
  1262. const planned = byCode.get(row.code)
  1263. return planned
  1264. ? { ...row, name: planned.name, status: planned.status, industry: planned.industry, tone: planned.tone }
  1265. : row
  1266. })
  1267. })(),
  1268. // 票 76(C3):强调量由**服务层**产出(`highlightMapObject()` 记录的目标 +
  1269. // domain 同一份 `shouldEmphasize`),DOM 不再回读被隐藏的 billboard scale。
  1270. pointScales: pointEmphasisScales(),
  1271. // 2026-10-28(维护者规格④):当前选中对象编码 —— overlay 用它给**非聚合设备点**
  1272. // (独立未关联设备 / k=1 关联设备)上「被选择态」同款高亮;
  1273. // 聚合设备的中心态另由引擎 `pinned` 标志表达,两者不混用。
  1274. selectedCode: getSelectedDevice(),
  1275. tones: colors,
  1276. // 票 45/84/85:摘要标记与关联设备各自的动效键(门控/降级判定都在 domain 计划里;
  1277. // 降级时键仍在,只把收敛系数下传,环不消失)。
  1278. effectOf: overlayFrameCache.effectOf,
  1279. deviceEffectOf: overlayFrameCache.deviceEffectOf,
  1280. facilityEffectOf: overlayFrameCache.facilityEffectOf,
  1281. converge: overlayFrameCache.converge,
  1282. planOf: overlayPlanMarkerCached,
  1283. // 贴地定位与 billboard `CLAMP_TO_GROUND` 同口径(票 47 维护者反馈①根因):
  1284. // 首版按椭球 0 米投影,地形区里 DOM 标记恒落在 billboard 标记的**地面以下**。
  1285. // 高度取值与 `projectMarkerEntity` 一致:地形瓦片实测高程,取不到回 0(椭球面)。
  1286. projectToWindow: (lon, lat) => {
  1287. const at = record.projectMapPoint(lon, lat)
  1288. if (!at) return null
  1289. // projectPivot 回**窗口坐标**;overlay 宿主 inset:0 铺在 host 内,
  1290. // 节点坐标原点 = host 左上角 → 减 host 的窗口原点(每帧只读一次布局)。
  1291. return { x: at.x - hostRect.left, y: at.y - hostRect.top }
  1292. },
  1293. }
  1294. })
  1295. }
  1296. // 引导视角基准 = 引擎挂载时反读到的 **Cesium Viewer 默认相机**
  1297. // (维护者 2026-09-30 裁定:初始引导页就是这张"整个地球"的地球仪视角,
  1298. // **不是** `INITIAL_PANORAMA`)。之后「闲置回引导」回到它。
  1299. const introState = record.cameraControl.getState()
  1300. if (introState && introState.shownPivot && introState.shownHpr) {
  1301. introView = {
  1302. pivot: { ...introState.shownPivot },
  1303. hpr: { ...introState.shownHpr },
  1304. }
  1305. }
  1306. // 票 30:进入引导期间让星球自转(进入系统时由 setGlobeSpin(0) 停)。
  1307. const introConfig = planIntro(mapConfig)
  1308. if (introConfig.enabled && introConfig.spinSeconds > 0) {
  1309. setGlobeSpin(introConfig.spinSeconds)
  1310. }
  1311. setEngineState(record, 'ready')
  1312. // 管线在引擎启动前收到的计划必须重放;不能等待一次偶然的摘要变化。
  1313. if (pendingPipeLayerPlan) void syncPipeLayerForPlan(pendingPipeLayerPlan).catch(() => {
  1314. if (instance === record && record.host?.dataset) record.host.dataset.mapPipeSync = 'failed'
  1315. })
  1316. publishDevHook()
  1317. } catch (error) {
  1318. if (instance !== record) return
  1319. setEngineState(record, 'failed', String((error && error.message) || error))
  1320. }
  1321. }
  1322. /**
  1323. * 挂载地图实例。幂等:已挂载时直接返回既有实例,绝不重建。
  1324. * @param {Element} host 承载地图的宿主要素
  1325. */
  1326. export function mountMap(host) {
  1327. if (instance) {
  1328. return instance
  1329. }
  1330. sequence += 1
  1331. createdCount += 1
  1332. instance = {
  1333. id: `cockpit-map-${sequence}`,
  1334. host: host || null,
  1335. createdAt: new Date().toISOString(),
  1336. engine: null,
  1337. viewer: null,
  1338. statusNode: null,
  1339. resizePending: false,
  1340. cameraControl: null,
  1341. detachSceneFocus: attachSceneFocus(host),
  1342. detachCameraInput: null,
  1343. // 票 10:点位点选的 DOM detach(与相机输入同一套生命周期)。
  1344. detachPickInput: null,
  1345. }
  1346. const record = instance
  1347. engineState = 'idle'
  1348. engineError = ''
  1349. if (isDomHost(record.host)) {
  1350. bootEngine(record)
  1351. } else {
  1352. setEngineState(record, 'unavailable')
  1353. }
  1354. return record
  1355. }
  1356. export function getMapInstance() {
  1357. return instance
  1358. }
  1359. /** 宿主尺寸变化时调用:引擎就绪后转调 `viewer.resize()`。 */
  1360. export function resizeMap() {
  1361. if (!instance) {
  1362. return
  1363. }
  1364. resizeCount += 1
  1365. lastResizeAt = new Date().toISOString()
  1366. if (instance.viewer) {
  1367. instance.engine.resizeViewer(instance.viewer)
  1368. } else {
  1369. // 引擎还在加载:记下来,等 boot 完成后补一次。
  1370. instance.resizePending = true
  1371. }
  1372. }
  1373. /** 只在离开 /cockpit 时销毁。 */
  1374. export function unmountMap() {
  1375. // 数据可能早于延迟挂载到达:没有 Viewer 也必须清掉旧路由的计划和选择。
  1376. cancelPointLocate()
  1377. pendingPipeLayerPlan = null
  1378. lastPipeLayerRefs = { pipes: null, components: null, viewMode: null, viewer: null, overlayActive: null }
  1379. facilityMarkerRows = []
  1380. // 设施点动效计划随行清空(否则卸载后仍按旧行放行/计数)。
  1381. facilityEffectsPlan = null
  1382. refreshCombinedEffectState()
  1383. selectedFacilityKey = ''
  1384. facilitySelectionIdentity = null
  1385. pipeComponentRows = []
  1386. pipeSyncDeferred = false
  1387. pipeSyncDeferredPlan = null
  1388. if (!instance) {
  1389. unmountMarkerOverlay()
  1390. return
  1391. }
  1392. const record = instance
  1393. instance = null
  1394. destroyedCount += 1
  1395. clearDevHook()
  1396. // 推迟队列与上限定时器随地图生命周期清掉:否则卸载后定时器仍会触发一次落地,
  1397. // 打到已销毁的 viewer 上(票 70 / 2026-10-27)。
  1398. if (deferredSceneApplyTimer !== null) {
  1399. clearTimeout(deferredSceneApplyTimer)
  1400. deferredSceneApplyTimer = null
  1401. }
  1402. deferredSceneTasks.clear()
  1403. deferredSceneApplyAt = 0
  1404. // 订单位一并复位:否则卸载后 store 会以为「还有未落地的订单」而反复重发同步。
  1405. deviceApplyPending = false
  1406. // 票 30:先停自转循环,再拆其它资源(否则 rAF 会打到已销毁的 viewer 上)。
  1407. stopSpin()
  1408. if (record.statusNode) {
  1409. record.statusNode.remove()
  1410. record.statusNode = null
  1411. }
  1412. // 场景焦点与浏览器菜单抑制随宿主生命周期拆除。
  1413. record.detachSceneFocus?.()
  1414. record.detachSceneFocus = null
  1415. // 相机:先摘事件绑定再销毁实例,避免销毁后仍有输入打进来。
  1416. if (record.detachLocateInput) {
  1417. record.detachLocateInput()
  1418. record.detachLocateInput = null
  1419. }
  1420. if (record.detachCameraInput) {
  1421. record.detachCameraInput()
  1422. record.detachCameraInput = null
  1423. }
  1424. // 票 10:点选监听同样先摘(它也会在销毁后读到空 viewer)。
  1425. if (record.detachPickInput) {
  1426. record.detachPickInput()
  1427. record.detachPickInput = null
  1428. }
  1429. // 票 47:DOM overlay 随地图卸载 —— 摘 postRender、清节点池与宿主。
  1430. // (票面核验修正:首版 `unmountMarkerOverlay` 只 import 未调用、`record.detachOverlay` 无人摘,
  1431. // 离开 /cockpit 后 postRender 仍打向已销毁的 viewer。)
  1432. if (record.detachOverlay) {
  1433. record.detachOverlay()
  1434. record.detachOverlay = null
  1435. }
  1436. unmountMarkerOverlay()
  1437. clearPipelinePickHandlers()
  1438. // 票 43:hover 监听一并摘掉(与点选同一个"销毁后读到空 viewer"的坑)。
  1439. if (record.detachHoverInput) {
  1440. record.detachHoverInput()
  1441. record.detachHoverInput = null
  1442. }
  1443. if (record.cameraControl) {
  1444. record.cameraControl.dispose()
  1445. record.cameraControl = null
  1446. }
  1447. // 票 38:自建雾处理级随地图销毁(规格:复用对象,离开地图时销毁)。
  1448. // 顺序在 viewer 销毁**之前** —— `postProcessStages.remove` 需要场景还活着。
  1449. stopFogLoop()
  1450. stopFogPerfSampleListener()
  1451. if (fogHandle) {
  1452. try {
  1453. fogHandle.destroy()
  1454. } catch (error) {
  1455. // 销毁失败不阻断离开页面(下面 viewer.destroy 会整体回收)。
  1456. }
  1457. fogHandle = null
  1458. }
  1459. // 一张图调色级随地图销毁(同一条纪律:先摘处理级,再销毁 viewer)。
  1460. if (gradeHandle) {
  1461. try {
  1462. gradeHandle.destroy()
  1463. } catch (error) {
  1464. // 销毁失败不阻断离开页面(下面 viewer.destroy 会整体回收)。
  1465. }
  1466. gradeHandle = null
  1467. }
  1468. // 状态一并归零:下次挂载从"全球态无雾"重新走,不残留上一轮的强度。
  1469. fogLifecycle = createFogLifecycle()
  1470. fogExecutable = false
  1471. fogEverEnabled = false
  1472. fogCreationFailed = false
  1473. if (isDomHost(record.host)) {
  1474. delete record.host.dataset.camera
  1475. // 调色读数一并收(宿主上不留上一轮的 on/off)。
  1476. delete record.host.dataset.mapGrade
  1477. // 票 10:选中态随地图一起收(宿主上不留上一轮的选中标记)。
  1478. delete record.host.dataset.mapPicked
  1479. delete record.host.dataset.mapPickedStatus
  1480. // 票 14:强调态随地图一起收(宿主上不留上一轮的高亮计数)。
  1481. delete record.host.dataset.mapEmphasis
  1482. // 票 42:关联设备图层的读数一并收(否则下次进入会读到上一轮残留的数)。
  1483. delete record.host.dataset.mapDevices
  1484. delete record.host.dataset.mapDevicesPlan
  1485. // 票 43:三个对应反应字段也一并收(不留上一轮的选中/hover 读数)。
  1486. delete record.host.dataset.mapDevicePicked
  1487. delete record.host.dataset.mapDeviceHovered
  1488. delete record.host.dataset.mapPanelSelection
  1489. }
  1490. // 票 42:设备图层的计划/计数也归零(viewer 随后销毁,图层本身随之消失)。
  1491. deviceRows = []
  1492. deviceRendered = 0
  1493. deviceSkipped = 0
  1494. // 票 43:选中/hover 同样归零。
  1495. selectedDeviceCode = ''
  1496. hoveredDeviceCode = ''
  1497. lastPicked = null
  1498. if (record.detachModelDiagnostics) { record.detachModelDiagnostics(); record.detachModelDiagnostics = null }
  1499. if (record.detachMapFrame) {
  1500. record.detachMapFrame()
  1501. record.detachMapFrame = null
  1502. }
  1503. if (record.tilesetCulling && typeof record.tilesetCulling.detach === 'function') {
  1504. record.tilesetCulling.detach()
  1505. record.tilesetCulling = null
  1506. }
  1507. if (record.viewer) {
  1508. if (record.pipeLayer) record.pipeLayer.clearPipeLayer(record.viewer)
  1509. record.engine.destroyViewer(record.viewer)
  1510. record.viewer = null
  1511. }
  1512. engineState = 'idle'
  1513. engineError = ''
  1514. }
  1515. /**
  1516. * 选中/取消选中相机中心(票 03 验收第 4、5 条):从无选中到选中时平滑换中心并保持 heading 与 range;
  1517. * 传 null 表示取消选中 —— 中心不回退,只有左键平移才把中心更新为地面拾取点。
  1518. * @param {{lon: number, lat: number, height?: number}|null} point 对象地表点
  1519. */
  1520. export function setCameraFocus(point) {
  1521. cancelPointLocate()
  1522. if (!instance || !instance.cameraControl) return false
  1523. return instance.cameraControl.setFocus(point || null)
  1524. }
  1525. export function clearCameraFocus() {
  1526. return setCameraFocus(null)
  1527. }
  1528. // ---- 票 30:视角预设(家)/ 快速归位 / 星球自转 ----
  1529. /** 当前生效的「家」视角(配置优先,缺项用默认)。
  1530. * 地形就绪后中心高程用**实测地形高度**(`record.homeHeight`),否则相机按椭球 0 米算会钻进山体。 */
  1531. export function getHomeView() {
  1532. const home = planHome((getCockpitConfig().map) || {})
  1533. if (instance && Number.isFinite(instance.homeHeight)) {
  1534. return { ...home, height: instance.homeHeight }
  1535. }
  1536. return home
  1537. }
  1538. /** 进入引导配置。 */
  1539. export function getIntroConfig() {
  1540. return planIntro((getCockpitConfig().map) || {})
  1541. }
  1542. /**
  1543. * 飞到「家」视角(快速归位)。`options.immediate` = 不走阻尼直接落位(进系统首帧用)。
  1544. * @returns {boolean} 是否已下发
  1545. */
  1546. export function goHome(options = {}) {
  1547. cancelPointLocate()
  1548. // 票 30:进入系统/归位都要先停自转 —— 否则自转循环每帧以 immediate 写中心,
  1549. // 会把这次飞行的目标一路冲掉(实测:点「进入」后镜头停在自转途中的经度上)。
  1550. stopSpin()
  1551. // 票 37:归位即进区(ADR 0009「点『进入系统』/『归位』进区并锁定」)。
  1552. // **先换限位再飞**:否则飞行目标 12,000 m 会被"区外"的宽上限放行、落地后才被夹,
  1553. // 画面会在到位瞬间跳一下。
  1554. setAreaState('enter')
  1555. const home = getHomeView()
  1556. return applyViewPreset(home, home, options)
  1557. }
  1558. /** `hpr` 是否已经是初始全景(引导态的角度)。用与「停稳」同一套阈值,不另立魔数。 */
  1559. function isAtPanorama(hpr) {
  1560. if (!hpr || !introView) return false
  1561. return Math.abs(hpr.heading - introView.hpr.heading) <= SETTLE_EPSILON.headingDeg
  1562. && Math.abs(hpr.pitch - introView.hpr.pitch) <= SETTLE_EPSILON.pitchDeg
  1563. && Math.abs(hpr.range - introView.hpr.range) <= SETTLE_EPSILON.rangeMeters
  1564. }
  1565. /**
  1566. * 引导态视角基准:**引擎挂载时反读到的 Cesium Viewer 默认相机**。
  1567. *
  1568. * 契约(维护者 2026-09-30 裁定):初始引导页看到的是 Cesium 默认相机给的那张
  1569. * "整个地球"的地球仪视角 —— **不是** `INITIAL_PANORAMA`(那是「返回全球」的目标,
  1570. * 俯角更小、距离更近,画面只能看到一部分球)。所以「闲置回引导」的目标取这份反读值,
  1571. * 三者各归各位:初始引导页 = 引导视角;返回全球 = INITIAL_PANORAMA;闲置回引导 = 引导视角。
  1572. */
  1573. let introView = null
  1574. /**
  1575. * 回引导态:把相机送回**引导视角**(引擎挂载时反读到的 Cesium 默认相机)。
  1576. *
  1577. * 维护者 2026-09-30 报障:「闲置回到引导状态时,摄像机角度没有回到引导状态,且地球也没有
  1578. * 重新开始旋转,除了中心出现『点击进入系统』外用户不一定能意识到进入引导状态了」。
  1579. * 修法两件:① 相机回到**引导视角**(本函数);② 自转恢复(`spinWanted` 意图 + 落定后接上)。
  1580. *
  1581. * 与 `setAreaState('returnGlobal')` 的分工(两者都要,别互相替代):
  1582. * · 本函数**不改区域状态** —— 回引导只在 `global` 态发生(`shouldReturnToIntro` 已把关);
  1583. * · 已经在引导视角上就**不排飞行**,直接返回 —— 否则自转要白等一个飞行时长才起得来。
  1584. * 中心沿用引导视角的中心(自转本来就会推进它)。
  1585. *
  1586. * @returns {boolean} 是否已下发(相机没接上或没有引导视角基准时 false,调用方可重试)
  1587. */
  1588. export function resetToIntroView() {
  1589. if (!instance || !instance.cameraControl) return false
  1590. // 引导视角基准还没取到(引擎刚挂、反读未完成)—— 不猜,等下一次调用。
  1591. if (!introView) return false
  1592. // 契约:闲置回引导**只在 global 态发生**。global 的限位就是放开的全局区间 ——
  1593. // 这里必须**先重算限位**再下发预设:若限位还停在区内的 max=12000,
  1594. // 引导视角的 range(约 3×10⁷)会被 `clampRange` 夹成 12000,
  1595. // 相机贴着县城上空、整屏被雾罩成一片绿(维护者真机复现)。
  1596. // `rebindAreaLimits()` 按**当前 areaState** 重算 —— global 时自然是放开的。
  1597. rebindAreaLimits()
  1598. const control = instance.cameraControl
  1599. const state = control.getState()
  1600. if (state && state.settled && isAtPanorama(state.shownHpr)) return true
  1601. // **中心保持在当前位置**(不回到挂载时的经度 —— 自转/拖动本来就会推进它);
  1602. // **角度/高度**回到引导视角基准(Cesium 默认相机)。
  1603. const pivot = (state && state.pivot) || introView.pivot
  1604. return applyViewPreset(pivot, introView.hpr, { flightMs: control.getState().returnFlightMs })
  1605. }
  1606. /**
  1607. * 内部共用:把预设下发给相机控件。
  1608. * @param {object} home planHome() 结果
  1609. * @param {object} hpr 预设的 heading/pitch/range
  1610. */
  1611. function applyViewPreset(home, hpr, options = {}) {
  1612. if (!instance || !instance.cameraControl) return false
  1613. const applied = instance.cameraControl.applyViewPreset(home, hpr, options)
  1614. if (applied) {
  1615. publishDevHook()
  1616. // 票 41:程序化下发视角(定位 / 归位 / 返回全球 / 自转)也要通知锚定跟随。
  1617. // 这条路径**不经过相机输入**,若只靠输入回调与渲染帧,隐藏标签页里(rAF 冻结)
  1618. // 锚定会停在旧位置 —— 真机走查实测:点「重新定位」后相机已回到点位、面板却仍显示隐藏。
  1619. notifyCameraStateChange()
  1620. }
  1621. return applied
  1622. }
  1623. /** 当前是否已停在「家」视角(归位按钮据此显示状态)。 */
  1624. export function atHome() {
  1625. if (!instance || !instance.cameraControl) return false
  1626. return isAtHome(instance.cameraControl.getState(), getHomeView())
  1627. }
  1628. // ---- 票 21:地面视图 / 管线视图(全局视图条件,语义见决策地图票 05)----
  1629. /**
  1630. * 切换地面/管线视图。
  1631. *
  1632. * 三件事,顺序有意为之:
  1633. * 1. **先记下**进管线前的地面视角(倾角 + 距离),回地面时按它还原;
  1634. * 2. 换相机的 pitch 上下限与预设(管线:pitch 抬到 -18°、弹簧臂压到 35%)——
  1635. * **只动 pitch 与 range,heading 与旋转中心原样带走**(决策地图硬约束:切换视图不重置中心);
  1636. * 3. 调图层可见性:管线视图把地形与 3D Tiles 压成半透明,地面视图还原。
  1637. *
  1638. * 三键分工不受影响:本函数不碰 `cameraControl` 的输入绑定,只改它的 limits 与目标 hpr。
  1639. * @param {string} mode `ground` | `pipeline`
  1640. * @param {object} [overrides] 配置覆盖(pitch / rangeFactor / 透明度)
  1641. * @returns {boolean} 是否生效(引擎未就绪时 false,调用方可稍后重试)
  1642. */
  1643. export function setViewMode(mode, overrides = {}) {
  1644. cancelPointLocate()
  1645. const plan = planViewMode(mode, overrides)
  1646. if (!instance || !instance.cameraControl) return false
  1647. const control = instance.cameraControl
  1648. const state = control.getState()
  1649. const hpr = state && state.hpr ? state.hpr : null
  1650. if (!hpr) return false
  1651. if (plan.mode === VIEW_MODES.PIPELINE) {
  1652. // 进管线:记下地面视角,供回地面时还原(只在第一次进管线时记,避免连续切换被覆盖)。
  1653. if (viewMode !== VIEW_MODES.PIPELINE) {
  1654. instance.groundHprBaseline = { pitch: hpr.pitch, range: hpr.range }
  1655. }
  1656. }
  1657. // limits 必须先换:否则回地面时 pitch 仍按管线的宽容范围夹,画面角度会不对。
  1658. control.setPitchLimits(plan.limits)
  1659. // 票 37:**先落模块级 `viewMode` 再算限位** —— `applyAreaLimits()` 是按当前视图模式
  1660. // 决定要不要叠管线区间 [400, 6000] 的。顺序反了会拿上一次的模式算,切到管线时区域上限
  1661. // 仍按地面算(读数与实测都不对)。这是本票实现时踩到的真顺序 bug。
  1662. viewMode = plan.mode
  1663. instance.tilesetCulling?.setPipeline?.(viewMode === VIEW_MODES.PIPELINE)
  1664. applyAreaLimits()
  1665. // 票 38:管线视图的降级上限跟着视图模式走(**默认不预关闭**,只有观察到透明错位才减弱)。
  1666. refreshFogDegrade()
  1667. paintFog()
  1668. const nextHpr = viewHprFor(hpr, plan, instance.groundHprBaseline)
  1669. control.setHpr(nextHpr)
  1670. if (instance.engine && instance.viewer) {
  1671. instance.engine.applyViewModeLayers(instance.viewer, instance.tilesetInstances || [], plan)
  1672. instance.pipeLayer?.setPipeViewMode(instance.viewer, plan.mode)
  1673. }
  1674. if (isDomHost(instance.host)) {
  1675. instance.host.dataset.mapViewMode = plan.mode
  1676. }
  1677. return true
  1678. }
  1679. /** 当前视图条件(ground / pipeline)。 */
  1680. export function getViewMode() {
  1681. return viewMode
  1682. }
  1683. // ---- 票 36:主场景区(限位与自建雾的开关与矩形)----
  1684. /**
  1685. * 重新计算主场景区计划并刷新读数。配置到手时调一次(`bootEngine`)。
  1686. *
  1687. * 为什么它不放进 `bootEngine` 的引擎链路里:本计划**不依赖引擎**——
  1688. * 票 37/38 只问「配没配、在哪儿」,引擎是死是活都不改变答案。所以引擎失败时这个读数照样有效。
  1689. */
  1690. function applyPrimaryArea(mapConfig) {
  1691. primaryAreaPlan = planPrimaryArea(mapConfig || {})
  1692. primaryAreaText = primaryAreaStatusText(primaryAreaPlan)
  1693. primaryAreaResolved = true
  1694. // 票 38:雾参数与主场景区一起算 —— 两者都由配置单方面决定,都不依赖引擎。
  1695. fogParams = planFog(mapConfig || {})
  1696. // 参数换了,限速中的高度系数要立刻按新参数对齐(否则会按两套参数各说各话)。
  1697. setAltitudeSlewImmediate()
  1698. fogReason = fogParams.enabled ? '' : '已由配置关闭自建雾(map.fog.enabled=false)'
  1699. // 票 39:交接参数同源(同样不依赖引擎)。
  1700. handoverParams = planHandover(mapConfig || {})
  1701. // 调色参数同源(同样不依赖引擎):配置一换,立即写进已建的处理级。
  1702. gradeParams = planSceneGrade(mapConfig || {})
  1703. paintGrade()
  1704. // 影像矩形缓存随配置失效(判"会露边的是影像边界"要用它)。
  1705. overlayBoundsResolved = false
  1706. overlayBoundsCache = null
  1707. if (instance) paintEngineState(instance)
  1708. }
  1709. /**
  1710. * 当前主场景区计划(票 37/38 的**唯一**入口)。
  1711. *
  1712. * 返回冻结的副本:消费方要按它开限位/开雾,但不能把这份计划改掉
  1713. * ——「谁开的、开在哪儿」必须只有配置一个来源。
  1714. * @returns {{state: string, enabled: boolean, bounds: number[]|null, reason: string, sizeText: string}}
  1715. */
  1716. export function getPrimaryAreaPlan() {
  1717. return { ...primaryAreaPlan, bounds: primaryAreaPlan.bounds ? primaryAreaPlan.bounds.slice() : null }
  1718. }
  1719. /**
  1720. * 底图措辞(票 25)。原为引擎浮标上的一句,现在由右下角信息面板展示。
  1721. * @returns {string} 人话措辞(如「底图:内置离线影像 Natural Earth II」)
  1722. */
  1723. export function getBasemapLabel() {
  1724. return basemapText
  1725. }
  1726. /**
  1727. * 数据来源署名(票 25)。
  1728. *
  1729. * Cesium 自带的署名容器已被换成**离屏容器**(`services/cesiumEngine.js`),因此
  1730. * 底图/叠加影像/3D Tiles 的 credit 改由地图信息面板展示。这是「隐藏引擎署名」之后
  1731. * 仍然必须保留的那部分来源标注——本部署不用 ion,但自有影像的署名不能一起丢。
  1732. * @returns {string[]} 去重前的署名条目(空数组 = 本部署没有声明任何 credit)
  1733. */
  1734. export function getDataSourceCredits() {
  1735. return [...dataSourceCredits]
  1736. }
  1737. // ---- 票 37:进区锁定与相机区域限位 ----
  1738. /**
  1739. * 把「主场景区计划 + 区域状态 + 视图模式」合成生效限位并下发给相机控件。
  1740. *
  1741. * 三处夹取的合成点(ADR 0009):区域上限 `home.range`、视图模式区间(管线 `[400, 6000]`)、
  1742. * 全局 `[120, 3×10⁷]`。**只在这里算一次**,相机控件不自己拼 —— 否则视图切换与进区各算一套,
  1743. * 「最终生效 = 三者交集」就没人保证了。
  1744. *
  1745. * 两件事分开:**算计划**与**推给相机**。计划是配置事实(引擎没起来也算得出来,读数照样要准),
  1746. * 推给相机需要实例。早先两者写在一起,导致"引擎就绪前进区"时 `getAreaLimits()` 还是旧的,
  1747. * 界面会显示 locked=false —— 而状态明明已经锁了。
  1748. */
  1749. function applyAreaLimits() {
  1750. const home = planHome(getCockpitConfig().map || {})
  1751. const isPipeline = viewMode === VIEW_MODES.PIPELINE
  1752. const viewPlan = planViewMode(viewMode)
  1753. // 地面视图不受视图模式区间约束(`planViewMode` 给的是管线区间;地面只认区域与全局)。
  1754. areaLimits = planAreaLimits({
  1755. plan: primaryAreaPlan,
  1756. areaState,
  1757. homeRange: home.range,
  1758. viewMin: isPipeline ? viewPlan.minRange : null,
  1759. viewMax: isPipeline ? viewPlan.maxRange : null,
  1760. })
  1761. if (!instance || !instance.cameraControl) return areaLimits
  1762. instance.cameraControl.setRangeLimits({
  1763. min: areaLimits.min,
  1764. max: areaLimits.max,
  1765. // 区域未启用时传 null = 不限中心(`area` 为空即"区外自由视角")。
  1766. area: areaLimits.locked ? { bounds: areaLimits.bounds, groundMargin: areaLimits.groundMargin } : null,
  1767. })
  1768. return areaLimits
  1769. }
  1770. /** 当前生效的区域限位(走查与人工排查读它)。 */
  1771. // 票 98:外壳 500ms 轮询会写 ref —— 返回新对象会白触发整壳重渲染,所以包稳定引用。
  1772. export const getAreaLimits = createStableReadout(() => ({ ...areaLimits }))
  1773. /** 当前区域状态(`locked` / `global`)。 */
  1774. export function getAreaState() {
  1775. return areaState
  1776. }
  1777. /**
  1778. * 进区 / 返回全球(ADR 0009 的**显式模式锁**)。
  1779. *
  1780. * 判定不依赖相机位置(贴边不会抖),只有这两条路能改状态:
  1781. * · `enter`(进入系统 / 归位)→ 进区锁定。主场景区不可用时**进不去**(票 36 的关闭路径),
  1782. * 返回 `blocked` 原因,调用方据此如实提示,不假装锁上了;
  1783. * · `returnGlobal` → 解除限位并阻尼飞到程序初始态全景,之后完全自由。
  1784. *
  1785. * @param {string} action `enter` | `returnGlobal`
  1786. * @returns {{applied: boolean, state: string, blocked: string}}
  1787. */
  1788. export function setAreaState(action) {
  1789. const result = transitionAreaState(areaState, action, primaryAreaPlan)
  1790. if (result.blocked) {
  1791. return { applied: false, state: areaState, blocked: result.blocked }
  1792. }
  1793. areaState = result.state
  1794. if (instance) instance.areaState = areaState
  1795. // ── 票 39:进出的**顺序**是这张票的核心,两处都按决策 08 的第 3 条排 ──
  1796. //
  1797. // 进入:雾**先**形成保护,闸门(`advanceHandover`)才可能放行影像渐显。
  1798. // 所以先 `retargetFogForArea`(把雾往锁定态推),再 `advanceHandover`。
  1799. //
  1800. // 返回:这里**故意不立刻撤雾**。决策 08 要求「精细影像在失去保护前收起」,
  1801. // 一按返回就撤雾会让影像在无保护状态下露出矩形边。正确顺序是:
  1802. // ① 影像立刻开始收起(与镜头起飞**并行**,避免"收完→停顿→才飞");
  1803. // ② 雾留到影像确实不可见之后,由 `maybeDropFogAfterConceal()` 撤除。
  1804. // 没有可交接的精细层时(没配叠加影像 / 交接关闭)就退化成旧行为,立刻撤雾 ——
  1805. // 否则雾会永远撤不掉。
  1806. const handoverWillHandleFogDrop = areaState !== AREA_STATE.LOCKED && handoverHandlesFogDrop()
  1807. if (action === 'returnGlobal') {
  1808. // 先让交接进入"收起"(立刻排帧),雾暂不动。
  1809. // `force=true`:进出是离散动作,必须**这一次调用就定下**目标相位,
  1810. // 不能被稳态节流吞掉(否则按下返回后要等最多 120 ms 才开始收影像)。
  1811. advanceHandover(true)
  1812. }
  1813. if (!handoverWillHandleFogDrop) {
  1814. retargetFogForArea(areaState)
  1815. }
  1816. if (action === 'returnGlobal') {
  1817. cancelPointLocate()
  1818. // 解除限位后再飞:先松绑,飞行途中就不会被区域上限一路夹着(ADR 0009「解除限位 + 阻尼飞到全景」)。
  1819. rebindAreaLimits()
  1820. stopSpin()
  1821. // 回程序初始态全景(heading 0 / pitch −60 / range 20,000,000)。
  1822. // 中心沿用当前位置 —— 全景是"退到星球尺度",不是"换个地方"。
  1823. //
  1824. // `flightMs`:**按时长 + 慢起缓动**飞回去,而不是走阻尼。
  1825. // 目标 range 是 20,000,000 m、起点 12,000 m(跨 1.7e6 倍),阻尼的指数逼近
  1826. // 起步最快,头一帧就越过整条雾带(20–42 km,实算 0.15 ms)——
  1827. // 观感就是"退出太快、雾一瞬间就散了",且那时雾还满着 ⇒ 整个地球被涂成雾色。
  1828. // 详见 `RETURN_FLIGHT_MS` 的注释。
  1829. //
  1830. // 票 40:目标**角度/高度**改用**引导视角基准**(`introView` —— 引擎挂载时反读的
  1831. // Cesium Viewer 默认相机,维护者裁定初始引导页就是那张"整个地球"的视角,
  1832. // `INITIAL_PANORAMA` 俯角/距离都不对);**中心仍沿用当前位置** ——
  1833. // 全景是"退到星球尺度",不是"换个地方"。
  1834. const current = getCameraState()
  1835. const pivot = (current && current.pivot) || getHomeView()
  1836. const targetHpr = introView
  1837. ? introView.hpr
  1838. : INITIAL_PANORAMA
  1839. applyViewPreset(pivot, targetHpr, {
  1840. flightMs: instance && instance.cameraControl
  1841. ? instance.cameraControl.getState().returnFlightMs
  1842. : RETURN_FLIGHT_MS,
  1843. })
  1844. } else {
  1845. // 进区:先把限位换成区内,再夹一次当前值 —— 否则「进区时 range 已在 12 km 以外」
  1846. // 要等用户滚一次才被夹(决策票 03 记的 4.7× 突跳就是这么来的)。
  1847. rebindAreaLimits()
  1848. if (instance && instance.cameraControl) instance.cameraControl.clampToLimits()
  1849. // 雾已按新状态推进(上面 `retargetFogForArea`)——此刻闸门读到的强度就是最新的,
  1850. // 于是"雾没到位 → 不放行渐显"这条在**同一次调用内**就生效,不留一帧窗口。
  1851. // `force=true`:进区是离散动作,同样不该被稳态节流吞掉。
  1852. advanceHandover(true)
  1853. }
  1854. paintAreaState()
  1855. publishDevHook()
  1856. return { applied: true, state: areaState, blocked: '' }
  1857. }
  1858. /** 限位变化后重新下发(进区 / 返回全球 / 切视图模式都要走它)。 */
  1859. function rebindAreaLimits() {
  1860. applyAreaLimits()
  1861. }
  1862. /** 区域状态写进宿主 dataset(走查断言面;人工排查也读它)。 */
  1863. function paintAreaState() {
  1864. if (!instance || !isDomHost(instance.host)) return
  1865. instance.host.dataset.mapAreaState = areaState
  1866. instance.host.dataset.mapAreaLocked = areaLimits.locked ? 'yes' : 'no'
  1867. instance.host.dataset.mapRangeMax = String(Math.round(areaLimits.max))
  1868. }
  1869. // ---- 票 38:区内视距雾气 ----
  1870. /**
  1871. * 雾的策略与生命周期**唯一所有者**(规格:引擎只负责一遍全屏 pass,决定"什么时候多浓"的在这里)。
  1872. *
  1873. * 状态分两层,与决策票 07 的措辞严格对应:
  1874. * · `supported` = 当前环境**有没有**深度能力(`Context.depthTexture`,布尔能力标志);
  1875. * · `executable` = 处理级**是否可执行**(`stage.ready`);
  1876. * · `strength` = 当前强度。
  1877. * **三者都不等于"矩形边缘已被遮住"** —— 对外读数里 `coversEdges` 恒为 false,
  1878. * 那是票 39 的交接判断,不在这里承诺。
  1879. */
  1880. let fogParams = planFog({})
  1881. let fogLifecycle = createFogLifecycle()
  1882. let fogHandle = null
  1883. let fogSupported = true
  1884. let fogExecutable = false
  1885. let fogReason = ''
  1886. // ---- 一张图调色(科技蓝滤镜):常驻全屏处理级,无进出生命周期,只有配置一个来源。----
  1887. let gradeParams = planSceneGrade({})
  1888. let gradeHandle = null
  1889. /**
  1890. * 是否曾经真正启用过 / 是否**曾经**观测到 `ready === true`(shader 编译并跑通过一遍)。
  1891. *
  1892. * 为什么不能拿"当前 ready"当判据:它**随渲染活动变化** —— 处理级在 `update()` 里被置 true,
  1893. * 一旦不再渲染(隐藏标签页、强度回 0 关掉处理级),下一次读就可能落回 false。
  1894. * 本票真机验证时实测到过这一幕:同一个 stage 先 `executable:true`(跑通了),
  1895. * 标签页转入隐藏后读数又变 false。若把它当"能不能用",读数会在「正常 ↔ 失败」间来回跳。
  1896. * 声明**必须放在 `paintFog` 之前**(它在渲染循环里就会写这两个标志)。
  1897. */
  1898. let fogEverEnabled = false
  1899. let fogEverReady = false
  1900. /**
  1901. * 是否**确凿地失败过**(创建抛错)。
  1902. *
  1903. * 这是 `failed` 状态的**唯一**来源。刻意不用"有没有 handle""此刻 ready 吗"这类**间接推断**:
  1904. * · 引擎还没起来时没有 handle,那不是故障(只是在启动);
  1905. * · 隐藏标签页不渲染时 `ready` 会落回 false,那也不是故障。
  1906. * 两者若被当成失败,界面就会在最不该报警的时刻报"视距雾不可用"(本票真机验证时撞上过)。
  1907. * 失败只由确凿证据判定:创建抛错,或环境不支持深度(后者走 `supported` → `unsupported`)。
  1908. */
  1909. let fogCreationFailed = false
  1910. let fogDegrade = { level: FOG_DEGRADE.NONE, strengthCap: 1, reason: '' }
  1911. /** 是否观察到管线视图下透明对象被错误雾化(由 DEV 把手/走查置位,默认未观察到)。 */
  1912. let fogArtifacts = false
  1913. /** 用户是否显式暂关(管线可读性优先时的人工开关)。 */
  1914. let fogUserDisabled = false
  1915. let fogFrameHandle = null
  1916. /** 性能采样(人工触发;不改动相机/参数)。 */
  1917. let fogPerfSample = null
  1918. let fogPerfResults = []
  1919. /** `scene.postRender` 的移除函数(采样结束时调)。 */
  1920. let fogAltitudePaintAt = 0
  1921. let fogPerfRemoveListener = null
  1922. /**
  1923. * 上一次 `paintFog()` 写进引擎时用到的**高度系数**。
  1924. *
  1925. * 用途:相机在飞行(进区/返回)时高度持续变化,而 `paintFog()` 只在
  1926. * 状态机推进(entering/exiting)期间才被逐帧调用;一旦状态机落定(locked/global),
  1927. * 高度变化就没人重画了 —— 那样"退出时高度把雾压到 0"就不会生效。
  1928. * 渲染循环因此比较这个值,变了才重画(不是每帧无脑重画)。
  1929. */
  1930. let fogPaintedAltitudeFactor = 1
  1931. /**
  1932. * **限速后的**高度系数(真正参与写入强度的那个值)。
  1933. *
  1934. * 为什么不直接用 `fogAltitudeFactor()` 的瞬时值:返回全球时 `range` 跨 1.7e6 倍,
  1935. * 雾带 20–42 km 只占其中 0.0001% —— 相机头一帧就越过整条带,瞬时系数会从 1 直接跳到 0,
  1936. * 观感是"雾一瞬间就散了"(维护者 2026-09-29 第二次反馈)。这里按时间限速收敛,
  1937. * 使画面上的变化不可能快于 `altitudeSlewMs`(见 `approachAltitudeFactor`)。
  1938. */
  1939. let fogAltitudeShown = 1
  1940. /** 上一次推进高度系数的时刻(`engineNow()`);0 = 还没推过(首帧 dt 按 0 算)。 */
  1941. let fogSlewAt = 0
  1942. /** 读当前相机椭球高度(米);取不到返回 null。 */
  1943. function cameraAltitude() {
  1944. if (!instance || !instance.viewer) return null
  1945. try {
  1946. const carto = instance.viewer.scene.globe.ellipsoid.cartesianToCartographic(instance.viewer.camera.positionWC)
  1947. return carto && Number.isFinite(carto.height) ? carto.height : null
  1948. } catch (error) {
  1949. return null
  1950. }
  1951. }
  1952. /**
  1953. * 把当前强度写进引擎 uniform 并刷新读数。
  1954. * **只改 uniform,不重建 stage**(规格:复用处理级对象,不在每次进出时 remove/destroy)。
  1955. */
  1956. /**
  1957. * **真正写进引擎的雾强度**(唯一口径)。
  1958. *
  1959. * 三层相乘,顺序即语义:
  1960. * 1. `fogLifecycle.strength` —— 状态机按进出时间推进出来的强度;
  1961. * 2. `fogDegrade.strengthCap` —— 降级上限(管线视图透明错位等);
  1962. * 3. `fogAltitudeShown` —— **相机高度收口**(见 `domain/fog.js`),且**按时间限速**。
  1963. *
  1964. * 为什么后两层都作用在"写入值"而不改进状态机:状态机只管进出时序,
  1965. * 保持它干净,恢复时不会因为中途被压低而错位。
  1966. *
  1967. * 为什么**必须**有这一个函数:交接闸门要判"雾保护是否成立",
  1968. * 它必须看**画面真的被雾罩住的强度**,而不是状态机那个数 ——
  1969. * 否则相机还在 60 km 高空(高度系数 0、画面毫无雾)时就被判成"保护成立"而揭示精细层,
  1970. * 矩形边缘当场露出来。
  1971. *
  1972. * 为什么第 3 层用**限速后的**系数而不是瞬时值:返回全球时 `range` 从 12 km 变到
  1973. * 20,000 km,相机头一帧就越过整条雾带(20–42 km),瞬时系数会让雾
  1974. * "一瞬间消失"(维护者 2026-09-29 第二次现场反馈)。限速后无论相机多快,
  1975. * 画面上的雾都至少花 `altitudeSlewMs` 变化。
  1976. */
  1977. function fogEffectiveStrength() {
  1978. const base = Math.min(fogLifecycle.strength, fogDegrade.strengthCap)
  1979. return Math.max(0, base * fogAltitudeShown)
  1980. }
  1981. /**
  1982. * 推进高度系数的限速(每个渲染帧一次)。
  1983. *
  1984. * 与 `paintFog` **分开**:`paintFog` 会被很多路径调用(配置变更、进出动作、
  1985. * 读数刷新),若在它里面推进时间轴,同一帧被调多次就会把时间走快。
  1986. * 所以推进只挂在唯一的渲染帧钩子上(见 `postRender`)。
  1987. */
  1988. function stepAltitudeSlew() {
  1989. const now = engineNow()
  1990. const dt = fogSlewAt === 0 ? 0 : now - fogSlewAt
  1991. fogSlewAt = now
  1992. const target = fogAltitudeFactor(cameraAltitude(), fogParams)
  1993. fogAltitudeShown = approachAltitudeFactor(fogAltitudeShown, target, dt, fogParams)
  1994. return fogAltitudeShown
  1995. }
  1996. /**
  1997. * 参数(雾端点 / 高度带 / 限速时长)变更后把系数**立刻对齐**到新参数。
  1998. *
  1999. * 为什么不能只靠渲染帧的那一步步限速:配置一变,`fogParams` 换了,
  2000. * 系数却还按**旧参数**慢慢爬,读数与画面会短暂地"按两套参数各说各话"。
  2001. * 对齐是一次性修正,不是绕过限速 —— 之后的每次变化仍受 `altitudeSlewMs` 约束。
  2002. */
  2003. function setAltitudeSlewImmediate() {
  2004. fogAltitudeShown = fogAltitudeFactor(cameraAltitude(), fogParams)
  2005. fogSlewAt = 0
  2006. fogPaintedAltitudeFactor = fogAltitudeShown
  2007. return fogAltitudeShown
  2008. }
  2009. function paintFog() {
  2010. if (!instance || !isDomHost(instance.host)) return
  2011. const colorRgb = parseHexColor(fogParams.color)
  2012. const effective = fogEffectiveStrength()
  2013. // 记下这一帧用到的系数,供"相机飞行时按需重画"判断(见渲染循环)。
  2014. fogPaintedAltitudeFactor = fogAltitudeShown
  2015. if (fogHandle) {
  2016. instance.engine.applyFogUniforms(fogHandle, {
  2017. strength: effective,
  2018. colorRgb,
  2019. startDistance: fogParams.startDistance,
  2020. endDistance: fogParams.endDistance,
  2021. })
  2022. // `ready` 只在启用后才有意义(见 `ensureFogStage` 的注释)。一旦强度 > 0 就记下"启用过",
  2023. // 并**记住曾经 ready 过**(之后隐藏标签页会让 ready 落回 false,但不该改口说"没跑通过")。
  2024. if (effective > 0) fogEverEnabled = true
  2025. fogExecutable = instance.engine.fogStageExecutable(fogHandle)
  2026. if (fogExecutable) fogEverReady = true
  2027. }
  2028. const readout = fogReadout({
  2029. state: fogLifecycle,
  2030. degrade: fogDegrade,
  2031. params: fogParams,
  2032. supported: fogSupported,
  2033. // **待命不是故障**:没启用过就不把 executable=false 当成 failed。
  2034. executable: fogExecutableForReadout(),
  2035. reason: fogReason,
  2036. })
  2037. readout.strength = Number(effective.toFixed(4))
  2038. const host = instance.host
  2039. host.dataset.mapFog = readout.state
  2040. host.dataset.mapFogPhase = readout.phase
  2041. host.dataset.mapFogStrength = String(readout.strength)
  2042. host.dataset.mapFogDegrade = readout.degrade
  2043. host.dataset.mapFogStart = String(Math.round(readout.startDistance))
  2044. host.dataset.mapFogEnd = String(Math.round(readout.endDistance))
  2045. host.dataset.mapFogReason = readout.reason
  2046. // 显式写出来,防止日后有人把它当成"已遮缝"来读(规格禁止这种承诺)。
  2047. host.dataset.mapFogCoversEdges = 'no'
  2048. host.dataset.mapFogStages = String(instance.viewer && instance.engine.hasFogStage(instance.viewer) ? 1 : 0)
  2049. }
  2050. /** 按当前降级选择重算上限(视图模式变化 / 观察到透明错位 / 用户暂关时调)。 */
  2051. function refreshFogDegrade() {
  2052. fogDegrade = planFogDegrade({
  2053. viewMode,
  2054. translucentArtifacts: fogArtifacts,
  2055. userDisabled: fogUserDisabled,
  2056. supported: fogSupported,
  2057. })
  2058. return fogDegrade
  2059. }
  2060. /**
  2061. * 立刻把强度推到某个值(进/出的**起点**由状态机给,这里只负责一步到位)。
  2062. * 用于"取消进出":立刻改成收起,不把正在变亮的过程放完(决策 08 第 1 轮 Q3=A)。
  2063. */
  2064. function settleFog(phase) {
  2065. if (phase === FOG_PHASE.LOCKED) fogLifecycle = { ...fogLifecycle, phase: FOG_PHASE.LOCKED, strength: 1 }
  2066. else fogLifecycle = { ...fogLifecycle, phase: FOG_PHASE.GLOBAL, strength: 0 }
  2067. stopFogLoop()
  2068. paintFog()
  2069. }
  2070. /** 每帧推进(只在 entering/exiting 期间排帧;落定即停,不留常驻 rAF)。 */
  2071. function tickFog() {
  2072. fogFrameHandle = null
  2073. if (fogLifecycle.phase !== FOG_PHASE.ENTERING && fogLifecycle.phase !== FOG_PHASE.EXITING) return
  2074. const now = engineNow()
  2075. const before = fogLifecycle
  2076. fogLifecycle = stepFogLifecycle(fogLifecycle, now, fogParams)
  2077. paintFog()
  2078. if (fogLifecycle.phase === FOG_PHASE.ENTERING || fogLifecycle.phase === FOG_PHASE.EXITING) {
  2079. scheduleFogFrame()
  2080. } else if (before.phase !== fogLifecycle.phase) {
  2081. // 落定:把最终读数再刷一次(强度精确到 0/1)。
  2082. paintFog()
  2083. }
  2084. }
  2085. function engineNow() {
  2086. return typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now()
  2087. }
  2088. function scheduleFogFrame() {
  2089. if (fogFrameHandle !== null) return
  2090. if (typeof requestAnimationFrame !== 'function') return
  2091. fogFrameHandle = requestAnimationFrame(tickFog)
  2092. }
  2093. function stopFogLoop() {
  2094. if (fogFrameHandle !== null && typeof cancelAnimationFrame === 'function') {
  2095. cancelAnimationFrame(fogFrameHandle)
  2096. }
  2097. fogFrameHandle = null
  2098. }
  2099. /**
  2100. * 进入 / 返回时的雾门控转移(票 37 的 `setAreaState` 会调它)。
  2101. *
  2102. * 规格:`locked` 期间生效;纯全球/引导态关闭。进入途中按**相机高度**给早期强度输入,
  2103. * 但那只是强度输入、**不是**"可揭示精细影像"的凭据(票 39 不得据此放行影像渐显)。
  2104. *
  2105. * ── 「过期操作不得生效」是怎么保证的(规格要求的一条,值得写清楚)──
  2106. * 不是靠"回调里比 token",而是**构造性**的:每次推进都由 `tickFog` 重新读模块级的
  2107. * `fogLifecycle`,**没有任何一处捕获旧状态的闭包**。于是"进 → 立刻返回 → 再进"这种快速反向,
  2108. * 下一帧读到的必然是最新状态,旧的那轮淡出**在结构上就没有机会**把新进入关掉。
  2109. * `beginFogTransition` 仍递增 `token` 供读数与断言使用(`domain/fog.js` 有 `isStaleFogToken`
  2110. * 作显式判据,服务层不重复造一套)。
  2111. */
  2112. function retargetFogForArea(nextAreaState) {
  2113. if (!fogParams.enabled) {
  2114. settleFog(FOG_PHASE.GLOBAL)
  2115. fogReason = '已由配置关闭自建雾(map.fog.enabled=false)'
  2116. paintFog()
  2117. return
  2118. }
  2119. refreshFogDegrade()
  2120. if (nextAreaState === AREA_STATE.LOCKED) {
  2121. // 进区:先把高度驱动的早期强度作为起点(飞入途中就已经有雾,不是落地才出现)。
  2122. const altitude = cameraAltitude()
  2123. const seed = Number.isFinite(altitude) ? enterAltitudeStrength(altitude, fogParams) : 0
  2124. fogLifecycle = beginFogTransition(
  2125. { ...fogLifecycle, strength: Math.max(fogLifecycle.strength, seed) },
  2126. 'enter',
  2127. engineNow(),
  2128. fogParams,
  2129. )
  2130. paintFog()
  2131. scheduleFogFrame()
  2132. return
  2133. }
  2134. // 返回全球:淡出(基准 0.8 s;决策 08 要求"比原型稍快",时长可配)。
  2135. fogLifecycle = beginFogTransition(fogLifecycle, 'exit', engineNow(), fogParams)
  2136. paintFog()
  2137. scheduleFogFrame()
  2138. }
  2139. /**
  2140. * 在场景上建**唯一**一个自建雾处理级(票 38)。幂等:同一 viewer 只会有一个。
  2141. *
  2142. * ── 一个必须讲清楚的 Cesium 事实(本票真机验证时抓到的坑)──
  2143. * `PostProcessStage.ready` **只有在处理级被 `enabled` 时的那次 update 里才会置 true**
  2144. * (源码:`_ready = false` 在构造里,置 true 发生在 `update()` 内的 uniform/纹理就绪分支)。
  2145. * 所以「强度 0 → stage.enabled=false → ready 永远是 false」。
  2146. * 如果拿 `ready` 直接当"能不能用"的判据,就会把**一个正常待命的雾**报成 `failed`
  2147. * ——本票第一版正是这样:区外读数一直是 `state=failed`,而实际只是还没启用。
  2148. *
  2149. * 因此这里分开两件事:
  2150. * · `supported` = 环境有没有深度能力(**这才是"能不能用"**,与 enabled 无关);
  2151. * · `executable`(= `ready`)= "**已经跑过一遍并且成功了**",只在启用后才有意义。
  2152. * 读数里 `executable=false` 且从未启用过时**不报 failed**,那是"待命"。
  2153. */
  2154. function ensureFogStage() {
  2155. if (!instance || !instance.engine || !instance.viewer) return false
  2156. if (fogHandle) return true
  2157. if (!fogParams.enabled) {
  2158. fogSupported = true
  2159. fogExecutable = false
  2160. fogReason = '已由配置关闭自建雾(map.fog.enabled=false)'
  2161. return false
  2162. }
  2163. try {
  2164. fogHandle = instance.engine.createFogStage(instance.viewer, fogParams)
  2165. fogSupported = fogHandle.supported !== false
  2166. fogExecutable = instance.engine.fogStageExecutable(fogHandle)
  2167. if (!fogSupported) {
  2168. fogReason = '当前环境不支持深度纹理,自建雾整级禁用(业务可用优先)'
  2169. } else {
  2170. // 注意:这里**不**因为 ready=false 而写 failed/reason —— 待命不是故障(见上方注释)。
  2171. fogReason = ''
  2172. }
  2173. refreshFogDegrade()
  2174. return true
  2175. } catch (error) {
  2176. // **确凿证据**:创建抛错 ⇒ 这才是 failed 的唯一来源。
  2177. fogHandle = null
  2178. fogSupported = false
  2179. fogCreationFailed = true
  2180. fogExecutable = false
  2181. fogReason = `自建雾创建失败:${String((error && error.message) || error)}`
  2182. refreshFogDegrade()
  2183. return false
  2184. }
  2185. }
  2186. // ---- 一张图调色(科技蓝滤镜):建级与写 uniform。与雾同一条「复用不重建」纪律。----
  2187. /** 在场景上建**唯一**一个调色处理级;配置关掉时不建(幂等)。 */
  2188. function ensureGradeStage() {
  2189. if (!instance || !instance.engine || !instance.viewer) return false
  2190. if (gradeHandle) return true
  2191. if (!gradeParams.enabled) return false
  2192. try {
  2193. gradeHandle = instance.engine.createSceneGradeStage(instance.viewer, {
  2194. ...gradeParams,
  2195. // 引擎吃 0..1 的 rgb;hex 解析沿用雾的同一份 parseHexColor。
  2196. tintRgb: parseHexColor(gradeParams.tint) || undefined,
  2197. })
  2198. return true
  2199. } catch (error) {
  2200. // 建失败只是这层滤镜缺席,不挡业务(与雾的失败立场一致)。
  2201. gradeHandle = null
  2202. console.warn('[mapService] 场景调色处理级创建失败,已跳过', error)
  2203. return false
  2204. }
  2205. }
  2206. /** 把当前计划写进 uniform 与 dataset 读数(走查可读 `data-map-grade`)。 */
  2207. function paintGrade() {
  2208. // 配置把 enabled 翻开而处理级还没建(启动时关、后来配置翻开):这里补建(幂等)。
  2209. if (!gradeHandle && gradeParams.enabled) ensureGradeStage()
  2210. if (gradeHandle && instance && instance.engine) {
  2211. instance.engine.applySceneGradeUniforms(gradeHandle, {
  2212. ...gradeParams,
  2213. tintRgb: parseHexColor(gradeParams.tint) || undefined,
  2214. })
  2215. }
  2216. if (instance && isDomHost(instance.host)) {
  2217. // 读数三态:on(在跑)/ off(配置关)/ pending(配置要但没建成 —— 引擎未就绪或建级失败)。
  2218. instance.host.dataset.mapGrade = !gradeParams.enabled ? 'off' : gradeHandle ? 'on' : 'pending'
  2219. }
  2220. }
  2221. /**
  2222. * 「可执行」的**唯一**口径(防止散在各调用点各写一遍而漂移)。
  2223. *
  2224. * 语义=**没有确凿的失败证据**(而不是"此刻 ready"):
  2225. * · 深度不支持 → false(`supported` 会报 unsupported);
  2226. * · 创建抛错 → false(`fogCreationFailed`,`fogReason` 写明原因);
  2227. * · 其余 → true。包括"引擎还没起来(无 handle)"与"强度 0 待命(ready 未置位)"
  2228. * —— 这两种都是**正常过程**,报 failed 会在最不该报警的时刻误报。
  2229. *
  2230. * 由此**不需要**"等几秒判失败"的宽限期,也不需要看 `ready`:
  2231. * 隐藏标签页不渲染是正常状态,据它判失败就是把正常情况报成故障(真机验证时撞上过)。
  2232. * 是否"已经跑通过"另由 `fogRanOnceForReadout()` 如实给出,供排查区分待命与跑过。
  2233. */
  2234. function fogExecutableForReadout() {
  2235. return fogSupported && !fogCreationFailed
  2236. }
  2237. /** 是否"曾经跑通过一遍"(与 `executable` 分开报,便于排查与走查区分待命/跑过)。 */
  2238. function fogRanOnceForReadout() {
  2239. return fogEverReady
  2240. }
  2241. /** 当前雾的完整读数(走查与人工排查读它)。 */
  2242. export const getFogState = createStableReadout(() => {
  2243. const readout = fogReadout({
  2244. state: fogLifecycle,
  2245. degrade: fogDegrade,
  2246. params: fogParams,
  2247. supported: fogSupported,
  2248. executable: fogExecutableForReadout(),
  2249. reason: fogReason,
  2250. })
  2251. return {
  2252. ...readout,
  2253. params: { ...fogParams },
  2254. /** 是否已把处理级挂上场景(**不是**"画面已遮住")。 */
  2255. staged: !!(fogHandle && instance && instance.engine && instance.viewer && instance.engine.hasFogStage(instance.viewer)),
  2256. /**
  2257. * 是否**曾经跑通过一遍**(`ready` 曾为 true)。
  2258. * 与 `executable` 分开报:隐藏标签页不渲染时当前 `ready` 会落回 false,
  2259. * 但"跑通过"这个事实不该改口 —— 排查时两者一起看才分得清「待命」与「跑过」。
  2260. */
  2261. ranOnce: fogRanOnceForReadout(),
  2262. /**
  2263. * DEV 诊断:对比"服务层以为写进去的强度"与"Cesium 真正喂给 shader 的强度"。
  2264. * 两者不一致 ⇒ uniform 通道没接对(常量快照 vs 函数每帧取)。
  2265. * 本票踩着这个坑:读数说强度 1,画面却什么都没有。
  2266. */
  2267. uniformDiagnostics: fogHandle && typeof fogHandle.readUniformDiagnostics === 'function' ? fogHandle.readUniformDiagnostics() : null,
  2268. perfResults: fogPerfResults.slice(),
  2269. }
  2270. })
  2271. /** 当前雾参数计划(配置 + 规格默认的合成结果)。 */
  2272. export function getFogParams() {
  2273. return { ...fogParams }
  2274. }
  2275. /**
  2276. * 人工/走查用:声明"是否观察到管线视图透明对象被错误雾化"。
  2277. * 规格:**正常情况不预先关闭**,只有真的观察到才减弱;相机限位不受影响。
  2278. */
  2279. export function setFogTranslucentArtifacts(observed) {
  2280. fogArtifacts = observed === true
  2281. refreshFogDegrade()
  2282. paintFog()
  2283. return { ...fogDegrade }
  2284. }
  2285. /** 人工/走查用:显式暂关/恢复自建雾(可读性优先开关)。 */
  2286. export function setFogUserDisabled(disabled) {
  2287. fogUserDisabled = disabled === true
  2288. refreshFogDegrade()
  2289. paintFog()
  2290. return { ...fogDegrade }
  2291. }
  2292. /**
  2293. * 性能采样:在**固定视角与参数**下收集帧间隔,算 P95。
  2294. * 与原型一样明确标注:这是**渲染事件间隔**,不是 GPU 时间,也不能单独判定达标。
  2295. *
  2296. * 帧源用 `scene.postRender`(每渲染一帧一次),而不是 rAF —— rAF 在标签页不可见时会停,
  2297. * 采样会静默收集不到数据;postRender 只在真的渲染时触发,与"帧耗时"语义更贴。
  2298. */
  2299. export function startFogPerfSample(label = '') {
  2300. stopFogPerfSampleListener()
  2301. fogPerfSample = { label, at: engineNow(), previous: null, frames: [] }
  2302. if (instance && instance.viewer && instance.viewer.scene && instance.viewer.scene.postRender) {
  2303. fogPerfRemoveListener = instance.viewer.scene.postRender.addEventListener(noteFogFrame)
  2304. }
  2305. return { started: true, label, listening: fogPerfRemoveListener !== null }
  2306. }
  2307. function stopFogPerfSampleListener() {
  2308. if (typeof fogPerfRemoveListener === 'function') {
  2309. try {
  2310. fogPerfRemoveListener()
  2311. } catch (error) {
  2312. // 场景已销毁时移除监听会抛:忽略即可,下面会重建。
  2313. }
  2314. }
  2315. fogPerfRemoveListener = null
  2316. }
  2317. export function stopFogPerfSample() {
  2318. const result = collectFogPerfSample()
  2319. stopFogPerfSampleListener()
  2320. fogPerfSample = null
  2321. return result
  2322. }
  2323. function collectFogPerfSample() {
  2324. if (!fogPerfSample) return null
  2325. const frames = fogPerfSample.frames.slice().sort((a, b) => a - b)
  2326. const p95 = frames.length ? frames[Math.ceil(frames.length * 0.95) - 1] : null
  2327. const result = {
  2328. label: fogPerfSample.label,
  2329. count: frames.length,
  2330. p95FrameIntervalMs: p95,
  2331. strength: Number(fogLifecycle.strength.toFixed(3)),
  2332. fogState: fogReadout({ state: fogLifecycle, degrade: fogDegrade, params: fogParams, supported: fogSupported, executable: fogExecutableForReadout() }).state,
  2333. note: '渲染事件间隔,不是 GPU 时间;不能单独判定 ≤15% 达标',
  2334. }
  2335. fogPerfResults.push(result)
  2336. if (fogPerfResults.length > 8) fogPerfResults.shift()
  2337. return result
  2338. }
  2339. /** 帧计数(由宿主每帧调;只在采样中累计)。 */
  2340. function noteFogFrame() {
  2341. if (!fogPerfSample) return
  2342. const now = engineNow()
  2343. if (fogPerfSample.previous !== null) fogPerfSample.frames.push(now - fogPerfSample.previous)
  2344. fogPerfSample.previous = now
  2345. }
  2346. // ---- 票 39:粗→精影像交接 ----
  2347. /**
  2348. * 精细影像交接的**策略与生命周期唯一所有者**(规格:引擎只负责"把 alpha 写进图层",
  2349. * 决定"什么时候该亮多少"的在这里)。
  2350. *
  2351. * 交接方向由决策票 08 固化:**全球/引导态隐藏精细层 → 雾先形成保护 → 再渐显 → 返回反向交接**。
  2352. * 本模块不复制一套影像状态机到别处,`domain/imageryHandover.js` 只做纯计算。
  2353. *
  2354. * ── 为什么"隐藏"用 alpha=0 而不是 show=false(关键实现事实)──
  2355. * 决策 08 第 1 轮 Q4=A:隐藏期间**可继续预取**瓦片,看不见不等于停止加载。
  2356. * 查过 Cesium 源码:`GlobeSurfaceTile.js:691` 按 **`layer.show`** 决定要不要建影像骨架,
  2357. * **不看 `alpha`**。所以 `alpha=0 + show=true` 才是"看不见但继续加载";
  2358. * 若用 `show=false`,瓦片会真的停掉,揭示瞬间才开始下载 → 正好造成"先空白再跳方块"。
  2359. */
  2360. let handoverParams = planHandover({})
  2361. let handoverLifecycle = createHandoverLifecycle()
  2362. let handoverBlock = HANDOVER_BLOCK.NONE
  2363. let handoverNote = ''
  2364. /** 精细层句柄(引擎侧 `applyOverlays` 返回)+ 瓦片就绪/失败读数。 */
  2365. let overlayHandles = []
  2366. let overlayReadiness = null
  2367. /**
  2368. * 交接帧循环句柄。
  2369. * 与雾那条 rAF 分开:雾的推进是"时间→强度",交接还有"瓦片就绪与否"这一外部条件,
  2370. * 两者节奏不同,合成一条会互相拖累(且雾落定后就不该再排帧)。
  2371. */
  2372. let handoverFrameHandle = null
  2373. /** 上一次写进宿主 dataset 的交接读数(避免每帧重写同样的值,见 `paintHandover`)。 */
  2374. let handoverPainted = {}
  2375. /**
  2376. * 精细影像覆盖矩形缓存。
  2377. *
  2378. * 为什么不每次现读:`currentEdgeVerdict()` 在渲染循环里被调,而
  2379. * `getCockpitConfig()` + 遍历 overlays 每帧跑一遍纯属浪费;影像矩形是**配置事实**,
  2380. * 引擎 boot 时定下就不变(换配置要重挂地图)。`applyPrimaryArea` 时失效重算。
  2381. */
  2382. let overlayBoundsCache = null
  2383. let overlayBoundsResolved = false
  2384. /** 稳态节流:下一次允许重新评估闸门的时间戳(见 `advanceHandover`)。 */
  2385. const HANDOVER_STEADY_EVAL_MS = 120
  2386. let handoverNextEvalAt = 0
  2387. /** 本帧算过的边缘判据(`advanceHandover` 写、`paintHandover` 复用,避免每帧算两遍)。 */
  2388. let handoverEdgeCache = null
  2389. /** 当前生效的交接参数(走查与人工排查读它)。 */
  2390. export function getHandoverParams() {
  2391. return { ...handoverParams }
  2392. }
  2393. /** 交接是否已由配置关闭。 */
  2394. export function handoverEnabled() {
  2395. return handoverParams.enabled !== false
  2396. }
  2397. /** 精细层当前是否有可交接的层(没配叠加影像时交接无事可做)。 */
  2398. export function hasOverlayLayers() {
  2399. return overlayHandles.length > 0
  2400. }
  2401. /**
  2402. * 把交接读数写进宿主 dataset(走查断言面;人工排查也读它)。
  2403. *
  2404. * 刻意**不**写任何"已遮边"的布尔承诺:本票能给的是"精细层 alpha / 阶段 / 被什么挡住 /
  2405. * 瓦片信号是什么性质",「矩形边是否真被遮住」归维护者目视(票 38 的同款纪律)。
  2406. */
  2407. function paintHandover(force = false) {
  2408. if (!instance || !isDomHost(instance.host)) return
  2409. const readout = handoverReadout({
  2410. state: handoverLifecycle,
  2411. block: handoverBlock,
  2412. note: handoverNote,
  2413. edge: handoverEdgeCache || currentEdgeVerdict(),
  2414. tiles: overlayReadiness ? overlayReadiness.state : null,
  2415. degrade: !fogSupported || !fogExecutableForReadout(),
  2416. enabled: handoverEnabled(),
  2417. })
  2418. // ── 为什么要把写入 diff 掉(性能预算 ≤15%,本函数在渲染循环里被调)──
  2419. // 交接读数每帧都刷会让每帧产生 8 次 DOM 属性写入 + 字符串拼接;稳态下这些值**根本不变**。
  2420. // 只在值真的变了才写,稳态帧的这部分开销降到一次对象比较。
  2421. const next = {
  2422. mapHandover: readout.phase,
  2423. mapHandoverAlpha: String(readout.alpha),
  2424. mapHandoverBlock: readout.block,
  2425. mapHandoverNote: readout.note,
  2426. mapHandoverLayers: String(overlayHandles.length),
  2427. mapHandoverEdgeKm: readout.edgeDistanceM === null ? '' : String(Math.round(readout.edgeDistanceM / 1000)),
  2428. mapHandoverDegrade: readout.degraded ? 'yes' : 'no',
  2429. // 显式写出来:瓦片信号是**代理**,不证明目标视野逐像素就绪(决策票 08 的警告)。
  2430. mapHandoverTilesProxy: 'yes',
  2431. }
  2432. const host = instance.host
  2433. for (const key of Object.keys(next)) {
  2434. if (force || handoverPainted[key] !== next[key]) {
  2435. host.dataset[key] = next[key]
  2436. handoverPainted[key] = next[key]
  2437. }
  2438. }
  2439. }
  2440. /** 当前相机位置(经纬度,度);取不到返回 null。 */
  2441. function currentCameraLonLat() {
  2442. if (!instance || !instance.viewer) return null
  2443. try {
  2444. const carto = instance.viewer.scene.globe.ellipsoid.cartesianToCartographic(instance.viewer.camera.positionWC)
  2445. if (!carto) return null
  2446. // 不依赖引擎导出的 `Math`:弧度换角度是纯算术,自己算,免得 engine 没暴露这个符号。
  2447. const toDeg = (rad) => (rad * 180) / Math.PI
  2448. return { lon: toDeg(carto.longitude), lat: toDeg(carto.latitude) }
  2449. } catch (error) {
  2450. return null
  2451. }
  2452. }
  2453. /** 边缘保护判据(把当前相机与影像矩形喂给纯函数)。 */
  2454. function currentEdgeVerdict() {
  2455. if (!primaryAreaPlan || !primaryAreaPlan.enabled) return null
  2456. const camera = currentCameraLonLat()
  2457. if (!camera) return null
  2458. // 用**影像矩形**(精细层覆盖范围)判,而不是主场景区矩形 —— 会露边的是影像边界。
  2459. const bounds = overlayBounds()
  2460. if (!bounds) return null
  2461. return edgeOcclusionVerdict(camera, bounds, fogParams, handoverParams.minEdgeOcclusion)
  2462. }
  2463. /** 精细影像的覆盖矩形(取第一个配了 bounds 的叠加层;没配就没有"边"可言)。 */
  2464. function overlayBounds() {
  2465. if (overlayBoundsResolved) return overlayBoundsCache
  2466. const source = (getCockpitConfig().map && getCockpitConfig().map.overlays) || []
  2467. overlayBoundsCache = null
  2468. for (const item of source) {
  2469. if (Array.isArray(item && item.bounds) && item.bounds.length === 4) {
  2470. overlayBoundsCache = item.bounds
  2471. break
  2472. }
  2473. }
  2474. overlayBoundsResolved = true
  2475. return overlayBoundsCache
  2476. }
  2477. /**
  2478. * 把交接状态对应的 alpha 写进精细层 —— **alpha 的唯一写入点**。
  2479. *
  2480. * 生产与 DEV 原型不得同时驱动 alpha(会互相打架)。因此这里先问一句
  2481. * "原型是否正在接管",是则让位(见 `setHandoverPrototypeActive`)。
  2482. */
  2483. function applyHandoverAlpha() {
  2484. if (handoverPrototypeActive) return 0
  2485. // 交接被配置关掉 = 回到"精细层常显"(票 39 之前的行为):把 alpha 还到 1。
  2486. // 否则"关掉交接"反而会让精细层永远隐形 —— 那是最糟的失败模式。
  2487. const target = handoverEnabled() ? handoverLifecycle.alpha : 1
  2488. let written = 0
  2489. for (const handle of overlayHandles) {
  2490. if (handle && typeof handle.setAlpha === 'function' && handle.alive()) written = handle.setAlpha(target)
  2491. }
  2492. if (written === 0 && overlayHandles.length > 0) written = target
  2493. return written
  2494. }
  2495. /**
  2496. * DEV 原型接管开关(**票 39 = 隔离,不删除**)。
  2497. *
  2498. * 维护者 2026-09-28 裁定:39 只负责让生产交接成为 alpha 的唯一写入方、且原型不得干扰生产;
  2499. * 删除与归档(存 throwaway 分支、留分支指针)归票 40。两者都写同一批 `layer.alpha`,
  2500. * 同时跑必然互相覆盖 —— 所以用这个开关显式让位,而不是靠"谁后跑谁赢"。
  2501. */
  2502. let handoverPrototypeActive = false
  2503. /** 原型挂载时置 true(让生产交接交出 alpha 写入权);卸载时置 false 并重画一次。 */
  2504. export function setHandoverPrototypeActive(active) {
  2505. handoverPrototypeActive = !!active
  2506. if (!handoverPrototypeActive) {
  2507. applyHandoverAlpha()
  2508. paintHandover()
  2509. }
  2510. return handoverPrototypeActive
  2511. }
  2512. /** 当前 alpha 是否由原型接管(读数/走查用)。 */
  2513. export function isHandoverPrototypeActive() {
  2514. return handoverPrototypeActive
  2515. }
  2516. /**
  2517. * 推进交接一帧(**每渲染帧调一次**,由 `postRender` 驱动)。
  2518. *
  2519. * ── 为什么不再自带一条 rAF 循环(本票实现中删掉的一个多余机制)──
  2520. * 第一版仿照雾的做法又排了一条 `requestAnimationFrame` 链。但 `advanceHandover` 已经挂在
  2521. * `scene.postRender` 上**每渲染帧**被调(见 `bootEngine`),再排 rAF 是**同一件事做两遍**:
  2522. * 多一条循环、多一处取消时机要维护,而且两者节奏可能错开(rAF 与渲染帧并不同步)。
  2523. * 更实际的坏处是**一条隐 bug**:rAF 在标签页隐藏时被冻结,于是"隐藏标签页里推进交接"
  2524. * 只能靠手动调 `advanceHandover()`;把推进并进 `advanceHandover` 之后,
  2525. * 手动调一次就能推一帧(可断言、可走查),不必依赖 rAF 是否还活着。
  2526. * 雾那条 rAF 保留:它的推进是纯时间驱动,与"有没有渲染帧"无关。
  2527. *
  2528. * ── 「过期操作不得生效」怎么保证 ──
  2529. * 与雾同款、**构造性**的:每帧都重新读模块级的 `handoverLifecycle`,
  2530. * **没有任何一处捕获旧状态的闭包**。于是"进 → 立刻返回 → 再进"这种快速反向,
  2531. * 下一帧读到的必然是最新状态,旧的那轮推进在结构上就没有机会把新目标覆盖掉。
  2532. */
  2533. function stepHandoverFrame() {
  2534. if (handoverLifecycle.phase !== HANDOVER_PHASE.ENTERING && handoverLifecycle.phase !== HANDOVER_PHASE.RETURNING) return false
  2535. const before = handoverLifecycle
  2536. handoverLifecycle = stepHandover(handoverLifecycle, engineNow(), handoverParams)
  2537. applyHandoverAlpha()
  2538. // 返回途中:精细层**确实收起后**才允许撤雾(决策 08 第 3 条:影像先收起、保护不断档)。
  2539. if (before.phase === HANDOVER_PHASE.RETURNING) {
  2540. const sync = returnSyncVerdict({
  2541. phase: handoverLifecycle.phase,
  2542. alpha: handoverLifecycle.alpha,
  2543. areaState,
  2544. fogStrength: fogLifecycle.strength,
  2545. })
  2546. if (sync.mayDropFog) maybeDropFogAfterConceal()
  2547. }
  2548. return true
  2549. }
  2550. function stopHandoverLoop() {
  2551. if (handoverFrameHandle !== null && typeof cancelAnimationFrame === 'function') {
  2552. cancelAnimationFrame(handoverFrameHandle)
  2553. }
  2554. handoverFrameHandle = null
  2555. }
  2556. /**
  2557. * 返回全球时:精细层收起后才真正撤雾。
  2558. *
  2559. * 决策 08 第 3 条同时要求两件看似矛盾的事:
  2560. * · 「影像在失去保护前收起」→ 所以**不能**一按返回就撤雾;
  2561. * · 「不能影像收完→停顿→镜头才飞走」→ 所以**也不能**等影像收完才开始飞。
  2562. * 解法是把两者**并行**:按下返回的那一帧就同时(a)开始收影像、(b)开始飞镜头;
  2563. * 雾则延后到"影像确实不可见"再淡出。本函数负责 (b) 之后的这步"延后撤雾"。
  2564. */
  2565. function maybeDropFogAfterConceal() {
  2566. if (!fogSupported || !fogParams.enabled) return false
  2567. if (fogLifecycle.phase !== FOG_PHASE.LOCKED) return false
  2568. fogLifecycle = beginFogTransition(fogLifecycle, 'exit', engineNow(), fogParams)
  2569. paintFog()
  2570. scheduleFogFrame()
  2571. return true
  2572. }
  2573. /** 便捷谓词:本次返回是否由交接负责"延后撤雾"(见 `setAreaState` 的顺序注释)。 */
  2574. function handoverHandlesFogDrop() {
  2575. return handoverEnabled() && overlayHandles.length > 0
  2576. }
  2577. /**
  2578. * 哪些挡板属于"硬失败",要**撤回**已经在渐显的精细层(而不只是拦住下一次渐显)。
  2579. *
  2580. * 刻意**不含** `NO_FOG`:规格明确"雾失败且精细层已经可见时,不突然藏起来,
  2581. * 同时把遮缝承诺改成降级"(业务可用优先)。若把雾也当硬失败,
  2582. * 雾一抖就会把影像闪没 —— 那比"没有遮缝保护"更伤业务可用性。
  2583. */
  2584. function isHardRevealBlock(block) {
  2585. return block === HANDOVER_BLOCK.TILES_FAILED || block === HANDOVER_BLOCK.EDGE_RISK
  2586. }
  2587. /**
  2588. * 推进交接(由外壳的刷新轮询或进出动作调用)。
  2589. *
  2590. * 幂等:已经在目标阶段就不重开一轮(重复点"进入"不该把渐显推倒重来)。
  2591. */
  2592. function advanceHandover(force = false) {
  2593. if (!overlayHandles.length) {
  2594. paintHandover()
  2595. return handoverLifecycle
  2596. }
  2597. if (!handoverEnabled()) {
  2598. // 交接关闭:精细层常显(`applyHandoverAlpha` 会把 alpha 还到 1)。
  2599. handoverBlock = HANDOVER_BLOCK.NONE
  2600. handoverNote = '交接已由配置关闭(精细层常显)'
  2601. applyHandoverAlpha()
  2602. paintHandover()
  2603. return handoverLifecycle
  2604. }
  2605. // ── 稳态节流(性能预算 ≤15%)──
  2606. // 本函数在 `postRender` 里**每帧**被调。判闸门要读相机位置
  2607. // (`cartesianToCartographic` **每次分配新对象**)并算到矩形四边的距离,
  2608. // 而稳态下这些值**根本没变**,每帧重算纯属浪费。所以:
  2609. // · 过渡中(entering/returning):**每帧**推进 —— alpha 要平滑,不能跳;
  2610. // · 稳态(global/revealed):最多每 `HANDOVER_STEADY_EVAL_MS` 评一次"条件变了没"。
  2611. const transitioning = handoverLifecycle.phase === HANDOVER_PHASE.ENTERING
  2612. || handoverLifecycle.phase === HANDOVER_PHASE.RETURNING
  2613. const now = engineNow()
  2614. if (!force && !transitioning && now < handoverNextEvalAt) return handoverLifecycle
  2615. handoverNextEvalAt = now + HANDOVER_STEADY_EVAL_MS
  2616. // 边缘判据这一帧只算一次,`paintHandover` 复用这份(别在渲染循环里算两遍)。
  2617. const edge = currentEdgeVerdict()
  2618. handoverEdgeCache = edge
  2619. const gate = revealGate({
  2620. enabled: true,
  2621. areaState,
  2622. // **必须用"真正写进引擎的强度"**(含相机高度收口),不能只用状态机那个数:
  2623. // 相机还在 60 km 高空时高度系数为 0、画面其实毫无雾,
  2624. // 若拿状态机的 1 去判"保护成立",就会在高空揭示精细层、矩形边缘当场露出来。
  2625. fogStrength: fogEffectiveStrength(),
  2626. // 「可执行」沿用票 38 的统一口径(不是"此刻 ready",那会随渲染活动跳)。
  2627. fogExecutable: fogExecutableForReadout(),
  2628. fogSupported,
  2629. edgeOk: (edge || { ok: true }).ok,
  2630. tilesLoaded: overlayReadiness ? overlayReadiness.state.loaded : false,
  2631. tilesFailed: overlayReadiness ? overlayReadiness.state.failed > 0 : false,
  2632. params: handoverParams,
  2633. })
  2634. handoverBlock = gate.block
  2635. handoverNote = gate.note
  2636. // 过渡中:**先推进这一帧**(alpha 才平滑),再评闸门。
  2637. // 顺序有意:先走时间轴,再判断"条件还成立吗" —— 反过来的话,
  2638. // 停止条件(瓦片失败/边缘风险)变化时会先被拦住、把正在收的影像卡在半透明。
  2639. if (transitioning) stepHandoverFrame()
  2640. if (areaState === AREA_STATE.LOCKED && gate.allowed) {
  2641. handoverLifecycle = beginHandover(handoverLifecycle, 'reveal', engineNow())
  2642. } else if (areaState === AREA_STATE.LOCKED && transitioning && isHardRevealBlock(gate.block)) {
  2643. // **渐显途中**条件转硬失败(瓦片挂了 / 相机退到边缘危险区)→ 立刻反向收起。
  2644. // 规格:"瓦片失败 → 保持隐藏、继续用粗底图";继续把坏瓦片亮出来正是
  2645. // "先空白再跳方块"那类缺陷。注意**刻意不含 NO_FOG**:
  2646. // 规格明确"雾失败且精细层已经可见时,**不**突然藏起来"(业务可用优先),
  2647. // 所以雾这一路只影响"要不要开始渐显",不影响"已经在渐显的要不要退回去"。
  2648. handoverLifecycle = beginHandover(handoverLifecycle, 'conceal', engineNow())
  2649. } else if (areaState !== AREA_STATE.LOCKED && handoverLifecycle.phase !== HANDOVER_PHASE.GLOBAL) {
  2650. // 返回全球:立刻开始收影像(不等待、不拖延),雾由 `maybeDropFogAfterConceal` 延后撤。
  2651. handoverLifecycle = beginHandover(handoverLifecycle, 'conceal', engineNow())
  2652. } else if (areaState !== AREA_STATE.LOCKED && handoverHandlesFogDrop()) {
  2653. // **已经在 global 相位**(精细层本来就没揭示,例如刚启动就点返回):
  2654. // `beginHandover` 是幂等空操作、没有过渡帧可推,于是没人去撤雾 ——
  2655. // 那样雾会永远留在锁定强度上。这里显式补一次"是否该撤雾"的判断。
  2656. const sync = returnSyncVerdict({
  2657. phase: handoverLifecycle.phase,
  2658. alpha: handoverLifecycle.alpha,
  2659. areaState,
  2660. fogStrength: fogLifecycle.strength,
  2661. })
  2662. if (sync.mayDropFog) maybeDropFogAfterConceal()
  2663. }
  2664. applyHandoverAlpha()
  2665. paintHandover()
  2666. return handoverLifecycle
  2667. }
  2668. /**
  2669. * 挂上精细层句柄并开始驱动交接(引擎 boot 完成后由 `bootEngine` 调)。
  2670. * 幂等:重复调用只更新引用,不重复挂监听。
  2671. */
  2672. function attachHandoverLayers(engine, handles) {
  2673. overlayHandles = Array.isArray(handles) ? handles : []
  2674. if (overlayReadiness) overlayReadiness.detach()
  2675. overlayReadiness = engine.createOverlayReadiness(overlayHandles)
  2676. // 初始即"隐藏但继续加载":alpha=0、show 保持 true(见本节约首注释)。
  2677. applyHandoverAlpha()
  2678. paintHandover()
  2679. }
  2680. /**
  2681. * 交接读数(走查与人工排查读它)。**不提供任何"已遮边"承诺。**
  2682. */
  2683. export function getHandoverState() {
  2684. // 走查/人工排查读的是**当前**真实值,所以这里**不复用**稳态缓存
  2685. // (`handoverEdgeCache` 可能最多滞后一个节流窗口)。缓存只服务渲染循环那一路。
  2686. const readout = handoverReadout({
  2687. state: handoverLifecycle,
  2688. block: handoverBlock,
  2689. note: handoverNote,
  2690. edge: currentEdgeVerdict(),
  2691. tiles: overlayReadiness ? overlayReadiness.state : null,
  2692. degrade: !fogSupported || !fogExecutableForReadout(),
  2693. enabled: handoverEnabled(),
  2694. })
  2695. return {
  2696. ...readout,
  2697. layers: overlayHandles.length,
  2698. /** 原型是否正在接管 alpha(生产交接已让位)。 */
  2699. prototypeActive: handoverPrototypeActive,
  2700. /** 当前**实际**写在图层上的 alpha(与状态机读数对照,抓"读数对、图层没变")。 */
  2701. appliedAlpha: overlayHandles.length && overlayHandles[0] && typeof overlayHandles[0].alpha === 'number' ? overlayHandles[0].alpha : null,
  2702. tilesFailed: overlayReadiness ? overlayReadiness.state.failed : 0,
  2703. tilesLastError: overlayReadiness ? overlayReadiness.state.lastError : '',
  2704. params: { ...handoverParams },
  2705. /**
  2706. * 精细层的**真实图层状态**(不是我们的状态机读数)。
  2707. *
  2708. * 为什么要有它(票 38 的教训):读数全对、图层没变,是这类功能最难查的失败模式。
  2709. * 这里直接回读 `ImageryLayer`:`alpha` 必须等于状态机推的值,
  2710. * 而 `show` **必须仍为 true** —— 那正是"隐藏期间继续预取瓦片"的实现依据
  2711. * (`GlobeSurfaceTile` 按 `show` 决定要不要建影像骨架,不看 `alpha`)。
  2712. */
  2713. layerProbe: overlayHandles.map((handle) => ({
  2714. show: !!(handle && handle.layer && handle.layer.show),
  2715. alpha: handle && typeof handle.alpha === 'number' ? Number(handle.alpha.toFixed(4)) : null,
  2716. })),
  2717. }
  2718. }
  2719. /** 刷新瓦片就绪读数(渲染循环里调;`globe.tilesLoaded` 是 getter,不便宜)。约 6Hz 节流。 */
  2720. let overlayReadinessLastAt = 0
  2721. function refreshOverlayReadiness() {
  2722. if (!overlayReadiness || !instance || !instance.viewer) return
  2723. const now = performance.now()
  2724. if (now - overlayReadinessLastAt < 160) return
  2725. overlayReadinessLastAt = now
  2726. overlayReadiness.refresh(instance.viewer.scene.globe)
  2727. }
  2728. // ---- 票 09:点位状态标记与图例 ----
  2729. /**
  2730. * 语义色的**唯一取法**:从 `.cockpit-shell` 的计算样式里读 `--lv1..--lv4 / --neutral`。
  2731. *
  2732. * 为什么绕这一道而不是在 JS 里写色值(票 06 的单一口径):色值只在 `styles/tokens.css`
  2733. * 声明一份,设计公司换视觉资源时改那一个文件即可;地图标记(SVG 图片)偏偏读不到 CSS 变量,
  2734. * 所以由服务层把「当前生效的颜色」读出来交给 domain 的 `markerSvg()` 烘焙进图片。
  2735. * 读不到(脱浏览器单测、样式未挂)时回退到与 token 同值的兜底色,标记不会变成透明。
  2736. * 兜底色表的**唯一定义**在 `domain/markerPlan.js` 的 `TONE_FALLBACK`
  2737. * —— Three 场景里的设备图标也用同一份,避免两张同值表各自漂移。
  2738. */
  2739. const TONE_FALLBACK = {
  2740. lv1: '#ff3b52',
  2741. lv2: '#ffb020',
  2742. lv3: '#01abea',
  2743. lv4: '#27d17a',
  2744. neutral: '#5680b0',
  2745. 'point-offline': '#94a3b8',
  2746. 'point-empty': '#c4d5e8',
  2747. 'point-unknown': '#a970ff',
  2748. // 票 45:「无关联设备」附加状态色=黄绿(与 tokens.css 同一值;本表只是 DOM 不可读时的兜底)。
  2749. 'point-unassociated': '#a3e635',
  2750. }
  2751. let toneColorsCache = null
  2752. /**
  2753. * 票 76(C4):billboard 视觉是否让位给 DOM overlay —— **唯一判据**。
  2754. *
  2755. * 为什么必须是一个函数:点位(`syncPointMarkers`)与关联设备(`syncAssociatedDevices`)是两条
  2756. * sync 入口,此前各写一份判据,于是出现「点位修好、设备点没修」。视觉去留只该有一个开关。
  2757. * 语义:overlay 已挂载 → billboard 只留实体与读数(`show:false`),
  2758. * 视觉由 DOM 承担;对照态(overlay 关)自动回到 billboard。
  2759. */
  2760. function billboardVisualHidden() {
  2761. return isMarkerOverlayMounted() && isMarkerOverlayVisible()
  2762. }
  2763. /**
  2764. * 票 76(C3):最近一次强调的目标编码(`highlightMapObject()` 解析后的最终列表)。
  2765. * 强调量从此在**服务层**推导,DOM 不再回读 billboard 实体。
  2766. */
  2767. let emphasisCodes = []
  2768. /**
  2769. * 票 76(C3):强调量由服务层**直接产出**给 DOM overlay。
  2770. *
  2771. * 此前 DOM 靠引擎回读被隐藏的 billboard `scale` ——
  2772. * 视觉已经不在 billboard 上,尺寸却仍以它为真值,与票 58 §四「DOM 是唯一视觉尺寸源」冲突。
  2773. * 现在候选编码与判据都和引擎同一份(domain `shouldEmphasize`;点位码 + 设备码数组),
  2774. * 只是真值改由这里算,引擎那侧不再被回读。
  2775. * @returns {Map<string, number>} `kind|code` → scale(1 = 常态,`EMPHASIS_STYLE.markerScale` = 强调)
  2776. */
  2777. function pointEmphasisScales() {
  2778. const scales = new Map()
  2779. const wanted = new Set(emphasisCodes)
  2780. for (const marker of markerPlan.markers || []) {
  2781. const candidates = [marker.code, ...(Array.isArray(marker.deviceCodes) ? marker.deviceCodes : [])]
  2782. scales.set(marker.kind + '|' + marker.code, shouldEmphasize(candidates, wanted) ? EMPHASIS_STYLE.markerScale : 1)
  2783. }
  2784. return scales
  2785. }
  2786. function toneColors() {
  2787. // 票 35 卡顿整改:`querySelector` + `getComputedStyle` 会触发布局/样式解析,
  2788. // 原实现每帧调一次。语义色只随 tokens.css 变,进程内缓存一份即可;
  2789. // 读不到 shell(尚未挂载/脱浏览器)时**不缓存**,下次挂载后再读。
  2790. if (toneColorsCache) return toneColorsCache
  2791. const colors = { ...TONE_FALLBACK }
  2792. if (typeof document === 'undefined' || !document.querySelector) return colors
  2793. const shell = document.querySelector('.cockpit-shell')
  2794. if (!shell || typeof getComputedStyle !== 'function') return colors
  2795. const styles = getComputedStyle(shell)
  2796. for (const tone of Object.keys(TONE_FALLBACK)) {
  2797. const value = styles.getPropertyValue(`--${tone}`)
  2798. if (value && value.trim()) colors[tone] = value.trim()
  2799. }
  2800. toneColorsCache = colors
  2801. return colors
  2802. }
  2803. /** 主题/样式表变化时丢掉缓存(目前没有调用方;导出给未来的换肤入口用)。 */
  2804. export function invalidateToneColors() {
  2805. toneColorsCache = null
  2806. }
  2807. // ---- 场景图层的"降峰"调度(票 35 第五轮)----------------------------------------------
  2808. //
  2809. // 维护者实测:`movingFrameIntervals` 的 p95 尖峰出现在**地图摘要更新与镜头移动同时发生**时,
  2810. // 而同一时刻 `renderSubmissionDuration` 只有 5–7ms —— 说明尖峰不在渲染提交里,而是摘要落地
  2811. // 那一拍在主线程上成批创建实体/位图/DOM 节点,把下一帧挤掉了。
  2812. //
  2813. // 对策:把"成批创建/销毁实体"这类重活从数据到达的那一刻挪开 ——
  2814. // · 镜头在动:先记账,等落定再落地(最多 700ms,避免长飞行期间一直不更新);
  2815. // · 镜头静止:照旧立即落地。
  2816. // 合并语义:同一类落地任务只保留最后一次(连续翻页/多次摘要到达不会叠加执行)。
  2817. /** 管线图层最近一次落地的计划引用(同引用短路用)与延迟重试状态。 */
  2818. let lastPipeLayerRefs = { pipes: null, components: null, viewMode: null, viewer: null, overlayActive: null }
  2819. let pendingPipeLayerPlan = null
  2820. let pipeSyncDeferred = false
  2821. let pipeSyncDeferredAt = 0
  2822. let pipeSyncDeferredPlan = null
  2823. /**
  2824. * 相机是否**正被动画驱动**(用户拖拽 / 换中心飞行 / 整段视角飞行)。
  2825. *
  2826. * 2026-10-27 修(票 70,维护者报「点监测点定位后子设备点要等一会 / 点几次刷新才出现」):
  2827. * 判据此前是 `getState().settled === false` —— 而 `settled` 把「阻尼收敛尾段」也算成在动
  2828. * (阈值 1e-7° / 0.5m,定位那一下要约 1.1–1.5s 才为真)。推迟队列的**两个**补跑点都要求
  2829. * `!cameraInMotion()`,于是设备实体被推到收敛那一帧才创建。
  2830. *
  2831. * 真正需要避让的是「画面正在被动画改写」;阻尼尾段不属于此列 —— 它本来也在 700ms 上限之后
  2832. * 被允许落地。开发把 `cameraControl.getState()` 已经给了三个驱动标志(`dragging/flying/flyingView`),
  2833. * 这里直接读它们;替身只提供 `settled` 时退回旧判据(测试替身不必补新字段)。
  2834. */
  2835. function cameraInMotion() {
  2836. const state = instance && instance.cameraControl ? instance.cameraControl.getState() : null
  2837. if (!state) return false
  2838. const hasDriveFlags = typeof state.dragging === 'boolean'
  2839. || typeof state.flying === 'boolean'
  2840. || typeof state.flyingView === 'boolean'
  2841. if (hasDriveFlags) {
  2842. return state.dragging === true || state.flying === true || state.flyingView === true
  2843. }
  2844. return state.settled === false
  2845. }
  2846. // 任务按"类"分槽:点位标记与关联设备图层是两类独立的落地,**不能互相覆盖**。
  2847. // (单槽位合并的写法会让后到的设备任务顶掉标记任务,导致标记停留在旧状态。)
  2848. const deferredSceneTasks = new Map()
  2849. let deferredSceneApplyAt = 0
  2850. const SCENE_APPLY_MAX_DEFER_MS = 700
  2851. /**
  2852. * 上限兜底的**真定时器**(2026-10-27 修,票 70)。
  2853. *
  2854. * 此前上限只在「下一次 `scheduleSceneApply` 被调用」时惰性判定 —— 地图空闲、没有新落地任务时
  2855. * (典型:点了点位、定位完成、用户不再操作)它永远不会到点,于是队列一直挂着,
  2856. * 只有 `force` 路径(刷新地图 / 换点 / 翻页)才会因 `overdue` 立即补跑。
  2857. * 这解释了维护者「要等一会,或者点几次刷新地图才出现」的第二半。
  2858. */
  2859. let deferredSceneApplyTimer = null
  2860. /**
  2861. * 关联设备层的「已下单但尚未落地」只读位(2026-10-27,票 70)。
  2862. *
  2863. * 为什么需要:`syncAssociatedDevices` 是**先入队再返回 true**,于是 store 侧的 `painted`
  2864. * 只代表「已下单」。若这份订单随后被清层吃掉(换点/翻页先撤后取),store 的计划指纹
  2865. * (`deviceLayerStore` 的 `lastPlanKey` 短路)会认为「同一份计划已经画过」而不再重发 ——
  2866. * 只有 `force` 路径(刷新地图)能救,这正是「必须点刷新」的结构性原因。
  2867. * 有了这个位,store 的短路改为「计划没变**且**没有未落地的订单」才跳过。
  2868. */
  2869. let deviceApplyPending = false
  2870. /** 关联设备层是否有尚未落地的订单(store 的指纹短路据此放行自愈)。 */
  2871. export function isDeviceApplyPending() {
  2872. return deviceApplyPending
  2873. }
  2874. /** 是否还有排队中的落地任务(渲染帧回调据此决定要不要补跑)。 */
  2875. function hasDeferredSceneApply() {
  2876. return deferredSceneTasks.size > 0
  2877. }
  2878. /** 提交一个场景落地任务;镜头在动时推迟到落定或上限到点。`key` 相同的任务只保留最后一次。 */
  2879. function scheduleSceneApply(key, task) {
  2880. deferredSceneTasks.set(key, task)
  2881. if (!deferredSceneApplyAt) deferredSceneApplyAt = engineNow()
  2882. const elapsed = engineNow() - deferredSceneApplyAt
  2883. if (!cameraInMotion() || elapsed >= SCENE_APPLY_MAX_DEFER_MS) {
  2884. flushSceneApply()
  2885. return
  2886. }
  2887. if (deferredSceneApplyTimer === null && typeof setTimeout === 'function') {
  2888. const remaining = Math.max(0, SCENE_APPLY_MAX_DEFER_MS - elapsed)
  2889. deferredSceneApplyTimer = setTimeout(() => {
  2890. deferredSceneApplyTimer = null
  2891. flushSceneApply()
  2892. }, remaining)
  2893. }
  2894. }
  2895. /** 真正执行排队中的落地任务(上限到点、渲染帧补跑、或相机停下时调)。 */
  2896. function flushSceneApply() {
  2897. if (deferredSceneApplyTimer !== null) {
  2898. clearTimeout(deferredSceneApplyTimer)
  2899. deferredSceneApplyTimer = null
  2900. }
  2901. if (!deferredSceneTasks.size) {
  2902. deferredSceneApplyAt = 0
  2903. return
  2904. }
  2905. const tasks = [...deferredSceneTasks.values()]
  2906. deferredSceneTasks.clear()
  2907. deferredSceneApplyAt = 0
  2908. for (const task of tasks) {
  2909. try {
  2910. task()
  2911. } catch (error) {
  2912. // 落地失败不该让地图停摆:记一条宿主读数,等下一次摘要再试。
  2913. if (instance && isDomHost(instance.host)) instance.host.dataset.mapSceneApplyError = String(error && error.message || error)
  2914. }
  2915. }
  2916. }
  2917. /**
  2918. * 同步**模式图层计划**给出的点位标记到地图上(票 09 验收第 1、4 条;票 46 改为消费计划)。
  2919. *
  2920. * 票 46 起,入参是 `domain/modeLayerPlan.js` 的输出(`{ markers, byStatus }`),
  2921. * **不再是原始 objects** —— 模式可见性的裁决已在计划层收口,服务层不再重判
  2922. * (规格 18:引擎和组件只消费计划,不做第二次过滤)。
  2923. *
  2924. * 引擎没就绪时只更新计划(图例仍能显示),返回 false;调用方(`MapCanvas`)会在就绪后重试。
  2925. * @param {{markers: Array, byStatus?: object}} plan 模式图层计划的标记段
  2926. * @param {object} [legendSource] 可选的图例行(默认由计划现算),便于界面直接复用同一份
  2927. * @returns {boolean} 是否已画到地图上
  2928. */
  2929. /**
  2930. * 票 85:刷新摘要标记、关联设备、设施点的合计状态。
  2931. * 所有参与图层共享阈值与收敛系数,计划更新或清层后立即重算。
  2932. */
  2933. function refreshCombinedEffectState() {
  2934. combinedEffectState = combineEffectPlans([markerEffectsPlan, deviceEffectsPlan, facilityEffectsPlan], resolveDeviceEffectsOptions())
  2935. paintMarkerEffects()
  2936. paintDeviceEffects()
  2937. return combinedEffectState
  2938. }
  2939. /**
  2940. * 票 85:给设备图层计划提供「另外两类对象」的异常数,让 store 侧的降级判定与视觉门控同源。
  2941. * 维护者 2026-11-06:设施点报警也计在同屏异常里(设施特效与设备同一套门控,不能各算各的)。
  2942. */
  2943. export function getEffectsAbnormalBase() {
  2944. const facilityAbnormal = facilityEffectsPlan ? facilityEffectsPlan.ownAbnormalCount : 0
  2945. return abnormalObjectCount(markerPlan.markers || []) + facilityAbnormal
  2946. }
  2947. export function syncPointMarkersForPlan(plan, legendSource) {
  2948. const markers = (plan && Array.isArray(plan.markers)) ? plan.markers : []
  2949. markerPlan = {
  2950. markers,
  2951. blocked: [],
  2952. byStatus: (plan && plan.byStatus) || summarizeMarkerStatuses(markers),
  2953. total: markers.length,
  2954. }
  2955. // 票 45:摘要标记(监测点/未关联设备)动效计划 —— 与设备动效同一映射与门控
  2956. // (planDeviceEffects:ALARM/WARNING/WORK_ORDER 三态动效,异常标记超限降级全停)。
  2957. // 行键用 `kind|code`:点位与设备编码空间独立,复合键防跨表撞码串环。
  2958. // 门控判定仍在 domain,本层只搬运(票 47 同一条纪律)。
  2959. markerEffectsPlan = planDeviceEffects({
  2960. devices: markers.map((item) => ({ code: `${item.kind}|${item.code}`, status: item.status })),
  2961. ...resolveDeviceEffectsOptions(),
  2962. })
  2963. refreshCombinedEffectState()
  2964. if (!instance || !instance.engine || !instance.viewer) {
  2965. paintMarkerState()
  2966. return false
  2967. }
  2968. // 降峰:镜头在动时把"成批建实体/位图"推迟到落定(见 scheduleSceneApply 的说明)。
  2969. // 计划本身已经同步更新,图例/面板/DOM 立即反映;只有 GPU 实体稍后落地。
  2970. scheduleSceneApply('markers', () => {
  2971. if (!instance || !instance.engine || !instance.viewer) return
  2972. const colors = toneColors()
  2973. // 分帧落地(票 35 第七轮):每帧最多新建 120 个实体,其余下一帧继续。
  2974. // 一页几百个点位原先在同一个任务里全部建完,是"点位加载时卡一下"的直接原因;
  2975. // DOM overlay 与图例都是全量的,所以分帧期间画面不会"缺点"。
  2976. const runBatch = () => {
  2977. if (!instance || !instance.engine || !instance.viewer) return
  2978. const result = instance.engine.syncPointMarkers(
  2979. instance.viewer,
  2980. markerPlan.markers,
  2981. (marker) => colors[marker.tone] || colors.neutral,
  2982. // 票 82:监测点兜底尺寸取自**单源几何模块**(`MARKER_BODY_WIDTH.point`)——
  2983. // 「点始终大于设备」只在一处成立,不会两边各写一个字面量而悄悄反转。
  2984. (shape, color) => markerSvg(shape, color, MARKER_BODY_WIDTH.point),
  2985. // 批次 3:合成计划层——每个标记按 (kind/status/industry) 产出双层水滴合成计划,
  2986. // 由 `renderMarker` 异步烘焙成 billboard 位图;首帧走 `markerFallbackSvg` 同步兜底。
  2987. (marker) => planMarker({ kind: 'monitoring-point', status: marker.status, industry: marker.industry, tone: marker.tone }),
  2988. (marker) => colors[marker.tone] || colors.neutral,
  2989. // 票 76(C4):DOM 成为唯一视觉源时隐藏 billboard 视觉(走查定档:pinBrightness=1 即够)。
  2990. // 判据与**关联设备层共用** `billboardVisualHidden()` —— 两条 sync 入口只有一个开关,
  2991. // 避免再出现「点位修好、设备点没修」。
  2992. { hideVisual: billboardVisualHidden(), budget: 120 },
  2993. )
  2994. // A9 修复(票 60 自查):`data-map-markers` 的契约是「**已画到地图上的**标记数」,
  2995. // 而 `result.rendered` 只是**本帧增量** —— 分帧期间两者差一个量级
  2996. // (实测 500 点位收敛后读数停在 20),而 t09/t46 走查正是读这一行断言
  2997. // 「标记数 == 计划数」,于是会长期假红且不会自愈。
  2998. // 真值 = 本帧新建 + 本轮未变(两者都在图上);未建的记在 remaining 里,三者互斥且穷尽。
  2999. markerRendered = result.rendered + result.unchanged
  3000. markerSkipped = result.skipped
  3001. paintMarkerState()
  3002. if (!result.complete) requestAnimationFrame(runBatch)
  3003. }
  3004. runBatch()
  3005. })
  3006. return true
  3007. }
  3008. /**
  3009. * 同步**设施点位与管线图层**(票 24)。
  3010. *
  3011. * 与摘要标记分开是结构性的:本函数只写 `cockpit-pipelines` 那个独立数据源,
  3012. * 摘要标记序列与图例一个字节都不动 —— 管线是**设施**、不是监测点,
  3013. * 两者的拾取身份、统计口径与坐标纪律都不同(见 `domain/pipeNetwork.js` 文件头)。
  3014. *
  3015. * 图层过滤只由 `domain/modeLayerPlan.js` 决定并随计划给出,本函数**不再筛一次**:
  3016. * 计划里给什么就画什么(`hidden` 体现为空数组,于是自动撤下)。
  3017. * @param {object} plan `planModeLayers()` 的结果
  3018. * @returns {Promise<boolean>} 是否已画到地图上
  3019. */
  3020. export async function syncPipeLayerForPlan(plan) {
  3021. pendingPipeLayerPlan = plan
  3022. const layer = {
  3023. pipes: plan && Array.isArray(plan.pipelines) ? plan.pipelines : [],
  3024. components: plan && Array.isArray(plan.facilities) ? plan.facilities : [],
  3025. }
  3026. // 半启动 Viewer 也能先画兜底;DOM 挂载后的同数组计划必须重新发布接管状态。
  3027. const overlayActive = billboardVisualHidden()
  3028. // 移动期只保留一个最新计划。B 到达后不能继续排队 A,再由旧定时器回画 A。
  3029. if (pipeSyncDeferred) {
  3030. pipeComponentRows = pipeComponentMarkers(layer.components)
  3031. pipeSyncDeferredPlan = plan
  3032. return false
  3033. }
  3034. // 票 35 第六轮(基础设施模式反而更卡):
  3035. // 1) **同引用短路**:计划层对同一批数据是原样引用的(见 modeLayerPlan 注释),
  3036. // 30s freshness tick 会让 watcher 再触发一次;引用没变、视图没变就不必重新指纹比较
  3037. // 几百条管段(每条一次 JSON.stringify)。基础设施模式管子最多,这一步最值钱。
  3038. // 2) **落地让路**:镜头在动时把这次同步排到落定后(最多 700ms),别和移动帧抢主线程。
  3039. if (layer.pipes === lastPipeLayerRefs.pipes && layer.components === lastPipeLayerRefs.components
  3040. && lastPipeLayerRefs.viewMode === viewMode && instance?.viewer
  3041. && lastPipeLayerRefs.viewer === instance.viewer
  3042. && lastPipeLayerRefs.overlayActive === overlayActive) {
  3043. return true
  3044. }
  3045. // 票 101:构件在地面视图升级为「点位标记」(DOM overlay)。行由领域层给出
  3046. // (符号/标签/行业色),这里只随图层计划同步;管线视图的 3D 模型不受影响。
  3047. pipeComponentRows = pipeComponentMarkers(layer.components)
  3048. if (cameraInMotion() && !pipeSyncDeferred) {
  3049. // 排队到镜头落定:保留最新一次计划,重试期间不被短路挡住。
  3050. const owner = instance
  3051. pipeSyncDeferred = true
  3052. pipeSyncDeferredAt = engineNow()
  3053. pipeSyncDeferredPlan = plan
  3054. lastPipeLayerRefs = { pipes: null, components: null, viewMode: null }
  3055. const retry = () => {
  3056. if (instance !== owner) return
  3057. if (!instance || !instance.viewer) { pipeSyncDeferred = false; return }
  3058. if (cameraInMotion() && engineNow() - pipeSyncDeferredAt < SCENE_APPLY_MAX_DEFER_MS) {
  3059. setTimeout(retry, 120)
  3060. return
  3061. }
  3062. pipeSyncDeferred = false
  3063. const queued = pipeSyncDeferredPlan
  3064. pipeSyncDeferredPlan = null
  3065. if (queued && queued === pendingPipeLayerPlan) void syncPipeLayerForPlan(queued)
  3066. }
  3067. setTimeout(retry, 120)
  3068. return false
  3069. }
  3070. if (!instance || !instance.viewer) return false
  3071. const record = instance
  3072. const appliedViewMode = viewMode
  3073. const engine = await import('./cesiumPipeLayer.js')
  3074. if (instance !== record || !record.viewer) return false
  3075. record.pipeLayer = engine
  3076. engine.setPipeViewMode(record.viewer, viewMode)
  3077. const result = await engine.syncPipeLayer(record.viewer, layer, {
  3078. componentOverlayActive: billboardVisualHidden,
  3079. onModelChange: (state) => {
  3080. if (instance !== record || !record.host) return
  3081. const host = record.host
  3082. if (host.dataset) {
  3083. host.dataset.mapPipeModelBatches = String(state.batches)
  3084. host.dataset.mapPipeModelInstances = String(state.instances)
  3085. host.dataset.mapPipeModelFallback = String(state.fallback)
  3086. host.dataset.mapPipeModelPending = String(state.pending)
  3087. host.dataset.mapPipeModelFailed = String(state.failed)
  3088. host.dataset.mapPipeModelKinds = JSON.stringify(state.byKind)
  3089. host.dataset.mapPipeVirtualDepth = 'category-default'
  3090. }
  3091. host.dispatchEvent(new CustomEvent('cockpit-pipe-model-state', { detail: state }))
  3092. } })
  3093. if (instance !== record || pendingPipeLayerPlan !== plan || result.rendered !== true) return false
  3094. lastPipeLayerRefs = { pipes: layer.pipes, components: layer.components, viewMode: appliedViewMode, viewer: record.viewer, overlayActive }
  3095. const host = record.host
  3096. if (host && host.dataset) {
  3097. host.dataset.mapPipes = String(layer.pipes.length)
  3098. host.dataset.mapPipeComponents = String(layer.components.length)
  3099. }
  3100. return true
  3101. }
  3102. /**
  3103. * 同步**设施点图层**(票 68)。
  3104. *
  3105. * 与摘要标记/关联设备分开是结构性的:设施点不是监测点、也不是设备,不进摘要序列、
  3106. * 不进设备图例或重叠判定;状态特效共享设备的门控与参数。DOM overlay 负责渲染,
  3107. * 本函数持有本轮计划并发布计数(与设施图例的「可上图 N」口径一致)。
  3108. *
  3109. * `rows` 已由 store 按设施类型 + 全局行业/片区筛好,计划层只按一级模式显隐,这里不再过滤。
  3110. *
  3111. * @param {Array} rows 设施点标记行(`domain/facilityPoints.js` 的 `facilityPointMarkers()` 输出)
  3112. * @returns {number} 本轮交给渲染层的设施点数
  3113. */
  3114. export function syncFacilityMarkersForPlan(rows) {
  3115. facilityMarkerRows = Array.isArray(rows) ? rows : []
  3116. // 状态归一来自设施合成计划;复用设备的开关、异常计数与降级,不拿色键猜状态。
  3117. facilityEffectsPlan = planDeviceEffects({
  3118. devices: facilityMarkerRows.filter(Boolean).map((row) => ({
  3119. code: row.key || (row.code ? `facility-point|${row.code}` : ''),
  3120. status: overlayFacilityPlanMarkerCached(row).effectStatus,
  3121. })),
  3122. ...resolveDeviceEffectsOptions(),
  3123. })
  3124. refreshCombinedEffectState()
  3125. // watcher 可能早于合并后的 marker 同步;以新行重算可见选择,避免保留已筛掉的高亮。
  3126. setFacilitySelection(facilitySelectionIdentity)
  3127. const host = instance && isDomHost(instance.host) ? instance.host : null
  3128. if (host && host.dataset) {
  3129. host.dataset.mapFacilityPoints = String(facilityMarkerRows.length)
  3130. const byType = {}
  3131. for (const row of facilityMarkerRows) {
  3132. if (!row || !row.type) continue
  3133. byType[row.type] = (byType[row.type] || 0) + 1
  3134. }
  3135. host.dataset.mapFacilityTypes = Object.entries(byType).map(([type, count]) => `${type}:${count}`).join(',')
  3136. // 特效读数面(走查直接读,不必翻 DOM):本轮放行的设施点动效条数 + 计入异常的条数。
  3137. const facilityEffects = facilityEffectsPlan || { animated: [], ownAbnormalCount: 0 }
  3138. host.dataset.mapFacilityEffectsAnimated = String((facilityEffects.animated || []).length)
  3139. host.dataset.mapFacilityEffectsAbnormal = String(facilityEffects.ownAbnormalCount || 0)
  3140. }
  3141. return facilityMarkerRows.length
  3142. }
  3143. /** 设施选中视觉只镜像实体详情 store 的身份;筛掉或关详情就清空,不自建第二套业务选择。 */
  3144. export function setFacilitySelection(identity) {
  3145. facilitySelectionIdentity = identity || null
  3146. const row = identity?.kind === 'facility' ? facilityMarkerRows.find(item =>
  3147. item.entityId === identity.id && item.detailCategory === identity.category) : null
  3148. selectedFacilityKey = row?.key || ''
  3149. if (instance?.host?.dataset) instance.host.dataset.mapFacilityPicked = selectedFacilityKey
  3150. }
  3151. function facilityMarkerFor(code, key = '') {
  3152. if (!code && !key) return null
  3153. const matches = facilityMarkerRows.filter(item => key
  3154. ? item.key === key && (!code || item.code === code)
  3155. : item.code === code)
  3156. return matches.length === 1 ? matches[0] : null
  3157. }
  3158. /** 本轮设施点计划行(走查/单测读;与宿主 `data-map-facility-*` 同源)。 */
  3159. export function getFacilityMarkerRows() {
  3160. return facilityMarkerRows
  3161. }
  3162. /**
  3163. * 同步**关联监测设备临时图层**(票 42)。
  3164. *
  3165. * 与摘要标记分开是结构性的:本函数只写 `DEVICE_MARKER_SOURCE` 那个独立数据源,
  3166. * 摘要的 `markerPlan`/`data-map-markers*` 一个字节都不动(规格:关联设备不写回摘要序列、
  3167. * 不分页、不进摘要图例与计数)。设备图例另有一套互斥计数(`domain/deviceLayer.js`)。
  3168. *
  3169. * 形状/色值仍走既有单一来源:形状来自传入的计划(票 44 会接管设备形状词表),
  3170. * 色值仍由 `toneColors()` 从 token 读 —— 本层不写任何色值。
  3171. *
  3172. * @param {Array} devices **已判定可上图**的领域行(`placement === 'drawn'`)
  3173. * @returns {boolean} 是否已画到地图上(引擎没就绪时只记计划,返回 false)
  3174. */
  3175. /**
  3176. * 应用单个重叠组呈现(票 76 四态规格 2026-10-26)。
  3177. *
  3178. * 四态由**两个独立变量**决定,本函数只做「入参 → 两个变量」的翻译:
  3179. * `pinned`(环是否固定) ②④ 为真;①③ 为假(只在 hover 时临时展开)
  3180. * `selectedCode`(中心设备)③④ 非空;①② 为空(中心是聚合点)
  3181. *
  3182. * ① 初始 pinned=false, selectedCode=''
  3183. * ② 被选择 pinned=true, selectedCode=''
  3184. * ③ 设备常态 pinned=false, selectedCode=<设备> ← 关键:环**不固定**
  3185. * ④ 设备被选择 pinned=true, selectedCode=<设备>
  3186. *
  3187. * **2026-10-26 修(事件冲突根因)**:此前 `pinned` 由 `state` 推出(`EXPANDED`/`SELECTED` → 真),
  3188. * 而 hover 预览也走 `state='expanded'` → **预览被当成固定**:一 hover 就固定环、发光、
  3189. * 把 `overlapPreviewGroup` 与固定态混在一起,于是「hover 意外进入选择态、之后点不动」。
  3190. * 现在 `pinned` 由调用方**显式**给出(`options.pinned`),`state` 只表达「环可见性」。
  3191. */
  3192. function applyOverlapPresentation(groupKey, state, preview = false, options = {}) {
  3193. const group = overlapGroups.find((item) => item.key === groupKey && item.k > 1)
  3194. if (!group) return false
  3195. const selectedCode = group.rows.some((row) => row.code === selectedDeviceCode) ? selectedDeviceCode : ''
  3196. // 固定与否**只由调用方显式声明**:预览态恒不固定;其余按 options.pinned。
  3197. const pinned = preview !== true && options.pinned === true
  3198. // **状态先行、渲染其次**(2026-10-26 关键修):
  3199. // 原实现在引擎未就绪时 `return false` 提前退出,`overlapState` / `overlapCountValue` 全都不更新
  3200. // —— 状态机被「渲染是否就绪」门控,于是同一串点击在不同时序下落到不同状态(走查的「不稳定」)。
  3201. // 状态是纯数据,必须无条件写;只有「画到三维」这件事需要引擎。
  3202. overlapState = state
  3203. overlapCountValue = group.k
  3204. if (!instance || !instance.engine || !instance.viewer) {
  3205. paintOverlapState()
  3206. return false
  3207. }
  3208. const fanGroup = selectedCode
  3209. ? { ...group, rows: group.rows.filter((row) => row.code !== selectedCode), maxFan: Math.max(1, group.maxFan - 1) }
  3210. : group
  3211. const fan = selectedCode && group.maxFan <= 1 ? [] : fanLayout(fanGroup, { center: { x: 0, y: 0 }, radius: 48 })
  3212. const offsets = Object.fromEntries(fan.map((item) => [item.deviceCode, { x: item.x, y: item.y }]))
  3213. instance.engine.setOverlapPresentation(instance.viewer, {
  3214. groupKey, state, preview, pinned, offsets, selectedCode,
  3215. })
  3216. paintOverlapState()
  3217. return true
  3218. }
  3219. function paintOverlapState() {
  3220. if (!instance || !isDomHost(instance.host)) return
  3221. const selectedGroup = overlapGroups.find((item) => item.k > 1 && item.rows.some((row) => row.code === selectedDeviceCode))
  3222. const groupKey = overlapExpandedGroup || overlapPreviewGroup || (selectedGroup && selectedGroup.key)
  3223. const group = overlapGroups.find((item) => item.key === groupKey && item.k > 1)
  3224. const degraded = overlapGroups.some((item) => item.k > 1 && item.degraded)
  3225. instance.host.dataset.mapOverlapState = group
  3226. ? (group.degraded ? OVERLAP_STATE.DEGRADED : overlapState)
  3227. : (degraded ? OVERLAP_STATE.DEGRADED : OVERLAP_STATE.COLLAPSED)
  3228. instance.host.dataset.mapOverlapCount = String(group ? group.k : overlapCountValue || 0)
  3229. }
  3230. function collapseOverlap(value = '') {
  3231. const selectedGroup = overlapGroups.find((item) => item.k > 1 && item.rows.some((row) => row.code === selectedDeviceCode))
  3232. const target = resolveOverlapGroupKey(value || overlapExpandedGroup || overlapPreviewGroup || (selectedGroup && selectedGroup.key))
  3233. const selectedCode = selectedGroup && selectedGroup.key === target ? selectedDeviceCode : ''
  3234. if (target && instance && instance.engine && instance.viewer) {
  3235. // 票 76(四态):收起后若仍有中心设备 → ③ 设备常态(环不固定、不发光),
  3236. // 只有「被选择态」才固定(②④)。
  3237. instance.engine.setOverlapPresentation(instance.viewer, {
  3238. groupKey: target, state: selectedCode ? 'expanded' : 'collapsed', selectedCode,
  3239. pinned: false,
  3240. })
  3241. }
  3242. overlapPreviewGroup = ''
  3243. overlapExpandedGroup = ''
  3244. overlapState = selectedGroup ? OVERLAP_STATE.EXPANDED : transitionOverlapState(overlapState, 'close')
  3245. overlapCountValue = selectedGroup ? selectedGroup.k : 0
  3246. paintOverlapState()
  3247. }
  3248. function resolveOverlapGroupKey(value) {
  3249. if (!value) return ''
  3250. const found = overlapGroups.find((item) => item.key === value || item.pointCode === value)
  3251. return found ? found.key : value
  3252. }
  3253. export function getOverlapTargets() {
  3254. if (!instance || !instance.engine || !instance.viewer) return []
  3255. return instance.engine.readOverlapTargets(instance.viewer)
  3256. }
  3257. export function nudgeOverlap(pointCode) {
  3258. const groupKey = resolveOverlapGroupKey(pointCode)
  3259. const group = overlapGroups.find((item) => item.key === groupKey && item.k > 1)
  3260. if (!group || !instance || !instance.engine || !instance.viewer) return false
  3261. return instance.engine.nudgeOverlap(instance.viewer, groupKey)
  3262. }
  3263. function toggleOverlap(value) {
  3264. const groupKey = resolveOverlapGroupKey(value)
  3265. if (!groupKey) return getOverlapState()
  3266. // 票 76(四态规格 2026-10-26)点聚合点图标:
  3267. // · 已在 ②(固定 + 中心是聚合点)→ 收起回 ①;
  3268. // · 从 ① → ②;从 ③④ → ②(放弃当前中心设备,回到聚合点的被选择态)。
  3269. // **点聚合入口永远是「聚合点被选择」**,不会进 ④ —— ④ 只能由「点中心设备」进入。
  3270. const group = overlapGroups.find((item) => item.key === groupKey && item.k > 1)
  3271. const centerSelected = Boolean(selectedDeviceCode && group && group.rows.some((row) => row.code === selectedDeviceCode))
  3272. const alreadyPinnedClean = overlapExpandedGroup === groupKey && !centerSelected
  3273. if (alreadyPinnedClean) {
  3274. collapseOverlap(groupKey)
  3275. } else {
  3276. // 先清中心设备,再展开:`applyOverlapPresentation` 用清除后的值算 selectedCode,
  3277. // 于是引擎侧中心回到聚合点(②),不会残留旧中心被误判成 ④。
  3278. if (centerSelected) {
  3279. selectedDeviceCode = ''
  3280. // 面板与地图必须同步清选中:`deviceLayerStore` 通过 `setSelectedDevice()` 的返回值/副作用
  3281. // 跟随,这里显式走一次设备反应同步(面板高亮、反应量都依赖它)。
  3282. syncDeviceReactions()
  3283. }
  3284. expandOverlap(groupKey, true)
  3285. }
  3286. return getOverlapState()
  3287. }
  3288. /**
  3289. * 结束 hover 预览(票 76 四态,2026-10-26)。
  3290. *
  3291. * 与 `collapseOverlap()` 的区别是本函数存在的全部理由:
  3292. * · `collapseOverlap` 是「收起整组」——会清 `overlapExpandedGroup`(固定态)并把状态打回 collapsed;
  3293. * · 本函数只撤销**预览**:固定态(②④)与中心设备(③④)**原样保留**。
  3294. *
  3295. * 之前 hover 离开时误用 `collapseOverlap`,导致 ③ 设备常态 hover 一次就失去选择能力
  3296. * (中心设备被清、`overlapExpandedGroup` 被清,后续点击判定全部失准)。
  3297. */
  3298. function endOverlapPreview() {
  3299. if (!overlapPreviewGroup) return false
  3300. const previewGroup = overlapPreviewGroup
  3301. overlapPreviewGroup = ''
  3302. const pinnedGroup = overlapExpandedGroup
  3303. const selectedGroup = overlapGroups.find((item) => item.k > 1 && item.rows.some((row) => row.code === selectedDeviceCode))
  3304. const keepKey = pinnedGroup || (selectedGroup && selectedGroup.key) || ''
  3305. if (keepKey && instance && instance.engine && instance.viewer) {
  3306. // 回到「无预览」的稳定呈现:有固定态 → 保持固定;否则若还有中心设备 → ③ 不固定。
  3307. const stillPinned = Boolean(pinnedGroup && pinnedGroup === keepKey)
  3308. applyOverlapPresentation(
  3309. keepKey,
  3310. stillPinned ? OVERLAP_STATE.SELECTED : OVERLAP_STATE.EXPANDED,
  3311. false,
  3312. { pinned: stillPinned },
  3313. )
  3314. } else if (instance && instance.engine && instance.viewer && previewGroup) {
  3315. instance.engine.setOverlapPresentation(instance.viewer, {
  3316. groupKey: previewGroup, state: 'collapsed', preview: false, pinned: false, selectedCode: '',
  3317. })
  3318. overlapState = transitionOverlapState(overlapState, 'close')
  3319. paintOverlapState()
  3320. }
  3321. return true
  3322. }
  3323. function expandOverlap(groupKey, sticky) {
  3324. if (!groupKey || !overlapGroups.some((item) => item.key === groupKey && item.k > 1)) return false
  3325. if (overlapExpandedGroup && overlapExpandedGroup !== groupKey) collapseOverlap(overlapExpandedGroup)
  3326. overlapPreviewGroup = sticky ? '' : groupKey
  3327. overlapExpandedGroup = sticky ? groupKey : ''
  3328. // 固定与否显式声明(票 76 四态):`sticky=点击` → 固定;`hover 预览` → 不固定。
  3329. applyOverlapPresentation(groupKey, OVERLAP_STATE.EXPANDED, !sticky, { pinned: sticky === true })
  3330. return true
  3331. }
  3332. export function syncAssociatedDevices(devices, options = {}) {
  3333. deviceRows = Array.isArray(devices) ? [...devices] : []
  3334. overlapMaxFan = Number.isInteger(options.maxFan) && options.maxFan > 0 ? options.maxFan : 10
  3335. overlapGroups = Array.isArray(options.overlapGroups)
  3336. ? options.overlapGroups
  3337. : groupOverlappingDevices(deviceRows, { maxFan: overlapMaxFan })
  3338. // 票 76(四态,2026-10-26 **关键修**):这里是**数据同步**,不是状态复位。
  3339. //
  3340. // 原实现无条件把 `overlapPreviewGroup`/`overlapExpandedGroup` 清空、`overlapState` 打回 COLLAPSED。
  3341. // 但本函数会被反复调用(设备行变化、镜头落定 `scheduleSceneApply` 重跑)——
  3342. // 于是用户刚点出来的 ③/④ 会被下一次同步静默清掉,表现为
  3343. // 「设备点进不去选择态 / 偶尔进去了又掉出来 / 点不动」。
  3344. //
  3345. // 正确做法:只保留**仍然有效**的聚合态 —— 组还在、中心设备还在,就保留;
  3346. // 组没了或设备不在了才复位。
  3347. const groupKeys = new Set(overlapGroups.filter((item) => item.k > 1).map((item) => item.key))
  3348. if (!overlapGroups.some((item) => item.k > 1 && item.rows.some((row) => row.code === selectedDeviceCode))) {
  3349. // 中心设备已不在任何聚合组里 → 聚合态无意义,复位(但不清 selectedDeviceCode 本身)。
  3350. overlapPreviewGroup = ''
  3351. overlapExpandedGroup = ''
  3352. overlapState = OVERLAP_STATE.COLLAPSED
  3353. } else {
  3354. // 组还在:只丢弃已失效的组键,保留当前态。
  3355. if (overlapPreviewGroup && !groupKeys.has(overlapPreviewGroup)) overlapPreviewGroup = ''
  3356. if (overlapExpandedGroup && !groupKeys.has(overlapExpandedGroup)) overlapExpandedGroup = ''
  3357. }
  3358. overlapCountValue = overlapGroups.reduce((max, group) => Math.max(max, group.k || 0), 0)
  3359. if (!instance || !instance.engine || !instance.viewer) {
  3360. paintDeviceState()
  3361. paintOverlapState()
  3362. return false
  3363. }
  3364. // 降峰:关联设备层同样是"每个设备一次 planDeviceAsset + 一次实体创建",
  3365. // 镜头在被动画驱动时把整批落地推迟到停下(计划与 overlapGroups 已同步更新,DOM 立即反映)。
  3366. // 2026-10-27(票 70):下单时置 `deviceApplyPending`,任务体首行清掉 ——
  3367. // store 侧据此判断「上一份计划是否真的落地过」,避免指纹短路把订单锁死。
  3368. deviceApplyPending = true
  3369. scheduleSceneApply('devices', () => {
  3370. deviceApplyPending = false
  3371. if (!instance || !instance.engine || !instance.viewer) return
  3372. const colors = toneColors()
  3373. const planned = deviceRows.map((device) => {
  3374. // 票 45:特效基准随标记**绘制宽**(plan.draw.width),不再是 14px 图标尺寸——
  3375. // 光环要罩得住图标本体(「特效不被图标尺寸压制」);引擎的 ensureRingEntities 读它。
  3376. const drawWidth = markerMetricsFor('monitoring-device').drawWidth
  3377. if (device.assetMode !== undefined) return { ...device, tone: device.tone || deviceVisualTone(device), drawWidth }
  3378. const asset = planDeviceAsset({ device, color: colors[deviceVisualTone(device)] || colors.neutral })
  3379. return {
  3380. ...device,
  3381. shape: asset.shape,
  3382. size: asset.size,
  3383. assetMode: asset.mode,
  3384. assetReason: asset.reason,
  3385. assetReasonText: asset.reasonText,
  3386. visualKey: asset.visualKey,
  3387. tone: deviceVisualTone(device),
  3388. drawWidth,
  3389. }
  3390. })
  3391. const result = instance.engine.syncAssociatedDevices(
  3392. instance.viewer,
  3393. planned,
  3394. (device) => colors[device.tone] || colors.neutral,
  3395. (shape, color, size) => markerSvg(shape, color, size),
  3396. overlapGroups,
  3397. // 批次 3:关联监测设备走实心母版 + 内层行业图标(规格 10),尖端锚定。
  3398. (device) => planMarker({ kind: 'monitoring-device', status: device.status, industry: device.industry, tone: device.tone }),
  3399. (device) => colors[device.tone] || colors.neutral,
  3400. // 票 76(C1):DOM overlay 挂载且可见时,聚合符号**视觉**由 overlay 承载 —— 引擎只保留
  3401. // 实体与读数面(身份/位置/投影/readAssociatedDevices 不变),billboard/label 不渲染,
  3402. // 两层不重画同一个聚合符号(票 76 / C6 后不再有 A/B 对照组)。
  3403. // 票 76(C1/C4):聚合符号与**设备本体**的视觉都让位给 DOM —— 同一判据 `billboardVisualHidden()`,
  3404. // 不会再出现「点位修好、设备点没修」。
  3405. {
  3406. hideAggregateVisual: billboardVisualHidden(),
  3407. hideDeviceVisual: billboardVisualHidden(),
  3408. },
  3409. )
  3410. deviceRows = planned
  3411. deviceRendered = result.rendered
  3412. deviceSkipped = result.skipped
  3413. // 票 47:动效计划随图层一起下发(门控判定在 domain,本层只搬运)。
  3414. deviceEffectsPlan = options.effects || null
  3415. refreshCombinedEffectState()
  3416. const selectedGroup = overlapGroups.find((group) => group.k > 1 && group.rows.some((row) => row.code === selectedDeviceCode))
  3417. // 票 76(四态,2026-10-26 修):重同步后**保持当前固定态**,不要一律置 ④。
  3418. // 之前这里无条件 `SELECTED`(= 固定),把 ③ 设备常态在每次设备层同步时抬成 ④,
  3419. // 表现为「设备常态自己跳进选择态、之后点不动」。
  3420. if (selectedGroup) {
  3421. const stillPinned = overlapExpandedGroup === selectedGroup.key
  3422. applyOverlapPresentation(
  3423. selectedGroup.key,
  3424. stillPinned ? OVERLAP_STATE.SELECTED : OVERLAP_STATE.EXPANDED,
  3425. false,
  3426. { pinned: stillPinned },
  3427. )
  3428. }
  3429. applyDeviceEffectsToEngine()
  3430. paintDeviceState()
  3431. paintOverlapState()
  3432. })
  3433. return true
  3434. }
  3435. /**
  3436. * 票 47:把动效计划交给引擎,并把**降级/开关**写成宿主只读字段(走查断言面)。
  3437. *
  3438. * 关掉特效 ≠ 关掉状态显示:这里只控制"动效",静态色/形/文字仍由 `applyDeviceReactions` 与
  3439. * 实体属性表达(决策票 07 问题 6 的原句)。
  3440. */
  3441. function applyDeviceEffectsToEngine() {
  3442. if (!instance || !instance.engine || !instance.viewer) {
  3443. paintDeviceEffects()
  3444. return false
  3445. }
  3446. // 票 76(C5):光环实体与逐帧动效已退役 —— 引擎只接收计划与选中(用于反应量基准),
  3447. // 视觉(多层环/辉光/闪烁/旋转)全部由 DOM overlay 的 `buildFxLayers` + CSS 承载。
  3448. const running = instance.engine.applyDeviceEffects(instance.viewer, deviceEffectsPlan, {
  3449. selectedCode: selectedDeviceCode,
  3450. })
  3451. paintDeviceEffects()
  3452. return running
  3453. }
  3454. /** 动效读数的宿主字段:`on|off|degraded` + 异常台数 + 参与动效台数。 */
  3455. function paintDeviceEffects() {
  3456. if (!instance || !isDomHost(instance.host)) return
  3457. const host = instance.host
  3458. // 票 85:读数按**跨图层合计**状态写(三类对象一套阈值、一套收敛系数)。
  3459. const state = combinedEffectState
  3460. if (state.enabled !== true) host.dataset.mapDeviceEffects = 'off'
  3461. else if (state.degraded) host.dataset.mapDeviceEffects = 'degraded'
  3462. else host.dataset.mapDeviceEffects = 'on'
  3463. host.dataset.mapDeviceEffectsAbnormal = String(state.abnormalCount)
  3464. host.dataset.mapDeviceEffectsAnimated = String(state.animatedCount)
  3465. host.dataset.mapDeviceEffectsConverge = state.converge ? `${state.converge.peak}/${state.converge.alpha}` : ''
  3466. }
  3467. /**
  3468. * 设备行 → 色键(票 44:色键来自**设备独立词表**,不借用点位表)。
  3469. *
  3470. * 单独成函数是为了让"设备颜色只表达设备状态"这条在服务层也一眼可见 ——
  3471. * 服务层不认识业务状态名,只问设备词表要一个色键。
  3472. */
  3473. function deviceVisualTone(device) {
  3474. return deviceVisualForStatus(device && device.status).tone
  3475. }
  3476. /** 清空关联设备临时图层(关闭 / 换点 / 刷新 / 快照失效 / 离开页面)。 */
  3477. export function clearAssociatedDevices() {
  3478. if (instance && instance.engine && instance.viewer) {
  3479. instance.engine.clearAssociatedDevices(instance.viewer)
  3480. }
  3481. deviceRows = []
  3482. deviceRendered = 0
  3483. deviceSkipped = 0
  3484. overlapGroups = []
  3485. overlapPreviewGroup = ''
  3486. overlapExpandedGroup = ''
  3487. overlapState = OVERLAP_STATE.COLLAPSED
  3488. overlapCountValue = 0
  3489. paintOverlapState()
  3490. // 票 43:图层撤下时**一并清掉选中与 hover**(否则会留着指向已不存在设备的强化读数,
  3491. // 走查与界面都会读到一个不存在的选中态)。
  3492. selectedDeviceCode = ''
  3493. hoveredDeviceCode = ''
  3494. // 票 47:动效随图层一起消失(计划清空 + 引擎侧已在 clearAssociatedDevices 里停循环)。
  3495. deviceEffectsPlan = null
  3496. refreshCombinedEffectState()
  3497. paintDeviceReactions()
  3498. paintDeviceState()
  3499. }
  3500. /** 当前关联设备图层的只读读数(走查与人工排查读它)。 */
  3501. export function getAssociatedDeviceState() {
  3502. let onMap = deviceRendered
  3503. let entities = []
  3504. if (instance && instance.engine && instance.viewer) {
  3505. entities = instance.engine.readAssociatedDevices(instance.viewer)
  3506. onMap = entities.filter((entity) => entity.deviceCode && entity.visible).length
  3507. }
  3508. return {
  3509. plan: [...deviceRows],
  3510. onMap,
  3511. skipped: deviceSkipped,
  3512. entities,
  3513. overlap: getOverlapState(),
  3514. }
  3515. }
  3516. export function clearOverlapNudge() {
  3517. if (instance && instance.engine && instance.viewer) instance.engine.clearOverlapNudge(instance.viewer)
  3518. }
  3519. /**
  3520. * 票 47:动效的只读读数 —— 计划(门控/降级/异常台数)+ 引擎里每台设备的**当前帧属性**。
  3521. *
  3522. * 走查的"真在跑"证据来自后者:对同一设备隔帧读两次,断言 `scale`/`alpha`(脉冲/闪烁)
  3523. * 或 `rotation`(旋转环)确实变了,而正常/离线设备**完全不变**。
  3524. */
  3525. export function getDeviceEffectState() {
  3526. const frames = instance && instance.engine && instance.viewer && instance.engine.readDeviceEffects
  3527. ? instance.engine.readDeviceEffects(instance.viewer)
  3528. : { tick: 0, running: false, devices: [] }
  3529. return { plan: deviceEffectsPlan, ...frames }
  3530. }
  3531. export function getOverlapState() {
  3532. const selectedGroup = overlapGroups.find((item) => item.k > 1 && item.rows.some((row) => row.code === selectedDeviceCode))
  3533. const groupKey = overlapExpandedGroup || overlapPreviewGroup || (selectedGroup && selectedGroup.key)
  3534. const group = overlapGroups.find((item) => item.key === groupKey && item.k > 1)
  3535. const degraded = overlapGroups.some((item) => item.k > 1 && item.degraded)
  3536. return {
  3537. state: group ? (group.degraded ? OVERLAP_STATE.DEGRADED : overlapState) : (degraded ? OVERLAP_STATE.DEGRADED : OVERLAP_STATE.COLLAPSED),
  3538. count: group ? group.k : overlapCountValue || 0,
  3539. pointCode: group ? group.pointCode : '',
  3540. groupKey: groupKey || '',
  3541. expanded: Boolean(overlapExpandedGroup),
  3542. selectedCode: selectedGroup ? selectedDeviceCode : '',
  3543. preview: Boolean(overlapPreviewGroup),
  3544. maxFan: overlapMaxFan,
  3545. }
  3546. }
  3547. /**
  3548. * 面板选择层级写进宿主 dataset(`data-map-panel-selection` = `none|point|equipment`)。
  3549. * 由外壳在详情层级变化时调它 —— 服务层不认识 Vue,所以层级值由调用方给。
  3550. */
  3551. export function setPanelSelectionLevel(level) {
  3552. if (level === 'point' || level === 'none') setSelectedDevice('')
  3553. if (!instance || !isDomHost(instance.host)) return
  3554. instance.host.dataset.mapPanelSelection = level || 'none'
  3555. }
  3556. /**
  3557. * 清空设备选中与 hover(图层撤下 / 关闭 / 换点 / 离开页面都走它)。
  3558. *
  3559. * 一并清引线:留着一条指向已撤下设备的线,是"归属指示"里最误导的一种残留。
  3560. */
  3561. export function clearDeviceSelection() {
  3562. const selectedGroup = overlapGroups.find((group) => group.rows.some((row) => row.code === selectedDeviceCode))
  3563. selectedDeviceCode = ''
  3564. hoveredDeviceCode = ''
  3565. collapseOverlap(selectedGroup ? selectedGroup.key : '')
  3566. if (instance && instance.engine && instance.viewer) {
  3567. instance.engine.clearDeviceLead(instance.viewer)
  3568. instance.engine.applyDeviceReactions(instance.viewer, [], reactionStyle)
  3569. }
  3570. paintDeviceReactions()
  3571. }
  3572. /** 当前选中/hover 的只读读数(走查与排查读它)。 */
  3573. export function getDeviceCorrespondenceState() {
  3574. return {
  3575. selected: selectedDeviceCode,
  3576. hovered: hoveredDeviceCode,
  3577. panelSelection: instance && isDomHost(instance.host) ? instance.host.dataset.mapPanelSelection || '' : '',
  3578. }
  3579. }
  3580. /** 设备图层读数写进宿主 dataset(走查读这一行;摘要那几个 `data-map-markers*` 不动)。 */
  3581. function paintDeviceState() {
  3582. if (!instance || !isDomHost(instance.host)) return
  3583. instance.host.dataset.mapDevices = String(deviceRendered)
  3584. instance.host.dataset.mapDevicesPlan = String(deviceRows.length)
  3585. }
  3586. // ---- 票 43:设备点的对应反应(hover / 选中 / 弱化 / 引线 / 拾取优先)----
  3587. /** 当前选中设备(`deviceCode`)与三维 hover 设备(`deviceCode`)。 */
  3588. let selectedDeviceCode = ''
  3589. /**
  3590. * 票 76(四态规格):一次点击内,`setSelectedDevice` 该落 ③ 还是 ④。
  3591. *
  3592. * 为什么需要这个抽屉变量:点击设备必须走**完整派发链**(`resolveOverlapPick → dispatchPickResult`
  3593. * → `pointPickHandlers` → `store.selectDevice` → `setSelectedDevice`),否则镜头不动、详情不弹
  3594. * (走查第 2 步的故障)。而 ③/④ 的区别是「是否点的是当前中心」,这个信息只有 `pickMarkerByIdentity`
  3595. * 在派发前知道。于是:派发前写入、`setSelectedDevice` 读取、派发后清空(try/finally 保证)。
  3596. * `null` = 不是经由聚合点击进入(面板/别的入口调用),此时按「同一台 = ④」的默认规则。
  3597. */
  3598. let pendingCenterChosen = null
  3599. let hoveredDeviceCode = ''
  3600. /**
  3601. * 票 47:当前设备图层的**动效计划**(`domain/deviceEffects.js` 的输出)。
  3602. * 由 store 计算后随 `syncAssociatedDevices` 传进来 —— 门控与降级的判定在 domain,
  3603. * 本层只负责把它交给引擎并暴露可断言读数。
  3604. */
  3605. let deviceEffectsPlan = null
  3606. /**
  3607. * 把反应档位写进引擎,并刷新三个可断言字段。
  3608. *
  3609. * 三个字段(规格点名的可断言口径,走查对断言不对图):
  3610. * · `data-map-device-picked` = 当前选中设备 `deviceCode`
  3611. * · `data-map-panel-selection` = `none|point|equipment`
  3612. * · `data-map-device-hovered` = 当前三维 hover 设备 `deviceCode`
  3613. * **不动既有 `data-map-picked`**(票 10 走查在用)。
  3614. */
  3615. function paintDeviceReactions() {
  3616. if (!instance || !isDomHost(instance.host)) return
  3617. const host = instance.host
  3618. host.dataset.mapDevicePicked = selectedDeviceCode || ''
  3619. host.dataset.mapDeviceHovered = hoveredDeviceCode || ''
  3620. }
  3621. /**
  3622. * 按当前选中/hover 重算每台设备的反应并写进引擎(纯判定在 domain,本层只搬运)。
  3623. * @param {object} [options] `{ devices }` 覆盖设备行(默认用图层计划)
  3624. */
  3625. export function syncDeviceReactions(options = {}) {
  3626. const devices = Array.isArray(options.devices) ? options.devices : deviceRows
  3627. const plan = planDeviceReactions({ devices, selectedCode: selectedDeviceCode, hoveredCode: hoveredDeviceCode })
  3628. if (instance && instance.engine && instance.viewer) {
  3629. instance.engine.applyDeviceReactions(instance.viewer, plan.reactions, reactionStyle)
  3630. // 票 47:反应量变了要重算动效 —— 动效是**乘在反应量基准上**的,
  3631. // 不重算的话选中强化的幅度会停在旧的基准上。
  3632. applyDeviceEffectsToEngine()
  3633. syncDeviceLeadForSelection(devices)
  3634. }
  3635. paintDeviceReactions()
  3636. return plan
  3637. }
  3638. /**
  3639. * 选中设备的归属引线:从设备点连到**监测点标记中心**(票 04/12 的锚点,归属不换)。
  3640. * 重合时按规格**退化不画**(`planDeviceLead().draw === false`)。
  3641. */
  3642. function syncDeviceLeadForSelection(devices) {
  3643. const list = Array.isArray(devices) ? devices : []
  3644. const selected = list.find((item) => item && item.code === selectedDeviceCode)
  3645. const pointMarker = markerPlan.markers.find((item) => item.kind === 'monitoring-point' && item.code === (selected && selected.pointCode))
  3646. if (!selected || !pointMarker) {
  3647. instance.engine.clearDeviceLead(instance.viewer)
  3648. return false
  3649. }
  3650. const lead = planDeviceLead({
  3651. deviceLon: selected.longitude,
  3652. deviceLat: selected.latitude,
  3653. pointLon: pointMarker.longitude,
  3654. pointLat: pointMarker.latitude,
  3655. })
  3656. if (!lead.draw) {
  3657. instance.engine.clearDeviceLead(instance.viewer)
  3658. return false
  3659. }
  3660. return instance.engine.syncDeviceLead(instance.viewer, {
  3661. deviceLon: selected.longitude,
  3662. deviceLat: selected.latitude,
  3663. pointLon: pointMarker.longitude,
  3664. pointLat: pointMarker.latitude,
  3665. deviceCode: selected.code,
  3666. pointCode: selected.pointCode,
  3667. })
  3668. }
  3669. /**
  3670. * 选中/取消选中一台设备(**面板与三维共用这一个入口**)。
  3671. * @param {string} deviceCode 设备编码;空串=取消设备选中
  3672. */
  3673. export function setSelectedDevice(deviceCode, options = {}) {
  3674. const next = typeof deviceCode === 'string' ? deviceCode.trim() : ''
  3675. const previousGroup = overlapGroups.find((group) => group.k > 1 && group.rows.some((row) => row.code === selectedDeviceCode))
  3676. const wasSameDevice = next !== '' && next === selectedDeviceCode
  3677. selectedDeviceCode = next
  3678. const selectedGroup = overlapGroups.find((group) => group.k > 1 && group.rows.some((row) => row.code === next))
  3679. // 票 76(四态规格 2026-10-26)③ 与 ④ 的判定:
  3680. // · 经由聚合点击(`pendingCenterChosen` 非 null)→ 完全听派发前的判定:
  3681. // 点环内设备 = ③(不固定),点中心设备 = ④(固定 + 发光);
  3682. // · 其它入口(面板/卡片)→ 同一台视为「再次选中」= ④,换一台视为 ③。
  3683. const chosen = pendingCenterChosen === null
  3684. ? wasSameDevice || options.centerChosen === true
  3685. : pendingCenterChosen === true
  3686. // 换组/离开聚合组时才收起旧的固定态;同组内切换设备**不能**收起(否则 ③ 拿不到固定态基线,
  3687. // 且会把刚写入的 pinned 立刻抹掉 —— 这是「第 3 步完全失效」的直接原因)。
  3688. if (previousGroup && previousGroup.key !== (selectedGroup && selectedGroup.key)) collapseOverlap(previousGroup.key)
  3689. else if (!selectedGroup && (overlapExpandedGroup || overlapPreviewGroup)) collapseOverlap()
  3690. if (selectedGroup) {
  3691. // `overlapExpandedGroup` 是后续同步/点击判定/**面板让位**(`getMapMarkerAnchor`)的唯一依据。
  3692. // **必须先写**:`applyOverlapPresentation` 内部会调 `paintOverlapState()` 读它;
  3693. // 之后再写会让让位判定落后一拍(走查:④ 有时让位、有时不让 —— 就是这个顺序问题)。
  3694. overlapExpandedGroup = chosen ? selectedGroup.key : ''
  3695. overlapPreviewGroup = ''
  3696. // 固定环由 `pinned` 显式表达:
  3697. // ③ 设备常态 → EXPANDED + pinned:false(环仅 hover 预览)
  3698. // ④ 设备被选择 → SELECTED + pinned:true(固定 + 发光)
  3699. applyOverlapPresentation(
  3700. selectedGroup.key,
  3701. chosen ? OVERLAP_STATE.SELECTED : OVERLAP_STATE.EXPANDED,
  3702. false,
  3703. { pinned: chosen },
  3704. )
  3705. }
  3706. syncDeviceReactions()
  3707. return selectedDeviceCode
  3708. }
  3709. /** 当前选中设备编码(只读)。 */
  3710. export function getSelectedDevice() {
  3711. return selectedDeviceCode
  3712. }
  3713. /**
  3714. * 三维 hover 一台设备(pointermove 拾取调用;空串=离开设备)。
  3715. *
  3716. * 只改 hover 档,**不动选中态**(规格:hover 是瞬态,选中是持久态,互不干扰)。
  3717. */
  3718. export function setHoveredDevice(deviceCode) {
  3719. const next = typeof deviceCode === 'string' ? deviceCode.trim() : ''
  3720. if (next === hoveredDeviceCode) return hoveredDeviceCode
  3721. hoveredDeviceCode = next
  3722. syncDeviceReactions()
  3723. return hoveredDeviceCode
  3724. }
  3725. /** 当前三维 hover 设备编码(只读)。 */
  3726. export function getHoveredDevice() {
  3727. return hoveredDeviceCode
  3728. }
  3729. const pipelinePickHandlers = new Set()
  3730. /** 管线/构件点选订阅;回调只收到业务身份,不收到 Cesium 实体。 */
  3731. export function onPipelinePick(handler) {
  3732. if (typeof handler !== 'function') return () => {}
  3733. pipelinePickHandlers.add(handler)
  3734. return () => pipelinePickHandlers.delete(handler)
  3735. }
  3736. export function clearPipelinePickHandlers() {
  3737. pipelinePickHandlers.clear()
  3738. }
  3739. function pickPipelineAt(clientX, clientY) {
  3740. if (!instance || !instance.engine || !instance.viewer || typeof instance.engine.pickPipeObjectAt !== 'function') {
  3741. return { target: 'none', pick: null }
  3742. }
  3743. const pick = instance.engine.pickPipeObjectAt(instance.viewer, clientX, clientY)
  3744. if (!pick) return { target: 'none', pick: null }
  3745. const result = { target: 'pipeline', pick }
  3746. for (const handler of [...pipelinePickHandlers]) handler(pick)
  3747. return result
  3748. }
  3749. /**
  3750. * 点选链路(票 43/23):**先设备点、再监测点**(规格:正常单点设备点优先、监测点兜底)。
  3751. *
  3752. * 优先级判定在 `domain/deviceCorrespondence.js` 的 `resolvePickOrder()`(可脱浏览器断言),
  3753. * 本层只负责"两次引擎拾取 + 按结论派发",不自己写 if-else 顺序。
  3754. * @returns {{target: string, pick: object|null}}
  3755. */
  3756. export function pickDeviceOrPointAt(clientX, clientY) {
  3757. // 票 76(C6):坐标点选改走 **DOM 命中盒**(`domMarkerAt`)—— billboard 的 GPU 拾取已退役。
  3758. // 保留本入口是因为 DEV 走查仍按「屏幕坐标」驱动同一条用户链路;实现里不再有 Cesium 拾取。
  3759. const target = domMarkerAt(clientX, clientY)
  3760. if (!target || !target.kind) return dispatchPickResult({ target: 'none', pick: null })
  3761. return pickMarkerByIdentity(target.kind, target.code, target.key)
  3762. }
  3763. /**
  3764. * 票 69:点到**正被强调**的对象时解除整轮强调。
  3765. *
  3766. * 为什么放在派发链上:用户点击走 `overlay 节点 → pickDeviceOrPointAt → dispatchPickResult`,
  3767. * 解除挂在同一条下游,两条入口(真实点击 / DEV 按坐标点选)不会有一条漏掉。
  3768. * 判定只认「点的是高亮对象本身」:点位码、`pointCode`、标记的 `deviceCodes`
  3769. * (与引擎候选编码同一份集合,走 `domain/mapEmphasis.shouldEmphasize`);
  3770. * 点别的对象或空白**不**替用户取消上一轮强调(事件详情可能还开着)。
  3771. * 还原复用 `highlightMapObject([])` 的同一条空目标路径(引擎 `emphasisBase` 写回原值),
  3772. * 不在这里另写一套尺寸还原。
  3773. * @returns {boolean} 这一次点击是否真的解除了强调
  3774. */
  3775. function releaseEmphasisIfPicked(resolved) {
  3776. const pick = resolved && resolved.pick
  3777. if (!pick || !emphasisCodes.length) return false
  3778. const marker = (markerPlan.markers || []).find((item) => item.code === pick.code) || null
  3779. const candidates = [
  3780. pick.code,
  3781. pick.pointCode,
  3782. ...(marker && Array.isArray(marker.deviceCodes) ? marker.deviceCodes : []),
  3783. ]
  3784. if (!shouldEmphasize(candidates, emphasisCodes)) return false
  3785. highlightMapObject([])
  3786. return true
  3787. }
  3788. /**
  3789. * 派发一次点选结论(票 76 / C6 抽出):GPU 拾取与「按 DOM 身份点选」共用同一段下游 ——
  3790. * 选中/呈现/收起/订阅派发只有这一处,两条入口不会各写一套行为。
  3791. */
  3792. function dispatchPickResult(resolved) {
  3793. // 票 69:点到的正是**当前被强调**的对象 —— 注意力已经落到地图上,这一轮强调到此为止。
  3794. // 用户真正的点击走这条(overlay 节点 → `pickDeviceOrPointAt` → 本函数),
  3795. // 与 DEV 把手同一条下游,所以解除也只需挂在这里一处。
  3796. releaseEmphasisIfPicked(resolved)
  3797. if (resolved.target === 'device') {
  3798. // 统一选择入口负责选中与呈现;不提前写 selectedDeviceCode,避免 store 的幂等检查吞掉更新。
  3799. lastPicked = resolved.pick
  3800. for (const handler of [...pointPickHandlers]) handler(resolved.pick)
  3801. } else if (resolved.target === 'point') {
  3802. if (overlapExpandedGroup) collapseOverlap(overlapExpandedGroup)
  3803. lastPicked = resolved.pick
  3804. paintPicked(instance, resolved.pick)
  3805. for (const handler of [...pointPickHandlers]) handler(resolved.pick)
  3806. } else {
  3807. // 点空白处:粘性展开明确收起。
  3808. if (overlapExpandedGroup || overlapPreviewGroup) collapseOverlap()
  3809. }
  3810. return resolved
  3811. }
  3812. /**
  3813. * 按 DOM 节点身份点选(票 76 / C6)。billboard 视觉隐藏后 GPU 不再有命中对象,
  3814. * 这里用**与 GPU 拾取同形状**的行数据(`markerPlan.markers` / `deviceRows`)构造 same payload,
  3815. * 走同一条 `dispatchPickResult` —— 点击行为与隐藏前一致(面板/详情/选中态都不变)。
  3816. * @param {string} kind `marker-overlay-node` 上的 `data-marker-overlay-kind`
  3817. * @param {string} code 节点上的 `data-marker-overlay-code`
  3818. * @returns {{target:string, pick:object|null}}
  3819. */
  3820. export function pickMarkerByIdentity(kind, code, key = '') {
  3821. if (kind === 'overlap-aggregate') return toggleOverlap(code)
  3822. // 票 101:设施点点击 —— 与独立设备**同一条派发链**(target 'point' → paintPicked +
  3823. // pointPickHandlers),由订阅方(MapCanvas)打开设施详情并做镜头定位;
  3824. // 详情与「设施台账」卡片点击同源(entityDetailStore + entity-detail 契约)。
  3825. // 缺主键/类别的行不可点(领域层 `clickable` 已判),这里再兜一层不把脏身份发出去。
  3826. if (kind === 'facility-point') {
  3827. // 节点 key 包含类别+主键;旧的 code-only 入口仅接受唯一匹配,不能猜第一条。
  3828. const row = facilityMarkerFor(code, key)
  3829. if (!row || !row.clickable) return { target: 'none', pick: null }
  3830. return dispatchPickResult({ target: 'point', pick: {
  3831. kind: 'facility-point',
  3832. key: row.key,
  3833. code: row.code,
  3834. name: row.name,
  3835. status: row.status,
  3836. id: row.entityId,
  3837. category: row.detailCategory,
  3838. industry: row.industry,
  3839. facilityType: row.type,
  3840. longitude: row.longitude,
  3841. latitude: row.latitude,
  3842. } })
  3843. }
  3844. // 票 101:管线构件点位点击 —— 与点构件实体/3D 模型同一条**管线拾取链**(局部增强),
  3845. // 不进设施详情。pick 形状与引擎 `pickPipeObjectAt` 的构件命中一致(objectKind:'component')。
  3846. if (kind === 'pipe-component') {
  3847. const row = (pipeComponentRows || []).find((item) => item.code === code) || null
  3848. if (!row) return { target: 'none', pick: null }
  3849. const pick = {
  3850. kind: 'pipeline',
  3851. code: row.code,
  3852. objectKind: 'component',
  3853. name: row.name,
  3854. componentKind: row.componentKind,
  3855. industry: row.industry,
  3856. }
  3857. for (const handler of [...pipelinePickHandlers]) handler(pick)
  3858. return { target: 'pipeline', pick }
  3859. }
  3860. if (!code) return { target: 'none', pick: null }
  3861. if (kind === 'associated-device') {
  3862. // 票 76(四态规格 2026-10-26 修)点击设备点:**始终走同一条派发链**。
  3863. //
  3864. // 上一版在这里分叉:③ 时直接 `setSelectedDevice()` 就 return,**没有 dispatchPickResult**
  3865. // → 不触发 `pointPickHandlers` → 镜头不动、详情面板不弹(走查报的「第 2 步没触发选中事件」)。
  3866. //
  3867. // 现在:先确定这次点击要把聚合呈现推到哪一态(③ 或 ④),再走**完整派发**
  3868. //(`resolveOverlapPick → dispatchPickResult`:选中/镜头/面板/订阅全在链上)。
  3869. // 呈现态只在这一步之后由 `setSelectedDevice` 依据 `centerChosen` 决定 —— 两条路径不再分叉。
  3870. const group = overlapGroups.find((item) => item.k > 1 && item.rows.some((row) => row.code === code)) || null
  3871. const isCenter = Boolean(group && code === selectedDeviceCode)
  3872. const isPinnedNow = Boolean(group && overlapExpandedGroup === group.key)
  3873. const row = deviceRows.find((item) => item.code === code) || null
  3874. const hit = {
  3875. kind: 'monitoring-device',
  3876. code,
  3877. pointCode: row ? row.pointCode : '',
  3878. status: row ? row.status : '',
  3879. }
  3880. // 票 76(四态规格 2026-10-26):点击设备点的落点由「当前处于哪一态」决定:
  3881. // ② 点环内设备 → ③(中心替换、环不固定)
  3882. // ③ 点中心设备 → ④(固定环 + 发光)
  3883. // ④ 点中心设备 → ③(回到设备常态;规格:「再次点击选择设备后又会进入设备常态」)
  3884. // 判据统一放这里,`setSelectedDevice` 只负责按 `pendingCenterChosen` 落地。
  3885. if (group) pendingCenterChosen = isCenter && !isPinnedNow
  3886. try {
  3887. return dispatchPickResult(resolveOverlapPick({ deviceHit: hit }))
  3888. } finally {
  3889. // 无论派发链路成功与否都要清空:残留会让下一次面板/卡片选中被误判成 ③。
  3890. pendingCenterChosen = null
  3891. }
  3892. }
  3893. const markers = markerPlan.markers || []
  3894. const row = markers.find((item) => item.kind === kind && item.code === code)
  3895. || markers.find((item) => item.code === code)
  3896. || null
  3897. // 与 `pickPointMarkerAt()` 同形状:`{ kind, code, status, name }`。
  3898. const hit = {
  3899. kind: row ? row.kind : kind,
  3900. code,
  3901. status: row ? row.status : '',
  3902. name: row ? row.name : '',
  3903. }
  3904. return dispatchPickResult(resolveOverlapPick({ pointHit: hit }))
  3905. }
  3906. /**
  3907. * 订阅设备 hover(三维 pointermove 用)。
  3908. *
  3909. * 与点选订阅分开:hover 是**高频**事件,订阅方(面板高亮)需要自己做节流,
  3910. * 服务层只负责"拾到谁就说谁"(不在这一层锁节流数值 —— 规格把节流交实现/原型定)。
  3911. */
  3912. const deviceHoverHandlers = new Set()
  3913. export function onDeviceHover(handler) {
  3914. if (typeof handler !== 'function') return () => {}
  3915. deviceHoverHandlers.add(handler)
  3916. return () => deviceHoverHandlers.delete(handler)
  3917. }
  3918. /**
  3919. * 三维 hover 拾取(由宿主 pointermove 驱动)。
  3920. * @returns {string} 当前 hover 的设备编码(空串=没 hover 到设备)
  3921. */
  3922. export function hoverDeviceAt(clientX, clientY) {
  3923. if (!instance) return ''
  3924. // 票 76(C6):hover 身份 = DOM 命中盒(宿主 pointermove 已写过 `domHover`;
  3925. // DEV 走查按坐标调用时在这里补一次 DOM 命中,不再碰 Cesium 拾取)。
  3926. const target = domHover || domMarkerAt(clientX, clientY)
  3927. const overAggregate = Boolean(target && target.kind === 'overlap-aggregate' && target.code)
  3928. if (overAggregate) {
  3929. const group = overlapGroups.find((item) => item.k > 1 && item.key === target.code)
  3930. // 已固定(②④)时不再进入预览——固定态优先,hover 只做 nudge 反馈。
  3931. if (!group || overlapExpandedGroup === group.key) return ''
  3932. if (!overlapExpandedGroup && overlapPreviewGroup !== group.key) {
  3933. endOverlapPreview()
  3934. expandOverlap(group.key, false)
  3935. }
  3936. nudgeOverlap(group.key)
  3937. return '' // 聚合入口不虚构设备身份;选中设备入口保留其真实编码。
  3938. }
  3939. // 从聚合入口移开:只**结束预览**,不能动固定态与中心设备。
  3940. // 2026-10-26 修(事件冲突):此前直接调 `collapseOverlap()` —— 它会清 `overlapExpandedGroup`
  3941. // 并把状态打回 collapsed,于是 ③ 设备常态被 hover 一次就「掉出选择能力」(点不动了)。
  3942. //
  3943. // 2026-10-28(维护者规格③):**hover 设备点也要展开预览环** ——
  3944. // ③ 设备常态的中心设备本身就是"替换了聚合点的设备点",它保留聚合点的 hover 特性。
  3945. // 因此在结束预览之前先判:指针下的设备是否属于某个 k>1 的聚合组。
  3946. const hoverDeviceCode = target && target.kind === 'associated-device' ? target.code : ''
  3947. const hoverGroup = hoverDeviceCode
  3948. ? overlapGroups.find((item) => item.k > 1 && item.rows.some((row) => row.code === hoverDeviceCode))
  3949. : null
  3950. if (hoverGroup && overlapExpandedGroup !== hoverGroup.key) {
  3951. // 固定态优先:已经固定(②④)就不再改状态,只让设备身份继续走下面(面板/ tooltip 照常)。
  3952. if (!overlapExpandedGroup && overlapPreviewGroup !== hoverGroup.key) {
  3953. endOverlapPreview()
  3954. expandOverlap(hoverGroup.key, false)
  3955. }
  3956. } else if (!hoverGroup && overlapPreviewGroup) {
  3957. endOverlapPreview()
  3958. }
  3959. const effective = domHoverDeviceHit(target)
  3960. const code = effective ? effective.code : ''
  3961. if (code === hoveredDeviceCode) return code
  3962. hoveredDeviceCode = code
  3963. syncDeviceReactions()
  3964. for (const handler of [...deviceHoverHandlers]) handler(effective)
  3965. return code
  3966. }
  3967. /** 最近一次由 overlay 派发的 DOM hover 身份(`null` = 指针不在节点上)。 */
  3968. let domHover = null
  3969. /** mapService ← markerOverlay 的单向通知:指针进入/离开某个 DOM 节点。 */
  3970. export function setDomHoverTarget(kind, code) {
  3971. domHover = kind && code ? { kind, code } : null
  3972. }
  3973. /**
  3974. * 票 76(C6):窗口坐标 → overlay 节点身份(**DOM 命中盒**取代 Cesium 拾取)。
  3975. *
  3976. * billboard 视觉退役后,标记的命中盒就是 DOM 节点本身 —— 与视觉同一份几何,
  3977. * 不再有第二套像素级命中判定,也不会出现「看得见点不到」的两层不一致。
  3978. * 认不出(空白/未挂载)返回 null。
  3979. */
  3980. function domMarkerAt(clientX, clientY) {
  3981. if (!Number.isFinite(clientX) || !Number.isFinite(clientY)) return null
  3982. // 不做「overlay 是否挂载」的前置判断:overlay 不在时 `elementFromPoint` 命中的自然不是节点,结果同为 null;
  3983. // 反过来这道判断会让「按坐标点选」在 node 里无法断言(行为闸门要驱动同一条链)。
  3984. if (!instance || !instance.host) return null
  3985. const doc = instance.host.ownerDocument
  3986. if (!doc || typeof doc.elementFromPoint !== 'function') return null
  3987. const element = doc.elementFromPoint(clientX, clientY)
  3988. const node = element && element.closest ? element.closest('.marker-overlay-node') : null
  3989. if (!node || !node.dataset || !instance.host.contains(node)) return null
  3990. return { kind: node.dataset.markerOverlayKind || '', code: node.dataset.markerOverlayCode || '', key: node.dataset.markerOverlayKey || '' }
  3991. }
  3992. /** DOM hover 身份 → 与点选同形状的设备命中行(只认关联设备;点位有 DOM tooltip)。 */
  3993. function domHoverDeviceHit(target) {
  3994. if (!target || target.kind !== 'associated-device') return null
  3995. const row = deviceRows.find((item) => item.code === target.code) || null
  3996. if (!row) return null
  3997. return {
  3998. kind: 'monitoring-device',
  3999. code: row.code,
  4000. pointCode: row.pointCode,
  4001. status: row.status,
  4002. name: row.name,
  4003. }
  4004. }
  4005. // ---- 批次 3:hover 完整名称 tooltip(label 截断后的通用 hover 详情)----
  4006. // 原生 DOM 层实现(不用 Cesium InfoBox:viewer 创建时 infoBox:false):一个绝对定位 div,
  4007. // hover 到点位/设备标记时显示完整名称,跟随鼠标;离开即隐藏。样式内联(无 scoped CSS 可挂)。
  4008. let tooltipNode = null
  4009. function ensureTooltipNode(record) {
  4010. if (tooltipNode && tooltipNode.isConnected) return tooltipNode
  4011. tooltipNode = record.host.ownerDocument.createElement('div')
  4012. tooltipNode.setAttribute('data-map-marker-tooltip', '')
  4013. tooltipNode.style.cssText = [
  4014. 'position:fixed', 'z-index:30', 'pointer-events:none',
  4015. 'max-width:320px', 'padding:5px 9px',
  4016. 'background:rgba(2,9,27,0.88)', 'border:1px solid rgba(1,171,234,0.55)',
  4017. 'border-radius:4px', 'color:#d7ecff',
  4018. 'font:600 12px "Microsoft YaHei UI","Microsoft YaHei","PingFang SC","Noto Sans SC",sans-serif',
  4019. 'white-space:nowrap', 'overflow:hidden', 'text-overflow:ellipsis',
  4020. ].join(';')
  4021. record.host.ownerDocument.body.appendChild(tooltipNode)
  4022. return tooltipNode
  4023. }
  4024. /** hover 移动:按 **DOM 节点身份**显示完整名称(无命中即隐藏)。 */
  4025. function paintMarkerTooltip(event) {
  4026. if (!instance) return
  4027. // 票 76(C6):名称只从 DOM 身份取 —— 视觉、命中盒、tooltip 三者同一份来源。
  4028. const name = (() => {
  4029. if (!domHover) return ''
  4030. if (domHover.kind === 'associated-device') {
  4031. const row = deviceRows.find((item) => item.code === domHover.code)
  4032. return row ? (row.name || '') : ''
  4033. }
  4034. const marker = (markerPlan.markers || []).find((item) => item.kind === domHover.kind && item.code === domHover.code)
  4035. return marker ? (marker.name || '') : ''
  4036. })()
  4037. if (!name) {
  4038. hideMarkerTooltip()
  4039. return
  4040. }
  4041. const node = ensureTooltipNode(instance)
  4042. node.textContent = name
  4043. node.style.display = 'block'
  4044. node.style.left = `${event.clientX + 14}px`
  4045. node.style.top = `${event.clientY + 10}px`
  4046. }
  4047. /** hover 离开 / 无命中:隐藏 tooltip。 */
  4048. function hideMarkerTooltip() {
  4049. if (tooltipNode && tooltipNode.style) tooltipNode.style.display = 'none'
  4050. }
  4051. /**
  4052. * 清空地图上的点位标记(离开 /cockpit 用)。
  4053. *
  4054. * **计划一起清掉**:留着计数而地图上没有标记,会让「图例与标记一致」这条验收在自己的
  4055. * 收尾路径上失真。地图上什么都没有时,图例就该是 0。
  4056. */
  4057. export function clearPointMarkers() {
  4058. if (instance && instance.engine && instance.viewer) {
  4059. instance.engine.clearPointMarkers(instance.viewer)
  4060. }
  4061. markerPlan = { markers: [], blocked: [], byStatus: {}, total: 0 }
  4062. markerRendered = 0
  4063. markerSkipped = 0
  4064. paintMarkerState()
  4065. }
  4066. /**
  4067. * 强调一批编码(票 14):设备码先解析成已上图的点位码,再连**设备码与管线码一起**交给引擎。
  4068. *
  4069. * 为什么把设备码也一起传:管线的关联键是设备码(管线不挂在点位上),
  4070. * 所以「高亮关联管线」只能按设备码匹配;点位码则用于强调那个点位标记。
  4071. * 引擎只调已存在实体的显示参数 —— 事件不新开图层;还没落地时(票 22 之前)返回 0,
  4072. * 调用方如实说「尚未出现在地图上」,不伪造成成功。
  4073. *
  4074. * **空目标必须下发给引擎**(票 69 修):票 14 的契约是 `highlightEntities(viewer, [])` = 全部还原,
  4075. * 但这里曾用 `resolved.length` 把空目标整段跳过 —— DOM 覆盖层只承担点位视觉,于是收起详情只改写了
  4076. * `data-map-emphasis` 这个读数,管线线宽与设备 billboard 一直停在强调值(「高亮解除不了」的真根因)。
  4077. * 还原本来就是**空目标那条路径**,不能因为「没有目标」就不发。
  4078. * @param {string|Array<string>} codes
  4079. * @returns {number} 实际被强调的实体数(0 = 目标还不在图上 / 引擎没就绪 / 刚还原完)
  4080. */
  4081. export function highlightMapObject(codes) {
  4082. const wanted = (Array.isArray(codes) ? codes : [codes]).map((item) => String(item || '').trim()).filter(Boolean)
  4083. // 设备码 → 已上图的点位码(点位标记按点位码建;设备码只作匹配线索)。
  4084. const resolved = wanted.flatMap((code) => {
  4085. const byPoint = markerPlan.markers.find((item) => item.code === code)
  4086. if (byPoint) return [code]
  4087. const byDevice = markerPlan.markers.find((item) => Array.isArray(item.deviceCodes) && item.deviceCodes.includes(code))
  4088. // 点位上图了就把点位码一起带上(标记按点位码画),设备码仍保留给管线匹配。
  4089. return byDevice ? [code, byDevice.code] : [code]
  4090. })
  4091. // 票 76(C3):强调目标记在服务层 —— DOM 的尺寸真值从这里出,不回读 billboard。
  4092. // 空数组 = 全部还原(`pointEmphasisScales()` 因此给出全 1)。
  4093. emphasisCodes = resolved
  4094. const matched =
  4095. instance && instance.engine && instance.viewer
  4096. ? instance.engine.highlightEntities(instance.viewer, resolved) || 0
  4097. : 0
  4098. // 强调结果写进宿主 dataset(`data-map-emphasis` = 当前被强调的实体数):
  4099. // 既给走查一个**可观察的信号**(收起详情后必须回到 0),也方便人工排查"到底亮没亮"。
  4100. // 引擎没就绪时同样写 0 —— 否则会留着上一轮的旧值,让"还原"看起来没发生。
  4101. if (instance && isDomHost(instance.host)) {
  4102. instance.host.dataset.mapEmphasis = String(matched)
  4103. }
  4104. return matched
  4105. }
  4106. /**
  4107. * 当前点位标记的计划与计数(纯数据,界面与走查唯一读它)。
  4108. * 图例由 `pointLegend()` 从同一份 `byStatus` 生成 —— 这是「图例与标记一致」的落点。
  4109. * @returns {{legend: Array, byStatus: object, markers: Array, blocked: Array, onMap: number, skipped: number}}
  4110. */
  4111. export function getPointMarkerState() {
  4112. // 引擎就绪时以**真实图层内容**为准(走查与人工排查读的是地图上真有什么),
  4113. // 未就绪时退回计划数(图例仍要能显示)。
  4114. let onMap = markerRendered
  4115. let shapes = [...new Set(markerPlan.markers.map((item) => item.shape))].sort()
  4116. let markerImages = {}
  4117. // 实体口径与计划口径**分开给**:`byStatus` 是计划(图例由它生成),
  4118. // `entityByStatus` 是从 Cesium 实体上真读回来的。走查拿后者跟图例 DOM 对照,
  4119. // 才是「图例 vs 地图」的交叉验证;若两边都读 byStatus,那条断言就是自己跟自己比(恒真)。
  4120. let entityByStatus = {}
  4121. if (instance && instance.engine && instance.viewer) {
  4122. const actual = instance.engine.readPointMarkers(instance.viewer)
  4123. onMap = actual.count
  4124. shapes = actual.shapes
  4125. // 每个视觉键对应的**真实 billboard 图片**:走查据此证明「不同状态颜色确实不同」
  4126. // 与「图例形状=标记形状」,而不是拿两份 JS 数据互相比(那是自己跟自己比)。
  4127. markerImages = actual.images
  4128. entityByStatus = { ...actual.byStatus }
  4129. }
  4130. return {
  4131. legend: pointLegend(markerPlan.byStatus),
  4132. byStatus: { ...markerPlan.byStatus },
  4133. entityByStatus,
  4134. markers: [...markerPlan.markers],
  4135. blocked: [...markerPlan.blocked],
  4136. onMap,
  4137. skipped: markerSkipped,
  4138. shapes,
  4139. markerImages,
  4140. }
  4141. }
  4142. /**
  4143. * 标记状态写进宿主 dataset(走查读这一行,不读中文,也不读图):
  4144. * `data-map-markers` = 已画到地图上的标记数
  4145. * `data-map-markers-plan` = 本次计划要画的标记数(坐标准入通过)
  4146. * `data-map-markers-status` = 状态分布,`ALARM:1,WARNING:1` 形式
  4147. * `data-map-marker-shapes` = 实际用到的形状名(证明「不只靠颜色」有形状这一半)
  4148. */
  4149. function paintMarkerState() {
  4150. if (!instance || !isDomHost(instance.host)) return
  4151. const host = instance.host
  4152. const keys = Object.keys(markerPlan.byStatus).sort()
  4153. host.dataset.mapMarkers = String(markerRendered)
  4154. host.dataset.mapMarkersPlan = String(markerPlan.total)
  4155. host.dataset.mapMarkersStatus = keys.map((key) => `${key}:${markerPlan.byStatus[key]}`).join(',')
  4156. host.dataset.mapMarkerShapes = [...new Set(markerPlan.markers.map((item) => item.shape))].sort().join(',')
  4157. paintMarkerEffects()
  4158. }
  4159. /** 合计动效变化就刷新,不必等点位实体重画。 */
  4160. function paintMarkerEffects() {
  4161. if (!instance || !isDomHost(instance.host)) return
  4162. const host = instance.host
  4163. // 摘要、设备与设施使用同一份跨图层合计读数。
  4164. const state = combinedEffectState
  4165. if (state.enabled !== true) host.dataset.mapMarkerEffects = 'off'
  4166. else if (state.degraded) host.dataset.mapMarkerEffects = 'degraded'
  4167. else host.dataset.mapMarkerEffects = 'on'
  4168. host.dataset.mapMarkerEffectsAbnormal = String(state.abnormalCount)
  4169. host.dataset.mapMarkerEffectsAnimated = String(state.animatedCount)
  4170. host.dataset.mapMarkerEffectsConverge = state.converge ? `${state.converge.peak}/${state.converge.alpha}` : ''
  4171. }
  4172. // ---- 票 10:点位点选(点地图上的标记打开两级详情) ----
  4173. /**
  4174. * 点选订阅者(MapCanvas 挂载时订阅、离开时退订)。
  4175. *
  4176. * 为什么是订阅而不是让服务层直接调 store:本模块不依赖 Vue、也不认识 pinia
  4177. * (票 02 的边界),所以「拾到谁」由服务层说,「打开什么」由订阅者决定。
  4178. * 订阅者可以有几个(详情浮层、将来的联动面板),互不知情。
  4179. */
  4180. const pointPickHandlers = new Set()
  4181. /** 最近一次拾到的标记(DEV 把手与走查读它)。 */
  4182. let lastPicked = null
  4183. /**
  4184. * 订阅点位点选。返回退订函数(组件 `onBeforeUnmount` 调它)。
  4185. * @param {(picked: {kind: string, code: string, status: string}) => void} handler
  4186. */
  4187. export function onPointPick(handler) {
  4188. if (typeof handler !== 'function') return () => {}
  4189. pointPickHandlers.add(handler)
  4190. return () => {
  4191. pointPickHandlers.delete(handler)
  4192. }
  4193. }
  4194. /** 选中标记写进宿主 dataset:`data-map-picked` = `kind|code`(走查读它,不读中文)。 */
  4195. function paintPicked(record, picked) {
  4196. if (!isDomHost(record && record.host)) return
  4197. record.host.dataset.mapPicked = picked ? `${picked.kind}|${picked.code}` : ''
  4198. record.host.dataset.mapPickedStatus = picked ? picked.status || '' : ''
  4199. }
  4200. /**
  4201. * 窗口坐标 → 点位标记 → 通知订阅者(票 10)。
  4202. *
  4203. * 引擎未就绪、没拾到标记、拾到的不是点位标记:都返回 `null` 且**不改选中态**
  4204. * (点空白处不该把已经打开的详情莫名其妙地收掉,那是「关闭」按钮的事)。
  4205. * @param {number} clientX 窗口坐标(与 `getBoundingClientRect()` 同系)
  4206. * @param {number} clientY
  4207. * @returns {{kind: string, code: string, status: string}|null}
  4208. */
  4209. export function pickPointAt(clientX, clientY) {
  4210. if (!instance) return null
  4211. // 票 76(C6):命中盒是 DOM 节点(与视觉同一份几何),只取点位标记。
  4212. const target = domMarkerAt(clientX, clientY)
  4213. if (!target || !target.code) return null
  4214. const marker = (markerPlan.markers || []).find((item) => item.code === target.code && item.kind === target.kind)
  4215. || (markerPlan.markers || []).find((item) => item.code === target.code)
  4216. if (!marker) return null
  4217. const picked = { kind: marker.kind, code: marker.code, status: marker.status, name: marker.name }
  4218. lastPicked = picked
  4219. paintPicked(instance, picked)
  4220. for (const handler of [...pointPickHandlers]) handler(picked)
  4221. return picked
  4222. }
  4223. /** 最近一次拾到的点位标记(没有就是 null)。 */
  4224. export function getPickedPoint() {
  4225. return lastPicked
  4226. }
  4227. /**
  4228. * 星球自转(引导页用):`seconds` = 转一圈的秒数,0 = 停止。
  4229. *
  4230. * 实现要点:**不能直接转相机**(`camera.rotate`)—— 票 03 的 `cameraControl` 每帧都会用
  4231. * `lookAtTransform(中心, HeadingPitchRange)` 重写相机,直接转会被立刻抹掉(实测:经度纹丝不动)。
  4232. * 所以自转走同一条路:按时间推进**中心点的经度**,再以 `immediate` 套用预设,让相机跟着中心绕地轴转。
  4233. * @returns {boolean} 是否生效;相机还没接上时返回 false(调用方可以稍后重试)
  4234. */
  4235. /** 兼容旧调用:`setGlobeSpin(0)` 也会停。 */
  4236. export function setGlobeSpin(seconds) {
  4237. const spinSeconds = Number(seconds)
  4238. if (!Number.isFinite(spinSeconds) || spinSeconds <= 0) {
  4239. stopSpin()
  4240. return true
  4241. }
  4242. // 先记下**意图**:相机没接上、或正在飞行/阻尼时,本次起不来 —— 等落定后由
  4243. // `resumeSpinIfWanted()` 自动接上。这样"点单"不会丢,也不会去冲掉飞行目标。
  4244. spinWanted = spinSeconds
  4245. if (!instance || !instance.cameraControl) return false
  4246. const state = instance.cameraControl.getState()
  4247. // 自转与飞行互斥:自转每帧以 `immediate` 写中心,飞行中起转会把目标冲掉。
  4248. // 判据用**相机是否真的停稳**,不用粘滞开关 —— 粘滞开关没人清,就会永久哑掉自转。
  4249. if (state && !state.settled) return false
  4250. startSpin(instance, spinSeconds)
  4251. return true
  4252. }
  4253. /**
  4254. * 相机落定后把"想要的"自转接上(由 `notifyCameraStateChange` 调,飞行最后一帧必到)。
  4255. *
  4256. * 为什么需要它:`setGlobeSpin` 在飞行中只能返回 false(不能起转),
  4257. * 而调用方(外壳回引导态)不会一直重试 —— 意图得由服务层自己兑现。
  4258. */
  4259. function resumeSpinIfWanted(state) {
  4260. if (spinWanted <= 0) return
  4261. if (!instance || !instance.cameraControl) return
  4262. if (instance.spin) return
  4263. if (state && !state.settled) return
  4264. startSpin(instance, spinWanted)
  4265. }
  4266. function startSpin(record, spinSeconds) {
  4267. // 只清"正在转的循环",**不清意图** —— 否则下面刚设的 spinWanted 会被自己抹掉。
  4268. cancelSpinLoop()
  4269. record.spin = { spinSeconds, lastAt: Date.now(), raf: null }
  4270. const step = () => {
  4271. // 自转随时可能被「进入系统/归位」打断:只要 spin 被清掉就立刻退出这一帧循环,
  4272. // 绝不能再用 immediate 去覆盖飞行目标。
  4273. if (!record.spin || instance !== record || !record.cameraControl) return
  4274. const now = Date.now()
  4275. const dt = Math.min(now - record.spin.lastAt, 100)
  4276. record.spin.lastAt = now
  4277. // 绕地轴推进中心经度:一圈 2π ÷ spinSeconds;负号 = 自西向东(与真实自转同向)。
  4278. const state = record.cameraControl.getState()
  4279. const pivot = state && state.pivot ? state.pivot : null
  4280. if (pivot) {
  4281. const deltaLon = (-360 * dt) / (record.spin.spinSeconds * 1000)
  4282. record.cameraControl.applyViewPreset(
  4283. { lon: pivot.lon + deltaLon, lat: pivot.lat, height: pivot.height || 0 },
  4284. state.hpr,
  4285. { immediate: true },
  4286. )
  4287. }
  4288. record.spin.raf = requestAnimationFrame(step)
  4289. }
  4290. record.spin.raf = requestAnimationFrame(step)
  4291. }
  4292. /** 只停"正在转的循环",不动意图(`startSpin` 重启时用)。 */
  4293. function cancelSpinLoop() {
  4294. if (instance && instance.spin) {
  4295. if (instance.spin.raf !== null) cancelAnimationFrame(instance.spin.raf)
  4296. instance.spin = null
  4297. }
  4298. }
  4299. /** 停自转**并放弃意图**:进入系统/归位/返回全球都不该再自己转起来。 */
  4300. function stopSpin() {
  4301. spinWanted = 0
  4302. cancelSpinLoop()
  4303. }
  4304. /** 当前相机状态快照(中心、弹簧臂参数、是否在拖动/飞行);引擎未就绪时为 null。 */
  4305. export function getCameraState() {
  4306. return instance && instance.cameraControl ? instance.cameraControl.getState() : null
  4307. }
  4308. /** 可观测的生命周期计数:走查与单测据此断言「只创建一次、未重建」。 */
  4309. export function getLifecycleStats() {
  4310. return {
  4311. created: createdCount,
  4312. destroyed: destroyedCount,
  4313. resize: resizeCount,
  4314. lastResizeAt,
  4315. mounted: !!instance,
  4316. instanceId: instance ? instance.id : '',
  4317. // 票 27 追加:引擎状态与底图状态(上面 6 个字段语义不变);票 29 起底图状态来自配置计划。
  4318. engine: engineState,
  4319. engineError,
  4320. basemap: basemapState,
  4321. // 票 21 追加:地形与叠加影像状态。
  4322. terrain: terrainState,
  4323. terrainError,
  4324. overlays: overlayCount,
  4325. // 票 21 追加:3D Tiles / 倾斜摄影(入口已留好,本机数据缺根 tileset.json 时如实报失败)。
  4326. tilesets: tilesetReady,
  4327. tilesetErrors: tilesetFailed.map((item) => item.error),
  4328. // 票 30 追加:自转是否在跑(引导页状态,走查与排查都看它)。
  4329. spin: !!(instance && instance.spin),
  4330. // 票 40 追加:**想要**的自转时长(秒;0 = 不要)。与 `spin` 分开看才能分辨
  4331. // 「意图立着但相机还没落定」和「压根没想转」—— 回引导态报障的定位就靠这一对。
  4332. spinWanted,
  4333. // 票 36 追加:主场景区状态与原因(票 37/38 能否开限位/开雾的唯一依据)。
  4334. primaryArea: primaryAreaPlan.state,
  4335. primaryAreaReason: primaryAreaPlan.reason,
  4336. primaryAreaBounds: primaryAreaPlan.bounds ? primaryAreaPlan.bounds.slice() : null,
  4337. // 票 37 追加:区域状态与生效限位(走查断言「进区后上限=home.range」用)。
  4338. areaState,
  4339. areaLocked: areaLimits.locked,
  4340. rangeMax: areaLimits.max,
  4341. rangeMin: areaLimits.min,
  4342. // 票 38 追加:自建雾的支持/可执行/强度/降级(**不等于"边缘已遮住"**)。
  4343. fog: fogReadout({
  4344. state: fogLifecycle,
  4345. degrade: fogDegrade,
  4346. params: fogParams,
  4347. supported: fogSupported,
  4348. executable: fogExecutableForReadout(),
  4349. reason: fogReason,
  4350. }),
  4351. // 票 03 追加:相机契约是否已挂上(三键输入、中心规则都在 cameraControl 内)。
  4352. camera: instance && instance.cameraControl ? 'attached' : 'none',
  4353. // 票 09 追加:点位标记的计划数 / 实际画到地图上的数(引擎没就绪时只有计划)。
  4354. markers: markerPlan.total,
  4355. markersOnMap: markerRendered,
  4356. markerStatus: { ...markerPlan.byStatus },
  4357. }
  4358. }
  4359. /** 仅测试/走查用:重置模块内计数,避免同一进程内多次断言互相污染。 */
  4360. export function resetLifecycleStatsForTest() {
  4361. if (instance) {
  4362. if (instance.detachCameraInput) instance.detachCameraInput()
  4363. if (instance.cameraControl) instance.cameraControl.dispose()
  4364. clearDevHook()
  4365. }
  4366. instance = null
  4367. cancelPointLocate()
  4368. mapFrameHandlers.clear()
  4369. cameraStateHandlers.clear()
  4370. sequence = 0
  4371. createdCount = 0
  4372. destroyedCount = 0
  4373. resizeCount = 0
  4374. lastResizeAt = ''
  4375. engineState = 'idle'
  4376. engineError = ''
  4377. basemapState = BASEMAP_STATE.NOT_CONFIGURED
  4378. basemapText = basemapStatusText(null)
  4379. terrainState = TERRAIN_STATE.NONE
  4380. terrainText = terrainStatusText(null)
  4381. terrainError = ''
  4382. overlayCount = 0
  4383. tilesetReady = 0
  4384. tilesetFailed = []
  4385. viewMode = VIEW_MODES.GROUND
  4386. // 票 36:重置回「未配置」——不得让上一轮的矩形留在读数里(同一进程复跑断言会互相污染)。
  4387. primaryAreaPlan = planPrimaryArea({})
  4388. primaryAreaText = primaryAreaStatusText(primaryAreaPlan)
  4389. primaryAreaResolved = false
  4390. // 票 37:区域状态与限位一并归零(否则同一进程里第二次断言会读到上一轮的锁定态)。
  4391. areaState = DEFAULT_AREA_STATE
  4392. areaLimits = planAreaLimits({ plan: primaryAreaPlan, areaState, homeRange: null })
  4393. // 票 38:雾状态一并归零(同一进程复跑断言不得互相污染)。
  4394. stopFogLoop()
  4395. fogHandle = null
  4396. // 调色状态一并归零:下次挂载按(重算后的)配置重建,不残留上一轮的处理级。
  4397. gradeHandle = null
  4398. gradeParams = planSceneGrade({})
  4399. fogLifecycle = createFogLifecycle()
  4400. fogSupported = true
  4401. fogExecutable = false
  4402. fogEverEnabled = false
  4403. fogCreationFailed = false
  4404. fogReason = ''
  4405. fogDegrade = { level: FOG_DEGRADE.NONE, strengthCap: 1, reason: '' }
  4406. fogArtifacts = false
  4407. // 限速的高度系数也要归零(否则下次挂载会从上一次的中间值起步)。
  4408. fogAltitudeShown = 1
  4409. fogSlewAt = 0
  4410. fogPaintedAltitudeFactor = 1
  4411. // 票 39:交接状态一并归零(同一进程复跑断言不得互相污染)。
  4412. // 注意**保留** `handoverParams`(配置决定的,与实例无关,由 applyPrimaryArea 重算);
  4413. // 归零的是"本次实例的运行态"。
  4414. stopHandoverLoop()
  4415. if (overlayReadiness) overlayReadiness.detach()
  4416. overlayReadiness = null
  4417. overlayHandles = []
  4418. handoverLifecycle = createHandoverLifecycle()
  4419. handoverBlock = HANDOVER_BLOCK.NONE
  4420. handoverNote = ''
  4421. handoverPrototypeActive = false
  4422. handoverPainted = {}
  4423. handoverNextEvalAt = 0
  4424. handoverEdgeCache = null
  4425. fogUserDisabled = false
  4426. fogPerfSample = null
  4427. fogPerfResults = []
  4428. markerPlan = { markers: [], blocked: [], byStatus: {}, total: 0 }
  4429. markerRendered = 0
  4430. markerSkipped = 0
  4431. // 票 42:设备图层状态同样归零(同一进程复跑断言不得互相污染)。
  4432. deviceRows = []
  4433. deviceRendered = 0
  4434. deviceSkipped = 0
  4435. // 票 45:聚合组与屏幕展开状态一并归零。
  4436. overlapGroups = []
  4437. overlapMaxFan = 10
  4438. overlapPreviewGroup = ''
  4439. overlapExpandedGroup = ''
  4440. overlapState = OVERLAP_STATE.COLLAPSED
  4441. overlapCountValue = 0
  4442. }