Explorar o código

feat(cockpit): 票47 地图标记 DOM overlay 渲染层交付与两缺陷修复

DOM overlay 把标记与标签迁到 UI 同层渲染,结构性解决 billboard 发糊与底板生硬:
- 外层母版自绘 SVG 水滴(按 512 母版实测拟合,帽圆 + 收尖三次贝塞尔),任何 DPR 都锐利;
- 内层行业图标继续用素材(renderInnerIconUrl 与 billboard 同一去色重染、256px 原生分辨率向下采样);
- 真 DOM 标签(圆角底板 / 装饰边 / 截断省略 / hover title,全 CSS 能力);
- 大小对齐 billboard 口径(白描边 paint-order 纯外扩 28、标签限宽取计划层 labelMaxWidth 单源);
- 贴地定位同 CLAMP_TO_GROUND(groundHeightAt);点选显式触发器照搬 pickDeviceOrPointAt;
- 节点池指纹重建 + unmount 接线;billboard 链路完整保留作降级底。

收口后维护者走查两缺陷修复:
- 设备点「比老版本小一圈」:根因是面板强调把 billboard.scale 改到 1.55 而 DOM 读不到该量。
  引擎新增 readPointMarkerScales,overlay 每帧镜像 billboard.scale 真值(transform-origin 钉尖端)。
- 关联监测点的设备点没走 DOM:根因是关联设备是独立 billboard 数据源、overlay 只渲染摘要计划。
  引擎新增 projectDeviceMarkers(每帧投影可见设备实体,含 pixelOffset 与 scale/alpha 反应量),
  设备 DOM 节点走同一套合成计划、无名字标签(与 billboard 个体设备同口径)。

顺带修复:mapService 悬空 return(补回 pickPipelineAt 签名行,HEAD 起即损坏)。

临时 A/B 开关(走查对照,billboard 替换收口后移除):URL ?overlay=0 + DEV 把手
__cockpitMap.setMarkerOverlay/toggleMarkerOverlay。

拟合探针留档 research/fit-pin-bezier.mjs 等。真实浏览器(real 数据源)走查:四类标记
渲染、监测点/关联设备点选联动、标签截断、零业务报错全通过。
ChenJZ hai 4 días
pai
achega
ebb10351ac

+ 65 - 15
.scratch/cockpit-real-data-wayfinding/issues/47-dom-overlay-billboard.md

@@ -15,34 +15,50 @@ Category: frontend-execution
 
 DOM overlay 把标记与标签迁到 UI 同层渲染(CSS 全能力),3D 场景只负责每帧提供窗口坐标——维护者原话「使用和 UI 同一层的渲染模式,使用 3D 算法进行定位」。
 
-## 做了什么(已实施)
+## 维护者裁定(2026-10-15 两轮走查后,本票方向定调)
 
-- **新增 `services/markerOverlay.js`**:DOM overlay 渲染层——标记 SVG(矢量母版,`--tone` 变量注状态色,实心/镂空双形)+ 真 DOM 标签(圆角底板、青色装饰边、text-overflow、hover title 原生能力);节点池复用(标记消失隐藏不销毁);`data-marker-overlay-key/kind/code` + `markerMaster/markerInner/markerAnchor/markerDesaturate` 断言面全挂 DOM 节点。
-- **每帧驱动**:`postRender` → `syncMarkerOverlay`(投影 + transform 更新 + 屏外隐藏);投影走 `createCameraEngine` 返回对象的 `projectPivot`(SceneTransforms 包装,含画布原点修正;首版误用 `instance.engine.projectPivot` 报 TypeError 当场修正)。
-- **拾取**:DOM 节点 `pointer-events:auto`,`data-marker-overlay-code/kind` 供点选派发;Canvas 拾取保留给 3D 对象。
-- **内层染色**:SVG `<image>` 不能逐像素重染,用 CSS filter 链近似(`brightness → invert → sepia → saturate → hue-rotate` → 浅蓝色调)——与图标使用共识 §4.2「内层去色重染」同一规则的 SVG 媒介实现。
-- **接入**:`mapService` 挂载时 `mountMarkerOverlay(record.host)` + `attachOverlayRenderer`(每帧从标记计划现算投影);失败自动回退 billboard 链路。
-- **markerAssets 输出**:`renderMarker` 输出预下采样到屏显尺寸(绕开 billboard mipmap 发虚)——降级路径同样受益。
+1. **「DOM 还存在很大问题,整体还没有还原 billboard 的功能;但显示效果清晰得多——等功能完全实现后再替换 billboard。」** billboard 链路保留(聚合、特效、Canvas 拾取、强调读数继续依赖它),DOM 与 billboard 同位叠放作两层。
+2. **渲染分工**:外层母版**自绘 SVG** 还原水滴(素材位图喂 DOM 在高 DPR 屏必糊——糊的根因是预下采样合成时机,不是素材本身);中间行业/设备图标**继续用素材**(去色重染的原生分辨率产物,向下采样永远锐利)。
+
+## 最终实现(第四轮收口,2026-10-15)
+
+`services/markerOverlay.js`(重写)+ `markerAssets.js`(新增 `renderInnerIconUrl`)+ `mapService.js`(接入):
+
+- **外层自绘 SVG**:水滴路径按 512px 白母版实测拟合——帽圆 (188.5,189) r189(逐行验证 <1px)+ 下半收尖三次贝塞尔(左缘 57 点最小二乘,最大残差 4.75 源像素 ≈ 0.34 屏显像素);镂空孔 = 实测圆 r75。状态色 `fill` 直注(token 单源 `toneColors()`);降饱和走 CSS filter(与 billboard 同参数)。拟合探针留档 `research/fit-pin-bezier.mjs`。
+- **内层行业图标 = 素材**:`renderInnerIconUrl(icon)` 用与 billboard **同一 `recolorInner`** 去色重染规则产出 **256px 原生分辨率** data URL(缓存复用),嵌进同一 SVG 的 `<image>`,按 `plan.inner.diameterRatio`(80%)× 母版宽、帽心对齐——DOM 里只做向下采样,任何 DPR 都锐利。`renderMarker`(billboard 路径)共用该缓存,行为不变。
+- **大小对齐 billboard 口径**(第四轮浏览器走查修正两处):① 白描边 `stroke-width=56` + `paint-order="stroke fill"`——彩色母版全尺寸保留、白色纯外扩 28 源像素,与 billboard `withWhiteOutline`(向外膨胀、母版不蚀)同式(此前 stroke 沿中心线,母版被蚀一圈、DOM 比 billboard 小);② 标签限宽取计划层 `labelMaxWidth`(宽×2.6,content-box),替换硬编码 220px——此前 DOM 标签比 billboard 宽 3 倍,两层并现一长一短。
+- **贴地定位**:与 billboard `CLAMP_TO_GROUND` 同口径——`engine.groundHeightAt`(地形瓦片实测高程,取不到回椭球 0)+ `projectPivot` 窗口坐标 − host 窗口原点;尖端/描边余量补偿与 billboard `verticalOrigin:BOTTOM + pixelOffset(0,pad)` 同式。
+- **点击触发器**:overlay 宿主挂显式 click 派发器,命中 `data-marker-overlay-*` 节点 → **照搬 `pickDeviceOrPointAt`**(与 Canvas 点选链同一功能函数);标记节点 pointerdown/up 停止冒泡防 attachPointPick 双派发;pointermove 不拦(hover/tooltip 照常);标签 `pointer-events:none` 不遮挡相机。
+- **节点池**:计划读数指纹(状态/行业/名称/母版/内层/降饱和/色值)变化才重建(NORMAL→ALARM 换色生效);消失隐藏不销毁;`unmountMap` 已接 `detachOverlay` + `unmountMarkerOverlay`。
+- **降级**:内层素材加载失败 = SVG 只剩母版水滴;billboard 双层在底下照常显示(链路未动)。
 
 ## 保持不变(契约面)
 
 - 合成计划层 `domain/markerPlan.js`(尺寸/锚点/归属/降饱和逻辑原样复用,overlay 只换渲染媒介);
 - `data-*` 断言面(`markerMaster/markerInner/...` 全部挂到 DOM 节点上);
-- 状态词表、拾取身份、面板联动、四模式可见性。
+- 状态词表、拾取身份、面板联动、四模式可见性;billboard 链路(`syncPointMarkersForPlan`)完整保留。
+
+## 核验记录(首版声称 vs 实况,均已修)
+
+首版(迁移自票 20)声称「已实施+走查通过」不成立:SVG 母版路径右半缺失(渲染成月牙碎片);投影原点减法写反(恒偏 host.left/top);内层硬编码 60% 且换算放大 ≈2.2 倍;节点永不刷新;unmount 未接线;「data-* 供点选派发」无消费者。第二/三轮全部修复,第二轮的「renderMarker 位图喂 DOM」经维护者走查证伪(高 DPR 屏放大发糊),第三轮改为自绘 SVG + 原生分辨率内层。
 
-## 走查结论(浏览器实测,2026-10-15)
+## 浏览器走查结论(第四轮,2026-10-15 实测,real 数据源)
 
-- 标记**锐利无糊**(SVG 矢量)、标签**圆角底板 + 装饰边**清晰、hover title 读全;三类点选(监测点/关联设备/未关联设备)镜头联动保持有效;控制台无业务报错。
-- billboard → DOM overlay 迁移验证:同一份标记计划输入,两种媒介渲染结果一致(归属/颜色/尺寸/标签文本)。
+- **形状**:维护者确认水滴还原 OK;大小按 billboard 口径对齐(描边纯外扩、标签限宽同源)。
+- **标记**:实心/镂空双形、状态色、降饱和(未关联设备橙色镂空 + 内层窨井图标)渲染正确;内层素材 256px 原生嵌入清晰。
+- **点选联动**:监测点(RQ001 燃气监测点001)与未关联设备(TEST-CNXHS0000001 测试消火栓)均正常——详情卡打开、镜头飞行、引线锚定、面板读数字段正确;`data-marker-overlay-key/kind/code` 身份断言面在位。
+- **标签**:圆角底板 + 青色装饰边 + 截断省略(与 billboard 同宽口径)+ hover title 读全。
+- **控制台**:零业务报错(唯一 error 为第三方百度统计 hm.js 的 permissions-policy 提示,与本票无关)。
 
-## 遗留
+## 遗留(不阻塞本票收口)
 
