Explorar o código

票47特效增强:多层同心能量环+中心辉光,改用径向渐变能量场图片

Kazerin hai 1 semana
pai
achega
7b8e2b3a5b

+ 28 - 0
.scratch/cockpit-shell-panel-layout/verify/t47-device-effects-walkthrough.mjs

@@ -124,7 +124,9 @@ const readEffects = () => page.evaluate(() => {
         degraded: state ? state.degraded : null,
         running: state ? state.running : null,
         tick: state ? state.tick : -1,
+        ringLayers: state ? state.ringLayers : 0,
         devices: state && state.devices ? state.devices : [],
+        fx: state && state.fx ? state.fx : [],
     }
 })
 
@@ -180,6 +182,32 @@ check('E6-normal-stays-static',
 check('E7-offline-stays-static',
     distinct('EQ-EF-OFFLINE', 'scale') === 1 && distinct('EQ-EF-OFFLINE', 'alpha') === 1 && distinct('EQ-EF-OFFLINE', 'rotation') === 1,
     `离线设备**全程静止**:scale ${offlineA && offlineA.scale}、alpha ${offlineA && offlineA.alpha}、rotation ${offlineA && offlineA.rotation}`)
+
+    // ── 科技感光环(决策票 07 新增方向):多层同心扩散环 + 中心辉光 ──
+    const fxValues = (code, kind, key) => SAMPLES
+        .flatMap((frame) => (frame.fx || []).filter((item) => item.for === code && item.kind === kind))
+        .map((item) => (Number.isFinite(item[key]) ? Number(item[key].toFixed(5)) : null))
+        .filter((value) => value !== null)
+    const ringLayersOf = (code) => new Set((SAMPLES[0].fx || [])
+        .filter((item) => item.for === code && item.kind === 'ring')
+        .map((item) => item.layer)).size
+
+    check('E13-alarm-has-multi-layer-rings',
+        frameA.ringLayers >= 3 && ringLayersOf('EQ-EF-ALARM') === frameA.ringLayers,
+        `报警设备有 ${ringLayersOf('EQ-EF-ALARM')} 层同心环(期望 ${frameA.ringLayers} 层)`)
+    check('E14-rings-expand-and-fade-across-frames',
+        new Set(fxValues('EQ-EF-ALARM', 'ring', 'scale')).size >= 3 &&
+        new Set(fxValues('EQ-EF-ALARM', 'ring', 'alpha')).size >= 3,
+        `环跨帧扩张渐隐:scale 取值数 ${new Set(fxValues('EQ-EF-ALARM', 'ring', 'scale')).size}、alpha 取值数 ${new Set(fxValues('EQ-EF-ALARM', 'ring', 'alpha')).size}`)
+    check('E15-glow-only-for-alarm',
+        fxValues('EQ-EF-ALARM', 'glow', 'alpha').length > 0 &&
+        new Set(fxValues('EQ-EF-ALARM', 'glow', 'alpha')).size >= 2 &&
+        fxValues('EQ-EF-WARNING', 'glow', 'alpha').length === 0 &&
+        fxValues('EQ-EF-NORMAL', 'ring', 'scale').length === 0,
+        `中心辉光只给报警(报警辉光取值数 ${new Set(fxValues('EQ-EF-ALARM', 'glow', 'alpha')).size};预警/正常无辉光/无环)`)
+    check('E16-static-devices-have-no-rings',
+        ringLayersOf('EQ-EF-NORMAL') === 0 && ringLayersOf('EQ-EF-OFFLINE') === 0,
+        `正常/离线**没有任何光环**(决策票 07:静态状态不参与动效)`)
 }
 
 // ── 第 3 层:真实帧差(同一视角两帧,字节不同 ⇒ 画面确实在变)──

+ 56 - 0
.scratch/cockpit-shell-panel-layout/verify/t47-device-effects.mjs

@@ -11,12 +11,18 @@
 import {
     DEVICE_EFFECT,
     DEFAULT_ABNORMAL_LIMIT,
+    RING_LAYERS,
     deviceEffectOf,
     hasDeviceEffect,
     effectPhaseAt,
     pulseStrengthAt,
     blinkStrengthAt,
     spinAngleAt,
+    ringScaleAt,
+    ringAlphaAt,
+    ringFrameAt,
+    glowStrengthAt,
+    glowScaleAt,
     planDeviceEffects,
     degradedAbnormalNote,
 } from '../../../src/modules/cockpit/domain/deviceEffects.js'
