Эх сурвалжийг харах

票47:中心图标常亮、设备详情随设备换锚并同步主场景五态样例

Kazerin 1 долоо хоног өмнө
parent
commit
09ef651bcf

+ 6 - 0
.scratch/cockpit-point-locate-and-device-link/issues/12-panel-anchor-following-spec.md

@@ -18,6 +18,12 @@ Blocked by: 04
 
 ## Solution
 
+### 维护者复验修订:详情按当前对象换锚(优先于旧条款)
+
+维护者明确取代“两级详情均指向监测点”:点位摘要跟随监测点;点击设备点或卡片进入设备详情后,面板及连接短线跟随所选设备视觉标记。设备 A→B 同步换锚,返回摘要恢复监测点锚点;设备的业务归属 pointCode 不变。
+
+锚点包含贴地高度与展开环 pixelOffset;短线接当前尺寸的图标外缘并保留 12 px 间隙。无有效坐标的设备显示注明自身原因的固定详情,不借父监测点坐标;可上图设备标记暂失时暂隐并保留选择。关闭不回退、已有侧栏层级与裁切规则保持。下方旧条款保留为历史,冲突以本节为准。上游决策票 04 的 deck 写入被文件权限拒绝,尚未同步;本节记录本次用户直接裁定。
+
 > ### ⚠ 2026-09-24 修订(跟随决策票 04 的原位修订)
 >
 > 维护者在票 08 走查后改变了遮挡规范。以下三条**取代**下方 `Implementation Decisions` 中关于避让与暂隐的条款(原文保留以追溯,凡冲突以本节为准):

+ 6 - 0
.scratch/cockpit-point-locate-and-device-link/issues/15-panel-device-correspondence-spec.md

@@ -20,6 +20,12 @@ Category: spec
 
 ## Solution
 
+### 维护者复验修订(优先于旧锚定与中心弱化条款)
+
+点位摘要跟随监测点;进入设备详情后面板及其屏幕连接短线跟随所选设备,A→B 换锚,返回摘要恢复监测点锚。设备仍属于原监测点,空间锚点变化不改变业务归属。设备—监测点的场景归属线与面板连接短线是不同指示,前者保留既有语义。
+
+中心设备图标始终不透明,不随状态特效闪烁、渐隐或旋转,不缩小到正常尺寸以下;选中/hover 的放大保留,外围光环承担状态动画。因此旧“其余中心设备压暗 + 缩小”条款不再用于地图中心符号。无位置设备按自身原因显示固定详情,不借父点坐标;展开偏移计入设备视觉锚点。此修订来自用户直接反馈,旧条款保留为历史。
+
 从值班人员视角:面板 hover 一张设备卡片,三维里对应设备点轻放大;三维里 hover 一个设备点,面板里对应卡片高亮。点击选中一台设备时,三维点显示「它是被选中的那台」——外环 + 放大 + 引线(引线连到该监测点标记中心,即面板锚点);其余设备(≥ 3 台时)压暗并缩小退后。首次进入设备级时镜头飞到设备级预设视角看清这台设备;在同一点位内切换设备只换中心不重飞;返回点位级时回到点位级视角。三维点设备点会反选面板卡片并进入设备详情第二级;「返回点位」把三维还原为监测点选中态。三维里的设备点被拾取使用独立身份,不冒充监测点(票 05),且设备点优先于监测点被拾到。走查能通过宿主只读字段读到「当前选中哪台设备、面板停在哪个层级、当前 hover 哪台」,而不是只看图。
 
 ## User Stories

+ 2 - 0
.scratch/cockpit-point-locate-and-device-link/map.md

@@ -56,6 +56,8 @@ Labels: wayfinder:map
 
 ## Decisions so far
 
+- [面板锚定规格:维护者复验修订](issues/12-panel-anchor-following-spec.md) / [设备对应规格:复验修订](issues/15-panel-device-correspondence-spec.md) — 点位摘要锚向监测点,设备详情及连接短线锚向所选设备;中心图标保持清晰,状态动画移至外围。旧索引中的“锚点仍跟随监测点”已被本次直接裁定取代;上游票 04 的 deck 写入权限拒绝,待同步。
+
 <!-- 本地图已关闭的决策票索引;一条决策只活在票里。 -->
 
 - [01: 调研 —— 点位下设备的位置数据来源与三维呈现原语](issues/01-research-device-position-data-and-rendering-primitives.md) — 产物 [`research/device-position-data-and-rendering-primitives.md`](research/device-position-data-and-rendering-primitives.md)。**一次拿不到设备坐标,且缺口在数据模型层而非响应包装**:坐标只挂在 `SourceSmartDevice` 上,按点位归集时被换成无坐标的 `CockpitMonitoringPointDeviceSnapshot`(改动点 `…DeviceSnapshot` → `…DeviceCard` → `StatusResolver.cards()`;**端点不必新增**);「#59 已带坐标而前端漏读」**排除**(#59 给的是编码清单)。`deviceCodes` 可直接用且**不被分页污染**(只在 `map-snapshot` 回传、含离线设备、空值 `[]`、源顺序未排序、前端目前 0 引用)。设备与点位**各存独立经纬度**,关联表只有 `equipment_id/point_id`,后端**无任何相对位置信息**(同井/同杆/高度/朝向 grep 0 命中)也**不保证**两点相等 → 「同坐标重叠」是必须预先处置的可能形态(实际重叠比例未知,已毕业为 [11](issues/11-overlapping-device-points.md))。呈现原语:三者 GPU 侧都批渲染、都能逐元素拾取,但 `scene.pick` **只回最上层一个**(重叠必须 `drillPick`),且**只有 `PointPrimitive` 不支持 `heightReference`**(Entity 的 `point` 贴地是引擎内部拿无图 billboard 顶替)。仓库**无设备级 glTF/GLB**、`SHAPES` 8 键**无任何设备形状**、驾驶舱图标是内联 SVG data-URL。

+ 19 - 0
.scratch/cockpit-shell-panel-layout/issues/47-device-status-effects.md

@@ -22,6 +22,25 @@ Labels: ready-for-agent
 
 > 唯一未勾的一项=**人工目视裁定**(本票第 7 条的"人工走查"半)。自动证据已齐,见下方进度。
 