-- 聚合符号(K>1)与设备特效(blink/glow/spin)仍在 billboard 层(Out of Scope)。
-- overlay 内层 filter 链是近似(sepia+hue-rotate 到浅蓝),与 billboard 逐像素重染色相有细微差异——维护者走查若不满意可换 SVG mask 精确染色。
+- **监测点预警/报警特效**:由[票 45](45-untitled.md) 承接(范围第 4 条),特效当前仍在 billboard 层;DOM 复刻特效是**替换 billboard** 的前置之一。
+- **聚合符号(K>1)DOM 化**:替换 billboard 的另一前置,未立票(挂地图 Not yet specified 雾区)。
+- **billboard 暂不摘除**:按维护者裁定,DOM 完整复刻(特效 + 聚合)后才替换;现状 DOM 标记与 billboard 同位叠放,billboard 特效动画期间底图短暂露边属过渡可接受。
 
 ## 进度
 
-Status: claimed
+Status: resolved
 Type: task
 Blocked by:
 Labels: ready-for-agent, wayfinder:task
@@ -54,5 +70,39 @@ Labels: ready-for-agent, wayfinder:task
 维护者第二轮反馈落地:①内层图标直径 60% → 80%(安全边界核验:圆帽区 ≈0.88 倍宽,80% 外沿距帽缘仍有 15 源像素余量);断言同步更新(合成计划 38/38)。overlay 与 billboard 降级路径同受益(同一合成计划)。
 
 