@@ -147,6 +153,56 @@ const device = (code, status) => ({ code, status, pointCode: 'MP-1' })
         `脏值退回默认并记 issue:${dirty.issues.filter((i) => i.includes('map.effects')).join(' | ')}`)
 }
 
+// ================================================================ E 科技感光环(决策票 07 新增方向)
+{
+    const samples = [0, 150, 300, 450, 600, 750, 900, 1050, 1200, 1350, 1500, 1650]
+
+    // 多层同心环:层数 ≥3(参考图 3–4 层),每层都随时间扩张。
+    check('E1-ring-layers-are-multi',
+        RING_LAYERS >= 3,
+        `光环层数 ${RING_LAYERS}(决策票 07 参考图:3–4 层同心扩散环)`)
+
+    const layerScales = Array.from({ length: RING_LAYERS }, (_, layer) =>
+        samples.map((t) => ringScaleAt(layer, t, DEVICE_EFFECT.PULSE)))
+    check('E2-every-ring-expands-over-time',
+        layerScales.every((list) => new Set(list.map((v) => v.toFixed(4))).size >= 5),
+        `每层都在扩张(各层 scale 取值数:${layerScales.map((l) => new Set(l.map((v) => v.toFixed(4))).size).join('/')})`)
+
+    check('E3-ring-scales-outward-and-fades',
+        ringScaleAt(0, 0, DEVICE_EFFECT.PULSE) >= 1 &&
+        ringScaleAt(0, 1700, DEVICE_EFFECT.PULSE) > 2.5 &&
+        ringAlphaAt(0, 1700, DEVICE_EFFECT.PULSE) < ringAlphaAt(0, 100, DEVICE_EFFECT.PULSE),
+        `环由内向外扩张(scale→${ringScaleAt(0, 1700, DEVICE_EFFECT.PULSE).toFixed(2)})且渐隐(alpha ${ringAlphaAt(0, 100, DEVICE_EFFECT.PULSE).toFixed(2)}→${ringAlphaAt(0, 1700, DEVICE_EFFECT.PULSE).toFixed(2)})`)
+
+    // 层间相位错开 ⇒ 同一时刻各层 scale 不同("连续向外扩散"的观感来源)。
+    const staggered = samples.some((t) => new Set(
+        Array.from({ length: RING_LAYERS }, (_, layer) => ringScaleAt(layer, t, DEVICE_EFFECT.PULSE).toFixed(3))).size === RING_LAYERS)
+    check('E4-layers-are-phase-staggered', staggered,
+        '同一时刻各层 scale 互不相同(层间错开相位 ⇒ 连续扩散观感,而非同涨同落)')
+
+    // 中心辉光:只给最高优先级(报警),且随时间呼吸。
+    const glowSamples = samples.map((t) => glowStrengthAt(t))
+    check('E5-glow-breathes-for-alarm-only',
+        new Set(glowSamples.map((v) => v.toFixed(4))).size >= 5 &&
+        ringFrameAt('ALARM', 300).glow !== null &&
+        ringFrameAt('WARNING', 300).glow === null &&
+        ringFrameAt('WORK_ORDER', 300).glow === null,
+        `辉光只给报警(三个状态辉光:${['ALARM', 'WARNING', 'WORK_ORDER'].map((s) => (ringFrameAt(s, 300).glow ? '有' : '无')).join('/')}),强度取值数 ${new Set(glowSamples.map((v) => v.toFixed(4))).size}`)
+
+    // 无动效状态不得有光环(正常/离线/未知)。
+    check('E6-no-rings-for-static-states',
+        ['NORMAL', 'OFFLINE', 'MAINTENANCE'].every((status) => {
+            const frame = ringFrameAt(status, 500)
+            return frame.rings.length === 0 && frame.glow === null
+        }),
+        '正常/离线/未知**没有任何光环**(决策票 07:静态状态不参与动效)')
+
+    // 三种需处置状态都有环(层数一致 ⇒ 不靠层数表达优先级)。
+    check('E7-all-attention-states-have-rings',
+        ['ALARM', 'WARNING', 'WORK_ORDER'].every((status) => ringFrameAt(status, 500).rings.length === RING_LAYERS),
+        `三种需处置状态都是 ${RING_LAYERS} 层环(层数一致,差异靠节奏与色相表达)`)
+}
+
 const failed = checks.filter((item) => !item.pass)
 console.log('')
 console.log(`T47 设备状态动效检查:${checks.length - failed.length}/${checks.length} 项通过。`)

