markerOverlay.js 73 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276
  1. /**
  2. * 地图标记 DOM overlay(票 47)——billboard 发糊与底板生硬的结构性解决。
  3. *
  4. * 渲染分工(维护者 2026-10-15 裁定):
  5. * · **外层母版 = 自绘 SVG**(矢量,任何 DPR 都锐利;billboard 发糊的根治)。
  6. * 水滴形按实测母版几何拟合:帽圆 (188.5,189) r189(逐行验证误差 <1px)+
  7. * 下半收尖三次贝塞尔(对 512px 母版左缘 57 点最小二乘拟合,最大残差 4.75 源像素
  8. * ≈ 0.34 屏显像素;拟合探针见 .scratch/cockpit-real-data-wayfinding/research/fit-pin-bezier.mjs)。
  9. * 镂空孔 = 实测圆 (188.5,189) r75。viewBox 外扩与 billboard 裁剪窗同形(单源 pad)。
  10. * · **内层行业图标 = 素材**(`markerAssets.renderInnerIconUrl`:与 billboard 同一
  11. * `recolorInner` 去色重染规则,但**不下采样**——256px 原生分辨率 data URL,
  12. * DOM 里按帽径向下采样,永远锐利)。
  13. * · 标签 = 真 DOM(圆角底板、装饰边、text-overflow、hover title 原生能力)。
  14. * · 每帧 `postRender` 投影:贴**当前已渲染的地形网格**高度;没有可信地面时本轮不上图
  15. * (不按椭球面 0 米摆到地底),口径见 `domain/groundAnchor.js`。
  16. *
  17. * 与既有链路的关系(维护者裁定:DOM 完整复刻 billboard 功能后才替换它):
  18. * · 合成计划层 `domain/markerPlan.js` **原样复用**(归属/几何/锚点/降饱和不换媒介);
  19. * · `syncPointMarkersForPlan`(billboard 链路)**保留不动**——聚合、特效、Canvas 拾取仍依赖它;
  20. * 内层素材加载失败时 SVG 只有母版 = 底下 billboard 双层照常显示(降级可见);
  21. * · 拾取:overlay 节点挂**显式 click 触发器**,回调直接复用 `pickDeviceOrPointAt`
  22. * (与 Canvas 点选链同一个功能函数,维护者原话「照搬原本的功能函数」);
  23. * · `data-*` 断言面全部挂到 DOM 节点,走查不读内部实现。
  24. *
  25. * 性能纪律:节点池复用(不每帧重建);计划读数指纹变化才重建;postRender 只做 transform 更新。
  26. */
  27. import { renderInnerIconUrl } from './markerAssets.js'
  28. // 票 76(C1 → 统一渲染):聚合符号不再用旧形状表(`markerSvg(shape)` 的三角形/圆形图标已废弃),
  29. // 与标记/设备一样走自绘水滴母版;domain 的 `markerSvg` 导入随之移除。
  30. // 票 45:特效随标记 DOM 化 —— 周期/层数/放大倍数与 billboard 特效同一份 domain 常量(节奏单源)。
  31. // 票 70(重做):本行导入的每一枚都必须有消费者(不留死导入,票面 Acceptance 第 3 条)。
  32. import { DEVICE_EFFECT, effectProfileFor } from '../domain/deviceEffects.js'
  33. // 票 76(2026-10-26):设备点的色键取设备状态词条(domain 单源),DOM 不自己映射状态→颜色。
  34. import { deviceVisualForStatus } from '../domain/deviceStatus.js'
  35. // 票 82/84:几何与光环参数都来自单源 `markerMetrics`;DOM 只把结论翻译成 CSS/DOM。
  36. import {
  37. MASTER_SOURCE,
  38. MARKER_COEFFICIENTS,
  39. RING_EFFECT,
  40. markerDevKnobs,
  41. RING_STYLE,
  42. blinkOpacityAt,
  43. glowScaleAt,
  44. glowStrengthAt,
  45. ringAlphaAt,
  46. ringPhaseOffsetOf,
  47. ringScaleAt,
  48. } from '../domain/markerMetrics.js'
  49. // 票 83:几何读数(本体宽 / 绘制宽 / pad)与计划读数同一出口,走查不读 DOM 内部结构。
  50. import { markerGeometryDataset } from '../domain/markerPlan.js'
  51. /**
  52. * 类名单源:注入的特效 CSS(下方模板串)与 DOM 构建(buildNode/mount)共用同一组常量。
  53. * 必须声明在 `FX_CSS` 之前——模板串在模块求值期就要读这些值(`const` 有 TDZ)。
  54. * 前三枚为票 47 既有值(原样保留,勿改:走查断言与样式钩子读这些类名)。
  55. */
  56. const OVERLAY_HOST_CLASS = 'marker-overlay-host'
  57. const NODE_CLASS = 'marker-overlay-node'
  58. const LABEL_CLASS = 'marker-overlay-label'
  59. /** 标签独立图层(2026-10-28):标签要盖在所有图标之上,必须脱离节点自身的层叠上下文。 */
  60. const LABEL_LAYER_CLASS = 'marker-overlay-labels'
  61. /** 标签的零尺寸锚点(随节点位移;真正的标签盒子在它内部)。 */
  62. const LABEL_WRAP_CLASS = 'marker-overlay-label-wrap'
  63. const FX_CLASS = 'marker-overlay-fx'
  64. const FX_RING_CLASS = 'marker-overlay-fx-ring'
  65. const FX_GLOW_CLASS = 'marker-overlay-fx-glow'
  66. const FX_SPARK_CLASS = 'marker-overlay-fx-spark'
  67. const FX_TICK_CLASS = 'marker-overlay-fx-tick'
  68. /** 关键帧采样相位(9 个停点足够平滑,且让「任何时刻总有一层在前 1/4」可断言)。 */
  69. const FX_SAMPLE_PHASES = [0, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875, 1]
  70. /** 刻度环/工单齿轮的旋转周期:由 RING_STYLE.tickDegPerSec 反推(20°/s → 18s)。 */
  71. const FX_TICK_PERIOD_MS = Math.round((360 / RING_STYLE.tickDegPerSec) * 1000)
  72. /** 单层环的关键帧名:每层 alpha 指数不同,所以按 (effect, layer) 生成,不共用一套曲线。 */
  73. function fxRingKeyframeName(effect, layer) {
  74. return `markerFxRing--${effect}--${layer}`
  75. }
  76. /** 从单源纯函数采样生成环的关键帧(scale/opacity/brightness 都来自 markerMetrics)。 */
  77. function fxRingKeyframes(status, profile, layer) {
  78. const stops = FX_SAMPLE_PHASES.map((phase) => {
  79. const timeMs = phase * profile.periodMs
  80. const scale = ringScaleAt(status, layer, timeMs).toFixed(3)
  81. const alpha = ringAlphaAt(status, layer, timeMs).toFixed(3)
  82. // 自发光亮度包络(2026-10-27 恢复已验收规格 2.0 → 0.8):
  83. // 只靠 opacity 会显得「灰」——发射瞬间必须亮一下才有能量感。
  84. const brightness = (
  85. MARKER_COEFFICIENTS.ringBrightnessStart
  86. - (MARKER_COEFFICIENTS.ringBrightnessStart - MARKER_COEFFICIENTS.ringBrightnessEnd) * phase
  87. ).toFixed(2)
  88. return ` ${(phase * 100).toFixed(1)}% { transform:translate(-50%,-50%) scale(${scale}); opacity:${alpha}; filter:brightness(${brightness}); }`
  89. })
  90. return `@keyframes ${fxRingKeyframeName(profile.effect, layer)} {\n${stops.join('\n')}\n}`
  91. }
  92. /** 工单处置:只旋转不扩散(scale 固定 1),不透明度按已验收包络 0.95 → 0.68 呼吸。 */
  93. function fxSpinKeyframes(profile) {
  94. const { spinAlphaStart, spinAlphaEnd } = MARKER_COEFFICIENTS
  95. return `@keyframes markerFxSpin {\n from { transform:translate(-50%,-50%) rotate(0deg); opacity:${spinAlphaStart}; }\n to { transform:translate(-50%,-50%) rotate(360deg); opacity:${spinAlphaEnd}; }\n}`
  96. }
  97. /** 中心辉光(只给报警):尺寸/亮度/不透明度同样从单源纯函数与系数采样。 */
  98. function fxGlowKeyframes() {
  99. const profile = RING_EFFECT.ALARM
  100. const { glowAlphaMin, glowAlphaPeak, glowBrightnessMin, glowBrightnessPeak } = MARKER_COEFFICIENTS
  101. const stops = FX_SAMPLE_PHASES.map((phase) => {
  102. const timeMs = phase * profile.glowPeriodMs
  103. const strength = glowStrengthAt(timeMs)
  104. // 已验收包络:不透明度 0.32 → 0.85、亮度 1.2 → 1.9。
  105. // 此前被压成固定 0.32 且无亮度提亮 —— 报警的「核心在烧」观感就是这么丢的。
  106. const alpha = (glowAlphaMin + (glowAlphaPeak - glowAlphaMin) * strength).toFixed(3)
  107. const brightness = (glowBrightnessMin + (glowBrightnessPeak - glowBrightnessMin) * strength).toFixed(2)
  108. return ` ${(phase * 100).toFixed(1)}% { transform:translate(-50%,-50%) scale(${glowScaleAt(timeMs).toFixed(3)}); opacity:${alpha}; filter:brightness(${brightness}); }`
  109. })
  110. return `@keyframes markerFxGlow {\n${stops.join('\n')}\n}`
  111. }
  112. /** 环关键帧(票 86:DEV 旋钮变更后要能重建,所以不是模块级常量)。 */
  113. function ringKeyframesCss() {
  114. return [
  115. ...Object.values(RING_EFFECT)
  116. .filter((profile) => profile.effect !== 'spin')
  117. .flatMap((profile) => Array.from({ length: profile.layers }, (_, layer) => fxRingKeyframes(profile.status, profile, layer))),
  118. fxSpinKeyframes(RING_EFFECT.WORK_ORDER),
  119. fxGlowKeyframes(),
  120. ].join('\n')
  121. }
  122. /**
  123. * 特效层样式(票 45 起,票 84 改写):
  124. * · 环带 = **近白硬边带**(不再是把状态色烘进平滑渐变),锐利才读作「能量」;
  125. * · scale/opacity 关键帧由 `markerMetrics` 的纯函数采样生成(起始 0.55 / 峰值 2.6 /
  126. * alpha 峰值 0.35–0.5 / 内层慢外层快的 alpha 指数)——渲染层不写死任何环数值;
  127. * · 相位去同步:层间均分 + 每对象哈希偏移,靠 `animation-delay`(负值 = 开播即处于该相位);
  128. * · 粒子感:3 个火花(径向推进)+ 2 层刻度环(反向旋转),数量来自 `RING_STYLE`;
  129. * · 锚点 = 节点原点(图标尖端落地位置),环直接标记地面目标点;
  130. * · **不做** reduced-motion 降级(票 72 D10:维护者明确「不做冗余设计」)。
  131. */
  132. function buildFxCss() {
  133. return `
  134. /* 票 76 曾对圈内设备(--fan)隐藏顶部标签;2026-10-27 维护者走查要求**子设备点也要标签**,
  135. 因此该隐藏规则移除(标签常态显示,hover 仍读 title 全名)。 */
  136. /* 选择态标识:发光用**点位本体颜色语义**(--marker-tone = 状态色),不用白光。
  137. 两处都生效:聚合点被点开、以及选中设备替换中心后中心设备本身。 */
  138. .${NODE_CLASS}--active { z-index:2; }
  139. .${NODE_CLASS}--center { z-index:3; }
  140. .${NODE_CLASS}--aggregate.${NODE_CLASS}--replaced { pointer-events:none; }
  141. .${NODE_CLASS}--active svg {
  142. filter:drop-shadow(0 0 6px var(--marker-tone, currentColor)) drop-shadow(0 0 14px var(--marker-tone, currentColor)) brightness(1.18);
  143. }
  144. /* 2026-10-28:标签显隐**不再走 CSS 后代选择器** —— 标签已搬进独立图层(不再是节点的后代),
  145. 而且规格里「圈内设备 hover 才显示」「选择态隐藏标签」「非聚合设备点常显」是**状态**规则,
  146. 统一由 applyLabelState() 驱动(见 syncMarkerOverlay 的三处调用)。
  147. 旧的两条 --active + 标签 的 display 规则已退役(它们现在匹配不到任何元素)。 */
  148. .${FX_CLASS} { position:absolute; pointer-events:none; transform-origin:50% 50%; will-change:transform; }
  149. .${FX_RING_CLASS} {
  150. position:absolute; left:50%; top:50%;
  151. border-radius:50%;
  152. /* 彩色注能环(2026-10-27 恢复已验收规格):环体用**状态色**(currentColor),
  153. 内沿到外沿按可见带旋钮收口;此前换成白色硬边带后,维护者走查报「特效非常微弱」——
  154. 白带在浅底图与薄雾上几乎没有对比度,而彩色环才有「能量」语义。 */
  155. background:radial-gradient(circle,
  156. rgba(0,0,0,0) calc(var(--fx-band-in, 88%) - 16%),
  157. currentColor var(--fx-band-in, 88%),
  158. rgba(0,0,0,0) calc(var(--fx-band-out, 93%) + 7%));
  159. transform:translate(-50%,-50%);
  160. animation-timing-function:linear; animation-iteration-count:infinite;
  161. /* 彩色外发光(已验收值 6px/16px):这是「亮度感」的主要来源之一,不能省。 */
  162. box-shadow:0 0 6px currentColor, 0 0 16px currentColor;
  163. will-change:transform,opacity;
  164. }
  165. .${FX_CLASS}--spin .${FX_RING_CLASS} {
  166. background:none;
  167. border:2px dashed currentColor;
  168. filter:drop-shadow(0 0 3px currentColor);
  169. }
  170. .${FX_SPARK_CLASS} {
  171. position:absolute; left:50%; top:50%;
  172. width:3px; height:3px; border-radius:50%;
  173. background:currentColor; box-shadow:0 0 4px currentColor;
  174. animation:markerFxSpark var(--fx-period,1800ms) linear infinite;
  175. will-change:transform,opacity;
  176. }
  177. @keyframes markerFxSpark {
  178. 0% { opacity:0; transform:translate(-50%,-50%) rotate(var(--fx-spark-angle,0deg)) translateY(0) scale(0.6); }
  179. 20% { opacity:0.85; }
  180. 100% { opacity:0; transform:translate(-50%,-50%) rotate(var(--fx-spark-angle,0deg)) translateY(calc(var(--fx-box,32px) * -0.42)) scale(1); }
  181. }
  182. .${FX_TICK_CLASS} {
  183. position:absolute; left:50%; top:50%;
  184. border-radius:50%;
  185. border:1px dashed currentColor;
  186. opacity:0.16;
  187. transform:translate(-50%,-50%);
  188. animation:markerFxTick ${FX_TICK_PERIOD_MS}ms linear infinite;
  189. }
  190. @keyframes markerFxTick { to { transform:translate(-50%,-50%) rotate(360deg); } }
  191. .${FX_GLOW_CLASS} {
  192. position:absolute; left:50%; top:50%;
  193. border-radius:50%;
  194. background:radial-gradient(circle, currentColor 0%, color-mix(in srgb, currentColor 42%, transparent) 30%, rgba(0,0,0,0) 72%);
  195. transform:translate(-50%,-50%);
  196. animation:markerFxGlow var(--fx-glow-period,2400ms) ease-in-out infinite;
  197. /* 中心辉光的外发光(已验收值 18px/40px):报警的「核心在烧」观感全靠它。 */
  198. box-shadow:0 0 18px currentColor, 0 0 40px currentColor;
  199. will-change:transform,opacity;
  200. }
  201. ${ringKeyframesCss()}
  202. `
  203. }
  204. const FX_STYLE_ID = 'marker-overlay-fx-styles'
  205. /** 注入一次特效样式(幂等:已存在就跳过)。 */
  206. function ensureFxStyles() {
  207. if (typeof document === 'undefined') return
  208. if (document.getElementById(FX_STYLE_ID)) return
  209. const style = document.createElement('style')
  210. style.id = FX_STYLE_ID
  211. style.textContent = buildFxCss()
  212. document.head.appendChild(style)
  213. }
  214. /**
  215. * 票 86:DEV 旋钮变更后重建环样式与节点(关键帧/带型都按新参数重新生成)。
  216. * 只在开发钩子里调用;生产路径不会走到。
  217. */
  218. export function refreshMarkerFx() {
  219. const style = typeof document !== 'undefined' ? document.getElementById(FX_STYLE_ID) : null
  220. if (style) style.textContent = buildFxCss()
  221. for (const record of pool.values()) {
  222. if (record.root && record.root.parentNode) record.root.parentNode.removeChild(record.root)
  223. }
  224. pool.clear()
  225. }
  226. /** 每帧由 `syncMarkerOverlay` 注入的门控查询(mapService 从 domain 动效计划产出)。 */
  227. let frameEffectOf = () => ''
  228. /** 票 85:降级收敛系数(null = 未降级);同一屏所有环共享。 */
  229. let frameConverge = null
  230. /** 关联设备层的门控查询(票 84:三类对象的环统一走同一条门控)。 */
  231. let frameDeviceEffectOf = () => ''
  232. /** 设施点层的门控查询(维护者 2026-11-06:设施点挂与设备同一套状态特效)。 */
  233. let frameFacilityEffectOf = () => ''
  234. /** 状态 → 特效 profile(domain 单源;DOM 层数/亮度/辉光只消费这一份表,不另造)。 */
  235. function markerFxProfile(status) {
  236. return effectProfileFor(status)
  237. }
  238. /**
  239. * 标记动效查询(票 70 重做):**双条件**才出特效——
  240. * ① 门控放行(`effectOf`:开关判定在 domain;超限仍挂环,只下传收敛系数);
  241. * ② 状态有 profile 且 profile 的动效与门控放行的动效一致(防串键)。
  242. * @returns {{effect:string, profile:object}|null}
  243. */
  244. function fxOf(marker) {
  245. const effect = frameEffectOf(marker)
  246. if (!effect) return null
  247. const profile = markerFxProfile(marker.status)
  248. if (!profile || profile.effect !== effect) return null
  249. return { effect, profile, converge: frameConverge }
  250. }
  251. /**
  252. * 环层数=状态 profile 的 layers(报警 4 / 预警 3 / 工单 1)。
  253. * 层数只从 profile 消费,DOM 侧不许写死。
  254. */
  255. function markerEffectLayers(profile) {
  256. return profile.layers
  257. }
  258. /**
  259. * 特效 DOM(票 70 重做,票 84 改写):固定 `profile.layers` 个环节点 + 3 火花 + 2 刻度环
  260. * (+报警辉光 1)。CSS 动画驱动、JS 不逐帧更新。容器几何:`left:-box/2` 使其中心 = 节点原点。
  261. * 相位偏移按 `phaseKey`(kind|code)哈希,保证同屏对象不同节拍、同 id 可复算。
  262. */
  263. function buildFxLayers(fx, plan, toneHex, phaseKey = '') {
  264. const { profile } = fx
  265. // 票 85:降级时环不消失 —— 整组环按收敛系数缩小(峰值 ×0.6)并压暗(alpha ×0.5),周期不变。
  266. const converge = fx.converge || null
  267. const convergePeak = converge ? converge.peak : 1
  268. const convergeAlpha = converge ? converge.alpha : 1
  269. const box = plan.draw.width
  270. const phaseOffset = ringPhaseOffsetOf(phaseKey)
  271. const host = document.createElement('div')
  272. host.className = `${FX_CLASS} ${FX_CLASS}--${profile.effect}`
  273. host.style.cssText = `position:absolute;left:${-box / 2}px;top:${-box / 2}px;width:${box}px;height:${box}px;color:${toneHex};`
  274. // 收敛量写进 dataset:走查能读出"这条环是不是降级态",选中 boost 也乘在它上面。
  275. host.dataset.fxBaseScale = String(convergePeak)
  276. host.dataset.fxConverged = converge ? 'yes' : 'no'
  277. host.style.opacity = String(convergeAlpha)
  278. if (convergePeak !== 1) host.style.transform = `scale(${convergePeak})`
  279. host.style.setProperty('--fx-box', `${box}px`)
  280. host.style.setProperty('--fx-period', `${profile.periodMs}ms`)
  281. host.style.setProperty('--fx-start-scale', String(profile.startScale))
  282. host.style.setProperty('--fx-peak-scale', String(profile.scale))
  283. host.style.setProperty('--fx-alpha-peak', String(profile.alpha))
  284. host.style.setProperty('--fx-glow-period', `${RING_EFFECT.ALARM.glowPeriodMs}ms`)
  285. // 票 86:可见带占比是 DEV 旋钮(内沿固定 88%,外沿 = 内沿 + 占比)。
  286. const bandWidth = markerDevKnobs().bandWidth
  287. host.style.setProperty('--fx-band-in', `${Math.round(RING_STYLE.bandInner * 100)}%`)
  288. host.style.setProperty('--fx-band-out', `${Math.round((RING_STYLE.bandInner + bandWidth) * 100)}%`)
  289. for (let layer = 0; layer < markerEffectLayers(profile); layer += 1) {
  290. const ring = document.createElement('div')
  291. ring.className = FX_RING_CLASS
  292. ring.style.width = `${box}px`
  293. ring.style.height = `${box}px`
  294. ring.style.animationName = profile.effect === DEVICE_EFFECT.SPIN ? 'markerFxSpin' : fxRingKeyframeName(profile.effect, layer)
  295. ring.style.animationDuration = `${profile.periodMs}ms`
  296. // 层间均分错相 + 每对象哈希偏移;负延迟 = 开播即处于该相位。
  297. ring.style.animationDelay = `${-(profile.periodMs * ((layer / markerEffectLayers(profile)) + phaseOffset))}ms`
  298. host.appendChild(ring)
  299. }
  300. if (profile.effect !== DEVICE_EFFECT.SPIN) {
  301. for (let index = 0; index < RING_STYLE.sparks; index += 1) {
  302. const spark = document.createElement('div')
  303. spark.className = FX_SPARK_CLASS
  304. spark.style.setProperty('--fx-spark-angle', `${(360 / RING_STYLE.sparks) * index}deg`)
  305. spark.style.animationDelay = `${-(profile.periodMs * ((index / RING_STYLE.sparks) + phaseOffset))}ms`
  306. host.appendChild(spark)
  307. }
  308. }
  309. for (let index = 0; index < RING_STYLE.tickRings; index += 1) {
  310. const tick = document.createElement('div')
  311. tick.className = FX_TICK_CLASS
  312. const size = Math.round(box * (0.55 + index * 0.17))
  313. tick.style.width = `${size}px`
  314. tick.style.height = `${size}px`
  315. tick.style.animationDirection = index % 2 === 1 ? 'reverse' : 'normal'
  316. host.appendChild(tick)
  317. }
  318. // 中心辉光只给报警(票 07 优先级语义,不给预警/工单)。
  319. if (profile.glow) {
  320. const glow = document.createElement('div')
  321. glow.className = FX_GLOW_CLASS
  322. const size = Math.round(box * 0.7)
  323. glow.style.width = `${size}px`
  324. glow.style.height = `${size}px`
  325. host.appendChild(glow)
  326. }
  327. return host
  328. }
  329. /**
  330. * 聚合符号节点(票 76 / C1):重叠设备的聚合符号此前只活在 billboard 层(票 47 遗留),
  331. * DOM 侧一个都没有。节点形态与 billboard 版对齐:
  332. * · 形状/色键 = 引擎读数(`overlapShape` / `overlapTone`),形状生成器复用 domain `markerSvg`
  333. * (billboard 的 `imageOf` 就是它)——同源,不另画一套;
  334. * · 尺寸 = billboard 宽真值(`size * 1.5` 的产物,票 58 的聚合符号档),DOM 不自己算;
  335. * · 锚点 = 投影点**居中**(对应 billboard `verticalOrigin: CENTER` + pixelOffset 0),
  336. * 与标记的尖端锚定不同;
  337. * · 标签文案与 billboard 版同式样(「聚合 K 台 · 组内<状态词>」);
  338. * · **不含设备身份**:`data-marker-overlay-code` 放 group key(点击派发用),不放 deviceCode。
  339. */
  340. function buildAggregateNode(key, row, toneHex, plan) {
  341. const root = document.createElement('div')
  342. // `--aggregate` 修饰类:让聚合标签走「常显」规则(设备标签只在 hover 时显示)。
  343. root.className = `${NODE_CLASS} ${NODE_CLASS}--aggregate`
  344. root.dataset.markerOverlayKey = key
  345. root.dataset.markerOverlayKind = 'overlap-aggregate'
  346. root.dataset.markerOverlayCode = row.group
  347. root.dataset.markerOverlayAggregate = String(row.k)
  348. root.dataset.markerOverlayPointCode = row.pointCode || ''
  349. // 票 83:聚合节点同样挂几何读数(聚合档 = 设备档);聚合符号恒不挂环。
  350. Object.assign(root.dataset, markerGeometryDataset(plan))
  351. root.dataset.markerRing = 'no'
  352. root.style.cssText = [
  353. 'position:absolute',
  354. 'left:0', 'top:0',
  355. 'width:0', 'height:0',
  356. 'pointer-events:auto',
  357. 'cursor:pointer',
  358. 'will-change:transform',
  359. ].join(';')
  360. // 票 76(选择态发光):状态色写成 CSS 变量(**必须在 cssText 之后**——cssText 会整段覆盖内联样式,
  361. // 先设置等于刚写就抹掉,这是上一版「还是白光」的第二层原因)。
  362. root.style.setProperty('--marker-tone', toneHex)
  363. // 票 76(统一设备点位渲染):聚合符号也走**自绘水滴母版**(与标记/设备同一套几何与渲染路径),
  364. // 不再用旧的形状表(三角形/圆形图标)——那是废弃的渲染模式。
  365. const w = plan.size.width
  366. const h = plan.size.height
  367. const pad = plan.draw.pad
  368. const wrap = document.createElement('div')
  369. wrap.innerHTML = markerSvg(plan.master, toneHex)
  370. const svg = wrap.firstChild
  371. svg.style.cssText = [
  372. 'position:absolute',
  373. `left:${-(pad + w * plan.anchor.x)}px`,
  374. `top:${-(h + pad)}px`,
  375. `width:${plan.draw.width}px`,
  376. `height:${plan.draw.height}px`,
  377. plan.desaturate ? 'filter:saturate(0.55) brightness(0.72)' : '',
  378. ].join(';')
  379. svg.style.transformOrigin = `${pad + w * plan.anchor.x}px ${h + pad}px`
  380. root.appendChild(svg)
  381. const innerEl = svg.querySelector('[data-marker-overlay-inner]')
  382. if (plan.inner.enabled && innerEl) {
  383. const diameter = plan.inner.diameterRatio * 378
  384. const cx = 378 * plan.anchor.x
  385. const cy = plan.inner.center.y * 475
  386. innerEl.setAttribute('x', String(cx - diameter / 2))
  387. innerEl.setAttribute('y', String(cy - diameter / 2))
  388. innerEl.setAttribute('width', String(diameter))
  389. innerEl.setAttribute('height', String(diameter))
  390. renderInnerIconUrl(plan.inner.icon)
  391. .then((dataUrl) => { innerEl.setAttribute('href', dataUrl) })
  392. .catch(() => {})
  393. }
  394. // 票 76(新需求 2026-10-26):聚合点图标内加**聚合符号**(不是行业图标 —— 聚合表达"一组",
  395. // 不是某一种行业)。用内联 SVG 画三层同心「堆叠」符号,颜色取描边白,与母版同一条渲染路径。
  396. {
  397. const diameter = 0.62 * 378
  398. const cx = 378 * plan.anchor.x
  399. const cy = plan.inner.center.y * 475
  400. const group = document.createElementNS('http://www.w3.org/2000/svg', 'g')
  401. group.setAttribute('data-marker-overlay-aggregate-symbol', '')
  402. const r = diameter / 2
  403. for (let i = 0; i < 3; i += 1) {
  404. const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
  405. circle.setAttribute('cx', String(cx - r * 0.45 + i * r * 0.45))
  406. circle.setAttribute('cy', String(cy + r * 0.18 - i * r * 0.18))
  407. circle.setAttribute('r', String(r * 0.42))
  408. circle.setAttribute('fill', '#ffffff')
  409. circle.setAttribute('fill-opacity', i === 2 ? '1' : '0.55')
  410. group.appendChild(circle)
  411. }
  412. svg.appendChild(group)
  413. }
  414. // 聚合标签同样挂在**独立图层**(2026-10-28):与设备/点位标签一致,永远盖在图标之上。
  415. let labelWrap = null
  416. let labelBox = null
  417. const labelText = `聚合 ${row.k} 台${row.label ? ` · 组内${row.label}` : ''}`
  418. if (labelLayer) {
  419. labelWrap = document.createElement('div')
  420. labelWrap.className = LABEL_WRAP_CLASS
  421. labelWrap.style.cssText = 'position:absolute;left:0;top:0;width:0;height:0;pointer-events:none;'
  422. labelBox = document.createElement('div')
  423. labelBox.className = LABEL_CLASS
  424. labelBox.textContent = labelText
  425. labelBox.title = labelText
  426. labelBox.style.cssText = [
  427. 'position:absolute',
  428. `bottom:${h + pad + 3}px`,
  429. 'left:0',
  430. 'transform:translateX(-50%)',
  431. 'box-sizing:content-box',
  432. 'max-width:170px',
  433. 'overflow:hidden',
  434. 'white-space:nowrap',
  435. 'text-overflow:ellipsis',
  436. 'padding:4px 9px',
  437. 'border-radius:8px',
  438. 'border:1px solid rgba(1,171,234,0.55)',
  439. 'background:rgba(2,20,40,0.82)',
  440. 'color:#d7ecff',
  441. 'font:600 12px "Microsoft YaHei UI","Microsoft YaHei","PingFang SC","Noto Sans SC",sans-serif',
  442. 'pointer-events:none',
  443. ].join(';')
  444. labelWrap.appendChild(labelBox)
  445. labelLayer.appendChild(labelWrap)
  446. }
  447. return { root, icon: svg, labelWrap, labelBox, labelMaxWidth: '170px' }
  448. }
  449. /**
  450. * 自绘水滴母版(bbox 坐标系:x∈[0,378]、y∈[0,475],尖端 (188.5,474))。
  451. * viewBox 外扩 = 单源 `MASTER_SOURCE.outlinePad`(40)推导(不再写死字面量)。
  452. *
  453. * 描边(浏览器走查修正):billboard 的 `withWhiteOutline` 是**向外膨胀** 28 源像素、
  454. * 母版本身保持 378 全宽;SVG `stroke` 默认沿路径**中心线**(外 14 / 内 14)会把彩色母版
  455. * 蚀掉一圈、白环也只出一半——同尺寸下 DOM 标记明显比 billboard 小。
  456. * 用 `paint-order="stroke fill"` 把描边压到填充**之下**:彩色母版全尺寸保留,
  457. * 白色纯外扩 `stroke-width/2` = 28 → 与 billboard 的描边同值(外沿 28 < pad 40 不被裁)。
  458. */
  459. 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'
  460. const PIN_HOLE = 'M263.5 189a75 75 0 1 0-150 0 75 75 0 1 0 150 0Z'
  461. // 描边宽度 = 源描边外扩 ×2(stroke 沿路径中心线,向外外扩一半)——单源不在字符串里写死 56。
  462. const PIN_STROKE = `stroke="#ffffff" stroke-width="${MASTER_SOURCE.outlineWidth * 2}" stroke-linejoin="round" paint-order="stroke fill"`
  463. /** SVG viewBox:bbox 四周外扩描边 pad(与 billboard 裁剪窗同形)。 */
  464. const MARKER_VIEWBOX = `${-MASTER_SOURCE.outlinePad} ${-MASTER_SOURCE.outlinePad} ${MASTER_SOURCE.bboxWidth + MASTER_SOURCE.outlinePad * 2} ${MASTER_SOURCE.bboxHeight + MASTER_SOURCE.outlinePad * 2}`
  465. function markerSvg(master, toneHex) {
  466. const body = master === 'hollow'
  467. ? `<path d="${PIN_OUTLINE}${PIN_HOLE}" fill="${toneHex}" fill-rule="evenodd" ${PIN_STROKE}/>`
  468. : `<path d="${PIN_OUTLINE}" fill="${toneHex}" ${PIN_STROKE}/>`
  469. 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>`
  470. }
  471. /** 节点池:key → { root, inner, fp };标记消失时节点隐藏不销毁,计划读数变化时重建。 */
  472. const pool = new Map()
  473. /** 帧内复用的"本轮在计划里"集合(票 35:原先每帧 new Set)。 */
  474. const wantedKeys = new Set()
  475. /** 指纹分隔符:用不可打印字符避免与业务字符串拼接歧义。 */
  476. const FP_SEP = '\u0001'
  477. let hostNode = null
  478. let hostRecord = null
  479. let postRenderRemove = null
  480. /** 标签独立图层(宿主子节点);`null` = 尚未挂载。 */
  481. let labelLayer = null
  482. /**
  483. * 当前被指针悬停的节点 key(2026-10-28)。
  484. *
  485. * 为什么由 JS 跟踪而不是 CSS `:hover`:标签搬进独立图层后不再是节点的后代,
  486. * `节点:hover .标签` 这类选择器已经匹配不到;而"hover 才显示全名"是维护者规格的一部分。
  487. */
  488. let hoveredLabelKey = ''
  489. /** 当前选中对象编码(帧状态给入;非聚合设备点的选择态高亮用)。 */
  490. let markerSelectedCode = ''
  491. /** 临时 A/B 开关(票 47 走查):false = 隐藏 DOM 层、露出底下 billboard(之前版本)。 */
  492. // 票 76(C6):A/B 显隐开关已退役 —— overlay 挂载即生效,billboard 视觉不再有「对照组」。
  493. /**
  494. * 单个标记节点(特效层 + 标记 SVG + 可选标签),一次构建、每帧只更新 transform。
  495. * @param {object|null} fx `fxOf(marker)` / 设备门控的产出(null = 无动效)。
  496. * 票 84 起三类对象(监测点 / 关联设备 / 未关联设备)都按**自身状态**挂环,环的视觉唯一载体是 DOM。
  497. */
  498. function buildNode(key, marker, plan, toneHex, fx) {
  499. const root = document.createElement('div')
  500. root.className = NODE_CLASS
  501. root.dataset.markerOverlayKey = key
  502. root.dataset.markerOverlayKind = marker.kind
  503. root.dataset.markerOverlayCode = marker.code
  504. // 宿主 data-* 断言面(规格 10 读数在 DOM 节点上延续)
  505. root.dataset.markerMaster = plan.master
  506. root.dataset.markerInner = plan.inner.enabled ? plan.inner.icon : ''
  507. root.dataset.markerAnchor = `${plan.anchor.x},${plan.anchor.y}`
  508. root.dataset.markerDesaturate = plan.desaturate ? 'yes' : 'no'
  509. root.dataset.markerTone = toneHex
  510. // 几何读数面(票 83):本体宽 / 绘制宽 / pad —— 由合成计划单源摊平,不写死数值。
  511. Object.assign(root.dataset, markerGeometryDataset(plan))
  512. // 特效读数面(票 70 重做 / t45 C3d):无动效不挂键;挂 = 动效键 + profile 层数 + alpha 真值。
  513. // 票 83 追加 `markerRing`(yes/no):三类对象统一用这一个键读「有没有环」。
  514. root.dataset.markerRing = fx ? 'yes' : 'no'
  515. if (fx) {
  516. root.dataset.markerEffect = fx.effect
  517. root.dataset.markerEffectLayers = String(markerEffectLayers(fx.profile))
  518. // 票 85:读数写**生效值**(降级时已乘收敛系数),走查不必自己再算。
  519. const alphaKnobFloor = markerDevKnobs().alphaFloor
  520. const alphaEffective = Math.max(alphaKnobFloor, fx.converge ? fx.profile.alpha * fx.converge.alpha : fx.profile.alpha)
  521. root.dataset.markerEffectAlpha = String(Number(alphaEffective.toFixed(3)))
  522. root.dataset.markerEffectConverged = fx.converge ? 'yes' : 'no'
  523. }
  524. const w = plan.size.width
  525. const h = plan.size.height
  526. const pad = plan.draw.pad
  527. root.style.cssText = [
  528. 'position:absolute',
  529. 'left:0', 'top:0',
  530. 'width:0', 'height:0',
  531. 'pointer-events:auto',
  532. 'cursor:pointer',
  533. 'will-change:transform',
  534. ].join(';')
  535. // 票 76(选择态发光):状态色写成 CSS 变量 —— **必须在 cssText 之后**(cssText 整段覆盖内联样式,
  536. // 先写等于刚写就抹掉;这是「还是白光」的第二层原因)。不能靠 `currentColor`:母版带白色描边,
  537. // currentColor 会落成白色(第一层原因)。
  538. root.style.setProperty('--marker-tone', toneHex)
  539. // 票 68/101:设施点与点位/设备同一套指针语义(可点,宿主 click 派发节点身份)。
  540. // 类型写在节点上,走查按 `data-facility-type` 断言四类符号而不是读渲染内部;
  541. // 不可点身份(缺主键/类别)由 `marker.clickable === false` 关掉指针(不伪装可点)。
  542. if (marker.kind === 'facility-point') {
  543. if (marker.clickable === false) {
  544. root.style.pointerEvents = 'none'
  545. root.style.cursor = 'default'
  546. }
  547. root.dataset.facilityType = marker.type || ''
  548. }
  549. // 特效层在标记本体之下(先挂 fx、SVG 后画盖住环心,环带在图标外扩散可见)。
  550. // 锚点是节点原点即尖端落地点(buildFxLayers 的 -box/2 偏移保证中心=原点)。
  551. const fxHost = fx ? buildFxLayers(fx, plan, toneHex, key) : null
  552. if (fxHost) {
  553. root.appendChild(fxHost)
  554. }
  555. const wrap = document.createElement('div')
  556. wrap.innerHTML = markerSvg(plan.master, toneHex)
  557. const svg = wrap.firstChild
  558. svg.style.cssText = [
  559. 'position:absolute',
  560. `left:${-(pad + w * plan.anchor.x)}px`,
  561. `top:${-(h + pad)}px`,
  562. `width:${plan.draw.width}px`,
  563. `height:${plan.draw.height}px`,
  564. plan.desaturate ? 'filter:saturate(0.55) brightness(0.72)' : '',
  565. ].join(';')
  566. // 反应量缩放的**原点 = 尖端**(尖端固定在地面坐标上,标记围绕尖端放大/缩小,
  567. // 与 billboard verticalOrigin:BOTTOM + scale 同一行为)。
  568. svg.style.transformOrigin = `${pad + w * plan.anchor.x}px ${h + pad}px`
  569. root.appendChild(svg)
  570. const innerEl = svg.querySelector('[data-marker-overlay-inner]')
  571. if (plan.inner.enabled) {
  572. // 直径/圆心 = 计划层几何(80% × 母版宽、帽心对齐)。viewBox 起点 -40 只是外扩描边
  573. // 余量,bbox 坐标即 SVG 用户坐标——圆心直接用母版帽心 (188.5, 0.397×475)。
  574. const diameter = plan.inner.diameterRatio * 378
  575. const cx = 378 * plan.anchor.x // 帽心 x = 尖端 x = 188.5
  576. const cy = plan.inner.center.y * 475
  577. innerEl.setAttribute('x', String(cx - diameter / 2))
  578. innerEl.setAttribute('y', String(cy - diameter / 2))
  579. innerEl.setAttribute('width', String(diameter))
  580. innerEl.setAttribute('height', String(diameter))
  581. }
  582. // 标签:真 DOM,且**挂在独立图层**(2026-10-28 维护者报「多点位时标签被图标遮挡」)。
  583. // 节点自身带 `transform`(定位)→ 自成层叠上下文 → 标签留在节点内部就永远盖不到别的节点之上。
  584. // 限宽取计划层 `labelMaxWidth`(宽 × 2.6,billboard 同一单源);hover 时放开限宽 = 显示全名。
  585. let labelWrap = null
  586. let labelBox = null
  587. if (marker.name && labelLayer) {
  588. labelWrap = document.createElement('div')
  589. labelWrap.className = LABEL_WRAP_CLASS
  590. // 零尺寸锚点:位移与节点同一次 transform,标签几何靠内层 `bottom/translateX(-50%)` 表达。
  591. labelWrap.style.cssText = 'position:absolute;left:0;top:0;width:0;height:0;pointer-events:none;'
  592. labelBox = document.createElement('div')
  593. labelBox.className = LABEL_CLASS
  594. labelBox.textContent = marker.name
  595. labelBox.title = marker.name // hover 读全(与 DOM 侧 EllipsisText 同语义)
  596. labelBox.style.cssText = [
  597. 'position:absolute',
  598. `bottom:${h + pad + 3}px`,
  599. 'left:0',
  600. 'transform:translateX(-50%)',
  601. // content-box:max-width 只管文字,与 Cesium label 的截断宽度同语义
  602. 'box-sizing:content-box',
  603. `max-width:${plan.labelMaxWidth}px`,
  604. 'overflow:hidden',
  605. 'white-space:nowrap',
  606. 'text-overflow:ellipsis',
  607. 'padding:4px 9px',
  608. 'border-radius:8px',
  609. 'border:1px solid rgba(1,171,234,0.55)',
  610. 'background:rgba(2,20,40,0.82)',
  611. 'color:#d7ecff',
  612. 'font:600 12px "Microsoft YaHei UI","Microsoft YaHei","PingFang SC","Noto Sans SC",sans-serif',
  613. 'pointer-events:none',
  614. ].join(';')
  615. labelWrap.appendChild(labelBox)
  616. labelLayer.appendChild(labelWrap)
  617. }
  618. return { root, inner: innerEl, svg, fxHost, labelWrap, labelBox, labelMaxWidth: `${plan.labelMaxWidth}px` }
  619. }
  620. /**
  621. * 初始化 overlay 宿主(挂在地图容器内,z-index 在 canvas 之上、面板之下)。
  622. * @param {Element} mapHost 地图宿主(.map-canvas)
  623. * @param {object} [options] `{ onPick }` 显式点击触发器回调(窗口坐标 + 节点身份)
  624. * @returns {boolean} 是否初始化成功(失败时调用方回退 billboard 链路)
  625. */
  626. export function mountMarkerOverlay(mapHost, { onPick, onHover } = {}) {
  627. try {
  628. if (!mapHost || typeof mapHost.appendChild !== 'function') return false
  629. if (hostNode && hostNode.isConnected) return true
  630. hostNode = document.createElement('div')
  631. hostNode.className = OVERLAY_HOST_CLASS
  632. hostNode.dataset.markerOverlay = ''
  633. hostNode.style.cssText = [
  634. 'position:absolute', 'inset:0',
  635. 'pointer-events:none', // 宿主不拦截;节点自身 pointer-events:auto
  636. 'overflow:hidden',
  637. 'z-index:1',
  638. ].join(';')
  639. hostNode.style.display = ''
  640. // 票 70(2026-10-28 维护者报「多点位时标签被坐标点图标遮挡」):
  641. // 标签必须**独立成层**,不能留在节点内部 —— 节点带 `transform`(定位用)因而自成层叠上下文,
  642. // 其子元素无论 z-index 多高都出不了这个上下文,于是后画的节点会盖住先画节点的标签。
  643. // 这一层铺满宿主、`pointer-events:none`,层级高于所有节点(含 `--center` 的 3)。
  644. labelLayer = document.createElement('div')
  645. labelLayer.className = LABEL_LAYER_CLASS
  646. labelLayer.style.cssText = [
  647. 'position:absolute', 'inset:0',
  648. 'pointer-events:none',
  649. 'z-index:6',
  650. ].join(';')
  651. // 标签层是宿主的子节点、**先挂**(节点后追加不影响 —— 层级由 z-index 决定)。
  652. hostNode.appendChild(labelLayer)
  653. // 票 76(C4 parity 现场把手):DOM 单层比 billboard 双层暗(镂空孔/边缘不再叠亮层)。
  654. // `?pinBrightness=1.2` 只做整体亮度补偿(不碰色相/饱和度);定档后写成默认常量并撤掉把手。
  655. try {
  656. if (import.meta.env && import.meta.env.DEV && typeof window !== 'undefined') {
  657. const hashQuery = String(window.location.hash || '').split('?')[1] || ''
  658. const raw = new URLSearchParams(window.location.search).get('pinBrightness')
  659. || new URLSearchParams(hashQuery).get('pinBrightness')
  660. || ''
  661. const value = Number(raw)
  662. if (Number.isFinite(value) && value > 0 && value !== 1) hostNode.style.filter = `brightness(${value})`
  663. }
  664. } catch (error) {
  665. // 走查把手:解析失败不影响挂载。
  666. }
  667. // 特效样式注入(票 70 重做 / t45 C3b):幂等一次;卸载时移除。
  668. ensureFxStyles()
  669. // 显式点击触发器(票 47 维护者裁定:给事件触发器、点击照搬原功能函数):
  670. // 命中标记节点 → 交给注入的 onPick(mapService 传 pickDeviceOrPointAt,与 Canvas 点选同一函数)。
  671. hostNode.addEventListener('click', (event) => {
  672. if (typeof onPick !== 'function') return
  673. const node = event.target && event.target.closest ? event.target.closest(`.${NODE_CLASS}`) : null
  674. if (!node) return
  675. onPick({
  676. x: event.clientX,
  677. y: event.clientY,
  678. kind: node.dataset.markerOverlayKind,
  679. code: node.dataset.markerOverlayCode,
  680. key: node.dataset.markerOverlayKey,
  681. })
  682. })
  683. // 标记节点上的按下/抬起不再冒泡到宿主:宿主的 attachPointPick 会对同一次点击**再拾一遍**
  684. // (双派发)。点选统一走上面的显式触发器。pointermove 不拦——hover 高亮与 tooltip 链路照常。
  685. const stopOnMarker = (event) => {
  686. if (event.target && event.target.closest && event.target.closest(`.${NODE_CLASS}`)) {
  687. event.stopPropagation()
  688. }
  689. }
  690. hostNode.addEventListener('pointerdown', stopOnMarker)
  691. hostNode.addEventListener('pointerup', stopOnMarker)
  692. // 票 76(C6):DOM 节点 hover 派发 —— billboard 视觉让位后 GPU 拾取没有命中对象,
  693. // hover(设备反应量/面板高亮/tooltip)改由 overlay 按**节点身份**通知服务层。
  694. // 指针不在任何节点上时发 `null`,服务层据此清空 hover 态。
  695. const emitHover = (event) => {
  696. if (typeof onHover !== 'function') return
  697. const node = event.target && event.target.closest ? event.target.closest(`.${NODE_CLASS}`) : null
  698. if (!node) {
  699. hoveredLabelKey = ''
  700. onHover(null)
  701. return
  702. }
  703. // 2026-10-28:hover 的节点 key 一并记下 —— 独立图层里的标签靠它决定「这一台是否展开全名」。
  704. hoveredLabelKey = node.dataset ? (node.dataset.markerOverlayKey || '') : ''
  705. onHover({
  706. kind: node.dataset.markerOverlayKind || '',
  707. code: node.dataset.markerOverlayCode || '',
  708. })
  709. }
  710. hostNode.addEventListener('pointermove', emitHover)
  711. hostNode.addEventListener('pointerleave', () => {
  712. hoveredLabelKey = ''
  713. if (typeof onHover === 'function') onHover(null)
  714. })
  715. mapHost.appendChild(hostNode)
  716. hostRecord = { host: mapHost }
  717. return true
  718. } catch {
  719. return false
  720. }
  721. }
  722. /** overlay 是否已挂载。 */
  723. export function isMarkerOverlayMounted() {
  724. return !!(hostNode && hostNode.isConnected)
  725. }
  726. // 票 76(C6):`setMarkerOverlayVisible()` 与 `?overlay=0` 已退役(票面「非目标」的收口项)——
  727. // 载体终裁 DOM 胜出后不再需要 billboard 对照组,第 ⑤ 条验收「点选统一走 overlay click 触发器」
  728. // 也因此不再有需要 GPU 拾取的第二态。
  729. /** overlay 是否在生效(票 76 / C6:挂载即生效,不再有 A/B 显隐态)。 */
  730. export function isMarkerOverlayVisible() {
  731. return isMarkerOverlayMounted()
  732. }
  733. /**
  734. * 每帧同步(由 mapService 的 postRender 驱动):
  735. * 投影 + transform 更新 + 屏外隐藏 + 池复用。**不重建节点**(指纹变化才重建)。
  736. * @param {object} params
  737. * @param {Array} params.markers `planPointMarkers().markers`(含 industry/tone/plan 消费字段)
  738. * @param {Array} [params.devices] 关联设备行(`projectDeviceMarkers()` 产出:屏幕坐标直供,
  739. * scale/alpha 已含选中/hover/弱化反应量——DOM 镜像 billboard 实体真值,防「小一圈」)
  740. * @param {Map} [params.pointScales] `kind|code` → 强调 scale(票 76 / C3:由**服务层**产出,
  741. * 1 = 常态、`EMPHASIS_STYLE.markerScale` = 强调;不再回读 billboard 实体)
  742. * @param {object} params.tones token 色表(tone 键 → CSS 颜色)
  743. * @param {Function} params.planOf marker → plan(markerPlan.planMarker 的包装)
  744. * @param {Function} [params.effectOf] marker → 门控后的动效键('' = 不动画;mapService 从
  745. * domain `planDeviceEffects` 计划产出,只给摘要标记行;关联设备使用 `deviceEffectOf`)
  746. * @param {Function} [params.facilityEffectOf] 设施点行 → 门控后的动效键(维护者 2026-11-06:
  747. * 设施点特效与设备同源,同样由 domain 计划判定开关/降级,渲染层不自行发明)
  748. * @param {Array} [params.aggregates] 重叠聚合符号行(票 76 / C1:`projectOverlapSymbols()` 产出;
  749. * group 身份/计数/组内状态词/形状/色键/屏幕坐标 —— 唯一没有设备身份的一类节点)
  750. * @param {Function} params.projectToWindow (lon, lat) → 宿主相对窗口坐标(贴地口径由调用方保证)
  751. */
  752. /**
  753. * 计划内但本轮画不出来的节点(坐标非法 / 投影失败)也要隐藏。
  754. *
  755. * 为什么必须显式隐藏:它们仍在 `wanted` 里,不会被池级隐藏循环收走;若继续 `continue`,
  756. * 节点会停在最后一次成功投影的位置 —— 与"被筛掉却定格"是同一类幽灵标记缺陷。
  757. */
  758. function hidePooledNode(key) {
  759. const record = pool.get(key)
  760. if (!record) return
  761. hideRecord(record)
  762. }
  763. /** 隐藏一条记录(节点 + 标签,标签在独立图层里必须一起收)。 */
  764. function hideRecord(record) {
  765. if (!record) return
  766. if (record.root && record.root.style.display !== 'none') record.root.style.display = 'none'
  767. if (record.labelWrap && record.labelWrap.style.display !== 'none') record.labelWrap.style.display = 'none'
  768. }
  769. /** 销毁一条记录的 DOM(节点 + 标签锚点)。重建时必须两个都清,否则标签会留在图层里变幽灵。 */
  770. function destroyRecord(record) {
  771. if (!record) return
  772. if (record.root && record.root.parentNode) record.root.parentNode.removeChild(record.root)
  773. if (record.labelWrap && record.labelWrap.parentNode) record.labelWrap.parentNode.removeChild(record.labelWrap)
  774. }
  775. /**
  776. * 标签显隐与「hover 展开全名」(2026-10-28 维护者规格)。
  777. *
  778. * 规则只有一个出口,调用方按**状态**给两个布尔量,渲染层不自己判状态:
  779. * · 聚合点:① 常态显示;① hover 预览 / ② 被选择 / ③④ 被替换 → 隐藏。
  780. * · 圈内设备(fan):常态一律隐藏;**只有被 hover 的那一台**显示(且展开为全名)。
  781. * · 中心设备:③ 设备常态显示;④ 设备被选择隐藏(与 ② 同性质)。
  782. * · 非聚合设备点(k=1 关联设备 / 独立未关联设备):**始终显示**(截断),hover 展开全名。
  783. */
  784. function applyLabelState(record, visible, expanded) {
  785. if (!record || !record.labelWrap || !record.labelBox) return
  786. const wantedDisplay = visible === true ? 'block' : 'none'
  787. if (record.labelWrap.style.display !== wantedDisplay) record.labelWrap.style.display = wantedDisplay
  788. if (!visible) return
  789. const wantedWidth = expanded === true ? 'none' : record.labelMaxWidth
  790. if (record.labelBox.style.maxWidth !== wantedWidth) record.labelBox.style.maxWidth = wantedWidth
  791. }
  792. /** 标签跟随节点位移(与节点同一次 transform;不逐帧读布局)。 */
  793. function placeLabel(record, x, y) {
  794. if (!record || !record.labelWrap) return
  795. const transform = `translate(${x}px, ${y}px)`
  796. if (record.labelWrap.style.transform !== transform) record.labelWrap.style.transform = transform
  797. }
  798. // ================================================================ 标签判据(纯函数,唯一住址)
  799. //
  800. // 2026-10-28 维护者给出的状态机把标签规则讲清楚了,而这块此前在三轮走查里反复翻烧饼
  801. // (圈内一律不建 → 圈内也要 → 常态隐藏、hover 显示)。所以判据**抽成纯函数**:
  802. // 渲染层只调用,不在模板/CSS 里再写一遍;闸门可以离线断言,不必起浏览器。
  803. //
  804. // | 对象 | 常态 | hover | 备注 |
  805. // | --- | --- | --- | --- |
  806. // | 聚合点(聚合标签) | ① 显示 | 隐藏(并展开预览环) | ② 固定 / ③④ 被替换也隐藏 |
  807. // | 圈内设备(fan) | 隐藏 | **显示全名**(仅被 hover 的那一台) | 环内密集,常态不显示 |
  808. // | 中心设备 | ③ 显示 | 显示全名 | ④ 被选择 → 隐藏(与 ② 同性质) |
  809. // | 非聚合设备点 / 点位 / 未关联设备 | 显示(截断) | 显示全名 | 聚合规则不得外溢到这里 |
  810. /**
  811. * 聚合点标签:只有 ① 常态(未预览、未固定、未被设备替换)才显示。
  812. * @param {{pinned?:boolean, preview?:boolean, replaced?:boolean}} row 引擎投影行
  813. */
  814. export function aggregateLabelState(row) {
  815. const item = row || {}
  816. const visible = item.pinned !== true && item.preview !== true && item.replaced !== true
  817. return { visible, expanded: false }
  818. }
  819. /**
  820. * 关联设备点标签。
  821. * @param {{fan?:boolean, pinned?:boolean}} device 引擎投影行
  822. * @param {boolean} hovered 指针是否在该节点上
  823. */
  824. export function deviceLabelState(device, hovered) {
  825. const item = device || {}
  826. if (item.fan === true) return { visible: hovered === true, expanded: hovered === true }
  827. if (item.pinned === true) return { visible: false, expanded: false }
  828. return { visible: true, expanded: hovered === true }
  829. }
  830. /** 点位 / 未关联设备(摘要标记):**始终显示**,hover 展开全名。 */
  831. export function markerLabelState(hovered) {
  832. return { visible: true, expanded: hovered === true }
  833. }
  834. export function syncMarkerOverlay({ markers, devices, aggregates, facilities, selectedFacilityKey, pipeComponents, pipeComponentsVisible, pointScales, tones, planOf, facilityPlanOf, componentPlanOf, effectOf, deviceEffectOf, facilityEffectOf, converge, selectedCode, projectToWindow }) {
  835. if (!hostNode || !hostNode.isConnected || typeof projectToWindow !== 'function') return
  836. // 每帧刷新门控查询(mapService 在计划变化时给新闭包;这里只搬运、不判定)。
  837. frameEffectOf = typeof effectOf === 'function' ? effectOf : () => ''
  838. frameDeviceEffectOf = typeof deviceEffectOf === 'function' ? deviceEffectOf : () => ''
  839. frameFacilityEffectOf = typeof facilityEffectOf === 'function' ? facilityEffectOf : () => ''
  840. frameConverge = converge || null
  841. // 当前选中的对象编码(2026-10-28):**非聚合设备点**选中时要上「被选择态」同款高亮,
  842. // 而聚合设备的中心由引擎的 pinned 标志负责;两者语义不同,故这里另收一份。
  843. markerSelectedCode = typeof selectedCode === 'string' ? selectedCode : ''
  844. // 票 35 卡顿整改:Set 复用(原先每帧 new 一个);指纹改成廉价拼接(原先每标记
  845. // JSON.stringify 一次,纯分配)。DOM 写入也改成"只有真的变了才写"。
  846. wantedKeys.clear()
  847. const wanted = wantedKeys
  848. // 设备行坐标是**窗口坐标**(引擎投影真值):转宿主相对要减宿主(overlay 挂载父级)的
  849. // 窗口原点。每帧读一次——宿主可能随布局移动,不能在挂载时缓存。
  850. const hostRect = hostNode.parentElement && hostNode.parentElement.getBoundingClientRect
  851. ? hostNode.parentElement.getBoundingClientRect()
  852. : { left: 0, top: 0 }
  853. const hostOffset = { x: hostRect.left, y: hostRect.top }
  854. for (const marker of markers || []) {
  855. const key = `${marker.kind}|${marker.code}`
  856. wanted.add(key)
  857. if (!Number.isFinite(marker.longitude) || !Number.isFinite(marker.latitude)) { hidePooledNode(key); continue }
  858. const windowPos = projectToWindow(marker.longitude, marker.latitude)
  859. if (!windowPos || !Number.isFinite(windowPos.x) || !Number.isFinite(windowPos.y)) { hidePooledNode(key); continue }
  860. const plan = planOf(marker)
  861. const toneHex = tones[marker.tone] || tones.neutral
  862. // 票 70:fxOf 的键取自 effectOf 门控查询(domain 计划已判开关/降级);profile 取状态。
  863. const fx = fxOf(marker)
  864. // 计划读数指纹:状态/行业/名称/母版/内层/降饱和/色值/**动效键与层数**任一变化就重建节点。
  865. const fp = [marker.status, marker.industry, marker.name,
  866. plan.master, plan.inner.enabled, plan.inner.icon, plan.inner.diameterRatio,
  867. plan.desaturate, toneHex, fx ? fx.effect : '', fx ? fx.profile.layers : 0,
  868. fx?.converge?.peak ?? 1, fx?.converge?.alpha ?? 1].join(FP_SEP)
  869. let record = pool.get(key)
  870. if (!record || record.fp !== fp) {
  871. destroyRecord(record)
  872. record = { ...buildNode(key, marker, plan, toneHex, fx), fp }
  873. pool.set(key, record)
  874. hostNode.appendChild(record.root)
  875. if (plan.inner.enabled) {
  876. // 内层素材:与 billboard 同一去色重染规则的原生分辨率 data URL(缓存复用)。
  877. // 竞态防护:加载回来时池记录可能已被更新的状态重建——只在仍是本节点时写入。
  878. const mine = record
  879. renderInnerIconUrl(plan.inner.icon)
  880. .then((dataUrl) => {
  881. if (pool.get(key) === mine) mine.inner.setAttribute('href', dataUrl)
  882. })
  883. .catch(() => {
  884. // 内层素材失败:只有母版水滴(降级可见),不阻塞其余标记。
  885. })
  886. }
  887. }
  888. // 坐标只在真的变了才写(镜头静止时零 DOM 写入)。
  889. const transform = `translate(${windowPos.x}px, ${windowPos.y}px)`
  890. if (record.root.style.transform !== transform) record.root.style.transform = transform
  891. // 强调/还原的缩放(票 76 / C3):量由服务层产出(不再回读 billboard.scale),
  892. // DOM 围绕尖端缩放该量(transformOrigin=尖端,见 buildNode)。
  893. const markerScale = pointScales instanceof Map ? pointScales.get(key) : undefined
  894. const scaleTransform = markerScale && markerScale !== 1 ? `scale(${markerScale})` : ''
  895. if (record.svg.style.transform !== scaleTransform) record.svg.style.transform = scaleTransform
  896. // 票 84:选中态环整体放大 —— boost 与图标强调共用 EMPHASIS_STYLE.markerScale(同一个 1.55)。
  897. if (record.fxHost) {
  898. const base = Number(record.fxHost.dataset.fxBaseScale || 1)
  899. const factor = base * (markerScale && markerScale !== 1 ? markerScale : 1)
  900. const fxTransform = factor !== 1 ? `scale(${factor})` : ''
  901. if (record.fxHost.style.transform !== fxTransform) record.fxHost.style.transform = fxTransform
  902. }
  903. if (record.root.style.display !== 'block') record.root.style.display = 'block'
  904. // 标签(2026-10-28):点位与未关联设备**始终显示**(截断),hover 才展开全名;
  905. // 若它是当前选中对象(独立设备被选中)则由下面的 `--active` 正규负责发光,标签仍在。
  906. placeLabel(record, windowPos.x, windowPos.y)
  907. const markerLabel = markerLabelState(hoveredLabelKey === key)
  908. applyLabelState(record, markerLabel.visible, markerLabel.expanded)
  909. // 选择态发光(2026-10-28 维护者规格④):**非聚合的设备点**(独立未关联设备)被选中时,
  910. // 用与「被选择态」同款的 --active 高亮。聚合设备中心由设备循环的 pinned 负责。
  911. const markerActive = marker.kind === 'unassociated-equipment' && markerSelectedCode !== '' && marker.code === markerSelectedCode
  912. record.root.classList.toggle(`${NODE_CLASS}--active`, markerActive)
  913. }
  914. // 关联设备(票 47 维护者反馈②根因:设备是独立 billboard 图层,此前 DOM 只渲染
  915. // 摘要标记计划、关联设备根本没进 DOM)。行由引擎每帧投影产出(屏幕坐标真值 +
  916. // 反应量真值),形态走与摘要未关联设备同一套合成计划(实心母版 + 内层行业图标)。
  917. for (const device of devices || []) {
  918. const key = device.key || `device|${device.code}`
  919. wanted.add(key)
  920. if (!Number.isFinite(device.x) || !Number.isFinite(device.y)) { hidePooledNode(key); continue }
  921. // 2026-10-27 维护者走查修正:圈内子设备点**也要顶部标签**(此前被置空 + CSS 隐藏,
  922. // 表现为"关联监测点的子设备点没有名称")。标签与普通设备点同一条渲染路径。
  923. const marker = {
  924. kind: 'associated-device',
  925. code: device.code,
  926. // 2026-10-27 走查修正:子设备点(fan)也要顶部标签 —— 不再清空名称。
  927. name: device.name || '',
  928. status: device.status || '',
  929. industry: device.industry || '',
  930. }
  931. const plan = planOf(marker)
  932. // 票 76(2026-10-26):色键由**设备状态**决定(domain 单源)——设备行不单独携带 tone,
  933. // 否则状态色与摘要标记两套口径。
  934. const toneHex = tones[deviceVisualForStatus(device.status).tone] || tones.neutral
  935. // 票 84(取代票 76 C6 的「只有中心设备挂环」):关联设备按**自身状态**挂环,
  936. // 且必须走与摘要标记同一条**门控**(`frameDeviceEffectOf`:关特效/降级时一律不挂)。
  937. // 聚合符号仍恒不挂环(聚合是"一组",不冒充设备告警身份)。
  938. // 状态 → profile 的唯一入口是 `effectProfileFor()`(domain 单源)。
  939. const gatedEffect = frameDeviceEffectOf(device)
  940. const profile = gatedEffect ? effectProfileFor(device.status) : null
  941. const fx = profile && profile.effect === gatedEffect ? { effect: gatedEffect, profile, converge: frameConverge } : null
  942. const fp = [marker.status, marker.industry, marker.name,
  943. plan.master, plan.inner.enabled, plan.inner.icon, plan.inner.diameterRatio,
  944. plan.desaturate, toneHex, fx ? fx.effect : '', fx ? fx.profile.layers : 0,
  945. fx?.converge?.peak ?? 1, fx?.converge?.alpha ?? 1].join(FP_SEP)
  946. let record = pool.get(key)
  947. if (!record || record.fp !== fp) {
  948. destroyRecord(record)
  949. record = { ...buildNode(key, marker, plan, toneHex, fx), fp }
  950. pool.set(key, record)
  951. hostNode.appendChild(record.root)
  952. if (plan.inner.enabled) {
  953. const mine = record
  954. renderInnerIconUrl(plan.inner.icon)
  955. .then((dataUrl) => {
  956. if (pool.get(key) === mine) mine.inner.setAttribute('href', dataUrl)
  957. })
  958. .catch(() => {})
  959. }
  960. }
  961. // 坐标 = 引擎投影的窗口坐标(含 pixelOffset 尖端补偿),只差宿主原点一层。
  962. const deviceTransform = `translate(${device.x - hostOffset.x}px, ${device.y - hostOffset.y}px)`
  963. if (record.root.style.transform !== deviceTransform) record.root.style.transform = deviceTransform
  964. // 反应量镜像:scale 读的是 billboard 实体真值(选中 1.6/hover 1.3/弱化 0.75),
  965. // 弱化的压暗同理镜像 alpha(只写透明度,不动色相——色键语义归设备状态)。
  966. // 中心设备与聚合点**同尺寸**(走查:选中设备后一直保持放大、且丢失选择态);其余设备照旧镜像。
  967. const effScale = device.selected ? 1 : device.scale
  968. const deviceScale = effScale && effScale !== 1 ? `scale(${effScale})` : ''
  969. if (record.svg.style.transform !== deviceScale) record.svg.style.transform = deviceScale
  970. const deviceOpacity = device.alpha !== undefined && device.alpha !== 1 ? String(device.alpha) : ''
  971. if (record.root.style.opacity !== deviceOpacity) record.root.style.opacity = deviceOpacity
  972. // 票 84:选中(中心)设备的环整体放大 —— 与图标强调共用同一个 1.55 常量。
  973. if (record.fxHost) {
  974. const base = Number(record.fxHost.dataset.fxBaseScale || 1)
  975. const factor = base * (device.selected ? markerDevKnobs().selectedBoost : 1)
  976. const fxTransform = factor !== 1 ? `scale(${factor})` : ''
  977. if (record.fxHost.style.transform !== fxTransform) record.fxHost.style.transform = fxTransform
  978. }
  979. if (record.root.style.display !== 'block') record.root.style.display = 'block'
  980. // ==================== 标签与选择态(2026-10-28 维护者状态机)====================
  981. // 标签规则(唯一出口 applyLabelState;这里只按状态给两个布尔量):
  982. // · 圈内设备(fan,处于「环可见」的 ①hover预览/②固定/③hover预览/④固定」):
  983. // 常态**一律隐藏**,只有被 hover 的那一台显示并展开全名 —— 你报的「环展开时顶部标签没隐藏」。
  984. // · ③ 设备常态的中心设备:显示(它此时就是一枚正常设备点);hover 展开全名。
  985. // · ④ 设备被选择的中心设备:隐藏(与 ② 聚合被选择态同性质)。
  986. // · 非聚合设备点(k=1 关联设备):**始终显示** —— 聚合规则不得外溢到它们。
  987. placeLabel(record, device.x - hostOffset.x, device.y - hostOffset.y)
  988. const isHovered = hoveredLabelKey === key
  989. const deviceLabel = deviceLabelState(device, isHovered)
  990. applyLabelState(record, deviceLabel.visible, deviceLabel.expanded)
  991. // 圈内设备标记(CSS/走查读这个类;标签显隐已由上面统一驱动,不再靠 CSS 后代选择器)。
  992. record.root.classList.toggle(`${NODE_CLASS}--fan`, device.fan === true)
  993. // 票 76(四态规格 2026-10-26)设备节点:
  994. // ③ 设备常态 中心=设备点,无发光,环只在 hover 预览(标签常态显示)
  995. // ④ 设备被选择态 与聚合点被选择态相同:状态色发光 + 隐藏自身标签
  996. // 2026-10-28:非聚合设备点(k=1 关联设备)被选中时也要上**同款高亮** ——
  997. // 走的是「设备选择事件」这条统一逻辑,与聚合规则无关。
  998. // 注意排除 `device.selected`(③ 的中心设备**正是**当前选中对象,但它不是选择态、不发光)、
  999. // 以及 `fan`(圈内设备的高亮由所在组的 `pinned` 决定)。
  1000. const deviceActive = device.pinned === true
  1001. || (markerSelectedCode !== '' && device.code === markerSelectedCode && device.selected !== true && device.fan !== true)
  1002. record.root.classList.toggle(`${NODE_CLASS}--active`, deviceActive)
  1003. // 票 76(四态,2026-10-26):③④ 的中心设备要**压过聚合节点**(聚合节点后追加、会吃掉点击),
  1004. // 否则「点中心设备进 ④」永远触发不了(走查第 3 步完全失效的直接原因)。
  1005. record.root.classList.toggle(`${NODE_CLASS}--center`, device.selected === true)
  1006. }
  1007. // 重叠聚合符号(票 76 / C1:billboard 版只在 billboard 层,DOM 此前完全没有——票 47 遗留)。
  1008. // 坐标同为引擎投影的窗口坐标,只差宿主原点一层。点击由宿主既有触发器派发(kind=overlap-aggregate)。
  1009. for (const row of aggregates || []) {
  1010. const key = row.key || `overlap|${row.pointCode}|${row.group}`
  1011. wanted.add(key)
  1012. if (!Number.isFinite(row.x) || !Number.isFinite(row.y)) { hidePooledNode(key); continue }
  1013. const toneHex = tones[row.tone] || tones.neutral
  1014. // 票 76(统一设备点位渲染):聚合符号也走 `planOf` 合成计划(母版/内层/几何与设备点同源),
  1015. // 不再用旧的形状表(三角形图标 = 废弃渲染模式);计划读数纳入指纹,变化才重建。
  1016. const plan = planOf({ kind: 'monitoring-device', status: row.status, industry: '', tone: row.tone })
  1017. const fp = [
  1018. row.k, row.label, row.status, row.state, toneHex,
  1019. plan.master, plan.inner.enabled, plan.inner.icon, plan.inner.diameterRatio, plan.desaturate,
  1020. ].join(FP_SEP)
  1021. let record = pool.get(key)
  1022. if (!record || record.fp !== fp) {
  1023. destroyRecord(record)
  1024. record = { ...buildAggregateNode(key, row, toneHex, plan), fp }
  1025. pool.set(key, record)
  1026. hostNode.appendChild(record.root)
  1027. }
  1028. const aggTransform = `translate(${row.x - hostOffset.x}px, ${row.y - hostOffset.y}px)`
  1029. if (record.root.style.transform !== aggTransform) record.root.style.transform = aggTransform
  1030. // 状态强调镜像(billboard `scale` / `outlineWidth` 真值)。
  1031. // 票 76(四态):② 被选择态 = `pinned`(聚合点固定环 + 发光 + 略放大);
  1032. // ① 的 hover 预览**不算**选择态,不放大也不发光。
  1033. const active = row.pinned === true
  1034. const activeBoost = active ? 1.12 : 1
  1035. const effAggScale = (row.scale && row.scale > 0 ? row.scale : 1) * activeBoost
  1036. const aggScale = effAggScale !== 1 ? `scale(${effAggScale})` : ''
  1037. if (record.icon.style.transform !== aggScale) record.icon.style.transform = aggScale
  1038. const aggFilter = row.outline ? 'drop-shadow(0 0 2px rgba(255,255,255,0.9))' : ''
  1039. if (record.icon.style.filter !== aggFilter) record.icon.style.filter = aggFilter
  1040. record.root.classList.toggle(`${NODE_CLASS}--active`, active)
  1041. // 票 76(四态):① 的 hover 预览与 ② 的固定态都隐藏聚合标签(让出设备环);
  1042. // ③④ 中心已是设备点 → 整枚聚合节点让位(标签随之隐藏)。
  1043. // 2026-10-28:标签在**独立图层**,显隐由 `applyLabelState` 统一驱动(不再内联查 DOM 子元素)。
  1044. placeLabel(record, row.x - hostOffset.x, row.y - hostOffset.y)
  1045. const aggregateLabel = aggregateLabelState(row)
  1046. applyLabelState(record, aggregateLabel.visible, aggregateLabel.expanded)
  1047. record.root.style.display = row.replaced ? 'none' : 'block'
  1048. // ③④ 中心已是设备点:聚合节点虽然隐藏,但保险起见再降掉 pointer-events,
  1049. // 避免它(或其子元素)在过渡帧里吃掉对中心设备的点击。
  1050. record.root.classList.toggle(`${NODE_CLASS}--replaced`, row.replaced === true)
  1051. }
  1052. // 设施点与点位/设备共用节点池,点击派发完整的节点 key;不可点身份不挂指针交互。
  1053. // 计划行已由 store 按设施类型 + 全局行业/片区筛好;这里只按一级模式显隐(计划层收口)。
  1054. for (const facility of facilities || []) {
  1055. const key = facility.key || `facility-point|${facility.code}`
  1056. wanted.add(key)
  1057. if (!Number.isFinite(facility.longitude) || !Number.isFinite(facility.latitude)) { hidePooledNode(key); continue }
  1058. const windowPos = projectToWindow(facility.longitude, facility.latitude)
  1059. if (!windowPos || !Number.isFinite(windowPos.x) || !Number.isFinite(windowPos.y)) { hidePooledNode(key); continue }
  1060. const marker = { ...facility, kind: 'facility-point' }
  1061. const plan = facilityPlanOf(marker)
  1062. const toneHex = tones[plan.tint] || tones.neutral
  1063. // 设施与设备同样校验「状态 profile + 门控键」,不能从门控键反推状态自证。
  1064. // 降级只收敛,不撤销环;系数由跨图层计划提供。
  1065. const gatedEffect = frameFacilityEffectOf(facility)
  1066. const facilityProfile = gatedEffect ? effectProfileFor(plan.effectStatus) : null
  1067. const fx = facilityProfile && facilityProfile.effect === gatedEffect
  1068. ? { effect: gatedEffect, profile: facilityProfile, converge: frameConverge }
  1069. : null
  1070. // 指纹含设施类型/名称/状态与合成计划读数:任一变化就重建节点(与点位标记同一条纪律)。
  1071. const fp = [marker.type, marker.status, marker.name, marker.clickable,
  1072. plan.master, plan.inner.enabled, plan.inner.icon, plan.inner.diameterRatio, toneHex,
  1073. fx ? fx.effect : '', fx ? fx.profile.layers : 0,
  1074. fx?.converge?.peak ?? 1, fx?.converge?.alpha ?? 1].join(FP_SEP)
  1075. let record = pool.get(key)
  1076. if (!record || record.fp !== fp) {
  1077. destroyRecord(record)
  1078. record = { ...buildNode(key, marker, plan, toneHex, fx), fp }
  1079. pool.set(key, record)
  1080. hostNode.appendChild(record.root)
  1081. if (plan.inner.enabled) {
  1082. const mine = record
  1083. renderInnerIconUrl(plan.inner.icon)
  1084. .then((dataUrl) => {
  1085. if (pool.get(key) === mine) mine.inner.setAttribute('href', dataUrl)
  1086. })
  1087. .catch(() => {
  1088. // 内层素材失败:只剩母版水滴(降级可见),不阻塞其它设施点。
  1089. })
  1090. }
  1091. }
  1092. const transform = `translate(${windowPos.x}px, ${windowPos.y}px)`
  1093. if (record.root.style.transform !== transform) record.root.style.transform = transform
  1094. if (record.root.style.display !== 'block') record.root.style.display = 'block'
  1095. placeLabel(record, windowPos.x, windowPos.y)
  1096. const facilityLabel = markerLabelState(hoveredLabelKey === key)
  1097. applyLabelState(record, facilityLabel.visible, facilityLabel.expanded)
  1098. record.root.classList.toggle(`${NODE_CLASS}--active`, selectedFacilityKey === key)
  1099. }
  1100. // 管线构件点位(票 101):阀门/窨井/特殊管点等。地面视图用 DOM 点位标记
  1101. // (与设备/设施同一套节点池与合成计划,行业色由领域层随行下发);管线视图的
  1102. // 3D 模型 / Cesium 兜底点由管线图层自己管(`pipeComponentsVisible` 关掉本循环)。
  1103. // 点击派发回**管线拾取链**(局部增强),不进设施详情 —— 构件不是设施实体。
  1104. for (const component of pipeComponentsVisible === false ? [] : (pipeComponents || [])) {
  1105. const key = component.key || `pipe-component|${component.code}`
  1106. wanted.add(key)
  1107. if (!Number.isFinite(component.longitude) || !Number.isFinite(component.latitude)) { hidePooledNode(key); continue }
  1108. const windowPos = projectToWindow(component.longitude, component.latitude)
  1109. if (!windowPos || !Number.isFinite(windowPos.x) || !Number.isFinite(windowPos.y)) { hidePooledNode(key); continue }
  1110. const marker = { ...component, kind: 'pipe-component' }
  1111. const plan = componentPlanOf(marker)
  1112. // 行业色随行下发(`toneHex`,与管线图层同一份 PIPE_PALETTE);缺行色回退中性。
  1113. const toneHex = marker.toneHex || tones[plan.tint] || tones.neutral
  1114. const fp = [marker.componentKind, marker.industry, marker.name,
  1115. plan.master, plan.inner.enabled, plan.inner.icon, toneHex].join(FP_SEP)
  1116. let record = pool.get(key)
  1117. if (!record || record.fp !== fp) {
  1118. destroyRecord(record)
  1119. record = { ...buildNode(key, marker, plan, toneHex, null), fp }
  1120. pool.set(key, record)
  1121. hostNode.appendChild(record.root)
  1122. if (plan.inner.enabled) {
  1123. const mine = record
  1124. renderInnerIconUrl(plan.inner.icon)
  1125. .then((dataUrl) => {
  1126. if (pool.get(key) === mine) mine.inner.setAttribute('href', dataUrl)
  1127. })
  1128. .catch(() => {
  1129. // 内层素材失败:只剩母版水滴(降级可见),不阻塞其它构件点。
  1130. })
  1131. }
  1132. }
  1133. const transform = `translate(${windowPos.x}px, ${windowPos.y}px)`
  1134. if (record.root.style.transform !== transform) record.root.style.transform = transform
  1135. if (record.root.style.display !== 'block') record.root.style.display = 'block'
  1136. placeLabel(record, windowPos.x, windowPos.y)
  1137. const componentLabel = markerLabelState(hoveredLabelKey === key)
  1138. applyLabelState(record, componentLabel.visible, componentLabel.expanded)
  1139. }
  1140. // 本轮不在计划里的:隐藏(池复用,不销毁)。
  1141. // ⚠️ 这一轮**必须**在所有计划遍历之后:放进某个循环里会漏掉「点位/设备被筛掉」的节点,
  1142. // 它们会停在最后一次投影位置变成幽灵标记(2026-10-27 实测缺陷,勿再合并进循环)。
  1143. for (const [key, record] of pool.entries()) {
  1144. if (!wanted.has(key)) hideRecord(record)
  1145. }
  1146. }
  1147. /**
  1148. * 挂 postRender 驱动(mapService 调用;返回卸载函数)。
  1149. * `getFrameState` 每帧由 mapService 提供最新标记计划与投影函数。
  1150. */
  1151. export function attachOverlayRenderer(viewer, getFrameState) {
  1152. if (!viewer || !viewer.scene) return () => {}
  1153. const onFrame = () => {
  1154. const state = getFrameState()
  1155. if (state) syncMarkerOverlay(state)
  1156. }
  1157. viewer.scene.postRender.addEventListener(onFrame)
  1158. postRenderRemove = () => viewer.scene.postRender.removeEventListener(onFrame)
  1159. return postRenderRemove
  1160. }
  1161. /** overlay 节点池(调试/断言用)。 */
  1162. export function overlayPoolSize() {
  1163. return pool.size
  1164. }
  1165. /** 某个池节点的 display 读数('' / 'block' / 'none')——幽灵标记回归闸门读它。 */
  1166. export function overlayNodeDisplayOf(key) {
  1167. const record = pool.get(key)
  1168. return record ? String(record.root.style.display || '') : ''
  1169. }
  1170. /** 某个池节点的标签文本(子设备点标签回归闸门读它)。 */
  1171. export function overlayNodeLabelTextOf(key) {
  1172. const record = pool.get(key)
  1173. if (!record) return ''
  1174. // 2026-10-28:标签已搬进独立图层(`record.labelBox`),不再挂在节点内部。
  1175. return record.labelBox ? String(record.labelBox.textContent || '') : ''
  1176. }
  1177. /**
  1178. * 某个池节点标签的**显隐读数**('' 表示从未设置 / 'block' / 'none')。
  1179. * 走查与闸门用它断言「环展开时圈内设备标签确实隐藏、hover 那一台才显示」。
  1180. */
  1181. export function overlayLabelDisplayOf(key) {
  1182. const record = pool.get(key)
  1183. return record && record.labelWrap ? String(record.labelWrap.style.display || '') : ''
  1184. }
  1185. /** 某个池节点标签是否处于**展开全名**(max-width 放开)状态。 */
  1186. export function overlayLabelExpandedOf(key) {
  1187. const record = pool.get(key)
  1188. return Boolean(record && record.labelBox && record.labelBox.style.maxWidth === 'none')
  1189. }
  1190. /** 卸载(离开 /cockpit 用):摘 postRender、清节点池与宿主、移除特效样式(t45 C3b 生命周期)。 */
  1191. export function unmountMarkerOverlay() {
  1192. if (postRenderRemove) {
  1193. postRenderRemove()
  1194. postRenderRemove = null
  1195. }
  1196. if (typeof document !== 'undefined') {
  1197. const style = document.getElementById(FX_STYLE_ID)
  1198. if (style && style.parentNode) style.parentNode.removeChild(style)
  1199. }
  1200. if (hostNode && hostNode.parentNode) hostNode.parentNode.removeChild(hostNode)
  1201. hostNode = null
  1202. hostRecord = null
  1203. // 标签图层与 hover 记忆一并清掉(宿主已移除,引用留着只会拖住 DOM)。
  1204. labelLayer = null
  1205. hoveredLabelKey = ''
  1206. markerSelectedCode = ''
  1207. pool.clear()
  1208. }