+### local — 2026-10-15(本会话执行记录)
+执行票 47:认领后对抗式核验首版「已实施」声称,发现六处确定性缺陷(母版路径残缺/投影原点写反/内层几何错/节点不刷新/unmount 未接线/点击派发无消费者),首版走查结论作废。按维护者走查三条反馈重写 overlay:素材改走 renderMarker 同源合成、贴地定位与 CLAMP_TO_GROUND 同口径、显式 click 触发器照搬 pickDeviceOrPointAt、指纹重建、卸载接线。特效需求确认已有票 45 承接(未重复立票)。浏览器复验按维护者指示推迟到功能完全实现后。票保持 open。按 AGENTS.md 跳过 build/dev 验证,仅做 node --check 与离线断言(38/38 过)。
+
+### local — 2026-10-15T07:11:12.351Z
+第三轮(同日,维护者反馈「用素材整体变糊」后裁定:外层自绘 SVG 还原水滴 + 内层行业图标继续用素材):
+
+根因先说清——糊不是素材的错,是合成时机:renderMarker 预下采样到 ~39 CSS px 是为绕 Cesium mipmap,但 DOM 无 mipmap、有 DPR 问题(2× 屏上 39px 源图铺 78 物理像素必然糊;内层火焰 302 源像素被压进 ~30px 更甚)。
+
+实施:
+① 外层母版改自绘 SVG:水滴路径按 512 母版实测拟合——帽圆 (188.5,189) r189(逐行验证 <1px)+ 下半收尖三次贝塞尔(左缘 57 点最小二乘,最大残差 4.75 源像素 ≈0.34 屏显像素),镂空孔实测圆 r75;描边 stroke 28 = billboard OUTLINE_WIDTH 同值;viewBox 外扩 40 同 OUTLINE_PAD 裁剪窗。拟合探针留档 .scratch/cockpit-real-data-wayfinding/research/fit-pin-bezier.mjs(含 fit-pin-path.mjs 首轮圆弧检验:收尖非圆弧,残差 338px 排除 A 命令)。
+② 内层素材:markerAssets 新增 renderInnerIconUrl——与 billboard 同一 recolorInner 去色重染规则,但不下采样(256px 原生分辨率 data URL,缓存复用),DOM 按帽径 80% 向下采样永远锐利;renderMarker 改为共用该缓存(billboard 路径行为不变,合成计划断言 38/38 复跑过)。
+③ 降级形态更新:内层素材失败 = SVG 只剩母版水滴;billboard 双层在底下照常(链路未动)。
+
+维护者已授权浏览器复验但指示先完成功能——复验窗口待维护者点名。票保持 open(下一步:浏览器走查同位/同款/点选 + 特效复刻归票 45 + 聚合 DOM 化)。
+
+### local — 2026-10-15(第四轮:浏览器走查 + 大小对齐收口)
+维护者确认形状 OK、指示走查后收口。浏览器实测(real 数据源,http://127.0.0.1:8000/#/cockpit)发现两处尺寸偏差并修正:① 白描边 stroke 沿路径中心线把彩色母版蚀掉一圈(billboard 的 withWhiteOutline 是纯外膨胀)→ 改 `stroke-width=56` + `paint-order="stroke fill"`,母版全尺寸、白环纯外扩 28 源像素同 billboard;② 标签硬编码 max-width:220px 比 billboard 计划层 labelMaxWidth(宽×2.6)宽 3 倍,两层并现一长一短 → 改取 `plan.labelMaxWidth` + content-box。复验:监测点/未关联设备点选联动(详情卡+镜头+引线)、镂空+降饱和+内层窨井图标、标签截断与 hover title 全通过;控制台零业务报错。票关闭。特效(票 45)与聚合 DOM 化是替换 billboard 的前置,留在遗留。
+
+### local — 2026-10-15T09:25:24.605Z
+追加(维护者要求同视角 A/B 对照):临时开关已接——① URL 参数 `#/cockpit?overlay=0`(hash 前后都认)挂载时初始隐藏 DOM 层 = 只露 billboard(之前版本);② DEV 把手 `__cockpitMap.toggleMarkerOverlay()` / `setMarkerOverlay(bool)` / `getMarkerOverlay()` 不重载即时互换(两层同位叠放,切 display 即对照)。浏览器实测两态截图各一张:overlay=0 时标记为位图版(发糊/原生 label),默认态为自绘 SVG 版(锐利/圆角底板标签)。**这是临时走查工具**:billboard 替换收口后随本票遗留项一并移除(markerOverlay.js 的 overlayVisible 两函数 + mapService.js 的 A/B 块与三个把手键)。改动仍未提交,入库裁剪时连本段一起。
+
+### local — 2026-10-16T01:52:05.232Z
+追加(收口后维护者走查反馈两缺陷,已修复并浏览器取证):
+
+① **「设备点图标比老版本小一圈」**——根因:面板强调(`highlightEntities`)把 billboard.scale 改到 1.55,而 DOM 镜像层读不到该量,强调时 billboard 放大、DOM 不动,两层一大小看起来「小了一圈」。修复:引擎新增 `readPointMarkerScales(viewer)`(`kind|code` → billboard.scale 真值),overlay 每帧镜像该量,`transform-origin` 设在尖端(与 billboard `verticalOrigin:BOTTOM` 缩放行为一致)。
+
+② **「有关联监测点的设备点没有正常应用 DOM」**——根因:关联设备是**独立 billboard 数据源**(`DEVICE_MARKER_SOURCE`,票 42 结构性隔离),DOM overlay 只渲染了摘要标记计划 `markerPlan.markers`,整个设备图层没进 DOM。修复:引擎新增 `projectDeviceMarkers(viewer)`(每帧投影当前可见设备实体:窗口坐标真值含 pixelOffset 尖端补偿 + scale/alpha 反应量真值,折叠聚合个体 `show=false` 不进 DOM——聚合符号按票面 Out of Scope 留在 billboard);mapService 帧状态把设备行与图层计划行并表(name/status/industry/tone)传入 overlay;设备 DOM 节点走与摘要设备同一套合成计划(实心母版 + 内层行业图标),**无名字标签**(billboard 个体设备本就无 label,两层同口径)。
+
+浏览器取证(real):选中城北排水监测点01(CBWS202610150001 报警)→ 关联设备「测试环网监测设备01(21119740)」出现 `data-marker-overlay-kind="associated-device"` 节点(key `device|CBWS…|21119740`,实心 + drainage 内层 + tone #ff3b52 与监测点同状态色),svg 26.72×32.11 = billboard `plan.draw` 同源同值,与 billboard 设备同位叠放;控制台零业务报错。期间见到的 Vue `__vnode` null 循环报错是并行会话 HMR 风暴的开发期残留(硬重载后消失),与本修复无关。改动仍未提交,随票 47 存量 hunks 一起裁剪入库。
+
+
+
+
 ## Answer
 
+DOM overlay 渲染层已交付:外层自绘 SVG 水滴(实测拟合,维护者确认形状)+ 内层素材原生分辨率(去色重染同源)+ 真 DOM 标签(圆角/装饰边/截断/hover 全 CSS 能力);大小按 billboard 口径对齐(描边纯外扩、标签限宽单源);贴地定位同 CLAMP_TO_GROUND;点选走显式触发器照搬 pickDeviceOrPointAt;节点池指纹重建、卸载接线。浏览器走查(real 数据源)四类标记渲染 + 两类点选联动 + 标签截断 + 零业务报错全通过。billboard 链路保留(维护者裁定:特效/聚合 DOM 化后才替换)。票 47 关闭。

+ 173 - 0
.scratch/cockpit-real-data-wayfinding/research/fit-pin-bezier.mjs

@@ -0,0 +1,173 @@
+// 一次性探针(票 47):母版下半收尖的三次贝塞尔拟合。
+// 假设:轮廓在帽圆某切点处离开圆,以一条三次贝塞尔到达尖端;对切点角度扫描取最优。
+// 输出 bbox 坐标系(x-67, y-19)的 path 参数,直接嵌进 markerOverlay.js。
+import { readFileSync } from 'node:fs'
+import { inflateSync } from 'node:zlib'
+
+function decodePng(path) {
+    const buf = readFileSync(path)
+    let pos = 8
+    const idat = []
+    let width = 0, height = 0, colorType = 0, trns = null
+    while (pos < buf.length) {
+        const len = buf.readUInt32BE(pos)
+        const type = buf.toString('ascii', pos + 4, pos + 8)
+        const data = buf.subarray(pos + 8, pos + 8 + len)
+        if (type === 'IHDR') { width = data.readUInt32BE(0); height = data.readUInt32BE(4); colorType = data[9] }
+        else if (type === 'tRNS') trns = Buffer.from(data)
+        else if (type === 'IDAT') idat.push(data)
+        else if (type === 'IEND') break
+        pos += 12 + len
+    }
+    const channels = colorType === 6 ? 4 : colorType === 2 ? 3 : 1
+    const stride = width * channels
+    const raw = inflateSync(Buffer.concat(idat))
+    const img = Buffer.alloc(height * stride)
+    for (let y = 0; y < height; y++) {
+        const filter = raw[y * (stride + 1)]
+        const row = raw.subarray(y * (stride + 1) + 1, (y + 1) * stride)
+        const prev = y > 0 ? img.subarray((y - 1) * stride, y * stride) : null
+        const out = img.subarray(y * stride, (y + 1) * stride)
+        for (let x = 0; x < stride; x++) {
+            const a = x >= channels ? out[x - channels] : 0
+            const b = prev ? prev[x] : 0
+            const c = prev && x >= channels ? prev[x - channels] : 0
+            let v = row[x]
+            if (filter === 1) v += a
+            else if (filter === 2) v += b
+            else if (filter === 3) v += (a + b) >> 1
+            else if (filter === 4) {
+                const p = a + b - c
+                const pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c)
+                v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c
+            }
+            out[x] = v & 0xff
+        }
+    }
+    const alphaAt = (x, y) => {
+        if (colorType === 6) return img[y * stride + x * 4 + 3]
+        const idx = img[y * stride + x]
+        if (trns && idx < trns.length) return trns[idx]
+        return 255
+    }
+    return { width, height, alphaAt }
+}
+
+// bbox 坐标系(绝对像素减 (67,19)):帽圆 (188.5,189) r189;尖端 (188.5,474)
+const CAP = { cx: 188.5, cy: 189, r: 189 }
+const TIP = { x: 188.5, y: 474 }
+
+const src = decodePng(process.argv[2] || 'public/cockpit/assets/icons/T_MarkPoint_WhiteFull.png')
+// 左缘采样:每行第一个不透明点(bbox 坐标)
+function leftEdge(yAbs) {
+    for (let x = 67; x <= 444; x++) if (src.alphaAt(x, yAbs) > 8) return x - 67
+    return null
+}
+const pts = []
+for (let yAbs = 380; yAbs <= 492; yAbs += 2) {
+    const x = leftEdge(yAbs)
+    if (x != null) pts.push({ x, y: yAbs - 19 })
+}
+console.log(`左缘采样 ${pts.length} 点(y ${pts[0].y}..${pts[pts.length - 1].y})`)
+console.log(pts.map((p) => `(${p.x.toFixed(1)},${p.y.toFixed(1)})`).join(' '))
+
+// 三次贝塞尔最小二乘(P0/P3 固定,求 P1/P2;弦长参数化)
+function fitBezier(p0, p3, samples) {
+    const cum = [0]
+    for (let i = 1; i < samples.length; i++) {
+        cum.push(cum[i - 1] + Math.hypot(samples[i].x - samples[i - 1].x, samples[i].y - samples[i - 1].y))
+    }
+    const total = cum[cum.length - 1] || 1
+    const ts = samples.map((_, i) => 0.5 - 0.5 * Math.cos(Math.PI * (cum[i] / total))) // 余弦参数化,端点加密
+    const n = samples.length
+    // 残差最小化:对每个样本 i,B(t_i) ≈ sample_i;B 对 P1,P2 线性 → 2×2 正规方程
+    let a11 = 0, a12 = 0, a22 = 0, b1x = 0, b1y = 0, b2x = 0, b2y = 0
+    const coef = (t) => {
+        const mt = 1 - t
+        return { p0: mt * mt * mt, p1: 3 * mt * mt * t, p2: 3 * mt * t * t, p3: t * t * t }
+    }
+    for (let i = 0; i < n; i++) {
+        const c = coef(ts[i])
+        const r1 = c.p1, r2 = c.p2
+        a11 += r1 * r1; a12 += r1 * r2; a22 += r2 * r2
+        b1x += r1 * (samples[i].x - c.p0 * p0.x - c.p3 * p3.x)
+        b1y += r1 * (samples[i].y - c.p0 * p0.y - c.p3 * p3.y)
+        b2x += r2 * (samples[i].x - c.p0 * p0.x - c.p3 * p3.x)
+        b2y += r2 * (samples[i].y - c.p0 * p0.y - c.p3 * p3.y)
+    }
+    const det = a11 * a22 - a12 * a12
+    const c1x = (b1x * a22 - b2x * a12) / det
+    const c1y = (b1y * a22 - b2y * a12) / det
+    const c2x = (b2x * a11 - b1x * a12) / det
+    const c2y = (b2y * a11 - b1y * a12) / det
+    const p1 = { x: c1x, y: c1y }, p2 = { x: c2x, y: c2y }
+    let maxRes = 0
+    for (let i = 0; i < n; i++) {
+        const c = coef(ts[i])
+        const bx = c.p0 * p0.x + c.p1 * p1.x + c.p2 * p2.x + c.p3 * TIP.x
+        const by = c.p0 * p0.y + c.p1 * p1.y + c.p2 * p2.y + c.p3 * TIP.y
+        maxRes = Math.max(maxRes, Math.hypot(bx - samples[i].x, by - samples[i].y))
+    }
+    return { p1, p2, maxRes }
+}
+
+// 切点扫描:P0 在帽圆左半(θ 自 +x 轴、y 向下;左下段 θ∈95°..140°:x<188.5 且 y>189)
+let best = null
+for (let deg = 95; deg <= 140; deg += 1) {
+    const rad = (deg * Math.PI) / 180
+    const p0 = { x: CAP.cx + CAP.r * Math.cos(rad), y: CAP.cy + CAP.r * Math.sin(rad) }
+    if (p0.y < 300) continue
+    // 路径方向 = 沿圆逆时针(屏幕系:顶→左→下),θ 递减方向的切向 = (sinθ, −cosθ)
+    const tan = { x: Math.sin(rad), y: -Math.cos(rad) }
+    // 样本 = 切点下方 3px 起的左缘点
+    const samples = pts.filter((p) => p.y >= p0.y + 3 && p.x <= CAP.cx)
+    if (samples.length < 6) continue
+    // P1 = p0 + k·tan(G1 方向固定,长度 k 粗扫)
+    for (let k = 10; k <= 160; k += 5) {
+        const p1 = { x: p0.x + tan.x * k, y: p0.y + tan.y * k }
+        const r = fitBezierG1(p0, p1, samples)
+        if (!best || r.maxRes < best.maxRes) best = { deg, k, p0, p1, p2: r.p2, maxRes: r.maxRes }
+    }
+}
+function fitBezierG1(p0, p1, samples) {
+    // 固定 P0,P1,P3,求 P2(线性)
+    const cum = [0]
+    for (let i = 1; i < samples.length; i++) cum.push(cum[i - 1] + Math.hypot(samples[i].x - samples[i - 1].x, samples[i].y - samples[i - 1].y))
+    const total = cum[cum.length - 1] || 1
+    const ts = samples.map((_, i) => cum[i] / total)
+    let aa = 0, ab = 0, bb = 0
+    const sx = [], sy = []
+    for (let i = 0; i < samples.length; i++) {
+        const t = ts[i], mt = 1 - t
+        const c0 = mt * mt * mt, c1 = 3 * mt * mt * t, c2 = 3 * mt * t * t, c3 = t * t * t
+        aa += c2 * c2; ab += c2 * c3; bb += c3 * c3
+        sx.push(samples[i].x - c0 * p0.x - c1 * p1.x)
+        sy.push(samples[i].y - c0 * p0.y - c1 * p1.y)
+    }
+    // 最小化 Σ|c2·P2 + c3·TIP - s|²;未知 P2 二维,正规方程含 c3·TIP 交叉项
+    let a2t = 0, s2x = 0, s2y = 0, ttx = 0, tty = 0
+    for (let i = 0; i < samples.length; i++) {
+        const t = ts[i], mt = 1 - t
+        const c2 = 3 * mt * t * t, c3 = t * t * t
+        a2t += c2 * c3; ttx += c3 * c3 * TIP.x; tty += c3 * c3 * TIP.y
+        s2x += c2 * sx[i]; s2y += c2 * sy[i]
+    }
+    // aa·P2 + a2t·TIP = Sx(分量式)→ P2 = (Sx - a2t·TIP.x)/aa
+    const p2 = { x: (s2x - a2t * TIP.x) / aa, y: (s2y - a2t * TIP.y) / aa }
+    let maxRes = 0
+    for (let i = 0; i < samples.length; i++) {
+        const t = ts[i], mt = 1 - t
+        const c0 = mt * mt * mt, c1 = 3 * mt * mt * t, c2 = 3 * mt * t * t, c3 = t * t * t
+        const bx = c0 * p0.x + c1 * p1.x + c2 * p2.x + c3 * TIP.x
+        const by = c0 * p0.y + c1 * p1.y + c2 * p2.y + c3 * TIP.y
+        maxRes = Math.max(maxRes, Math.hypot(bx - samples[i].x, by - samples[i].y))
+    }
+    return { p2, maxRes }
+}
+if (!best) { console.log('无有效拟合'); process.exit(1) }
+console.log(`\n最优:切点角 ${best.deg}° → P0(${best.p0.x.toFixed(1)}, ${best.p0.y.toFixed(1)}),k=${best.k}`)
+console.log(`P1(${best.p1.x.toFixed(1)}, ${best.p1.y.toFixed(1)}) P2(${best.p2.x.toFixed(1)}, ${best.p2.y.toFixed(1)}) → TIP(188.5,474)`)
+console.log(`最大残差 ${best.maxRes.toFixed(2)}px`)
+// 完整闭合路径(bbox 空间,viewBox 外扩 40 同 billboard 裁剪窗):
+// 顶点起,左弧(视觉逆时针 = sweep 0)到切点 → 贝塞尔到尖端 → 镜像贝塞尔(反放:控制点 P2',P1')到右切点 → 右弧回顶。
+console.log(`\npath: M188.5 0 A189 189 0 0 0 ${best.p0.x.toFixed(1)} ${best.p0.y.toFixed(1)} C${best.p1.x.toFixed(1)} ${best.p1.y.toFixed(1)} ${best.p2.x.toFixed(1)} ${best.p2.y.toFixed(1)} 188.5 474 C${(377 - best.p2.x).toFixed(1)} ${best.p2.y.toFixed(1)} ${(377 - best.p1.x).toFixed(1)} ${best.p1.y.toFixed(1)} ${(377 - best.p0.x).toFixed(1)} ${best.p0.y.toFixed(1)} A189 189 0 0 0 188.5 0 Z`)

+ 143 - 0
.scratch/cockpit-real-data-wayfinding/research/fit-pin-path.mjs

@@ -0,0 +1,143 @@
+// 一次性探针(票 47 DOM overlay 自绘母版):
+// ① 拟合母版下半收尖轮廓(假设=圆弧,检验残差);② 量镂空版主版的孔洞几何。
+// 输出直接嵌进 markerOverlay.js 的手绘 SVG path。
+import { readFileSync } from 'node:fs'
+import { inflateSync } from 'node:zlib'
+
+function decodePng(path) {
+    const buf = readFileSync(path)
+    let pos = 8
+    const idat = []
+    let width = 0, height = 0, colorType = 0, trns = null
+    while (pos < buf.length) {
+        const len = buf.readUInt32BE(pos)
+        const type = buf.toString('ascii', pos + 4, pos + 8)
+        const data = buf.subarray(pos + 8, pos + 8 + len)
+        if (type === 'IHDR') { width = data.readUInt32BE(0); height = data.readUInt32BE(4); colorType = data[9] }
+        else if (type === 'tRNS') trns = Buffer.from(data)
+        else if (type === 'IDAT') idat.push(data)
+        else if (type === 'IEND') break
+        pos += 12 + len
+    }
+    const channels = colorType === 6 ? 4 : colorType === 2 ? 3 : 1
+    const stride = width * channels
+    const raw = inflateSync(Buffer.concat(idat))
+    const img = Buffer.alloc(height * stride)
+    for (let y = 0; y < height; y++) {
+        const filter = raw[y * (stride + 1)]
+        const row = raw.subarray(y * (stride + 1) + 1, (y + 1) * stride)
+        const prev = y > 0 ? img.subarray((y - 1) * stride, y * stride) : null
+        const out = img.subarray(y * stride, (y + 1) * stride)
+        for (let x = 0; x < stride; x++) {
+            const a = x >= channels ? out[x - channels] : 0
+            const b = prev ? prev[x] : 0
+            const c = prev && x >= channels ? prev[x - channels] : 0
+            let v = row[x]
+            if (filter === 1) v += a
+            else if (filter === 2) v += b
+            else if (filter === 3) v += (a + b) >> 1
+            else if (filter === 4) {
+                const p = a + b - c
+                const pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c)
+                v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c
+            }
+            out[x] = v & 0xff
+        }
+    }
+    const alphaAt = (x, y) => {
+        if (colorType === 6) return img[y * stride + x * 4 + 3]
+        if (colorType === 2) return 255
+        const idx = img[y * stride + x]
+        if (trns && idx < trns.length) return trns[idx]
+        return 255
+    }
+    return { width, height, alphaAt }
+}
+
+const MINX = 67, MAXX = 444, MINY = 19, MAXY = 493
+// 帽圆(实测):圆心 (255.5, 208),半径 189 —— 与 MASTER_GEOMETRY capCenter(0.499,0.397) 一致
+const CAP = { cx: 255.5, cy: 208, r: 189 }
+
+const file = process.argv[2] || 'public/cockpit/assets/icons/T_MarkPoint_WhiteFull.png'
+const { alphaAt } = decodePng(file)
+const edge = (y) => {
+    for (let x = MINX; x <= MAXX; x++) if (alphaAt(x, y) > 8) return x
+    return null
+}
+
+// ---- ① 下半段左缘采样 + 圆拟合 ----
+const pts = []
+for (let y = 380; y <= 490; y += 5) {
+    const x = edge(y)
+    if (x != null) pts.push([x, y])
+}
+function fitCircle(points) {
+    const n = points.length
+    let suu = 0, suv = 0, su = 0, svv = 0, sv = 0, suw = 0, svw = 0, sw = 0
+    for (const [u, v] of points) {
+        const w = u * u + v * v
+        suu += u * u; suv += u * v; su += u; svv += v * v; sv += v
+        suw += w * u; svw += w * v; sw += w
+    }
+    const A = [[suu, suv, su], [suv, svv, sv], [su, sv, n]]
+    const b = [suw, svw, sw]
+    for (let i = 0; i < 3; i++) {
+        let p = i
+        for (let k = i + 1; k < 3; k++) if (Math.abs(A[k][i]) > Math.abs(A[p][i])) p = k
+        ;[A[i], A[p]] = [A[p], A[i]]; [b[i], b[p]] = [b[p], b[i]]
+        for (let k = i + 1; k < 3; k++) {
+            const f = A[k][i] / A[i][i]
+            for (let j = i; j < 3; j++) A[k][j] -= f * A[i][j]
+            b[k] -= f * b[i]
+        }
+    }
+    const z = [0, 0, 0]
+    for (let i = 2; i >= 0; i--) {
+        z[i] = b[i]
+        for (let j = i + 1; j < 3; j++) z[i] -= A[i][j] * z[j]
+        z[i] /= A[i][i]
+    }
+    const [D, E, F] = z
+    return { cx: -D / 2, cy: -E / 2, r: Math.sqrt((D * D) / 4 + (E * E) / 4 - F) }
+}
+const fit = fitCircle(pts)
+let maxRes = 0
+for (const [x, y] of pts) maxRes = Math.max(maxRes, Math.abs(Math.hypot(x - fit.cx, y - fit.cy) - fit.r))
+console.log(`下半段圆弧拟合:圆心 (${fit.cx.toFixed(1)}, ${fit.cy.toFixed(1)}) 半径 ${fit.r.toFixed(1)},最大残差 ${maxRes.toFixed(2)}px`)
+const tip = { x: 255.5, y: 493 }
+const dTip = Math.hypot(tip.x - fit.cx, tip.y - fit.cy)
+console.log(`尖端到拟合圆心距离 ${dTip.toFixed(1)} vs 半径 ${fit.r.toFixed(1)}(差 ${(dTip - fit.r).toFixed(1)}px → ${Math.abs(dTip - fit.r) < 2 ? '尖端在圆上,A 命令直达' : '尖端不在圆上'})`)
+// 帽圆 ∩ 拟合圆 交点
+const dx = fit.cx - CAP.cx, dy = fit.cy - CAP.cy, d = Math.hypot(dx, dy)
+const a = (CAP.r * CAP.r - fit.r * fit.r + d * d) / (2 * d)
+const h = Math.sqrt(Math.max(0, CAP.r * CAP.r - a * a))
+const mx = CAP.cx + (a * dx) / d, my = CAP.cy + (a * dy) / d
+console.log(`帽圆∩拟合圆:(${(mx + (h * dy) / d).toFixed(1)}, ${(my - (h * dx) / d).toFixed(1)}) / (${(mx - (h * dy) / d).toFixed(1)}, ${(my + (h * dx) / d).toFixed(1)})`)
+
+// ---- ② 镂空版孔洞几何 ----
+const hollow = decodePng('public/cockpit/assets/icons/T_MarkPoint_White.png')
+const rowGaps = (y) => {
+    const gaps = []
+    let start = -1
+    for (let x = MINX; x <= MAXX; x++) {
+        const al = hollow.alphaAt(x, y)
+        if (al <= 8 && start < 0) start = x
+        if (al > 8 && start >= 0) { gaps.push([start, x - 1]); start = -1 }
+    }
+    if (start >= 0) gaps.push([start, MAXX])
+    return gaps
+}
+const colGaps = () => {
+    const gaps = []
+    let start = -1
+    for (let y = MINY; y <= MAXY; y++) {
+        const al = hollow.alphaAt(256, y)
+        if (al <= 8 && start < 0) start = y
+        if (al > 8 && start >= 0) { gaps.push([start, y - 1]); start = -1 }
+    }
+    if (start >= 0) gaps.push([start, MAXY])
+    return gaps
+}
+console.log('\n镂空版 y=208 透明段:', JSON.stringify(rowGaps(208)))
+console.log('镂空版 y=190 透明段:', JSON.stringify(rowGaps(190)))
+console.log('镂空版 x=256 透明段:', JSON.stringify(colGaps()))