+## 最终自检与主场景同步(当前结论优先于下方历史记录)
+
+维护者确认“基本符合我的要求”,授权再次自检、同步主场景、提交推送后关闭本票,人工确认前置条件已满足。当前先完成代码提交与推送,成功后将本票改为 resolved。
+
+- 默认 `success` 主场景共用中心图标清晰度、设备详情换锚及外围光环代码,无需 URL 场景参数。MP-001 保持 K=2;MP-002 保持 K=11,设备坐标修至太常点附近,五态分布为 3 报警 / 3 预警 / 1 工单 / 3 正常 / 1 离线,前三种状态播外围动效。
+- 新增主场景契约检查 `t47-main-scene.mjs`:真实 mock 传输→地图/详情适配→聚合/动效/锚点计划,验证编码一致、设备自身有效坐标、K2/K11与五态动效。
+- `t47-readability-anchor.mjs` 19 项通过,涵盖中心 alpha=1、外围闪烁、展开偏移、图层撤下、关特效停循环、设备换锚、返回点位、无位置与待确认退化。
+- 相关 11 组脱浏览器检查全部通过(t05 283/283、t09 38/38、t41 46/46、t42 39/39、t43 40/40、t44 47/47、t45 18/18、t46 29/29、t47 27/27及两个新增脚本)。Vue SFC 解析和修改文件语法检查通过。
+- 扩大检查共 39 组,35 组通过,4 组失败保留:t06 素材 JSON 导入要求 import attribute;t11 设备卡片局部 ref 撞旧守卫;t15 WebSocket 刷新断言;t28 Puppeteer 依赖缺失。本票提交不包含并行素材/3D Tiles/其它接口在途变更。
+- 本轮按 AGENTS.md 跳过 build/dev、浏览器调试和截图;人工验收依据维护者本次确认,历史浏览器证据不是本次复跑结果。上游决策票 04 的 deck 写入权限拒绝仍待同步,当前规格 12/15、地图索引及 CONTEXT 已记用户直接裁定,不阻塞本票按授权收口。
+
+## 本次复验反馈修复(历史记录)
+
+维护者指出中心图标闪烁太暗、设备间距较大时设备详情仍留在父点并遮住所选设备。本次已将中心图标改为不透明、稳定形状且不缩小至正常尺寸以下;闪烁/扩张/旋转仅写外围光环。光环跟随设备显示与 pixelOffset;按需渲染时动态设备主动请求帧。
+
+设备详情及面板连接短线改为锚向所选设备(设备 A→B 换锚,返回摘要恢复监测点)。锚点包含贴地高度、展开偏移和当前图标尺寸;无位置设备显示自身原因,不借父点坐标。维护者直接裁定已记录在决策规格 12/15、决策地图与 CONTEXT.md;上游决策票 04 的 deck 写入被文件权限拒绝,尚待同步。
+
+验证:新增 `verify/t47-readability-anchor.mjs` 覆盖生产动画写帧、锚点身份、设备位置退化与实际投影;相邻特效/锚定/联动/重叠/模式脱浏览器闸门通过。本次依据 AGENTS.md **未执行 build/dev、浏览器调试或截图**;已更新真实走查脚本,尚未复跑,因此下方历史跨帧结果不能当作本次画面验收。票保持 claimed,待人工复验与权限允许后的决策票同步。
+
 ## 进度:实施完成,**待人工确认**(2026-09-30)
 
 **未关闭**:本票验收第 7 条明写「真实帧差与人工走查分别记录结果,**未获人工确认不关闭本票**」。

+ 6 - 3
.scratch/cockpit-shell-panel-layout/verify/t41-point-locate-anchor.mjs

@@ -255,7 +255,8 @@ const CANVAS = { left: 0, top: 0, right: 1600, bottom: 900 }
     //
     // 守卫要**正面**要求回退分支存在,不能只否定一个已删掉的参数名
     // (变异 M15 实测:`!/terrainReady\) return null/` 对删参后的实现恒真、断言没牙)。