+ 106 - 0
src/modules/cockpit/domain/deviceEffects.js

@@ -126,3 +126,109 @@ export function degradedAbnormalNote(plan) {
     if (!plan || plan.degraded !== true) return ''
     return `${plan.abnormalCount} 台异常`
 }
+
+// ================================================================ 科技感光环(决策票 07 新增方向)
+
+/**
+ * 光环层数:**多层同心扩散环**(决策票 07 维护者给的参考图观感:3–4 层由内向外扩张渐隐)。
+ * 三个需处置状态都用同一套层数 —— 差异由**节奏与色相**表达,不由层数表达
+ * (层数不同会让"哪台更紧急"被误读成"环多的更严重")。
+ */
+export const RING_LAYERS = 4
+
+/** 各动效的光环周期(毫秒):报警最急、预警次之、工单最缓("进行中"的从容感)。 */
+export const RING_PERIOD_MS = {
+    [DEVICE_EFFECT.PULSE]: 1800,
+    [DEVICE_EFFECT.BLINK]: 1400,
+    [DEVICE_EFFECT.SPIN]: 2600,
+}
+
+/** 中心辉光的呼吸周期(毫秒):比环慢,避免与环同频显得呆板。 */
+export const GLOW_PERIOD_MS = 2400
+
+/** 光环最大放大倍数(相对设备图标尺寸)。表现层数值,留现场校准。 */
+export const RING_MAX_SCALE = 3.8
+
+/** 中心辉光最大放大倍数(相对设备图标尺寸)。 */
+export const GLOW_MAX_SCALE = 1.9
+
+/**
+ * 单层光环的相位与强度(0..1)。
+ *
+ * 每层**均匀错开**一个 `1/RING_LAYERS` 的相位,形成"连续向外扩散"的观感;
+ * 强度用 `(1 - phase)` 做**渐隐**(越外越淡),这正是参考图里"扩张渐隐"的节奏。
+ * @param {number} layer 层序号(0 = 最内层)
+ * @param {number} timeMs 绝对时间
+ * @param {string} effect 动效键(决定周期)
+ */
+export function ringPhaseAt(layer, timeMs, effect) {
+    const period = RING_PERIOD_MS[effect] || RING_PERIOD_MS[DEVICE_EFFECT.PULSE]
+    const offset = (Number.isFinite(layer) ? layer : 0) / RING_LAYERS
+    const t = Number.isFinite(timeMs) ? timeMs : 0
+    const raw = ((t % period) / period + offset) % 1
+    return raw < 0 ? raw + 1 : raw
+}
+
+/** 单层光环的放大倍数:从 1 扩到 `RING_MAX_SCALE`(由内向外)。 */
+export function ringScaleAt(layer, timeMs, effect) {
+    const phase = ringPhaseAt(layer, timeMs, effect)
+    return 1 + (RING_MAX_SCALE - 1) * phase
+}
+
+/** 单层光环的不透明度:由内向外渐隐(放缓的幂次,让环在扩张中保持可见)。 */
+export function ringAlphaAt(layer, timeMs, effect, baseAlpha = 1) {
+    const phase = ringPhaseAt(layer, timeMs, effect)
+    // 幂次 1.35 而不是 2:首版用平方衰减,环一扩散就淡到看不见(真机截图抓到偏弱)。
+    const fade = Math.pow(1 - phase, 1.35)
+    return Math.max(0, Math.min(1, baseAlpha * fade))
+}
+
+/** 中心辉光的呼吸强度(0..1,正弦,比环更柔)。 */
+export function glowStrengthAt(timeMs) {
+    const t = Number.isFinite(timeMs) ? timeMs : 0
+    const phase = (t % GLOW_PERIOD_MS) / GLOW_PERIOD_MS
+    return (1 - Math.cos(phase * Math.PI * 2)) / 2
+}
+
+/** 中心辉光的放大倍数(1 .. GLOW_MAX_SCALE,随呼吸)。 */
+export function glowScaleAt(timeMs) {
+    return 1 + (GLOW_MAX_SCALE - 1) * glowStrengthAt(timeMs)
+}
+
+/**
+ * 该状态是否带**中心辉光**(最高优先级最亮)。
+ *
+ * 单独成函数是为了让引擎**不必知道业务状态名**(t09 F5:引擎只认形状/键,
+ * 状态→视觉的映射只有 domain 一份)。引擎问的是"这台有没有辉光",不是"它是不是报警"。
+ */
+export function hasGlow(status) {
+    return deviceEffectOf(status) === DEVICE_EFFECT.PULSE
+}
+
+/** 某台设备的光环实体数量(多层环 + 可能的辉光);引擎据此判断是否需要重建。 */
+export function ringEntityCount(status) {
+    return deviceEffectOf(status) ? RING_LAYERS + (hasGlow(status) ? 1 : 0) : 0
+}
+
+/**
+ * 生成某个设备在某一时刻的**光环帧**(纯函数;引擎只负责把结论写成 billboard 属性)。
+ *
+ * 走查可以按任意时间点算出期望值 —— 这是"跨帧快照"能成为证据的前提
+ * (否则只能拿两份 JS 数据互比,是恒真断言)。
+ * @returns {{rings:Array<{layer:number,scale:number,alpha:number}>, glow:{scale:number,alpha:number}|null}}
+ */
+export function ringFrameAt(status, timeMs, { baseAlpha = 1 } = {}) {
+    const effect = deviceEffectOf(status)
+    if (!effect) return { rings: [], glow: null }
+    const rings = Array.from({ length: RING_LAYERS }, (_, layer) => ({
+        layer,
+        scale: ringScaleAt(layer, timeMs, effect),
+        alpha: ringAlphaAt(layer, timeMs, effect, baseAlpha),
+    }))
+    // 中心辉光只给**最高优先级**(报警):三个状态都加辉光会让画面糊成一团,
+    // 而"最要紧的那台最亮"正是优先级语义要传达的。
+    const glow = hasGlow(status)
+        ? { scale: glowScaleAt(timeMs), alpha: 0.32 * glowStrengthAt(timeMs) * baseAlpha }
+        : null
+    return { rings, glow }
+}

