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