/**
* 地图标记 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()
}