markerOverlay.js 45 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808
  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 外扩 40 与 billboard 裁剪窗(OUTLINE_PAD)同形。
  10. * · **内层行业图标 = 素材**(`markerAssets.renderInnerIconUrl`:与 billboard 同一
  11. * `recolorInner` 去色重染规则,但**不下采样**——256px 原生分辨率 data URL,
  12. * DOM 里按帽径向下采样,永远锐利)。
  13. * · 标签 = 真 DOM(圆角底板、装饰边、text-overflow、hover title 原生能力)。
  14. * · 每帧 `postRender` 投影:与 billboard `CLAMP_TO_GROUND` 同口径(地形实测高程,取不到回椭球 0)。
  15. *
  16. * 与既有链路的关系(维护者裁定:DOM 完整复刻 billboard 功能后才替换它):
  17. * · 合成计划层 `domain/markerPlan.js` **原样复用**(归属/几何/锚点/降饱和不换媒介);
  18. * · `syncPointMarkersForPlan`(billboard 链路)**保留不动**——聚合、特效、Canvas 拾取仍依赖它;
  19. * 内层素材加载失败时 SVG 只有母版 = 底下 billboard 双层照常显示(降级可见);
  20. * · 拾取:overlay 节点挂**显式 click 触发器**,回调直接复用 `pickDeviceOrPointAt`
  21. * (与 Canvas 点选链同一个功能函数,维护者原话「照搬原本的功能函数」);
  22. * · `data-*` 断言面全部挂到 DOM 节点,走查不读内部实现。
  23. *
  24. * 性能纪律:节点池复用(不每帧重建);计划读数指纹变化才重建;postRender 只做 transform 更新。
  25. */
  26. import { renderInnerIconUrl } from './markerAssets.js'
  27. // 票 76(C1 → 统一渲染):聚合符号不再用旧形状表(`markerSvg(shape)` 的三角形/圆形图标已废弃),
  28. // 与标记/设备一样走自绘水滴母版;domain 的 `markerSvg` 导入随之移除。
  29. // 票 45:特效随标记 DOM 化 —— 周期/层数/放大倍数与 billboard 特效同一份 domain 常量(节奏单源)。
  30. // 票 70(重做):本行导入的每一枚都必须有消费者(不留死导入,票面 Acceptance 第 3 条)。
  31. import { DEVICE_EFFECT, effectProfileFor, RING_PERIOD_MS, GLOW_PERIOD_MS, EFFECT_PERIOD_MS } from '../domain/deviceEffects.js'
  32. // 票 76(2026-10-26):设备点的色键取设备状态词条(domain 单源),DOM 不自己映射状态→颜色。
  33. import { deviceVisualForStatus } from '../domain/deviceStatus.js'
  34. /**
  35. * 类名单源:注入的特效 CSS(下方模板串)与 DOM 构建(buildNode/mount)共用同一组常量。
  36. * 必须声明在 `FX_CSS` 之前——模板串在模块求值期就要读这些值(`const` 有 TDZ)。
  37. * 前三枚为票 47 既有值(原样保留,勿改:走查断言与样式钩子读这些类名)。
  38. */
  39. const OVERLAY_HOST_CLASS = 'marker-overlay-host'
  40. const NODE_CLASS = 'marker-overlay-node'
  41. const LABEL_CLASS = 'marker-overlay-label'
  42. const FX_CLASS = 'marker-overlay-fx'
  43. const FX_RING_CLASS = 'marker-overlay-fx-ring'
  44. const FX_GLOW_CLASS = 'marker-overlay-fx-glow'
  45. /**
  46. * 特效层样式(票 45):keyframes 无法用行内 cssText 表达,所以注入一次 <style>。
  47. * 环带 = 径向渐变(透明度 52% → 高亮 70% → 渐隐),对比更强烈;阴影更小但扩散节奏更快。
  48. * 锚点是**节点原点**即图标尖端落地位置(不是帽心),这样辐射环直接标记地面目标点。
  49. *
  50. * 票 70 走查反馈②(2026-10-21):扩散/旋转峰值统一读 `--fx-profile-scale`
  51. * (domain EFFECT_PROFILE 单源——预警此前 1.22 峰值探不出图标 = 「看不出动态」的根因);
  52. * 齿轮环 keyframes 的 scale 改为 profile 驱动(此前 0.9/1.12 写死,profile 的 scale/alpha 无人消费);
  53. * 中心辉光尺寸/亮度配合报警存在感提升。
  54. */
  55. const FX_CSS = `
  56. /* 票 76(标签规格,2026-10-26 终版):标签**常态显示**(监测点 / 普通设备点 / 中心设备 / 聚合点)。
  57. 圈内设备(--fan)**一律不显示顶部标签** —— 维护者裁定:
  58. · 环内设备密集,顶部标签互相压叠、还会被图案与特效淹没看不清;
  59. · 它们的完整名称由**右下角 tooltip**(paintMarkerTooltip,跟随鼠标)承担,一份就够。
  60. 因此这里不是「hover 才显示」,而是**根本没有**(连 hover 也不显示)。 */
  61. .${NODE_CLASS}--fan .${LABEL_CLASS} { display:none !important; }
  62. /* 选择态标识(2026-10-26 二次修正):发光用**点位本体颜色语义**(--marker-tone = 状态色),
  63. 不再用白光。两处都生效、两种进入方式共用:
  64. ① 聚合点被点开(选择态);
  65. ② 选中设备替换中心后,中心设备本身也是选择态(可再点回选择态)。
  66. --marker-tone 由节点构建时写入(见 buildNode),不靠 currentColor 兜底 ——
  67. 母版白色描边会让 currentColor 落成白色,这正是上一版「还是白光」的原因。 */
  68. .${NODE_CLASS}--active { z-index:2; }
  69. /* 票 76(四态,2026-10-26 修「第 3 步点不动」):聚合节点在设备节点**之后**追加到 DOM,
  70. 天然盖在中心设备上方 —— 聚合节点的 pointer-events:auto 会把对中心设备的点击全部吃掉。
  71. ③④ 的中心设备必须压过聚合节点,否则「点中心设备进 ④」永远不触发。
  72. 中心设备给比 --active 更高的层级,且聚合节点在中心存在时降为 pointer-events:none。 */
  73. .${NODE_CLASS}--center { z-index:3; }
  74. .${NODE_CLASS}--aggregate.${NODE_CLASS}--replaced { pointer-events:none; }
  75. .${NODE_CLASS}--active svg {
  76. filter:drop-shadow(0 0 6px var(--marker-tone, currentColor)) drop-shadow(0 0 14px var(--marker-tone, currentColor)) brightness(1.18);
  77. }
  78. /* 票 76(四态规格 2026-10-26):② 与 ④ 都是「被选择态」 —— 发光 + **隐藏自身标签**
  79. (② 隐藏聚合标签、④ 隐藏中心设备标签),hover 圈内设备才出全名。 */
  80. .${NODE_CLASS}--active .${LABEL_CLASS} { display:none; }
  81. .${NODE_CLASS}--active:hover .${LABEL_CLASS} { display:block; }
  82. .${FX_CLASS} { position:absolute; pointer-events:none; transform-origin:50% 50%; }
  83. .${FX_RING_CLASS} {
  84. position:absolute; left:50%; top:50%;
  85. border-radius:50%;
  86. background:radial-gradient(circle, rgba(0,0,0,0) 52%, currentColor 70%, rgba(0,0,0,0) 98%);
  87. transform:translate(-50%,-50%);
  88. animation:markerFxExpand var(--fx-period,1800ms) linear infinite;
  89. /* 减小静态光环,但增强扩散对比 */
  90. box-shadow:0 0 6px currentColor, 0 0 16px currentColor;
  91. will-change:transform,opacity;
  92. }
  93. .${FX_GLOW_CLASS} {
  94. position:absolute; left:50%; top:50%;
  95. border-radius:50%;
  96. background:radial-gradient(circle, currentColor 0%, color-mix(in srgb, currentColor 42%, transparent) 30%, rgba(0,0,0,0) 72%);
  97. box-shadow:0 0 18px currentColor, 0 0 40px currentColor;
  98. transform:translate(-50%,-50%);
  99. animation:markerFxGlow var(--fx-glow-period,2400ms) ease-in-out infinite;
  100. will-change:transform,opacity;
  101. }
  102. .${FX_CLASS}--blink .${FX_RING_CLASS} {
  103. animation:markerFxExpand var(--fx-blink-period,1400ms) linear infinite,
  104. markerFxFlicker ${EFFECT_PERIOD_MS.BLINK}ms steps(2, jump-none) infinite;
  105. }
  106. .${FX_CLASS}--spin .${FX_RING_CLASS} {
  107. background:none;
  108. border:2px dashed currentColor;
  109. animation:markerFxSpin var(--fx-spin-period,3700ms) linear infinite;
  110. }
  111. @keyframes markerFxExpand {
  112. 0% { transform:translate(-50%,-50%) scale(0.1); opacity:1; filter:brightness(2); }
  113. 35% { opacity:0.85; filter:brightness(1.6); }
  114. 70% { opacity:0.4; filter:brightness(1.1); }
  115. 100% { transform:translate(-50%,-50%) scale(var(--fx-profile-scale, 1)); opacity:0; filter:brightness(0.8); }
  116. }
  117. @keyframes markerFxGlow {
  118. 0%, 100% { transform:translate(-50%,-50%) scale(0.75); opacity:0.32; filter:brightness(1.2); }
  119. 50% { transform:translate(-50%,-50%) scale(1.45); opacity:0.85; filter:brightness(1.9); }
  120. }
  121. @keyframes markerFxFlicker {
  122. 0%, 100% { opacity:1; }
  123. 50% { opacity:0.28; }
  124. }
  125. @keyframes markerFxSpin {
  126. from { transform:translate(-50%,-50%) rotate(0deg) scale(calc(var(--fx-profile-scale, 1) * 0.9)); opacity:0.95; }
  127. to { transform:translate(-50%,-50%) rotate(360deg) scale(calc(var(--fx-profile-scale, 1) * 1.12)); opacity:0.68; }
  128. }
  129. @media (prefers-reduced-motion: reduce) { .${FX_CLASS} { display:none; } }
  130. `
  131. const FX_STYLE_ID = 'marker-overlay-fx-styles'
  132. /** 注入一次特效样式(幂等:已存在就跳过)。 */
  133. function ensureFxStyles() {
  134. if (typeof document === 'undefined') return
  135. if (document.getElementById(FX_STYLE_ID)) return
  136. const style = document.createElement('style')
  137. style.id = FX_STYLE_ID
  138. style.textContent = FX_CSS
  139. document.head.appendChild(style)
  140. }
  141. /** 每帧由 `syncMarkerOverlay` 注入的门控查询(mapService 从 domain 动效计划产出)。 */
  142. let frameEffectOf = () => ''
  143. /** 状态 → 特效 profile(domain 单源;DOM 层数/亮度/辉光只消费这一份表,不另造)。 */
  144. function markerFxProfile(status) {
  145. return effectProfileFor(status)
  146. }
  147. /**
  148. * 标记动效查询(票 70 重做):**双条件**才出特效——
  149. * ① 门控放行(`effectOf`:开且未降级,判定在 domain,渲染层不自行发明);
  150. * ② 状态有 profile 且 profile 的动效与门控放行的动效一致(防串键)。
  151. * @returns {{effect:string, profile:object}|null}
  152. */
  153. function fxOf(marker) {
  154. const effect = frameEffectOf(marker)
  155. if (!effect) return null
  156. const profile = markerFxProfile(marker.status)
  157. if (!profile || profile.effect !== effect) return null
  158. return { effect, profile }
  159. }
  160. /**
  161. * 环层数=状态 profile 的 layers(报警 4 / 预警 3 / 工单 2;辉光不计入层读数,只给报警)。
  162. * C3f 口径:层数只从 profile 消费,DOM 侧不许写死。
  163. */
  164. function markerEffectLayers(profile) {
  165. return profile.layers
  166. }
  167. /**
  168. * 特效 DOM(票 70 重做):固定 `markerEffectLayers(profile)`(+报警辉光 1)个节点,
  169. * CSS 动画驱动、JS 不逐帧更新。容器几何:`left:${-box / 2}px` 使其**中心 = 节点原点**
  170. * (图标尖端落地点),辐射环直接标记地面目标;`currentColor` 由 tone 染色(与标记本体同色)。
  171. * 周期变量全部来自 domain 常量(节奏单源):环 `RING_PERIOD_MS`、辉光 `GLOW_PERIOD_MS`。
  172. */
  173. function buildFxLayers(fx, plan, toneHex) {
  174. const { effect, profile } = fx
  175. const period = RING_PERIOD_MS[effect] || RING_PERIOD_MS[DEVICE_EFFECT.PULSE]
  176. const box = plan.draw.width
  177. const host = document.createElement('div')
  178. host.className = `${FX_CLASS} ${FX_CLASS}--${effect}`
  179. host.style.cssText = `position:absolute;left:${-box / 2}px;top:${-box / 2}px;width:${box}px;height:${box}px;color:${toneHex};`
  180. host.style.setProperty('--fx-period', `${period}ms`)
  181. host.style.setProperty('--fx-glow-period', `${GLOW_PERIOD_MS}ms`)
  182. host.style.setProperty('--fx-profile-scale', String(profile.scale))
  183. if (effect === DEVICE_EFFECT.BLINK) host.style.setProperty('--fx-blink-period', `${period}ms`)
  184. if (effect === DEVICE_EFFECT.SPIN) host.style.setProperty('--fx-spin-period', `${period}ms`)
  185. // 均分错相:第 i 层延迟 -period*i/layers(负延迟=开播即处于各自相位,形成连续外扩)。
  186. for (let layer = 0; layer < markerEffectLayers(profile); layer += 1) {
  187. const ring = document.createElement('div')
  188. ring.className = FX_RING_CLASS
  189. ring.style.width = `${box}px`
  190. ring.style.height = `${box}px`
  191. ring.style.animationDelay = `${-(period / markerEffectLayers(profile)) * layer}ms`
  192. host.appendChild(ring)
  193. }
  194. // 中心辉光只给报警(EFFECT_PROFILE.glow;票 07 优先级语义,不给预警/工单)。
  195. if (profile.glow) {
  196. const glow = document.createElement('div')
  197. glow.className = FX_GLOW_CLASS
  198. const g = Math.round(box * 0.7)
  199. glow.style.width = `${g}px`
  200. glow.style.height = `${g}px`
  201. host.appendChild(glow)
  202. }
  203. return host
  204. }
  205. /**
  206. * 聚合符号节点(票 76 / C1):重叠设备的聚合符号此前只活在 billboard 层(票 47 遗留),
  207. * DOM 侧一个都没有。节点形态与 billboard 版对齐:
  208. * · 形状/色键 = 引擎读数(`overlapShape` / `overlapTone`),形状生成器复用 domain `markerSvg`
  209. * (billboard 的 `imageOf` 就是它)——同源,不另画一套;
  210. * · 尺寸 = billboard 宽真值(`size * 1.5` 的产物,票 58 的聚合符号档),DOM 不自己算;
  211. * · 锚点 = 投影点**居中**(对应 billboard `verticalOrigin: CENTER` + pixelOffset 0),
  212. * 与标记的尖端锚定不同;
  213. * · 标签文案与 billboard 版同式样(「聚合 K 台 · 组内<状态词>」);
  214. * · **不含设备身份**:`data-marker-overlay-code` 放 group key(点击派发用),不放 deviceCode。
  215. */
  216. function buildAggregateNode(key, row, toneHex, plan) {
  217. const root = document.createElement('div')
  218. // `--aggregate` 修饰类:让聚合标签走「常显」规则(设备标签只在 hover 时显示)。
  219. root.className = `${NODE_CLASS} ${NODE_CLASS}--aggregate`
  220. root.dataset.markerOverlayKey = key
  221. root.dataset.markerOverlayKind = 'overlap-aggregate'
  222. root.dataset.markerOverlayCode = row.group
  223. root.dataset.markerOverlayAggregate = String(row.k)
  224. root.dataset.markerOverlayPointCode = row.pointCode || ''
  225. root.style.cssText = [
  226. 'position:absolute',
  227. 'left:0', 'top:0',
  228. 'width:0', 'height:0',
  229. 'pointer-events:auto',
  230. 'cursor:pointer',
  231. 'will-change:transform',
  232. ].join(';')
  233. // 票 76(选择态发光):状态色写成 CSS 变量(**必须在 cssText 之后**——cssText 会整段覆盖内联样式,
  234. // 先设置等于刚写就抹掉,这是上一版「还是白光」的第二层原因)。
  235. root.style.setProperty('--marker-tone', toneHex)
  236. // 票 76(统一设备点位渲染):聚合符号也走**自绘水滴母版**(与标记/设备同一套几何与渲染路径),
  237. // 不再用旧的形状表(三角形/圆形图标)——那是废弃的渲染模式。
  238. const w = plan.size.width
  239. const h = plan.size.height
  240. const pad = plan.draw.pad
  241. const wrap = document.createElement('div')
  242. wrap.innerHTML = markerSvg(plan.master, toneHex)
  243. const svg = wrap.firstChild
  244. svg.style.cssText = [
  245. 'position:absolute',
  246. `left:${-(pad + w * plan.anchor.x)}px`,
  247. `top:${-(h + pad)}px`,
  248. `width:${plan.draw.width}px`,
  249. `height:${plan.draw.height}px`,
  250. plan.desaturate ? 'filter:saturate(0.55) brightness(0.72)' : '',
  251. ].join(';')
  252. svg.style.transformOrigin = `${pad + w * plan.anchor.x}px ${h + pad}px`
  253. root.appendChild(svg)
  254. const innerEl = svg.querySelector('[data-marker-overlay-inner]')
  255. if (plan.inner.enabled && innerEl) {
  256. const diameter = plan.inner.diameterRatio * 378
  257. const cx = 378 * plan.anchor.x
  258. const cy = plan.inner.center.y * 475
  259. innerEl.setAttribute('x', String(cx - diameter / 2))
  260. innerEl.setAttribute('y', String(cy - diameter / 2))
  261. innerEl.setAttribute('width', String(diameter))
  262. innerEl.setAttribute('height', String(diameter))
  263. renderInnerIconUrl(plan.inner.icon)
  264. .then((dataUrl) => { innerEl.setAttribute('href', dataUrl) })
  265. .catch(() => {})
  266. }
  267. // 票 76(新需求 2026-10-26):聚合点图标内加**聚合符号**(不是行业图标 —— 聚合表达"一组",
  268. // 不是某一种行业)。用内联 SVG 画三层同心「堆叠」符号,颜色取描边白,与母版同一条渲染路径。
  269. {
  270. const diameter = 0.62 * 378
  271. const cx = 378 * plan.anchor.x
  272. const cy = plan.inner.center.y * 475
  273. const group = document.createElementNS('http://www.w3.org/2000/svg', 'g')
  274. group.setAttribute('data-marker-overlay-aggregate-symbol', '')
  275. const r = diameter / 2
  276. for (let i = 0; i < 3; i += 1) {
  277. const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle')
  278. circle.setAttribute('cx', String(cx - r * 0.45 + i * r * 0.45))
  279. circle.setAttribute('cy', String(cy + r * 0.18 - i * r * 0.18))
  280. circle.setAttribute('r', String(r * 0.42))
  281. circle.setAttribute('fill', '#ffffff')
  282. circle.setAttribute('fill-opacity', i === 2 ? '1' : '0.55')
  283. group.appendChild(circle)
  284. }
  285. svg.appendChild(group)
  286. }
  287. const label = document.createElement('div')
  288. label.className = LABEL_CLASS
  289. label.textContent = `聚合 ${row.k} 台${row.label ? ` · 组内${row.label}` : ''}`
  290. label.title = label.textContent
  291. label.style.cssText = [
  292. 'position:absolute',
  293. `bottom:${h + pad + 3}px`,
  294. 'left:0',
  295. 'transform:translateX(-50%)',
  296. 'box-sizing:content-box',
  297. 'max-width:170px',
  298. 'overflow:hidden',
  299. 'white-space:nowrap',
  300. 'text-overflow:ellipsis',
  301. 'padding:4px 9px',
  302. 'border-radius:8px',
  303. 'border:1px solid rgba(1,171,234,0.55)',
  304. 'background:rgba(2,20,40,0.82)',
  305. 'color:#d7ecff',
  306. 'font:600 12px "Microsoft YaHei UI","Microsoft YaHei","PingFang SC","Noto Sans SC",sans-serif',
  307. 'pointer-events:none',
  308. ].join(';')
  309. root.appendChild(label)
  310. return { root, icon: svg }
  311. }
  312. /**
  313. * 自绘水滴母版(bbox 坐标系:x∈[0,378]、y∈[0,475],尖端 (188.5,474))。
  314. * viewBox 外扩 40(同 billboard OUTLINE_PAD 裁剪窗)→ "-40 -40 458 555"。
  315. *
  316. * 描边(浏览器走查修正):billboard 的 `withWhiteOutline` 是**向外膨胀** 28 源像素、
  317. * 母版本身保持 378 全宽;SVG `stroke` 默认沿路径**中心线**(外 14 / 内 14)会把彩色母版
  318. * 蚀掉一圈、白环也只出一半——同尺寸下 DOM 标记明显比 billboard 小。
  319. * 用 `paint-order="stroke fill"` 把描边压到填充**之下**:彩色母版全尺寸保留,
  320. * 白色纯外扩 `stroke-width/2` = 28 → 与 billboard 的 OUTLINE_WIDTH 同值(外沿 28 < pad 40 不被裁)。
  321. */
  322. 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'
  323. const PIN_HOLE = 'M263.5 189a75 75 0 1 0-150 0 75 75 0 1 0 150 0Z'
  324. const PIN_STROKE = 'stroke="#ffffff" stroke-width="56" stroke-linejoin="round" paint-order="stroke fill"'
  325. function markerSvg(master, toneHex) {
  326. const body = master === 'hollow'
  327. ? `<path d="${PIN_OUTLINE}${PIN_HOLE}" fill="${toneHex}" fill-rule="evenodd" ${PIN_STROKE}/>`
  328. : `<path d="${PIN_OUTLINE}" fill="${toneHex}" ${PIN_STROKE}/>`
  329. return `<svg viewBox="-40 -40 458 555" 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>`
  330. }
  331. /** 节点池:key → { root, inner, fp };标记消失时节点隐藏不销毁,计划读数变化时重建。 */
  332. const pool = new Map()
  333. /** 帧内复用的"本轮在计划里"集合(票 35:原先每帧 new Set)。 */
  334. const wantedKeys = new Set()
  335. /** 指纹分隔符:用不可打印字符避免与业务字符串拼接歧义。 */
  336. const FP_SEP = '\u0001'
  337. let hostNode = null
  338. let hostRecord = null
  339. let postRenderRemove = null
  340. /** 临时 A/B 开关(票 47 走查):false = 隐藏 DOM 层、露出底下 billboard(之前版本)。 */
  341. // 票 76(C6):A/B 显隐开关已退役 —— overlay 挂载即生效,billboard 视觉不再有「对照组」。
  342. /**
  343. * 单个标记节点(特效层 + 标记 SVG + 可选标签),一次构建、每帧只更新 transform。
  344. * @param {object|null} fx `fxOf(marker)` 的产出(null = 无动效;关联设备恒 null——
  345. * 它们的特效留在 billboard 层,DOM 不画第二套环,同位双环会糊,票 72 遗留决策一约束)。
  346. */
  347. function buildNode(key, marker, plan, toneHex, fx) {
  348. const root = document.createElement('div')
  349. root.className = NODE_CLASS
  350. root.dataset.markerOverlayKey = key
  351. root.dataset.markerOverlayKind = marker.kind
  352. root.dataset.markerOverlayCode = marker.code
  353. // 宿主 data-* 断言面(规格 10 读数在 DOM 节点上延续)
  354. root.dataset.markerMaster = plan.master
  355. root.dataset.markerInner = plan.inner.enabled ? plan.inner.icon : ''
  356. root.dataset.markerAnchor = `${plan.anchor.x},${plan.anchor.y}`
  357. root.dataset.markerDesaturate = plan.desaturate ? 'yes' : 'no'
  358. root.dataset.markerTone = toneHex
  359. // 特效读数面(票 70 重做 / t45 C3d):无动效不挂键;挂 = 动效键 + profile 层数 + alpha 真值。
  360. if (fx) {
  361. root.dataset.markerEffect = fx.effect
  362. root.dataset.markerEffectLayers = String(markerEffectLayers(fx.profile))
  363. root.dataset.markerEffectAlpha = String(fx.profile.alpha)
  364. }
  365. const w = plan.size.width
  366. const h = plan.size.height
  367. const pad = plan.draw.pad
  368. root.style.cssText = [
  369. 'position:absolute',
  370. 'left:0', 'top:0',
  371. 'width:0', 'height:0',
  372. 'pointer-events:auto',
  373. 'cursor:pointer',
  374. 'will-change:transform',
  375. ].join(';')
  376. // 票 76(选择态发光):状态色写成 CSS 变量 —— **必须在 cssText 之后**(cssText 整段覆盖内联样式,
  377. // 先写等于刚写就抹掉;这是「还是白光」的第二层原因)。不能靠 `currentColor`:母版带白色描边,
  378. // currentColor 会落成白色(第一层原因)。
  379. root.style.setProperty('--marker-tone', toneHex)
  380. // 特效层在标记本体之下(先挂 fx、SVG 后画盖住环心,环带在图标外扩散可见)。
  381. // 锚点是节点原点即尖端落地点(buildFxLayers 的 -box/2 偏移保证中心=原点)。
  382. if (fx) {
  383. root.appendChild(buildFxLayers(fx, plan, toneHex))
  384. }
  385. const wrap = document.createElement('div')
  386. wrap.innerHTML = markerSvg(plan.master, toneHex)
  387. const svg = wrap.firstChild
  388. svg.style.cssText = [
  389. 'position:absolute',
  390. `left:${-(pad + w * plan.anchor.x)}px`,
  391. `top:${-(h + pad)}px`,
  392. `width:${plan.draw.width}px`,
  393. `height:${plan.draw.height}px`,
  394. plan.desaturate ? 'filter:saturate(0.55) brightness(0.72)' : '',
  395. ].join(';')
  396. // 反应量缩放的**原点 = 尖端**(尖端固定在地面坐标上,标记围绕尖端放大/缩小,
  397. // 与 billboard verticalOrigin:BOTTOM + scale 同一行为)。
  398. svg.style.transformOrigin = `${pad + w * plan.anchor.x}px ${h + pad}px`
  399. root.appendChild(svg)
  400. const innerEl = svg.querySelector('[data-marker-overlay-inner]')
  401. if (plan.inner.enabled) {
  402. // 直径/圆心 = 计划层几何(80% × 母版宽、帽心对齐)。viewBox 起点 -40 只是外扩描边
  403. // 余量,bbox 坐标即 SVG 用户坐标——圆心直接用母版帽心 (188.5, 0.397×475)。
  404. const diameter = plan.inner.diameterRatio * 378
  405. const cx = 378 * plan.anchor.x // 帽心 x = 尖端 x = 188.5
  406. const cy = plan.inner.center.y * 475
  407. innerEl.setAttribute('x', String(cx - diameter / 2))
  408. innerEl.setAttribute('y', String(cy - diameter / 2))
  409. innerEl.setAttribute('width', String(diameter))
  410. innerEl.setAttribute('height', String(diameter))
  411. }
  412. // 标签:真 DOM。pointer-events:none —— 不拦截事件,相机拖拽与 Canvas 拾取照常穿过。
  413. // 限宽取计划层 `labelMaxWidth`(宽 × 2.6,billboard 同一单源)——浏览器走查修正:
  414. // 首版硬编码 220px,比 billboard 截断宽 3 倍,两层标签并现时一长一短明显不一致。
  415. if (marker.name) {
  416. const label = document.createElement('div')
  417. label.className = LABEL_CLASS
  418. label.textContent = marker.name
  419. label.title = marker.name // hover 读全(与 DOM 侧 EllipsisText 同语义)
  420. label.style.cssText = [
  421. 'position:absolute',
  422. `bottom:${h + pad + 3}px`,
  423. 'left:0',
  424. 'transform:translateX(-50%)',
  425. // content-box:max-width 只管文字,与 Cesium label 的截断宽度同语义
  426. 'box-sizing:content-box',
  427. `max-width:${plan.labelMaxWidth}px`,
  428. 'overflow:hidden',
  429. 'white-space:nowrap',
  430. 'text-overflow:ellipsis',
  431. 'padding:4px 9px',
  432. 'border-radius:8px',
  433. 'border:1px solid rgba(1,171,234,0.55)',
  434. 'background:rgba(2,20,40,0.82)',
  435. 'color:#d7ecff',
  436. 'font:600 12px "Microsoft YaHei UI","Microsoft YaHei","PingFang SC","Noto Sans SC",sans-serif',
  437. 'pointer-events:none',
  438. ].join(';')
  439. root.appendChild(label)
  440. }
  441. return { root, inner: innerEl, svg }
  442. }
  443. /**
  444. * 初始化 overlay 宿主(挂在地图容器内,z-index 在 canvas 之上、面板之下)。
  445. * @param {Element} mapHost 地图宿主(.map-canvas)
  446. * @param {object} [options] `{ onPick }` 显式点击触发器回调(窗口坐标 + 节点身份)
  447. * @returns {boolean} 是否初始化成功(失败时调用方回退 billboard 链路)
  448. */
  449. export function mountMarkerOverlay(mapHost, { onPick, onHover } = {}) {
  450. try {
  451. if (!mapHost || typeof mapHost.appendChild !== 'function') return false
  452. if (hostNode && hostNode.isConnected) return true
  453. hostNode = document.createElement('div')
  454. hostNode.className = OVERLAY_HOST_CLASS
  455. hostNode.dataset.markerOverlay = ''
  456. hostNode.style.cssText = [
  457. 'position:absolute', 'inset:0',
  458. 'pointer-events:none', // 宿主不拦截;节点自身 pointer-events:auto
  459. 'overflow:hidden',
  460. 'z-index:1',
  461. ].join(';')
  462. hostNode.style.display = ''
  463. // 票 76(C4 parity 现场把手):DOM 单层比 billboard 双层暗(镂空孔/边缘不再叠亮层)。
  464. // `?pinBrightness=1.2` 只做整体亮度补偿(不碰色相/饱和度);定档后写成默认常量并撤掉把手。
  465. try {
  466. if (import.meta.env && import.meta.env.DEV && typeof window !== 'undefined') {
  467. const hashQuery = String(window.location.hash || '').split('?')[1] || ''
  468. const raw = new URLSearchParams(window.location.search).get('pinBrightness')
  469. || new URLSearchParams(hashQuery).get('pinBrightness')
  470. || ''
  471. const value = Number(raw)
  472. if (Number.isFinite(value) && value > 0 && value !== 1) hostNode.style.filter = `brightness(${value})`
  473. }
  474. } catch (error) {
  475. // 走查把手:解析失败不影响挂载。
  476. }
  477. // 特效样式注入(票 70 重做 / t45 C3b):幂等一次;卸载时移除。
  478. ensureFxStyles()
  479. // 显式点击触发器(票 47 维护者裁定:给事件触发器、点击照搬原功能函数):
  480. // 命中标记节点 → 交给注入的 onPick(mapService 传 pickDeviceOrPointAt,与 Canvas 点选同一函数)。
  481. hostNode.addEventListener('click', (event) => {
  482. if (typeof onPick !== 'function') return
  483. const node = event.target && event.target.closest ? event.target.closest(`.${NODE_CLASS}`) : null
  484. if (!node) return
  485. onPick({
  486. x: event.clientX,
  487. y: event.clientY,
  488. kind: node.dataset.markerOverlayKind,
  489. code: node.dataset.markerOverlayCode,
  490. })
  491. })
  492. // 标记节点上的按下/抬起不再冒泡到宿主:宿主的 attachPointPick 会对同一次点击**再拾一遍**
  493. // (双派发)。点选统一走上面的显式触发器。pointermove 不拦——hover 高亮与 tooltip 链路照常。
  494. const stopOnMarker = (event) => {
  495. if (event.target && event.target.closest && event.target.closest(`.${NODE_CLASS}`)) {
  496. event.stopPropagation()
  497. }
  498. }
  499. hostNode.addEventListener('pointerdown', stopOnMarker)
  500. hostNode.addEventListener('pointerup', stopOnMarker)
  501. // 票 76(C6):DOM 节点 hover 派发 —— billboard 视觉让位后 GPU 拾取没有命中对象,
  502. // hover(设备反应量/面板高亮/tooltip)改由 overlay 按**节点身份**通知服务层。
  503. // 指针不在任何节点上时发 `null`,服务层据此清空 hover 态。
  504. const emitHover = (event) => {
  505. if (typeof onHover !== 'function') return
  506. const node = event.target && event.target.closest ? event.target.closest(`.${NODE_CLASS}`) : null
  507. if (!node) {
  508. onHover(null)
  509. return
  510. }
  511. onHover({
  512. kind: node.dataset.markerOverlayKind || '',
  513. code: node.dataset.markerOverlayCode || '',
  514. })
  515. }
  516. hostNode.addEventListener('pointermove', emitHover)
  517. hostNode.addEventListener('pointerleave', () => {
  518. if (typeof onHover === 'function') onHover(null)
  519. })
  520. mapHost.appendChild(hostNode)
  521. hostRecord = { host: mapHost }
  522. return true
  523. } catch {
  524. return false
  525. }
  526. }
  527. /** overlay 是否已挂载。 */
  528. export function isMarkerOverlayMounted() {
  529. return !!(hostNode && hostNode.isConnected)
  530. }
  531. // 票 76(C6):`setMarkerOverlayVisible()` 与 `?overlay=0` 已退役(票面「非目标」的收口项)——
  532. // 载体终裁 DOM 胜出后不再需要 billboard 对照组,第 ⑤ 条验收「点选统一走 overlay click 触发器」
  533. // 也因此不再有需要 GPU 拾取的第二态。
  534. /** overlay 是否在生效(票 76 / C6:挂载即生效,不再有 A/B 显隐态)。 */
  535. export function isMarkerOverlayVisible() {
  536. return isMarkerOverlayMounted()
  537. }
  538. /**
  539. * 每帧同步(由 mapService 的 postRender 驱动):
  540. * 投影 + transform 更新 + 屏外隐藏 + 池复用。**不重建节点**(指纹变化才重建)。
  541. * @param {object} params
  542. * @param {Array} params.markers `planPointMarkers().markers`(含 industry/tone/plan 消费字段)
  543. * @param {Array} [params.devices] 关联设备行(`projectDeviceMarkers()` 产出:屏幕坐标直供,
  544. * scale/alpha 已含选中/hover/弱化反应量——DOM 镜像 billboard 实体真值,防「小一圈」)
  545. * @param {Map} [params.pointScales] `kind|code` → 强调 scale(票 76 / C3:由**服务层**产出,
  546. * 1 = 常态、`EMPHASIS_STYLE.markerScale` = 强调;不再回读 billboard 实体)
  547. * @param {object} params.tones token 色表(tone 键 → CSS 颜色)
  548. * @param {Function} params.planOf marker → plan(markerPlan.planMarker 的包装)
  549. * @param {Function} [params.effectOf] marker → 门控后的动效键('' = 不动画;mapService 从
  550. * domain `planDeviceEffects` 计划产出,**只给摘要标记行**——关联设备的特效仍在 billboard 层)
  551. * @param {Array} [params.aggregates] 重叠聚合符号行(票 76 / C1:`projectOverlapSymbols()` 产出;
  552. * group 身份/计数/组内状态词/形状/色键/屏幕坐标 —— 唯一没有设备身份的一类节点)
  553. * @param {Function} params.projectToWindow (lon, lat) → 宿主相对窗口坐标(贴地口径由调用方保证)
  554. */
  555. export function syncMarkerOverlay({ markers, devices, aggregates, pointScales, tones, planOf, effectOf, projectToWindow }) {
  556. if (!hostNode || !hostNode.isConnected || typeof projectToWindow !== 'function') return
  557. // 每帧刷新门控查询(mapService 在计划变化时给新闭包;这里只搬运、不判定)。
  558. frameEffectOf = typeof effectOf === 'function' ? effectOf : () => ''
  559. // 票 35 卡顿整改:Set 复用(原先每帧 new 一个);指纹改成廉价拼接(原先每标记
  560. // JSON.stringify 一次,纯分配)。DOM 写入也改成"只有真的变了才写"。
  561. wantedKeys.clear()
  562. const wanted = wantedKeys
  563. // 设备行坐标是**窗口坐标**(引擎投影真值):转宿主相对要减宿主(overlay 挂载父级)的
  564. // 窗口原点。每帧读一次——宿主可能随布局移动,不能在挂载时缓存。
  565. const hostRect = hostNode.parentElement && hostNode.parentElement.getBoundingClientRect
  566. ? hostNode.parentElement.getBoundingClientRect()
  567. : { left: 0, top: 0 }
  568. const hostOffset = { x: hostRect.left, y: hostRect.top }
  569. for (const marker of markers || []) {
  570. const key = `${marker.kind}|${marker.code}`
  571. wanted.add(key)
  572. if (!Number.isFinite(marker.longitude) || !Number.isFinite(marker.latitude)) continue
  573. const windowPos = projectToWindow(marker.longitude, marker.latitude)
  574. if (!windowPos || !Number.isFinite(windowPos.x) || !Number.isFinite(windowPos.y)) continue
  575. const plan = planOf(marker)
  576. const toneHex = tones[marker.tone] || tones.neutral
  577. // 票 70:fxOf 的键取自 effectOf 门控查询(domain 计划已判开关/降级);profile 取状态。
  578. const fx = fxOf(marker)
  579. // 计划读数指纹:状态/行业/名称/母版/内层/降饱和/色值/**动效键与层数**任一变化就重建节点。
  580. const fp = [marker.status, marker.industry, marker.name,
  581. plan.master, plan.inner.enabled, plan.inner.icon, plan.inner.diameterRatio,
  582. plan.desaturate, toneHex, fx ? fx.effect : '', fx ? fx.profile.layers : 0].join(FP_SEP)
  583. let record = pool.get(key)
  584. if (!record || record.fp !== fp) {
  585. if (record && record.root.parentNode) record.root.parentNode.removeChild(record.root)
  586. record = { ...buildNode(key, marker, plan, toneHex, fx), fp }
  587. pool.set(key, record)
  588. hostNode.appendChild(record.root)
  589. if (plan.inner.enabled) {
  590. // 内层素材:与 billboard 同一去色重染规则的原生分辨率 data URL(缓存复用)。
  591. // 竞态防护:加载回来时池记录可能已被更新的状态重建——只在仍是本节点时写入。
  592. const mine = record
  593. renderInnerIconUrl(plan.inner.icon)
  594. .then((dataUrl) => {
  595. if (pool.get(key) === mine) mine.inner.setAttribute('href', dataUrl)
  596. })
  597. .catch(() => {
  598. // 内层素材失败:只有母版水滴(降级可见),不阻塞其余标记。
  599. })
  600. }
  601. }
  602. // 坐标只在真的变了才写(镜头静止时零 DOM 写入)。
  603. const transform = `translate(${windowPos.x}px, ${windowPos.y}px)`
  604. if (record.root.style.transform !== transform) record.root.style.transform = transform
  605. // 强调/还原的缩放(票 76 / C3):量由服务层产出(不再回读 billboard.scale),
  606. // DOM 围绕尖端缩放该量(transformOrigin=尖端,见 buildNode)。
  607. const markerScale = pointScales instanceof Map ? pointScales.get(key) : undefined
  608. const scaleTransform = markerScale && markerScale !== 1 ? `scale(${markerScale})` : ''
  609. if (record.svg.style.transform !== scaleTransform) record.svg.style.transform = scaleTransform
  610. if (record.root.style.display !== 'block') record.root.style.display = 'block'
  611. }
  612. // 关联设备(票 47 维护者反馈②根因:设备是独立 billboard 图层,此前 DOM 只渲染
  613. // 摘要标记计划、关联设备根本没进 DOM)。行由引擎每帧投影产出(屏幕坐标真值 +
  614. // 反应量真值),形态走与摘要未关联设备同一套合成计划(实心母版 + 内层行业图标)。
  615. for (const device of devices || []) {
  616. const key = device.key || `device|${device.code}`
  617. wanted.add(key)
  618. if (!Number.isFinite(device.x) || !Number.isFinite(device.y)) continue
  619. // 票 76(标签规格 2026-10-26 终版,维护者裁定):**圈内设备不建顶部标签**。
  620. // 理由:环内设备密集,顶部标签互相压叠且会被图案/特效淹没;它们的全名由右下角
  621. // tooltip(`paintMarkerTooltip`)承担,一份就够。
  622. // 实现:`fan` 行直接把 name 置空 → `buildNode` 不创建 label 节点(比 CSS 隐藏更彻底,
  623. // 也不会因为后续 `--fan` 类切换而重新冒出来)。
  624. const marker = {
  625. kind: 'associated-device',
  626. code: device.code,
  627. name: device.fan === true ? '' : (device.group ? (device.name || '') : ''),
  628. status: device.status || '',
  629. industry: device.industry || '',
  630. }
  631. const plan = planOf(marker)
  632. // 票 76(2026-10-26):色键由**设备状态**决定(domain 单源)——设备行不单独携带 tone,
  633. // 否则状态色与摘要标记两套口径。
  634. const toneHex = tones[deviceVisualForStatus(device.status).tone] || tones.neutral
  635. // 票 70(C3c 语义):关联设备循环**恒不画 DOM 环**(fx 恒 null)——它们的特效留在
  636. // billboard 层(ensureRingEntities 链路),同位双环会糊。
  637. // 票 76(特效归属,维护者裁定 A):聚合本身不挂环;**只有中心(被选中)设备**按自身状态挂环。
  638. // 2026-10-26 修:`DEVICE_EFFECT` 是**动效键名表**(PULSE/BLINK/SPIN),不是「状态 → 动效」映射 ——
  639. // 此前写成 `DEVICE_EFFECT[device.status]` 恒为 undefined,中心设备因此一直没有特效。
  640. // 状态 → profile 的唯一入口是 `effectProfileFor()`(domain 单源)。
  641. const profile = device.selected ? effectProfileFor(device.status) : null
  642. const fx = profile ? { effect: profile.effect, profile } : null
  643. const fp = [marker.status, marker.industry, marker.name,
  644. plan.master, plan.inner.enabled, plan.inner.icon, plan.inner.diameterRatio,
  645. plan.desaturate, toneHex, fx ? fx.effect : '', fx ? fx.profile.layers : 0].join(FP_SEP)
  646. let record = pool.get(key)
  647. if (!record || record.fp !== fp) {
  648. if (record && record.root.parentNode) record.root.parentNode.removeChild(record.root)
  649. record = { ...buildNode(key, marker, plan, toneHex, fx), fp }
  650. pool.set(key, record)
  651. hostNode.appendChild(record.root)
  652. if (plan.inner.enabled) {
  653. const mine = record
  654. renderInnerIconUrl(plan.inner.icon)
  655. .then((dataUrl) => {
  656. if (pool.get(key) === mine) mine.inner.setAttribute('href', dataUrl)
  657. })
  658. .catch(() => {})
  659. }
  660. }
  661. // 坐标 = 引擎投影的窗口坐标(含 pixelOffset 尖端补偿),只差宿主原点一层。
  662. const deviceTransform = `translate(${device.x - hostOffset.x}px, ${device.y - hostOffset.y}px)`
  663. if (record.root.style.transform !== deviceTransform) record.root.style.transform = deviceTransform
  664. // 反应量镜像:scale 读的是 billboard 实体真值(选中 1.6/hover 1.3/弱化 0.75),
  665. // 弱化的压暗同理镜像 alpha(只写透明度,不动色相——色键语义归设备状态)。
  666. // 中心设备与聚合点**同尺寸**(走查:选中设备后一直保持放大、且丢失选择态);其余设备照旧镜像。
  667. const effScale = device.selected ? 1 : device.scale
  668. const deviceScale = effScale && effScale !== 1 ? `scale(${effScale})` : ''
  669. if (record.svg.style.transform !== deviceScale) record.svg.style.transform = deviceScale
  670. const deviceOpacity = device.alpha !== undefined && device.alpha !== 1 ? String(device.alpha) : ''
  671. if (record.root.style.opacity !== deviceOpacity) record.root.style.opacity = deviceOpacity
  672. if (record.root.style.display !== 'block') record.root.style.display = 'block'
  673. // 票 76(标签规格):圈内设备(`fan`)标签改 hover-only;中心设备与普通设备点维持常态标签。
  674. record.root.classList.toggle(`${NODE_CLASS}--fan`, device.fan === true)
  675. // 票 76(四态规格 2026-10-26)设备节点:
  676. // ③ 设备常态 中心=设备点,无发光,环只在 hover 预览(`fan` 标签 hover-only)
  677. // ④ 设备被选择态 与聚合点被选择态相同:状态色发光 + 隐藏自身标签
  678. record.root.classList.toggle(`${NODE_CLASS}--active`, device.pinned === true)
  679. // 票 76(四态,2026-10-26):③④ 的中心设备要**压过聚合节点**(聚合节点后追加、会吃掉点击),
  680. // 否则「点中心设备进 ④」永远触发不了(走查第 3 步完全失效的直接原因)。
  681. record.root.classList.toggle(`${NODE_CLASS}--center`, device.selected === true)
  682. }
  683. // 重叠聚合符号(票 76 / C1:billboard 版只在 billboard 层,DOM 此前完全没有——票 47 遗留)。
  684. // 坐标同为引擎投影的窗口坐标,只差宿主原点一层。点击由宿主既有触发器派发(kind=overlap-aggregate)。
  685. for (const row of aggregates || []) {
  686. const key = row.key || `overlap|${row.pointCode}|${row.group}`
  687. wanted.add(key)
  688. if (!Number.isFinite(row.x) || !Number.isFinite(row.y)) continue
  689. const toneHex = tones[row.tone] || tones.neutral
  690. // 票 76(统一设备点位渲染):聚合符号也走 `planOf` 合成计划(母版/内层/几何与设备点同源),
  691. // 不再用旧的形状表(三角形图标 = 废弃渲染模式);计划读数纳入指纹,变化才重建。
  692. const plan = planOf({ kind: 'monitoring-device', status: row.status, industry: '', tone: row.tone })
  693. const fp = [
  694. row.k, row.label, row.status, row.state, toneHex,
  695. plan.master, plan.inner.enabled, plan.inner.icon, plan.inner.diameterRatio, plan.desaturate,
  696. ].join(FP_SEP)
  697. let record = pool.get(key)
  698. if (!record || record.fp !== fp) {
  699. if (record && record.root.parentNode) record.root.parentNode.removeChild(record.root)
  700. record = { ...buildAggregateNode(key, row, toneHex, plan), fp }
  701. pool.set(key, record)
  702. hostNode.appendChild(record.root)
  703. }
  704. const aggTransform = `translate(${row.x - hostOffset.x}px, ${row.y - hostOffset.y}px)`
  705. if (record.root.style.transform !== aggTransform) record.root.style.transform = aggTransform
  706. // 状态强调镜像(billboard `scale` / `outlineWidth` 真值)。
  707. // 票 76(四态):② 被选择态 = `pinned`(聚合点固定环 + 发光 + 略放大);
  708. // ① 的 hover 预览**不算**选择态,不放大也不发光。
  709. const active = row.pinned === true
  710. const activeBoost = active ? 1.12 : 1
  711. const effAggScale = (row.scale && row.scale > 0 ? row.scale : 1) * activeBoost
  712. const aggScale = effAggScale !== 1 ? `scale(${effAggScale})` : ''
  713. if (record.icon.style.transform !== aggScale) record.icon.style.transform = aggScale
  714. const aggFilter = row.outline ? 'drop-shadow(0 0 2px rgba(255,255,255,0.9))' : ''
  715. if (record.icon.style.filter !== aggFilter) record.icon.style.filter = aggFilter
  716. record.root.classList.toggle(`${NODE_CLASS}--active`, active)
  717. // 票 76(四态):① 的 hover 预览与 ② 的固定态都隐藏聚合标签(让出设备环);
  718. // ③④ 中心已是设备点 → 整枚聚合节点让位。
  719. const labelHidden = row.pinned === true || row.preview === true
  720. const label = record.root.querySelector(`.${LABEL_CLASS}`)
  721. if (label) {
  722. const wantedDisplay = labelHidden ? 'none' : 'block'
  723. if (label.style.display !== wantedDisplay) label.style.display = wantedDisplay
  724. }
  725. record.root.style.display = row.replaced ? 'none' : 'block'
  726. // ③④ 中心已是设备点:聚合节点虽然隐藏,但保险起见再降掉 pointer-events,
  727. // 避免它(或其子元素)在过渡帧里吃掉对中心设备的点击。
  728. record.root.classList.toggle(`${NODE_CLASS}--replaced`, row.replaced === true)
  729. }
  730. // 本轮不在计划里的:隐藏(池复用,不销毁)
  731. for (const [key, record] of pool.entries()) {
  732. if (!wanted.has(key) && record.root.style.display !== 'none') record.root.style.display = 'none'
  733. }
  734. }
  735. /**
  736. * 挂 postRender 驱动(mapService 调用;返回卸载函数)。
  737. * `getFrameState` 每帧由 mapService 提供最新标记计划与投影函数。
  738. */
  739. export function attachOverlayRenderer(viewer, getFrameState) {
  740. if (!viewer || !viewer.scene) return () => {}
  741. const onFrame = () => {
  742. const state = getFrameState()
  743. if (state) syncMarkerOverlay(state)
  744. }
  745. viewer.scene.postRender.addEventListener(onFrame)
  746. postRenderRemove = () => viewer.scene.postRender.removeEventListener(onFrame)
  747. return postRenderRemove
  748. }
  749. /** overlay 节点池(调试/断言用)。 */
  750. export function overlayPoolSize() {
  751. return pool.size
  752. }
  753. /** 卸载(离开 /cockpit 用):摘 postRender、清节点池与宿主、移除特效样式(t45 C3b 生命周期)。 */
  754. export function unmountMarkerOverlay() {
  755. if (postRenderRemove) {
  756. postRenderRemove()
  757. postRenderRemove = null
  758. }
  759. if (typeof document !== 'undefined') {
  760. const style = document.getElementById(FX_STYLE_ID)
  761. if (style && style.parentNode) style.parentNode.removeChild(style)
  762. }
  763. if (hostNode && hostNode.parentNode) hostNode.parentNode.removeChild(hostNode)
  764. hostNode = null
  765. hostRecord = null
  766. pool.clear()
  767. }