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