| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259 |
- /**
- * 地图标记 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'
- ? `<path d="${PIN_OUTLINE}${PIN_HOLE}" fill="${toneHex}" fill-rule="evenodd" ${PIN_STROKE}/>`
- : `<path d="${PIN_OUTLINE}" fill="${toneHex}" ${PIN_STROKE}/>`
- return `<svg viewBox="${MARKER_VIEWBOX}" width="100%" height="100%" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">${body}<image data-marker-overlay-inner="" x="0" y="0" width="0" height="0"/></svg>`
- }
- /** 节点池: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()
- }
|