+ 86 - 0
.scratch/cockpit-real-data-wayfinding/research/probe-contour.mjs

@@ -0,0 +1,86 @@
+// 一次性探针:采样白母版的逐行轮廓(左右缘 x),用于拟合自绘 SVG 水滴路径。
+// 用法:node probe-contour.mjs <png>
+import { readFileSync } from 'node:fs'
+import { inflateSync } from 'node:zlib'
+
+function decodePng(path) {
+    const buf = readFileSync(path)
+    let pos = 8
+    const idat = []
+    let width = 0, height = 0, bitDepth = 0, colorType = 0, palette = null, trns = null
+    while (pos < buf.length) {
+        const len = buf.readUInt32BE(pos)
+        const type = buf.toString('ascii', pos + 4, pos + 8)
+        const data = buf.subarray(pos + 8, pos + 8 + len)
+        if (type === 'IHDR') {
+            width = data.readUInt32BE(0); height = data.readUInt32BE(4)
+            bitDepth = data[8]; colorType = data[9]
+        } else if (type === 'PLTE') palette = Buffer.from(data)
+        else if (type === 'tRNS') trns = Buffer.from(data)
+        else if (type === 'IDAT') idat.push(data)
+        else if (type === 'IEND') break
+        pos += 12 + len
+    }
+    const channels = colorType === 6 ? 4 : colorType === 2 ? 3 : 1
+    const stride = width * channels
+    const raw = inflateSync(Buffer.concat(idat))
+    const img = Buffer.alloc(height * stride)
+    for (let y = 0; y < height; y++) {
+        const filter = raw[y * (stride + 1)]
+        const row = raw.subarray(y * (stride + 1) + 1, (y + 1) * stride)
+        const prev = y > 0 ? img.subarray((y - 1) * stride, y * stride) : null
+        const out = img.subarray(y * stride, (y + 1) * stride)
+        for (let x = 0; x < stride; x++) {
+            const a = x >= channels ? out[x - channels] : 0
+            const b = prev ? prev[x] : 0
+            const c = prev && x >= channels ? prev[x - channels] : 0
+            let v = row[x]
+            if (filter === 1) v += a
+            else if (filter === 2) v += b
+            else if (filter === 3) v += (a + b) >> 1
+            else if (filter === 4) {
+                const p = a + b - c
+                const pa = Math.abs(p - a), pb = Math.abs(p - b), pc = Math.abs(p - c)
+                v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c
+            }
+            out[x] = v & 0xff
+        }
+    }
+    const alphaAt = (x, y) => {
+        if (colorType === 6) return img[y * stride + x * 4 + 3]
+        if (colorType === 2) return 255
+        const idx = img[y * stride + x]
+        if (trns && idx < trns.length) return trns[idx]
+        return 255
+    }
+    return { width, height, alphaAt }
+}
+
+const file = process.argv[2] || 'public/cockpit/assets/icons/T_MarkPoint_WhiteFull.png'
+const { width, height, alphaAt } = decodePng(file)
+const MINX = 67, MAXX = 444, MINY = 19, MAXY = 493
+const BW = MAXX - MINX + 1, BH = MAXY - MINY + 1
+
+console.log(`== ${file} 逐行轮廓(bbox 相对坐标,宽 = 左右缘跨度 / bbox 宽)==`)
+for (let i = 0; i <= 20; i++) {
+    const t = i / 20 // 0=顶 1=底
+    const y = Math.round(MINY + t * (MAXY - MINY))
+    let lo = -1, hi = -1
+    for (let x = MINX; x <= MAXX; x++) {
+        if (alphaAt(x, y) > 8) { if (lo < 0) lo = x; hi = x }
+    }
+    if (lo < 0) { console.log(`t=${t.toFixed(2)} y=${y}  (空)`); continue }
+    const lx = ((lo - MINX) / BW).toFixed(3)
+    const rx = ((hi - MINX) / BW).toFixed(3)
+    const w = ((hi - lo + 1) / BW).toFixed(3)
+    console.log(`t=${t.toFixed(2)} y=${y}  左${lx} 右${rx} 宽${w}`)
+}
+
+// 圆帽拟合验证:找最宽行与其 y,算帽心/半径(bbox 相对)
+let widest = 0, widestY = MINY
+for (let y = MINY; y <= MAXY; y++) {
+    let lo = -1, hi = -1
+    for (let x = MINX; x <= MAXX; x++) if (alphaAt(x, y) > 8) { if (lo < 0) lo = x; hi = x }
+    if (lo >= 0 && hi - lo + 1 > widest) { widest = hi - lo + 1; widestY = y }
+}
+console.log(`\n最宽行 y=${widestY}(相对 ${(widestY - MINY) / BH})宽 ${widest} = bbox 宽 ${(widest / BW).toFixed(3)}`)

