|
@@ -6,7 +6,7 @@
|
|
|
* 水滴形按实测母版几何拟合:帽圆 (188.5,189) r189(逐行验证误差 <1px)+
|
|
* 水滴形按实测母版几何拟合:帽圆 (188.5,189) r189(逐行验证误差 <1px)+
|
|
|
* 下半收尖三次贝塞尔(对 512px 母版左缘 57 点最小二乘拟合,最大残差 4.75 源像素
|
|
* 下半收尖三次贝塞尔(对 512px 母版左缘 57 点最小二乘拟合,最大残差 4.75 源像素
|
|
|
* ≈ 0.34 屏显像素;拟合探针见 .scratch/cockpit-real-data-wayfinding/research/fit-pin-bezier.mjs)。
|
|
* ≈ 0.34 屏显像素;拟合探针见 .scratch/cockpit-real-data-wayfinding/research/fit-pin-bezier.mjs)。
|
|
|
- * 镂空孔 = 实测圆 (188.5,189) r75。viewBox 外扩 40 与 billboard 裁剪窗(OUTLINE_PAD)同形。
|
|
|
|
|
|
|
+ * 镂空孔 = 实测圆 (188.5,189) r75。viewBox 外扩与 billboard 裁剪窗同形(单源 pad)。
|
|
|
* · **内层行业图标 = 素材**(`markerAssets.renderInnerIconUrl`:与 billboard 同一
|
|
* · **内层行业图标 = 素材**(`markerAssets.renderInnerIconUrl`:与 billboard 同一
|
|
|
* `recolorInner` 去色重染规则,但**不下采样**——256px 原生分辨率 data URL,
|
|
* `recolorInner` 去色重染规则,但**不下采样**——256px 原生分辨率 data URL,
|
|
|
* DOM 里按帽径向下采样,永远锐利)。
|
|
* DOM 里按帽径向下采样,永远锐利)。
|
|
@@ -28,9 +28,25 @@ import { renderInnerIconUrl } from './markerAssets.js'
|
|
|
// 与标记/设备一样走自绘水滴母版;domain 的 `markerSvg` 导入随之移除。
|
|
// 与标记/设备一样走自绘水滴母版;domain 的 `markerSvg` 导入随之移除。
|
|
|
// 票 45:特效随标记 DOM 化 —— 周期/层数/放大倍数与 billboard 特效同一份 domain 常量(节奏单源)。
|
|
// 票 45:特效随标记 DOM 化 —— 周期/层数/放大倍数与 billboard 特效同一份 domain 常量(节奏单源)。
|
|
|
// 票 70(重做):本行导入的每一枚都必须有消费者(不留死导入,票面 Acceptance 第 3 条)。
|
|
// 票 70(重做):本行导入的每一枚都必须有消费者(不留死导入,票面 Acceptance 第 3 条)。
|
|
|
-import { DEVICE_EFFECT, effectProfileFor, RING_PERIOD_MS, GLOW_PERIOD_MS, EFFECT_PERIOD_MS } from '../domain/deviceEffects.js'
|
|
|
|
|
|
|
+import { DEVICE_EFFECT, effectProfileFor } from '../domain/deviceEffects.js'
|
|
|
// 票 76(2026-10-26):设备点的色键取设备状态词条(domain 单源),DOM 不自己映射状态→颜色。
|
|
// 票 76(2026-10-26):设备点的色键取设备状态词条(domain 单源),DOM 不自己映射状态→颜色。
|
|
|
import { deviceVisualForStatus } from '../domain/deviceStatus.js'
|
|
import { deviceVisualForStatus } from '../domain/deviceStatus.js'
|
|
|
|
|
+// 票 82/84:几何与光环参数都来自单源 `markerMetrics`;DOM 只把结论翻译成 CSS/DOM。
|
|
|
|
|
+import {
|
|
|
|
|
+ MASTER_SOURCE,
|
|
|
|
|
+ MARKER_COEFFICIENTS,
|
|
|
|
|
+ RING_EFFECT,
|
|
|
|
|
+ markerDevKnobs,
|
|
|
|
|
+ RING_STYLE,
|
|
|
|
|
+ blinkOpacityAt,
|
|
|
|
|
+ glowScaleAt,
|
|
|
|
|
+ glowStrengthAt,
|
|
|
|
|
+ ringAlphaAt,
|
|
|
|
|
+ ringPhaseOffsetOf,
|
|
|
|
|
+ ringScaleAt,
|
|
|
|
|
+} from '../domain/markerMetrics.js'
|
|
|
|
|
+// 票 83:几何读数(本体宽 / 绘制宽 / pad)与计划读数同一出口,走查不读 DOM 内部结构。
|
|
|
|
|
+import { markerGeometryDataset } from '../domain/markerPlan.js'
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
* 类名单源:注入的特效 CSS(下方模板串)与 DOM 构建(buildNode/mount)共用同一组常量。
|
|
* 类名单源:注入的特效 CSS(下方模板串)与 DOM 构建(buildNode/mount)共用同一组常量。
|
|
@@ -40,96 +56,158 @@ import { deviceVisualForStatus } from '../domain/deviceStatus.js'
|
|
|
const OVERLAY_HOST_CLASS = 'marker-overlay-host'
|
|
const OVERLAY_HOST_CLASS = 'marker-overlay-host'
|
|
|
const NODE_CLASS = 'marker-overlay-node'
|
|
const NODE_CLASS = 'marker-overlay-node'
|
|
|
const LABEL_CLASS = 'marker-overlay-label'
|
|
const LABEL_CLASS = 'marker-overlay-label'
|
|
|
|
|
+/** 标签独立图层(2026-10-28):标签要盖在所有图标之上,必须脱离节点自身的层叠上下文。 */
|
|
|
|
|
+const LABEL_LAYER_CLASS = 'marker-overlay-labels'
|
|
|
|
|
+/** 标签的零尺寸锚点(随节点位移;真正的标签盒子在它内部)。 */
|
|
|
|
|
+const LABEL_WRAP_CLASS = 'marker-overlay-label-wrap'
|
|
|
const FX_CLASS = 'marker-overlay-fx'
|
|
const FX_CLASS = 'marker-overlay-fx'
|
|
|
const FX_RING_CLASS = 'marker-overlay-fx-ring'
|
|
const FX_RING_CLASS = 'marker-overlay-fx-ring'
|
|
|
const FX_GLOW_CLASS = 'marker-overlay-fx-glow'
|
|
const FX_GLOW_CLASS = 'marker-overlay-fx-glow'
|
|
|
|
|
+const FX_SPARK_CLASS = 'marker-overlay-fx-spark'
|
|
|
|
|
+const FX_TICK_CLASS = 'marker-overlay-fx-tick'
|
|
|
|
|
+
|
|
|
|
|
+/** 关键帧采样相位(9 个停点足够平滑,且让「任何时刻总有一层在前 1/4」可断言)。 */
|
|
|
|
|
+const FX_SAMPLE_PHASES = [0, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875, 1]
|
|
|
|
|
+/** 刻度环/工单齿轮的旋转周期:由 RING_STYLE.tickDegPerSec 反推(20°/s → 18s)。 */
|
|
|
|
|
+const FX_TICK_PERIOD_MS = Math.round((360 / RING_STYLE.tickDegPerSec) * 1000)
|
|
|
|
|
+
|
|
|
|
|
+/** 单层环的关键帧名:每层 alpha 指数不同,所以按 (effect, layer) 生成,不共用一套曲线。 */
|
|
|
|
|
+function fxRingKeyframeName(effect, layer) {
|
|
|
|
|
+ return `markerFxRing--${effect}--${layer}`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 从单源纯函数采样生成环的关键帧(scale/opacity/brightness 都来自 markerMetrics)。 */
|
|
|
|
|
+function fxRingKeyframes(status, profile, layer) {
|
|
|
|
|
+ const stops = FX_SAMPLE_PHASES.map((phase) => {
|
|
|
|
|
+ const timeMs = phase * profile.periodMs
|
|
|
|
|
+ const scale = ringScaleAt(status, layer, timeMs).toFixed(3)
|
|
|
|
|
+ const alpha = ringAlphaAt(status, layer, timeMs).toFixed(3)
|
|
|
|
|
+ // 自发光亮度包络(2026-10-27 恢复已验收规格 2.0 → 0.8):
|
|
|
|
|
+ // 只靠 opacity 会显得「灰」——发射瞬间必须亮一下才有能量感。
|
|
|
|
|
+ const brightness = (
|
|
|
|
|
+ MARKER_COEFFICIENTS.ringBrightnessStart
|
|
|
|
|
+ - (MARKER_COEFFICIENTS.ringBrightnessStart - MARKER_COEFFICIENTS.ringBrightnessEnd) * phase
|
|
|
|
|
+ ).toFixed(2)
|
|
|
|
|
+ return ` ${(phase * 100).toFixed(1)}% { transform:translate(-50%,-50%) scale(${scale}); opacity:${alpha}; filter:brightness(${brightness}); }`
|
|
|
|
|
+ })
|
|
|
|
|
+ return `@keyframes ${fxRingKeyframeName(profile.effect, layer)} {\n${stops.join('\n')}\n}`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 工单处置:只旋转不扩散(scale 固定 1),不透明度按已验收包络 0.95 → 0.68 呼吸。 */
|
|
|
|
|
+function fxSpinKeyframes(profile) {
|
|
|
|
|
+ const { spinAlphaStart, spinAlphaEnd } = MARKER_COEFFICIENTS
|
|
|
|
|
+ return `@keyframes markerFxSpin {\n from { transform:translate(-50%,-50%) rotate(0deg); opacity:${spinAlphaStart}; }\n to { transform:translate(-50%,-50%) rotate(360deg); opacity:${spinAlphaEnd}; }\n}`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 中心辉光(只给报警):尺寸/亮度/不透明度同样从单源纯函数与系数采样。 */
|
|
|
|
|
+function fxGlowKeyframes() {
|
|
|
|
|
+ const profile = RING_EFFECT.ALARM
|
|
|
|
|
+ const { glowAlphaMin, glowAlphaPeak, glowBrightnessMin, glowBrightnessPeak } = MARKER_COEFFICIENTS
|
|
|
|
|
+ const stops = FX_SAMPLE_PHASES.map((phase) => {
|
|
|
|
|
+ const timeMs = phase * profile.glowPeriodMs
|
|
|
|
|
+ const strength = glowStrengthAt(timeMs)
|
|
|
|
|
+ // 已验收包络:不透明度 0.32 → 0.85、亮度 1.2 → 1.9。
|
|
|
|
|
+ // 此前被压成固定 0.32 且无亮度提亮 —— 报警的「核心在烧」观感就是这么丢的。
|
|
|
|
|
+ const alpha = (glowAlphaMin + (glowAlphaPeak - glowAlphaMin) * strength).toFixed(3)
|
|
|
|
|
+ const brightness = (glowBrightnessMin + (glowBrightnessPeak - glowBrightnessMin) * strength).toFixed(2)
|
|
|
|
|
+ return ` ${(phase * 100).toFixed(1)}% { transform:translate(-50%,-50%) scale(${glowScaleAt(timeMs).toFixed(3)}); opacity:${alpha}; filter:brightness(${brightness}); }`
|
|
|
|
|
+ })
|
|
|
|
|
+ return `@keyframes markerFxGlow {\n${stops.join('\n')}\n}`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 环关键帧(票 86:DEV 旋钮变更后要能重建,所以不是模块级常量)。 */
|
|
|
|
|
+function ringKeyframesCss() {
|
|
|
|
|
+ return [
|
|
|
|
|
+ ...Object.values(RING_EFFECT)
|
|
|
|
|
+ .filter((profile) => profile.effect !== 'spin')
|
|
|
|
|
+ .flatMap((profile) => Array.from({ length: profile.layers }, (_, layer) => fxRingKeyframes(profile.status, profile, layer))),
|
|
|
|
|
+ fxSpinKeyframes(RING_EFFECT.WORK_ORDER),
|
|
|
|
|
+ fxGlowKeyframes(),
|
|
|
|
|
+ ].join('\n')
|
|
|
|
|
+}
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
- * 特效层样式(票 45):keyframes 无法用行内 cssText 表达,所以注入一次 <style>。
|
|
|
|
|
- * 环带 = 径向渐变(透明度 52% → 高亮 70% → 渐隐),对比更强烈;阴影更小但扩散节奏更快。
|
|
|
|
|
- * 锚点是**节点原点**即图标尖端落地位置(不是帽心),这样辐射环直接标记地面目标点。
|
|
|
|
|
- *
|
|
|
|
|
- * 票 70 走查反馈②(2026-10-21):扩散/旋转峰值统一读 `--fx-profile-scale`
|
|
|
|
|
- * (domain EFFECT_PROFILE 单源——预警此前 1.22 峰值探不出图标 = 「看不出动态」的根因);
|
|
|
|
|
- * 齿轮环 keyframes 的 scale 改为 profile 驱动(此前 0.9/1.12 写死,profile 的 scale/alpha 无人消费);
|
|
|
|
|
- * 中心辉光尺寸/亮度配合报警存在感提升。
|
|
|
|
|
|
|
+ * 特效层样式(票 45 起,票 84 改写):
|
|
|
|
|
+ * · 环带 = **近白硬边带**(不再是把状态色烘进平滑渐变),锐利才读作「能量」;
|
|
|
|
|
+ * · scale/opacity 关键帧由 `markerMetrics` 的纯函数采样生成(起始 0.55 / 峰值 2.6 /
|
|
|
|
|
+ * alpha 峰值 0.35–0.5 / 内层慢外层快的 alpha 指数)——渲染层不写死任何环数值;
|
|
|
|
|
+ * · 相位去同步:层间均分 + 每对象哈希偏移,靠 `animation-delay`(负值 = 开播即处于该相位);
|
|
|
|
|
+ * · 粒子感:3 个火花(径向推进)+ 2 层刻度环(反向旋转),数量来自 `RING_STYLE`;
|
|
|
|
|
+ * · 锚点 = 节点原点(图标尖端落地位置),环直接标记地面目标点;
|
|
|
|
|
+ * · **不做** reduced-motion 降级(票 72 D10:维护者明确「不做冗余设计」)。
|
|
|
*/
|
|
*/
|
|
|
-const FX_CSS = `
|
|
|
|
|
-/* 票 76(标签规格,2026-10-26 终版):标签**常态显示**(监测点 / 普通设备点 / 中心设备 / 聚合点)。
|
|
|
|
|
- 圈内设备(--fan)**一律不显示顶部标签** —— 维护者裁定:
|
|
|
|
|
- · 环内设备密集,顶部标签互相压叠、还会被图案与特效淹没看不清;
|
|
|
|
|
- · 它们的完整名称由**右下角 tooltip**(paintMarkerTooltip,跟随鼠标)承担,一份就够。
|
|
|
|
|
- 因此这里不是「hover 才显示」,而是**根本没有**(连 hover 也不显示)。 */
|
|
|
|
|
-.${NODE_CLASS}--fan .${LABEL_CLASS} { display:none !important; }
|
|
|
|
|
-/* 选择态标识(2026-10-26 二次修正):发光用**点位本体颜色语义**(--marker-tone = 状态色),
|
|
|
|
|
- 不再用白光。两处都生效、两种进入方式共用:
|
|
|
|
|
- ① 聚合点被点开(选择态);
|
|
|
|
|
- ② 选中设备替换中心后,中心设备本身也是选择态(可再点回选择态)。
|
|
|
|
|
- --marker-tone 由节点构建时写入(见 buildNode),不靠 currentColor 兜底 ——
|
|
|
|
|
- 母版白色描边会让 currentColor 落成白色,这正是上一版「还是白光」的原因。 */
|
|
|
|
|
|
|
+function buildFxCss() {
|
|
|
|
|
+ return `
|
|
|
|
|
+/* 票 76 曾对圈内设备(--fan)隐藏顶部标签;2026-10-27 维护者走查要求**子设备点也要标签**,
|
|
|
|
|
+ 因此该隐藏规则移除(标签常态显示,hover 仍读 title 全名)。 */
|
|
|
|
|
+/* 选择态标识:发光用**点位本体颜色语义**(--marker-tone = 状态色),不用白光。
|
|
|
|
|
+ 两处都生效:聚合点被点开、以及选中设备替换中心后中心设备本身。 */
|
|
|
.${NODE_CLASS}--active { z-index:2; }
|
|
.${NODE_CLASS}--active { z-index:2; }
|
|
|
-/* 票 76(四态,2026-10-26 修「第 3 步点不动」):聚合节点在设备节点**之后**追加到 DOM,
|
|
|
|
|
- 天然盖在中心设备上方 —— 聚合节点的 pointer-events:auto 会把对中心设备的点击全部吃掉。
|
|
|
|
|
- ③④ 的中心设备必须压过聚合节点,否则「点中心设备进 ④」永远不触发。
|
|
|
|
|
- 中心设备给比 --active 更高的层级,且聚合节点在中心存在时降为 pointer-events:none。 */
|
|
|
|
|
.${NODE_CLASS}--center { z-index:3; }
|
|
.${NODE_CLASS}--center { z-index:3; }
|
|
|
.${NODE_CLASS}--aggregate.${NODE_CLASS}--replaced { pointer-events:none; }
|
|
.${NODE_CLASS}--aggregate.${NODE_CLASS}--replaced { pointer-events:none; }
|
|
|
.${NODE_CLASS}--active svg {
|
|
.${NODE_CLASS}--active svg {
|
|
|
filter:drop-shadow(0 0 6px var(--marker-tone, currentColor)) drop-shadow(0 0 14px var(--marker-tone, currentColor)) brightness(1.18);
|
|
filter:drop-shadow(0 0 6px var(--marker-tone, currentColor)) drop-shadow(0 0 14px var(--marker-tone, currentColor)) brightness(1.18);
|
|
|
}
|
|
}
|
|
|
-/* 票 76(四态规格 2026-10-26):② 与 ④ 都是「被选择态」 —— 发光 + **隐藏自身标签**
|
|
|
|
|
- (② 隐藏聚合标签、④ 隐藏中心设备标签),hover 圈内设备才出全名。 */
|
|
|
|
|
-.${NODE_CLASS}--active .${LABEL_CLASS} { display:none; }
|
|
|
|
|
-.${NODE_CLASS}--active:hover .${LABEL_CLASS} { display:block; }
|
|
|
|
|
-.${FX_CLASS} { position:absolute; pointer-events:none; transform-origin:50% 50%; }
|
|
|
|
|
|
|
+/* 2026-10-28:标签显隐**不再走 CSS 后代选择器** —— 标签已搬进独立图层(不再是节点的后代),
|
|
|
|
|
+ 而且规格里「圈内设备 hover 才显示」「选择态隐藏标签」「非聚合设备点常显」是**状态**规则,
|
|
|
|
|
+ 统一由 applyLabelState() 驱动(见 syncMarkerOverlay 的三处调用)。
|
|
|
|
|
+ 旧的两条 --active + 标签 的 display 规则已退役(它们现在匹配不到任何元素)。 */
|
|
|
|
|
+.${FX_CLASS} { position:absolute; pointer-events:none; transform-origin:50% 50%; will-change:transform; }
|
|
|
.${FX_RING_CLASS} {
|
|
.${FX_RING_CLASS} {
|
|
|
position:absolute; left:50%; top:50%;
|
|
position:absolute; left:50%; top:50%;
|
|
|
border-radius:50%;
|
|
border-radius:50%;
|
|
|
- background:radial-gradient(circle, rgba(0,0,0,0) 52%, currentColor 70%, rgba(0,0,0,0) 98%);
|
|
|
|
|
|
|
+ /* 彩色注能环(2026-10-27 恢复已验收规格):环体用**状态色**(currentColor),
|
|
|
|
|
+ 内沿到外沿按可见带旋钮收口;此前换成白色硬边带后,维护者走查报「特效非常微弱」——
|
|
|
|
|
+ 白带在浅底图与薄雾上几乎没有对比度,而彩色环才有「能量」语义。 */
|
|
|
|
|
+ background:radial-gradient(circle,
|
|
|
|
|
+ rgba(0,0,0,0) calc(var(--fx-band-in, 88%) - 16%),
|
|
|
|
|
+ currentColor var(--fx-band-in, 88%),
|
|
|
|
|
+ rgba(0,0,0,0) calc(var(--fx-band-out, 93%) + 7%));
|
|
|
transform:translate(-50%,-50%);
|
|
transform:translate(-50%,-50%);
|
|
|
- animation:markerFxExpand var(--fx-period,1800ms) linear infinite;
|
|
|
|
|
- /* 减小静态光环,但增强扩散对比 */
|
|
|
|
|
|
|
+ animation-timing-function:linear; animation-iteration-count:infinite;
|
|
|
|
|
+ /* 彩色外发光(已验收值 6px/16px):这是「亮度感」的主要来源之一,不能省。 */
|
|
|
box-shadow:0 0 6px currentColor, 0 0 16px currentColor;
|
|
box-shadow:0 0 6px currentColor, 0 0 16px currentColor;
|
|
|
will-change:transform,opacity;
|
|
will-change:transform,opacity;
|
|
|
}
|
|
}
|
|
|
-.${FX_GLOW_CLASS} {
|
|
|
|
|
- position:absolute; left:50%; top:50%;
|
|
|
|
|
- border-radius:50%;
|
|
|
|
|
- background:radial-gradient(circle, currentColor 0%, color-mix(in srgb, currentColor 42%, transparent) 30%, rgba(0,0,0,0) 72%);
|
|
|
|
|
- box-shadow:0 0 18px currentColor, 0 0 40px currentColor;
|
|
|
|
|
- transform:translate(-50%,-50%);
|
|
|
|
|
- animation:markerFxGlow var(--fx-glow-period,2400ms) ease-in-out infinite;
|
|
|
|
|
- will-change:transform,opacity;
|
|
|
|
|
-}
|
|
|
|
|
-.${FX_CLASS}--blink .${FX_RING_CLASS} {
|
|
|
|
|
- animation:markerFxExpand var(--fx-blink-period,1400ms) linear infinite,
|
|
|
|
|
- markerFxFlicker ${EFFECT_PERIOD_MS.BLINK}ms steps(2, jump-none) infinite;
|
|
|
|
|
-}
|
|
|
|
|
.${FX_CLASS}--spin .${FX_RING_CLASS} {
|
|
.${FX_CLASS}--spin .${FX_RING_CLASS} {
|
|
|
background:none;
|
|
background:none;
|
|
|
border:2px dashed currentColor;
|
|
border:2px dashed currentColor;
|
|
|
- animation:markerFxSpin var(--fx-spin-period,3700ms) linear infinite;
|
|
|
|
|
|
|
+ filter:drop-shadow(0 0 3px currentColor);
|
|
|
}
|
|
}
|
|
|
-@keyframes markerFxExpand {
|
|
|
|
|
- 0% { transform:translate(-50%,-50%) scale(0.1); opacity:1; filter:brightness(2); }
|
|
|
|
|
- 35% { opacity:0.85; filter:brightness(1.6); }
|
|
|
|
|
- 70% { opacity:0.4; filter:brightness(1.1); }
|
|
|
|
|
- 100% { transform:translate(-50%,-50%) scale(var(--fx-profile-scale, 1)); opacity:0; filter:brightness(0.8); }
|
|
|
|
|
|
|
+.${FX_SPARK_CLASS} {
|
|
|
|
|
+ position:absolute; left:50%; top:50%;
|
|
|
|
|
+ width:3px; height:3px; border-radius:50%;
|
|
|
|
|
+ background:currentColor; box-shadow:0 0 4px currentColor;
|
|
|
|
|
+ animation:markerFxSpark var(--fx-period,1800ms) linear infinite;
|
|
|
|
|
+ will-change:transform,opacity;
|
|
|
}
|
|
}
|
|
|
-@keyframes markerFxGlow {
|
|
|
|
|
- 0%, 100% { transform:translate(-50%,-50%) scale(0.75); opacity:0.32; filter:brightness(1.2); }
|
|
|
|
|
- 50% { transform:translate(-50%,-50%) scale(1.45); opacity:0.85; filter:brightness(1.9); }
|
|
|
|
|
|
|
+@keyframes markerFxSpark {
|
|
|
|
|
+ 0% { opacity:0; transform:translate(-50%,-50%) rotate(var(--fx-spark-angle,0deg)) translateY(0) scale(0.6); }
|
|
|
|
|
+ 20% { opacity:0.85; }
|
|
|
|
|
+ 100% { opacity:0; transform:translate(-50%,-50%) rotate(var(--fx-spark-angle,0deg)) translateY(calc(var(--fx-box,32px) * -0.42)) scale(1); }
|
|
|
}
|
|
}
|
|
|
-@keyframes markerFxFlicker {
|
|
|
|
|
- 0%, 100% { opacity:1; }
|
|
|
|
|
- 50% { opacity:0.28; }
|
|
|
|
|
|
|
+.${FX_TICK_CLASS} {
|
|
|
|
|
+ position:absolute; left:50%; top:50%;
|
|
|
|
|
+ border-radius:50%;
|
|
|
|
|
+ border:1px dashed currentColor;
|
|
|
|
|
+ opacity:0.16;
|
|
|
|
|
+ transform:translate(-50%,-50%);
|
|
|
|
|
+ animation:markerFxTick ${FX_TICK_PERIOD_MS}ms linear infinite;
|
|
|
}
|
|
}
|
|
|
-@keyframes markerFxSpin {
|
|
|
|
|
- from { transform:translate(-50%,-50%) rotate(0deg) scale(calc(var(--fx-profile-scale, 1) * 0.9)); opacity:0.95; }
|
|
|
|
|
- to { transform:translate(-50%,-50%) rotate(360deg) scale(calc(var(--fx-profile-scale, 1) * 1.12)); opacity:0.68; }
|
|
|
|
|
|
|
+@keyframes markerFxTick { to { transform:translate(-50%,-50%) rotate(360deg); } }
|
|
|
|
|
+.${FX_GLOW_CLASS} {
|
|
|
|
|
+ position:absolute; left:50%; top:50%;
|
|
|
|
|
+ border-radius:50%;
|
|
|
|
|
+ background:radial-gradient(circle, currentColor 0%, color-mix(in srgb, currentColor 42%, transparent) 30%, rgba(0,0,0,0) 72%);
|
|
|
|
|
+ transform:translate(-50%,-50%);
|
|
|
|
|
+ animation:markerFxGlow var(--fx-glow-period,2400ms) ease-in-out infinite;
|
|
|
|
|
+ /* 中心辉光的外发光(已验收值 18px/40px):报警的「核心在烧」观感全靠它。 */
|
|
|
|
|
+ box-shadow:0 0 18px currentColor, 0 0 40px currentColor;
|
|
|
|
|
+ will-change:transform,opacity;
|
|
|
}
|
|
}
|
|
|
-@media (prefers-reduced-motion: reduce) { .${FX_CLASS} { display:none; } }
|
|
|
|
|
|
|
+${ringKeyframesCss()}
|
|
|
`
|
|
`
|
|
|
|
|
+}
|
|
|
const FX_STYLE_ID = 'marker-overlay-fx-styles'
|
|
const FX_STYLE_ID = 'marker-overlay-fx-styles'
|
|
|
|
|
|
|
|
/** 注入一次特效样式(幂等:已存在就跳过)。 */
|
|
/** 注入一次特效样式(幂等:已存在就跳过)。 */
|
|
@@ -138,12 +216,29 @@ function ensureFxStyles() {
|
|
|
if (document.getElementById(FX_STYLE_ID)) return
|
|
if (document.getElementById(FX_STYLE_ID)) return
|
|
|
const style = document.createElement('style')
|
|
const style = document.createElement('style')
|
|
|
style.id = FX_STYLE_ID
|
|
style.id = FX_STYLE_ID
|
|
|
- style.textContent = FX_CSS
|
|
|
|
|
|
|
+ style.textContent = buildFxCss()
|
|
|
document.head.appendChild(style)
|
|
document.head.appendChild(style)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+/**
|
|
|
|
|
+ * 票 86:DEV 旋钮变更后重建环样式与节点(关键帧/带型都按新参数重新生成)。
|
|
|
|
|
+ * 只在开发钩子里调用;生产路径不会走到。
|
|
|
|
|
+ */
|
|
|
|
|
+export function refreshMarkerFx() {
|
|
|
|
|
+ const style = typeof document !== 'undefined' ? document.getElementById(FX_STYLE_ID) : null
|
|
|
|
|
+ if (style) style.textContent = buildFxCss()
|
|
|
|
|
+ for (const record of pool.values()) {
|
|
|
|
|
+ if (record.root && record.root.parentNode) record.root.parentNode.removeChild(record.root)
|
|
|
|
|
+ }
|
|
|
|
|
+ pool.clear()
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
/** 每帧由 `syncMarkerOverlay` 注入的门控查询(mapService 从 domain 动效计划产出)。 */
|
|
/** 每帧由 `syncMarkerOverlay` 注入的门控查询(mapService 从 domain 动效计划产出)。 */
|
|
|
let frameEffectOf = () => ''
|
|
let frameEffectOf = () => ''
|
|
|
|
|
+/** 票 85:降级收敛系数(null = 未降级);同一屏所有环共享。 */
|
|
|
|
|
+let frameConverge = null
|
|
|
|
|
+/** 关联设备层的门控查询(票 84:三类对象的环统一走同一条门控)。 */
|
|
|
|
|
+let frameDeviceEffectOf = () => ''
|
|
|
|
|
|
|
|
/** 状态 → 特效 profile(domain 单源;DOM 层数/亮度/辉光只消费这一份表,不另造)。 */
|
|
/** 状态 → 特效 profile(domain 单源;DOM 层数/亮度/辉光只消费这一份表,不另造)。 */
|
|
|
function markerFxProfile(status) {
|
|
function markerFxProfile(status) {
|
|
@@ -161,51 +256,84 @@ function fxOf(marker) {
|
|
|
if (!effect) return null
|
|
if (!effect) return null
|
|
|
const profile = markerFxProfile(marker.status)
|
|
const profile = markerFxProfile(marker.status)
|
|
|
if (!profile || profile.effect !== effect) return null
|
|
if (!profile || profile.effect !== effect) return null
|
|
|
- return { effect, profile }
|
|
|
|
|
|
|
+ return { effect, profile, converge: frameConverge }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
- * 环层数=状态 profile 的 layers(报警 4 / 预警 3 / 工单 2;辉光不计入层读数,只给报警)。
|
|
|
|
|
- * C3f 口径:层数只从 profile 消费,DOM 侧不许写死。
|
|
|
|
|
|
|
+ * 环层数=状态 profile 的 layers(报警 4 / 预警 3 / 工单 1)。
|
|
|
|
|
+ * 层数只从 profile 消费,DOM 侧不许写死。
|
|
|
*/
|
|
*/
|
|
|
function markerEffectLayers(profile) {
|
|
function markerEffectLayers(profile) {
|
|
|
return profile.layers
|
|
return profile.layers
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
- * 特效 DOM(票 70 重做):固定 `markerEffectLayers(profile)`(+报警辉光 1)个节点,
|
|
|
|
|
- * CSS 动画驱动、JS 不逐帧更新。容器几何:`left:${-box / 2}px` 使其**中心 = 节点原点**
|
|
|
|
|
- * (图标尖端落地点),辐射环直接标记地面目标;`currentColor` 由 tone 染色(与标记本体同色)。
|
|
|
|
|
- * 周期变量全部来自 domain 常量(节奏单源):环 `RING_PERIOD_MS`、辉光 `GLOW_PERIOD_MS`。
|
|
|
|
|
|
|
+ * 特效 DOM(票 70 重做,票 84 改写):固定 `profile.layers` 个环节点 + 3 火花 + 2 刻度环
|
|
|
|
|
+ * (+报警辉光 1)。CSS 动画驱动、JS 不逐帧更新。容器几何:`left:-box/2` 使其中心 = 节点原点。
|
|
|
|
|
+ * 相位偏移按 `phaseKey`(kind|code)哈希,保证同屏对象不同节拍、同 id 可复算。
|
|
|
*/
|
|
*/
|
|
|
-function buildFxLayers(fx, plan, toneHex) {
|
|
|
|
|
- const { effect, profile } = fx
|
|
|
|
|
- const period = RING_PERIOD_MS[effect] || RING_PERIOD_MS[DEVICE_EFFECT.PULSE]
|
|
|
|
|
|
|
+function buildFxLayers(fx, plan, toneHex, phaseKey = '') {
|
|
|
|
|
+ const { profile } = fx
|
|
|
|
|
+ // 票 85:降级时环不消失 —— 整组环按收敛系数缩小(峰值 ×0.6)并压暗(alpha ×0.5),周期不变。
|
|
|
|
|
+ const converge = fx.converge || null
|
|
|
|
|
+ const convergePeak = converge ? converge.peak : 1
|
|
|
|
|
+ const convergeAlpha = converge ? converge.alpha : 1
|
|
|
const box = plan.draw.width
|
|
const box = plan.draw.width
|
|
|
|
|
+ const phaseOffset = ringPhaseOffsetOf(phaseKey)
|
|
|
const host = document.createElement('div')
|
|
const host = document.createElement('div')
|
|
|
- host.className = `${FX_CLASS} ${FX_CLASS}--${effect}`
|
|
|
|
|
|
|
+ host.className = `${FX_CLASS} ${FX_CLASS}--${profile.effect}`
|
|
|
host.style.cssText = `position:absolute;left:${-box / 2}px;top:${-box / 2}px;width:${box}px;height:${box}px;color:${toneHex};`
|
|
host.style.cssText = `position:absolute;left:${-box / 2}px;top:${-box / 2}px;width:${box}px;height:${box}px;color:${toneHex};`
|
|
|
- host.style.setProperty('--fx-period', `${period}ms`)
|
|
|
|
|
- host.style.setProperty('--fx-glow-period', `${GLOW_PERIOD_MS}ms`)
|
|
|
|
|
- host.style.setProperty('--fx-profile-scale', String(profile.scale))
|
|
|
|
|
- if (effect === DEVICE_EFFECT.BLINK) host.style.setProperty('--fx-blink-period', `${period}ms`)
|
|
|
|
|
- if (effect === DEVICE_EFFECT.SPIN) host.style.setProperty('--fx-spin-period', `${period}ms`)
|
|
|
|
|
- // 均分错相:第 i 层延迟 -period*i/layers(负延迟=开播即处于各自相位,形成连续外扩)。
|
|
|
|
|
|
|
+ // 收敛量写进 dataset:走查能读出"这条环是不是降级态",选中 boost 也乘在它上面。
|
|
|
|
|
+ host.dataset.fxBaseScale = String(convergePeak)
|
|
|
|
|
+ host.dataset.fxConverged = converge ? 'yes' : 'no'
|
|
|
|
|
+ host.style.opacity = String(convergeAlpha)
|
|
|
|
|
+ if (convergePeak !== 1) host.style.transform = `scale(${convergePeak})`
|
|
|
|
|
+ host.style.setProperty('--fx-box', `${box}px`)
|
|
|
|
|
+ host.style.setProperty('--fx-period', `${profile.periodMs}ms`)
|
|
|
|
|
+ host.style.setProperty('--fx-start-scale', String(profile.startScale))
|
|
|
|
|
+ host.style.setProperty('--fx-peak-scale', String(profile.scale))
|
|
|
|
|
+ host.style.setProperty('--fx-alpha-peak', String(profile.alpha))
|
|
|
|
|
+ host.style.setProperty('--fx-glow-period', `${RING_EFFECT.ALARM.glowPeriodMs}ms`)
|
|
|
|
|
+ // 票 86:可见带占比是 DEV 旋钮(内沿固定 88%,外沿 = 内沿 + 占比)。
|
|
|
|
|
+ const bandWidth = markerDevKnobs().bandWidth
|
|
|
|
|
+ host.style.setProperty('--fx-band-in', `${Math.round(RING_STYLE.bandInner * 100)}%`)
|
|
|
|
|
+ host.style.setProperty('--fx-band-out', `${Math.round((RING_STYLE.bandInner + bandWidth) * 100)}%`)
|
|
|
for (let layer = 0; layer < markerEffectLayers(profile); layer += 1) {
|
|
for (let layer = 0; layer < markerEffectLayers(profile); layer += 1) {
|
|
|
const ring = document.createElement('div')
|
|
const ring = document.createElement('div')
|
|
|
ring.className = FX_RING_CLASS
|
|
ring.className = FX_RING_CLASS
|
|
|
ring.style.width = `${box}px`
|
|
ring.style.width = `${box}px`
|
|
|
ring.style.height = `${box}px`
|
|
ring.style.height = `${box}px`
|
|
|
- ring.style.animationDelay = `${-(period / markerEffectLayers(profile)) * layer}ms`
|
|
|
|
|
|
|
+ ring.style.animationName = profile.effect === DEVICE_EFFECT.SPIN ? 'markerFxSpin' : fxRingKeyframeName(profile.effect, layer)
|
|
|
|
|
+ ring.style.animationDuration = `${profile.periodMs}ms`
|
|
|
|
|
+ // 层间均分错相 + 每对象哈希偏移;负延迟 = 开播即处于该相位。
|
|
|
|
|
+ ring.style.animationDelay = `${-(profile.periodMs * ((layer / markerEffectLayers(profile)) + phaseOffset))}ms`
|
|
|
host.appendChild(ring)
|
|
host.appendChild(ring)
|
|
|
}
|
|
}
|
|
|
- // 中心辉光只给报警(EFFECT_PROFILE.glow;票 07 优先级语义,不给预警/工单)。
|
|
|
|
|
|
|
+ if (profile.effect !== DEVICE_EFFECT.SPIN) {
|
|
|
|
|
+ for (let index = 0; index < RING_STYLE.sparks; index += 1) {
|
|
|
|
|
+ const spark = document.createElement('div')
|
|
|
|
|
+ spark.className = FX_SPARK_CLASS
|
|
|
|
|
+ spark.style.setProperty('--fx-spark-angle', `${(360 / RING_STYLE.sparks) * index}deg`)
|
|
|
|
|
+ spark.style.animationDelay = `${-(profile.periodMs * ((index / RING_STYLE.sparks) + phaseOffset))}ms`
|
|
|
|
|
+ host.appendChild(spark)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ for (let index = 0; index < RING_STYLE.tickRings; index += 1) {
|
|
|
|
|
+ const tick = document.createElement('div')
|
|
|
|
|
+ tick.className = FX_TICK_CLASS
|
|
|
|
|
+ const size = Math.round(box * (0.55 + index * 0.17))
|
|
|
|
|
+ tick.style.width = `${size}px`
|
|
|
|
|
+ tick.style.height = `${size}px`
|
|
|
|
|
+ tick.style.animationDirection = index % 2 === 1 ? 'reverse' : 'normal'
|
|
|
|
|
+ host.appendChild(tick)
|
|
|
|
|
+ }
|
|
|
|
|
+ // 中心辉光只给报警(票 07 优先级语义,不给预警/工单)。
|
|
|
if (profile.glow) {
|
|
if (profile.glow) {
|
|
|
const glow = document.createElement('div')
|
|
const glow = document.createElement('div')
|
|
|
glow.className = FX_GLOW_CLASS
|
|
glow.className = FX_GLOW_CLASS
|
|
|
- const g = Math.round(box * 0.7)
|
|
|
|
|
- glow.style.width = `${g}px`
|
|
|
|
|
- glow.style.height = `${g}px`
|
|
|
|
|
|
|
+ const size = Math.round(box * 0.7)
|
|
|
|
|
+ glow.style.width = `${size}px`
|
|
|
|
|
+ glow.style.height = `${size}px`
|
|
|
host.appendChild(glow)
|
|
host.appendChild(glow)
|
|
|
}
|
|
}
|
|
|
return host
|
|
return host
|
|
@@ -231,6 +359,9 @@ function buildAggregateNode(key, row, toneHex, plan) {
|
|
|
root.dataset.markerOverlayCode = row.group
|
|
root.dataset.markerOverlayCode = row.group
|
|
|
root.dataset.markerOverlayAggregate = String(row.k)
|
|
root.dataset.markerOverlayAggregate = String(row.k)
|
|
|
root.dataset.markerOverlayPointCode = row.pointCode || ''
|
|
root.dataset.markerOverlayPointCode = row.pointCode || ''
|
|
|
|
|
+ // 票 83:聚合节点同样挂几何读数(聚合档 = 设备档);聚合符号恒不挂环。
|
|
|
|
|
+ Object.assign(root.dataset, markerGeometryDataset(plan))
|
|
|
|
|
+ root.dataset.markerRing = 'no'
|
|
|
root.style.cssText = [
|
|
root.style.cssText = [
|
|
|
'position:absolute',
|
|
'position:absolute',
|
|
|
'left:0', 'top:0',
|
|
'left:0', 'top:0',
|
|
@@ -296,51 +427,64 @@ function buildAggregateNode(key, row, toneHex, plan) {
|
|
|
svg.appendChild(group)
|
|
svg.appendChild(group)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- const label = document.createElement('div')
|
|
|
|
|
- label.className = LABEL_CLASS
|
|
|
|
|
- label.textContent = `聚合 ${row.k} 台${row.label ? ` · 组内${row.label}` : ''}`
|
|
|
|
|
- label.title = label.textContent
|
|
|
|
|
- label.style.cssText = [
|
|
|
|
|
- 'position:absolute',
|
|
|
|
|
- `bottom:${h + pad + 3}px`,
|
|
|
|
|
- 'left:0',
|
|
|
|
|
- 'transform:translateX(-50%)',
|
|
|
|
|
- 'box-sizing:content-box',
|
|
|
|
|
- 'max-width:170px',
|
|
|
|
|
- 'overflow:hidden',
|
|
|
|
|
- 'white-space:nowrap',
|
|
|
|
|
- 'text-overflow:ellipsis',
|
|
|
|
|
- 'padding:4px 9px',
|
|
|
|
|
- 'border-radius:8px',
|
|
|
|
|
- 'border:1px solid rgba(1,171,234,0.55)',
|
|
|
|
|
- 'background:rgba(2,20,40,0.82)',
|
|
|
|
|
- 'color:#d7ecff',
|
|
|
|
|
- 'font:600 12px "Microsoft YaHei UI","Microsoft YaHei","PingFang SC","Noto Sans SC",sans-serif',
|
|
|
|
|
- 'pointer-events:none',
|
|
|
|
|
- ].join(';')
|
|
|
|
|
- root.appendChild(label)
|
|
|
|
|
- return { root, icon: svg }
|
|
|
|
|
|
|
+ // 聚合标签同样挂在**独立图层**(2026-10-28):与设备/点位标签一致,永远盖在图标之上。
|
|
|
|
|
+ let labelWrap = null
|
|
|
|
|
+ let labelBox = null
|
|
|
|
|
+ const labelText = `聚合 ${row.k} 台${row.label ? ` · 组内${row.label}` : ''}`
|
|
|
|
|
+ if (labelLayer) {
|
|
|
|
|
+ labelWrap = document.createElement('div')
|
|
|
|
|
+ labelWrap.className = LABEL_WRAP_CLASS
|
|
|
|
|
+ labelWrap.style.cssText = 'position:absolute;left:0;top:0;width:0;height:0;pointer-events:none;'
|
|
|
|
|
+ labelBox = document.createElement('div')
|
|
|
|
|
+ labelBox.className = LABEL_CLASS
|
|
|
|
|
+ labelBox.textContent = labelText
|
|
|
|
|
+ labelBox.title = labelText
|
|
|
|
|
+ labelBox.style.cssText = [
|
|
|
|
|
+ 'position:absolute',
|
|
|
|
|
+ `bottom:${h + pad + 3}px`,
|
|
|
|
|
+ 'left:0',
|
|
|
|
|
+ 'transform:translateX(-50%)',
|
|
|
|
|
+ 'box-sizing:content-box',
|
|
|
|
|
+ 'max-width:170px',
|
|
|
|
|
+ 'overflow:hidden',
|
|
|
|
|
+ 'white-space:nowrap',
|
|
|
|
|
+ 'text-overflow:ellipsis',
|
|
|
|
|
+ 'padding:4px 9px',
|
|
|
|
|
+ 'border-radius:8px',
|
|
|
|
|
+ 'border:1px solid rgba(1,171,234,0.55)',
|
|
|
|
|
+ 'background:rgba(2,20,40,0.82)',
|
|
|
|
|
+ 'color:#d7ecff',
|
|
|
|
|
+ 'font:600 12px "Microsoft YaHei UI","Microsoft YaHei","PingFang SC","Noto Sans SC",sans-serif',
|
|
|
|
|
+ 'pointer-events:none',
|
|
|
|
|
+ ].join(';')
|
|
|
|
|
+ labelWrap.appendChild(labelBox)
|
|
|
|
|
+ labelLayer.appendChild(labelWrap)
|
|
|
|
|
+ }
|
|
|
|
|
+ return { root, icon: svg, labelWrap, labelBox, labelMaxWidth: '170px' }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
* 自绘水滴母版(bbox 坐标系:x∈[0,378]、y∈[0,475],尖端 (188.5,474))。
|
|
* 自绘水滴母版(bbox 坐标系:x∈[0,378]、y∈[0,475],尖端 (188.5,474))。
|
|
|
- * viewBox 外扩 40(同 billboard OUTLINE_PAD 裁剪窗)→ "-40 -40 458 555"。
|
|
|
|
|
|
|
+ * viewBox 外扩 = 单源 `MASTER_SOURCE.outlinePad`(40)推导(不再写死字面量)。
|
|
|
*
|
|
*
|
|
|
* 描边(浏览器走查修正):billboard 的 `withWhiteOutline` 是**向外膨胀** 28 源像素、
|
|
* 描边(浏览器走查修正):billboard 的 `withWhiteOutline` 是**向外膨胀** 28 源像素、
|
|
|
* 母版本身保持 378 全宽;SVG `stroke` 默认沿路径**中心线**(外 14 / 内 14)会把彩色母版
|
|
* 母版本身保持 378 全宽;SVG `stroke` 默认沿路径**中心线**(外 14 / 内 14)会把彩色母版
|
|
|
* 蚀掉一圈、白环也只出一半——同尺寸下 DOM 标记明显比 billboard 小。
|
|
* 蚀掉一圈、白环也只出一半——同尺寸下 DOM 标记明显比 billboard 小。
|
|
|
* 用 `paint-order="stroke fill"` 把描边压到填充**之下**:彩色母版全尺寸保留,
|
|
* 用 `paint-order="stroke fill"` 把描边压到填充**之下**:彩色母版全尺寸保留,
|
|
|
- * 白色纯外扩 `stroke-width/2` = 28 → 与 billboard 的 OUTLINE_WIDTH 同值(外沿 28 < pad 40 不被裁)。
|
|
|
|
|
|
|
+ * 白色纯外扩 `stroke-width/2` = 28 → 与 billboard 的描边同值(外沿 28 < pad 40 不被裁)。
|
|
|
*/
|
|
*/
|
|
|
const PIN_OUTLINE = 'M188.5 0A189 189 0 0 0 111.6 361.7C157.3 382 173.6 435.5 188.5 474C203.4 435.5 219.7 382 265.4 361.7A189 189 0 0 0 188.5 0Z'
|
|
const PIN_OUTLINE = 'M188.5 0A189 189 0 0 0 111.6 361.7C157.3 382 173.6 435.5 188.5 474C203.4 435.5 219.7 382 265.4 361.7A189 189 0 0 0 188.5 0Z'
|
|
|
const PIN_HOLE = 'M263.5 189a75 75 0 1 0-150 0 75 75 0 1 0 150 0Z'
|
|
const PIN_HOLE = 'M263.5 189a75 75 0 1 0-150 0 75 75 0 1 0 150 0Z'
|
|
|
-const PIN_STROKE = 'stroke="#ffffff" stroke-width="56" stroke-linejoin="round" paint-order="stroke fill"'
|
|
|
|
|
|
|
+// 描边宽度 = 源描边外扩 ×2(stroke 沿路径中心线,向外外扩一半)——单源不在字符串里写死 56。
|
|
|
|
|
+const PIN_STROKE = `stroke="#ffffff" stroke-width="${MASTER_SOURCE.outlineWidth * 2}" stroke-linejoin="round" paint-order="stroke fill"`
|
|
|
|
|
+/** SVG viewBox:bbox 四周外扩描边 pad(与 billboard 裁剪窗同形)。 */
|
|
|
|
|
+const MARKER_VIEWBOX = `${-MASTER_SOURCE.outlinePad} ${-MASTER_SOURCE.outlinePad} ${MASTER_SOURCE.bboxWidth + MASTER_SOURCE.outlinePad * 2} ${MASTER_SOURCE.bboxHeight + MASTER_SOURCE.outlinePad * 2}`
|
|
|
|
|
|
|
|
function markerSvg(master, toneHex) {
|
|
function markerSvg(master, toneHex) {
|
|
|
const body = master === 'hollow'
|
|
const body = master === 'hollow'
|
|
|
? `<path d="${PIN_OUTLINE}${PIN_HOLE}" fill="${toneHex}" fill-rule="evenodd" ${PIN_STROKE}/>`
|
|
? `<path d="${PIN_OUTLINE}${PIN_HOLE}" fill="${toneHex}" fill-rule="evenodd" ${PIN_STROKE}/>`
|
|
|
: `<path d="${PIN_OUTLINE}" fill="${toneHex}" ${PIN_STROKE}/>`
|
|
: `<path d="${PIN_OUTLINE}" fill="${toneHex}" ${PIN_STROKE}/>`
|
|
|
- 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>`
|
|
|
|
|
|
|
+ return `<svg viewBox="${MARKER_VIEWBOX}" width="100%" height="100%" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">${body}<image data-marker-overlay-inner="" x="0" y="0" width="0" height="0"/></svg>`
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/** 节点池:key → { root, inner, fp };标记消失时节点隐藏不销毁,计划读数变化时重建。 */
|
|
/** 节点池:key → { root, inner, fp };标记消失时节点隐藏不销毁,计划读数变化时重建。 */
|
|
@@ -352,13 +496,24 @@ const FP_SEP = '\u0001'
|
|
|
let hostNode = null
|
|
let hostNode = null
|
|
|
let hostRecord = null
|
|
let hostRecord = null
|
|
|
let postRenderRemove = null
|
|
let postRenderRemove = null
|
|
|
|
|
+/** 标签独立图层(宿主子节点);`null` = 尚未挂载。 */
|
|
|
|
|
+let labelLayer = null
|
|
|
|
|
+/**
|
|
|
|
|
+ * 当前被指针悬停的节点 key(2026-10-28)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 为什么由 JS 跟踪而不是 CSS `:hover`:标签搬进独立图层后不再是节点的后代,
|
|
|
|
|
+ * `节点:hover .标签` 这类选择器已经匹配不到;而"hover 才显示全名"是维护者规格的一部分。
|
|
|
|
|
+ */
|
|
|
|
|
+let hoveredLabelKey = ''
|
|
|
|
|
+/** 当前选中对象编码(帧状态给入;非聚合设备点的选择态高亮用)。 */
|
|
|
|
|
+let markerSelectedCode = ''
|
|
|
/** 临时 A/B 开关(票 47 走查):false = 隐藏 DOM 层、露出底下 billboard(之前版本)。 */
|
|
/** 临时 A/B 开关(票 47 走查):false = 隐藏 DOM 层、露出底下 billboard(之前版本)。 */
|
|
|
// 票 76(C6):A/B 显隐开关已退役 —— overlay 挂载即生效,billboard 视觉不再有「对照组」。
|
|
// 票 76(C6):A/B 显隐开关已退役 —— overlay 挂载即生效,billboard 视觉不再有「对照组」。
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
* 单个标记节点(特效层 + 标记 SVG + 可选标签),一次构建、每帧只更新 transform。
|
|
* 单个标记节点(特效层 + 标记 SVG + 可选标签),一次构建、每帧只更新 transform。
|
|
|
- * @param {object|null} fx `fxOf(marker)` 的产出(null = 无动效;关联设备恒 null——
|
|
|
|
|
- * 它们的特效留在 billboard 层,DOM 不画第二套环,同位双环会糊,票 72 遗留决策一约束)。
|
|
|
|
|
|
|
+ * @param {object|null} fx `fxOf(marker)` / 设备门控的产出(null = 无动效)。
|
|
|
|
|
+ * 票 84 起三类对象(监测点 / 关联设备 / 未关联设备)都按**自身状态**挂环,环的视觉唯一载体是 DOM。
|
|
|
*/
|
|
*/
|
|
|
function buildNode(key, marker, plan, toneHex, fx) {
|
|
function buildNode(key, marker, plan, toneHex, fx) {
|
|
|
const root = document.createElement('div')
|
|
const root = document.createElement('div')
|
|
@@ -372,11 +527,19 @@ function buildNode(key, marker, plan, toneHex, fx) {
|
|
|
root.dataset.markerAnchor = `${plan.anchor.x},${plan.anchor.y}`
|
|
root.dataset.markerAnchor = `${plan.anchor.x},${plan.anchor.y}`
|
|
|
root.dataset.markerDesaturate = plan.desaturate ? 'yes' : 'no'
|
|
root.dataset.markerDesaturate = plan.desaturate ? 'yes' : 'no'
|
|
|
root.dataset.markerTone = toneHex
|
|
root.dataset.markerTone = toneHex
|
|
|
|
|
+ // 几何读数面(票 83):本体宽 / 绘制宽 / pad —— 由合成计划单源摊平,不写死数值。
|
|
|
|
|
+ Object.assign(root.dataset, markerGeometryDataset(plan))
|
|
|
// 特效读数面(票 70 重做 / t45 C3d):无动效不挂键;挂 = 动效键 + profile 层数 + alpha 真值。
|
|
// 特效读数面(票 70 重做 / t45 C3d):无动效不挂键;挂 = 动效键 + profile 层数 + alpha 真值。
|
|
|
|
|
+ // 票 83 追加 `markerRing`(yes/no):三类对象统一用这一个键读「有没有环」。
|
|
|
|
|
+ root.dataset.markerRing = fx ? 'yes' : 'no'
|
|
|
if (fx) {
|
|
if (fx) {
|
|
|
root.dataset.markerEffect = fx.effect
|
|
root.dataset.markerEffect = fx.effect
|
|
|
root.dataset.markerEffectLayers = String(markerEffectLayers(fx.profile))
|
|
root.dataset.markerEffectLayers = String(markerEffectLayers(fx.profile))
|
|
|
- root.dataset.markerEffectAlpha = String(fx.profile.alpha)
|
|
|
|
|
|
|
+ // 票 85:读数写**生效值**(降级时已乘收敛系数),走查不必自己再算。
|
|
|
|
|
+ const alphaKnobFloor = markerDevKnobs().alphaFloor
|
|
|
|
|
+ const alphaEffective = Math.max(alphaKnobFloor, fx.converge ? fx.profile.alpha * fx.converge.alpha : fx.profile.alpha)
|
|
|
|
|
+ root.dataset.markerEffectAlpha = String(Number(alphaEffective.toFixed(3)))
|
|
|
|
|
+ root.dataset.markerEffectConverged = fx.converge ? 'yes' : 'no'
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
const w = plan.size.width
|
|
const w = plan.size.width
|
|
@@ -398,8 +561,9 @@ function buildNode(key, marker, plan, toneHex, fx) {
|
|
|
|
|
|
|
|
// 特效层在标记本体之下(先挂 fx、SVG 后画盖住环心,环带在图标外扩散可见)。
|
|
// 特效层在标记本体之下(先挂 fx、SVG 后画盖住环心,环带在图标外扩散可见)。
|
|
|
// 锚点是节点原点即尖端落地点(buildFxLayers 的 -box/2 偏移保证中心=原点)。
|
|
// 锚点是节点原点即尖端落地点(buildFxLayers 的 -box/2 偏移保证中心=原点)。
|
|
|
- if (fx) {
|
|
|
|
|
- root.appendChild(buildFxLayers(fx, plan, toneHex))
|
|
|
|
|
|
|
+ const fxHost = fx ? buildFxLayers(fx, plan, toneHex, key) : null
|
|
|
|
|
+ if (fxHost) {
|
|
|
|
|
+ root.appendChild(fxHost)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
const wrap = document.createElement('div')
|
|
const wrap = document.createElement('div')
|
|
@@ -431,15 +595,21 @@ function buildNode(key, marker, plan, toneHex, fx) {
|
|
|
innerEl.setAttribute('height', String(diameter))
|
|
innerEl.setAttribute('height', String(diameter))
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 标签:真 DOM。pointer-events:none —— 不拦截事件,相机拖拽与 Canvas 拾取照常穿过。
|
|
|
|
|
- // 限宽取计划层 `labelMaxWidth`(宽 × 2.6,billboard 同一单源)——浏览器走查修正:
|
|
|
|
|
- // 首版硬编码 220px,比 billboard 截断宽 3 倍,两层标签并现时一长一短明显不一致。
|
|
|
|
|
- if (marker.name) {
|
|
|
|
|
- const label = document.createElement('div')
|
|
|
|
|
- label.className = LABEL_CLASS
|
|
|
|
|
- label.textContent = marker.name
|
|
|
|
|
- label.title = marker.name // hover 读全(与 DOM 侧 EllipsisText 同语义)
|
|
|
|
|
- label.style.cssText = [
|
|
|
|
|
|
|
+ // 标签:真 DOM,且**挂在独立图层**(2026-10-28 维护者报「多点位时标签被图标遮挡」)。
|
|
|
|
|
+ // 节点自身带 `transform`(定位)→ 自成层叠上下文 → 标签留在节点内部就永远盖不到别的节点之上。
|
|
|
|
|
+ // 限宽取计划层 `labelMaxWidth`(宽 × 2.6,billboard 同一单源);hover 时放开限宽 = 显示全名。
|
|
|
|
|
+ let labelWrap = null
|
|
|
|
|
+ let labelBox = null
|
|
|
|
|
+ if (marker.name && labelLayer) {
|
|
|
|
|
+ labelWrap = document.createElement('div')
|
|
|
|
|
+ labelWrap.className = LABEL_WRAP_CLASS
|
|
|
|
|
+ // 零尺寸锚点:位移与节点同一次 transform,标签几何靠内层 `bottom/translateX(-50%)` 表达。
|
|
|
|
|
+ labelWrap.style.cssText = 'position:absolute;left:0;top:0;width:0;height:0;pointer-events:none;'
|
|
|
|
|
+ labelBox = document.createElement('div')
|
|
|
|
|
+ labelBox.className = LABEL_CLASS
|
|
|
|
|
+ labelBox.textContent = marker.name
|
|
|
|
|
+ labelBox.title = marker.name // hover 读全(与 DOM 侧 EllipsisText 同语义)
|
|
|
|
|
+ labelBox.style.cssText = [
|
|
|
'position:absolute',
|
|
'position:absolute',
|
|
|
`bottom:${h + pad + 3}px`,
|
|
`bottom:${h + pad + 3}px`,
|
|
|
'left:0',
|
|
'left:0',
|
|
@@ -458,10 +628,11 @@ function buildNode(key, marker, plan, toneHex, fx) {
|
|
|
'font:600 12px "Microsoft YaHei UI","Microsoft YaHei","PingFang SC","Noto Sans SC",sans-serif',
|
|
'font:600 12px "Microsoft YaHei UI","Microsoft YaHei","PingFang SC","Noto Sans SC",sans-serif',
|
|
|
'pointer-events:none',
|
|
'pointer-events:none',
|
|
|
].join(';')
|
|
].join(';')
|
|
|
- root.appendChild(label)
|
|
|
|
|
|
|
+ labelWrap.appendChild(labelBox)
|
|
|
|
|
+ labelLayer.appendChild(labelWrap)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- return { root, inner: innerEl, svg }
|
|
|
|
|
|
|
+ return { root, inner: innerEl, svg, fxHost, labelWrap, labelBox, labelMaxWidth: `${plan.labelMaxWidth}px` }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
@@ -484,6 +655,19 @@ export function mountMarkerOverlay(mapHost, { onPick, onHover } = {}) {
|
|
|
'z-index:1',
|
|
'z-index:1',
|
|
|
].join(';')
|
|
].join(';')
|
|
|
hostNode.style.display = ''
|
|
hostNode.style.display = ''
|
|
|
|
|
+ // 票 70(2026-10-28 维护者报「多点位时标签被坐标点图标遮挡」):
|
|
|
|
|
+ // 标签必须**独立成层**,不能留在节点内部 —— 节点带 `transform`(定位用)因而自成层叠上下文,
|
|
|
|
|
+ // 其子元素无论 z-index 多高都出不了这个上下文,于是后画的节点会盖住先画节点的标签。
|
|
|
|
|
+ // 这一层铺满宿主、`pointer-events:none`,层级高于所有节点(含 `--center` 的 3)。
|
|
|
|
|
+ labelLayer = document.createElement('div')
|
|
|
|
|
+ labelLayer.className = LABEL_LAYER_CLASS
|
|
|
|
|
+ labelLayer.style.cssText = [
|
|
|
|
|
+ 'position:absolute', 'inset:0',
|
|
|
|
|
+ 'pointer-events:none',
|
|
|
|
|
+ 'z-index:6',
|
|
|
|
|
+ ].join(';')
|
|
|
|
|
+ // 标签层是宿主的子节点、**先挂**(节点后追加不影响 —— 层级由 z-index 决定)。
|
|
|
|
|
+ hostNode.appendChild(labelLayer)
|
|
|
// 票 76(C4 parity 现场把手):DOM 单层比 billboard 双层暗(镂空孔/边缘不再叠亮层)。
|
|
// 票 76(C4 parity 现场把手):DOM 单层比 billboard 双层暗(镂空孔/边缘不再叠亮层)。
|
|
|
// `?pinBrightness=1.2` 只做整体亮度补偿(不碰色相/饱和度);定档后写成默认常量并撤掉把手。
|
|
// `?pinBrightness=1.2` 只做整体亮度补偿(不碰色相/饱和度);定档后写成默认常量并撤掉把手。
|
|
|
try {
|
|
try {
|
|
@@ -529,9 +713,12 @@ export function mountMarkerOverlay(mapHost, { onPick, onHover } = {}) {
|
|
|
if (typeof onHover !== 'function') return
|
|
if (typeof onHover !== 'function') return
|
|
|
const node = event.target && event.target.closest ? event.target.closest(`.${NODE_CLASS}`) : null
|
|
const node = event.target && event.target.closest ? event.target.closest(`.${NODE_CLASS}`) : null
|
|
|
if (!node) {
|
|
if (!node) {
|
|
|
|
|
+ hoveredLabelKey = ''
|
|
|
onHover(null)
|
|
onHover(null)
|
|
|
return
|
|
return
|
|
|
}
|
|
}
|
|
|
|
|
+ // 2026-10-28:hover 的节点 key 一并记下 —— 独立图层里的标签靠它决定「这一台是否展开全名」。
|
|
|
|
|
+ hoveredLabelKey = node.dataset ? (node.dataset.markerOverlayKey || '') : ''
|
|
|
onHover({
|
|
onHover({
|
|
|
kind: node.dataset.markerOverlayKind || '',
|
|
kind: node.dataset.markerOverlayKind || '',
|
|
|
code: node.dataset.markerOverlayCode || '',
|
|
code: node.dataset.markerOverlayCode || '',
|
|
@@ -539,6 +726,7 @@ export function mountMarkerOverlay(mapHost, { onPick, onHover } = {}) {
|
|
|
}
|
|
}
|
|
|
hostNode.addEventListener('pointermove', emitHover)
|
|
hostNode.addEventListener('pointermove', emitHover)
|
|
|
hostNode.addEventListener('pointerleave', () => {
|
|
hostNode.addEventListener('pointerleave', () => {
|
|
|
|
|
+ hoveredLabelKey = ''
|
|
|
if (typeof onHover === 'function') onHover(null)
|
|
if (typeof onHover === 'function') onHover(null)
|
|
|
})
|
|
})
|
|
|
mapHost.appendChild(hostNode)
|
|
mapHost.appendChild(hostNode)
|
|
@@ -580,10 +768,106 @@ export function isMarkerOverlayVisible() {
|
|
|
* group 身份/计数/组内状态词/形状/色键/屏幕坐标 —— 唯一没有设备身份的一类节点)
|
|
* group 身份/计数/组内状态词/形状/色键/屏幕坐标 —— 唯一没有设备身份的一类节点)
|
|
|
* @param {Function} params.projectToWindow (lon, lat) → 宿主相对窗口坐标(贴地口径由调用方保证)
|
|
* @param {Function} params.projectToWindow (lon, lat) → 宿主相对窗口坐标(贴地口径由调用方保证)
|
|
|
*/
|
|
*/
|
|
|
-export function syncMarkerOverlay({ markers, devices, aggregates, pointScales, tones, planOf, effectOf, projectToWindow }) {
|
|
|
|
|
|
|
+/**
|
|
|
|
|
+ * 计划内但本轮画不出来的节点(坐标非法 / 投影失败)也要隐藏。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 为什么必须显式隐藏:它们仍在 `wanted` 里,不会被池级隐藏循环收走;若继续 `continue`,
|
|
|
|
|
+ * 节点会停在最后一次成功投影的位置 —— 与"被筛掉却定格"是同一类幽灵标记缺陷。
|
|
|
|
|
+ */
|
|
|
|
|
+function hidePooledNode(key) {
|
|
|
|
|
+ const record = pool.get(key)
|
|
|
|
|
+ if (!record) return
|
|
|
|
|
+ hideRecord(record)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 隐藏一条记录(节点 + 标签,标签在独立图层里必须一起收)。 */
|
|
|
|
|
+function hideRecord(record) {
|
|
|
|
|
+ if (!record) return
|
|
|
|
|
+ if (record.root && record.root.style.display !== 'none') record.root.style.display = 'none'
|
|
|
|
|
+ if (record.labelWrap && record.labelWrap.style.display !== 'none') record.labelWrap.style.display = 'none'
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 销毁一条记录的 DOM(节点 + 标签锚点)。重建时必须两个都清,否则标签会留在图层里变幽灵。 */
|
|
|
|
|
+function destroyRecord(record) {
|
|
|
|
|
+ if (!record) return
|
|
|
|
|
+ if (record.root && record.root.parentNode) record.root.parentNode.removeChild(record.root)
|
|
|
|
|
+ if (record.labelWrap && record.labelWrap.parentNode) record.labelWrap.parentNode.removeChild(record.labelWrap)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 标签显隐与「hover 展开全名」(2026-10-28 维护者规格)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * 规则只有一个出口,调用方按**状态**给两个布尔量,渲染层不自己判状态:
|
|
|
|
|
+ * · 聚合点:① 常态显示;① hover 预览 / ② 被选择 / ③④ 被替换 → 隐藏。
|
|
|
|
|
+ * · 圈内设备(fan):常态一律隐藏;**只有被 hover 的那一台**显示(且展开为全名)。
|
|
|
|
|
+ * · 中心设备:③ 设备常态显示;④ 设备被选择隐藏(与 ② 同性质)。
|
|
|
|
|
+ * · 非聚合设备点(k=1 关联设备 / 独立未关联设备):**始终显示**(截断),hover 展开全名。
|
|
|
|
|
+ */
|
|
|
|
|
+function applyLabelState(record, visible, expanded) {
|
|
|
|
|
+ if (!record || !record.labelWrap || !record.labelBox) return
|
|
|
|
|
+ const wantedDisplay = visible === true ? 'block' : 'none'
|
|
|
|
|
+ if (record.labelWrap.style.display !== wantedDisplay) record.labelWrap.style.display = wantedDisplay
|
|
|
|
|
+ if (!visible) return
|
|
|
|
|
+ const wantedWidth = expanded === true ? 'none' : record.labelMaxWidth
|
|
|
|
|
+ if (record.labelBox.style.maxWidth !== wantedWidth) record.labelBox.style.maxWidth = wantedWidth
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 标签跟随节点位移(与节点同一次 transform;不逐帧读布局)。 */
|
|
|
|
|
+function placeLabel(record, x, y) {
|
|
|
|
|
+ if (!record || !record.labelWrap) return
|
|
|
|
|
+ const transform = `translate(${x}px, ${y}px)`
|
|
|
|
|
+ if (record.labelWrap.style.transform !== transform) record.labelWrap.style.transform = transform
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ================================================================ 标签判据(纯函数,唯一住址)
|
|
|
|
|
+//
|
|
|
|
|
+// 2026-10-28 维护者给出的状态机把标签规则讲清楚了,而这块此前在三轮走查里反复翻烧饼
|
|
|
|
|
+// (圈内一律不建 → 圈内也要 → 常态隐藏、hover 显示)。所以判据**抽成纯函数**:
|
|
|
|
|
+// 渲染层只调用,不在模板/CSS 里再写一遍;闸门可以离线断言,不必起浏览器。
|
|
|
|
|
+//
|
|
|
|
|
+// | 对象 | 常态 | hover | 备注 |
|
|
|
|
|
+// | --- | --- | --- | --- |
|
|
|
|
|
+// | 聚合点(聚合标签) | ① 显示 | 隐藏(并展开预览环) | ② 固定 / ③④ 被替换也隐藏 |
|
|
|
|
|
+// | 圈内设备(fan) | 隐藏 | **显示全名**(仅被 hover 的那一台) | 环内密集,常态不显示 |
|
|
|
|
|
+// | 中心设备 | ③ 显示 | 显示全名 | ④ 被选择 → 隐藏(与 ② 同性质) |
|
|
|
|
|
+// | 非聚合设备点 / 点位 / 未关联设备 | 显示(截断) | 显示全名 | 聚合规则不得外溢到这里 |
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 聚合点标签:只有 ① 常态(未预览、未固定、未被设备替换)才显示。
|
|
|
|
|
+ * @param {{pinned?:boolean, preview?:boolean, replaced?:boolean}} row 引擎投影行
|
|
|
|
|
+ */
|
|
|
|
|
+export function aggregateLabelState(row) {
|
|
|
|
|
+ const item = row || {}
|
|
|
|
|
+ const visible = item.pinned !== true && item.preview !== true && item.replaced !== true
|
|
|
|
|
+ return { visible, expanded: false }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 关联设备点标签。
|
|
|
|
|
+ * @param {{fan?:boolean, pinned?:boolean}} device 引擎投影行
|
|
|
|
|
+ * @param {boolean} hovered 指针是否在该节点上
|
|
|
|
|
+ */
|
|
|
|
|
+export function deviceLabelState(device, hovered) {
|
|
|
|
|
+ const item = device || {}
|
|
|
|
|
+ if (item.fan === true) return { visible: hovered === true, expanded: hovered === true }
|
|
|
|
|
+ if (item.pinned === true) return { visible: false, expanded: false }
|
|
|
|
|
+ return { visible: true, expanded: hovered === true }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 点位 / 未关联设备(摘要标记):**始终显示**,hover 展开全名。 */
|
|
|
|
|
+export function markerLabelState(hovered) {
|
|
|
|
|
+ return { visible: true, expanded: hovered === true }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export function syncMarkerOverlay({ markers, devices, aggregates, pointScales, tones, planOf, effectOf, deviceEffectOf, converge, selectedCode, projectToWindow }) {
|
|
|
if (!hostNode || !hostNode.isConnected || typeof projectToWindow !== 'function') return
|
|
if (!hostNode || !hostNode.isConnected || typeof projectToWindow !== 'function') return
|
|
|
// 每帧刷新门控查询(mapService 在计划变化时给新闭包;这里只搬运、不判定)。
|
|
// 每帧刷新门控查询(mapService 在计划变化时给新闭包;这里只搬运、不判定)。
|
|
|
frameEffectOf = typeof effectOf === 'function' ? effectOf : () => ''
|
|
frameEffectOf = typeof effectOf === 'function' ? effectOf : () => ''
|
|
|
|
|
+ frameDeviceEffectOf = typeof deviceEffectOf === 'function' ? deviceEffectOf : () => ''
|
|
|
|
|
+ frameConverge = converge || null
|
|
|
|
|
+ // 当前选中的对象编码(2026-10-28):**非聚合设备点**选中时要上「被选择态」同款高亮,
|
|
|
|
|
+ // 而聚合设备的中心由引擎的 pinned 标志负责;两者语义不同,故这里另收一份。
|
|
|
|
|
+ markerSelectedCode = typeof selectedCode === 'string' ? selectedCode : ''
|
|
|
// 票 35 卡顿整改:Set 复用(原先每帧 new 一个);指纹改成廉价拼接(原先每标记
|
|
// 票 35 卡顿整改:Set 复用(原先每帧 new 一个);指纹改成廉价拼接(原先每标记
|
|
|
// JSON.stringify 一次,纯分配)。DOM 写入也改成"只有真的变了才写"。
|
|
// JSON.stringify 一次,纯分配)。DOM 写入也改成"只有真的变了才写"。
|
|
|
wantedKeys.clear()
|
|
wantedKeys.clear()
|
|
@@ -598,10 +882,10 @@ export function syncMarkerOverlay({ markers, devices, aggregates, pointScales, t
|
|
|
for (const marker of markers || []) {
|
|
for (const marker of markers || []) {
|
|
|
const key = `${marker.kind}|${marker.code}`
|
|
const key = `${marker.kind}|${marker.code}`
|
|
|
wanted.add(key)
|
|
wanted.add(key)
|
|
|
- if (!Number.isFinite(marker.longitude) || !Number.isFinite(marker.latitude)) continue
|
|
|
|
|
|
|
+ if (!Number.isFinite(marker.longitude) || !Number.isFinite(marker.latitude)) { hidePooledNode(key); continue }
|
|
|
|
|
|
|
|
const windowPos = projectToWindow(marker.longitude, marker.latitude)
|
|
const windowPos = projectToWindow(marker.longitude, marker.latitude)
|
|
|
- if (!windowPos || !Number.isFinite(windowPos.x) || !Number.isFinite(windowPos.y)) continue
|
|
|
|
|
|
|
+ if (!windowPos || !Number.isFinite(windowPos.x) || !Number.isFinite(windowPos.y)) { hidePooledNode(key); continue }
|
|
|
|
|
|
|
|
const plan = planOf(marker)
|
|
const plan = planOf(marker)
|
|
|
const toneHex = tones[marker.tone] || tones.neutral
|
|
const toneHex = tones[marker.tone] || tones.neutral
|
|
@@ -614,7 +898,7 @@ export function syncMarkerOverlay({ markers, devices, aggregates, pointScales, t
|
|
|
|
|
|
|
|
let record = pool.get(key)
|
|
let record = pool.get(key)
|
|
|
if (!record || record.fp !== fp) {
|
|
if (!record || record.fp !== fp) {
|
|
|
- if (record && record.root.parentNode) record.root.parentNode.removeChild(record.root)
|
|
|
|
|
|
|
+ destroyRecord(record)
|
|
|
record = { ...buildNode(key, marker, plan, toneHex, fx), fp }
|
|
record = { ...buildNode(key, marker, plan, toneHex, fx), fp }
|
|
|
pool.set(key, record)
|
|
pool.set(key, record)
|
|
|
hostNode.appendChild(record.root)
|
|
hostNode.appendChild(record.root)
|
|
@@ -639,7 +923,23 @@ export function syncMarkerOverlay({ markers, devices, aggregates, pointScales, t
|
|
|
const markerScale = pointScales instanceof Map ? pointScales.get(key) : undefined
|
|
const markerScale = pointScales instanceof Map ? pointScales.get(key) : undefined
|
|
|
const scaleTransform = markerScale && markerScale !== 1 ? `scale(${markerScale})` : ''
|
|
const scaleTransform = markerScale && markerScale !== 1 ? `scale(${markerScale})` : ''
|
|
|
if (record.svg.style.transform !== scaleTransform) record.svg.style.transform = scaleTransform
|
|
if (record.svg.style.transform !== scaleTransform) record.svg.style.transform = scaleTransform
|
|
|
|
|
+ // 票 84:选中态环整体放大 —— boost 与图标强调共用 EMPHASIS_STYLE.markerScale(同一个 1.55)。
|
|
|
|
|
+ if (record.fxHost) {
|
|
|
|
|
+ const base = Number(record.fxHost.dataset.fxBaseScale || 1)
|
|
|
|
|
+ const factor = base * (markerScale && markerScale !== 1 ? markerScale : 1)
|
|
|
|
|
+ const fxTransform = factor !== 1 ? `scale(${factor})` : ''
|
|
|
|
|
+ if (record.fxHost.style.transform !== fxTransform) record.fxHost.style.transform = fxTransform
|
|
|
|
|
+ }
|
|
|
if (record.root.style.display !== 'block') record.root.style.display = 'block'
|
|
if (record.root.style.display !== 'block') record.root.style.display = 'block'
|
|
|
|
|
+ // 标签(2026-10-28):点位与未关联设备**始终显示**(截断),hover 才展开全名;
|
|
|
|
|
+ // 若它是当前选中对象(独立设备被选中)则由下面的 `--active` 正규负责发光,标签仍在。
|
|
|
|
|
+ placeLabel(record, windowPos.x, windowPos.y)
|
|
|
|
|
+ const markerLabel = markerLabelState(hoveredLabelKey === key)
|
|
|
|
|
+ applyLabelState(record, markerLabel.visible, markerLabel.expanded)
|
|
|
|
|
+ // 选择态发光(2026-10-28 维护者规格④):**非聚合的设备点**(独立未关联设备)被选中时,
|
|
|
|
|
+ // 用与「被选择态」同款的 --active 高亮。聚合设备中心由设备循环的 pinned 负责。
|
|
|
|
|
+ const markerActive = marker.kind === 'unassociated-equipment' && markerSelectedCode !== '' && marker.code === markerSelectedCode
|
|
|
|
|
+ record.root.classList.toggle(`${NODE_CLASS}--active`, markerActive)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// 关联设备(票 47 维护者反馈②根因:设备是独立 billboard 图层,此前 DOM 只渲染
|
|
// 关联设备(票 47 维护者反馈②根因:设备是独立 billboard 图层,此前 DOM 只渲染
|
|
@@ -648,17 +948,15 @@ export function syncMarkerOverlay({ markers, devices, aggregates, pointScales, t
|
|
|
for (const device of devices || []) {
|
|
for (const device of devices || []) {
|
|
|
const key = device.key || `device|${device.code}`
|
|
const key = device.key || `device|${device.code}`
|
|
|
wanted.add(key)
|
|
wanted.add(key)
|
|
|
- if (!Number.isFinite(device.x) || !Number.isFinite(device.y)) continue
|
|
|
|
|
|
|
+ if (!Number.isFinite(device.x) || !Number.isFinite(device.y)) { hidePooledNode(key); continue }
|
|
|
|
|
|
|
|
- // 票 76(标签规格 2026-10-26 终版,维护者裁定):**圈内设备不建顶部标签**。
|
|
|
|
|
- // 理由:环内设备密集,顶部标签互相压叠且会被图案/特效淹没;它们的全名由右下角
|
|
|
|
|
- // tooltip(`paintMarkerTooltip`)承担,一份就够。
|
|
|
|
|
- // 实现:`fan` 行直接把 name 置空 → `buildNode` 不创建 label 节点(比 CSS 隐藏更彻底,
|
|
|
|
|
- // 也不会因为后续 `--fan` 类切换而重新冒出来)。
|
|
|
|
|
|
|
+ // 2026-10-27 维护者走查修正:圈内子设备点**也要顶部标签**(此前被置空 + CSS 隐藏,
|
|
|
|
|
+ // 表现为"关联监测点的子设备点没有名称")。标签与普通设备点同一条渲染路径。
|
|
|
const marker = {
|
|
const marker = {
|
|
|
kind: 'associated-device',
|
|
kind: 'associated-device',
|
|
|
code: device.code,
|
|
code: device.code,
|
|
|
- name: device.fan === true ? '' : (device.group ? (device.name || '') : ''),
|
|
|
|
|
|
|
+ // 2026-10-27 走查修正:子设备点(fan)也要顶部标签 —— 不再清空名称。
|
|
|
|
|
+ name: device.name || '',
|
|
|
status: device.status || '',
|
|
status: device.status || '',
|
|
|
industry: device.industry || '',
|
|
industry: device.industry || '',
|
|
|
}
|
|
}
|
|
@@ -666,21 +964,20 @@ export function syncMarkerOverlay({ markers, devices, aggregates, pointScales, t
|
|
|
// 票 76(2026-10-26):色键由**设备状态**决定(domain 单源)——设备行不单独携带 tone,
|
|
// 票 76(2026-10-26):色键由**设备状态**决定(domain 单源)——设备行不单独携带 tone,
|
|
|
// 否则状态色与摘要标记两套口径。
|
|
// 否则状态色与摘要标记两套口径。
|
|
|
const toneHex = tones[deviceVisualForStatus(device.status).tone] || tones.neutral
|
|
const toneHex = tones[deviceVisualForStatus(device.status).tone] || tones.neutral
|
|
|
- // 票 70(C3c 语义):关联设备循环**恒不画 DOM 环**(fx 恒 null)——它们的特效留在
|
|
|
|
|
- // billboard 层(ensureRingEntities 链路),同位双环会糊。
|
|
|
|
|
- // 票 76(特效归属,维护者裁定 A):聚合本身不挂环;**只有中心(被选中)设备**按自身状态挂环。
|
|
|
|
|
- // 2026-10-26 修:`DEVICE_EFFECT` 是**动效键名表**(PULSE/BLINK/SPIN),不是「状态 → 动效」映射 ——
|
|
|
|
|
- // 此前写成 `DEVICE_EFFECT[device.status]` 恒为 undefined,中心设备因此一直没有特效。
|
|
|
|
|
|
|
+ // 票 84(取代票 76 C6 的「只有中心设备挂环」):关联设备按**自身状态**挂环,
|
|
|
|
|
+ // 且必须走与摘要标记同一条**门控**(`frameDeviceEffectOf`:关特效/降级时一律不挂)。
|
|
|
|
|
+ // 聚合符号仍恒不挂环(聚合是"一组",不冒充设备告警身份)。
|
|
|
// 状态 → profile 的唯一入口是 `effectProfileFor()`(domain 单源)。
|
|
// 状态 → profile 的唯一入口是 `effectProfileFor()`(domain 单源)。
|
|
|
- const profile = device.selected ? effectProfileFor(device.status) : null
|
|
|
|
|
- const fx = profile ? { effect: profile.effect, profile } : null
|
|
|
|
|
|
|
+ const gatedEffect = frameDeviceEffectOf(device)
|
|
|
|
|
+ const profile = gatedEffect ? effectProfileFor(device.status) : null
|
|
|
|
|
+ const fx = profile && profile.effect === gatedEffect ? { effect: gatedEffect, profile, converge: frameConverge } : null
|
|
|
const fp = [marker.status, marker.industry, marker.name,
|
|
const fp = [marker.status, marker.industry, marker.name,
|
|
|
plan.master, plan.inner.enabled, plan.inner.icon, plan.inner.diameterRatio,
|
|
plan.master, plan.inner.enabled, plan.inner.icon, plan.inner.diameterRatio,
|
|
|
plan.desaturate, toneHex, fx ? fx.effect : '', fx ? fx.profile.layers : 0].join(FP_SEP)
|
|
plan.desaturate, toneHex, fx ? fx.effect : '', fx ? fx.profile.layers : 0].join(FP_SEP)
|
|
|
|
|
|
|
|
let record = pool.get(key)
|
|
let record = pool.get(key)
|
|
|
if (!record || record.fp !== fp) {
|
|
if (!record || record.fp !== fp) {
|
|
|
- if (record && record.root.parentNode) record.root.parentNode.removeChild(record.root)
|
|
|
|
|
|
|
+ destroyRecord(record)
|
|
|
record = { ...buildNode(key, marker, plan, toneHex, fx), fp }
|
|
record = { ...buildNode(key, marker, plan, toneHex, fx), fp }
|
|
|
pool.set(key, record)
|
|
pool.set(key, record)
|
|
|
hostNode.appendChild(record.root)
|
|
hostNode.appendChild(record.root)
|
|
@@ -704,13 +1001,37 @@ export function syncMarkerOverlay({ markers, devices, aggregates, pointScales, t
|
|
|
if (record.svg.style.transform !== deviceScale) record.svg.style.transform = deviceScale
|
|
if (record.svg.style.transform !== deviceScale) record.svg.style.transform = deviceScale
|
|
|
const deviceOpacity = device.alpha !== undefined && device.alpha !== 1 ? String(device.alpha) : ''
|
|
const deviceOpacity = device.alpha !== undefined && device.alpha !== 1 ? String(device.alpha) : ''
|
|
|
if (record.root.style.opacity !== deviceOpacity) record.root.style.opacity = deviceOpacity
|
|
if (record.root.style.opacity !== deviceOpacity) record.root.style.opacity = deviceOpacity
|
|
|
|
|
+ // 票 84:选中(中心)设备的环整体放大 —— 与图标强调共用同一个 1.55 常量。
|
|
|
|
|
+ if (record.fxHost) {
|
|
|
|
|
+ const base = Number(record.fxHost.dataset.fxBaseScale || 1)
|
|
|
|
|
+ const factor = base * (device.selected ? markerDevKnobs().selectedBoost : 1)
|
|
|
|
|
+ const fxTransform = factor !== 1 ? `scale(${factor})` : ''
|
|
|
|
|
+ if (record.fxHost.style.transform !== fxTransform) record.fxHost.style.transform = fxTransform
|
|
|
|
|
+ }
|
|
|
if (record.root.style.display !== 'block') record.root.style.display = 'block'
|
|
if (record.root.style.display !== 'block') record.root.style.display = 'block'
|
|
|
- // 票 76(标签规格):圈内设备(`fan`)标签改 hover-only;中心设备与普通设备点维持常态标签。
|
|
|
|
|
|
|
+ // ==================== 标签与选择态(2026-10-28 维护者状态机)====================
|
|
|
|
|
+ // 标签规则(唯一出口 applyLabelState;这里只按状态给两个布尔量):
|
|
|
|
|
+ // · 圈内设备(fan,处于「环可见」的 ①hover预览/②固定/③hover预览/④固定」):
|
|
|
|
|
+ // 常态**一律隐藏**,只有被 hover 的那一台显示并展开全名 —— 你报的「环展开时顶部标签没隐藏」。
|
|
|
|
|
+ // · ③ 设备常态的中心设备:显示(它此时就是一枚正常设备点);hover 展开全名。
|
|
|
|
|
+ // · ④ 设备被选择的中心设备:隐藏(与 ② 聚合被选择态同性质)。
|
|
|
|
|
+ // · 非聚合设备点(k=1 关联设备):**始终显示** —— 聚合规则不得外溢到它们。
|
|
|
|
|
+ placeLabel(record, device.x - hostOffset.x, device.y - hostOffset.y)
|
|
|
|
|
+ const isHovered = hoveredLabelKey === key
|
|
|
|
|
+ const deviceLabel = deviceLabelState(device, isHovered)
|
|
|
|
|
+ applyLabelState(record, deviceLabel.visible, deviceLabel.expanded)
|
|
|
|
|
+ // 圈内设备标记(CSS/走查读这个类;标签显隐已由上面统一驱动,不再靠 CSS 后代选择器)。
|
|
|
record.root.classList.toggle(`${NODE_CLASS}--fan`, device.fan === true)
|
|
record.root.classList.toggle(`${NODE_CLASS}--fan`, device.fan === true)
|
|
|
// 票 76(四态规格 2026-10-26)设备节点:
|
|
// 票 76(四态规格 2026-10-26)设备节点:
|
|
|
- // ③ 设备常态 中心=设备点,无发光,环只在 hover 预览(`fan` 标签 hover-only)
|
|
|
|
|
|
|
+ // ③ 设备常态 中心=设备点,无发光,环只在 hover 预览(标签常态显示)
|
|
|
// ④ 设备被选择态 与聚合点被选择态相同:状态色发光 + 隐藏自身标签
|
|
// ④ 设备被选择态 与聚合点被选择态相同:状态色发光 + 隐藏自身标签
|
|
|
- record.root.classList.toggle(`${NODE_CLASS}--active`, device.pinned === true)
|
|
|
|
|
|
|
+ // 2026-10-28:非聚合设备点(k=1 关联设备)被选中时也要上**同款高亮** ——
|
|
|
|
|
+ // 走的是「设备选择事件」这条统一逻辑,与聚合规则无关。
|
|
|
|
|
+ // 注意排除 `device.selected`(③ 的中心设备**正是**当前选中对象,但它不是选择态、不发光)、
|
|
|
|
|
+ // 以及 `fan`(圈内设备的高亮由所在组的 `pinned` 决定)。
|
|
|
|
|
+ const deviceActive = device.pinned === true
|
|
|
|
|
+ || (markerSelectedCode !== '' && device.code === markerSelectedCode && device.selected !== true && device.fan !== true)
|
|
|
|
|
+ record.root.classList.toggle(`${NODE_CLASS}--active`, deviceActive)
|
|
|
// 票 76(四态,2026-10-26):③④ 的中心设备要**压过聚合节点**(聚合节点后追加、会吃掉点击),
|
|
// 票 76(四态,2026-10-26):③④ 的中心设备要**压过聚合节点**(聚合节点后追加、会吃掉点击),
|
|
|
// 否则「点中心设备进 ④」永远触发不了(走查第 3 步完全失效的直接原因)。
|
|
// 否则「点中心设备进 ④」永远触发不了(走查第 3 步完全失效的直接原因)。
|
|
|
record.root.classList.toggle(`${NODE_CLASS}--center`, device.selected === true)
|
|
record.root.classList.toggle(`${NODE_CLASS}--center`, device.selected === true)
|
|
@@ -721,7 +1042,7 @@ export function syncMarkerOverlay({ markers, devices, aggregates, pointScales, t
|
|
|
for (const row of aggregates || []) {
|
|
for (const row of aggregates || []) {
|
|
|
const key = row.key || `overlap|${row.pointCode}|${row.group}`
|
|
const key = row.key || `overlap|${row.pointCode}|${row.group}`
|
|
|
wanted.add(key)
|
|
wanted.add(key)
|
|
|
- if (!Number.isFinite(row.x) || !Number.isFinite(row.y)) continue
|
|
|
|
|
|
|
+ if (!Number.isFinite(row.x) || !Number.isFinite(row.y)) { hidePooledNode(key); continue }
|
|
|
|
|
|
|
|
const toneHex = tones[row.tone] || tones.neutral
|
|
const toneHex = tones[row.tone] || tones.neutral
|
|
|
// 票 76(统一设备点位渲染):聚合符号也走 `planOf` 合成计划(母版/内层/几何与设备点同源),
|
|
// 票 76(统一设备点位渲染):聚合符号也走 `planOf` 合成计划(母版/内层/几何与设备点同源),
|
|
@@ -733,7 +1054,7 @@ export function syncMarkerOverlay({ markers, devices, aggregates, pointScales, t
|
|
|
].join(FP_SEP)
|
|
].join(FP_SEP)
|
|
|
let record = pool.get(key)
|
|
let record = pool.get(key)
|
|
|
if (!record || record.fp !== fp) {
|
|
if (!record || record.fp !== fp) {
|
|
|
- if (record && record.root.parentNode) record.root.parentNode.removeChild(record.root)
|
|
|
|
|
|
|
+ destroyRecord(record)
|
|
|
record = { ...buildAggregateNode(key, row, toneHex, plan), fp }
|
|
record = { ...buildAggregateNode(key, row, toneHex, plan), fp }
|
|
|
pool.set(key, record)
|
|
pool.set(key, record)
|
|
|
hostNode.appendChild(record.root)
|
|
hostNode.appendChild(record.root)
|
|
@@ -752,22 +1073,22 @@ export function syncMarkerOverlay({ markers, devices, aggregates, pointScales, t
|
|
|
if (record.icon.style.filter !== aggFilter) record.icon.style.filter = aggFilter
|
|
if (record.icon.style.filter !== aggFilter) record.icon.style.filter = aggFilter
|
|
|
record.root.classList.toggle(`${NODE_CLASS}--active`, active)
|
|
record.root.classList.toggle(`${NODE_CLASS}--active`, active)
|
|
|
// 票 76(四态):① 的 hover 预览与 ② 的固定态都隐藏聚合标签(让出设备环);
|
|
// 票 76(四态):① 的 hover 预览与 ② 的固定态都隐藏聚合标签(让出设备环);
|
|
|
- // ③④ 中心已是设备点 → 整枚聚合节点让位。
|
|
|
|
|
- const labelHidden = row.pinned === true || row.preview === true
|
|
|
|
|
- const label = record.root.querySelector(`.${LABEL_CLASS}`)
|
|
|
|
|
- if (label) {
|
|
|
|
|
- const wantedDisplay = labelHidden ? 'none' : 'block'
|
|
|
|
|
- if (label.style.display !== wantedDisplay) label.style.display = wantedDisplay
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ // ③④ 中心已是设备点 → 整枚聚合节点让位(标签随之隐藏)。
|
|
|
|
|
+ // 2026-10-28:标签在**独立图层**,显隐由 `applyLabelState` 统一驱动(不再内联查 DOM 子元素)。
|
|
|
|
|
+ placeLabel(record, row.x - hostOffset.x, row.y - hostOffset.y)
|
|
|
|
|
+ const aggregateLabel = aggregateLabelState(row)
|
|
|
|
|
+ applyLabelState(record, aggregateLabel.visible, aggregateLabel.expanded)
|
|
|
record.root.style.display = row.replaced ? 'none' : 'block'
|
|
record.root.style.display = row.replaced ? 'none' : 'block'
|
|
|
// ③④ 中心已是设备点:聚合节点虽然隐藏,但保险起见再降掉 pointer-events,
|
|
// ③④ 中心已是设备点:聚合节点虽然隐藏,但保险起见再降掉 pointer-events,
|
|
|
// 避免它(或其子元素)在过渡帧里吃掉对中心设备的点击。
|
|
// 避免它(或其子元素)在过渡帧里吃掉对中心设备的点击。
|
|
|
record.root.classList.toggle(`${NODE_CLASS}--replaced`, row.replaced === true)
|
|
record.root.classList.toggle(`${NODE_CLASS}--replaced`, row.replaced === true)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- // 本轮不在计划里的:隐藏(池复用,不销毁)
|
|
|
|
|
|
|
+ // 本轮不在计划里的:隐藏(池复用,不销毁)。
|
|
|
|
|
+ // ⚠️ 这一轮**必须**在所有计划遍历之后:放进某个循环里会漏掉「点位/设备被筛掉」的节点,
|
|
|
|
|
+ // 它们会停在最后一次投影位置变成幽灵标记(2026-10-27 实测缺陷,勿再合并进循环)。
|
|
|
for (const [key, record] of pool.entries()) {
|
|
for (const [key, record] of pool.entries()) {
|
|
|
- if (!wanted.has(key) && record.root.style.display !== 'none') record.root.style.display = 'none'
|
|
|
|
|
|
|
+ if (!wanted.has(key)) hideRecord(record)
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -791,6 +1112,35 @@ export function overlayPoolSize() {
|
|
|
return pool.size
|
|
return pool.size
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+/** 某个池节点的 display 读数('' / 'block' / 'none')——幽灵标记回归闸门读它。 */
|
|
|
|
|
+export function overlayNodeDisplayOf(key) {
|
|
|
|
|
+ const record = pool.get(key)
|
|
|
|
|
+ return record ? String(record.root.style.display || '') : ''
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 某个池节点的标签文本(子设备点标签回归闸门读它)。 */
|
|
|
|
|
+export function overlayNodeLabelTextOf(key) {
|
|
|
|
|
+ const record = pool.get(key)
|
|
|
|
|
+ if (!record) return ''
|
|
|
|
|
+ // 2026-10-28:标签已搬进独立图层(`record.labelBox`),不再挂在节点内部。
|
|
|
|
|
+ return record.labelBox ? String(record.labelBox.textContent || '') : ''
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 某个池节点标签的**显隐读数**('' 表示从未设置 / 'block' / 'none')。
|
|
|
|
|
+ * 走查与闸门用它断言「环展开时圈内设备标签确实隐藏、hover 那一台才显示」。
|
|
|
|
|
+ */
|
|
|
|
|
+export function overlayLabelDisplayOf(key) {
|
|
|
|
|
+ const record = pool.get(key)
|
|
|
|
|
+ return record && record.labelWrap ? String(record.labelWrap.style.display || '') : ''
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/** 某个池节点标签是否处于**展开全名**(max-width 放开)状态。 */
|
|
|
|
|
+export function overlayLabelExpandedOf(key) {
|
|
|
|
|
+ const record = pool.get(key)
|
|
|
|
|
+ return Boolean(record && record.labelBox && record.labelBox.style.maxWidth === 'none')
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
/** 卸载(离开 /cockpit 用):摘 postRender、清节点池与宿主、移除特效样式(t45 C3b 生命周期)。 */
|
|
/** 卸载(离开 /cockpit 用):摘 postRender、清节点池与宿主、移除特效样式(t45 C3b 生命周期)。 */
|
|
|
export function unmountMarkerOverlay() {
|
|
export function unmountMarkerOverlay() {
|
|
|
if (postRenderRemove) {
|
|
if (postRenderRemove) {
|
|
@@ -804,5 +1154,9 @@ export function unmountMarkerOverlay() {
|
|
|
if (hostNode && hostNode.parentNode) hostNode.parentNode.removeChild(hostNode)
|
|
if (hostNode && hostNode.parentNode) hostNode.parentNode.removeChild(hostNode)
|
|
|
hostNode = null
|
|
hostNode = null
|
|
|
hostRecord = null
|
|
hostRecord = null
|
|
|
|
|
+ // 标签图层与 hover 记忆一并清掉(宿主已移除,引用留着只会拖住 DOM)。
|
|
|
|
|
+ labelLayer = null
|
|
|
|
|
+ hoveredLabelKey = ''
|
|
|
|
|
+ markerSelectedCode = ''
|
|
|
pool.clear()
|
|
pool.clear()
|
|
|
}
|
|
}
|