+ 43 - 0
src/modules/cockpit/domain/pointStatus.js

@@ -222,3 +222,46 @@ export function markerSvg(shape, color, size = 12) {
         `fill="currentColor" color="${color || '#ffffff'}">${shapeSvg(shape)}</svg>`
     return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
 }
+
+/**
+ * 票 47(科技感方向):**能量光环**图片。
+ *
+ * 与 `markerSvg` 的实心/描边形状不同 —— 光环要的是"能量场"观感,不是"画了个圈":
+ * 用**径向渐变**做柔和边缘(内透明 → 环带最亮 → 外渐隐),缩放时边缘自然扩散,
+ * 不会像 2px 描边那样一放大就变成一根细线(首版真机截图抓到的问题)。
+ *
+ * 色值仍**由调用方给**(服务层从 `tokens.css` 计算样式读出后烘焙)——本文件不写 `#rrggbb`。
+ * @param {string} color CSS 颜色
+ * @param {number} [size] 视框像素尺寸
+ */
+export function haloSvg(color, size = 64) {
+    const stroke = color || '#ffffff'
+    const svg =
+        `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="${size}" height="${size}">` +
+        '<defs><radialGradient id="halo" cx="50%" cy="50%" r="50%">' +
+        `<stop offset="58%" stop-color="${stroke}" stop-opacity="0"/>` +
+        `<stop offset="78%" stop-color="${stroke}" stop-opacity="1"/>` +
+        `<stop offset="90%" stop-color="${stroke}" stop-opacity="0.45"/>` +
+        `<stop offset="100%" stop-color="${stroke}" stop-opacity="0"/>` +
+        '</radialGradient></defs>' +
+        '<circle cx="32" cy="32" r="32" fill="url(#halo)"/></svg>'
+    return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
+}
+
+/**
+ * 票 47(科技感方向):**中心辉光**图片(中心最亮、向外渐隐的光晕)。
+ * 与光环同一套做法(径向渐变),但形状是实心光斑 —— 放在设备图标**下面**当底光。
+ */
+export function glowSvg(color, size = 64) {
+    const core = color || '#ffffff'
+    const svg =
+        `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="${size}" height="${size}">` +
+        '<defs><radialGradient id="glow" cx="50%" cy="50%" r="50%">' +
+        `<stop offset="0%" stop-color="${core}" stop-opacity="0.95"/>` +
+        `<stop offset="38%" stop-color="${core}" stop-opacity="0.42"/>` +
+        `<stop offset="70%" stop-color="${core}" stop-opacity="0.12"/>` +
+        `<stop offset="100%" stop-color="${core}" stop-opacity="0"/>` +
+        '</radialGradient></defs>' +
+        '<circle cx="32" cy="32" r="32" fill="url(#glow)"/></svg>'
+    return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
+}

