/** * 地图标记 DOM overlay(票 47)——billboard 发糊与底板生硬的结构性解决。 * * 渲染分工(维护者 2026-10-15 裁定): * · **外层母版 = 自绘 SVG**(矢量,任何 DPR 都锐利;billboard 发糊的根治)。 * 水滴形按实测母版几何拟合:帽圆 (188.5,189) r189(逐行验证误差 <1px)+ * 下半收尖三次贝塞尔(对 512px 母版左缘 57 点最小二乘拟合,最大残差 4.75 源像素 * ≈ 0.34 屏显像素;拟合探针见 .scratch/cockpit-real-data-wayfinding/research/fit-pin-bezier.mjs)。 * 镂空孔 = 实测圆 (188.5,189) r75。viewBox 外扩与 billboard 裁剪窗同形(单源 pad)。 * · **内层行业图标 = 素材**(`markerAssets.renderInnerIconUrl`:与 billboard 同一 * `recolorInner` 去色重染规则,但**不下采样**——256px 原生分辨率 data URL, * DOM 里按帽径向下采样,永远锐利)。 * · 标签 = 真 DOM(圆角底板、装饰边、text-overflow、hover title 原生能力)。 * · 每帧 `postRender` 投影:与 billboard `CLAMP_TO_GROUND` 同口径(地形实测高程,取不到回椭球 0)。 * * 与既有链路的关系(维护者裁定:DOM 完整复刻 billboard 功能后才替换它): * · 合成计划层 `domain/markerPlan.js` **原样复用**(归属/几何/锚点/降饱和不换媒介); * · `syncPointMarkersForPlan`(billboard 链路)**保留不动**——聚合、特效、Canvas 拾取仍依赖它; * 内层素材加载失败时 SVG 只有母版 = 底下 billboard 双层照常显示(降级可见); * · 拾取:overlay 节点挂**显式 click 触发器**,回调直接复用 `pickDeviceOrPointAt` * (与 Canvas 点选链同一个功能函数,维护者原话「照搬原本的功能函数」); * · `data-*` 断言面全部挂到 DOM 节点,走查不读内部实现。 * * 性能纪律:节点池复用(不每帧重建);计划读数指纹变化才重建;postRender 只做 transform 更新。 */ import { renderInnerIconUrl } from './markerAssets.js' // 票 76(C1 → 统一渲染):聚合符号不再用旧形状表(`markerSvg(shape)` 的三角形/圆形图标已废弃), // 与标记/设备一样走自绘水滴母版;domain 的 `markerSvg` 导入随之移除。 // 票 45:特效随标记 DOM 化 —— 周期/层数/放大倍数与 billboard 特效同一份 domain 常量(节奏单源)。 // 票 70(重做):本行导入的每一枚都必须有消费者(不留死导入,票面 Acceptance 第 3 条)。 import { DEVICE_EFFECT, effectProfileFor } from '../domain/deviceEffects.js' // 票 76(2026-10-26):设备点的色键取设备状态词条(domain 单源),DOM 不自己映射状态→颜色。 import { deviceVisualForStatus } from '../domain/deviceStatus.js' // 票 82/84:几何与光环参数都来自单源 `markerMetrics`;DOM 只把结论翻译成 CSS/DOM。 import { MASTER_SOURCE, MARKER_COEFFICIENTS, RING_EFFECT, markerDevKnobs, RING_STYLE, blinkOpacityAt, glowScaleAt, glowStrengthAt, ringAlphaAt, ringPhaseOffsetOf, ringScaleAt, } from '../domain/markerMetrics.js' // 票 83:几何读数(本体宽 / 绘制宽 / pad)与计划读数同一出口,走查不读 DOM 内部结构。 import { markerGeometryDataset } from '../domain/markerPlan.js' /** * 类名单源:注入的特效 CSS(下方模板串)与 DOM 构建(buildNode/mount)共用同一组常量。 * 必须声明在 `FX_CSS` 之前——模板串在模块求值期就要读这些值(`const` 有 TDZ)。 * 前三枚为票 47 既有值(原样保留,勿改:走查断言与样式钩子读这些类名)。 */ const OVERLAY_HOST_CLASS = 'marker-overlay-host' const NODE_CLASS = 'marker-overlay-node' const LABEL_CLASS = 'marker-overlay-label' /** 标签独立图层(2026-10-28):标签要盖在所有图标之上,必须脱离节点自身的层叠上下文。 */ const LABEL_LAYER_CLASS = 'marker-overlay-labels' /** 标签的零尺寸锚点(随节点位移;真正的标签盒子在它内部)。 */ const LABEL_WRAP_CLASS = 'marker-overlay-label-wrap' const FX_CLASS = 'marker-overlay-fx' const FX_RING_CLASS = 'marker-overlay-fx-ring' const FX_GLOW_CLASS = 'marker-overlay-fx-glow' const FX_SPARK_CLASS = 'marker-overlay-fx-spark' const FX_TICK_CLASS = 'marker-overlay-fx-tick' /** 关键帧采样相位(9 个停点足够平滑,且让「任何时刻总有一层在前 1/4」可断言)。 */ const FX_SAMPLE_PHASES = [0, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875, 1] /** 刻度环/工单齿轮的旋转周期:由 RING_STYLE.tickDegPerSec 反推(20°/s → 18s)。 */ const FX_TICK_PERIOD_MS = Math.round((360 / RING_STYLE.tickDegPerSec) * 1000) /** 单层环的关键帧名:每层 alpha 指数不同,所以按 (effect, layer) 生成,不共用一套曲线。 */ function fxRingKeyframeName(effect, layer) { return `markerFxRing--${effect}--${layer}` } /** 从单源纯函数采样生成环的关键帧(scale/opacity/brightness 都来自 markerMetrics)。 */ function fxRingKeyframes(status, profile, layer) { const stops = FX_SAMPLE_PHASES.map((phase) => { const timeMs = phase * profile.periodMs const scale = ringScaleAt(status, layer, timeMs).toFixed(3) const alpha = ringAlphaAt(status, layer, timeMs).toFixed(3) // 自发光亮度包络(2026-10-27 恢复已验收规格 2.0 → 0.8): // 只靠 opacity 会显得「灰」——发射瞬间必须亮一下才有能量感。 const brightness = ( MARKER_COEFFICIENTS.ringBrightnessStart - (MARKER_COEFFICIENTS.ringBrightnessStart - MARKER_COEFFICIENTS.ringBrightnessEnd) * phase ).toFixed(2) return ` ${(phase * 100).toFixed(1)}% { transform:translate(-50%,-50%) scale(${scale}); opacity:${alpha}; filter:brightness(${brightness}); }` }) return `@keyframes ${fxRingKeyframeName(profile.effect, layer)} {\n${stops.join('\n')}\n}` } /** 工单处置:只旋转不扩散(scale 固定 1),不透明度按已验收包络 0.95 → 0.68 呼吸。 */ function fxSpinKeyframes(profile) { const { spinAlphaStart, spinAlphaEnd } = MARKER_COEFFICIENTS return `@keyframes markerFxSpin {\n from { transform:translate(-50%,-50%) rotate(0deg); opacity:${spinAlphaStart}; }\n to { transform:translate(-50%,-50%) rotate(360deg); opacity:${spinAlphaEnd}; }\n}` } /** 中心辉光(只给报警):尺寸/亮度/不透明度同样从单源纯函数与系数采样。 */ function fxGlowKeyframes() { const profile = RING_EFFECT.ALARM const { glowAlphaMin, glowAlphaPeak, glowBrightnessMin, glowBrightnessPeak } = MARKER_COEFFICIENTS const stops = FX_SAMPLE_PHASES.map((phase) => { const timeMs = phase * profile.glowPeriodMs const strength = glowStrengthAt(timeMs) // 已验收包络:不透明度 0.32 → 0.85、亮度 1.2 → 1.9。 // 此前被压成固定 0.32 且无亮度提亮 —— 报警的「核心在烧」观感就是这么丢的。 const alpha = (glowAlphaMin + (glowAlphaPeak - glowAlphaMin) * strength).toFixed(3) const brightness = (glowBrightnessMin + (glowBrightnessPeak - glowBrightnessMin) * strength).toFixed(2) return ` ${(phase * 100).toFixed(1)}% { transform:translate(-50%,-50%) scale(${glowScaleAt(timeMs).toFixed(3)}); opacity:${alpha}; filter:brightness(${brightness}); }` }) return `@keyframes markerFxGlow {\n${stops.join('\n')}\n}` } /** 环关键帧(票 86:DEV 旋钮变更后要能重建,所以不是模块级常量)。 */ function ringKeyframesCss() { return [ ...Object.values(RING_EFFECT) .filter((profile) => profile.effect !== 'spin') .flatMap((profile) => Array.from({ length: profile.layers }, (_, layer) => fxRingKeyframes(profile.status, profile, layer))), fxSpinKeyframes(RING_EFFECT.WORK_ORDER), fxGlowKeyframes(), ].join('\n') } /** * 特效层样式(票 45 起,票 84 改写): * · 环带 = **近白硬边带**(不再是把状态色烘进平滑渐变),锐利才读作「能量」; * · scale/opacity 关键帧由 `markerMetrics` 的纯函数采样生成(起始 0.55 / 峰值 2.6 / * alpha 峰值 0.35–0.5 / 内层慢外层快的 alpha 指数)——渲染层不写死任何环数值; * · 相位去同步:层间均分 + 每对象哈希偏移,靠 `animation-delay`(负值 = 开播即处于该相位); * · 粒子感:3 个火花(径向推进)+ 2 层刻度环(反向旋转),数量来自 `RING_STYLE`; * · 锚点 = 节点原点(图标尖端落地位置),环直接标记地面目标点; * · **不做** reduced-motion 降级(票 72 D10:维护者明确「不做冗余设计」)。 */ function buildFxCss() { return ` /* 票 76 曾对圈内设备(--fan)隐藏顶部标签;2026-10-27 维护者走查要求**子设备点也要标签**, 因此该隐藏规则移除(标签常态显示,hover 仍读 title 全名)。 */ /* 选择态标识:发光用**点位本体颜色语义**(--marker-tone = 状态色),不用白光。 两处都生效:聚合点被点开、以及选中设备替换中心后中心设备本身。 */ .${NODE_CLASS}--active { z-index:2; } .${NODE_CLASS}--center { z-index:3; } .${NODE_CLASS}--aggregate.${NODE_CLASS}--replaced { pointer-events:none; } .${NODE_CLASS}--active svg { filter:drop-shadow(0 0 6px var(--marker-tone, currentColor)) drop-shadow(0 0 14px var(--marker-tone, currentColor)) brightness(1.18); } /* 2026-10-28:标签显隐**不再走 CSS 后代选择器** —— 标签已搬进独立图层(不再是节点的后代), 而且规格里「圈内设备 hover 才显示」「选择态隐藏标签」「非聚合设备点常显」是**状态**规则, 统一由 applyLabelState() 驱动(见 syncMarkerOverlay 的三处调用)。 旧的两条 --active + 标签 的 display 规则已退役(它们现在匹配不到任何元素)。 */ .${FX_CLASS} { position:absolute; pointer-events:none; transform-origin:50% 50%; will-change:transform; } .${FX_RING_CLASS} { position:absolute; left:50%; top:50%; border-radius:50%; /* 彩色注能环(2026-10-27 恢复已验收规格):环体用**状态色**(currentColor), 内沿到外沿按可见带旋钮收口;此前换成白色硬边带后,维护者走查报「特效非常微弱」—— 白带在浅底图与薄雾上几乎没有对比度,而彩色环才有「能量」语义。 */ background:radial-gradient(circle, rgba(0,0,0,0) calc(var(--fx-band-in, 88%) - 16%), currentColor var(--fx-band-in, 88%), rgba(0,0,0,0) calc(var(--fx-band-out, 93%) + 7%)); transform:translate(-50%,-50%); animation-timing-function:linear; animation-iteration-count:infinite; /* 彩色外发光(已验收值 6px/16px):这是「亮度感」的主要来源之一,不能省。 */ box-shadow:0 0 6px currentColor, 0 0 16px currentColor; will-change:transform,opacity; } .${FX_CLASS}--spin .${FX_RING_CLASS} { background:none; border:2px dashed currentColor; filter:drop-shadow(0 0 3px currentColor); } .${FX_SPARK_CLASS} { position:absolute; left:50%; top:50%; width:3px; height:3px; border-radius:50%; background:currentColor; box-shadow:0 0 4px currentColor; animation:markerFxSpark var(--fx-period,1800ms) linear infinite; will-change:transform,opacity; } @keyframes markerFxSpark { 0% { opacity:0; transform:translate(-50%,-50%) rotate(var(--fx-spark-angle,0deg)) translateY(0) scale(0.6); } 20% { opacity:0.85; } 100% { opacity:0; transform:translate(-50%,-50%) rotate(var(--fx-spark-angle,0deg)) translateY(calc(var(--fx-box,32px) * -0.42)) scale(1); } } .${FX_TICK_CLASS} { position:absolute; left:50%; top:50%; border-radius:50%; border:1px dashed currentColor; opacity:0.16; transform:translate(-50%,-50%); animation:markerFxTick ${FX_TICK_PERIOD_MS}ms linear infinite; } @keyframes markerFxTick { to { transform:translate(-50%,-50%) rotate(360deg); } } .${FX_GLOW_CLASS} { position:absolute; left:50%; top:50%; border-radius:50%; background:radial-gradient(circle, currentColor 0%, color-mix(in srgb, currentColor 42%, transparent) 30%, rgba(0,0,0,0) 72%); transform:translate(-50%,-50%); animation:markerFxGlow var(--fx-glow-period,2400ms) ease-in-out infinite; /* 中心辉光的外发光(已验收值 18px/40px):报警的「核心在烧」观感全靠它。 */ box-shadow:0 0 18px currentColor, 0 0 40px currentColor; will-change:transform,opacity; } ${ringKeyframesCss()} ` } const FX_STYLE_ID = 'marker-overlay-fx-styles' /** 注入一次特效样式(幂等:已存在就跳过)。 */ function ensureFxStyles() { if (typeof document === 'undefined') return if (document.getElementById(FX_STYLE_ID)) return const style = document.createElement('style') style.id = FX_STYLE_ID style.textContent = buildFxCss() document.head.appendChild(style) } /** * 票 86:DEV 旋钮变更后重建环样式与节点(关键帧/带型都按新参数重新生成)。 * 只在开发钩子里调用;生产路径不会走到。 */ export function refreshMarkerFx() { const style = typeof document !== 'undefined' ? document.getElementById(FX_STYLE_ID) : null if (style) style.textContent = buildFxCss() for (const record of pool.values()) { if (record.root && record.root.parentNode) record.root.parentNode.removeChild(record.root) } pool.clear() } /** 每帧由 `syncMarkerOverlay` 注入的门控查询(mapService 从 domain 动效计划产出)。 */ let frameEffectOf = () => '' /** 票 85:降级收敛系数(null = 未降级);同一屏所有环共享。 */ let frameConverge = null /** 关联设备层的门控查询(票 84:三类对象的环统一走同一条门控)。 */ let frameDeviceEffectOf = () => '' /** 状态 → 特效 profile(domain 单源;DOM 层数/亮度/辉光只消费这一份表,不另造)。 */ function markerFxProfile(status) { return effectProfileFor(status) } /** * 标记动效查询(票 70 重做):**双条件**才出特效—— * ① 门控放行(`effectOf`:开且未降级,判定在 domain,渲染层不自行发明); * ② 状态有 profile 且 profile 的动效与门控放行的动效一致(防串键)。 * @returns {{effect:string, profile:object}|null} */ function fxOf(marker) { const effect = frameEffectOf(marker) if (!effect) return null const profile = markerFxProfile(marker.status) if (!profile || profile.effect !== effect) return null return { effect, profile, converge: frameConverge } } /** * 环层数=状态 profile 的 layers(报警 4 / 预警 3 / 工单 1)。 * 层数只从 profile 消费,DOM 侧不许写死。 */ function markerEffectLayers(profile) { return profile.layers } /** * 特效 DOM(票 70 重做,票 84 改写):固定 `profile.layers` 个环节点 + 3 火花 + 2 刻度环 * (+报警辉光 1)。CSS 动画驱动、JS 不逐帧更新。容器几何:`left:-box/2` 使其中心 = 节点原点。 * 相位偏移按 `phaseKey`(kind|code)哈希,保证同屏对象不同节拍、同 id 可复算。 */ function buildFxLayers(fx, plan, toneHex, phaseKey = '') { const { profile } = fx // 票 85:降级时环不消失 —— 整组环按收敛系数缩小(峰值 ×0.6)并压暗(alpha ×0.5),周期不变。 const converge = fx.converge || null const convergePeak = converge ? converge.peak : 1 const convergeAlpha = converge ? converge.alpha : 1 const box = plan.draw.width const phaseOffset = ringPhaseOffsetOf(phaseKey) const host = document.createElement('div') host.className = `${FX_CLASS} ${FX_CLASS}--${profile.effect}` host.style.cssText = `position:absolute;left:${-box / 2}px;top:${-box / 2}px;width:${box}px;height:${box}px;color:${toneHex};` // 收敛量写进 dataset:走查能读出"这条环是不是降级态",选中 boost 也乘在它上面。 host.dataset.fxBaseScale = String(convergePeak) host.dataset.fxConverged = converge ? 'yes' : 'no' host.style.opacity = String(convergeAlpha) if (convergePeak !== 1) host.style.transform = `scale(${convergePeak})` host.style.setProperty('--fx-box', `${box}px`) host.style.setProperty('--fx-period', `${profile.periodMs}ms`) host.style.setProperty('--fx-start-scale', String(profile.startScale)) host.style.setProperty('--fx-peak-scale', String(profile.scale)) host.style.setProperty('--fx-alpha-peak', String(profile.alpha)) host.style.setProperty('--fx-glow-period', `${RING_EFFECT.ALARM.glowPeriodMs}ms`) // 票 86:可见带占比是 DEV 旋钮(内沿固定 88%,外沿 = 内沿 + 占比)。 const bandWidth = markerDevKnobs().bandWidth host.style.setProperty('--fx-band-in', `${Math.round(RING_STYLE.bandInner * 100)}%`) host.style.setProperty('--fx-band-out', `${Math.round((RING_STYLE.bandInner + bandWidth) * 100)}%`) for (let layer = 0; layer < markerEffectLayers(profile); layer += 1) { const ring = document.createElement('div') ring.className = FX_RING_CLASS ring.style.width = `${box}px` ring.style.height = `${box}px` ring.style.animationName = profile.effect === DEVICE_EFFECT.SPIN ? 'markerFxSpin' : fxRingKeyframeName(profile.effect, layer) ring.style.animationDuration = `${profile.periodMs}ms` // 层间均分错相 + 每对象哈希偏移;负延迟 = 开播即处于该相位。 ring.style.animationDelay = `${-(profile.periodMs * ((layer / markerEffectLayers(profile)) + phaseOffset))}ms` host.appendChild(ring) } if (profile.effect !== DEVICE_EFFECT.SPIN) { for (let index = 0; index < RING_STYLE.sparks; index += 1) { const spark = document.createElement('div') spark.className = FX_SPARK_CLASS spark.style.setProperty('--fx-spark-angle', `${(360 / RING_STYLE.sparks) * index}deg`) spark.style.animationDelay = `${-(profile.periodMs * ((index / RING_STYLE.sparks) + phaseOffset))}ms` host.appendChild(spark) } } for (let index = 0; index < RING_STYLE.tickRings; index += 1) { const tick = document.createElement('div') tick.className = FX_TICK_CLASS const size = Math.round(box * (0.55 + index * 0.17)) tick.style.width = `${size}px` tick.style.height = `${size}px` tick.style.animationDirection = index % 2 === 1 ? 'reverse' : 'normal' host.appendChild(tick) } // 中心辉光只给报警(票 07 优先级语义,不给预警/工单)。 if (profile.glow) { const glow = document.createElement('div') glow.className = FX_GLOW_CLASS const size = Math.round(box * 0.7) glow.style.width = `${size}px` glow.style.height = `${size}px` host.appendChild(glow) } return host } /** * 聚合符号节点(票 76 / C1):重叠设备的聚合符号此前只活在 billboard 层(票 47 遗留), * DOM 侧一个都没有。节点形态与 billboard 版对齐: * · 形状/色键 = 引擎读数(`overlapShape` / `overlapTone`),形状生成器复用 domain `markerSvg` * (billboard 的 `imageOf` 就是它)——同源,不另画一套; * · 尺寸 = billboard 宽真值(`size * 1.5` 的产物,票 58 的聚合符号档),DOM 不自己算; * · 锚点 = 投影点**居中**(对应 billboard `verticalOrigin: CENTER` + pixelOffset 0), * 与标记的尖端锚定不同; * · 标签文案与 billboard 版同式样(「聚合 K 台 · 组内<状态词>」); * · **不含设备身份**:`data-marker-overlay-code` 放 group key(点击派发用),不放 deviceCode。 */ function buildAggregateNode(key, row, toneHex, plan) { const root = document.createElement('div') // `--aggregate` 修饰类:让聚合标签走「常显」规则(设备标签只在 hover 时显示)。 root.className = `${NODE_CLASS} ${NODE_CLASS}--aggregate` root.dataset.markerOverlayKey = key root.dataset.markerOverlayKind = 'overlap-aggregate' root.dataset.markerOverlayCode = row.group root.dataset.markerOverlayAggregate = String(row.k) root.dataset.markerOverlayPointCode = row.pointCode || '' // 票 83:聚合节点同样挂几何读数(聚合档 = 设备档);聚合符号恒不挂环。 Object.assign(root.dataset, markerGeometryDataset(plan)) root.dataset.markerRing = 'no' root.style.cssText = [ 'position:absolute', 'left:0', 'top:0', 'width:0', 'height:0', 'pointer-events:auto', 'cursor:pointer', 'will-change:transform', ].join(';') // 票 76(选择态发光):状态色写成 CSS 变量(**必须在 cssText 之后**——cssText 会整段覆盖内联样式, // 先设置等于刚写就抹掉,这是上一版「还是白光」的第二层原因)。 root.style.setProperty('--marker-tone', toneHex) // 票 76(统一设备点位渲染):聚合符号也走**自绘水滴母版**(与标记/设备同一套几何与渲染路径), // 不再用旧的形状表(三角形/圆形图标)——那是废弃的渲染模式。 const w = plan.size.width const h = plan.size.height const pad = plan.draw.pad const wrap = document.createElement('div') wrap.innerHTML = markerSvg(plan.master, toneHex) const svg = wrap.firstChild svg.style.cssText = [ 'position:absolute', `left:${-(pad + w * plan.anchor.x)}px`, `top:${-(h + pad)}px`, `width:${plan.draw.width}px`, `height:${plan.draw.height}px`, plan.desaturate ? 'filter:saturate(0.55) brightness(0.72)' : '', ].join(';') svg.style.transformOrigin = `${pad + w * plan.anchor.x}px ${h + pad}px` root.appendChild(svg) const innerEl = svg.querySelector('[data-marker-overlay-inner]') if (plan.inner.enabled && innerEl) { const diameter = plan.inner.diameterRatio * 378 const cx = 378 * plan.anchor.x const cy = plan.inner.center.y * 475 innerEl.setAttribute('x', String(cx - diameter / 2)) innerEl.setAttribute('y', String(cy - diameter / 2)) innerEl.setAttribute('width', String(diameter)) innerEl.setAttribute('height', String(diameter)) renderInnerIconUrl(plan.inner.icon) .then((dataUrl) => { innerEl.setAttribute('href', dataUrl) }) .catch(() => {}) } // 票 76(新需求 2026-10-26):聚合点图标内加**聚合符号**(不是行业图标 —— 聚合表达"一组", // 不是某一种行业)。用内联 SVG 画三层同心「堆叠」符号,颜色取描边白,与母版同一条渲染路径。 { const diameter = 0.62 * 378 const cx = 378 * plan.anchor.x const cy = plan.inner.center.y * 475 const group = document.createElementNS('http://www.w3.org/2000/svg', 'g') group.setAttribute('data-marker-overlay-aggregate-symbol', '') const r = diameter / 2 for (let i = 0; i < 3; i += 1) { const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle') circle.setAttribute('cx', String(cx - r * 0.45 + i * r * 0.45)) circle.setAttribute('cy', String(cy + r * 0.18 - i * r * 0.18)) circle.setAttribute('r', String(r * 0.42)) circle.setAttribute('fill', '#ffffff') circle.setAttribute('fill-opacity', i === 2 ? '1' : '0.55') group.appendChild(circle) } svg.appendChild(group) } // 聚合标签同样挂在**独立图层**(2026-10-28):与设备/点位标签一致,永远盖在图标之上。 let labelWrap = null let labelBox = null const labelText = `聚合 ${row.k} 台${row.label ? ` · 组内${row.label}` : ''}` if (labelLayer) { labelWrap = document.createElement('div') labelWrap.className = LABEL_WRAP_CLASS labelWrap.style.cssText = 'position:absolute;left:0;top:0;width:0;height:0;pointer-events:none;' labelBox = document.createElement('div') labelBox.className = LABEL_CLASS labelBox.textContent = labelText labelBox.title = labelText labelBox.style.cssText = [ 'position:absolute', `bottom:${h + pad + 3}px`, 'left:0', 'transform:translateX(-50%)', 'box-sizing:content-box', 'max-width:170px', 'overflow:hidden', 'white-space:nowrap', 'text-overflow:ellipsis', 'padding:4px 9px', 'border-radius:8px', 'border:1px solid rgba(1,171,234,0.55)', 'background:rgba(2,20,40,0.82)', 'color:#d7ecff', 'font:600 12px "Microsoft YaHei UI","Microsoft YaHei","PingFang SC","Noto Sans SC",sans-serif', 'pointer-events:none', ].join(';') labelWrap.appendChild(labelBox) labelLayer.appendChild(labelWrap) } return { root, icon: svg, labelWrap, labelBox, labelMaxWidth: '170px' } } /** * 自绘水滴母版(bbox 坐标系:x∈[0,378]、y∈[0,475],尖端 (188.5,474))。 * viewBox 外扩 = 单源 `MASTER_SOURCE.outlinePad`(40)推导(不再写死字面量)。 * * 描边(浏览器走查修正):billboard 的 `withWhiteOutline` 是**向外膨胀** 28 源像素、 * 母版本身保持 378 全宽;SVG `stroke` 默认沿路径**中心线**(外 14 / 内 14)会把彩色母版 * 蚀掉一圈、白环也只出一半——同尺寸下 DOM 标记明显比 billboard 小。 * 用 `paint-order="stroke fill"` 把描边压到填充**之下**:彩色母版全尺寸保留, * 白色纯外扩 `stroke-width/2` = 28 → 与 billboard 的描边同值(外沿 28 < pad 40 不被裁)。 */ const PIN_OUTLINE = 'M188.5 0A189 189 0 0 0 111.6 361.7C157.3 382 173.6 435.5 188.5 474C203.4 435.5 219.7 382 265.4 361.7A189 189 0 0 0 188.5 0Z' const PIN_HOLE = 'M263.5 189a75 75 0 1 0-150 0 75 75 0 1 0 150 0Z' // 描边宽度 = 源描边外扩 ×2(stroke 沿路径中心线,向外外扩一半)——单源不在字符串里写死 56。 const PIN_STROKE = `stroke="#ffffff" stroke-width="${MASTER_SOURCE.outlineWidth * 2}" stroke-linejoin="round" paint-order="stroke fill"` /** SVG viewBox:bbox 四周外扩描边 pad(与 billboard 裁剪窗同形)。 */ const MARKER_VIEWBOX = `${-MASTER_SOURCE.outlinePad} ${-MASTER_SOURCE.outlinePad} ${MASTER_SOURCE.bboxWidth + MASTER_SOURCE.outlinePad * 2} ${MASTER_SOURCE.bboxHeight + MASTER_SOURCE.outlinePad * 2}` function markerSvg(master, toneHex) { const body = master === 'hollow' ? `` : `` return `` } /** 节点池:key → { root, inner, fp };标记消失时节点隐藏不销毁,计划读数变化时重建。 */ const pool = new Map() /** 帧内复用的"本轮在计划里"集合(票 35:原先每帧 new Set)。 */ const wantedKeys = new Set() /** 指纹分隔符:用不可打印字符避免与业务字符串拼接歧义。 */ const FP_SEP = '\u0001' let hostNode = null let hostRecord = null let postRenderRemove = null /** 标签独立图层(宿主子节点);`null` = 尚未挂载。 */ let labelLayer = null /** * 当前被指针悬停的节点 key(2026-10-28)。 * * 为什么由 JS 跟踪而不是 CSS `:hover`:标签搬进独立图层后不再是节点的后代, * `节点:hover .标签` 这类选择器已经匹配不到;而"hover 才显示全名"是维护者规格的一部分。 */ let hoveredLabelKey = '' /** 当前选中对象编码(帧状态给入;非聚合设备点的选择态高亮用)。 */ let markerSelectedCode = '' /** 临时 A/B 开关(票 47 走查):false = 隐藏 DOM 层、露出底下 billboard(之前版本)。 */ // 票 76(C6):A/B 显隐开关已退役 —— overlay 挂载即生效,billboard 视觉不再有「对照组」。 /** * 单个标记节点(特效层 + 标记 SVG + 可选标签),一次构建、每帧只更新 transform。 * @param {object|null} fx `fxOf(marker)` / 设备门控的产出(null = 无动效)。 * 票 84 起三类对象(监测点 / 关联设备 / 未关联设备)都按**自身状态**挂环,环的视觉唯一载体是 DOM。 */ function buildNode(key, marker, plan, toneHex, fx) { const root = document.createElement('div') root.className = NODE_CLASS root.dataset.markerOverlayKey = key root.dataset.markerOverlayKind = marker.kind root.dataset.markerOverlayCode = marker.code // 宿主 data-* 断言面(规格 10 读数在 DOM 节点上延续) root.dataset.markerMaster = plan.master root.dataset.markerInner = plan.inner.enabled ? plan.inner.icon : '' root.dataset.markerAnchor = `${plan.anchor.x},${plan.anchor.y}` root.dataset.markerDesaturate = plan.desaturate ? 'yes' : 'no' root.dataset.markerTone = toneHex // 几何读数面(票 83):本体宽 / 绘制宽 / pad —— 由合成计划单源摊平,不写死数值。 Object.assign(root.dataset, markerGeometryDataset(plan)) // 特效读数面(票 70 重做 / t45 C3d):无动效不挂键;挂 = 动效键 + profile 层数 + alpha 真值。 // 票 83 追加 `markerRing`(yes/no):三类对象统一用这一个键读「有没有环」。 root.dataset.markerRing = fx ? 'yes' : 'no' if (fx) { root.dataset.markerEffect = fx.effect root.dataset.markerEffectLayers = String(markerEffectLayers(fx.profile)) // 票 85:读数写**生效值**(降级时已乘收敛系数),走查不必自己再算。 const alphaKnobFloor = markerDevKnobs().alphaFloor const alphaEffective = Math.max(alphaKnobFloor, fx.converge ? fx.profile.alpha * fx.converge.alpha : fx.profile.alpha) root.dataset.markerEffectAlpha = String(Number(alphaEffective.toFixed(3))) root.dataset.markerEffectConverged = fx.converge ? 'yes' : 'no' } const w = plan.size.width const h = plan.size.height const pad = plan.draw.pad root.style.cssText = [ 'position:absolute', 'left:0', 'top:0', 'width:0', 'height:0', 'pointer-events:auto', 'cursor:pointer', 'will-change:transform', ].join(';') // 票 76(选择态发光):状态色写成 CSS 变量 —— **必须在 cssText 之后**(cssText 整段覆盖内联样式, // 先写等于刚写就抹掉;这是「还是白光」的第二层原因)。不能靠 `currentColor`:母版带白色描边, // currentColor 会落成白色(第一层原因)。 root.style.setProperty('--marker-tone', toneHex) // 票 68/101:设施点与点位/设备同一套指针语义(可点,宿主 click 派发节点身份)。 // 类型写在节点上,走查按 `data-facility-type` 断言四类符号而不是读渲染内部; // 不可点身份(缺主键/类别)由 `marker.clickable === false` 关掉指针(不伪装可点)。 if (marker.kind === 'facility-point') { if (marker.clickable === false) { root.style.pointerEvents = 'none' root.style.cursor = 'default' } root.dataset.facilityType = marker.type || '' } // 特效层在标记本体之下(先挂 fx、SVG 后画盖住环心,环带在图标外扩散可见)。 // 锚点是节点原点即尖端落地点(buildFxLayers 的 -box/2 偏移保证中心=原点)。 const fxHost = fx ? buildFxLayers(fx, plan, toneHex, key) : null if (fxHost) { root.appendChild(fxHost) } const wrap = document.createElement('div') wrap.innerHTML = markerSvg(plan.master, toneHex) const svg = wrap.firstChild svg.style.cssText = [ 'position:absolute', `left:${-(pad + w * plan.anchor.x)}px`, `top:${-(h + pad)}px`, `width:${plan.draw.width}px`, `height:${plan.draw.height}px`, plan.desaturate ? 'filter:saturate(0.55) brightness(0.72)' : '', ].join(';') // 反应量缩放的**原点 = 尖端**(尖端固定在地面坐标上,标记围绕尖端放大/缩小, // 与 billboard verticalOrigin:BOTTOM + scale 同一行为)。 svg.style.transformOrigin = `${pad + w * plan.anchor.x}px ${h + pad}px` root.appendChild(svg) const innerEl = svg.querySelector('[data-marker-overlay-inner]') if (plan.inner.enabled) { // 直径/圆心 = 计划层几何(80% × 母版宽、帽心对齐)。viewBox 起点 -40 只是外扩描边 // 余量,bbox 坐标即 SVG 用户坐标——圆心直接用母版帽心 (188.5, 0.397×475)。 const diameter = plan.inner.diameterRatio * 378 const cx = 378 * plan.anchor.x // 帽心 x = 尖端 x = 188.5 const cy = plan.inner.center.y * 475 innerEl.setAttribute('x', String(cx - diameter / 2)) innerEl.setAttribute('y', String(cy - diameter / 2)) innerEl.setAttribute('width', String(diameter)) innerEl.setAttribute('height', String(diameter)) } // 标签:真 DOM,且**挂在独立图层**(2026-10-28 维护者报「多点位时标签被图标遮挡」)。 // 节点自身带 `transform`(定位)→ 自成层叠上下文 → 标签留在节点内部就永远盖不到别的节点之上。 // 限宽取计划层 `labelMaxWidth`(宽 × 2.6,billboard 同一单源);hover 时放开限宽 = 显示全名。 let labelWrap = null let labelBox = null if (marker.name && labelLayer) { labelWrap = document.createElement('div') labelWrap.className = LABEL_WRAP_CLASS // 零尺寸锚点:位移与节点同一次 transform,标签几何靠内层 `bottom/translateX(-50%)` 表达。 labelWrap.style.cssText = 'position:absolute;left:0;top:0;width:0;height:0;pointer-events:none;' labelBox = document.createElement('div') labelBox.className = LABEL_CLASS labelBox.textContent = marker.name labelBox.title = marker.name // hover 读全(与 DOM 侧 EllipsisText 同语义) labelBox.style.cssText = [ 'position:absolute', `bottom:${h + pad + 3}px`, 'left:0', 'transform:translateX(-50%)', // content-box:max-width 只管文字,与 Cesium label 的截断宽度同语义 'box-sizing:content-box', `max-width:${plan.labelMaxWidth}px`, 'overflow:hidden', 'white-space:nowrap', 'text-overflow:ellipsis', 'padding:4px 9px', 'border-radius:8px', 'border:1px solid rgba(1,171,234,0.55)', 'background:rgba(2,20,40,0.82)', 'color:#d7ecff', 'font:600 12px "Microsoft YaHei UI","Microsoft YaHei","PingFang SC","Noto Sans SC",sans-serif', 'pointer-events:none', ].join(';') labelWrap.appendChild(labelBox) labelLayer.appendChild(labelWrap) } return { root, inner: innerEl, svg, fxHost, labelWrap, labelBox, labelMaxWidth: `${plan.labelMaxWidth}px` } } /** * 初始化 overlay 宿主(挂在地图容器内,z-index 在 canvas 之上、面板之下)。 * @param {Element} mapHost 地图宿主(.map-canvas) * @param {object} [options] `{ onPick }` 显式点击触发器回调(窗口坐标 + 节点身份) * @returns {boolean} 是否初始化成功(失败时调用方回退 billboard 链路) */ export function mountMarkerOverlay(mapHost, { onPick, onHover } = {}) { try { if (!mapHost || typeof mapHost.appendChild !== 'function') return false if (hostNode && hostNode.isConnected) return true hostNode = document.createElement('div') hostNode.className = OVERLAY_HOST_CLASS hostNode.dataset.markerOverlay = '' hostNode.style.cssText = [ 'position:absolute', 'inset:0', 'pointer-events:none', // 宿主不拦截;节点自身 pointer-events:auto 'overflow:hidden', 'z-index:1', ].join(';') hostNode.style.display = '' // 票 70(2026-10-28 维护者报「多点位时标签被坐标点图标遮挡」): // 标签必须**独立成层**,不能留在节点内部 —— 节点带 `transform`(定位用)因而自成层叠上下文, // 其子元素无论 z-index 多高都出不了这个上下文,于是后画的节点会盖住先画节点的标签。 // 这一层铺满宿主、`pointer-events:none`,层级高于所有节点(含 `--center` 的 3)。 labelLayer = document.createElement('div') labelLayer.className = LABEL_LAYER_CLASS labelLayer.style.cssText = [ 'position:absolute', 'inset:0', 'pointer-events:none', 'z-index:6', ].join(';') // 标签层是宿主的子节点、**先挂**(节点后追加不影响 —— 层级由 z-index 决定)。 hostNode.appendChild(labelLayer) // 票 76(C4 parity 现场把手):DOM 单层比 billboard 双层暗(镂空孔/边缘不再叠亮层)。 // `?pinBrightness=1.2` 只做整体亮度补偿(不碰色相/饱和度);定档后写成默认常量并撤掉把手。 try { if (import.meta.env && import.meta.env.DEV && typeof window !== 'undefined') { const hashQuery = String(window.location.hash || '').split('?')[1] || '' const raw = new URLSearchParams(window.location.search).get('pinBrightness') || new URLSearchParams(hashQuery).get('pinBrightness') || '' const value = Number(raw) if (Number.isFinite(value) && value > 0 && value !== 1) hostNode.style.filter = `brightness(${value})` } } catch (error) { // 走查把手:解析失败不影响挂载。 } // 特效样式注入(票 70 重做 / t45 C3b):幂等一次;卸载时移除。 ensureFxStyles() // 显式点击触发器(票 47 维护者裁定:给事件触发器、点击照搬原功能函数): // 命中标记节点 → 交给注入的 onPick(mapService 传 pickDeviceOrPointAt,与 Canvas 点选同一函数)。 hostNode.addEventListener('click', (event) => { if (typeof onPick !== 'function') return const node = event.target && event.target.closest ? event.target.closest(`.${NODE_CLASS}`) : null if (!node) return onPick({ x: event.clientX, y: event.clientY, kind: node.dataset.markerOverlayKind, code: node.dataset.markerOverlayCode, key: node.dataset.markerOverlayKey, }) }) // 标记节点上的按下/抬起不再冒泡到宿主:宿主的 attachPointPick 会对同一次点击**再拾一遍** // (双派发)。点选统一走上面的显式触发器。pointermove 不拦——hover 高亮与 tooltip 链路照常。 const stopOnMarker = (event) => { if (event.target && event.target.closest && event.target.closest(`.${NODE_CLASS}`)) { event.stopPropagation() } } hostNode.addEventListener('pointerdown', stopOnMarker) hostNode.addEventListener('pointerup', stopOnMarker) // 票 76(C6):DOM 节点 hover 派发 —— billboard 视觉让位后 GPU 拾取没有命中对象, // hover(设备反应量/面板高亮/tooltip)改由 overlay 按**节点身份**通知服务层。 // 指针不在任何节点上时发 `null`,服务层据此清空 hover 态。 const emitHover = (event) => { if (typeof onHover !== 'function') return const node = event.target && event.target.closest ? event.target.closest(`.${NODE_CLASS}`) : null if (!node) { hoveredLabelKey = '' onHover(null) return } // 2026-10-28:hover 的节点 key 一并记下 —— 独立图层里的标签靠它决定「这一台是否展开全名」。 hoveredLabelKey = node.dataset ? (node.dataset.markerOverlayKey || '') : '' onHover({ kind: node.dataset.markerOverlayKind || '', code: node.dataset.markerOverlayCode || '', }) } hostNode.addEventListener('pointermove', emitHover) hostNode.addEventListener('pointerleave', () => { hoveredLabelKey = '' if (typeof onHover === 'function') onHover(null) }) mapHost.appendChild(hostNode) hostRecord = { host: mapHost } return true } catch { return false } } /** overlay 是否已挂载。 */ export function isMarkerOverlayMounted() { return !!(hostNode && hostNode.isConnected) } // 票 76(C6):`setMarkerOverlayVisible()` 与 `?overlay=0` 已退役(票面「非目标」的收口项)—— // 载体终裁 DOM 胜出后不再需要 billboard 对照组,第 ⑤ 条验收「点选统一走 overlay click 触发器」 // 也因此不再有需要 GPU 拾取的第二态。 /** overlay 是否在生效(票 76 / C6:挂载即生效,不再有 A/B 显隐态)。 */ export function isMarkerOverlayVisible() { return isMarkerOverlayMounted() } /** * 每帧同步(由 mapService 的 postRender 驱动): * 投影 + transform 更新 + 屏外隐藏 + 池复用。**不重建节点**(指纹变化才重建)。 * @param {object} params * @param {Array} params.markers `planPointMarkers().markers`(含 industry/tone/plan 消费字段) * @param {Array} [params.devices] 关联设备行(`projectDeviceMarkers()` 产出:屏幕坐标直供, * scale/alpha 已含选中/hover/弱化反应量——DOM 镜像 billboard 实体真值,防「小一圈」) * @param {Map} [params.pointScales] `kind|code` → 强调 scale(票 76 / C3:由**服务层**产出, * 1 = 常态、`EMPHASIS_STYLE.markerScale` = 强调;不再回读 billboard 实体) * @param {object} params.tones token 色表(tone 键 → CSS 颜色) * @param {Function} params.planOf marker → plan(markerPlan.planMarker 的包装) * @param {Function} [params.effectOf] marker → 门控后的动效键('' = 不动画;mapService 从 * domain `planDeviceEffects` 计划产出,**只给摘要标记行**——关联设备的特效仍在 billboard 层) * @param {Array} [params.aggregates] 重叠聚合符号行(票 76 / C1:`projectOverlapSymbols()` 产出; * group 身份/计数/组内状态词/形状/色键/屏幕坐标 —— 唯一没有设备身份的一类节点) * @param {Function} params.projectToWindow (lon, lat) → 宿主相对窗口坐标(贴地口径由调用方保证) */ /** * 计划内但本轮画不出来的节点(坐标非法 / 投影失败)也要隐藏。 * * 为什么必须显式隐藏:它们仍在 `wanted` 里,不会被池级隐藏循环收走;若继续 `continue`, * 节点会停在最后一次成功投影的位置 —— 与"被筛掉却定格"是同一类幽灵标记缺陷。 */ function hidePooledNode(key) { const record = pool.get(key) if (!record) return hideRecord(record) } /** 隐藏一条记录(节点 + 标签,标签在独立图层里必须一起收)。 */ function hideRecord(record) { if (!record) return if (record.root && record.root.style.display !== 'none') record.root.style.display = 'none' if (record.labelWrap && record.labelWrap.style.display !== 'none') record.labelWrap.style.display = 'none' } /** 销毁一条记录的 DOM(节点 + 标签锚点)。重建时必须两个都清,否则标签会留在图层里变幽灵。 */ function destroyRecord(record) { if (!record) return if (record.root && record.root.parentNode) record.root.parentNode.removeChild(record.root) if (record.labelWrap && record.labelWrap.parentNode) record.labelWrap.parentNode.removeChild(record.labelWrap) } /** * 标签显隐与「hover 展开全名」(2026-10-28 维护者规格)。 * * 规则只有一个出口,调用方按**状态**给两个布尔量,渲染层不自己判状态: * · 聚合点:① 常态显示;① hover 预览 / ② 被选择 / ③④ 被替换 → 隐藏。 * · 圈内设备(fan):常态一律隐藏;**只有被 hover 的那一台**显示(且展开为全名)。 * · 中心设备:③ 设备常态显示;④ 设备被选择隐藏(与 ② 同性质)。 * · 非聚合设备点(k=1 关联设备 / 独立未关联设备):**始终显示**(截断),hover 展开全名。 */ function applyLabelState(record, visible, expanded) { if (!record || !record.labelWrap || !record.labelBox) return const wantedDisplay = visible === true ? 'block' : 'none' if (record.labelWrap.style.display !== wantedDisplay) record.labelWrap.style.display = wantedDisplay if (!visible) return const wantedWidth = expanded === true ? 'none' : record.labelMaxWidth if (record.labelBox.style.maxWidth !== wantedWidth) record.labelBox.style.maxWidth = wantedWidth } /** 标签跟随节点位移(与节点同一次 transform;不逐帧读布局)。 */ function placeLabel(record, x, y) { if (!record || !record.labelWrap) return const transform = `translate(${x}px, ${y}px)` if (record.labelWrap.style.transform !== transform) record.labelWrap.style.transform = transform } // ================================================================ 标签判据(纯函数,唯一住址) // // 2026-10-28 维护者给出的状态机把标签规则讲清楚了,而这块此前在三轮走查里反复翻烧饼 // (圈内一律不建 → 圈内也要 → 常态隐藏、hover 显示)。所以判据**抽成纯函数**: // 渲染层只调用,不在模板/CSS 里再写一遍;闸门可以离线断言,不必起浏览器。 // // | 对象 | 常态 | hover | 备注 | // | --- | --- | --- | --- | // | 聚合点(聚合标签) | ① 显示 | 隐藏(并展开预览环) | ② 固定 / ③④ 被替换也隐藏 | // | 圈内设备(fan) | 隐藏 | **显示全名**(仅被 hover 的那一台) | 环内密集,常态不显示 | // | 中心设备 | ③ 显示 | 显示全名 | ④ 被选择 → 隐藏(与 ② 同性质) | // | 非聚合设备点 / 点位 / 未关联设备 | 显示(截断) | 显示全名 | 聚合规则不得外溢到这里 | /** * 聚合点标签:只有 ① 常态(未预览、未固定、未被设备替换)才显示。 * @param {{pinned?:boolean, preview?:boolean, replaced?:boolean}} row 引擎投影行 */ export function aggregateLabelState(row) { const item = row || {} const visible = item.pinned !== true && item.preview !== true && item.replaced !== true return { visible, expanded: false } } /** * 关联设备点标签。 * @param {{fan?:boolean, pinned?:boolean}} device 引擎投影行 * @param {boolean} hovered 指针是否在该节点上 */ export function deviceLabelState(device, hovered) { const item = device || {} if (item.fan === true) return { visible: hovered === true, expanded: hovered === true } if (item.pinned === true) return { visible: false, expanded: false } return { visible: true, expanded: hovered === true } } /** 点位 / 未关联设备(摘要标记):**始终显示**,hover 展开全名。 */ export function markerLabelState(hovered) { return { visible: true, expanded: hovered === true } } export function syncMarkerOverlay({ markers, devices, aggregates, facilities, selectedFacilityKey, pipeComponents, pipeComponentsVisible, pointScales, tones, planOf, facilityPlanOf, componentPlanOf, effectOf, deviceEffectOf, converge, selectedCode, projectToWindow }) { if (!hostNode || !hostNode.isConnected || typeof projectToWindow !== 'function') return // 每帧刷新门控查询(mapService 在计划变化时给新闭包;这里只搬运、不判定)。 frameEffectOf = typeof effectOf === 'function' ? effectOf : () => '' frameDeviceEffectOf = typeof deviceEffectOf === 'function' ? deviceEffectOf : () => '' frameConverge = converge || null // 当前选中的对象编码(2026-10-28):**非聚合设备点**选中时要上「被选择态」同款高亮, // 而聚合设备的中心由引擎的 pinned 标志负责;两者语义不同,故这里另收一份。 markerSelectedCode = typeof selectedCode === 'string' ? selectedCode : '' // 票 35 卡顿整改:Set 复用(原先每帧 new 一个);指纹改成廉价拼接(原先每标记 // JSON.stringify 一次,纯分配)。DOM 写入也改成"只有真的变了才写"。 wantedKeys.clear() const wanted = wantedKeys // 设备行坐标是**窗口坐标**(引擎投影真值):转宿主相对要减宿主(overlay 挂载父级)的 // 窗口原点。每帧读一次——宿主可能随布局移动,不能在挂载时缓存。 const hostRect = hostNode.parentElement && hostNode.parentElement.getBoundingClientRect ? hostNode.parentElement.getBoundingClientRect() : { left: 0, top: 0 } const hostOffset = { x: hostRect.left, y: hostRect.top } for (const marker of markers || []) { const key = `${marker.kind}|${marker.code}` wanted.add(key) if (!Number.isFinite(marker.longitude) || !Number.isFinite(marker.latitude)) { hidePooledNode(key); continue } const windowPos = projectToWindow(marker.longitude, marker.latitude) if (!windowPos || !Number.isFinite(windowPos.x) || !Number.isFinite(windowPos.y)) { hidePooledNode(key); continue } const plan = planOf(marker) const toneHex = tones[marker.tone] || tones.neutral // 票 70:fxOf 的键取自 effectOf 门控查询(domain 计划已判开关/降级);profile 取状态。 const fx = fxOf(marker) // 计划读数指纹:状态/行业/名称/母版/内层/降饱和/色值/**动效键与层数**任一变化就重建节点。 const fp = [marker.status, marker.industry, marker.name, plan.master, plan.inner.enabled, plan.inner.icon, plan.inner.diameterRatio, plan.desaturate, toneHex, fx ? fx.effect : '', fx ? fx.profile.layers : 0].join(FP_SEP) let record = pool.get(key) if (!record || record.fp !== fp) { destroyRecord(record) record = { ...buildNode(key, marker, plan, toneHex, fx), fp } pool.set(key, record) hostNode.appendChild(record.root) if (plan.inner.enabled) { // 内层素材:与 billboard 同一去色重染规则的原生分辨率 data URL(缓存复用)。 // 竞态防护:加载回来时池记录可能已被更新的状态重建——只在仍是本节点时写入。 const mine = record renderInnerIconUrl(plan.inner.icon) .then((dataUrl) => { if (pool.get(key) === mine) mine.inner.setAttribute('href', dataUrl) }) .catch(() => { // 内层素材失败:只有母版水滴(降级可见),不阻塞其余标记。 }) } } // 坐标只在真的变了才写(镜头静止时零 DOM 写入)。 const transform = `translate(${windowPos.x}px, ${windowPos.y}px)` if (record.root.style.transform !== transform) record.root.style.transform = transform // 强调/还原的缩放(票 76 / C3):量由服务层产出(不再回读 billboard.scale), // DOM 围绕尖端缩放该量(transformOrigin=尖端,见 buildNode)。 const markerScale = pointScales instanceof Map ? pointScales.get(key) : undefined const scaleTransform = markerScale && markerScale !== 1 ? `scale(${markerScale})` : '' if (record.svg.style.transform !== scaleTransform) record.svg.style.transform = scaleTransform // 票 84:选中态环整体放大 —— boost 与图标强调共用 EMPHASIS_STYLE.markerScale(同一个 1.55)。 if (record.fxHost) { const base = Number(record.fxHost.dataset.fxBaseScale || 1) const factor = base * (markerScale && markerScale !== 1 ? markerScale : 1) const fxTransform = factor !== 1 ? `scale(${factor})` : '' if (record.fxHost.style.transform !== fxTransform) record.fxHost.style.transform = fxTransform } if (record.root.style.display !== 'block') record.root.style.display = 'block' // 标签(2026-10-28):点位与未关联设备**始终显示**(截断),hover 才展开全名; // 若它是当前选中对象(独立设备被选中)则由下面的 `--active` 正규负责发光,标签仍在。 placeLabel(record, windowPos.x, windowPos.y) const markerLabel = markerLabelState(hoveredLabelKey === key) applyLabelState(record, markerLabel.visible, markerLabel.expanded) // 选择态发光(2026-10-28 维护者规格④):**非聚合的设备点**(独立未关联设备)被选中时, // 用与「被选择态」同款的 --active 高亮。聚合设备中心由设备循环的 pinned 负责。 const markerActive = marker.kind === 'unassociated-equipment' && markerSelectedCode !== '' && marker.code === markerSelectedCode record.root.classList.toggle(`${NODE_CLASS}--active`, markerActive) } // 关联设备(票 47 维护者反馈②根因:设备是独立 billboard 图层,此前 DOM 只渲染 // 摘要标记计划、关联设备根本没进 DOM)。行由引擎每帧投影产出(屏幕坐标真值 + // 反应量真值),形态走与摘要未关联设备同一套合成计划(实心母版 + 内层行业图标)。 for (const device of devices || []) { const key = device.key || `device|${device.code}` wanted.add(key) if (!Number.isFinite(device.x) || !Number.isFinite(device.y)) { hidePooledNode(key); continue } // 2026-10-27 维护者走查修正:圈内子设备点**也要顶部标签**(此前被置空 + CSS 隐藏, // 表现为"关联监测点的子设备点没有名称")。标签与普通设备点同一条渲染路径。 const marker = { kind: 'associated-device', code: device.code, // 2026-10-27 走查修正:子设备点(fan)也要顶部标签 —— 不再清空名称。 name: device.name || '', status: device.status || '', industry: device.industry || '', } const plan = planOf(marker) // 票 76(2026-10-26):色键由**设备状态**决定(domain 单源)——设备行不单独携带 tone, // 否则状态色与摘要标记两套口径。 const toneHex = tones[deviceVisualForStatus(device.status).tone] || tones.neutral // 票 84(取代票 76 C6 的「只有中心设备挂环」):关联设备按**自身状态**挂环, // 且必须走与摘要标记同一条**门控**(`frameDeviceEffectOf`:关特效/降级时一律不挂)。 // 聚合符号仍恒不挂环(聚合是"一组",不冒充设备告警身份)。 // 状态 → profile 的唯一入口是 `effectProfileFor()`(domain 单源)。 const gatedEffect = frameDeviceEffectOf(device) const profile = gatedEffect ? effectProfileFor(device.status) : null const fx = profile && profile.effect === gatedEffect ? { effect: gatedEffect, profile, converge: frameConverge } : null const fp = [marker.status, marker.industry, marker.name, plan.master, plan.inner.enabled, plan.inner.icon, plan.inner.diameterRatio, plan.desaturate, toneHex, fx ? fx.effect : '', fx ? fx.profile.layers : 0].join(FP_SEP) let record = pool.get(key) if (!record || record.fp !== fp) { destroyRecord(record) record = { ...buildNode(key, marker, plan, toneHex, fx), fp } pool.set(key, record) hostNode.appendChild(record.root) if (plan.inner.enabled) { const mine = record renderInnerIconUrl(plan.inner.icon) .then((dataUrl) => { if (pool.get(key) === mine) mine.inner.setAttribute('href', dataUrl) }) .catch(() => {}) } } // 坐标 = 引擎投影的窗口坐标(含 pixelOffset 尖端补偿),只差宿主原点一层。 const deviceTransform = `translate(${device.x - hostOffset.x}px, ${device.y - hostOffset.y}px)` if (record.root.style.transform !== deviceTransform) record.root.style.transform = deviceTransform // 反应量镜像:scale 读的是 billboard 实体真值(选中 1.6/hover 1.3/弱化 0.75), // 弱化的压暗同理镜像 alpha(只写透明度,不动色相——色键语义归设备状态)。 // 中心设备与聚合点**同尺寸**(走查:选中设备后一直保持放大、且丢失选择态);其余设备照旧镜像。 const effScale = device.selected ? 1 : device.scale const deviceScale = effScale && effScale !== 1 ? `scale(${effScale})` : '' if (record.svg.style.transform !== deviceScale) record.svg.style.transform = deviceScale const deviceOpacity = device.alpha !== undefined && device.alpha !== 1 ? String(device.alpha) : '' if (record.root.style.opacity !== deviceOpacity) record.root.style.opacity = deviceOpacity // 票 84:选中(中心)设备的环整体放大 —— 与图标强调共用同一个 1.55 常量。 if (record.fxHost) { const base = Number(record.fxHost.dataset.fxBaseScale || 1) const factor = base * (device.selected ? markerDevKnobs().selectedBoost : 1) const fxTransform = factor !== 1 ? `scale(${factor})` : '' if (record.fxHost.style.transform !== fxTransform) record.fxHost.style.transform = fxTransform } if (record.root.style.display !== 'block') record.root.style.display = 'block' // ==================== 标签与选择态(2026-10-28 维护者状态机)==================== // 标签规则(唯一出口 applyLabelState;这里只按状态给两个布尔量): // · 圈内设备(fan,处于「环可见」的 ①hover预览/②固定/③hover预览/④固定」): // 常态**一律隐藏**,只有被 hover 的那一台显示并展开全名 —— 你报的「环展开时顶部标签没隐藏」。 // · ③ 设备常态的中心设备:显示(它此时就是一枚正常设备点);hover 展开全名。 // · ④ 设备被选择的中心设备:隐藏(与 ② 聚合被选择态同性质)。 // · 非聚合设备点(k=1 关联设备):**始终显示** —— 聚合规则不得外溢到它们。 placeLabel(record, device.x - hostOffset.x, device.y - hostOffset.y) const isHovered = hoveredLabelKey === key const deviceLabel = deviceLabelState(device, isHovered) applyLabelState(record, deviceLabel.visible, deviceLabel.expanded) // 圈内设备标记(CSS/走查读这个类;标签显隐已由上面统一驱动,不再靠 CSS 后代选择器)。 record.root.classList.toggle(`${NODE_CLASS}--fan`, device.fan === true) // 票 76(四态规格 2026-10-26)设备节点: // ③ 设备常态 中心=设备点,无发光,环只在 hover 预览(标签常态显示) // ④ 设备被选择态 与聚合点被选择态相同:状态色发光 + 隐藏自身标签 // 2026-10-28:非聚合设备点(k=1 关联设备)被选中时也要上**同款高亮** —— // 走的是「设备选择事件」这条统一逻辑,与聚合规则无关。 // 注意排除 `device.selected`(③ 的中心设备**正是**当前选中对象,但它不是选择态、不发光)、 // 以及 `fan`(圈内设备的高亮由所在组的 `pinned` 决定)。 const deviceActive = device.pinned === true || (markerSelectedCode !== '' && device.code === markerSelectedCode && device.selected !== true && device.fan !== true) record.root.classList.toggle(`${NODE_CLASS}--active`, deviceActive) // 票 76(四态,2026-10-26):③④ 的中心设备要**压过聚合节点**(聚合节点后追加、会吃掉点击), // 否则「点中心设备进 ④」永远触发不了(走查第 3 步完全失效的直接原因)。 record.root.classList.toggle(`${NODE_CLASS}--center`, device.selected === true) } // 重叠聚合符号(票 76 / C1:billboard 版只在 billboard 层,DOM 此前完全没有——票 47 遗留)。 // 坐标同为引擎投影的窗口坐标,只差宿主原点一层。点击由宿主既有触发器派发(kind=overlap-aggregate)。 for (const row of aggregates || []) { const key = row.key || `overlap|${row.pointCode}|${row.group}` wanted.add(key) if (!Number.isFinite(row.x) || !Number.isFinite(row.y)) { hidePooledNode(key); continue } const toneHex = tones[row.tone] || tones.neutral // 票 76(统一设备点位渲染):聚合符号也走 `planOf` 合成计划(母版/内层/几何与设备点同源), // 不再用旧的形状表(三角形图标 = 废弃渲染模式);计划读数纳入指纹,变化才重建。 const plan = planOf({ kind: 'monitoring-device', status: row.status, industry: '', tone: row.tone }) const fp = [ row.k, row.label, row.status, row.state, toneHex, plan.master, plan.inner.enabled, plan.inner.icon, plan.inner.diameterRatio, plan.desaturate, ].join(FP_SEP) let record = pool.get(key) if (!record || record.fp !== fp) { destroyRecord(record) record = { ...buildAggregateNode(key, row, toneHex, plan), fp } pool.set(key, record) hostNode.appendChild(record.root) } const aggTransform = `translate(${row.x - hostOffset.x}px, ${row.y - hostOffset.y}px)` if (record.root.style.transform !== aggTransform) record.root.style.transform = aggTransform // 状态强调镜像(billboard `scale` / `outlineWidth` 真值)。 // 票 76(四态):② 被选择态 = `pinned`(聚合点固定环 + 发光 + 略放大); // ① 的 hover 预览**不算**选择态,不放大也不发光。 const active = row.pinned === true const activeBoost = active ? 1.12 : 1 const effAggScale = (row.scale && row.scale > 0 ? row.scale : 1) * activeBoost const aggScale = effAggScale !== 1 ? `scale(${effAggScale})` : '' if (record.icon.style.transform !== aggScale) record.icon.style.transform = aggScale const aggFilter = row.outline ? 'drop-shadow(0 0 2px rgba(255,255,255,0.9))' : '' if (record.icon.style.filter !== aggFilter) record.icon.style.filter = aggFilter record.root.classList.toggle(`${NODE_CLASS}--active`, active) // 票 76(四态):① 的 hover 预览与 ② 的固定态都隐藏聚合标签(让出设备环); // ③④ 中心已是设备点 → 整枚聚合节点让位(标签随之隐藏)。 // 2026-10-28:标签在**独立图层**,显隐由 `applyLabelState` 统一驱动(不再内联查 DOM 子元素)。 placeLabel(record, row.x - hostOffset.x, row.y - hostOffset.y) const aggregateLabel = aggregateLabelState(row) applyLabelState(record, aggregateLabel.visible, aggregateLabel.expanded) record.root.style.display = row.replaced ? 'none' : 'block' // ③④ 中心已是设备点:聚合节点虽然隐藏,但保险起见再降掉 pointer-events, // 避免它(或其子元素)在过渡帧里吃掉对中心设备的点击。 record.root.classList.toggle(`${NODE_CLASS}--replaced`, row.replaced === true) } // 设施点与点位/设备共用节点池,点击派发完整的节点 key;不可点身份不挂指针交互。 // 计划行已由 store 按设施类型 + 全局行业/片区筛好;这里只按一级模式显隐(计划层收口)。 for (const facility of facilities || []) { const key = facility.key || `facility-point|${facility.code}` wanted.add(key) if (!Number.isFinite(facility.longitude) || !Number.isFinite(facility.latitude)) { hidePooledNode(key); continue } const windowPos = projectToWindow(facility.longitude, facility.latitude) if (!windowPos || !Number.isFinite(windowPos.x) || !Number.isFinite(windowPos.y)) { hidePooledNode(key); continue } const marker = { ...facility, kind: 'facility-point' } const plan = facilityPlanOf(marker) const toneHex = tones[plan.tint] || tones.neutral // 指纹含设施类型/名称/状态与合成计划读数:任一变化就重建节点(与点位标记同一条纪律)。 const fp = [marker.type, marker.status, marker.name, marker.clickable, plan.master, plan.inner.enabled, plan.inner.icon, plan.inner.diameterRatio, toneHex].join(FP_SEP) let record = pool.get(key) if (!record || record.fp !== fp) { destroyRecord(record) record = { ...buildNode(key, marker, plan, toneHex, null), fp } pool.set(key, record) hostNode.appendChild(record.root) if (plan.inner.enabled) { const mine = record renderInnerIconUrl(plan.inner.icon) .then((dataUrl) => { if (pool.get(key) === mine) mine.inner.setAttribute('href', dataUrl) }) .catch(() => { // 内层素材失败:只剩母版水滴(降级可见),不阻塞其它设施点。 }) } } const transform = `translate(${windowPos.x}px, ${windowPos.y}px)` if (record.root.style.transform !== transform) record.root.style.transform = transform if (record.root.style.display !== 'block') record.root.style.display = 'block' placeLabel(record, windowPos.x, windowPos.y) const facilityLabel = markerLabelState(hoveredLabelKey === key) applyLabelState(record, facilityLabel.visible, facilityLabel.expanded) record.root.classList.toggle(`${NODE_CLASS}--active`, selectedFacilityKey === key) } // 管线构件点位(票 101):阀门/窨井/特殊管点等。地面视图用 DOM 点位标记 // (与设备/设施同一套节点池与合成计划,行业色由领域层随行下发);管线视图的 // 3D 模型 / Cesium 兜底点由管线图层自己管(`pipeComponentsVisible` 关掉本循环)。 // 点击派发回**管线拾取链**(局部增强),不进设施详情 —— 构件不是设施实体。 for (const component of pipeComponentsVisible === false ? [] : (pipeComponents || [])) { const key = component.key || `pipe-component|${component.code}` wanted.add(key) if (!Number.isFinite(component.longitude) || !Number.isFinite(component.latitude)) { hidePooledNode(key); continue } const windowPos = projectToWindow(component.longitude, component.latitude) if (!windowPos || !Number.isFinite(windowPos.x) || !Number.isFinite(windowPos.y)) { hidePooledNode(key); continue } const marker = { ...component, kind: 'pipe-component' } const plan = componentPlanOf(marker) // 行业色随行下发(`toneHex`,与管线图层同一份 PIPE_PALETTE);缺行色回退中性。 const toneHex = marker.toneHex || tones[plan.tint] || tones.neutral const fp = [marker.componentKind, marker.industry, marker.name, plan.master, plan.inner.enabled, plan.inner.icon, toneHex].join(FP_SEP) let record = pool.get(key) if (!record || record.fp !== fp) { destroyRecord(record) record = { ...buildNode(key, marker, plan, toneHex, null), fp } pool.set(key, record) hostNode.appendChild(record.root) if (plan.inner.enabled) { const mine = record renderInnerIconUrl(plan.inner.icon) .then((dataUrl) => { if (pool.get(key) === mine) mine.inner.setAttribute('href', dataUrl) }) .catch(() => { // 内层素材失败:只剩母版水滴(降级可见),不阻塞其它构件点。 }) } } const transform = `translate(${windowPos.x}px, ${windowPos.y}px)` if (record.root.style.transform !== transform) record.root.style.transform = transform if (record.root.style.display !== 'block') record.root.style.display = 'block' placeLabel(record, windowPos.x, windowPos.y) const componentLabel = markerLabelState(hoveredLabelKey === key) applyLabelState(record, componentLabel.visible, componentLabel.expanded) } // 本轮不在计划里的:隐藏(池复用,不销毁)。 // ⚠️ 这一轮**必须**在所有计划遍历之后:放进某个循环里会漏掉「点位/设备被筛掉」的节点, // 它们会停在最后一次投影位置变成幽灵标记(2026-10-27 实测缺陷,勿再合并进循环)。 for (const [key, record] of pool.entries()) { if (!wanted.has(key)) hideRecord(record) } } /** * 挂 postRender 驱动(mapService 调用;返回卸载函数)。 * `getFrameState` 每帧由 mapService 提供最新标记计划与投影函数。 */ export function attachOverlayRenderer(viewer, getFrameState) { if (!viewer || !viewer.scene) return () => {} const onFrame = () => { const state = getFrameState() if (state) syncMarkerOverlay(state) } viewer.scene.postRender.addEventListener(onFrame) postRenderRemove = () => viewer.scene.postRender.removeEventListener(onFrame) return postRenderRemove } /** overlay 节点池(调试/断言用)。 */ export function overlayPoolSize() { return pool.size } /** 某个池节点的 display 读数('' / 'block' / 'none')——幽灵标记回归闸门读它。 */ export function overlayNodeDisplayOf(key) { const record = pool.get(key) return record ? String(record.root.style.display || '') : '' } /** 某个池节点的标签文本(子设备点标签回归闸门读它)。 */ export function overlayNodeLabelTextOf(key) { const record = pool.get(key) if (!record) return '' // 2026-10-28:标签已搬进独立图层(`record.labelBox`),不再挂在节点内部。 return record.labelBox ? String(record.labelBox.textContent || '') : '' } /** * 某个池节点标签的**显隐读数**('' 表示从未设置 / 'block' / 'none')。 * 走查与闸门用它断言「环展开时圈内设备标签确实隐藏、hover 那一台才显示」。 */ export function overlayLabelDisplayOf(key) { const record = pool.get(key) return record && record.labelWrap ? String(record.labelWrap.style.display || '') : '' } /** 某个池节点标签是否处于**展开全名**(max-width 放开)状态。 */ export function overlayLabelExpandedOf(key) { const record = pool.get(key) return Boolean(record && record.labelBox && record.labelBox.style.maxWidth === 'none') } /** 卸载(离开 /cockpit 用):摘 postRender、清节点池与宿主、移除特效样式(t45 C3b 生命周期)。 */ export function unmountMarkerOverlay() { if (postRenderRemove) { postRenderRemove() postRenderRemove = null } if (typeof document !== 'undefined') { const style = document.getElementById(FX_STYLE_ID) if (style && style.parentNode) style.parentNode.removeChild(style) } if (hostNode && hostNode.parentNode) hostNode.parentNode.removeChild(hostNode) hostNode = null hostRecord = null // 标签图层与 hover 记忆一并清掉(宿主已移除,引用留着只会拖住 DOM)。 labelLayer = null hoveredLabelKey = '' markerSelectedCode = '' pool.clear() }