+ 57 - 0
src/modules/cockpit/services/cesiumEngine.js

@@ -1382,6 +1382,63 @@ export function setOverlapPresentation(viewer, { groupKey = '', state = 'collaps
     return touched
 }
 
+/**
+ * 票 47:关联设备图层的每帧投影读数(DOM overlay 消费)。
+ *
+ * 只回**当前可见**的设备实体:折叠聚合下的个体 `show=false` 不进 DOM(聚合符号按票面
+ * Out of Scope 留在 billboard);展开扇形后的个体带着 pixelOffset 回来,DOM 与 billboard
+ * 同位同量。坐标 = 窗口坐标(与 `projectPivot` 同系);`scale`/`alpha` 读的是实体上
+ * **当前真值**(选中 1.6 / hover 1.3 / 弱化 0.75 / 强调 1.55 都含在内)——
+ * DOM 镜像它才不会出现「比 billboard 小一圈」。
+ */
+export function projectDeviceMarkers(viewer) {
+    const source = deviceMarkerSource(viewer)
+    if (!viewer || !viewer.scene || !viewer.canvas || !source || source.show === false) return []
+    const out = []
+    for (const entity of source.entities.values) {
+        const properties = entity.properties || {}
+        const read = (key) => (properties[key] && properties[key].getValue ? properties[key].getValue() : '')
+        const code = read('deviceCode')
+        if (!code || entity.show === false) continue
+        // projectMarkerEntity 已含画布原点(窗口坐标)与 pixelOffset(尖端落点)——直接用,
+        // 调用方只减宿主原点(与 projectToWindow 的口径只差 host 一层)。
+        const projected = projectMarkerEntity(viewer, entity)
+        if (!projected) continue
+        const color = entity.billboard && entity.billboard.color ? entity.billboard.color.getValue() : null
+        out.push({
+            key: `device|${read('monitoringPointCode')}|${code}`,
+            code,
+            pointCode: read('monitoringPointCode'),
+            x: projected.x,
+            y: projected.y,
+            scale: entity.billboard && entity.billboard.scale ? Number(entity.billboard.scale.getValue()) || 1 : 1,
+            alpha: color && Number.isFinite(color.alpha) ? color.alpha : 1,
+        })
+    }
+    return out
+}
+
+/**
+ * 票 47:点位标记(监测点/未关联设备)的每帧缩放读数。
+ * 强调(`highlightEntities` 的 1.55)写在 billboard.scale 上,DOM 要镜像同一个量,
+ * 否则面板联动强调时两层尺寸不一致(维护者走查抓到的「小了一圈」)。
+ * @returns {Map<string, number>} `kind|code` → scale(没实体 = 不在图上,DOM 自行隐藏)
+ */
+export function readPointMarkerScales(viewer) {
+    const scales = new Map()
+    const source = (viewer && markerSources.get(viewer)) || (viewer && viewer.dataSources ? viewer.dataSources.getByName(POINT_MARKER_SOURCE)[0] : null)
+    if (!source) return scales
+    for (const entity of source.entities.values) {
+        const properties = entity.properties || {}
+        const read = (key) => (properties[key] && properties[key].getValue ? properties[key].getValue() : '')
+        const code = read('pointCode')
+        if (!code) continue
+        const kind = read('pointKind') || 'monitoring-point'
+        scales.set(`${kind}|${code}`, entity.billboard && entity.billboard.scale ? Number(entity.billboard.scale.getValue()) || 1 : 1)
+    }
+    return scales
+}
+
 /** 只读回关联设备图层的真实内容(走查据此断言「设备真的画在独立图层上」,以及「画成的样子来自接缝」)。 */
 export function readAssociatedDevices(viewer) {
     const source = deviceMarkerSource(viewer)

+ 59 - 23
src/modules/cockpit/services/mapService.js

@@ -44,8 +44,7 @@ import { MARKER_SIZE, planDeviceAsset } from '../domain/deviceAssets.js'
 import { planMarker, planToDataset } from '../domain/markerPlan.js'
 import { renderMarker } from './markerAssets.js'
 // 批次 3 第五轮(票 20):DOM overlay 渲染层(billboard 发糊的结构性解决)。
-import { mountMarkerOverlay, attachOverlayRenderer, unmountMarkerOverlay } from './markerOverlay.js'
-import { resolveAsset } from '../domain/runtimeAssets.js'
+import { mountMarkerOverlay, attachOverlayRenderer, unmountMarkerOverlay, setMarkerOverlayVisible, isMarkerOverlayVisible } from './markerOverlay.js'
 import { DEVICE_LAYER_KIND } from '../domain/deviceLayer.js'
 import { deviceVisualForStatus } from '../domain/deviceStatus.js'
 import { isRenderable } from '../domain/coordinate.js'
@@ -421,6 +420,11 @@ function publishDevHook() {
         // 票 21:地面/管线视图切换(走查断言"切换不重置中心"用)。
         setViewMode: (mode, overrides) => setViewMode(mode, overrides),
         getViewMode: () => getViewMode(),
+        // 票 47 临时 A/B(走查对照):DOM overlay(当前版)↔ billboard(之前版)同视角互换。
+        // 只切显示不切数据链路;`toggle` 无参翻转,`get` 读当前态。
+        setMarkerOverlay: (visible) => setMarkerOverlayVisible(visible),
+        toggleMarkerOverlay: () => setMarkerOverlayVisible(!isMarkerOverlayVisible()),
+        getMarkerOverlay: () => isMarkerOverlayVisible(),
         // 票 36:主场景区(走查与人工排查读"配没配、矩形是哪一个",票 37/38 也读这一个出口)。
         getPrimaryAreaPlan: () => getPrimaryAreaPlan(),
         // 票 37:进区锁定与区域限位(走查驱动「进入 → 锁定 → 拉远被夹 → 返回全球」全过程)。
@@ -855,46 +859,70 @@ async function bootEngine(record) {
         // 反读出来的 `{pivot, hpr}` 会被存成 `introView`,之后「闲置回引导」回到它。
         record.cameraControl.syncFromEngine()
 
-        // 批次 3 第五轮(票 20):DOM overlay 渲染标记与标签——billboard 发糊与底板生硬的
-        // 结构性解决。失败自动回退既有 billboard 链路(syncPointMarkersForPlan 不动)。
-        if (mountMarkerOverlay(record.host)) {
+        // 批次 3 第五轮(票 47):DOM overlay 渲染标记与标签——外层自绘 SVG(矢量,任何 DPR
+        // 都锐利)+ 内层行业素材(原生分辨率去色重染)+ 真 DOM 标签。billboard 链路保留
+        // (聚合/特效/Canvas 拾取仍依赖;内层素材失败时只有母版 = 降级可见)。维护者裁定:
+        // DOM 完整复刻 billboard 功能后才替换它。
+        // 临时 A/B(走查对照,维护者要求):URL 带 `overlay=0` 时本页初始只显示 billboard
+        // (= 之前版本),与运行时把手 `__cockpitMap.setMarkerOverlay(bool)` 配合做同视角互换。
+        // hash 路由下参数可能在 `#` 前(`/?overlay=0#/cockpit`)也可能在 hash 内
+        // (`/#/cockpit?overlay=0`),两处都认。
+        if (typeof window !== 'undefined') {
+            const hashQuery = String(window.location.hash || '').split('?')[1] || ''
+            const off = new URLSearchParams(window.location.search).get('overlay') === '0'
+                || new URLSearchParams(hashQuery).get('overlay') === '0'
+            if (off) setMarkerOverlayVisible(false)
+        }
+        if (mountMarkerOverlay(record.host, {
+            // 点击触发器:直接照搬 Canvas 点选链同一个功能函数(维护者原话)。
+            // DOM 标记与 billboard 同位同尺寸(贴地口径一致),窗口坐标拾取必然命中同一实体。
+            onPick: ({ x, y }) => pickDeviceOrPointAt(x, y),
+        })) {
             record.detachOverlay = attachOverlayRenderer(instance.viewer, () => {
                 if (!markerPlan || !instance || !instance.viewer) return null
                 const colors = toneColors()
                 return {
                     markers: markerPlan.markers,
+                    // 票 47 维护者反馈②:关联设备图层每帧投影进 DOM(此前只渲染摘要标记计划,
+                    // 关联监测点的设备整体缺 DOM 形态)。行内 scale/alpha 是 billboard 实体
+                    // 真值(选中/hover/弱化反应量),DOM 镜像同一量防两层尺寸/浓度分离。
+                    // 业务字段(名称/状态/行业/tone)从图层计划行并表——引擎投影只认实体身份。
+                    devices: (() => {
+                        const byCode = new Map(deviceRows.map((row) => [row.code, row]))
+                        return engine.projectDeviceMarkers(instance.viewer).map((row) => {
+                            const planned = byCode.get(row.code)
+                            return planned
+                                ? { ...row, name: planned.name, status: planned.status, industry: planned.industry, tone: planned.tone }
+                                : row
+                        })
+                    })(),
+                    // 票 47 维护者反馈①:面板强调把 billboard.scale 改到 1.55,DOM 要镜像
+                    // 同一个量(否则强调时两层一大小,看起来「设备点小了一圈」)。
+                    pointScales: engine.readPointMarkerScales(instance.viewer),
                     tones: colors,
                     planOf: (marker) => planMarker({
-                        kind: marker.kind === 'unassociated-equipment' ? 'monitoring-device' : 'monitoring-point',
+                        kind: marker.kind === 'monitoring-point' ? 'monitoring-point' : 'monitoring-device',
                         status: marker.status,
                         industry: marker.industry,
                         tone: marker.tone,
                         unassociated: marker.kind === 'unassociated-equipment',
                     }),
-                    innerUrls: markerInnerUrls(),
-                    // 走相机引擎的 projectPivot(createCameraEngine 返回对象上的方法,SceneTransforms 包装)
+                    // 贴地定位与 billboard `CLAMP_TO_GROUND` 同口径(票 47 维护者反馈①根因):
+                    // 首版按椭球 0 米投影,地形区里 DOM 标记恒落在 billboard 标记的**地面以下**。
+                    // 高度取值与 `projectMarkerEntity` 一致:地形瓦片实测高程,取不到回 0(椭球面)。
                     projectToWindow: (lon, lat) => {
-                        const at = cameraEngine.projectPivot({ lon, lat, height: 0 })
+                        const ground = engine.groundHeightAt(instance.viewer, lon, lat)
+                        const at = cameraEngine.projectPivot({ lon, lat, height: Number.isFinite(ground) ? ground : 0 })
                         if (!at) return null
-                        // overlay 节点挂在画布容器内(相对同一原点),去掉 host 的画布偏移
+                        // projectPivot 回**窗口坐标**;overlay 宿主 inset:0 铺在 host 内,
+                        // 节点坐标原点 = host 左上角 → 减 host 的窗口原点。
                         const rect = record.host.getBoundingClientRect()
-                        const canvas = instance.viewer.canvas.getBoundingClientRect()
-                        return { x: at.x - (canvas.left - rect.left), y: at.y - (canvas.top - rect.top) }
+                        return { x: at.x - rect.left, y: at.y - rect.top }
                     },
                 }
             })
         }
 
-        // 批次 3:overlay 的内层图标 URL 表(manifest 解析产物,语义名 → data URL)
-        function markerInnerUrls() {
-            const urls = {}
-            for (const icon of ['gas', 'water', 'drainage', 'manhole']) {
-                const asset = resolveAsset(`marker.inner.${icon}`)
-                if (asset.url) urls[icon] = asset.url
-            }
-            return urls
-        }
-
         // 引导视角基准 = 引擎挂载时反读到的 **Cesium Viewer 默认相机**
         // (维护者 2026-09-30 裁定:初始引导页就是这张"整个地球"的地球仪视角,
         // **不是** `INITIAL_PANORAMA`)。之后「闲置回引导」回到它。
@@ -1013,6 +1041,14 @@ export function unmountMap() {
         record.detachPickInput()
         record.detachPickInput = null
     }
+    // 票 47:DOM overlay 随地图卸载 —— 摘 postRender、清节点池与宿主。
+    // (票面核验修正:首版 `unmountMarkerOverlay` 只 import 未调用、`record.detachOverlay` 无人摘,
+    //  离开 /cockpit 后 postRender 仍打向已销毁的 viewer。)
+    if (record.detachOverlay) {
+        record.detachOverlay()
+        record.detachOverlay = null
+    }
+    unmountMarkerOverlay()
     clearPipelinePickHandlers()
     // 票 43:hover 监听一并摘掉(与点选同一个"销毁后读到空 viewer"的坑)。
     if (record.detachHoverInput) {
@@ -2996,7 +3032,7 @@ export function clearPipelinePickHandlers() {
     pipelinePickHandlers.clear()
 }
 
-
+function pickPipelineAt(clientX, clientY) {
     if (!instance || !instance.engine || !instance.viewer || typeof instance.engine.pickPipeObjectAt !== 'function') {
         return { target: 'none', pick: null }
     }

+ 29 - 13
src/modules/cockpit/services/markerAssets.js

@@ -220,6 +220,26 @@ function hexToRgb(hex) {
  * @param {string} opts.tintHex 状态色(调用方从 tokens 读出的 CSS 颜色)
  * @returns {Promise<string>} billboard 用的 data URL(PNG,已裁剪到 bbox)
  */
+/**
+ * 内层图标的**去色重染 data URL**(票 47 DOM overlay 用):与 billboard 合成同一
+ * `recolorInner` 规则,但**不下采样**——返回 256px 原生分辨率产物,DOM 侧按帽径
+ * 向下采样永远锐利(预下采样位图喂给 DOM 会在高 DPR 屏被放大发糊,这是"素材变糊"的根因)。
+ * 每图标一次合成、缓存复用。
+ */
+const innerIconCache = new Map() // icon → Promise<dataURL>
+export function renderInnerIconUrl(icon) {
+    if (innerIconCache.has(icon)) return innerIconCache.get(icon)
+    const p = (async () => {
+        const asset = resolveAsset(`${INNER_KEY_PREFIX}${icon}`)
+        if (!asset.url) throw new Error(`内层未登记: ${icon}`)
+        const img = await loadImage(asset.url)
+        return recolorInner(img).toDataURL('image/png')
+    })()
+    innerIconCache.set(icon, p)
+    p.catch(() => innerIconCache.delete(icon))
+    return p
+}
+
 export async function renderMarker(plan, { tintHex } = {}) {
     if (typeof document === 'undefined') throw new Error('renderMarker 仅在浏览器环境可用')
     const masterKey = MASTER_KEYS[plan.master]
@@ -237,20 +257,16 @@ export async function renderMarker(plan, { tintHex } = {}) {
     const fctx = full.getContext('2d')
     fctx.drawImage(tintMaster(masterBitmap, rgb), 0, 0)
 
-    // 内层行业图标:直径 60% × 母版宽,圆心对齐帽心;**去色重染**(蓝白双色层次,见文件头规则)。
+    // 内层行业图标:直径按 `plan.inner.diameterRatio` × 母版宽,圆心对齐帽心;**去色重染**。
     if (plan.inner.enabled) {
-        const innerAsset = resolveAsset(`${INNER_KEY_PREFIX}${plan.inner.icon}`)
-        if (innerAsset.url) {
-            try {
-                const inner = await loadImage(innerAsset.url)
-                const recolored = recolorInner(inner)
-                const diameter = Math.round(bbox.width * plan.inner.diameterRatio)
-                const cx = bbox.x + bbox.width * plan.inner.center.x
-                const cy = bbox.y + bbox.height * plan.inner.center.y
-                fctx.drawImage(recolored, cx - diameter / 2, cy - diameter / 2, diameter, diameter)
-            } catch {
-                // 内层加载失败:母版照常上图(降级可见),不阻塞整个标记。
-            }
+        try {
+            const recolored = await loadImage(await renderInnerIconUrl(plan.inner.icon))
+            const diameter = Math.round(bbox.width * plan.inner.diameterRatio)
+            const cx = bbox.x + bbox.width * plan.inner.center.x
+            const cy = bbox.y + bbox.height * plan.inner.center.y
+            fctx.drawImage(recolored, cx - diameter / 2, cy - diameter / 2, diameter, diameter)
+        } catch {
+            // 内层加载失败:母版照常上图(降级可见),不阻塞整个标记。
         }
     }
 

+ 223 - 76
src/modules/cockpit/services/markerOverlay.js

@@ -1,58 +1,65 @@
 /**
- * 地图标记 DOM overlay(票 20)——billboard 发糊与底板生硬的结构性解决。
+ * 地图标记 DOM overlay(票 47)——billboard 发糊与底板生硬的结构性解决。
  *
- * 把 `planPointMarkers` 产出的标记从 GPU billboard 迁到 **UI 同层的 DOM 渲染**:
- *   · 标记图形 = 内联 SVG(状态色母版 + 内层图标 + 白描边),矢量渲染**永不发糊**;
- *   · 标签 = 真 DOM(圆角底板、装饰边、text-overflow、hover title 全部原生能力);
- *   · 每帧由 `postRender` 把经纬度经 `wgs84ToWindowCoordinates` 投影成窗口坐标,
- *     只写 `transform`(合成层更新,不触发布局)。
+ * 渲染分工(维护者 2026-10-15 裁定):
+ *   · **外层母版 = 自绘 SVG**(矢量,任何 DPR 都锐利;billboard 发糊的根治)。
+ *     水滴形按实测母版几何拟合:帽圆 (188.5,189) r189(逐行验证误差 <1px)+
+ *     下半收尖三次贝塞尔(对 512px 母版左缘 57 点最小二乘拟合,最大残差 4.75 源像素
+ *     ≈ 0.34 屏显像素;拟合探针见 .scratch/cockpit-real-data-wayfinding/research/fit-pin-bezier.mjs)。
+ *     镂空孔 = 实测圆 (188.5,189) r75。viewBox 外扩 40 与 billboard 裁剪窗(OUTLINE_PAD)同形。
+ *   · **内层行业图标 = 素材**(`markerAssets.renderInnerIconUrl`:与 billboard 同一
+ *     `recolorInner` 去色重染规则,但**不下采样**——256px 原生分辨率 data URL,
+ *     DOM 里按帽径向下采样,永远锐利)。
+ *   · 标签 = 真 DOM(圆角底板、装饰边、text-overflow、hover title 原生能力)。
+ *   · 每帧 `postRender` 投影:与 billboard `CLAMP_TO_GROUND` 同口径(地形实测高程,取不到回椭球 0)。
  *
- * 与既有链路的关系:
- *   · 合成计划层 `domain/markerPlan.js` **原样复用**(归属/几何/锚点/降饱和逻辑不换媒介);
- *   · `syncPointMarkersForPlan`(billboard 链路)**保留不动**——overlay 初始化失败自动回退;
- *   · 拾取:DOM 节点自身可点(直接派发给既有 onPointPick 订阅者),Canvas 拾取继续服务 3D 对象;
- *   · `data-*` 断言面全部挂到 DOM 节点(`data-marker-overlay-key` 等),走查不读内部实现。
+ * 与既有链路的关系(维护者裁定:DOM 完整复刻 billboard 功能后才替换它):
+ *   · 合成计划层 `domain/markerPlan.js` **原样复用**(归属/几何/锚点/降饱和不换媒介);
+ *   · `syncPointMarkersForPlan`(billboard 链路)**保留不动**——聚合、特效、Canvas 拾取仍依赖它;
+ *     内层素材加载失败时 SVG 只有母版 = 底下 billboard 双层照常显示(降级可见);
+ *   · 拾取:overlay 节点挂**显式 click 触发器**,回调直接复用 `pickDeviceOrPointAt`
+ *     (与 Canvas 点选链同一个功能函数,维护者原话「照搬原本的功能函数」);
+ *   · `data-*` 断言面全部挂到 DOM 节点,走查不读内部实现。
  *
- * 性能纪律:节点池复用(不每帧重建);postRender 回调内只做 transform 与 visibility 更新。
+ * 性能纪律:节点池复用(不每帧重建);计划读数指纹变化才重建;postRender 只做 transform 更新。
  */
+import { renderInnerIconUrl } from './markerAssets.js'
+
 const OVERLAY_HOST_CLASS = 'marker-overlay-host'
 const NODE_CLASS = 'marker-overlay-node'
 const LABEL_CLASS = 'marker-overlay-label'
 
-/** 节点池:key → { root, label, lastX, lastY };标记消失时节点隐藏不销毁。 */
-const pool = new Map()
-let hostNode = null
-let hostRecord = null
-let postRenderRemove = null
-
 /**
- * 水滴形 SVG(矢量母版,替代位图):状态色由调用方注入 `--tone` 变量。
- * viewBox 0 0 100 126(宽高比 ≈ 1.257,与白母版实测一致);尖端 (50,126)。
+ * 自绘水滴母版(bbox 坐标系:x∈[0,378]、y∈[0,475],尖端 (188.5,474))。
+ * viewBox 外扩 40(同 billboard OUTLINE_PAD 裁剪窗)→ "-40 -40 458 555"。
+ *
+ * 描边(浏览器走查修正):billboard 的 `withWhiteOutline` 是**向外膨胀** 28 源像素、
+ * 母版本身保持 378 全宽;SVG `stroke` 默认沿路径**中心线**(外 14 / 内 14)会把彩色母版
+ * 蚀掉一圈、白环也只出一半——同尺寸下 DOM 标记明显比 billboard 小。
+ * 用 `paint-order="stroke fill"` 把描边压到填充**之下**:彩色母版全尺寸保留,
+ * 白色纯外扩 `stroke-width/2` = 28 → 与 billboard 的 OUTLINE_WIDTH 同值(外沿 28 < pad 40 不被裁)。
  */
-function markerSvg(master) {
-    const fill = master === 'hollow'
-        ? '<path d="M50 4C25 4 6 24 6 50c0 37 44 66 44 76s-44-39-44-76C6 24 25 4 50 4Zm0 26a20 20 0 1 1 0 40 20 20 0 0 1 0-40Z" fill="var(--tone)" fill-rule="evenodd"/>'
-        : '<path d="M50 4C25 4 6 24 6 50c0 37 44 66 44 76s-44-39-44-76C6 24 25 4 50 4Z" fill="var(--tone)"/>'
-    return `<svg viewBox="0 0 100 126" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">${fill}</svg>`
-}
+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_STROKE = 'stroke="#ffffff" stroke-width="56" stroke-linejoin="round" paint-order="stroke fill"'
 
-/**
- * 内层行业图标:manifest 登记的彩色/重染切图以 <image> 嵌入 SVG(矢量母版内嵌位图,
- * 位图按 60% 帽径绘制在帽心,与 billboard 合成一致——但 SVG 缩放矢量无损,无发糊)。
- */
-function innerSvg(icon, capCenterX, capCenterY, diameter, innerUrls) {
-    if (!icon || !innerUrls) return ''
-    const asset = innerUrls[icon]
-    if (!asset) return ''
-    const half = diameter / 2
-    // 染色规则(共识 §4.2):内层一律去色重染(亮部白、暗部浅蓝)——SVG <image> 不能逐像素
-    // 重染,用 CSS filter 近似:去色 → 提亮反转(invert 让深色剪影变亮)→ 上浅蓝色调。
-    return `<image href="${asset}" x="${capCenterX - half}" y="${capCenterY - half}" width="${diameter}" height="${diameter}"` +
-        ` filter="brightness(0.35) invert(1) sepia(1) saturate(3) hue-rotate(160deg)" opacity="0.95"/>`
+function markerSvg(master, toneHex) {
+    const body = master === 'hollow'
+        ? `<path d="${PIN_OUTLINE}${PIN_HOLE}" fill="${toneHex}" fill-rule="evenodd" ${PIN_STROKE}/>`
+        : `<path d="${PIN_OUTLINE}" fill="${toneHex}" ${PIN_STROKE}/>`
+    return `<svg viewBox="-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>`
 }
 
+/** 节点池:key → { root, inner, fp };标记消失时节点隐藏不销毁,计划读数变化时重建。 */
+const pool = new Map()
+let hostNode = null
+let hostRecord = null
+let postRenderRemove = null
+/** 临时 A/B 开关(票 47 走查):false = 隐藏 DOM 层、露出底下 billboard(之前版本)。 */
+let overlayVisible = true
+
 /** 单个标记节点(标记 SVG + 可选标签),一次构建、每帧只更新 transform。 */
-function buildNode(key, marker, plan, toneHex, innerUrls) {
+function buildNode(key, marker, plan, toneHex) {
     const root = document.createElement('div')
     root.className = NODE_CLASS
     root.dataset.markerOverlayKey = key
@@ -63,13 +70,11 @@ function buildNode(key, marker, plan, toneHex, innerUrls) {
     root.dataset.markerInner = plan.inner.enabled ? plan.inner.icon : ''
     root.dataset.markerAnchor = `${plan.anchor.x},${plan.anchor.y}`
     root.dataset.markerDesaturate = plan.desaturate ? 'yes' : 'no'
+    root.dataset.markerTone = toneHex
 
     const w = plan.size.width
     const h = plan.size.height
-    // SVG viewBox 100x126 → 屏显 w×h;帽心/内层按 MASTER_GEOMETRY 比例换算到 viewBox 坐标
-    const capX = 50
-    const capY = 47.6 // 0.397 × 120(bbox 高在 viewBox 中 ≈ 120)
-    const diameter = 60 // 60% × 100(viewBox 宽)
+    const pad = plan.draw.pad
 
     root.style.cssText = [
         'position:absolute',
@@ -80,21 +85,41 @@ function buildNode(key, marker, plan, toneHex, innerUrls) {
         'will-change:transform',
     ].join(';')
 
-    const markerLayer = document.createElement('div')
-    markerLayer.style.cssText = [
-        `position:absolute`,
-        `width:${w}px`,
-        `height:${h}px`,
-        // BOTTOM 锚定:节点 transform 原点是标记尖端,图形向上画
-        `left:${-w / 2}px`,
-        `top:${-h}px`,
-        `--tone:${toneHex}`,
+    // 标记本体:自绘 SVG(外层母版)+ 异步嵌入的内层素材 <image>(原生分辨率,向下采样)。
+    // 摆放:尖端(锚点)落在 root 原点——合成窗含 pad,尖端距输出底边/左缘的换算与
+    // billboard `verticalOrigin:BOTTOM + pixelOffset(0,pad)` 同一补偿。
+    const wrap = document.createElement('div')
+    wrap.innerHTML = markerSvg(plan.master, toneHex)
+    const svg = wrap.firstChild
+    svg.style.cssText = [
+        'position:absolute',
+        `left:${-(pad + w * plan.anchor.x)}px`,
+        `top:${-(h + pad)}px`,
+        `width:${plan.draw.width}px`,
+        `height:${plan.draw.height}px`,
         plan.desaturate ? 'filter:saturate(0.55) brightness(0.72)' : '',
     ].join(';')
-    markerLayer.innerHTML = markerSvg(plan.master) + innerSvg(plan.inner.icon, capX, capY, diameter * (100 / w), innerUrls)
-    root.appendChild(markerLayer)
+    // 反应量缩放的**原点 = 尖端**(尖端固定在地面坐标上,标记围绕尖端放大/缩小,
+    // 与 billboard verticalOrigin:BOTTOM + scale 同一行为)。
+    svg.style.transformOrigin = `${pad + w * plan.anchor.x}px ${h + pad}px`
+    root.appendChild(svg)
+
+    const innerEl = svg.querySelector('[data-marker-overlay-inner]')
+    if (plan.inner.enabled) {
+        // 直径/圆心 = 计划层几何(80% × 母版宽、帽心对齐)。viewBox 起点 -40 只是外扩描边
+        // 余量,bbox 坐标即 SVG 用户坐标——圆心直接用母版帽心 (188.5, 0.397×475)。
+        const diameter = plan.inner.diameterRatio * 378
+        const cx = 378 * plan.anchor.x // 帽心 x = 尖端 x = 188.5
+        const cy = plan.inner.center.y * 475
+        innerEl.setAttribute('x', String(cx - diameter / 2))
+        innerEl.setAttribute('y', String(cy - diameter / 2))
+        innerEl.setAttribute('width', String(diameter))
+        innerEl.setAttribute('height', String(diameter))
+    }
 
-    // 标签:真 DOM(圆角底板 + 装饰边 + text-overflow + hover title 原生能力)
+    // 标签:真 DOM。pointer-events:none —— 不拦截事件,相机拖拽与 Canvas 拾取照常穿过。
+    // 限宽取计划层 `labelMaxWidth`(宽 × 2.6,billboard 同一单源)——浏览器走查修正:
+    // 首版硬编码 220px,比 billboard 截断宽 3 倍,两层标签并现时一长一短明显不一致。
     if (marker.name) {
         const label = document.createElement('div')
         label.className = LABEL_CLASS
@@ -102,11 +127,12 @@ function buildNode(key, marker, plan, toneHex, innerUrls) {
         label.title = marker.name // hover 读全(与 DOM 侧 EllipsisText 同语义)
         label.style.cssText = [
             'position:absolute',
-            // 标签底边在标记上方:-(标记高 + 2px 间距)
-            `bottom:${h + 2}px`,
+            `bottom:${h + pad + 3}px`,
             'left:0',
             'transform:translateX(-50%)',
-            'max-width:220px',
+            // content-box:max-width 只管文字,与 Cesium label 的截断宽度同语义
+            'box-sizing:content-box',
+            `max-width:${plan.labelMaxWidth}px`,
             'overflow:hidden',
             'white-space:nowrap',
             'text-overflow:ellipsis',
@@ -116,19 +142,21 @@ function buildNode(key, marker, plan, toneHex, innerUrls) {
             '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:auto',
+            'pointer-events:none',
         ].join(';')
         root.appendChild(label)
     }
 
-    return root
+    return { root, inner: innerEl, svg }
 }
 
 /**
  * 初始化 overlay 宿主(挂在地图容器内,z-index 在 canvas 之上、面板之下)。
+ * @param {Element} mapHost 地图宿主(.map-canvas)
+ * @param {object} [options] `{ onPick }` 显式点击触发器回调(窗口坐标 + 节点身份)
  * @returns {boolean} 是否初始化成功(失败时调用方回退 billboard 链路)
  */
-export function mountMarkerOverlay(mapHost) {
+export function mountMarkerOverlay(mapHost, { onPick } = {}) {
     try {
         if (!mapHost || typeof mapHost.appendChild !== 'function') return false
         if (hostNode && hostNode.isConnected) return true
@@ -141,6 +169,30 @@ export function mountMarkerOverlay(mapHost) {
             'overflow:hidden',
             'z-index:1',
         ].join(';')
+        // 挂载时尊重当前 A/B 开关(`?overlay=0` 会在挂载前就置 false)。
+        hostNode.style.display = overlayVisible ? '' : 'none'
+        // 显式点击触发器(票 47 维护者裁定:给事件触发器、点击照搬原功能函数):
+        // 命中标记节点 → 交给注入的 onPick(mapService 传 pickDeviceOrPointAt,与 Canvas 点选同一函数)。
+        hostNode.addEventListener('click', (event) => {
+            if (typeof onPick !== 'function') return
+            const node = event.target && event.target.closest ? event.target.closest(`.${NODE_CLASS}`) : null
+            if (!node) return
+            onPick({
+                x: event.clientX,
+                y: event.clientY,
+                kind: node.dataset.markerOverlayKind,
+                code: node.dataset.markerOverlayCode,
+            })
+        })
+        // 标记节点上的按下/抬起不再冒泡到宿主:宿主的 attachPointPick 会对同一次点击**再拾一遍**
+        // (双派发)。点选统一走上面的显式触发器。pointermove 不拦——hover 高亮与 tooltip 链路照常。
+        const stopOnMarker = (event) => {
+            if (event.target && event.target.closest && event.target.closest(`.${NODE_CLASS}`)) {
+                event.stopPropagation()
+            }
+        }
+        hostNode.addEventListener('pointerdown', stopOnMarker)
+        hostNode.addEventListener('pointerup', stopOnMarker)
         mapHost.appendChild(hostNode)
         hostRecord = { host: mapHost }
         return true
@@ -154,40 +206,135 @@ export function isMarkerOverlayMounted() {
     return !!(hostNode && hostNode.isConnected)
 }
 
+/**
+ * 临时 A/B 开关(票 47 走查用,维护者裁定「等功能完全实现后再替换 billboard」期间的对照工具):
+ * `false` 隐藏 DOM overlay 层、露出底下 billboard(= 之前版本);`true` 回到当前版本。
+ * 只切显示、不切数据链路——两层同位同素材,同一视角下互换即对照。
+ * 入口:DEV 把手 `__cockpitMap.setMarkerOverlay(bool)`;初始态 `?overlay=0`(mapService 读)。
+ */
+export function setMarkerOverlayVisible(visible) {
+    overlayVisible = visible !== false
+    if (hostNode) hostNode.style.display = overlayVisible ? '' : 'none'
+    return overlayVisible
+}
+
+/** A/B 开关当前读数(走查断言用)。 */
+export function isMarkerOverlayVisible() {
+    return overlayVisible
+}
+
 /**
  * 每帧同步(由 mapService 的 postRender 驱动):
- * 投影 + transform 更新 + 屏外隐藏 + 池复用。**不重建节点**。
+ * 投影 + transform 更新 + 屏外隐藏 + 池复用。**不重建节点**(指纹变化才重建)。
  * @param {object} params
- * @param {Viewer} params.viewer Cesium viewer(只取 scene 投影 API)
  * @param {Array} params.markers `planPointMarkers().markers`(含 industry/tone/plan 消费字段)
+ * @param {Array} [params.devices] 关联设备行(`projectDeviceMarkers()` 产出:屏幕坐标直供,
+ *   scale/alpha 已含选中/hover/弱化反应量——DOM 镜像 billboard 实体真值,防「小一圈」)
+ * @param {Map} [params.pointScales] `kind|code` → billboard 当前 scale(强调 1.55 等),DOM 同步镜像
  * @param {object} params.tones token 色表(tone 键 → CSS 颜色)
  * @param {Function} params.planOf marker → plan(markerPlan.planMarker 的包装)
- * @param {object} params.innerUrls 内层图标语义名 → data URL(manifest 解析产物)
+ * @param {Function} params.projectToWindow (lon, lat) → 宿主相对窗口坐标(贴地口径由调用方保证)
  */
-export function syncMarkerOverlay({ markers, tones, planOf, innerUrls, projectToWindow }) {
+export function syncMarkerOverlay({ markers, devices, pointScales, tones, planOf, projectToWindow }) {
     if (!hostNode || !hostNode.isConnected || typeof projectToWindow !== 'function') return
     const wanted = new Set()
+    // 设备行坐标是**窗口坐标**(引擎投影真值):转宿主相对要减宿主(overlay 挂载父级)的
+    // 窗口原点。每帧读一次——宿主可能随布局移动,不能在挂载时缓存。
+    const hostRect = hostNode.parentElement && hostNode.parentElement.getBoundingClientRect
+        ? hostNode.parentElement.getBoundingClientRect()
+        : { left: 0, top: 0 }
+    const hostOffset = { x: hostRect.left, y: hostRect.top }
 
     for (const marker of markers || []) {
         const key = `${marker.kind}|${marker.code}`
         wanted.add(key)
         if (!Number.isFinite(marker.longitude) || !Number.isFinite(marker.latitude)) continue
 
-        // 经纬度 → 窗口坐标:投影函数由调用方注入(cesiumEngine 的 SceneTransforms 包装,
-        // overlay 不直接 import 'cesium'——与 mapService 的层次纪律一致)。
         const windowPos = projectToWindow(marker.longitude, marker.latitude)
         if (!windowPos || !Number.isFinite(windowPos.x) || !Number.isFinite(windowPos.y)) continue
 
-        let node = pool.get(key)
-        if (!node) {
-            const plan = planOf(marker)
-            const toneHex = tones[marker.tone] || tones.neutral
-            node = buildNode(key, marker, plan, toneHex, innerUrls)
-            pool.set(key, { root: node })
-            hostNode.appendChild(node)
+        const plan = planOf(marker)
+        const toneHex = tones[marker.tone] || tones.neutral
+        // 计划读数指纹:状态/行业/名称/母版/内层/降饱和/色值任一变化就重建节点。
+        const fp = JSON.stringify([
+            marker.status, marker.industry, marker.name,
+            plan.master, plan.inner.enabled, plan.inner.icon, plan.inner.diameterRatio,
+            plan.desaturate, toneHex,
+        ])
+
+        let record = pool.get(key)
+        if (!record || record.fp !== fp) {
+            if (record && record.root.parentNode) record.root.parentNode.removeChild(record.root)
+            record = { ...buildNode(key, marker, plan, toneHex), fp }
+            pool.set(key, record)
+            hostNode.appendChild(record.root)
+            if (plan.inner.enabled) {
+                // 内层素材:与 billboard 同一去色重染规则的原生分辨率 data URL(缓存复用)。
+                // 竞态防护:加载回来时池记录可能已被更新的状态重建——只在仍是本节点时写入。
+                const mine = record
+                renderInnerIconUrl(plan.inner.icon)
+                    .then((dataUrl) => {
+                        if (pool.get(key) === mine) mine.inner.setAttribute('href', dataUrl)
+                    })
+                    .catch(() => {
+                        // 内层素材失败:只有母版水滴(降级可见),不阻塞其余标记。
+                    })
+            }
         }
-        const record = pool.get(key)
         record.root.style.transform = `translate(${windowPos.x}px, ${windowPos.y}px)`
+        // 强调/还原的缩放镜像:billboard.scale 被 highlightEntities(1.55)等改写时,
+        // DOM 围绕尖端缩放同一个量(transformOrigin=尖端,见 buildNode)——两层不分离。
+        const markerScale = pointScales instanceof Map ? pointScales.get(key) : undefined
+        record.svg.style.transform = markerScale && markerScale !== 1 ? `scale(${markerScale})` : ''
+        record.root.style.display = 'block'
+    }
+
+    // 关联设备(票 47 维护者反馈②根因:设备是独立 billboard 图层,此前 DOM 只渲染
+    // 摘要标记计划、关联设备根本没进 DOM)。行由引擎每帧投影产出(屏幕坐标真值 +
+    // 反应量真值),形态走与摘要未关联设备同一套合成计划(实心母版 + 内层行业图标)。
+    for (const device of devices || []) {
+        const key = device.key || `device|${device.code}`
+        wanted.add(key)
+        if (!Number.isFinite(device.x) || !Number.isFinite(device.y)) continue
+
+        // billboard 图层里个体设备**没有 label**(只有聚合符号有)——DOM 侧同口径不渲染
+        // 名字标签(name 置空即不建 label 节点),否则两层并现时 DOM 多出一排设备名牌。
+        const marker = {
+            kind: 'associated-device',
+            code: device.code,
+            name: '',
+            status: device.status || '',
+            industry: device.industry || '',
+        }
+        const plan = planOf(marker)
+        const toneHex = tones[device.tone] || tones.neutral
+        const fp = JSON.stringify([
+            marker.status, marker.industry,
+            plan.master, plan.inner.enabled, plan.inner.icon, plan.inner.diameterRatio,
+            plan.desaturate, toneHex,
+        ])
+
+        let record = pool.get(key)
+        if (!record || record.fp !== fp) {
+            if (record && record.root.parentNode) record.root.parentNode.removeChild(record.root)
+            record = { ...buildNode(key, marker, plan, toneHex), fp }
+            pool.set(key, record)
+            hostNode.appendChild(record.root)
+            if (plan.inner.enabled) {
+                const mine = record
+                renderInnerIconUrl(plan.inner.icon)
+                    .then((dataUrl) => {
+                        if (pool.get(key) === mine) mine.inner.setAttribute('href', dataUrl)
+                    })
+                    .catch(() => {})
+            }
+        }
+        // 坐标 = 引擎投影的窗口坐标(含 pixelOffset 尖端补偿),只差宿主原点一层。
+        record.root.style.transform = `translate(${device.x - hostOffset.x}px, ${device.y - hostOffset.y}px)`
+        // 反应量镜像:scale 读的是 billboard 实体真值(选中 1.6/hover 1.3/弱化 0.75),
+        // 弱化的压暗同理镜像 alpha(只写透明度,不动色相——色键语义归设备状态)。
+        record.svg.style.transform = device.scale && device.scale !== 1 ? `scale(${device.scale})` : ''
+        record.root.style.opacity = device.alpha !== undefined && device.alpha !== 1 ? String(device.alpha) : ''
         record.root.style.display = 'block'
     }