+ 170 - 1
src/modules/cockpit/services/cesiumEngine.js

@@ -49,7 +49,7 @@ import 'cesium/Build/Cesium/Widgets/widgets.css'
 import { EMPHASIS_STYLE, shouldEmphasize } from '../domain/mapEmphasis.js'
 // 票 47:动效的**相位与映射**同样在 domain(纯函数,可跨时间采样断言);
 // 本层只把相位写成 billboard 的 scale/alpha/rotation。
-import { DEVICE_EFFECT, blinkStrengthAt, pulseStrengthAt, spinAngleAt } from '../domain/deviceEffects.js'
+import { DEVICE_EFFECT, RING_LAYERS, blinkStrengthAt, glowScaleAt, glowStrengthAt, hasGlow, pulseStrengthAt, ringEntityCount, ringFrameAt, spinAngleAt } from '../domain/deviceEffects.js'
 
 const DEG = Math.PI / 180
 
@@ -1401,6 +1401,94 @@ const deviceEffectState = {
 /** 反应量写下的**基准** scale/alpha(动效在其上做乘性修饰)。键=实体 id。 */
 const deviceBaseStyle = new Map()
 
+/**
+ * 票 47(科技感方向):光环实体池。
+ *
+ * ── 为什么用**独立 billboard 实体**而不是改设备图标本身 ──
+ * 决策票 07 的参考图要的是「多层同心扩散环 + 中心辉光 + 能量描边」;
+ * 设备图标只有一个 billboard,塞不下多层环。所以另建一层光环实体(`fx|` 前缀):
+ *   · **不带 `deviceCode`** ⇒ 按研究笔记第 7 条,它不参与设备拾取判定,不会挡住设备点;
+ *   · 位置跟设备同坐标,随图层一起重建/销毁(特效挂在图层上);
+ *   · 每台最多 `RING_LAYERS` 个环 + 1 个辉光,同处一个数据源(一次 draw call 量级)。
+ */
+const RING_ENTITY_PREFIX = 'fx|'
+
+function ringEntitiesOf(source, code) {
+    return source.entities.values.filter((entity) =>
+        typeof entity.id === 'string' &&
+        entity.id.startsWith(RING_ENTITY_PREFIX) &&
+        entity.id.includes(`|${code}|`))
+}
+
+/** 建/更新某台设备的光环实体(层数固定:缺则建、数量不符则整组重建)。 */
+function ensureRingEntities(source, device, color, fxImageOf) {
+    const size = Number.isFinite(device.size) ? device.size : 14
+    // 层数与"要不要辉光"都问 domain(引擎不认识业务状态名,只认"几层环")。
+    const wanted = ringEntityCount(device.status)
+    if (!wanted) return []
+    const existing = ringEntitiesOf(source, device.code)
+    if (existing.length === wanted) return existing
+    for (const entity of existing) source.entities.remove(entity)
+    const created = []
+    for (let layer = 0; layer < RING_LAYERS; layer += 1) {
+        created.push(source.entities.add({
+            id: `${RING_ENTITY_PREFIX}${device.pointCode}|${device.code}|ring${layer}`,
+            position: Cartesian3.fromDegrees(device.longitude, device.latitude),
+            billboard: {
+                // 环用**能量场图片**(径向渐变柔和边缘):比 2px 描边圆更像特效,
+                // 放大时边缘自然扩散,不会一放大就细成一根线(首版真机截图抓到的问题)。
+                image: fxImageOf('ring', color, size),
+                width: size,
+                height: size,
+                scale: 1,
+                color: Color.WHITE.withAlpha(0),
+                heightReference: HeightReference.CLAMP_TO_GROUND,
+                verticalOrigin: VerticalOrigin.CENTER,
+            },
+            properties: {
+                effectFx: true,
+                effectFxKind: 'ring',
+                effectFxLayer: layer,
+                effectFxFor: device.code,
+                effectFxStatus: device.status,
+                // 刻意**不写 deviceCode / monitoringPointCode**:光环不冒充设备、不吃拾取。
+            },
+        }))
+    }
+    if (hasGlow(device.status)) {
+        created.push(source.entities.add({
+            id: `${RING_ENTITY_PREFIX}${device.pointCode}|${device.code}|glow`,
+            position: Cartesian3.fromDegrees(device.longitude, device.latitude),
+            billboard: {
+                image: fxImageOf('glow', color, size),
+                width: size,
+                height: size,
+                scale: 1,
+                color: Color.WHITE.withAlpha(0),
+                heightReference: HeightReference.CLAMP_TO_GROUND,
+                verticalOrigin: VerticalOrigin.CENTER,
+            },
+            properties: {
+                effectFx: true,
+                effectFxKind: 'glow',
+                effectFxLayer: -1,
+                effectFxFor: device.code,
+                effectFxStatus: device.status,
+            },
+        }))
+    }
+    return created
+}
+
+/** 清掉某台(或全部)光环实体。 */
+function clearRingEntities(source, code = '') {
+    for (const entity of source.entities.values.slice()) {
+        if (typeof entity.id !== 'string' || !entity.id.startsWith(RING_ENTITY_PREFIX)) continue
+        if (code && !entity.id.includes(`|${code}|`)) continue
+        source.entities.remove(entity)
+    }
+}
+
 function baseStyleOf(entityId) {
     return deviceBaseStyle.get(entityId) || { scale: 1, alpha: 1 }
 }
@@ -1451,6 +1539,40 @@ function writeEffectFrame() {
         billboard.color = Color.WHITE.withAlpha(base.alpha * (0.45 + 0.55 * factor))
         billboard.rotation = 0
     }
+
+    // ── 科技感光环层(多层同心扩散环 + 中心辉光)──
+    // 光环实体不带 deviceCode,所以**不参与上面的设备图标循环**,也不吃拾取。
+    const selectedBoost = 1.25
+    for (const entity of source.entities.values) {
+        const billboard = entity.billboard
+        const properties = entity.properties || {}
+        const isFx = properties.effectFx && properties.effectFx.getValue && properties.effectFx.getValue() === true
+        if (!billboard || !isFx) continue
+        const forCode = properties.effectFxFor && properties.effectFxFor.getValue ? properties.effectFxFor.getValue() : ''
+        const status = properties.effectFxStatus && properties.effectFxStatus.getValue ? properties.effectFxStatus.getValue() : ''
+        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) : ''
+        if (!effect) {
+            // 无动效(正常/离线/未知、特效关闭或降级):光环**完全透明**,不留残影。
+            billboard.scale = 1
+            billboard.color = Color.WHITE.withAlpha(0)
+            continue
+        }
+        const isSelected = deviceEffectState.selectedCode && forCode === deviceEffectState.selectedCode
+        const boost = isSelected ? selectedBoost : 1
+        if (kind === 'glow') {
+            const strength = glowStrengthAt(now)
+            billboard.scale = glowScaleAt(now) * boost
+            billboard.color = Color.WHITE.withAlpha(0.32 * strength * boost)
+            continue
+        }
+        // 多层环:每层相位错开、由内向外扩张渐隐。
+        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))
+    }
     deviceEffectState.tick += 1
 }
 
