/** * Cesium 引擎适配层(票 27):本仓库里唯一 `import 'cesium'` 的地方。 * * 只在 `mapService.mountMap()` 完成 `window.CESIUM_BASE_URL` 注入之后被动态 import, * 因此静态 import 提升不会踩到时序(见 services/cesiumAssets.js)。 * * 本模块负责两件事: * 1. 「把引擎接到宿主上」(票 27):唯一 `Viewer`、自带控件与默认相机输入全关、 * 不碰需要账号的能力;底图按 `domain/basemap.js` 的计划挂(票 29), * 没配就不挂(票 27 的「底图未配置不伪造配准」); * 2. 「把相机契约接到引擎上」(票 03):`createCameraEngine()` 把三键契约需要的原语 * (弹簧臂 `lookAtTransform(东北天局部系, HeadingPitchRange)`、屏幕中心地面拾取、 * 中心点投影)包成 services/cameraControl.js 的引擎接口 —— 相机语义本身不在本文件, * 这里只做 Cesium 方言的翻译。 */ import { Cartesian2, BoundingSphere, Intersect, Cartesian3, Cartographic, Cesium3DTileset, Cesium3DTileStyle, CesiumTerrainProvider, Color, CustomDataSource, DirectionalLight, Ellipsoid, EllipsoidalOccluder, ImageBasedLighting, HeightReference, HeadingPitchRange, ImageryLayer, LabelStyle, Math as CesiumMath, Matrix3, Matrix4, OrientedBoundingBox, PostProcessStage, PostProcessStageLibrary, Rectangle, RequestScheduler, SceneTransforms, TileMapServiceImageryProvider, Transforms, UrlTemplateImageryProvider, VerticalOrigin, Viewer, buildModuleUrl, sampleTerrainMostDetailed, } from 'cesium' import 'cesium/Build/Cesium/Widgets/widgets.css' // 强调的**判定**在 domain(纯函数,可脱浏览器断言);本层只把结论写成显示参数。 import { EMPHASIS_STYLE, shouldEmphasize } from '../domain/mapEmphasis.js' import { MAP_LABEL_STYLE, mapLabelText } from '../domain/mapLabelStyle.js' // 批次 3 第四轮:标签省略走 domain/ellipsis.js 的共用契约(与 DOM 侧 EllipsisText 同语义)。 import { ellipsisForMapLabel } from '../domain/ellipsis.js' import { createTilesetLoadingController } from './tilesetLoading.js' import { setTilesetPerformance, tilesetOptions, detailTilesetOptions, setDetailTilesetPerformance } from './tilesetOptions.js' import { createRegionLoadingController, contentAdmission } from './regionLoading.js' import { clampResolution, motionResolution, sameResolution } from '../domain/renderResolution.js' import { removeSceneTileset } from './tilesetCleanup.js' const regionControllers = new WeakMap() // 票 47:动效的**相位与映射**同样在 domain(纯函数,可跨时间采样断言); // 本层只把相位写成 billboard 的 scale/alpha/rotation。 // 场景调色的兜底色直接取 domain 的默认(不再手抄一份 #2fa8ff 的浮点,两处会漂)。 // 注意:GRADE_DEFAULTS 来自 sceneGrade;parseHexColor 的 owner 是 fog.js —— // sceneGrade 自己也只是导入它,从这里导 parseHexColor 会是 ESM 链接错误(引擎整个起不来)。 import { GRADE_DEFAULTS } from '../domain/sceneGrade.js' import { parseHexColor } from '../domain/fog.js' // 批次 3:合成计划宿主读数 + 母版合成渲染。 import { planToDataset } from '../domain/markerPlan.js' // 票 82/84:标记几何与光环层数读数都取单源(旧的 RING_LAYERS 常量已随 billboard 环退役)。 import { MARKER_BODY_WIDTH, markerMetricsFor, maxRingLayers } from '../domain/markerMetrics.js' import { renderMarker } from './markerAssets.js' const DEG = Math.PI / 180 // ================================================================ 场景级 bloom(票 86:默认关闭的画质开关) /** 当前 bloom 处理级(**懒建**:默认不建,只有显式开启才创建)。 */ let sceneBloomStage = null /** 默认关闭:任何自动路径都不许打开(告警可读性不得依赖 bloom)。 */ let sceneBloomEnabled = false /** * 场景级 bloom 画质开关(默认**关闭**)。 * * 票 72 的裁定:bloom 只作为走查时可对比的开关,**告警可读性不得依赖它** —— * 环的亮/锐由 DOM 的硬边带与 alpha 保证,bloom 只是整体观感增强。 * 只由 DEV 钩子调用;不写进部署配置。 */ export function setSceneBloom(viewer, enabled) { const scene = viewer && viewer.scene if (!scene || !scene.postProcessStages) return false const next = enabled === true if (next) { if (!sceneBloomStage) { sceneBloomStage = scene.postProcessStages.add(PostProcessStageLibrary.createBloomStage()) // Cesium 默认参数(对比/亮度/模糊半径)保持库默认:走查时由维护者对比开/关,不预调。 sceneBloomStage.uniforms.glowOnly = false } sceneBloomStage.enabled = true } else if (sceneBloomStage) { sceneBloomStage.enabled = false } sceneBloomEnabled = next return sceneBloomEnabled } /** 当前 bloom 是否开启(走查读数;默认 false)。 */ export function getSceneBloom() { return sceneBloomEnabled } /** 卸载场景时移除 bloom 处理级(避免重建 viewer 后重复叠加)。 */ export function disposeSceneBloom(viewer) { const scene = viewer && viewer.scene if (scene && sceneBloomStage && scene.postProcessStages) { try { scene.postProcessStages.remove(sceneBloomStage) } catch { /* 已随场景销毁 */ } } sceneBloomStage = null sceneBloomEnabled = false } /** * 逐帧投影的跨实体缓存(票 35 卡顿整改)。 * * `projectMarkerEntity` 每帧要对**每个**标记/设备跑一次;原先每次调用都做的事里有两件 * 不属于"每个实体"的粒度: * · `viewer.canvas.getBoundingClientRect()` —— 读布局,每实体一次就是每帧几百次强制回流; * · `new EllipsoidalOccluder(...)` —— 只跟相机位置有关,同一帧内所有实体共用一份。 * 这里各缓存一份:画布矩形按 120ms 保鲜(只有 resize 才会变),遮挡体按相机位置失效。 */ const projectionCache = { rect: null, rectAt: 0, occluder: null, occluderKey: '', occluderEllipsoid: null, toward: new Cartesian3() } /** 实体 → 上一次查到的地面高程(静态位置,3 秒保鲜;瓦片补齐后自然更新)。 */ const entityGroundHeightCache = new WeakMap() function canvasRectCached(viewer) { const now = typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now() if (!projectionCache.rect || now - projectionCache.rectAt > 120) { projectionCache.rect = viewer.canvas.getBoundingClientRect() projectionCache.rectAt = now } return projectionCache.rect } function occluderCached(viewer, camera) { const p = camera.positionWC const key = `${p.x.toFixed(2)}|${p.y.toFixed(2)}|${p.z.toFixed(2)}` if (projectionCache.occluderKey !== key || projectionCache.occluderEllipsoid !== viewer.scene.globe.ellipsoid) { projectionCache.occluder = new EllipsoidalOccluder(viewer.scene.globe.ellipsoid, p) projectionCache.occluderKey = key projectionCache.occluderEllipsoid = viewer.scene.globe.ellipsoid } return projectionCache.occluder } const VIEWER_OPTIONS = { // 自带控件全关(导航/时间轴/拾取框等)。 animation: false, timeline: false, geocoder: false, homeButton: false, sceneModePicker: false, baseLayerPicker: false, navigationHelpButton: false, navigationInstructionsInitiallyVisible: false, fullscreenButton: false, infoBox: false, selectionIndicator: false, vrButton: false, // 相机静止、无新瓦片时减少重复绘制;按需渲染不等于解码/GPU 上传自动分帧。 // maximumRenderTimeChange 是模拟时钟变化阈值,不是墙钟时间的固定渲染间隔。 requestRenderMode: true, maximumRenderTimeChange: 0.5, } /** 按底图计划建 imagery layer;未配置或计划无效时返回 false = 不配底图(票 27:不伪造地理配准)。 */ function createBaseLayer(plan) { if (!plan || !plan.ready) return false if (plan.kind === 'tms') { // Cesium 自带的离线 TMS(随 Assets 目录进产物,不需要账号或外网)。 return ImageryLayer.fromProviderAsync(TileMapServiceImageryProvider.fromUrl(buildModuleUrl(plan.url))) } if (plan.kind === 'xyz') { const options = { url: plan.url } if (plan.subdomains && plan.subdomains.length) options.subdomains = plan.subdomains if (Number.isInteger(plan.minimumLevel)) options.minimumLevel = plan.minimumLevel if (Number.isInteger(plan.maximumLevel)) options.maximumLevel = plan.maximumLevel if (plan.credit) options.credit = plan.credit return new ImageryLayer(new UrlTemplateImageryProvider(options)) } return false } /** * 在宿主要素上创建 Viewer。 * * **署名容器(票 25)**:`creditContainer` **不是开关,是「放哪个容器」**—— * Cesium 的 Viewer 构造写死 `defined(options.creditContainer) ? options.creditContainer : bottomContainer`, * 随后 CesiumWidget **两条路径都会**建 CreditDisplay * (`creditContainer.appendChild(innerCreditContainer)`)。 * 所以传 `false`/`''` 既关不掉、还会让 `getElement()` 拿到 null 而崩。 * 真正能生效的做法是**自己给一个不进 DOM 的容器**:Cesium 照旧把署名写进去, * 但它不在文档流里,屏幕上不出现,destroy 时 Cesium 也会自行摘掉内部节点。 * * 合法性前提(本部署已核实,勿轻易推翻):地形走 `CesiumTerrainProvider.fromUrl(本地 URL)`、 * 影像走内置 TMS 与 `UrlTemplateImageryProvider`、3D Tiles 走 `Cesium3DTileset.fromUrl(本地 URL)`, * **全链路无 Cesium ion 服务与 ion 资产**。ion 署名义务只在用到 ion 资产时成立, * 故此处隐藏署名不构成违约;自有数据源署名由地图信息面板在业务界面另行展示。 * * @param {Element} host 宿主要素(`.map-canvas`) * @param {object} [basemapPlan] `domain/basemap.js` 的底图计划(票 29);缺省 = 不配底图 * @returns {Viewer} */ export function createViewer(host, basemapPlan) { // 票 25:离屏署名容器 —— 故意不 appendChild,署名因此不上屏。 const creditContainer = document.createElement('div') const viewer = new Viewer(host, { ...VIEWER_OPTIONS, creditContainer, baseLayer: createBaseLayer(basemapPlan) }) const controller = viewer.scene.screenSpaceCameraController controller.enableRotate = false controller.enableZoom = false controller.enableTilt = false controller.enableLook = false controller.enableTranslate = false // 球体底色用驾驶舱底色:没配底图时看到的就是这个球,配了底图会被影像盖住。 viewer.scene.globe.baseColor = Color.fromCssColorString('#04121f') // 地面视图必须保留地形深度,否则埋在地表以下的 3D Tiles 也会覆盖到地形之上。 // 回归:tools/city-massing/verify-occlusion.mjs 用实际渲染像素检查遮挡。 viewer.scene.globe.depthTestAgainstTerrain = true return viewer } export function resizeViewer(viewer) { viewer.resize() } /** * 渲染开销档位(票 35)。 * * 维护者裁定:「视觉基本接受、但**天空大气必须保留**(去掉后雾的观感不对), * DOM 图标必须逐帧跟手;可以用离散 LOD 与降低内部渲染分辨率换帧率」。 * 因此这里只保留不破坏观感的两项,其余一律回落为原值: * · `resolutionScale 0.75` 内部渲染 75% 分辨率:像素量约 56%,是帧率的大头 * · `msaaSamples 0` 关多重采样、保留 FXAA(省掉 MSAA 解析,边缘仍平滑) * · `atmosphere true` 天空/地面散射与 Cesium 自带雾**保持开启**(雾的观感依赖它) * · `globeScreenSpaceError 3` 地形瓦片少一半左右(山体细节略糙) * 每一项都可以在 `public/cockpit/config.json` 的 `map.performance` 里覆盖。 */ const PERFORMANCE_DEFAULTS = { resolutionScale: 0.75, msaaSamples: 0, fxaa: true, atmosphere: true, globeScreenSpaceError: 3, // ── 加载期尖峰相关 ── // 地形顶点法线:关掉能省加载期 CPU,但地形光照会变平、观感发灰 → **恢复开启**。 terrainVertexNormals: true, // 3D Tiles 球谐 IBL:关掉会让建筑"没有环境光"、整体发灰发黑 → // 维护者反馈后**恢复开启**(这是建筑是否有颜色的关键项)。 ibl: true, // 同时进行的网络请求数:越小,内容"落地"越分散,主线程上传/编译的尖峰越平缓(加载更慢一点)。 maxConcurrentRequests: 6, // 相机在动时的内部渲染分辨率(静态恢复 resolutionScale)。 // // 近景卡顿主要是**填充率**:楼占满屏幕时每像素都要跑一遍材质/阴影。移动期把内部 // 分辨率压到 0.6(像素量约为 0.75 的 64%),停下立刻恢复满档 —— 静态画质不变, // 只有"正在动"的那几秒降档。这是自适应分辨率的标准做法,也是唯一不动观感的两全法。 movingResolutionScale: 0.6, // 自适应分辨率下限(票 61 第十九轮):机器撑不住 60fps 时降到这条线为止, // 再低画质就明显受损了 —— 这是「保持当前质量」的量化底线。 adaptiveFloor: 0.55, } /** 生效中的档位(applyPerformanceOptions 写入后续的 applyTerrain/applyTilesets 读取)。 */ let activePerformance = { ...PERFORMANCE_DEFAULTS } /** * 细模覆盖档(票 61):`map.detail` 覆盖,未配置即用 domain 默认档。 * 与 `activePerformance` 分开,是因为它只影响"细模覆盖到多远/占多少预算", * 不改变任何渲染开销档位(两者可以独立回退)。 */ let activeDetail = { frontier: null, budget: null, tiersConfigured: false } /** 应用细模覆盖档(`map.detail`);脏值忽略,交给 domain 的默认档兜底。 */ export function applyDetailOptions(options = {}) { const next = options && typeof options === 'object' ? options : {} activeDetail = { frontier: next.frontier && typeof next.frontier === 'object' ? next.frontier : null, budget: next.budget && typeof next.budget === 'object' ? next.budget : null, // 配置层显式给了 tiers ⇒ 它是部署侧的最终裁定,优先于资产 manifest。 // 这条优先级对"低配机把 fine 收到 150m"至关重要:若 manifest 的 250m // 每次加载都覆盖配置,运维旋钮就形同虚设。 tiersConfigured: Array.isArray(next.tiers) && next.tiers.length >= 2, } // 细模批次的 SSE / 中心偏置也归这里(`map.detail.fineScreenSpaceError` / `fineFoveated`)。 try { setDetailTilesetPerformance(next) } catch { /* 忽略 */ } } export const PERFORMANCE_DEFAULT_OPTIONS = { ...PERFORMANCE_DEFAULTS } import { stepAdaptiveResolution, frameIntervalP95 } from '../domain/adaptiveResolution.js' /** 自适应分辨率控制器(票 61 第十九轮)。惰性初始化,第一次 tick 才建。 */ let adaptiveResolution = null /** * 惰性建控制器(frameIntervalP95 / stepAdaptiveResolution 见 domain)。 */ function ensureAdaptiveResolution() { if (adaptiveResolution) return adaptiveResolution adaptiveResolution = { intervals: [], scale: null, // 惰性取部署基准档(首次应用时定) hotSinceMs: null, coldSinceMs: null, } return adaptiveResolution } /** * 记录一帧间隔(帧间隔超过 1s 视为切后台/长暂停,丢弃——那不是性能信号)。 * @param {number} intervalMs 上一帧到这一帧的间隔 */ export function noteFrameInterval(intervalMs) { const n = Number(intervalMs) if (!Number.isFinite(n) || n <= 0 || n > 1000) return const state = ensureAdaptiveResolution() state.intervals.push(n) if (state.intervals.length > 90) state.intervals.shift() } /** * 按最近帧间隔决定是否微调内部渲染分辨率。 * 在 viewer 的 postRender 回调里每帧调一次;样本不足 / 时机未到都返回 false(没改)。 * @param {Viewer} viewer * @param {number} moving 相机是否在动(在动时对卡顿更敏感,阈值更保守 —— 这里只记帧间隔,决策不依赖它) * @returns {boolean} 是否真的改了分辨率 */ /** 自适应分辨率的当前读数(DEV 面板用;模块级单源,不另算)。 */ export function adaptiveResolutionStatus() { const state = ensureAdaptiveResolution() return { scale: state.scale, samples: state.intervals.length, p95Ms: frameIntervalP95(state.intervals), } } export function tickAdaptiveResolution(viewer) { if (!viewer || !viewer.scene) return false const state = ensureAdaptiveResolution() const base = Number.isFinite(activePerformance.resolutionScale) ? activePerformance.resolutionScale : PERFORMANCE_DEFAULTS.resolutionScale const floor = Number.isFinite(activePerformance.adaptiveFloor) ? activePerformance.adaptiveFloor : PERFORMANCE_DEFAULTS.adaptiveFloor if (state.scale === null) state.scale = base const p95 = frameIntervalP95(state.intervals) if (p95 === null) return false const nowMs = typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now() const step = stepAdaptiveResolution({ p95Ms: p95, currentScale: state.scale, baseScale: base, floorScale: floor, nowMs, hotSinceMs: state.hotSinceMs, coldSinceMs: state.coldSinceMs, }) state.hotSinceMs = step.hotSinceMs state.coldSinceMs = step.coldSinceMs if (!step.changed) return false const changed = setRenderResolution(viewer, step.scale) if (changed) state.scale = step.scale return changed } /** * 应用渲染开销档位;未配置的项取上面的默认档。 * @param {Viewer} viewer * @param {object} [options] `map.performance`(每项可选) */ export function applyPerformanceOptions(viewer, options = {}) { if (!viewer || !viewer.scene) return const scene = viewer.scene const settings = { ...PERFORMANCE_DEFAULTS, ...(options || {}) } if (Number.isFinite(settings.msaaSamples)) { try { scene.msaaSamples = Math.max(0, Math.min(4, Math.round(settings.msaaSamples))) } catch { /* 老上下文忽略 */ } } if (Number.isFinite(settings.resolutionScale)) { const scale = Math.min(1, Math.max(0.5, settings.resolutionScale)) if (Math.abs(viewer.resolutionScale - scale) > 1e-6) { viewer.resolutionScale = scale viewer.resize() } } try { scene.postProcessStages.fxaa.enabled = settings.fxaa !== false } catch { /* 无该级时忽略 */ } // 大气全屏散射:关掉后 globe 片元少算一大段,天空不再有蓝晕(外观退让点)。 if (settings.atmosphere === false) { try { scene.skyAtmosphere.show = false } catch { /* 无大气时忽略 */ } try { scene.globe.showGroundAtmosphere = false } catch { /* 忽略 */ } try { scene.fog.enabled = false } catch { /* Cesium 自带雾也一并省掉 */ } } if (Number.isFinite(settings.globeScreenSpaceError)) { try { scene.globe.maximumScreenSpaceError = Math.max(1, Math.min(4, settings.globeScreenSpaceError)) } catch { /* 忽略 */ } } // 瓦片流式策略同档降载:SSE 系数放大(少选/少解 GLB)、缓存上限下调(弱机 GC 峰值更低)。 try { setTilesetPerformance(options || {}) } catch { /* 忽略 */ } // 供后续 applyTerrain / applyTilesets / 光照选择读取(它们在 viewer 建好之后才跑)。 activePerformance = settings } /** * 运行时改内部渲染分辨率(票 61 第十四轮:移动期自适应降档)。 * * 只做"值变了才写",并且写后立刻 resize —— 不做这两步会让 Cesium 的绘制缓冲与 * resolutionScale 脱节,表现为画面糊一格或帧率不掉。返回是否真的改了(调用方可据此跳过)。 * * @param {Viewer} viewer * @param {number} scale 0.5..1 * @returns {boolean} 是否发生了变化 */ export function setRenderResolution(viewer, scale) { if (!viewer || !viewer.scene) return false const target = clampResolution(scale) if (target === null) return false if (sameResolution(viewer.resolutionScale, target)) return false viewer.resolutionScale = target try { viewer.resize() } catch { /* 无宿主尺寸时忽略 */ } return true } /** * 按"相机在动 / 静止"切换渲染分辨率(票 61 第十四轮)。 * * 在动 ⇒ 降到 movingResolutionScale;静止 ⇒ 升回 resolutionScale。 * 两者都取 `map.performance` 的生效档,未配置就是代码默认(0.75 / 0.6)。 * * @param {Viewer} viewer * @param {boolean} moving * @returns {boolean} 是否真的改了 */ export function applyMotionResolution(viewer, moving) { const base = Number.isFinite(activePerformance.resolutionScale) ? activePerformance.resolutionScale : PERFORMANCE_DEFAULTS.resolutionScale const movingScale = Number.isFinite(activePerformance.movingResolutionScale) ? activePerformance.movingResolutionScale : PERFORMANCE_DEFAULTS.movingResolutionScale // 判定与夹取都在 domain(单源、脱浏览器可断言);这里只做应用。 return setRenderResolution(viewer, motionResolution({ base, moving: movingScale, isMoving: moving === true })) } /** 叠加影像(票 21):本地精细切片画在底图之上,URL 模板里可用 `{reverseY}` 表示 TMS 的 y。 */ function createOverlayProvider(overlay) { const options = { url: overlay.url } if (Number.isInteger(overlay.minimumLevel)) options.minimumLevel = overlay.minimumLevel if (Number.isInteger(overlay.maximumLevel)) options.maximumLevel = overlay.maximumLevel if (overlay.bounds) options.rectangle = Rectangle.fromDegrees(overlay.bounds[0], overlay.bounds[1], overlay.bounds[2], overlay.bounds[3]) if (overlay.credit) options.credit = overlay.credit return new UrlTemplateImageryProvider(options) } /** * 批量采样真实地表高程(票 34 的建模用途):一次请求把整张网格取回来, * 比逐点调 `sampleTerrainHeight()` 快得多(逐点会重复拉同一批瓦片)。 * @param {Array<{lon: number, lat: number}>} points * @returns {Promise>} 与入参等长;无地形或取不到的点为 null */ export async function sampleTerrainHeights(viewer, points) { const list = Array.isArray(points) ? points : [] if (!viewer || !viewer.terrainProvider || !list.length) return list.map(() => null) try { const cartos = list.map((p) => Cartographic.fromDegrees(p.lon, p.lat)) const sampled = await sampleTerrainMostDetailed(viewer.terrainProvider, cartos) return sampled.map((c) => (c && Number.isFinite(c.height) ? c.height : null)) } catch { return list.map(() => null) } } /** * 采样一点的**真实地表高程**(票 30):地形感知的中心点/预设视角靠它把高度从「椭球 0 米」 * 换成实际地形高度,否则相机高度按 0 算,一拉近就落进山体(实测 range 120m 全黑)。 * @returns {Promise} 米;没有地形或采样失败返回 null */ export async function sampleTerrainHeight(viewer, lon, lat) { if (!viewer || !viewer.terrainProvider) return null try { const carto = Cartographic.fromDegrees(lon, lat) const [sampled] = await sampleTerrainMostDetailed(viewer.terrainProvider, [carto]) const height = sampled && sampled.height return Number.isFinite(height) ? height : null } catch { return null } } /** * 挂地形(票 21)。DEM 是**量化网格**:`CesiumTerrainProvider.fromUrl()`, * `layer.json` 里的 `scheme: "tms"` 由 Cesium 自己认,服务端不需要翻 y。 * @returns {Promise} `'ready'`;失败时抛错,由调用方记成失败态(保留椭球兜底) */ export async function applyTerrain(viewer, terrainPlan) { // 顶点法线按瓦片在主线程计算,是加载期最典型的 20–100ms 尖峰来源; // 性能档默认关闭(`map.performance.terrainVertexNormals: true` 可要回)。 const wantNormals = activePerformance.terrainVertexNormals === true && terrainPlan.requestVertexNormals !== false const provider = await CesiumTerrainProvider.fromUrl(terrainPlan.url, { requestVertexNormals: wantNormals, }) viewer.terrainProvider = provider return 'ready' } /** * 叠加影像句柄(票 39):把「精细层」包成一个只暴露必需动作的对象,供交接状态机驱动。 * * ── 两个必须讲清楚的 Cesium 事实(本票实现依据,都查过源码)── * * 1. **隐藏期间仍要继续预取瓦片**(决策 08 第 1 轮 Q4=A「看不见不等于停止加载」)。 * 瓦片是否被请求由 `GlobeSurfaceTile` 按 **`layer.show`** 决定 * (`Source/Scene/GlobeSurfaceTile.js:691` `if (layer.show) { layer._createTileImagerySkeletons(...) }`), * **不看 `alpha`**。所以"隐藏精细层"的正确做法是 **`alpha = 0` 且保持 `show = true`** —— * 用 `show = false` 会真的停掉预取,等揭示时才开始下载,正好造成"先空白再跳方块"。 * * 2. **alpha 只允许一处写**。本模块提供 `setAlpha()` 作为唯一入口, * 服务层与原型都不得直接摸 `layer.alpha`(原型若同时写会互相打架,见 mapService 的隔离注释)。 */ function createOverlayHandle(layer) { return { layer, /** 该层的原始 alpha(销毁/还原时用)。 */ initialAlpha: Number.isFinite(layer.alpha) ? layer.alpha : 1, /** 当前由交接写入的 alpha(读数与断言用;不代表"画面已遮住")。 */ alpha: Number.isFinite(layer.alpha) ? layer.alpha : 1, /** * 写 alpha(**唯一入口**)。 * * 刻意**不**动 `show`:`alpha = 0` 即不可见,但 `show` 保持 true 让瓦片继续预取 * (见上方事实 1)。UI 不可见 ≠ 停止加载。 */ setAlpha(value) { const next = Math.max(0, Math.min(1, Number.isFinite(value) ? value : 0)) this.alpha = next layer.alpha = next return next }, /** 当前层对象是否还有效(viewer 销毁后不要再碰)。 */ alive() { return !!layer && typeof layer.alpha === 'number' }, } } /** * 把相机**直接放到指定椭球高度**(DEV 诊断用;保持当前经纬度与姿态)。 * * 为什么放在引擎层:`Cartesian3` 只允许出现在本仓唯一 `import 'cesium'` 的地方(ADR 0004), * 服务层不碰 Cesium 类型。 * * 为什么不能直接赋值:`camera.positionWC` / `positionCartographic` 在 * `Source/Scene/Camera.js` 里**只有 getter**(内部由 `_position` 推导), * 赋值不会生效 —— 必须走 `setView()` 这个受支持入口。 * * @returns {number|null} 实际下发的椭球高度(米);失败返回 null */ export function setCameraAltitude(viewer, altitudeM) { if (!viewer || !viewer.camera || !viewer.scene || !viewer.scene.globe) return null if (!Number.isFinite(altitudeM)) return null try { const ellipsoid = viewer.scene.globe.ellipsoid const carto = ellipsoid.cartesianToCartographic(viewer.camera.positionWC) if (!carto) return null const camera = viewer.camera camera.setView({ destination: Cartesian3.fromRadians(carto.longitude, carto.latitude, altitudeM), orientation: { heading: camera.heading, pitch: camera.pitch, roll: camera.roll }, }) return Math.round(altitudeM) } catch (error) { return null } } /** 按配置顺序叠加影像层,返回**句柄数组**(票 39 起需要驱动 alpha,故不再只返回层数)。 */ export function applyOverlays(viewer, overlays) { const handles = [] for (const overlay of overlays || []) { const layer = new ImageryLayer(createOverlayProvider(overlay)) viewer.imageryLayers.add(layer) handles.push(createOverlayHandle(layer)) } return handles } /** * 目标视野的瓦片就绪读数(**如实标注它是代理信号**)。 * * `globe.tilesLoaded` = **地形与影像加载队列为空**(`Source/Scene/Globe.js:422` 的 getter), * 它**不证明**目标视野逐像素就绪 —— 决策票 08 明确警告过这一点。所以这里连同 * "它是代理"这个事实一起返回,免得下游把它当充分证明。 * * 瓦片失败:Cesium 在**没有任何 errorEvent 监听者**时会把错误直接抛到窗口 * (`ImageryLayer.js:1708` `handleError`),所以本层挂一个监听者把失败**收进读数**, * 而不是让它变成页面级报错(走查的「无页面级报错」会因此误伤)。 */ export function createOverlayReadiness(handles = []) { const state = { loaded: false, failed: 0, lastError: '' } const removers = [] for (const handle of handles) { const layer = handle && handle.layer if (!layer || !layer.errorEvent || typeof layer.errorEvent.addEventListener !== 'function') continue // 注意:Cesium 的 `Event.addEventListener` **返回一个"移除函数"**,不是 id // (`Source/Core/Event.js` 末尾 `return function () { event.removeEventListener(...) }`)。 const remove = layer.errorEvent.addEventListener((error) => { state.failed += 1 state.lastError = String((error && error.message) || error || '瓦片加载失败') }) if (typeof remove === 'function') removers.push(remove) } return { state, /** 由服务层在渲染循环里刷新(`globe.tilesLoaded` 是 getter,读它不便宜,故不在这里轮询)。 */ refresh(globe) { state.loaded = !!(globe && globe.tilesLoaded) return state }, detach() { for (const remove of removers) { try { remove() } catch { // 场景已销毁:忽略 } } removers.length = 0 }, } } /** * 3D Tiles 的公共诊断读数:排查「tileset.json 拉到了、内容却没请求」时, * 使用 Cesium 1.145 的公开 `tilesLoaded`、内存和 tile 事件结果;不读取内部 statistics/ready/contentState。 * `visited`/`selected` 保留为 null,因为 Cesium 没有公开的稳定逐帧字段。 */ export function readTilesetDiagnostics(tilesets) { const list = Array.isArray(tilesets) ? tilesets : [] return list.map((tileset) => { const root = tileset.root || {} return { visited: null, selected: null, contentReady: root ? Number(Boolean(root.content)) : 0, tilesLoaded: Boolean(tileset.tilesLoaded), memoryBytes: tileset.totalMemoryUsageInBytes || 0, rootHasContent: Boolean(root && root.content), rootChildren: Array.isArray(root && root.children) ? root.children.length : 0, maximumScreenSpaceError: tileset.maximumScreenSpaceError, } }) } /** * 挂 3D Tiles / 倾斜摄影(票 21):每个条目一个 tileset。 * 单项失败不影响其它项;返回 `{ ready, failed, instances }` 供上层如实报状态 * (页面不白屏、不静默),并把实例交出去给视图条件改透明度。 */ function applyBuildingLighting(scene, tileset) { // Derive a stable local daylight from this dataset's geographic bounds; do not key lighting to the camera. const origin = tileset.boundingSphere?.center if (!origin) return const frame = Transforms.eastNorthUpToFixedFrame(origin) const direction = Matrix4.multiplyByPointAsVector(frame, new Cartesian3(-0.4, -0.6, -1), new Cartesian3()) Cartesian3.normalize(direction, direction) scene.light = new DirectionalLight({ direction, intensity: 2.4 }) // 球谐 IBL 会多一条高光分支,也让"首帧着色器编译"更贵(加载期尖峰的一部分)。 // 性能档默认关闭:保留平行光的漫反射,只是不再有基于球谐的高光/环境反射。 if (activePerformance.ibl === true) { tileset.imageBasedLighting = new ImageBasedLighting({ imageBasedLightingFactor: new Cartesian2(1, 1), sphericalHarmonicCoefficients: [new Cartesian3(0.65, 0.65, 0.65), ...Array.from({ length: 8 }, () => new Cartesian3(0, 0, 0))], }) } } function boundsFromTilesetRoot(root) { const box = root && root.boundingVolume && root.boundingVolume.box if (!Array.isArray(box) || box.length < 12) return null const transform = Array.isArray(root.transform) && root.transform.length === 16 ? Matrix4.fromArray(root.transform) : Matrix4.IDENTITY const center = Matrix4.multiplyByPoint(transform, new Cartesian3(box[0], box[1], box[2]), new Cartesian3()) const radii = [ Matrix4.multiplyByPointAsVector(transform, new Cartesian3(box[3], box[4], box[5]), new Cartesian3()), Matrix4.multiplyByPointAsVector(transform, new Cartesian3(box[6], box[7], box[8]), new Cartesian3()), Matrix4.multiplyByPointAsVector(transform, new Cartesian3(box[9], box[10], box[11]), new Cartesian3()), ] const halfAxes = Matrix3.fromArray(radii.flatMap((axis) => [axis.x, axis.y, axis.z])) return new OrientedBoundingBox(center, halfAxes) } async function readTilesetRootBounds(plan, signal) { const response = await fetch(plan.url, { cache: 'no-store', signal }) if (!response.ok) throw new Error(`tileset metadata ${response.status}: ${plan.url}`) const json = await response.json() const bounds = boundsFromTilesetRoot(json.root) if (!bounds) throw new Error(`tileset root bounding box missing: ${plan.url}`) return bounds } function createTilesetCullingController(scene, plans, instances, failed, onChange) { const camera = scene && scene.camera if (!camera || !plans.length) return null const xyz = (value) => [value.x, value.y, value.z] const regionHandles = [] const regionAbort = new AbortController() let regionPipeline = false async function initializeRegions(model, plan) { if (!plan.regionManifest) return const response = await fetch(plan.regionManifest, { cache: 'no-store', signal: regionAbort.signal }) if (!response.ok) throw new Error('region metadata ' + response.status) const manifest = await response.json() if (model.isDestroyed() || regionAbort.signal.aborted) return const url = new URL(plan.regionManifest, document.baseURI) // 票 91:资产在自己的 manifest 里声明层级表(`lodLevels.tiers`)。 // 运行时以它为准 —— "加一级/改距离带"只改资产,不必动代码; // 读不到就保持当前默认表(三级资产照常工作,不报错、不阻断)。 if (!activeDetail.tiersConfigured && manifest.lodLevels && Array.isArray(manifest.lodLevels.tiers) && manifest.lodLevels.tiers.length) { // 资产声明只是**默认**;部署配置显式给 tiers 时不覆盖(配置优先)。 try { setDetailTilesetPerformance({ tiers: manifest.lodLevels.tiers }) } catch { /* 非法声明忽略,用默认表 */ } } const transform = Matrix4.fromArray(manifest.transform) const regions = manifest.regions.map(r => { const spheres = (r.admissionBounds || [r.bounds]).map(volume => { const s = volume.sphere || [...volume.box.slice(0, 3), Math.hypot(volume.box[3], volume.box[7], volume.box[11])] return new BoundingSphere(Matrix4.multiplyByPoint(transform, new Cartesian3(...s.slice(0, 3)), new Cartesian3()), s[3]) }) const regionUrl = new URL(r.url, url) const absolute = (list) => (list || []).map(p => new URL(p, regionUrl).href) // 票 91(五级 LOD):manifest 的 `contentUrls` 只列 preview+fine,**不含 medium** // (medium 在 tileset 层级里,见 region-0/tileset.json 的 ge=20 层)。 // 已知内容集必须是三者的**并集**,否则 medium 的 tileLoad 事件会被漏记, // "细模是否已渲染"的判定也会把 medium 误当成 fine。 const mediumUrls = absolute(r.mediumContentUrls) return { ...r, url: regionUrl.href, admissionSpheres: spheres, // 预算口径:medium 与 fine 是**同时**可能驻留的,必须一起计入, // 否则按 600 条 / 384MiB 选的区域实际占用会明显超出。 decodedGeometryBytes: (Number(r.decodedGeometryBytes) || 0) + (Number(r.decodedMediumBytes) || 0), expectedContentUrls: [...absolute(r.contentUrls), ...mediumUrls], expectedPreviewContentUrls: absolute(r.previewContentUrls), expectedMediumContentUrls: mediumUrls, } }) const localInstances = [] let cullingVolume, coarseRendered = false const region = createRegionLoadingController({ regions, canStart: () => coarseRendered && model.tilesLoaded && model.show && !model.isDestroyed(), beforeRefresh: () => { cullingVolume = camera.frustum.computeCullingVolume(camera.positionWC, camera.directionWC, camera.upWC) }, onFailure: (r, error) => { failed.push({ url: error.url || r.url, error: String(error.message || error) }); onChange?.({ ready: instances.length, instances, failed }) }, distance: r => { const result = contentAdmission(r.admissionSpheres, sphere => cullingVolume.computeVisibility(sphere) !== Intersect.OUTSIDE, sphere => Cartesian3.distance(camera.positionWC, sphere.center)) r.centerDistance = result.centerDistance return result.distance }, // 票 61 第二轮:细模批次用**专用档**,不继承粗模的 SSE。 // // 旧写法是 `tilesetOptions(plan)` —— plan 是**粗模批次**的配置(SSE 115,为全城 // 少选瓦片而定)。细模层级是 preview(ge=48) → fine(ge=0),窗户/立面全在 fine; // 套用 115 意味着只有 ~293m 内才细化到"有窗",之外停在 preview 的无窗灰盒子。 // 再加上粗模档开着的 foveated 中心偏置 ⇒ 同距离的建筑屏幕中心精细、边缘粗糙, // 正是"同距离一半精模一半火柴盒"。专用档把阈值降到 24 并关掉中心偏置。 load: r => Cesium3DTileset.fromUrl(r.url, detailTilesetOptions(plan)), add: (fine) => { scene.primitives.add(fine); localInstances.push(fine); instances.push(fine); onChange?.({ ready: instances.length, instances, failed }) }, remove: fine => { const i = instances.indexOf(fine); if (i >= 0) instances.splice(i, 1); const j = localInstances.indexOf(fine); if (j >= 0) localInstances.splice(j, 1); removeSceneTileset(scene, fine) }, requestRender: () => scene.requestRender(), // 票 61:前沿参数与预算可由 map.detail 覆盖;未配置即用 domain 默认档。 frontier: activeDetail.frontier, budget: activeDetail.budget, // 判"相机在动/静止"用真实位姿(米级取整),不用区域距离兜底 —— // 细微平移在区域距离上看不出来,会误判成静止而提前放大前沿。 readCameraKey: () => { const p = camera.positionWC, d = camera.directionWC return [p.x, p.y, p.z, d.x, d.y, d.z].map((v) => Math.round(v)).join(',') }, }) region.setPipeline(regionPipeline) const unmask = model.tileVisible.addEventListener(tile => { if (tile.content?.trianglesLength > 0) coarseRendered = true const c = tile.content if (c?.featuresLength === 1) { const feature = c.getFeature(0); feature.show = !region.detailReady(feature.getProperty('region')) } }) const detachFrame = scene.postRender.addEventListener(region.onRender) regionControllers.set(model, region) regionHandles.push({ region, detachFrame, unmask }) region.refresh() scene.requestRender() } const controller = createTilesetLoadingController({ plans, instances, failed, onChange, prepareBounds: readTilesetRootBounds, readCamera: () => ({ position: xyz(camera.positionWC), direction: xyz(camera.directionWC), up: xyz(camera.upWC) }), distanceTo: (bounds) => Math.sqrt(bounds.distanceSquaredTo(camera.positionWC)), load: (plan) => Cesium3DTileset.fromUrl(plan.url, tilesetOptions(plan)), add: (instance, plan) => { if (plan.lighting === true) applyBuildingLighting(scene, instance) scene.primitives.add(instance) if (plan.regionManifest) void initializeRegions(instance, plan).catch(error => { if (regionAbort.signal.aborted) return failed.push({ url: plan.regionManifest, error: String(error.message || error) }); onChange?.({ ready: instances.length, instances, failed }) }) }, remove: (instance) => { removeSceneTileset(scene, instance) }, requestRender: () => scene.requestRender(), }) const detachCamera = camera.changed.addEventListener(controller.refresh) const detachRender = scene.postRender.addEventListener(controller.onRender) let detached = false return { ...controller, setPipeline(value) { regionPipeline = !!value; for (const h of regionHandles) h.region.setPipeline(regionPipeline) }, regionSnapshot() { return regionHandles.flatMap(h => h.region.snapshot()) }, /** 票 61:细模覆盖前沿读数(覆盖到多远、为什么停)。 */ frontierSnapshot() { return regionHandles.map(h => h.region.frontierSnapshot()).filter(Boolean) }, detach() { if (detached) return detached = true regionAbort.abort() for (const h of regionHandles) { h.detachFrame(); h.unmask(); h.region.detach() } detachCamera(); detachRender(); controller.detach() }, } } /** * 3D Tiles 懒加载:启动只读根 tileset.json 的包围盒,eager 批次立即加载, * 其余批次只有相机进入 loadDistance 才初始化;离开较远区域后销毁非 eager 批次。 * 已加载 tileset 仍由 Cesium 做 tile 级视锥剔除和内部 LOD。 */ export async function applyTilesets(scene, tilesets, onChange, onController) { // 网络节流只限制请求,不能代替 GLB 解码/上传完成的批次闸门。 // 第三轮:并发从 10/5 降到 6/3 —— 内容"落地"分散后,主线程上传/着色器编译的 // 单帧尖峰显著变小(p95 更稳),代价是整体加载略慢。 const maxRequests = Number.isFinite(activePerformance.maxConcurrentRequests) ? Math.max(2, Math.min(12, Math.round(activePerformance.maxConcurrentRequests))) : 6 RequestScheduler.maximumRequests = maxRequests RequestScheduler.maximumRequestsPerServer = Math.max(1, Math.ceil(maxRequests / 2)) const instances = [] const failed = [] const culling = createTilesetCullingController(scene, tilesets || [], instances, failed, onChange) if (onController) onController(culling) if (culling) await culling.prepare() return { ready: instances.length, failed, instances, culling } } /** * 视图条件对图层的影响(票 21):管线视图把地形与 3D Tiles 压成半透明(保留空间参照, * 不完全隐藏),地面视图还原为完全不透明。 * * 地形用 Cesium 的 `globe.translucency`(前台/背面 alpha 一起设,避免只透一半); * 3D Tiles 用 `Cesium3DTileStyle` 的 color alpha。两者都只改显示,不动数据,也不碰相机。 * @param {object} viewer * @param {Array} tilesets 已挂上的 tileset 实例(applyTilesets 的 instances) * @param {object} plan `domain/viewModes.js` 的 planViewMode() */ export function applyViewModeLayers(viewer, tilesets, plan) { if (!viewer || !plan) return const globe = viewer.scene && viewer.scene.globe if (globe) { // 始终参与真实地形深度;管线透视只由显式 translucency 开关控制。 globe.depthTestAgainstTerrain = true const alpha = Number.isFinite(plan.terrainAlpha) ? plan.terrainAlpha : 1 globe.translucency.enabled = alpha < 1 globe.translucency.frontFaceAlpha = alpha globe.translucency.backFaceAlpha = alpha } const alpha = Number.isFinite(plan.tilesetAlpha) ? plan.tilesetAlpha : 1 for (const tileset of tilesets || []) { regionControllers.get(tileset)?.setPipeline(plan.mode === 'pipeline') try { // 地面视图还原 = 清掉样式(而不是设成 white/1,省得留一份无意义样式)。 tileset.style = alpha < 1 ? new Cesium3DTileStyle({ color: `color('white', ${alpha})` }) : undefined } catch { // 单个 tileset 不支持 style 时忽略,不影响其它图层。 } } } export function destroyViewer(viewer) { if (!viewer.isDestroyed()) viewer.destroy() } // ================================================================ 自建视距雾(票 38) /** 自建雾处理级的名字(唯一一个)。 */ export const FOG_STAGE_NAME = 'cockpit-area-fog' /** * 世界射线距离雾的片元着色器(**B 方案:只处理有有效几何深度的地物**)。 * * 契约来源 `cockpit-primary-area-guardrails/spec-fog.md`(决策票 07 固化 B 方案)。三条硬约束: * * 1. **无有效深度 ≠ 天空**,但本级**不重绘它**:`rawDepth >= 1.0` 时整段返回原色 * (不套雾色、不做地平线渐变)。依据决策票 07「采用 B:仅地物距离雾……无有效几何深度背景保持原颜色」, * 以及其只读复核「深度清为 1.0,天空盒与大气不写深度」「无深度值也可能对应没写自身深度的透明内容」。 * 2. **只反解一次对数深度**:`czm_windowToEyeCoordinates(gl_FragCoord.xy, rawDepth)` 这个重载 * **内部自己解 log**(`windowToEyeCoordinates.glsl`),喂给它的是**原始** `texture(depthTexture).r`。 * 先 `czm_reverseLogDepth` 再传进去会解两次 —— 复核里点名的坑。 * 沿视线的距离取重建位置的长度,不取 `-z`;并按 `gl_FragCoord.w` 做齐次除法。 * 3. **两端语义精确**:0 处全清、`endDistance` 处全遮蔽。曲线形状(smoothstep + 保端点光学厚度) * 在 `domain/fog.js` 的 `fogAttenuation` 里算好,这里只做 `mix`,**GLSL 不重写一份数学** * (两处各写一份必然漂移,而端点语义是本票的验收点)。 * * 与原型 `prototypes/fog-prototype-stage.js`(**已归档到 `throwaway/cockpit-prototypes` 分支**, * 票 40 收口)的区别:去掉只有决策票 07 才有的 C 方案分支 * (地平线染色与太阳暖色,已裁定不采用),并改用 CPU 侧算好的强度 uniform。 */ const FOG_FRAGMENT_SHADER = ` uniform sampler2D colorTexture; uniform sampler2D depthTexture; uniform vec3 fogColor; uniform float effectStrength; uniform float startDistance; uniform float endDistance; in vec2 v_textureCoordinates; // 世界射线距离 → 不透明度。与 domain/fog.js 的 fogAttenuation 同一形状, // 但它必须在 GPU 上逐片元算,所以这里保留一份等价实现;**端点断言在 CPU 侧**。 float fogAlpha(float distanceM) { if (distanceM <= startDistance) return 0.0; if (distanceM >= endDistance) return 1.0; float t = (distanceM - startDistance) / (endDistance - startDistance); float smoothT = t * t * (3.0 - 2.0 * t); return (1.0 - exp(-3.0 * smoothT)) / (1.0 - exp(-3.0)); } void main() { vec4 base = texture(colorTexture, v_textureCoordinates); float rawDepth = texture(depthTexture, v_textureCoordinates).r; // 无有效几何深度:**保留原色**(B 方案)。这一支也不做"天空分类"的臆断。 if (rawDepth >= 1.0) { out_FragColor = base; return; } // 原始深度交给这个重载,它内部自己解 log;再补一次齐次除法。 vec4 eye = czm_windowToEyeCoordinates(gl_FragCoord.xy, rawDepth); vec3 eyePos = eye.xyz / eye.w; float distanceM = length(eyePos); float alpha = clamp(fogAlpha(distanceM) * effectStrength, 0.0, 1.0); out_FragColor = vec4(mix(base.rgb, fogColor, alpha), base.a); }` /** * 在场景上建**唯一**一个自建雾处理级(票 38)。幂等:同一 viewer 只会有一个。 * * 三条实现要点,每条都对着决策票 07 的只读复核: * · **能力探测交给引擎**:stage 声明了 `depthTexture` 而设备不支持时, * `PostProcessStage` 自己会整级禁用(`_isSupported`);我们只如实读回 `stage.ready` * 与 `scene.context.depthTexture`,不自己造探测逻辑。 * · **复用对象**:进出与调强度都只改 uniform,不 remove/add(`PostProcessStageCollection.remove` * 会 destroy,对象不可复用)。只有离开地图才 `remove` + 释放。 * · **禁用不等于零开销**:规格明确「禁用后引擎仍可能准备/清理后处理 framebuffer」, * 所以对外措辞只说"禁用",不承诺"完全没有 GPU 开销"。 * * @param {Viewer} viewer * @param {object} params `domain/fog.js` 的 `planFog()` 结果 * @returns {{stage: PostProcessStage, uniforms: object, supported: boolean, destroy: () => void}} */ export function createFogStage(viewer, params) { const scene = viewer.scene /** * 雾的动态 uniform **必须用函数传**(不能用常量)。 * * 依据(Cesium 1.145 `PostProcessStage` 源码,本票实测确认): * · 构造时 `_actualUniforms[name] = uniforms[name]` 会**快照一份**常量值; * · 之后每帧喂给 shader 的是 `_actualUniforms`(`getUniformMapFunction` 读它), * 而 `stage._uniforms` 在构造末尾被**换成另一个对象**。 * 所以「传常量 + 事后改那个常量对象」**永远不会生效** —— 改了原始对象, * 快照仍是初值,shader 拿到的始终是初值。症状极具迷惑性: * 读数、dataset、`stage.enabled` 全都说"已启用、强度 1",而**画面上什么都没有**。 * * 这也正是可丢弃原型 `prototypes/fog-prototype-stage.js`(**已归档到 * `throwaway/cockpit-prototypes` 分支**,票 40 收口)用 `() => params.amount` * 这种写法能跑通的原因 —— 函数形式的 uniform 每帧现取,天然支持动态改值。 */ const state = { fogColor: new Cartesian3(1, 1, 1), effectStrength: 0, startDistance: params.startDistance, endDistance: params.endDistance, } const stage = scene.postProcessStages.add( new PostProcessStage({ name: FOG_STAGE_NAME, fragmentShader: FOG_FRAGMENT_SHADER, uniforms: { fogColor: () => state.fogColor, effectStrength: () => state.effectStrength, startDistance: () => state.startDistance, endDistance: () => state.endDistance, }, }), ) // 先禁用:强度为 0 时不该有任何一次全屏 pass(首次启用由宿主显式打开)。 stage.enabled = false return { stage, state, /** 深度能力:`Context.depthTexture` 是**布尔能力标志**(不是纹理对象,且不在 d.ts 里)。 */ supported: scene.context ? scene.context.depthTexture === true : false, /** * DEV 诊断:对比"我们以为写进去的强度"与"Cesium 真正喂给 shader 的强度"。 * * 读的是 `stage._uniformMap[name]()` —— 那是每帧实际取值的那条路径, * 也是唯一能证伪"值到底有没有送进 shader"的地方。两者不一致就说明 uniform 通道没接对 * (常量快照坑:直接写原始对象绕过了 setter,`_actualUniforms` 永远是初值)。 */ readUniformDiagnostics() { let delivered = null let storedKind = null let storedValue = null try { const uniformMap = stage._uniformMap delivered = uniformMap && typeof uniformMap.effectStrength === 'function' ? uniformMap.effectStrength() : null } catch (error) { delivered = null } try { // `_actualUniforms` 在**构造时**就写好了(不等渲染),所以这一项在隐藏标签页里也可读。 // 它是判定"动态 uniform 有没有接对"的决定性证据: // · 存的是 `function` → 每帧现取,改 state 立刻生效(正确形态); // · 存的是 `number` → 构造时快照,事后改原始对象**永远不生效**(本票的坑)。 const stored = stage._actualUniforms ? stage._actualUniforms.effectStrength : undefined storedKind = typeof stored storedValue = typeof stored === 'number' ? stored : null } catch (error) { storedKind = null } return { intended: state.effectStrength, deliveredToShader: delivered, matches: delivered === state.effectStrength, /** `'function'` = 动态(对);`'number'` = 常量快照(错,改了不生效)。 */ storedKind, storedValue, enabled: stage.enabled, ready: stage.ready === true, } }, destroy() { if (scene.isDestroyed && scene.isDestroyed()) return if (stage.isDestroyed && stage.isDestroyed()) return scene.postProcessStages.remove(stage) }, } } /** 把强度与颜色写进 uniform(**只改 dynamic state,不重建 stage**)。 */ export function applyFogUniforms(handle, { strength, colorRgb, startDistance, endDistance }) { if (!handle || !handle.state) return const state = handle.state if (Number.isFinite(strength)) state.effectStrength = Math.min(1, Math.max(0, strength)) if (colorRgb) state.fogColor = new Cartesian3(colorRgb.r, colorRgb.g, colorRgb.b) if (Number.isFinite(startDistance)) state.startDistance = startDistance if (Number.isFinite(endDistance)) state.endDistance = endDistance // 开关与强度同源:强度为 0 就没有必要跑这一遍全屏 pass。 if (handle.stage) handle.stage.enabled = state.effectStrength > 0 } /** 该处理级是否已可执行(**不等于**画面已被遮住 —— 规格明确区分这两件事)。 */ export function fogStageExecutable(handle) { if (!handle || !handle.stage) return false if (handle.stage.isDestroyed && handle.stage.isDestroyed()) return false return handle.stage.ready === true } /** 当前是否有自建雾处理级挂在场景上(排查与走查读数)。 */ export function hasFogStage(viewer) { if (!viewer || !viewer.scene || !viewer.scene.postProcessStages) return false return viewer.scene.postProcessStages.length > 0 } // ================================================================ 场景调色(科技蓝滤镜) const GRADE_STAGE_NAME = 'cockpit-scene-grade' /** * 全屏调色 fragment shader:降饱和 → 对比度 → 亮度 → 阴影偏蓝。 * 数学与 `domain/sceneGrade.js` 的 `gradeColor()` **同序同式**(那边是本式的逐位镜像, * 逻辑闸门在 domain 上断言的结论才对引擎侧成立)。不做 hue-rotate 双色 —— * 状态点的红/绿是第一线索(票 14),色相顺序不能动。 */ const GRADE_FRAGMENT_SHADER = ` uniform sampler2D colorTexture; uniform float gradeSaturation; uniform float gradeBrightness; uniform float gradeContrast; uniform vec3 gradeTint; uniform float gradeTintStrength; in vec2 v_textureCoordinates; void main(void) { vec4 base = texture(colorTexture, v_textureCoordinates); float luma = dot(base.rgb, vec3(0.2126, 0.7152, 0.0722)); vec3 color = mix(vec3(luma), base.rgb, gradeSaturation); color = clamp((color - 0.5) * gradeContrast + 0.5, 0.0, 1.0); color = clamp(color * gradeBrightness, 0.0, 1.0); float tintWeight = clamp(gradeTintStrength * (1.0 - luma), 0.0, 1.0); color = mix(color, gradeTint, tintWeight); out_FragColor = vec4(color, base.a); }` /** * 在场景上建**唯一**一个调色处理级。常驻(不做进出生命周期):enabled 随配置。 * * uniform 一律**函数形态**(票 38 实测的坑:常量会被 PostProcessStage 在构造时快照, * 事后改原始对象永远不生效;函数每帧现取,改 state 立即生效)。 * 不需要深度纹理能力(雾才要),所以没有 support 探测 —— 建失败走 try/catch 如实上抛。 * * @param {Viewer} viewer * @param {object} params `domain/sceneGrade.js` 的 `planSceneGrade()` 结果 */ export function createSceneGradeStage(viewer, params) { const scene = viewer.scene const tint = params.tintRgb || parseHexColor(GRADE_DEFAULTS.tint) const state = { saturation: params.saturation, brightness: params.brightness, contrast: params.contrast, tint: new Cartesian3(tint.r, tint.g, tint.b), tintStrength: params.tintStrength, } const stage = scene.postProcessStages.add( new PostProcessStage({ name: GRADE_STAGE_NAME, fragmentShader: GRADE_FRAGMENT_SHADER, uniforms: { gradeSaturation: () => state.saturation, gradeBrightness: () => state.brightness, gradeContrast: () => state.contrast, gradeTint: () => state.tint, gradeTintStrength: () => state.tintStrength, }, }), ) stage.enabled = params.enabled !== false return { stage, state, destroy() { if (scene.isDestroyed && scene.isDestroyed()) return if (stage.isDestroyed && stage.isDestroyed()) return scene.postProcessStages.remove(stage) }, } } /** 把调色参数写进 uniform(**只改 dynamic state,不重建 stage**;与 applyFogUniforms 同纪律)。 */ export function applySceneGradeUniforms(handle, { saturation, brightness, contrast, tintRgb, tintStrength, enabled }) { if (!handle || !handle.state) return const state = handle.state if (Number.isFinite(saturation)) state.saturation = saturation if (Number.isFinite(brightness)) state.brightness = brightness if (Number.isFinite(contrast)) state.contrast = contrast if (tintRgb) state.tint = new Cartesian3(tintRgb.r, tintRgb.g, tintRgb.b) if (Number.isFinite(tintStrength)) state.tintStrength = tintStrength if (handle.stage) handle.stage.enabled = enabled !== false } // ================================================================ 点位标记(票 09) /** * 点位标记的数据源名:**唯一一个**,重新同步时整份替换而不是叠加 * (否则翻一页就多画一批,地图上会出现已经不存在于当前快照的点)。 */ export const POINT_MARKER_SOURCE = 'cockpit-point-markers' /** 管线数据源名(票 22 交付;票 14 的高亮已经按它扫,落地当天即生效,不需要再改高亮)。 */ export const PIPELINE_SOURCE = 'cockpit-pipelines' /** * 关联监测设备图层的**独立**数据源名(票 42)。 * * 与 `POINT_MARKER_SOURCE` 分开是**结构性**的:关联设备只进这个临时图层, * 永远不写回摘要标记计划(规格:摘要分页、摘要图例、`data-map-markers*` 都不含它)。 */ export const DEVICE_MARKER_SOURCE = 'cockpit-associated-device-markers' /** * 建/取点位标记的数据源(幂等)。 * * **必须自己缓存实例,不能只靠 `dataSources.getByName()`**: * Cesium 的 `DataSourceCollection.add()` 是**异步**的(`Promise.resolve(ds).then(...)` 里才 push * 进 `_dataSources`)。同一个 tick 里「先写后读」会走成这样: * 1. `syncPointMarkers()` 建了数据源 A(还没注册进集合)并把 8 个标记加进 A; * 2. 紧接着 `readPointMarkers()` 调 `getByName()` —— 集合里还没有 A,于是**又建了一个空数据源 B**, * 读回来 0 个标记。 * 症状极具迷惑性:模块计数与 dataset 都说 8,组件渲染出来的却是「点位标记 0」, * 而晚一点的 DEV 把手调用(那时 A 已注册)又读回 8 —— 同一个页面两套答案。 * 这是视觉探针截图里「图例有计数、标记写 0」的真正原因(走查没抓到,因为它读的是 dataset 与 DEV 把手)。 * * 用 WeakMap 按 viewer 缓存:viewer 销毁后条目自然消失,不会漏实例,也不会在集合里留空数据源。 */ const markerSources = new WeakMap() function pointMarkerSource(viewer) { const cached = markerSources.get(viewer) if (cached) return cached // 兜一道:热重载等情况下集合里可能已经有同名数据源,优先复用它。 const existing = viewer.dataSources.getByName(POINT_MARKER_SOURCE)[0] if (existing) { markerSources.set(viewer, existing) return existing } const source = new CustomDataSource(POINT_MARKER_SOURCE) viewer.dataSources.add(source) markerSources.set(viewer, source) return source } /** 地图文字:浅亮字面、状态色细描边,名称与聚合计数共用。 * 批次 3 走查修订:位置符号标签加**深色底板**(配色对齐 UI 面板深蓝系)提升可读; * `pixelOffset` 可覆盖默认偏移(BOTTOM 锚定的标记需要把标签顶到图标上方)。 * `maxWidthPx` 限宽(文字可明显超出图标),超宽截断走 **domain/ellipsis.js 的共用契约** * (`ellipsisForMapLabel`——与 DOM 侧 EllipsisText 同一份省略语义,媒介不同实现各异)。 * 完整名称走通用 hover:截断的 label 只管「认得出」,全名由 hover tooltip(DOM 层) * 与实体 description 承载,不塞进标签文本。 */ function mapTextLabel(text, color, kind = 'name', show = true, pixelOffset = null, maxWidthPx = 0) { const style = MAP_LABEL_STYLE[kind] const tone = Color.fromCssColorString(color) || Color.WHITE // 限宽截断走共用契约(字宽估算的字号从本标签的 font 里取)。 const fontMatch = /(\d+(?:\.\d+)?)px/.exec(style.font) const fontSize = fontMatch ? Number(fontMatch[1]) : 12 const { shown } = ellipsisForMapLabel(text, maxWidthPx, fontSize) return { text: mapLabelText(shown, kind), font: style.font, fillColor: Color.WHITE, outlineColor: tone.withAlpha(0.65), outlineWidth: style.outlineWidth, style: LabelStyle.FILL_AND_OUTLINE, pixelOffset: pixelOffset || new Cartesian2(0, style.offsetY), heightReference: HeightReference.CLAMP_TO_GROUND, verticalOrigin: VerticalOrigin.BOTTOM, // 深色底板:label 与地图影像同色系时不再糊在一起(走查反馈)。 // 底板配色对齐 UI 面板风格(tokens 的面板深蓝 + 青色线):Cesium 底板不支持渐变/圆角, // 用「深蓝实底 + 面板线色」近似 cockpit 面板观感;文字描边已弱化(底板承担对比度)。 showBackground: true, backgroundColor: Color.fromCssColorString('#03203c').withAlpha(0.78), // 走查第五轮:文字到框边的距离收窄(4→3),底板更贴字。 backgroundPadding: new Cartesian2(3, 3), show, } } /** * 点位标记的**差量同步**(票 33)。 * * 以前这里是 `removeAll()` 整层重建(票 26 诊断:与管线层全量重赋值同批发生, * 刷新期标记同步闪烁)。现在按 `point|${key}` 索引 + 内容指纹做增删改: * · 指纹未变 → 一个属性都不写(实体原样留在图上,不闪); * · 指纹变了 / 坐标失效 / 从计划里消失 → 只动那一个实体; * · 重新上图仍用**同一个实体 id**,拾取与读数路径(`readPointMarkers` 等)不变。 * 指纹取自标记计划输入(非 Cesium 对象),除 key 外一切影响渲染的字段都进来。 * * @param {Viewer} viewer * @param {Array} markers `domain/pointStatus.js` 的 `planPointMarkers().markers` * @param {(marker: object) => string} colorOf 取该标记的 CSS 颜色(服务层从 token 读,引擎不写色值) * @param {(shape: string, color: string) => string} imageOf 形状+颜色 → 图片 URL(domain/markerSvg) * @returns {{rendered: number, skipped: number, unchanged: number}} */ const markerEntityIndex = new WeakMap() // viewer → Map(`point|${key}` → { entity, fp }) function markerFingerprint(marker) { return JSON.stringify([ marker.key, marker.longitude, marker.latitude, marker.status, marker.tone, marker.shape, marker.visualKey, marker.code, marker.name || '', marker.deviceCodes || [], ]) } export function syncPointMarkers(viewer, markers, colorOf, imageOf, planOf, tintOf, options = {}) { const source = pointMarkerSource(viewer) // 票 35 第七轮(点位加载卡顿):整批实体创建 + 逐标记 canvas 合成挤在同一个任务里, // 一页几百个点位就会卡住一帧以上。这里支持**分帧落地**:本帧先建 `options.budget` 个, // 其余在下一帧继续(DOM overlay 那层不受影响,标记图例也已从计划立刻更新)。 const budget = Number.isFinite(options.budget) && options.budget > 0 ? options.budget : Infinity // 票 76(C4):DOM overlay 接管点位视觉时(`options.hideVisual`),实体照建、读数照旧, // 只把 billboard/label 的 `show` 置 false —— 不重画同一枚标记;DOM 点位坐标走相机投影 // (`projectToWindow`),不依赖 billboard,故隐藏视觉不影响 DOM 位置。 const hideVisual = options.hideVisual === true let index = markerEntityIndex.get(viewer) if (!index) { index = new Map() markerEntityIndex.set(viewer, index) } const wanted = new Set() let rendered = 0 let skipped = 0 let unchanged = 0 let truncated = false // 本帧因预算被跳过、但**确实需要重建**的标记数。它就是调用方下一帧还欠的工作量, // 也是 remaining 的唯一真值来源(旧实现用 wanted.size - index.size 推算,截断时恒错)。 let backlog = 0 for (const marker of markers || []) { const id = `point|${marker.key}` // 经纬度缺失或非有限值不上图:计划层已按 `renderable` 过滤, // 这里再挡一道,免得脏数据把整批 entity 建崩(一条坏数据不该让其余点位消失)。 if (!Number.isFinite(marker.longitude) || !Number.isFinite(marker.latitude)) { skipped += 1 // 票 33:曾经上过图、现在坐标失效的,按差量撤下——不留在图上冒充仍可渲染。 const stale = index.get(id) if (stale) { source.entities.remove(stale.entity) index.delete(id) } continue } wanted.add(id) const fp = markerFingerprint(marker) const record = index.get(id) if (record && record.fp === fp) { unchanged += 1 continue } // 分帧预算用尽:本帧不再新建实体,但**必须继续遍历**,不能 break。 // 缺陷复盘(票 60 自查):这里原先是 break,于是截断点之后的标记既没被 wanted 收录、 // 也没被渲染;紧随其后的「撤下计划外」循环便把**仍在计划里**的既有实体当成计划外删掉 // (实测:图上已有 500 个点位 + 250 个状态变化散落全表 + budget=120 ⇒ 首帧误删 259 个, // 下一帧再重建回来,既闪烁又白花 GPU)。用 continue 只「跳过新建」, // 让计划成员资格(wanted)与分帧预算彻底解耦。 if (rendered >= budget) { truncated = true; backlog += 1; continue } const color = colorOf(marker) if (record) source.entities.remove(record.entity) // 批次 3:合成计划(若有)决定母版/内层/锚点/降饱和与最终尺寸;尖端锚定。 // 未传 planOf(单测/旧调用方)时保持中心锚定的旧 billboard 行为。 // 第三轮:billboard 绘制尺寸用 `plan.draw`(含描边余量),本体锚点比例按 pad 重算—— // 尖端在含 pad 画布里的相对位置 = (pad + 本体高 × 1) / 绘制高(BOTTOM 对齐的是画布底)。 const plan = planOf ? planOf(marker) : null const size = plan ? plan.draw.width : 18 const height = plan ? plan.draw.height : 18 const entity = source.entities.add({ id, position: Cartesian3.fromDegrees(marker.longitude, marker.latitude), billboard: { // 首帧用同步兜底(纯色水滴形),异步合成完成后替换为双层合成位图。 image: plan ? imageOf(marker.shape, color) : imageOf(marker.shape, color), // 像素尺寸:宽度基准(点 18 / 设备 14),高按母版比例。 width: size, height, scale: 1, // 贴地:有地形时点位落在地表而不是椭球面。 heightReference: HeightReference.CLAMP_TO_GROUND, // 批次 3:尖端锚定——含 pad 画布的底边比尖端低 pad 屏显值,pixelOffset 下移补偿, // 使水滴尖(而非描边外沿)落在真实坐标。 verticalOrigin: plan ? VerticalOrigin.BOTTOM : VerticalOrigin.CENTER, pixelOffset: plan ? new Cartesian2(0, plan.draw.pad) : new Cartesian2(0, 0), disableDepthTestDistance: Number.POSITIVE_INFINITY, show: !hideVisual, }, label: marker.name ? { // 批次 3:BOTTOM 锚定下标签顶到图标上方(计划层统一产出偏移),修「标签与图标重叠」。 // 首帧用原生 label(截断文本),异步**底板位图**完成后整体替换—— // 圆角 + 青色装饰边的底板 Cesium 原生画不出(走查反馈「硬得不行」)。 ...mapTextLabel(marker.name, color, 'name', true, plan ? new Cartesian2(0, plan.labelOffsetY) : null, plan ? plan.labelMaxWidth : 0), disableDepthTestDistance: Number.POSITIVE_INFINITY, show: !hideVisual, } : undefined, // 完整名称给通用 hover/拾取链路(走查反馈「hover 时显示详情」)。 description: marker.name ? `
名称${marker.name}
编码${marker.code}
状态${marker.label || marker.status}
` : undefined, // 供走查/人工排查读状态:不依赖中文字符串,也不依赖读图。 properties: { pointCode: marker.code, // 批次 3:hover tooltip 用完整名称(label 只放截断文本)。 pointName: marker.name || '', deviceCodes: marker.deviceCodes || [], pointKind: marker.kind, pointStatus: marker.status, visualKey: marker.visualKey, shape: marker.shape, tone: marker.tone, // 批次 3:合成计划读数(规格 10 宿主观测接缝)。 ...(plan ? planToDataset(plan) : {}), }, }) index.set(id, { entity, fp }) rendered += 1 // 异步合成:完成后替换 billboard 图片(指纹含计划读数,避免重复合成)。 if (plan && planOf) { renderMarker(plan, { tintHex: tintOf ? tintOf(marker) : color }) .then((dataUrl) => { // 实体可能已被差量撤下:只在仍在图上时替换。 if (index.get(id) && entity.billboard) { entity.billboard.image = dataUrl } }) .catch(() => { // 合成失败保留同步兜底图(降级可见),不阻塞其余标记。 }) // 批次 3 第五轮曾试「底板烘位图替换 label」——维护者复核**文字消失**(Cesium label // 的 image/text 互斥渲染 + text=' ' 导致零宽),已**回退为原生 label 底板**: // 文字可见性优先于底板美化;圆角/装饰边等 Cesium 引擎限制如实登记(走查遗留)。 } } // 本轮不该出现的(被计划裁掉的):逐个撤下,不再整层 removeAll。 for (const [id, record] of [...index.entries()]) { if (wanted.has(id)) continue source.entities.remove(record.entity) index.delete(id) } // 分帧落地:还有没建完的标记时如实告诉调用方,由它下一帧继续(DOM 层已经是全量)。 // remaining 直接取本帧记下的欠账,**不再**用 wanted.size - index.size 推算: // 那个式子在截断时只数到 break 点,实测把「还差 380」报成「还差 1」, // 任何按它做进度/重试的下游都会读到假数字。 const complete = !truncated return { rendered, skipped, unchanged, complete, remaining: backlog } } /** * 只读回点位标记的**真实图层内容**(走查据此断言「标记真的画在地图上」, * 而不是只信计划算出来的数)。 * * 除了计数与状态分布,还回每个视觉键对应的 billboard 图片 URL:走查用它证明 * 「每个状态的颜色确实不同、形状确实是形状」——这两件事只有从引擎实体上读才算证据。 */ export function readPointMarkers(viewer) { const source = pointMarkerSource(viewer) const byStatus = {} const shapes = new Set() const images = {} for (const entity of source.entities.values) { const properties = entity.properties || {} const get = (key) => (properties[key] ? properties[key].getValue() : '') const visualKey = get('visualKey') || 'UNKNOWN' const shape = get('shape') || '' byStatus[visualKey] = (byStatus[visualKey] || 0) + 1 if (shape) shapes.add(shape) // 同一视觉键只留一份图片,够判「不同状态图片不同」。 if (!images[visualKey]) { const billboard = entity.billboard images[visualKey] = { shape, tone: get('tone'), image: billboard ? billboard.image.getValue() : '', } } } return { count: source.entities.values.length, byStatus, shapes: [...shapes].filter(Boolean).sort(), images } } /** * 只读回点位标记的**窗口坐标**(票 45 走查用:真实鼠标要点的位置)。 * * 与 `readOverlapTargets` 同一条纪律:只回投影后的屏幕坐标与身份,不出 Cesium 实体。 * 用**逻辑可见性** `entity.show`(`isShowing` 是渲染态,headless 下会差一帧)。 */ export function readPointMarkerTargets(viewer) { const source = pointMarkerSource(viewer) const rect = viewer && viewer.canvas ? viewer.canvas.getBoundingClientRect() : null if (!rect || !viewer.scene) return [] const ellipsoid = viewer.scene.globe && viewer.scene.globe.ellipsoid return source.entities.values .map((entity) => { const properties = entity.properties || {} const get = (key) => (properties[key] && properties[key].getValue ? properties[key].getValue() : '') const position = entity.position && entity.position.getValue ? entity.position.getValue(viewer.clock.currentTime) : null if (!position) return null // 点位标记是 `HeightReference.CLAMP_TO_GROUND` **贴地形**渲染的; // 直接投影 `entity.position`(椭球零高度)会与画面上看到的标记差出一段地形高度, // 真实鼠标按那个坐标点会**打不中**(票 45 真实路径走查抓到)。 // 这里与渲染同口径:先取地形高度,再投影。 let world = position if (ellipsoid) { const carto = ellipsoid.cartesianToCartographic(position) if (carto) { let height = 0 const sampled = viewer.scene.globe.getHeight(carto) if (Number.isFinite(sampled)) height = sampled world = Cartesian3.fromRadians(carto.longitude, carto.latitude, height) } } const projected = SceneTransforms.worldToWindowCoordinates(viewer.scene, world) if (!projected || !Number.isFinite(projected.x) || !Number.isFinite(projected.y)) return null return { x: projected.x + rect.left, y: projected.y + rect.top, code: get('pointCode'), status: get('pointStatus'), visible: entity.show !== false, } }) .filter(Boolean) } /** 清空点位标记(快照失效/刷新前用:旧快照的点不得留在图上)。 */ export function clearPointMarkers(viewer) { pointMarkerSource(viewer).entities.removeAll() // 票 33:差量索引一起作废——实体已整层清掉,留指纹会让下次同步误判「未变」。 markerEntityIndex.delete(viewer) } // ================================================================ 关联监测设备图层(票 42) const deviceSources = new WeakMap() /** 建/取关联设备图层的数据源(幂等,与点位标记同款:必须自己缓存,理由见上方注释)。 */ function deviceMarkerSource(viewer) { const cached = deviceSources.get(viewer) if (cached) return cached const existing = viewer.dataSources.getByName(DEVICE_MARKER_SOURCE)[0] if (existing) { deviceSources.set(viewer, existing) return existing } const source = new CustomDataSource(DEVICE_MARKER_SOURCE) viewer.dataSources.add(source) deviceSources.set(viewer, source) return source } /** * 把**当前选中点位的关联设备**同步到独立临时图层(票 42)。 * * 三件事是规格硬要求,都体现在签名与实体属性上: * 1. **整份替换**(`removeAll` 后重建):关闭/换点/刷新时旧图层立即撤下,不会与新的混在一起; * 2. **独立身份**:实体 id 与属性都带 `deviceCode` **和** `monitoringPointCode`, * 绝不写 `pointCode` —— 设备不冒充监测点(票 05 的结构性保证); * 3. **尺寸小于监测点**(14 vs 18):层级靠尺寸区分,具体形状/资产接缝归票 44。 * * 调用方只传**已判定可上图**的行(`domain/deviceLayer.js` 的 `placement === 'drawn'`); * 这里再挡一道经纬度,避免一条脏数据把整批 entity 建崩。 * * @param {Viewer} viewer * @param {Array} devices 领域行(含 `code`/`pointCode`/`longitude`/`latitude`/`status`) * @param {(device: object) => string} colorOf 取该设备的 CSS 颜色(色值仍由服务层从 token 读) * @param {(shape: string, color: string) => string} imageOf 形状+颜色 → 图片 URL * @returns {{rendered: number, skipped: number}} */ /** * 票 76(C4):最近一次设备层同步是否把 billboard/label 视觉让位给了 DOM overlay。 * 给 `setOverlapPresentation()` 用 —— 展开/选中时它会把设备 label 打开,若不拦, * 被关掉的旧 Cesium label 会「回流」(走查:点开聚合内设备后顶部标签变回旧渲染)。 */ let deviceVisualHidden = false export function syncAssociatedDevices(viewer, devices, colorOf, imageOf, overlapGroups = [], planOf, tintOf, options = {}) { deviceVisualHidden = options.hideDeviceVisual === true || options.hideAggregateVisual === true const source = deviceMarkerSource(viewer) source.entities.removeAll() // 票 47:实体是**整份重建**的,动效基准与循环必须一起复位 // (否则基准表里留着上一批实体的 id,动效会写到不存在的对象上)。 deviceBaseStyle.clear() stopDeviceEffects() let rendered = 0 let skipped = 0 const groupsByCode = new Map() for (const group of Array.isArray(overlapGroups) ? overlapGroups : []) { if (!group || group.k < 2) continue for (const row of group.rows || []) groupsByCode.set(row.code, group) } for (const device of devices || []) { if (!Number.isFinite(device.longitude) || !Number.isFinite(device.latitude)) { skipped += 1 continue } const color = colorOf(device) // 票 44:尺寸与形状都来自**单一资产接缝**(`domain/deviceAssets.js` 的计划), // 引擎不认识"设备该多大",也不写死尺寸字面量。 const size = Number.isFinite(device.size) ? device.size : MARKER_BODY_WIDTH.device const group = groupsByCode.get(device.code) // 批次 3:关联设备走实心母版合成计划(第三轮:绘制尺寸含描边余量 `plan.draw`)。 const plan = planOf ? planOf(device) : null const drawW = plan ? plan.draw.width : size const drawH = plan ? plan.draw.height : size const entity = source.entities.add({ id: `device|${device.pointCode}|${device.code}`, position: Cartesian3.fromDegrees(device.longitude, device.latitude), show: !group, billboard: { image: imageOf(device.shape || 'circle', color, size), width: drawW, height: drawH, scale: 1, // 第三轮:含 pad 画布的底边比尖端低 pad 屏显值,下移补偿(尖端落真实坐标)。 pixelOffset: plan ? new Cartesian2(0, plan.draw.pad) : new Cartesian2(0, 0), heightReference: HeightReference.CLAMP_TO_GROUND, verticalOrigin: plan ? VerticalOrigin.BOTTOM : VerticalOrigin.CENTER, // 票 76(C4):DOM 承担设备视觉时只关渲染(`show:false`),实体与属性照旧 —— // `projectDeviceMarkers` 的坐标/scale/alpha 镜像、`projectMarkerEntity` 投影都不受影响。 show: !(options.hideDeviceVisual === true), }, label: group ? { ...mapTextLabel(`聚合 ${group.k} 台${group.visual.label ? ` · 组内${group.visual.label}` : ''}`, color, 'count', false, plan ? new Cartesian2(0, plan.labelOffsetY) : null, plan ? plan.labelMaxWidth : 0), show: !(options.hideDeviceVisual === true), } : undefined, properties: { deviceCode: device.code, // 批次 3:hover tooltip 用完整名称(label 只放截断文本)。 deviceName: device.name || '', monitoringPointCode: device.pointCode, deviceStatus: device.status || '', deviceShape: device.shape || 'circle', assetMode: device.assetMode || '', assetReason: device.assetReason || '', assetReasonText: device.assetReasonText || '', overlapGroup: group ? group.key : '', overlapPointCode: group ? group.pointCode : '', overlapAggregate: false, overlapEntry: false, overlapCount: group ? group.k : 0, overlapFanIndex: -1, // 批次 3:合成计划读数(规格 10 宿主观测接缝)。 ...(plan ? planToDataset(plan) : {}), }, }) rendered += 1 // 异步合成:完成后替换 billboard 图片(失败保留同步兜底)。 if (plan) { renderMarker(plan, { tintHex: tintOf ? tintOf(device) : color }) .then((dataUrl) => { if (entity.billboard) entity.billboard.image = dataUrl }) .catch(() => {}) } } // 聚合符号没有 deviceCode:拾取它只能返回 overlap action,不能冒充任一台设备。 // // 票 76(C1):DOM overlay 接管聚合符号视觉后(`options.hideAggregateVisual`), // 实体**保留**(身份/位置/属性/投影/读数面全在),只把 `billboard.show` / `label.show` // 置 false —— 不重画同一个聚合符号,同时 `entity.isShowing` 仍为真(Cesium 的 // isShowing 只看 entity.show 与 availability,不看单个 graphics 的 show), // 于是 `projectMarkerEntity`(要求有 billboard)与 `readAssociatedDevices` 读数照旧; // GPU 拾取交给 overlay 节点的点击(见 mapService.onPick)。 const hideAggregateVisual = options.hideAggregateVisual === true // 票 82:聚合档 = 设备档(单源几何),billboard 尺寸不再 `size * 1.5` 自造第三个口径。 const aggregateMetrics = markerMetricsFor('overlap-aggregate') for (const group of Array.isArray(overlapGroups) ? overlapGroups : []) { if (!group || group.k < 2 || !group.rows.length) continue const representative = group.rows.find((row) => row.status === (group.visual && group.visual.status)) || group.rows[0] const color = colorOf(representative) const size = Number.isFinite(representative.size) ? representative.size : MARKER_BODY_WIDTH.device const visual = group.visual || {} source.entities.add({ id: `overlap|${group.pointCode}|${group.key}`, position: Cartesian3.fromDegrees(representative.longitude, representative.latitude), billboard: { image: imageOf(visual.shape, color, size), width: aggregateMetrics.drawWidth, height: aggregateMetrics.drawHeight, scale: 1, pixelOffset: new Cartesian2(0, 0), heightReference: HeightReference.CLAMP_TO_GROUND, verticalOrigin: VerticalOrigin.CENTER, show: !hideAggregateVisual, }, label: { ...mapTextLabel(`聚合 ${group.k} 台${visual.label ? ` · 组内${visual.label}` : ''}`, color, 'count'), show: !hideAggregateVisual, }, properties: { overlapAggregate: true, overlapGroup: group.key, overlapPointCode: group.pointCode, overlapCount: group.k, overlapState: group.state, overlapLabel: group.label, deviceStatus: visual.status, overlapEffectCode: representative.code, // 票 76(C1):DOM 承载聚合视觉所需的形状/色键(与 billboard 同源,渲染层不另造)。 overlapShape: visual.shape || 'circle', overlapTone: visual.tone || '', // 不能含 `deviceCode` / `monitoringPointCode`:聚合符号不冒充设备身份。 }, }) } return { rendered: devices ? devices.length : 0, skipped } } /** * 展开/预览重叠设备:只改 billboard `pixelOffset`(屏幕渲染层);实体 position 与属性经纬度不动。 * `offsets` 是 `{[deviceCode]: {x,y}}`;collapsed 时恢复为 0。 */ export function nudgeOverlap(viewer, groupKey) { const source = deviceMarkerSource(viewer) let touched = 0 for (const entity of source.entities.values) { const properties = entity.properties || {} const group = properties.overlapGroup && properties.overlapGroup.getValue ? properties.overlapGroup.getValue() : '' const aggregate = properties.overlapAggregate && properties.overlapAggregate.getValue ? properties.overlapAggregate.getValue() : false const entry = properties.overlapEntry && properties.overlapEntry.getValue ? properties.overlapEntry.getValue() : false if (group !== groupKey || (!aggregate && !entry)) continue if (entity.billboard) { entity.billboard.scale = entry ? 1.6 : 1.2 entity.billboard.outlineWidth = 2 entity.billboard.outlineColor = Color.WHITE.withAlpha(0.85) } touched += 1 } return touched } /** 清空重叠聚合面板hover弱提示。 */ export function clearOverlapNudge(viewer) { const source = deviceMarkerSource(viewer) for (const entity of source.entities.values) { const properties = entity.properties || {} const aggregate = properties.overlapAggregate && properties.overlapAggregate.getValue ? properties.overlapAggregate.getValue() : false if (aggregate && entity.billboard) { entity.billboard.scale = 1 entity.billboard.outlineWidth = 0 } } } /** * 聚合交互四态(维护者 2026-10-26 规格):**两个独立变量**的组合,不是一个状态机。 * * 中心 = 聚合点 or 设备点 环 = hover 预览 or 固定 * ① 聚合点 + 预览 ② 聚合点 + 固定(状态色发光) * ③ 设备 + 预览 ④ 设备 + 固定(状态色发光) * * 关键:③(设备常态)**环不固定** —— 只有 hover 才展开;旧实现把「选中设备」当成「环固定」, * 于是 ③ 也固定、也发光,与规格不符。 * * 引擎只把它投影成投影行上的读数,渲染层不自行推断: * `pinned`(环固定)/ `preview`(hover 预览)/ `selectedCode`(中心设备)。 */ let overlapPresentation = { groupKey: '', state: 'collapsed', preview: false, pinned: false, selectedCode: '' } export function setOverlapPresentation(viewer, { groupKey = '', state = 'collapsed', preview = false, pinned = false, offsets = {}, selectedCode = '' } = {}) { overlapPresentation = { groupKey, state, preview, pinned, selectedCode } const source = deviceMarkerSource(viewer) let touched = 0 // 环是否可见 = 固定 or hover 预览(②④ 固定;①③ 只在 hover 时可见)。 const ringActive = pinned === true || preview === true for (const entity of source.entities.values) { const properties = entity.properties || {} const read = (key) => properties[key] && properties[key].getValue ? properties[key].getValue() : '' if (!groupKey || read('overlapGroup') !== groupKey) continue const aggregate = read('overlapAggregate') === true if (aggregate) { // 聚合入口常驻(关掉会让 DOM 聚合节点消失,用户失去「切换 fan 内设备」的入口)。 entity.show = true if (entity.billboard) { // 固定态压暗让位给设备(计数仍可读);预览态微放大作为 hover 反馈。 entity.billboard.color = Color.WHITE.withAlpha(pinned && !preview ? 0.35 : 1) entity.billboard.scale = preview ? 1.2 : 1 entity.billboard.outlineWidth = preview ? 2 : 0 entity.billboard.outlineColor = Color.WHITE } } else { const code = read('deviceCode') const selected = Boolean(selectedCode && code === selectedCode) const offset = selected ? { x: 0, y: 0 } : offsets[code] // 中心设备**恒显示**(③④ 都要在中心看到设备点);圈内其余设备仅在环可见时显示。 entity.show = selected || (ringActive && Boolean(offset)) if (entity.billboard) { entity.billboard.pixelOffset = offset ? new Cartesian2(offset.x, offset.y) : new Cartesian2(0, 0) // 中心/圈内设备不放大 —— 与聚合点同尺寸。 entity.billboard.scale = 1 } // `typeof` 守卫:VM 探针(t47-readability-anchor)只抽取部分函数,模块级变量不在其上下文里。 if (entity.label) { entity.label.show = selected && !(typeof deviceVisualHidden !== 'undefined' && deviceVisualHidden) } if (properties.overlapEntry && properties.overlapEntry.setValue) properties.overlapEntry.setValue(selected) else if (properties.addProperty) properties.addProperty('overlapEntry', selected) } touched += 1 } if (viewer.scene && viewer.scene.requestRender) viewer.scene.requestRender() return touched } /** * 票 47:关联设备图层的每帧投影读数(DOM overlay 消费)。 * * 只回**当前可见**的设备实体:折叠聚合下的个体 `show=false` 不进 DOM(聚合符号按票面 * Out of Scope 留在 billboard);展开扇形后的个体带着 pixelOffset 回来,DOM 与 billboard * 同位同量。坐标 = 窗口坐标(与 `projectPivot` 同系);`scale`/`alpha` 读的是实体上 * **当前真值**(选中 1.6 / hover 1.3 / 弱化 0.75 / 强调 1.55 都含在内)—— * DOM 镜像它才不会出现「比 billboard 小一圈」。 */ export function projectDeviceMarkers(viewer) { const source = deviceMarkerSource(viewer) if (!viewer || !viewer.scene || !viewer.canvas || !source || source.show === false) return [] const out = [] for (const entity of source.entities.values) { const properties = entity.properties || {} const read = (key) => (properties[key] && properties[key].getValue ? properties[key].getValue() : '') const code = read('deviceCode') if (!code || entity.show === false) continue // projectMarkerEntity 已含画布原点(窗口坐标)与 pixelOffset(尖端落点)——直接用, // 调用方只减宿主原点(与 projectToWindow 的口径只差 host 一层)。 const projected = projectMarkerEntity(viewer, entity) if (!projected) continue const color = entity.billboard && entity.billboard.color ? entity.billboard.color.getValue() : null // 本设备是否是当前组的中心(= 聚合点已被它替换)。 const isCenterDevice = Boolean( overlapPresentation.selectedCode && code === overlapPresentation.selectedCode && read('overlapGroup') === overlapPresentation.groupKey, ) out.push({ key: `device|${read('monitoringPointCode')}|${code}`, code, pointCode: read('monitoringPointCode'), x: projected.x, y: projected.y, scale: numberOf(entity.billboard && entity.billboard.scale, 1) || 1, alpha: color && Number.isFinite(color.alpha) ? color.alpha : 1, // 票 76(①A):fan 内设备点的 DOM 顶部标签需要名字与所属聚合组。 name: read('deviceName'), group: read('overlapGroup'), // 票 76(2026-10-26 走查):设备**状态**必须一起投影 —— overlay 靠它决定色键与 // 「中心设备按自身状态挂环」;此前漏投影导致状态为空、中心设备不出特效、颜色落 neutral。 status: read('deviceStatus'), industry: read('deviceIndustry'), // 票 76(四态规格 2026-10-26):本设备是否为**中心设备**(③④ 的中心)。 // 中心拥有正常设备点的一切(图标/标签/特效),尺寸与聚合点一致。 selected: isCenterDevice, // 环是否**固定**(②④);③ 是设备常态 → 环不固定,只在 hover 预览时展开。 pinned: Boolean(isCenterDevice && overlapPresentation.pinned === true), // 是否只是 hover 预览环(① 初始、③ 设备常态)—— 不是选择态,不出发光。 preview: Boolean(overlapPresentation.groupKey === read('overlapGroup') && overlapPresentation.preview === true), // 是否属于「环可见时的圈内设备」(标签 hover-only);中心不在其列(中心标签按态决定)。 fan: Boolean( overlapPresentation.groupKey === read('overlapGroup') && (overlapPresentation.pinned === true || overlapPresentation.preview === true) && !isCenterDevice, ), }) } return out } /** * 票 76(C1):重叠聚合符号的每帧投影 —— DOM overlay 承载聚合视觉的读数面。 * * 行形状与 `projectDeviceMarkers` 同口径(窗口坐标;调用方只减宿主原点),但**不含设备身份** * (聚合符号不冒充设备:只有 group / pointCode / k / 组内状态词),供 overlay 渲染 * 「聚合 K 台」节点。实体被 `hideAggregateVisual` 隐去视觉时仍在(billboard 保留), * 因此这里的投影照旧可用。 */ /** * 读取 Cesium Graphics 属性的数值:可能是**普通数字**(Cesium 图形默认值 0,或直接赋值路径, * 如 `nudgeOverlap` 的 `billboard.outlineWidth = 2`),也可能是 `Property`(有 `.getValue()`)。 * * 票 76 修(2026-10-23 走查):此前一律按 Property 调 `.getValue()`, * `outlineWidth` 是数字时抛 `TypeError: ...outlineWidth.getValue is not a function`, * **直接打断 Cesium 渲染循环**(整张地图停渲染)。 */ function numberOf(value, fallback = 0) { if (typeof value === 'number') return Number.isFinite(value) ? value : fallback const raw = value && typeof value.getValue === 'function' ? value.getValue() : value const num = Number(raw) return Number.isFinite(num) ? num : fallback } export function projectOverlapSymbols(viewer) { const source = deviceMarkerSource(viewer) if (!viewer || !viewer.scene || !viewer.canvas || !source || source.show === false) return [] const out = [] for (const entity of source.entities.values) { const properties = entity.properties || {} const read = (key) => (properties[key] && properties[key].getValue ? properties[key].getValue() : '') if (read('overlapAggregate') !== true || entity.isShowing !== true) continue const projected = projectMarkerEntity(viewer, entity) if (!projected) continue out.push({ key: `overlap|${read('overlapPointCode')}|${read('overlapGroup')}`, group: read('overlapGroup'), pointCode: read('overlapPointCode'), k: Number(read('overlapCount')) || 0, status: read('deviceStatus'), state: read('overlapState'), label: read('overlapLabel'), shape: read('overlapShape') || 'circle', tone: read('overlapTone'), // 状态强调真值(`setOverlapPresentation`/`nudgeOverlap` 写在 billboard 上): // DOM 镜像同一量,否则选中/展开时两层强调不一致。 scale: numberOf(entity.billboard && entity.billboard.scale, 1) || 1, outline: numberOf(entity.billboard && entity.billboard.outlineWidth, 0) > 0, // 票 76(四态规格 2026-10-26)聚合符号的读数: // `pinned` = ② 被选择态(聚合点固定环 + 发光); // `preview` = ① 初始态的 hover 预览(不算选择态); // `replaced`= ③④ 中心已是设备点 → 聚合节点让位。 pinned: Boolean( overlapPresentation.groupKey === read('overlapGroup') && overlapPresentation.pinned === true && !overlapPresentation.selectedCode, ), replaced: Boolean( overlapPresentation.selectedCode && overlapPresentation.groupKey === read('overlapGroup'), ), // 是否只是 hover 预览(非正式选择态)——DOM 以此区分「已选定」的选择态标识强度。 preview: Boolean( overlapPresentation.groupKey === read('overlapGroup') && overlapPresentation.preview === true, ), x: projected.x, y: projected.y, }) } return out } /** * 票 76(C3):点位强调 scale 的 billboard 回读**已退役**。 * 真值改由服务层 `mapService.pointEmphasisScales()` 产出(同一份 domain `shouldEmphasize`), * DOM 不再从被隐藏的 billboard 实体读尺寸 —— 与票 58 §四「DOM 是唯一视觉尺寸源」一致。 */ /** 只读回关联设备图层的真实内容(走查据此断言「设备真的画在独立图层上」,以及「画成的样子来自接缝」)。 */ export function readAssociatedDevices(viewer) { const source = deviceMarkerSource(viewer) return source.entities.values.map((entity) => { const properties = entity.properties || {} const get = (key) => (properties[key] ? properties[key].getValue() : '') // 票 44:实体上**已经**带着接缝的结论(形状/资产模式/降级原因),原读取器却把它们丢掉了 —— // 于是「地图上真画的那台设备是什么形状、什么资产模式」在引擎侧读不到,走查只能退回去读 // 计划行(那是"打算画什么",不是"画成了什么")。这里如实读回来。 // 尺寸取 **billboard 的真实宽高**,而不是再读一个属性:这样读到的是真画上去的尺寸。 const size = entity.billboard && entity.billboard.width ? entity.billboard.width.getValue() : null return { id: entity.id, deviceCode: get('deviceCode'), monitoringPointCode: get('monitoringPointCode'), deviceStatus: get('deviceStatus'), shape: get('deviceShape'), size, assetMode: get('assetMode'), assetReason: get('assetReason'), assetReasonText: get('assetReasonText'), visible: entity.isShowing === true, overlapAggregate: get('overlapAggregate') === true, overlapEntry: get('overlapEntry') === true, overlapGroup: get('overlapGroup'), overlapPointCode: get('overlapPointCode'), overlapCount: get('overlapCount'), labelText: entity.label && entity.label.text ? entity.label.text.getValue() : '', pixelOffset: entity.billboard && entity.billboard.pixelOffset ? entity.billboard.pixelOffset.getValue() : null, } }) } /** 清空关联设备图层(关闭 / 换点 / 快照失效 / 离开页面都走它)。 */ /** * 票 45:重叠实体的只读窗口坐标(真实 Cesium 投影 + pixelOffset)。 * 走查用它把真实鼠标送到聚合符号/展开子点;只读渲染坐标,不写设备经纬度。 */ export function readOverlapTargets(viewer) { const source = deviceMarkerSource(viewer) const rect = viewer && viewer.canvas ? viewer.canvas.getBoundingClientRect() : null if (!rect || !viewer.scene) return [] return source.entities.values .filter((entity) => entity.properties && entity.properties.overlapGroup) .map((entity) => { const projected = projectMarkerEntity(viewer, entity) if (!projected) return null const properties = entity.properties || {} const get = (key) => properties[key] && properties[key].getValue ? properties[key].getValue() : '' return { x: projected.x, y: projected.y, // 用**逻辑可见性** `entity.show`(Cesium 里 `isShowing` 是渲染态,headless 下 // `show` 改变后可能要等一帧才同步;拾取判据不能依赖渲染帧,否则展开态设备 // 会被误滤掉而聚合却可见 —— 真机走查抓到过这一帧)。 visible: entity.show !== false, aggregate: get('overlapAggregate') === true, deviceCode: get('deviceCode'), pointCode: get('monitoringPointCode') || get('overlapPointCode'), status: get('deviceStatus'), overlapEntry: get('overlapEntry') === true, overlapGroup: get('overlapGroup'), } }) .filter(Boolean) } export function clearAssociatedDevices(viewer) { // 票 47:图层撤下 ⇒ 特效随图层一起消失(决策票 07:不需要单独的消失逻辑, // 因为特效就挂在图层上)。这里显式停循环,避免空转写帧。 stopDeviceEffects() deviceBaseStyle.clear() deviceMarkerSource(viewer).entities.removeAll() } /** 设备、监测点之外的第三层拾取入口:只返回管段/构件身份(管线是 Cesium 几何,不属标记层)。 */ export function pickPipeObjectAt(viewer, windowX, windowY) { if (!viewer || !viewer.scene || !viewer.canvas || typeof viewer.scene.pick !== 'function') return null const rect = viewer.canvas.getBoundingClientRect() const picked = viewer.scene.pick(new Cartesian2(windowX - rect.left, windowY - rect.top)) const source = pipelineSource(viewer) const id = picked && picked.id if (source && id && source.entities.values.includes(id)) { const properties = id.properties || {} const read = (key) => properties[key] && properties[key].getValue ? properties[key].getValue() : '' const kind = read('kind') const code = read('code') if (kind === 'pipe' && code) return { kind: 'pipeline', code, objectKind: 'pipe' } if (kind === 'component' && code) return { kind: 'pipeline', code, objectKind: 'component' } } // Instanced glTF batches expose the batch id and instanceId through public pick data. // Resolve only that instance; never report the first item in a batch as the hit. if (id && id.kind === 'pipe-model-batch' && Number.isInteger(picked.instanceId)) { const instance = Array.isArray(id.instances) ? id.instances[picked.instanceId] : '' const match = /^(pipe|component):(.*)$/.exec(String(instance || '')) if (match) return { kind: 'pipeline', code: match[2], objectKind: match[1] === 'pipe' ? 'pipe' : 'component' } } return null } // ================================================================ 设备点的对应反应(票 43) /** * 把「每台设备的反应档位」写进显示参数(票 43 的反应语言)。 * * 三条纪律都体现在这里: * 1. **不用颜色**:只改 `scale` 与 `color.alpha`(弱化是压暗,不是换色)—— * 色键语义仍只属于设备状态(票 44/07); * 2. **外环**用 billboard 的 `outlineWidth`/`outlineColor`(Cesium 公开属性), * 不用新建实体、不用着色器 —— 关掉时写回 0; * 3. **每个实体都过一遍**:命中与未命中都要写回值,否则上次的强化会一直留在图上 * (与票 14 的 `highlightEntities` 同一条教训)。 * * @param {Viewer} viewer * @param {Array} reactions `domain/deviceCorrespondence.js` 的 `planDeviceReactions().reactions` * @param {(reaction: string) => object} styleOf 档位 → `{ scale, alpha, ring }` * @returns {number} 本次被写入的实体数(0 = 图层为空 / 引擎没就绪) */ export function applyDeviceReactions(viewer, reactions, styleOf) { const source = deviceSources.get(viewer) || (viewer && viewer.dataSources ? viewer.dataSources.getByName(DEVICE_MARKER_SOURCE)[0] : null) if (!source) return 0 const byId = new Map() for (const item of reactions || []) { if (item && item.code) byId.set(`device|${item.pointCode}|${item.code}`, item.reaction) } let written = 0 for (const entity of source.entities.values) { // 图层里没有对应反应的实体(理论上不该有)也按常态写回,避免残留强化。 const reaction = byId.get(entity.id) || 'normal' const style = styleOf(reaction) || { scale: 1, alpha: 1, ring: false } const billboard = entity.billboard // 票 76(C5):光环实体退役后只剩聚合符号需要跳过(它不吃反应量)。 if (!billboard || (entity.properties && entity.properties.overlapAggregate && entity.properties.overlapAggregate.getValue() === true)) continue const isDevice = entity.properties && entity.properties.deviceCode billboard.scale = isDevice ? Math.max(1, style.scale) : style.scale // 设备中心保持不透明;选中/hover 仍用放大与外围特效区分。 billboard.color = Color.WHITE.withAlpha(isDevice ? 1 : style.alpha) // 选中外环:Cesium billboard 自带的描边,不用新建实体。 billboard.outlineWidth = style.ring ? 3 : 0 billboard.outlineColor = Color.WHITE // 票 47:记下**基准**(动效在此之上做乘性修饰,不覆盖反应量)。 deviceBaseStyle.set(entity.id, { scale: style.scale, alpha: style.alpha }) // 反应量是外部写入:丢掉动效层的"上次写了什么"缓存,下一动效帧必须重新落笔, // 否则"变了才写"会以为已经是这个值而跳过(票 35 卡顿整改的配套不变量)。 effectApplied.delete(entity) written += 1 } if (written) deviceEffectState.baseDirty = true return written } /** * 票 47:设备状态**动效**驱动器(决策票 07 的三个动效 + 门控 + 降级)。 * * 中心符号保持不透明和稳定形状;选中/hover 的尺寸强化仍保留。 * 扩张、闪烁、渐隐和旋转只作用于外围光环,不再与中心弱化透明度相乘。 * 场景按需渲染:存在动态设备时 requestRender,关闭或降级时取消动画循环。 * * ── 载体是场景内 billboard,**不是后处理级** ── * 决策票 07 已裁定:特效走 billboard ⇒「先特效、后雾」自动成立,不占 `PostProcessStageCollection`、 * 与雾无排序冲突,也不新增穿雾例外。 */ const deviceEffectState = { viewer: null, plan: null, selectedCode: '', raf: 0, tick: 0, // 卡顿整改(票 35):动效计划 Map 与帧节流的上次时间戳。 animatedCachePlan: null, animatedCacheMap: null, lastFrameAt: 0, /** 反应量写过之后需要重新对齐一次基础样式。 */ baseDirty: true, } /** 反应量写下的**基准** scale/alpha(动效在其上做乘性修饰)。键=实体 id。 */ const deviceBaseStyle = new Map() function baseStyleOf(entityId) { return deviceBaseStyle.get(entityId) || { scale: 1, alpha: 1 } } /** `plan.animated` → Map 的缓存:原实现每帧重建一次 Map(60 次/秒的纯垃圾)。 */ function animatedByCode(plan) { if (deviceEffectState.animatedCachePlan === plan && deviceEffectState.animatedCacheMap) { return deviceEffectState.animatedCacheMap } const map = new Map() for (const item of (plan && plan.animated) || []) map.set(item.code, item.effect) deviceEffectState.animatedCachePlan = plan deviceEffectState.animatedCacheMap = map return map } // 逐帧写入的"上次数值"缓存:**只有真的变了才写**。 // Cesium 里给 billboard 属性赋值会把这个实体标脏,进而让整个 billboard collection 重建 // 顶点缓冲;原实现每帧给每台设备重写 scale/color/rotation,等于每帧重建一次 GPU 数据。 const effectApplied = new WeakMap() const ZERO_PIXEL_OFFSET = new Cartesian2(0, 0) const EFFECT_EPSILON = 0.002 /** 只有变化超过阈值才写;返回是否真的写了。 */ function writeIfChanged(record, key, value, apply) { const previous = record[key] if (previous !== undefined && Math.abs(previous - value) <= EFFECT_EPSILON) return false record[key] = value apply(value) return true } /** 设备图标本体:只在基础样式真的变了时落笔(选中/强调会自动带回本帧)。 */ function writeDeviceBaseFrame(source) { // 反应量写在同一批实体上;没有新的反应量写入时不必每 33ms 扫一遍全部设备。 if (!deviceEffectState.baseDirty) return deviceEffectState.baseDirty = false for (const entity of source.entities.values) { const billboard = entity.billboard if (!billboard) continue const properties = entity.properties || {} const code = properties.deviceCode && properties.deviceCode.getValue ? properties.deviceCode.getValue() : '' if (!code) continue const base = baseStyleOf(entity.id) const target = Math.max(1, base.scale) let record = effectApplied.get(entity) if (!record) { record = { scale: undefined, alpha: undefined, rotation: undefined }; effectApplied.set(entity, record) } writeIfChanged(record, 'scale', target, (v) => { billboard.scale = v }) writeIfChanged(record, 'alpha', 1, () => { billboard.color = Color.WHITE.withAlpha(1) }) writeIfChanged(record, 'rotation', 0, (v) => { billboard.rotation = v }) } } /** * 应用动效计划(由服务层在设备图层同步后调用)。 * * 票 76(C5):设备状态动效**全部由 DOM overlay 承载**(`buildFxLayers` + CSS keyframes)。 * 引擎侧不再建光环实体、也不再跑逐帧动效循环 —— 本函数只做两件事: * ① 记录计划与选中(`setOverlapPresentation` 与读数面用); * ② 把「反应量基准」(选中/hover/弱化)写到设备 billboard —— 它是 DOM 设备行的 * 投影真值(`projectDeviceMarkers` 的 scale/alpha 镜像),不是视觉本身。 * @param {Viewer} viewer * @param {object} plan `domain/deviceEffects.js` 的 `planDeviceEffects()` 输出 * @param {object} [options] `{ selectedCode }` * @returns {boolean} 计划是否「在动」(DOM 据此挂特效;引擎不再逐帧写) */ export function applyDeviceEffects(viewer, plan, options = {}) { deviceEffectState.viewer = viewer || null deviceEffectState.plan = plan || null deviceEffectState.selectedCode = (options && options.selectedCode) || '' const source = viewer && (deviceSources.get(viewer) || (viewer.dataSources ? viewer.dataSources.getByName(DEVICE_MARKER_SOURCE)[0] : null)) if (source) { deviceEffectState.baseDirty = true writeDeviceBaseFrame(source) } deviceEffectState.tick += 1 return Boolean(plan && plan.enabled === true && !plan.degraded && plan.animated && plan.animated.length) } /** 停止动效(图层撤下 / 详情关闭):只清计划与选中,视觉已由 DOM 承载。 */ export function stopDeviceEffects() { deviceEffectState.plan = null deviceEffectState.selectedCode = '' deviceEffectState.baseDirty = true deviceEffectState.tick += 1 } /** * 只读读回设备行**当前的基准反应量**(走查用它判断选中/hover/弱化有没有落笔)。 * * 票 76(C5):光环实体已退役 —— `fx` 恒为空数组;动效「真在跑」的证据改由 DOM 承担 * (`data-marker-effect*` 与 CSS 动画,见 markerOverlay)。这里如实返回空,不伪造。 */ export function readDeviceEffects(viewer) { const source = viewer && (deviceSources.get(viewer) || (viewer.dataSources ? viewer.dataSources.getByName(DEVICE_MARKER_SOURCE)[0] : null)) const animated = animatedByCode(deviceEffectState.plan) const summary = { tick: deviceEffectState.tick, running: false, degraded: Boolean(deviceEffectState.plan && deviceEffectState.plan.degraded), enabled: Boolean(deviceEffectState.plan && deviceEffectState.plan.enabled), // 票 84 / 票 70:层数读数取单源。**用状态无关的 `maxRingLayers()`** —— // 此前写 `ringLayersFor('ALARM')` 等于把业务状态名带进引擎, // 违反「引擎只认 shape/tone」的守卫(t09 F5);读数要的是"本套光环最多几层"。 ringLayers: maxRingLayers(), } if (!source) return { ...summary, devices: [], fx: [] } const devices = [] for (const entity of source.entities.values) { const billboard = entity.billboard if (!billboard) continue const properties = entity.properties || {} const get = (key) => (properties[key] && properties[key].getValue ? properties[key].getValue() : '') const code = get('deviceCode') if (!code) continue const color = billboard.color && billboard.color.getValue ? billboard.color.getValue() : null devices.push({ code, status: get('deviceStatus'), effect: animated.get(code) || '', animated: animated.has(code), scale: billboard.scale && billboard.scale.getValue ? billboard.scale.getValue() : billboard.scale, alpha: color ? color.alpha : null, rotation: billboard.rotation && billboard.rotation.getValue ? billboard.rotation.getValue() : (billboard.rotation || 0), }) } return { ...summary, devices, fx: [] } } /** * 选中设备的**归属引线**:从设备点连到监测点标记中心(票 04/12 的面板锚点)。 * * 用一条独立 polyline 实体画在同一个数据源上(`lead|...` 前缀,不参与设备拾取判定)。 * 重合时调用方不该调本函数(`planDeviceLead().draw === false`),所以这里只负责画。 */ export function syncDeviceLead(viewer, { deviceLon, deviceLat, pointLon, pointLat, deviceCode, pointCode } = {}) { const source = deviceMarkerSource(viewer) // 先清掉上一条引线(同一时刻最多一条:只画**当前选中**那台的归属)。 for (const entity of source.entities.values.slice()) { if (typeof entity.id === 'string' && entity.id.startsWith('lead|')) source.entities.remove(entity) } if (![deviceLon, deviceLat, pointLon, pointLat].every(Number.isFinite)) return false source.entities.add({ id: `lead|${pointCode}|${deviceCode}`, polyline: { positions: Cartesian3.fromDegreesArray([deviceLon, deviceLat, pointLon, pointLat]), width: 2, // 引线是**归属指示**,不用色键(那是设备状态语义);用中性白并压半透明。 material: Color.WHITE.withAlpha(0.7), // 贴地线会随地形起伏扭动,引线要的是"直连"语义,所以不贴地。 clampToGround: false, }, }) return true } /** 清空选中引线(取消选中 / 返回点位 / 图层撤下)。 */ export function clearDeviceLead(viewer) { const source = deviceSources.get(viewer) || (viewer && viewer.dataSources ? viewer.dataSources.getByName(DEVICE_MARKER_SOURCE)[0] : null) if (!source) return false for (const entity of source.entities.values.slice()) { if (typeof entity.id === 'string' && entity.id.startsWith('lead|')) source.entities.remove(entity) } return true } /** * 强调一批编码对应的地图实体(票 14:点事件详情时高亮关联设备与关联管线)。 * * 只调**已存在实体**的显示参数,不新增实体 —— 事件因此永远不会变成独立地图图层 * (票 13 的硬纪律)。两处来源都扫: * 1. 点位标记数据源(票 09):按实体属性 `pointCode` / `deviceCodes` 匹配; * 2. 管线数据源(**票 22 才交付**):按 `pipelineCode` / `deviceCode` 匹配。 * 管线层还没落地时这一处自然扫到 0 个,不需要开关,也不会报错 —— * 票 22 落地的管线只要带这两个属性之一,本函数就自动把它一起高亮。 * * **传空数组 = 还原全部**(详情收起时用):初值存在 `emphasisBase` 里,还原是把 * 上次记下的原值写回去,而不是"猜一个默认宽度" —— 否则收起详情后点位标记会一直胖着、 * 管线的线宽也回不到原样。 * * @param {Viewer} viewer * @param {Array} codes 要高亮的编码(点位码 / 设备码 / 管线码);空数组表示还原 * @returns {number} 本次被强调的实体数(0 = 目标还不在图上,调用方据此说人话) */ export function highlightEntities(viewer, codes) { if (!viewer || !viewer.dataSources) return 0 const wanted = new Set((Array.isArray(codes) ? codes : [codes]).map((item) => String(item || '').trim()).filter(Boolean)) let matched = 0 const devices = deviceSources.get(viewer) || viewer.dataSources.getByName(DEVICE_MARKER_SOURCE)[0] for (const source of [pointMarkerSource(viewer), pipelineSource(viewer), devices]) { if (!source) continue for (const entity of source.entities.values) { const properties = (entity && entity.properties) || {} const read = (key) => (properties[key] && properties[key].getValue ? properties[key].getValue() : '') const list = read('deviceCodes') const candidates = [ read('pointCode'), read('pipelineCode'), read('deviceCode'), ...(Array.isArray(list) ? list : []), ] // 每个实体都过判定:命中与**未命中**都要写回值,后者即"还原"。 const hit = shouldEmphasize(candidates, wanted) if (hit) matched += 1 const base = hit ? rememberEmphasisBase(entity) : restoreEmphasisBase(entity) if (entity.billboard) entity.billboard.scale = hit ? EMPHASIS_STYLE.markerScale : base.billboardScale if (entity.polyline) entity.polyline.width = hit ? EMPHASIS_STYLE.pipelineWidth : base.polylineWidth } } return matched } /** 强调前的原值(按实体缓存:还原要有据可依,不能猜默认值)。 */ const emphasisBase = new WeakMap() function rememberEmphasisBase(entity) { if (!emphasisBase.has(entity)) { emphasisBase.set(entity, { billboardScale: entity.billboard ? entity.billboard.scale : 1, polylineWidth: entity.polyline ? entity.polyline.width : undefined, }) } return emphasisBase.get(entity) } function restoreEmphasisBase(entity) { const base = emphasisBase.get(entity) emphasisBase.delete(entity) return base || { billboardScale: 1, polylineWidth: undefined } } /** 管线数据源(票 22 交付时才存在;没有就回 null,不当成错误)。 */ export function pipelineSource(viewer) { if (!viewer || !viewer.dataSources) return null return viewer.dataSources.getByName(PIPELINE_SOURCE)[0] || null } /** Currently loaded terrain height; null means no reliable surface sample yet. */ export function groundHeightAt(viewer, lon, lat) { const globe = viewer && viewer.scene && viewer.scene.globe if (!globe || !Number.isFinite(lon) || !Number.isFinite(lat)) return null const height = globe.getHeight(Cartographic.fromDegrees(lon, lat)) return Number.isFinite(height) ? height : null } /** 贴地投影与屏幕分离共用一份口径,返回视觉中心及当前图标尺寸。 */ function projectMarkerEntity(viewer, entity) { if (!viewer || !viewer.scene || !viewer.canvas || !entity || !entity.isShowing || !entity.position || !entity.billboard) return null const original = entity.position.getValue(viewer.clock.currentTime) if (!original) return null const ellipsoid = viewer.scene.globe.ellipsoid const carto = ellipsoid.cartesianToCartographic(original) if (!carto) return null // 地面高程:实体位置是静态的,地形瓦片也只会逐步补齐;每帧给每个实体查一次 // `globe.getHeight`(四叉树下降)是纯重复。按实体缓存 3 秒,瓦片补齐后自然刷新。 const nowMs = typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now() let height = 0 const cachedHeight = entityGroundHeightCache.get(entity) if (cachedHeight && nowMs - cachedHeight.at < 3000) { height = cachedHeight.height } else { const sampled = viewer.scene.globe.getHeight(carto) height = Number.isFinite(sampled) ? sampled : 0 entityGroundHeightCache.set(entity, { height, at: nowMs }) } const world = Cartesian3.fromRadians(carto.longitude, carto.latitude, height) const camera = viewer.camera // 复用同一个 scratch 向量:每实体每帧 new Cartesian3 在高频投影里是纯垃圾。 const toward = Cartesian3.subtract(world, camera.positionWC, projectionCache.toward) if (Cartesian3.dot(toward, camera.directionWC) <= 0) return null // 遮挡体同一帧内所有实体共用(原本每实体 new 一个,是纯浪费)。 const occluder = occluderCached(viewer, camera) if (!occluder.isPointVisible(world)) return null const at = SceneTransforms.worldToWindowCoordinates(viewer.scene, world) if (!at || !Number.isFinite(at.x) || !Number.isFinite(at.y)) return null // 画布矩形只随窗口尺寸变;120ms 保鲜即可,不必每个实体读一次布局。 const rect = canvasRectCached(viewer) const time = viewer.clock.currentTime const read = (property, fallback) => property && property.getValue ? property.getValue(time) : fallback const offset = read(entity.billboard.pixelOffset, ZERO_PIXEL_OFFSET) const scale = read(entity.billboard.scale, 1) const iconSize = Math.max(read(entity.billboard.width, 18), read(entity.billboard.height, 18)) * scale return { x: at.x + rect.left + offset.x, y: at.y + rect.top + offset.y, iconSize } } export function hasPointMarker(viewer, kind, code) { if (!viewer || !code) return false const source = markerSources.get(viewer) || viewer.dataSources.getByName(POINT_MARKER_SOURCE)[0] return !!(source && source.show && source.entities.getById(`point|${kind}|${code}`)) } /** * 把**渲染中的**贴地标记中心投到窗口坐标(票 41 的锚定锚点)。 * * 锚点必须与**眼睛看到的那个标记**一致,所以高度取值顺序照 Cesium 画 billboard 的实际情况: * 1. 地形已有该处瓦片 → `globe.getHeight()` 给的实测高程(贴地后的真实中心); * 2. 瓦片还没加载/无地形 → **椭球面**(Cesium 的 `CLAMP_TO_GROUND` 在无地形数据时正是画在椭球面上)。 * * 第 2 条是**真机走查抓到的缺陷**:早先写成「配了地形就必须取到高度,取不到回 null」, * 于是相机一飞到尚未加载瓦片的区域,`getHeight` 返回 undefined,锚定被误判成"标记离屏"、 * 整块面板消失 —— 而画面上那个标记明明还看得见。锚点判据不能比"可见性"本身更严。 * * 可见性只用 Cesium **公开**判据:相机前方(`directionWC` 点积)+ 地球遮挡(`EllipsoidalOccluder`), * 再交给 `SceneTransforms` 投影;不做第二套相机矩阵手算。 */ export function projectPointMarker(viewer, kind, code) { if (!viewer || !viewer.scene || !viewer.canvas || !code) return null const source = markerSources.get(viewer) || viewer.dataSources.getByName(POINT_MARKER_SOURCE)[0] if (!source || !source.show) return null const entity = source.entities.getById(`point|${kind}|${code}`) return projectMarkerEntity(viewer, entity) } /** 关联设备锚向实际绘制的设备实体,不回退到父监测点。 */ export function hasDeviceMarker(viewer, code, pointCode = '') { return Boolean(findDeviceMarker(viewer, code, pointCode)) } export function projectDeviceMarker(viewer, code, pointCode = '') { return projectMarkerEntity(viewer, findDeviceMarker(viewer, code, pointCode)) } function findDeviceMarker(viewer, code, pointCode) { if (!viewer || !code) return null const source = deviceSources.get(viewer) || viewer.dataSources.getByName(DEVICE_MARKER_SOURCE)[0] if (!source || !source.show) return null return source.entities.values.find((entity) => { const properties = entity.properties || {} return properties.deviceCode && properties.deviceCode.getValue() === code && (!pointCode || (properties.monitoringPointCode && properties.monitoringPointCode.getValue() === pointCode)) && entity.show !== false }) || null } /** * 经纬度 → 画布窗口坐标(批次 3 第五轮,DOM overlay 的 3D 定位入口)。 * @returns {{x: number, y: number}|null} 相对画布左上角;未就绪/不可投影返回 null */ export function projectPointToWindow(viewer, longitude, latitude) { if (!viewer || !viewer.scene) return null const world = Cartesian3.fromDegrees(longitude, latitude, 0) const at = SceneTransforms.worldToWindowCoordinates(viewer.scene, world) if (!at || !Number.isFinite(at.x) || !Number.isFinite(at.y)) return null return { x: at.x, y: at.y } } /** * 相机契约的 Cesium 适配(票 03)。返回 services/cameraControl.js 期望的引擎接口; * 相机语义(三键怎么算、中心是谁)在 domain/camera.js 与 cameraControl.js,本函数只翻译方言。 * @param {Viewer} viewer */ export function createCameraEngine(viewer) { const scene = viewer.scene const camera = viewer.camera let frameHandle = null /** * 经纬高 → 该点东北天局部系(弹簧臂原语要求中心是局部地表点)。 * * 极点保护:`eastNorthUpToFixedFrame` 内部用 `east = up × north` 求东向, * 纬度到 ±90 时 up 与 north 共线、叉积退化为零向量,局部系不再是正交基, * `lookAtTransform` 拿它当参照就会出现「整个地球乱转」。 * 域层已把中心纬度夹在 ±POLAR_LAT_LIMIT 内(见 domain/camera.js), * 这里是引擎侧的第二道防线:任何调用方直接喂端点纬度也不会把参照系搞坏。 */ function frameAt(pivot) { const lat = Math.min(89.9, Math.max(-89.9, pivot.lat)) return Transforms.eastNorthUpToFixedFrame(Cartesian3.fromDegrees(pivot.lon, lat, pivot.height || 0)) } return { applyView(pivot, hpr) { camera.lookAtTransform( frameAt(pivot), new HeadingPitchRange(hpr.heading * DEG, hpr.pitch * DEG, hpr.range), ) }, /** 反读当前相机相对该中心的弹簧臂参数,用于挂载时对齐真实相机(避免首次交互跳变)。 */ readView(pivot) { const inverse = Matrix4.inverseTransformation(frameAt(pivot), new Matrix4()) const local = Matrix4.multiplyByPoint(inverse, camera.positionWC, new Cartesian3()) const range = Cartesian3.magnitude(local) if (!(range > 0)) return { heading: 0, pitch: -60, range: 1 } const heading = CesiumMath.toDegrees(Math.atan2(local.x, local.y)) const pitch = -CesiumMath.toDegrees(Math.asin(Math.min(1, Math.max(-1, local.z / range)))) return { heading: (heading + 360) % 360, pitch, range } }, /** * 屏幕中心的地面拾取点(票 21/30 改进):**优先按地形拾取**,拿不到高度再退回椭球。 * * 为什么必须按地形:纯 `pickEllipsoid` 给的是椭球面交点(高程 0),而沅陵县城地面高程 * 两百多米起。用它当中心,弹簧臂 range 一降到几百米,相机就落进山体内部 —— 实测 range 120 m * 时整屏全黑(票 21 走查记录)。地形就绪后用 `globe.getHeight` 把中心抬到真实地表, * 相机高度随之正确,近景才不会钻进山里。 */ pickGroundAtCenter() { const canvas = scene.canvas const at = new Cartesian2(canvas.clientWidth / 2, canvas.clientHeight / 2) const ellipsoid = (scene.globe && scene.globe.ellipsoid) || Ellipsoid.WGS84 const hit = camera.pickEllipsoid(at, ellipsoid) if (!hit) return null const carto = ellipsoid.cartesianToCartographic(hit) const lon = CesiumMath.toDegrees(carto.longitude) const lat = CesiumMath.toDegrees(carto.latitude) // 地形就绪时把中心抬到真实地表;取样不到就保持 0(等价于旧行为,不会更糟)。 let height = 0 const globe = scene.globe if (globe && typeof globe.getHeight === 'function') { const sampled = globe.getHeight(carto) if (Number.isFinite(sampled)) height = sampled } return { lon, lat, height } }, /** * 中心点投影到**窗口坐标**(与 `getBoundingClientRect()` 同一坐标系)。 * * 实测 `SceneTransforms.worldToWindowCoordinates()` 返回的是**相对画布左上角**的值 * (画布在壳里下移了一段,直接用会与 `getBoundingClientRect()` 差出一个画布原点偏移, * 走查里表现为「中心点没落在画布中心」的假失败)。这里显式加上画布原点, * 让消费方拿到的就是窗口坐标,别再靠语义猜。 */ projectPivot(pivot) { const at = SceneTransforms.worldToWindowCoordinates(scene, Cartesian3.fromDegrees(pivot.lon, pivot.lat, pivot.height || 0)) if (!at) return null const rect = viewer.canvas.getBoundingClientRect() return { x: at.x + rect.left, y: at.y + rect.top } }, /** * **相机正下方**的地形高度(米),取不到返回 null(票 37 的防入地输入)。 * * 为什么是「相机正下方」而不是「pivot 下方」:防入地要保证的是**相机自己**别钻进山体。 * 相机在 pivot 沿 heading/pitch 退开 range 的位置上,两者可能隔着几百米、翻过一个山脊, * 用 pivot 的地形判断会在斜坡上误判。 */ groundHeightBelowCamera() { const globe = scene.globe if (!globe || typeof globe.getHeight !== 'function') return null try { const carto = globe.ellipsoid.cartesianToCartographic(camera.positionWC) if (!carto) return null const sampled = globe.getHeight(carto) return Number.isFinite(sampled) ? sampled : null } catch (error) { return null } }, /** 相机当前**绝对**高度(米,椭球高),取不到返回 null。防入地的另一半输入。 */ cameraHeight() { try { const carto = scene.globe.ellipsoid.cartesianToCartographic(camera.positionWC) return carto && Number.isFinite(carto.height) ? carto.height : null } catch (error) { return null } }, inputTarget() { return viewer.canvas }, viewport() { return { width: scene.canvas.clientWidth, height: scene.canvas.clientHeight } }, fovY() { return camera.frustum.fovy || 60 * DEG }, now() { return typeof performance !== 'undefined' ? performance.now() : Date.now() }, requestFrame(callback) { frameHandle = requestAnimationFrame(callback) }, } }