-    const projectBody = (/export function projectPointMarker[\s\S]*?\n\}/.exec(engine) || [''])[0]
+    const projectEntry = (/export function projectPointMarker[\s\S]*?\n\}/.exec(engine) || [''])[0]
+    const projectBody = (/function projectMarkerEntity[\s\S]*?\n\}/.exec(engine) || [''])[0] || projectEntry
     check('E4b-project-falls-back-to-ellipsoid',
         /if \(Number\.isFinite\(sampled\)\) height = sampled/.test(projectBody)
         && /Cartesian3\.fromRadians\(carto\.longitude, carto\.latitude, height\)/.test(projectBody)
@@ -293,8 +294,10 @@ const CANVAS = { left: 0, top: 0, right: 1600, bottom: 900 }
     // 暂隐时不转固定卡:固定详情只由**后端判定**触发,且判据取自 mapStore 的只读结论
     // (票 07 的 I3:界面不许自己调 isRenderable / blockReason 判坐标)。
     check('E7-fixed-only-when-blocked',
-        /mapStore\.locationVerdict\(/.test(shell) && !/\b(isRenderable|blockReason)\s*\(/.test(shell),
-        '外壳读 mapStore.locationVerdict() 的结论,自己不**调用**坐标判据(未确认≠无地图位置)')
+        /detailAnchorLocation\(target\)/.test(shell)
+        && /locationVerdict\(target\.object/.test(read('domain/detailAnchor.js'))
+        && !/\b(isRenderable|blockReason)\s*\(/.test(shell),
+        '外壳消费当前锚定对象的领域坐标准入结论;设备无位置不借父点坐标,未确认不冒充无位置')
     // 暂隐时的「重新定位」也必须走统一入口(别处改路径不会让这里悄悄失效)。
     check('E7c-relocate-uses-shared-entry',
         /function relocateSelected[\s\S]*?\n\}/.test(shell)

+ 37 - 18
.scratch/cockpit-shell-panel-layout/verify/t47-device-effects-walkthrough.mjs

@@ -96,15 +96,6 @@ for (let round = 0; round < 4; round += 1) {
     }
     await sleep(300)
 }
-// 遮罩让真实鼠标能命中画布(既有锚定浮层会盖住画布;仅测试态)。
-await page.evaluate(() => {
-    const detail = document.querySelector('.cockpit-shell__detail')
-    if (detail) {
-        detail.style.pointerEvents = 'none'
-        detail.querySelectorAll('*').forEach((child) => { child.style.pointerEvents = 'none' })
-    }
-})
-
 const opened = await readDetail()
 check('E1-controlled-sample-opens',
     opened.level === 'point' && opened.pointCode === 'MP-001',
@@ -167,15 +158,19 @@ check('E2-effects-enabled-and-running',
         ? `降级场景:enabled=${frameA.enabled} host=${frameA.host.effects} 动效台数=${frameA.host.animated} 异常台数=${frameA.host.abnormal}`
         : `enabled=${frameA.enabled} running=${frameA.running} host=${frameA.host.effects} 动效台数=${frameA.host.animated} 异常台数=${frameA.host.abnormal}(tick ${frameA.tick}→${frameB.tick})`)
 if (SCENARIO === 'device-effects') {
-    check('E3-alarm-pulses-across-frames',
-    distinct('EQ-EF-ALARM', 'scale') >= 2 || distinct('EQ-EF-ALARM', 'alpha') >= 2,
-    `报警脉冲:scale 取值数 ${distinct('EQ-EF-ALARM', 'scale')}、alpha 取值数 ${distinct('EQ-EF-ALARM', 'alpha')}`)
-check('E4-warning-blinks-across-frames',
-    distinct('EQ-EF-WARNING', 'alpha') >= 2,
-    `预警闪烁:alpha 取值数 ${distinct('EQ-EF-WARNING', 'alpha')}(取值 ${valuesOf('EQ-EF-WARNING', 'alpha').join(',')})`)
-check('E5-work-order-spins-across-frames',
-    distinct('EQ-EF-WORK', 'rotation') >= 2,
-    `工单旋转:rotation 取值数 ${distinct('EQ-EF-WORK', 'rotation')}`)
+    const fxSeries = (code, key) => SAMPLES.map((frame) =>
+        (frame.fx || []).find((item) => item.for === code && item.kind === 'ring' && item.layer === 0)?.[key])
+    const fxDistinct = (code, key) => new Set(fxSeries(code, key).filter(Number.isFinite).map((value) => value.toFixed(5))).size
+    check('E3-alarm-pulses-across-frames', fxDistinct('EQ-EF-ALARM', 'scale') >= 2,
+        '报警外围环跨帧扩张,中心符号保持清晰')
+    check('E4-warning-blinks-across-frames', fxDistinct('EQ-EF-WARNING', 'alpha') >= 2,
+        '预警外围环闪烁,中心不参与透明度动画')
+    check('E5-work-order-spins-across-frames', fxDistinct('EQ-EF-WORK', 'rotation') >= 2,
+        '工单外围环旋转,中心状态形状不转动')
+    check('E17-center-icons-remain-opaque',
+        ['EQ-EF-ALARM', 'EQ-EF-WARNING', 'EQ-EF-WORK'].every((code) =>
+            valuesOf(code, 'alpha').length === SAMPLES.length && valuesOf(code, 'alpha').every((value) => value === 1)),
+        '三台动态状态设备的中心图标在全部采样帧中保持 alpha=1')
 check('E6-normal-stays-static',
     distinct('EQ-EF-NORMAL', 'scale') === 1 && distinct('EQ-EF-NORMAL', 'alpha') === 1 && distinct('EQ-EF-NORMAL', 'rotation') === 1,
     `正常设备**全程静止**:scale ${normalA && normalA.scale}、alpha ${normalA && normalA.alpha}、rotation ${normalA && normalA.rotation}`)
@@ -239,6 +234,30 @@ if (SCENARIO === 'device-effects-degraded') {
         `降级后**仍画 31 台静态异常标记**(不静默删点):实际 ${frameA.devices.length} 台`)
 }
 
+// 不改变面板 pointer-events,以真实卡片点击核验设备详情换锚。
+if (SCENARIO === 'device-effects') {
+    const panelState = () => page.evaluate(() => {
+        const panel = document.querySelector('.cockpit-shell__detail')
+        const box = panel?.getBoundingClientRect()
+        return { code: panel?.dataset.anchorCode, state: panel?.dataset.anchorState,
+            lead: Boolean(document.querySelector('[data-anchor-lead]')),
+            box: box ? { left: box.left, right: box.right, top: box.top, bottom: box.bottom } : null }
+    })
+    for (const code of ['EQ-EF-ALARM', 'EQ-EF-WARNING']) {
+        await page.click(`[data-device-layer-code="${code}"]`)
+        await page.waitForFunction((wanted) => document.querySelector('.cockpit-shell__detail')?.dataset.anchorCode === wanted,
+            { timeout: 10000 }, code)
+        await sleep(300)
+        const state = await panelState()
+        check(`E18-device-anchor-${code}`, state.code === code && state.state === 'visible' && state.lead,
+            `设备详情锚点=${state.code}、面板=${state.state}、连接线=${state.lead}`)
+        await page.click('[data-detail-back]')
+        await page.waitForFunction(() => document.querySelector('.cockpit-shell__detail')?.dataset.anchorCode === 'MP-001',
+            { timeout: 10000 })
+        check(`E19-return-anchor-${code}`, (await panelState()).code === 'MP-001', '返回摘要恢复监测点锚点')
+    }
+}
+
 check('E9-no-page-errors', errors.length === 0, errors.length ? errors.slice(0, 3).join(' | ') : '无 pageerror / console.error')
 
 await page.close()

+ 44 - 0
.scratch/cockpit-shell-panel-layout/verify/t47-main-scene.mjs

@@ -0,0 +1,44 @@
+import assert from 'node:assert/strict'
+import { registerCockpitMocks } from '../../../src/modules/cockpit/mocks/contractMocks.js'
+import { registerMockTransport, getMockTransportPaths, resetMockTransportsForTest } from '../../../src/modules/cockpit/services/cockpitApi.js'
+import { fetchMapSnapshot } from '../../../src/modules/cockpit/services/mapApi.js'
+import { fetchMonitoringPointDetail } from '../../../src/modules/cockpit/services/detailApi.js'
+import { groupOverlappingDevices } from '../../../src/modules/cockpit/domain/overlapDevices.js'
+import { planDeviceEffects } from '../../../src/modules/cockpit/domain/deviceEffects.js'
+import { resolveDetailAnchor } from '../../../src/modules/cockpit/domain/detailAnchor.js'
+
+const config = { dataSource: 'mock', api: { baseUrl: '', token: '' } }
+registerCockpitMocks({ scenario: 'success', handlers: { registerMockTransport, getMockTransportPaths, resetMockTransportsForTest } })
+const snapshot = await fetchMapSnapshot({ page: 1, pageSize: 100, config })
+assert.equal(snapshot.state, 'success')
+const objects = snapshot.data.points
+for (const [code, k] of [['MP-001', 2], ['MP-002', 11]]) {
+    const section = await fetchMonitoringPointDetail(code, { config })
+    assert.equal(section.state, 'success')
+    const point = objects.find((item) => item.code === code)
+    assert.ok(point)
+    const devices = section.data.devices.map((device) => ({ ...device, pointCode: code, placement: 'drawn' }))
+    assert.equal(devices.length, k)
+    assert.deepEqual(devices.map((item) => item.code), point.deviceCodes)
+    assert.ok(devices.every((item) => item.coordinate.renderable === true))
+    const groups = groupOverlappingDevices(devices, { maxFan: 10 })
+    assert.equal(groups.length, 1)
+    assert.equal(groups[0].k, k)
+    assert.equal(groups[0].degraded, k > 10)
+    const effects = planDeviceEffects({ devices, enabled: true })
+    assert.ok(effects.animated.length > 0)
+    assert.equal(effects.degraded, false)
+    for (const device of devices) {
+        assert.ok(Math.abs(device.longitude - point.longitude) < 0.002)
+        assert.ok(Math.abs(device.latitude - point.latitude) < 0.002)
+        const anchor = resolveDetailAnchor({ selection: { level: 'equipment', pointCode: code, deviceCode: device.code }, objects, devices })
+        assert.equal(anchor.code, device.code)
+        assert.equal(anchor.object, device)
+    }
+    if (code === 'MP-002') {
+        assert.deepEqual([...new Set(devices.map((item) => item.status))].sort(), ['ALARM', 'WARNING', 'WORK_ORDER', 'NORMAL', 'OFFLINE'].sort())
+        assert.deepEqual([...new Set(effects.animated.map((item) => item.effect))].sort(), ['pulse', 'blink', 'spin'].sort())
+    }
+    console.log(`PASS default main scene ${code}: K=${k}, matching snapshot/detail, local coordinates, device anchors and effects`)
+}
+resetMockTransportsForTest()

+ 138 - 0
.scratch/cockpit-shell-panel-layout/verify/t47-readability-anchor.mjs

@@ -0,0 +1,138 @@
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+import vm from 'node:vm'
+import { resolveDetailAnchor, detailAnchorLocation } from '../../../src/modules/cockpit/domain/detailAnchor.js'
+import { solvePointAnchor } from '../../../src/modules/cockpit/domain/pointAnchor.js'
+
+const root = new URL('../../../src/modules/cockpit/', import.meta.url)
+const read = (path) => readFileSync(new URL(path, root), 'utf8')
+const engine = read('services/cesiumEngine.js')
+const fn = (name) => {
+    const match = engine.match(new RegExp(`(?:export )?function ${name}\\([^]*?\\n\\}`))
+    assert.ok(match, `${name} exists`)
+    return match[0].replace(/^export /, '')
+}
+const property = (value) => ({ getValue: () => value })
+const core = {
+    id: 'device|MP-001|EQ-A', show: true,
+    properties: { deviceCode: property('EQ-A') },
+    billboard: { scale: 1, color: { alpha: 1 }, rotation: 0 },
+}
+core.billboard.pixelOffset = property({ x: 42, y: -26 })
+const halo = {
+    id: 'fx|MP-001|EQ-A|ring0', show: true,
+    properties: { effectFx: property(true), effectFxFor: property('EQ-A'), effectFxStatus: property('WARNING'), effectFxKind: property('ring'), effectFxLayer: property(0) },
+    billboard: { scale: 1, color: { alpha: 1 }, rotation: 0 },
+}
+const source = { entities: { values: [core, halo] } }
+let renderRequests = 0
+const viewer = { scene: { requestRender() { renderRequests += 1 } } }
+const context = vm.createContext({
+    deviceEffectState: { viewer, plan: { animated: [{ code: 'EQ-A', effect: 'blink' }] }, selectedCode: '', tick: 0 },
+    deviceSources: new Map([[viewer, source]]),
+    baseStyleOf: () => ({ scale: 0.75, alpha: 0.45 }),
+    DEVICE_EFFECT: { SPIN: 'spin', BLINK: 'blink' },
+    Color: { WHITE: { withAlpha: (alpha) => ({ alpha }) } },
+    blinkStrengthAt: () => 0.25, pulseStrengthAt: () => 0,
+    spinAngleAt: () => 1, glowStrengthAt: () => 0, glowScaleAt: () => 1,
+    ringFrameAt: () => ({ rings: [{ scale: 2, alpha: 0.8 }] }), Date,
+    Cartesian2: class { constructor(x, y) { this.x = x; this.y = y } },
+})
+vm.runInContext(`${fn('animatedByCode')}\n${fn('writeEffectFrame')}\nwriteEffectFrame()`, context)
+let failures = 0
+const check = (name, action) => {
+    try { action(); console.log(`PASS ${name}`) }
+    catch (error) { failures += 1; console.error(`FAIL ${name}: ${String(error.message).split('\n')[0]}`) }
+}
+check('center icon remains opaque at the dimmest blink phase', () => assert.equal(core.billboard.color.alpha, 1))
+check('center icon never shrinks below its normal size', () => assert.ok(core.billboard.scale >= 1))
+check('blink applies to the outer halo while center stays opaque', () => assert.equal(halo.billboard.color.alpha, 0.2))
+check('halo follows the expanded device pixelOffset', () => assert.equal(halo.billboard.pixelOffset, core.billboard.pixelOffset))
+check('active effects request a real render', () => assert.ok(renderRequests > 0))
+check('halo disappears with its device', () => {
+    core.show = false
+    vm.runInContext('writeEffectFrame()', context)
+    assert.equal(halo.show, false)
+    core.show = true
+})
+const objects = [{ kind: 'monitoring-point', code: 'MP-001', coordinate: { renderable: true } }]
+const devices = [
+    { code: 'EQ-A', pointCode: 'MP-001', coordinate: { renderable: true } },
+    { code: 'EQ-B', pointCode: 'MP-001', coordinate: { renderable: true } },
+    { code: 'EQ-X', pointCode: 'MP-001', coordinate: { renderable: false, validated: true, reason: 'UNVERIFIED_COORDINATE' } },
+]
+const target = (level, deviceCode = '') => resolveDetailAnchor({ selection: { level, pointCode: 'MP-001', deviceCode }, objects, devices })
+check('equipment A anchors to A while keeping point ownership', () => {
+    assert.equal(target('equipment', 'EQ-A').code, 'EQ-A')
+    assert.equal(target('equipment', 'EQ-A').pointCode, 'MP-001')
+    assert.equal(target('equipment', 'EQ-A').kind, 'monitoring-device')
+})
+check('equipment A to B changes anchor identity', () => assert.equal(target('equipment', 'EQ-B').code, 'EQ-B'))
+check('back to point summary restores point anchor', () => assert.equal(target('point', 'EQ-B').code, 'MP-001'))
+check('no-location equipment uses its own verdict', () => assert.equal(detailAnchorLocation(target('equipment', 'EQ-X')).reasonText, '坐标未经校验'))
+check('missing equipment does not borrow its parent location', () => assert.equal(detailAnchorLocation(target('equipment', 'EQ-missing')).present, false))
+check('unassociated equipment anchors to itself', () => {
+    const standalone = { kind: 'unassociated-equipment', code: 'EQ-U' }
+    assert.equal(resolveDetailAnchor({ selection: { level: 'equipment', deviceCode: 'EQ-U' }, objects: [standalone] }).object, standalone)
+})
+check('closed detail has no anchor', () => assert.equal(target('none', 'EQ-A'), null))
+check('shell consumes anchor policy and includes line movement in its update key', () => {
+    assert.ok(/resolveDetailAnchor\(/.test(read('CockpitShell.vue')))
+    assert.ok(/JSON.stringify\(layout.line\)/.test(read('CockpitShell.vue')))
+})
+
+// Execute the production projection with a small Cesium public-API double.
+const entity = {
+    isShowing: true, show: true, position: property({ longitude: 1, latitude: 2 }),
+    properties: { deviceCode: property('EQ-A'), monitoringPointCode: property('MP-001') },
+    billboard: { pixelOffset: property({ x: 42, y: -26 }), scale: property(1.6), width: property(14), height: property(14) },
+}
+const projectionViewer = {
+    clock: { currentTime: 0 }, canvas: { getBoundingClientRect: () => ({ left: 12, top: 102 }) },
+    scene: { globe: { ellipsoid: { cartesianToCartographic: (p) => p }, getHeight: () => 123 } },
+    camera: { positionWC: {}, directionWC: {} },
+}
+function Cartesian3() {}
+Cartesian3.fromRadians = (longitude, latitude, height) => ({ longitude, latitude, height })
+Cartesian3.subtract = () => ({})
+Cartesian3.dot = () => 1
+const projectionContext = vm.createContext({
+    Cartesian3,
+    EllipsoidalOccluder: class { isPointVisible() { return true } },
+    SceneTransforms: { worldToWindowCoordinates: (_scene, position) => { assert.equal(position.height, 123); return { x: 500, y: 320 } } },
+    deviceSources: new Map([[projectionViewer, { show: true, entities: { values: [entity] } }]]),
+    viewer: projectionViewer,
+})
+vm.runInContext(`${fn('findDeviceMarker')}\n${fn('projectMarkerEntity')}\n${fn('projectDeviceMarker')}`, projectionContext)
+check('device anchor includes terrain, fan pixelOffset and selected icon size', () => {
+    const at = vm.runInContext("projectDeviceMarker(viewer, 'EQ-A', 'MP-001')", projectionContext)
+    assert.equal(at.x, 554)
+    assert.equal(at.y, 396)
+    assert.equal(at.iconSize, 22.400000000000002)
+    const layout = solvePointAnchor({ anchor: at, canvas: { left: 12, top: 102, right: 1650, bottom: 842 }, iconSize: at.iconSize })
+    assert.ok(layout.rect.left > at.x + at.iconSize / 2)
+    assert.equal(layout.line.from.x, at.x + at.iconSize / 2)
+})
+check('hidden fan device has no fake parent anchor', () => {
+    entity.show = false
+    assert.equal(vm.runInContext("projectDeviceMarker(viewer, 'EQ-A', 'MP-001')", projectionContext), null)
+})
+check('effects off cancels the loop without hiding the center icon', () => {
+    const cancelled = []
+    context.window = { cancelAnimationFrame: (id) => cancelled.push(id) }
+    context.clearRingEntities = () => {}
+    context.deviceEffectState.raf = 17
+    vm.runInContext(`${fn('applyDeviceEffects')}\napplyDeviceEffects(deviceEffectState.viewer, { enabled: false, animated: [] })`, context)
+    assert.deepEqual(cancelled, [17])
+    assert.equal(context.deviceEffectState.raf, 0)
+    assert.equal(core.billboard.color.alpha, 1)
+})
+check('wrong parent identity never resolves another device marker', () => {
+    entity.show = true
+    assert.equal(vm.runInContext("projectDeviceMarker(viewer, 'EQ-A', 'MP-other')", projectionContext), null)
+})
+check('pending device does not pretend to have a known location failure', () => {
+    assert.equal(detailAnchorLocation({ code: 'EQ-pending', object: { placement: 'pending' } }).present, false)
+})
+console.log(`T47 readability/anchor: ${failures ? 'FAILED' : 'passed'}`)
+process.exitCode = failures ? 1 : 0

+ 2 - 2
CONTEXT.md

@@ -133,5 +133,5 @@ _Avoid_: 归位(那是回「家」视角的另一动作)、只换中心(
 _Avoid_: 定位、回到上一个点位
 
 **面板锚点**:
-监测点详情面板在一张图中指向的所属监测点视觉标记中心;该标记暂时不可见时,详情可以暂隐,但对象归属仍保持。
-_Avoid_: 相机旋转中心、监测设备点位(指监测点详情归属时)
+当前详情面板在一张图中指向的业务对象视觉标记中心:点位摘要指向监测点,设备详情指向所选监测设备;返回摘要后恢复监测点锚点。标记暂时不可见时,详情可以暂隐,设备与监测点的业务归属仍保持。
+_Avoid_: 相机旋转中心、设备详情仍指向父监测点、将面板锚点变化当作设备归属变化

+ 20 - 23
src/modules/cockpit/CockpitShell.vue

@@ -425,7 +425,9 @@ import { MODES, columnsForMode, labelForPanel, modeLabel } from './domain/panelC
 import { panelView, hasPanelView } from './domain/panelViews.js'
 // 票 20:概览的联动映射(`link` 键 → 目标模式);外壳不自己发明这张表。
 import { OVERVIEW_LINK_MODES } from './domain/overviewPanels.js'
-import { LOCATE_RESULT, findMapObject } from './domain/mapLocate.js'
+import { LOCATE_RESULT } from './domain/mapLocate.js'
+import { resolveDetailAnchor, detailAnchorLocation } from './domain/detailAnchor.js'
+import { DEVICE_LAYER_KIND } from './domain/deviceLayer.js'
 import { ANCHOR_HIDDEN, solvePointAnchor } from './domain/pointAnchor.js'
 import {
     overallFreshness,
@@ -838,18 +840,12 @@ const anchorOrigin = ref({ left: 0, top: 0 })
 /** 上一帧写下去的落位:与本次相同就**不再写 DOM**(规格:镜头稳定且布局未变时停止重算)。 */
 let lastAnchorKey = ''
 
-/** 当前详情对应的地图对象身份(两级详情都指向所属监测点;未关联设备锚向自身)。 */
-const detailTarget = computed(() => {
-    const current = selection.value
-    const objects = mapStore.summary.objects
-    if (current.pointCode) {
-        const point = findMapObject(objects.filter((item) => item.kind === MAP_OBJECT_KIND.MONITORING_POINT), current.pointCode)
-        return { kind: MAP_OBJECT_KIND.MONITORING_POINT, code: current.pointCode, object: point || null }
-    }
-    if (!current.deviceCode) return null
-    const device = objects.find((item) => item.kind === MAP_OBJECT_KIND.UNASSOCIATED_EQUIPMENT && item.code === current.deviceCode)
-    return { kind: MAP_OBJECT_KIND.UNASSOCIATED_EQUIPMENT, code: current.deviceCode, object: device || null }
-})
+/** 摘要锚向监测点;设备详情锚向所选设备;业务归属编码保持不变。 */
+const detailTarget = computed(() => resolveDetailAnchor({
+    selection: selection.value,
+    objects: mapStore.summary.objects,
+    devices: deviceRows.value,
+}))
 
 /** 详情卡上要显示的对象编码(顶栏暂隐入口读它)。 */
 const selectedDetailCode = computed(() => {
@@ -860,7 +856,7 @@ const selectedDetailCode = computed(() => {
 /**
  * 「无地图位置」的固定详情(票 04 的特例)。
  *
- * **判据取自 `mapStore.locationVerdict()`**(票 07 立的坐标准入唯一出处)——
+ * 判据由 `detailAnchorLocation()` 转交坐标准入领域函数,按当前点位或设备读取——
  * 外壳不自己调 `isRenderable` / `blockReason`:界面自己判坐标正是票 07 的 I3 守卫禁止的事。
  *
  * 三种情形分得开:对象**不在已加载快照里**(分页没翻到 / 引擎未就绪)**不算**无地图位置,
@@ -869,7 +865,7 @@ const selectedDetailCode = computed(() => {
 const fixedLocationReason = computed(() => {
     const target = detailTarget.value
     if (!target) return ''
-    const verdict = mapStore.locationVerdict(target.code)
+    const verdict = detailAnchorLocation(target)
     return verdict.present && !verdict.renderable ? verdict.reasonText : ''
 })
 
@@ -877,7 +873,7 @@ const hiddenAnchorText = computed(() => ANCHOR_HIDDEN[anchorLayout.value.reason]
 /** 仅**标记仍有效**时才给重新定位入口(不对已消失的标记发无效定位)。 */
 const canRelocate = computed(() => {
     if (introVisible.value || !detailTarget.value || fixedLocationReason.value) return false
-    return hasMapMarker(detailTarget.value.kind, detailTarget.value.code)
+    return hasMapMarker(detailTarget.value.kind, detailTarget.value.code, detailTarget.value.pointCode)
 })
 
 /**
@@ -892,8 +888,8 @@ function measureAnchor() {
     const hidden = (reason) => ({ canvas, layout: { visible: false, reason, rect: null, line: null, side: '' } })
     const target = detailTarget.value
     if (!target) return hidden('marker-missing')
-    if (!hasMapMarker(target.kind, target.code)) return hidden('marker-missing')
-    const anchor = getMapMarkerAnchor(target.kind, target.code)
+    if (!hasMapMarker(target.kind, target.code, target.pointCode)) return hidden('marker-missing')
+    const anchor = getMapMarkerAnchor(target.kind, target.code, target.pointCode)
     // 投影拿不到(相机背面 / 地平线后 / 投影无效)与标记在画布外,都归"完全离开画面"。
     if (!anchor) return hidden('marker-offscreen')
 
@@ -901,7 +897,7 @@ function measureAnchor() {
     // 用它做判据不会出现「设了高度→量到高度→又设回 auto」的来回抖动。
     const natural = detailRef.value ? detailRef.value.scrollHeight : 0
     const height = Number.isFinite(natural) && natural > 0 ? natural : 0
-    return { canvas, layout: solvePointAnchor({ anchor, canvas, height }) }
+    return { canvas, layout: solvePointAnchor({ anchor, canvas, height, iconSize: anchor.iconSize || 18 }) }
 }
 
 /**
@@ -927,7 +923,7 @@ function refreshAnchor(options = {}) {
 
     const { layout } = measureAnchor()
     const key = layout.visible
-        ? `${layout.side}|${Math.round(layout.rect.left)},${Math.round(layout.rect.top)},${Math.round(layout.rect.width)},${Math.round(layout.rect.height)}`
+        ? `${layout.side}|${Math.round(layout.rect.left)},${Math.round(layout.rect.top)},${Math.round(layout.rect.width)},${Math.round(layout.rect.height)}|${JSON.stringify(layout.line)}`
         : layout.reason
     // 与上一帧相同就**不再写 DOM**(规格:镜头稳定且布局未变时停止重算)。
     if (key === lastAnchorKey) return
@@ -981,8 +977,9 @@ const anchorLeadStyle = computed(() => {
 /** 暂隐入口的「重新定位」:只对仍有效的监测点标记走统一选择入口(不发明第二条定位路径)。 */
 function relocateSelected() {
     const target = detailTarget.value
-    if (!target || target.kind !== MAP_OBJECT_KIND.MONITORING_POINT) return
-    selectMonitoringPoint(target.code)
+    if (!target) return
+    if (target.kind === DEVICE_LAYER_KIND) deviceLayerStore.selectDevice(target.code)
+    else if (target.kind === MAP_OBJECT_KIND.MONITORING_POINT) selectMonitoringPoint(target.code)
 }
 
 /**
@@ -991,7 +988,7 @@ function relocateSelected() {
  * 直接量会把长卡判成"没内容"而永远按 auto 摆放。
  */
 watch(
-    [() => selection.value.level, () => selection.value.pointCode, () => selection.value.deviceCode, () => mapStore.summary.objects],
+    [() => selection.value.level, () => selection.value.pointCode, () => selection.value.deviceCode, () => mapStore.summary.objects, () => deviceRows.value],
     () => {
         lastAnchorKey = ''
         nextTick(() => refreshAnchor({ force: true }))

+ 28 - 0
src/modules/cockpit/domain/detailAnchor.js

@@ -0,0 +1,28 @@
+/** 详情层级决定空间锚点;设备归属仍由 selection.pointCode 保留。 */
+import { MAP_OBJECT_KIND } from './mapSummary.js'
+import { DEVICE_LAYER_KIND } from './deviceLayer.js'
+import { DETAIL_LEVEL } from './detailFlow.js'
+import { locationVerdict } from './coordinate.js'
+
+export function resolveDetailAnchor({ selection, objects = [], devices = [] } = {}) {
+    const current = selection || {}
+    if (current.level === DETAIL_LEVEL.NONE) return null
+    if (current.level === DETAIL_LEVEL.EQUIPMENT && current.deviceCode) {
+        if (current.pointCode) {
+            const device = devices.find((item) => item.code === current.deviceCode && item.pointCode === current.pointCode) || null
+            return { kind: DEVICE_LAYER_KIND, code: current.deviceCode, pointCode: current.pointCode, object: device }
+        }
+        const device = objects.find((item) => item.kind === MAP_OBJECT_KIND.UNASSOCIATED_EQUIPMENT && item.code === current.deviceCode) || null
+        return { kind: MAP_OBJECT_KIND.UNASSOCIATED_EQUIPMENT, code: current.deviceCode, object: device }
+    }
+    if (!current.pointCode) return null
+    const point = objects.find((item) => item.kind === MAP_OBJECT_KIND.MONITORING_POINT && item.code === current.pointCode) || null
+    return { kind: MAP_OBJECT_KIND.MONITORING_POINT, code: current.pointCode, object: point }
+}
+
+export function detailAnchorLocation(target) {
+    if (!target || (target.object && target.object.placement === 'pending')) {
+        return { present: false, renderable: false, reasonText: '' }
+    }
+    return locationVerdict(target.object ? [target.object] : [], target.code)
+}

+ 13 - 9
src/modules/cockpit/mocks/contractMocks.js

@@ -614,8 +614,7 @@ function mapFilterEcho(filters, points, equipment) {
  * 主场景(MP-002)与专用场景 `device-layer-overlap-degraded` **共用这一份** ——
  * 两处各写一份必然漂移(本仓反复栽在"同一份样例登记两处"上)。
  */
-function degradedOverlapPointDetail({ code, name }) {
-    const statuses = ['ALARM', 'ALARM', 'ALARM', 'WARNING', 'WARNING', 'WARNING', 'NORMAL', 'NORMAL', 'NORMAL', 'NORMAL', 'NORMAL']
+function degradedOverlapPointDetail({ code, name, coords = OVERLAP_DEGRADED_COORDS, statuses = ['ALARM', 'ALARM', 'ALARM', 'WARNING', 'WARNING', 'WARNING', 'NORMAL', 'NORMAL', 'NORMAL', 'NORMAL', 'NORMAL'] }) {
     const devices = OVERLAP_DEGRADED_CODES.map((code_, index) => {
         const deviceStatus = statuses[index]
         return {
@@ -626,7 +625,7 @@ function degradedOverlapPointDetail({ code, name }) {
             statusLabel: { ALARM: '报警', WARNING: '预警', WORK_ORDER: '工单处置', NORMAL: '正常', OFFLINE: '离线', EMPTY: '无设备' }[deviceStatus],
             statusReasons: deviceStatus === 'ALARM' ? ['压力超高'] : deviceStatus === 'WARNING' ? ['液位偏高'] : [],
             statusTime: '2026-09-02T09:59:00+08:00',
-            ...deviceCoordFields(OVERLAP_DEGRADED_COORDS, index),
+            ...deviceCoordFields(coords, index),
         }
     })
     return {
@@ -640,11 +639,11 @@ function degradedOverlapPointDetail({ code, name }) {
         statusReasons: [{ status: 'ALARM', deviceCode: OVERLAP_DEGRADED_CODES[0], reason: '压力超高' }],
         deviceStatistics: {
             totalDeviceCount: devices.length,
-            alarmDeviceCount: 3,
-            warningDeviceCount: 3,
-            workOrderDeviceCount: 0,
-            normalDeviceCount: 5,
-            offlineDeviceCount: 0,
+            alarmDeviceCount: statuses.filter((status) => status === 'ALARM').length,
+            warningDeviceCount: statuses.filter((status) => status === 'WARNING').length,
+            workOrderDeviceCount: statuses.filter((status) => status === 'WORK_ORDER').length,
+            normalDeviceCount: statuses.filter((status) => status === 'NORMAL').length,
+            offlineDeviceCount: statuses.filter((status) => status === 'OFFLINE').length,
         },
         devices,
         coordinateValidation: { state: 'VALID', reason: null, renderable: true, crs: 'WGS84' },
@@ -799,7 +798,12 @@ function pointDetailPayload(scenario, requestedCode = '') {
         }
         if (requestedCode === 'MP-002') {
             // 主场景的 MP-002 是 ALARM 点位(示例用于展示降级聚合的最高优先级状态)。
-            return degradedOverlapPointDetail({ code: 'MP-002', name: '太常测试监测点' })
+            return degradedOverlapPointDetail({
+                code: 'MP-002', name: '太常测试监测点',
+                // 主场景仍是 K=11 同坐标样例,位置在太常点附近;五态沿用已验收的动效词表。
+                coords: OVERLAP_DEGRADED_CODES.map(() => ({ longitude: 110.3632, latitude: 28.4524 })),
+                statuses: ['ALARM', 'ALARM', 'ALARM', 'WARNING', 'WARNING', 'WARNING', 'WORK_ORDER', 'NORMAL', 'NORMAL', 'OFFLINE', 'NORMAL'],
+            })
         }
     }
 

+ 61 - 49
src/modules/cockpit/services/cesiumEngine.js

@@ -1318,13 +1318,6 @@ export function pickDeviceMarkerAt(viewer, windowX, windowY) {
     return overlapTargetAt(viewer, windowX, windowY)
 }
 
-/** 某台设备标记是否存在(暂隐/重新定位类判据用)。 */
-export function hasDeviceMarker(viewer, pointCode, code) {
-    if (!viewer || !code) return false
-    const source = deviceSources.get(viewer) || viewer.dataSources.getByName(DEVICE_MARKER_SOURCE)[0]
-    return !!(source && source.show && source.entities.getById(`device|${pointCode}|${code}`))
-}
-
 // ================================================================ 设备点的对应反应(票 43)
 
 /**
@@ -1358,11 +1351,12 @@ export function applyDeviceReactions(viewer, reactions, styleOf) {
         const reaction = byId.get(entity.id) || 'normal'
         const style = styleOf(reaction) || { scale: 1, alpha: 1, ring: false }
         const billboard = entity.billboard
-        if (!billboard) continue
+        if (!billboard || (entity.properties && entity.properties.effectFx)) continue
 
-        billboard.scale = style.scale
-        // 弱化=压暗(降 alpha),**不动色相**:颜色仍表达设备状态。
-        billboard.color = Color.WHITE.withAlpha(style.alpha)
+        const isDevice = entity.properties && entity.properties.deviceCode
+        billboard.scale = isDevice ? Math.max(1, style.scale) : style.scale
+        // 设备中心保持不透明;选中/hover 仍用放大与外围特效区分。
+        billboard.color = Color.WHITE.withAlpha(isDevice ? 1 : style.alpha)
         // 选中外环:Cesium billboard 自带的描边,不用新建实体。
         billboard.outlineWidth = style.ring ? 3 : 0
         billboard.outlineColor = Color.WHITE
@@ -1376,15 +1370,9 @@ export function applyDeviceReactions(viewer, reactions, styleOf) {
 /**
  * 票 47:设备状态**动效**驱动器(决策票 07 的三个动效 + 门控 + 降级)。
  *
- * ── 为什么要和票 43 的"反应量"**合成**而不是各写各的 ──
- * `applyDeviceReactions()` 已经按选中/hover/弱化写 `scale` 与 `alpha`;动效也要动这两个属性。
- * 若动效直接赋值,两者会互相抹掉(表现为"选中态闪一下就没了"或"动画把弱化顶回去")。
- * 因此这里记住**反应量写下的基准值**(`deviceBaseStyle`),动效只在基准上做**乘性**修饰。
- *
- * ── 为什么不用 `CallbackProperty` ──
- * `isConstant=false` 的回调会持续请求新帧(票 02 已核),代价被限制在**异常设备**上是对的,
- * 但更简单且可控的做法是:一个 rAF 循环只写**参与动效的实体**,其余实体一个字节不动。
- * 场景本身未开 `requestRenderMode`,所以这个循环不额外改变渲染节奏。
+ * 中心符号保持不透明和稳定形状;选中/hover 的尺寸强化仍保留。
+ * 扩张、闪烁、渐隐和旋转只作用于外围光环,不再与中心弱化透明度相乘。
+ * 场景按需渲染:存在动态设备时 requestRender,关闭或降级时取消动画循环。
  *
  * ── 载体是场景内 billboard,**不是后处理级** ──
  * 决策票 07 已裁定:特效走 billboard ⇒「先特效、后雾」自动成立,不占 `PostProcessStageCollection`、
@@ -1506,43 +1494,25 @@ function writeEffectFrame() {
     const plan = deviceEffectState.plan
     const animated = animatedByCode(plan)
     const now = Date.now()
-    // 选中那台**额外强化**(决策票 07:选中不等于"只有它有特效",而是它更强)。
-    const amp = 0.34
-    const ampSelected = 0.55
-
     for (const entity of source.entities.values) {
         const billboard = entity.billboard
         if (!billboard) continue
         const properties = entity.properties || {}
         const code = properties.deviceCode && properties.deviceCode.getValue ? properties.deviceCode.getValue() : ''
+        if (!code) continue
         const base = baseStyleOf(entity.id)
-        const effect = code ? animated.get(code) : ''
-        if (!effect) {
-            // 不参与动效(正常/离线/未知,或特效关闭/降级):写回**基准**,不留上一帧的残留。
-            billboard.scale = base.scale
-            billboard.color = Color.WHITE.withAlpha(base.alpha)
-            billboard.rotation = 0
-            continue
-        }
-        const isSelected = deviceEffectState.selectedCode && code === deviceEffectState.selectedCode
-        const strength = isSelected ? ampSelected : amp
-        if (effect === DEVICE_EFFECT.SPIN) {
-            // 旋转环:只转角度,不呼吸(与脉冲/闪烁区分开)。
-            billboard.rotation = spinAngleAt(now)
-            billboard.scale = base.scale
-            billboard.color = Color.WHITE.withAlpha(base.alpha)
-            continue
-        }
-        const factor = effect === DEVICE_EFFECT.BLINK ? blinkStrengthAt(now) : pulseStrengthAt(now)
-        billboard.scale = base.scale * (1 + strength * factor)
-        // 脉冲/闪烁同时走**透明度**:只放大不透明度变化时,远看几乎看不出在动。
-        billboard.color = Color.WHITE.withAlpha(base.alpha * (0.45 + 0.55 * factor))
+        // 中心符号负责识别,外围光环负责动画;不把闪烁与弱化透明度乘到中心上。
+        billboard.scale = Math.max(1, base.scale)
+        billboard.color = Color.WHITE.withAlpha(1)
         billboard.rotation = 0
     }
 
     // ── 科技感光环层(多层同心扩散环 + 中心辉光)──
     // 光环实体不带 deviceCode,所以**不参与上面的设备图标循环**,也不吃拾取。
     const selectedBoost = 1.25
+    const owners = new Map(source.entities.values
+        .filter((entity) => entity.properties && entity.properties.deviceCode)
+        .map((entity) => [entity.properties.deviceCode.getValue(), entity]))
     for (const entity of source.entities.values) {
         const billboard = entity.billboard
         const properties = entity.properties || {}
@@ -1553,6 +1523,9 @@ function writeEffectFrame() {
         const kind = properties.effectFxKind && properties.effectFxKind.getValue ? properties.effectFxKind.getValue() : ''
         const layer = properties.effectFxLayer && properties.effectFxLayer.getValue ? properties.effectFxLayer.getValue() : 0
         const effect = forCode ? animated.get(forCode) : ''
+        const owner = owners.get(forCode)
+        entity.show = Boolean(owner && owner.show !== false)
+        billboard.pixelOffset = owner && owner.billboard ? owner.billboard.pixelOffset : new Cartesian2(0, 0)
         if (!effect) {
             // 无动效(正常/离线/未知、特效关闭或降级):光环**完全透明**,不留残影。
             billboard.scale = 1
@@ -1571,8 +1544,11 @@ function writeEffectFrame() {
         const frame = ringFrameAt(status, now)
         const ring = frame.rings[layer] || { scale: 1, alpha: 0 }
         billboard.scale = ring.scale * boost
-        billboard.color = Color.WHITE.withAlpha(Math.min(1, ring.alpha * boost))
+        const flicker = effect === DEVICE_EFFECT.BLINK ? blinkStrengthAt(now) : 1
+        billboard.color = Color.WHITE.withAlpha(Math.min(1, ring.alpha * boost * flicker))
+        billboard.rotation = effect === DEVICE_EFFECT.SPIN ? spinAngleAt(now) : 0
     }
+    if (animated.size && viewer.scene && viewer.scene.requestRender) viewer.scene.requestRender()
     deviceEffectState.tick += 1
 }
 
@@ -1627,7 +1603,11 @@ export function applyDeviceEffects(viewer, plan, options = {}) {
         }
     }
     if (running) ensureEffectLoop()
-    // 即使不跑动效也要写一帧:把上一轮可能残留的放大/透明度**写回基准**。
+    else if (deviceEffectState.raf && typeof window !== 'undefined') {
+        window.cancelAnimationFrame(deviceEffectState.raf)
+        deviceEffectState.raf = 0
+    }
+    // 中心符号保持可读;停用时外围动画复位。
     writeEffectFrame()
     return running
 }
@@ -1689,6 +1669,7 @@ export function readDeviceEffects(viewer) {
             status: get('effectFxStatus'),
             scale: billboard.scale && billboard.scale.getValue ? billboard.scale.getValue() : billboard.scale,
             alpha: color ? color.alpha : null,
+            rotation: billboard.rotation && billboard.rotation.getValue ? billboard.rotation.getValue() : (billboard.rotation || 0),
         })
     }
     return {
@@ -1855,7 +1836,33 @@ export function projectPointMarker(viewer, kind, code) {
     const source = markerSources.get(viewer) || viewer.dataSources.getByName(POINT_MARKER_SOURCE)[0]
     if (!source || !source.show) return null
     const entity = source.entities.getById(`point|${kind}|${code}`)
-    if (!entity || !entity.isShowing || !entity.position || !entity.billboard) return null
+    return projectMarkerEntity(viewer, entity)
+}
+
+/** 关联设备锚向实际绘制的设备实体,不回退到父监测点。 */
+export function hasDeviceMarker(viewer, code, pointCode = '') {
+    return Boolean(findDeviceMarker(viewer, code, pointCode))
+}
+
+export function projectDeviceMarker(viewer, code, pointCode = '') {
+    return projectMarkerEntity(viewer, findDeviceMarker(viewer, code, pointCode))
+}
+
+function findDeviceMarker(viewer, code, pointCode) {
+    if (!viewer || !code) return null
+    const source = deviceSources.get(viewer) || viewer.dataSources.getByName(DEVICE_MARKER_SOURCE)[0]
+    if (!source || !source.show) return null
+    return source.entities.values.find((entity) => {
+        const properties = entity.properties || {}
+        return properties.deviceCode && properties.deviceCode.getValue() === code
+            && (!pointCode || (properties.monitoringPointCode && properties.monitoringPointCode.getValue() === pointCode))
+            && entity.show !== false
+    }) || null
+}
+
+/** 贴地投影与屏幕分离共用一份口径,返回视觉中心及当前图标尺寸。 */
+function projectMarkerEntity(viewer, entity) {
+    if (!viewer || !viewer.scene || !viewer.canvas || !entity || !entity.isShowing || !entity.position || !entity.billboard) return null
     const original = entity.position.getValue(viewer.clock.currentTime)
     if (!original) return null
     const ellipsoid = viewer.scene.globe.ellipsoid
@@ -1874,7 +1881,12 @@ export function projectPointMarker(viewer, kind, code) {
     const at = SceneTransforms.worldToWindowCoordinates(viewer.scene, world)
     if (!at || !Number.isFinite(at.x) || !Number.isFinite(at.y)) return null
     const rect = viewer.canvas.getBoundingClientRect()
-    return { x: at.x + rect.left, y: at.y + rect.top }
+    const time = viewer.clock.currentTime
+    const read = (property, fallback) => property && property.getValue ? property.getValue(time) : fallback
+    const offset = read(entity.billboard.pixelOffset, { x: 0, y: 0 })
+    const scale = read(entity.billboard.scale, 1)
+    const iconSize = Math.max(read(entity.billboard.width, 18), read(entity.billboard.height, 18)) * scale
+    return { x: at.x + rect.left + offset.x, y: at.y + rect.top + offset.y, iconSize }
 }
 
 /**

+ 10 - 4
src/modules/cockpit/services/mapService.js

@@ -40,6 +40,7 @@ import {
     transitionOverlapState,
 } from '../domain/overlapDevices.js'
 import { MARKER_SIZE, planDeviceAsset } from '../domain/deviceAssets.js'
+import { DEVICE_LAYER_KIND } from '../domain/deviceLayer.js'
 import { deviceVisualForStatus } from '../domain/deviceStatus.js'
 import { isRenderable } from '../domain/coordinate.js'
 import { VIEW_MODES, planViewMode, viewHprFor } from '../domain/viewModes.js'
@@ -250,13 +251,18 @@ export function getMapCanvasRect() {
     return { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom }
 }
 
-export function hasMapMarker(kind, code) {
-    return !!(instance && instance.engine && instance.viewer && instance.engine.hasPointMarker(instance.viewer, kind, code))
+export function hasMapMarker(kind, code, pointCode = '') {
+    if (!instance || !instance.engine || !instance.viewer) return false
+    return kind === DEVICE_LAYER_KIND
+        ? instance.engine.hasDeviceMarker(instance.viewer, code, pointCode)
+        : instance.engine.hasPointMarker(instance.viewer, kind, code)
 }
 
-export function getMapMarkerAnchor(kind, code) {
+export function getMapMarkerAnchor(kind, code, pointCode = '') {
     if (!instance || !instance.engine || !instance.viewer || !code) return null
-    return instance.engine.projectPointMarker(instance.viewer, kind, code)
+    return kind === DEVICE_LAYER_KIND
+        ? instance.engine.projectDeviceMarker(instance.viewer, code, pointCode)
+        : instance.engine.projectPointMarker(instance.viewer, kind, code)
 }
 
 let pointLocateToken = 0