@@ -1477,6 +1599,33 @@ export function applyDeviceEffects(viewer, plan, options = {}) {
     deviceEffectState.plan = plan || null
     deviceEffectState.selectedCode = (options && options.selectedCode) || ''
     const running = Boolean(viewer && plan && plan.enabled === true && !plan.degraded && plan.animated && plan.animated.length)
+    // 光环实体按计划重建:参与的设备建环,其余(含降级/关闭时全部)清掉 —— 降级后动画全停,
+    // 光环也必须一起消失,否则"停了动画却还留着环"会被读成"还在报警"。
+    const source = viewer && (deviceSources.get(viewer) || (viewer.dataSources ? viewer.dataSources.getByName(DEVICE_MARKER_SOURCE)[0] : null))
+    if (source) {
+        if (running && options && options.devices) {
+            const animatedCodes = new Set((plan.animated || []).map((item) => item.code))
+            for (const device of options.devices) {
+                if (!animatedCodes.has(device.code)) { clearRingEntities(source, device.code); continue }
+                ensureRingEntities(
+                    source,
+                    device,
+                    options.colorOf ? options.colorOf(device) : Color.WHITE,
+                    options.fxImageOf || (() => ''),
+                )
+            }
+            // 已不在本批设备里的光环(换点/换状态)也要清。
+            const known = new Set(options.devices.map((item) => item.code))
+            for (const entity of source.entities.values.slice()) {
+                const isFx = entity.properties && entity.properties.effectFx && entity.properties.effectFx.getValue && entity.properties.effectFx.getValue() === true
+                if (!isFx) continue
+                const forCode = entity.properties.effectFxFor && entity.properties.effectFxFor.getValue ? entity.properties.effectFxFor.getValue() : ''
+                if (!known.has(forCode) || !animatedCodes.has(forCode)) source.entities.remove(entity)
+            }
+        } else {
+            clearRingEntities(source)
+        }
+    }
     if (running) ensureEffectLoop()
     // 即使不跑动效也要写一帧:把上一轮可能残留的放大/透明度**写回基准**。
     writeEffectFrame()
@@ -1524,12 +1673,32 @@ export function readDeviceEffects(viewer) {
             rotation: billboard.rotation && billboard.rotation.getValue ? billboard.rotation.getValue() : (billboard.rotation || 0),
         })
     }
+    // 科技感光环的逐层读数(多层同心环 + 辉光):走查据此断言"环真的在扩张渐隐"。
+    const fx = []
+    for (const entity of source.entities.values) {
+        const billboard = entity.billboard
+        if (!billboard) continue
+        const properties = entity.properties || {}
+        const get = (key) => (properties[key] && properties[key].getValue ? properties[key].getValue() : '')
+        if (get('effectFx') !== true) continue
+        const color = billboard.color && billboard.color.getValue ? billboard.color.getValue() : null
+        fx.push({
+            for: get('effectFxFor'),
+            kind: get('effectFxKind'),
+            layer: get('effectFxLayer'),
+            status: get('effectFxStatus'),
+            scale: billboard.scale && billboard.scale.getValue ? billboard.scale.getValue() : billboard.scale,
+            alpha: color ? color.alpha : null,
+        })
+    }
     return {
         tick: deviceEffectState.tick,
         running: deviceEffectState.raf !== 0,
         degraded: Boolean(deviceEffectState.plan && deviceEffectState.plan.degraded),
         enabled: Boolean(deviceEffectState.plan && deviceEffectState.plan.enabled),
+        ringLayers: RING_LAYERS,
         devices,
+        fx,
     }
 }
 

+ 9 - 1
src/modules/cockpit/services/mapService.js

@@ -46,7 +46,7 @@ import { VIEW_MODES, planViewMode, viewHprFor } from '../domain/viewModes.js'
 import { TERRAIN_STATE, planOverlays, planTerrain, planTilesets, terrainStatusText } from '../domain/mapLayers.js'
 // 票 46:`planPointMarkers` 不再在服务层调用(模式过滤归 `domain/modeLayerPlan.js`),
 // 但 `summarizeMarkerStatuses` 仍需要 —— 计划没带 byStatus 时兜底现算,保证图例同源。
-import { markerSvg, pointLegend, summarizeMarkerStatuses } from '../domain/pointStatus.js'
+import { markerSvg, pointLegend, summarizeMarkerStatuses, haloSvg, glowSvg } from '../domain/pointStatus.js'
 import { planPrimaryArea, primaryAreaStatusText } from '../domain/primaryArea.js'
 import {
     AREA_STATE,
@@ -2475,8 +2475,16 @@ function applyDeviceEffectsToEngine() {
         paintDeviceEffects()
         return false
     }
+    // 光环实体的色值必须由**服务层烘焙**后下传(引擎读不到 CSS 变量,研究笔记第 6 条):
+    // 这里复用与设备图标同一份 `toneColors()` 与 `markerSvg()`。
+    const colors = toneColors()
     const running = instance.engine.applyDeviceEffects(instance.viewer, deviceEffectsPlan, {
         selectedCode: selectedDeviceCode,
+        devices: deviceRows,
+        colorOf: (device) => colors[deviceVisualTone(device)] || colors.neutral,
+        imageOf: (shape, color, size) => markerSvg(shape, color, size),
+        // 科技感光环/辉光用**能量场图片**(径向渐变柔和边缘),与图标同一份烘焙色值。
+        fxImageOf: (kind, color, size) => (kind === 'glow' ? glowSvg(color, size) : haloSvg(color, size)),
     })
     paintDeviceEffects()
     return running