Quellcode durchsuchen

feat(cockpit): 为设施点位复用设备状态特效

ChenJZ vor 2 Tagen
Ursprung
Commit
d4e3bb1060

+ 2 - 10
src/modules/cockpit/domain/entityDetail.js

@@ -8,6 +8,8 @@ import { eventLevelFromBucket, eventLevelTag, eventStatusText } from './eventLis
 import { DEFAULT_TONE } from './statusTones.js'
 import { toWorkOrder, toWorkOrderTimeline, workOrderStateName } from './workOrder.js'
 import { trackOf } from './workOrderTimeline.js'
+import { facilityStatusTone } from './facilityStatus.js'
+export { facilityStatusTone } from './facilityStatus.js'
 
 export const ENTITY_DETAIL_KIND = Object.freeze({
     FACILITY: 'facility',
@@ -135,16 +137,6 @@ function normalizeAsset(identity, source) {
     }
 }
 
-/** 设施状态 → 语义色键(0 正常=绿、1 维护中=黄、2 异常=红;认不出走中性)。
- *  维护者第 15 轮:运行信息的指标行也要按设施状态着色,所以本函数对外导出(原先是模块私有)。 */
-export function facilityStatusTone(status) {
-    const code = status === null || status === undefined ? '' : String(status).trim()
-    if (code === '0') return 'lv4'
-    if (code === '1') return 'lv2'
-    if (code === '2') return 'lv1'
-    return 'neutral'
-}
-
 /**
  * 数值型指标的**单位登记**(维护者 2026-10-29「所有数值都要带单位」)。
  *

+ 25 - 0
src/modules/cockpit/domain/facilityStatus.js

@@ -0,0 +1,25 @@
+import { deviceVisualForStatus } from './deviceStatus.js'
+
+// 设施码值不是设备状态码。只归一已明确的语义,不从颜色或动效键反推业务状态。
+const VISUAL_STATUS = {
+    '0': 'NORMAL',
+    '1': 'WORK_ORDER', // 维护者裁定:维护中用蓝色工单旋转环。
+    '2': 'ALARM',
+    NORMAL: 'NORMAL',
+    WARNING: 'WARNING',
+    ALARM: 'ALARM',
+    WORK_ORDER: 'WORK_ORDER',
+    OFFLINE: 'OFFLINE',
+}
+
+/** 设施状态 → 共享视觉状态词;未知码不猜成正常或报警,也不新增数字预警码。 */
+export function facilityVisualStatus(status) {
+    const code = status === null || status === undefined ? '' : String(status).trim()
+    return Object.hasOwn(VISUAL_STATUS, code) ? VISUAL_STATUS[code] : ''
+}
+
+/** 设施状态色与设备同源;未发布/离线保留设施的中性色。 */
+export function facilityStatusTone(status) {
+    const visualStatus = facilityVisualStatus(status)
+    return visualStatus && visualStatus !== 'OFFLINE' ? deviceVisualForStatus(visualStatus).tone : 'neutral'
+}

+ 5 - 2
src/modules/cockpit/domain/markerPlan.js

@@ -15,6 +15,7 @@
  */
 import { isBusinessAttentionStatus } from './pointStatus.js'
 import { MASTER_ASPECT, MASTER_SOURCE, markerMetricsFor } from './markerMetrics.js'
+import { facilityStatusTone, facilityVisualStatus } from './facilityStatus.js'
 
 
 /** 母版素材属性(实测登记,两个母版同形同包围盒)。尺寸关系来自单源 `markerMetrics.MASTER_SOURCE`。 */
@@ -119,6 +120,7 @@ const TONE_BY_STATUS = {
  *   size: { width: number, height: number },
  *   anchor: { x: number, y: number },
  *   tint: string,
+ *   effectStatus: string,
  *   desaturate: boolean,
  *   labelOffsetY: number,
  *   labelMaxWidth: number,
@@ -139,7 +141,8 @@ export function planMarker({ kind, status, industry = '', facilityType = '', com
     const unassoc = !isPoint && !isFacility && !isComponent && unassociated === true
 
     // 色键:默认按状态词表推导;调用方可显式覆盖(如设备自身状态表与点位表并行的场合)。
-    const tint = tone || TONE_BY_STATUS[status] || 'neutral'
+    const effectStatus = isFacility ? facilityVisualStatus(status) : isComponent ? '' : status || ''
+    const tint = tone || (isFacility ? facilityStatusTone(status) : TONE_BY_STATUS[status]) || 'neutral'
 
     // 母版归属(规格 10):监测点与关联监测设备 = 实心;EMPTY 点位与未关联设备 = 镂空。
     const master = empty || unassoc ? 'hollow' : 'solid'
@@ -183,7 +186,7 @@ export function planMarker({ kind, status, industry = '', facilityType = '', com
     const labelMaxWidth = Math.round(width * 2.6 * 100) / 100
 
     return {
-        master, inner, size: { width, height }, anchor, tint, desaturate,
+        master, inner, size: { width, height }, anchor, tint, desaturate, effectStatus,
         labelOffsetY, labelMaxWidth,
         // billboard 绘制尺寸(含描边余量)与 pad 屏显值——引擎按它设 billboard 宽高。
         draw: { width: drawWidth, height: drawHeight, pad: padScreen },

+ 49 - 11
src/modules/cockpit/services/mapService.js

@@ -221,7 +221,7 @@ let viewMode = VIEW_MODES.GROUND
 let markerPlan = { markers: [], blocked: [], byStatus: {}, total: 0 }
 // 票 45:摘要标记(监测点/未关联设备)的动效计划(planDeviceEffects 的输出,形状同设备动效计划)。
 let markerEffectsPlan = null
-// 票 85:跨图层合计降级状态(三类对象合计计数;两张计划共用同一个阈值与收敛系数)。
+// 票 85:跨图层合计降级状态;摘要、关联设备和设施共用阈值与收敛系数。
 let combinedEffectState = { enabled: false, limit: 30, abnormalCount: 0, animatedCount: 0, degraded: false, converge: null }
 let markerRendered = 0
 let markerSkipped = 0
@@ -229,8 +229,11 @@ let markerSkipped = 0
 let deviceRows = []
 
 // 票 68:设施点(水厂/泵站/水源地/用水户)的最近一次计划行。与摘要标记/关联设备**分开**持有:
-// 设施点不进摘要序列、不进设备图例,也不参与设备的动效/重叠判定。
+// 设施点不进摘要序列、不进设备图例,也不参与设备的重叠判定;但状态特效与设备**同一套**
+// (维护者 2026-11-06:预警/报警/工单的特效设施点也要有,视觉与设备一致)。
 let facilityMarkerRows = []
+// 设施点动效计划:与设备动效**同一份** `planDeviceEffects()` 输出(开关/降级/工单不计异常同一条纪律)。
+let facilityEffectsPlan = null
 let selectedFacilityKey = ''
 let facilitySelectionIdentity = null
 
@@ -251,6 +254,8 @@ const overlayFrameCache = {
     rect: null, rectKey: '', rectAt: 0,
     // 票 84:关联设备层的门控查询(与摘要标记同一条门控;三类对象都按自身状态挂环)。
     deviceEffectOf: () => '',
+    // 维护者 2026-11-06:设施点与设备同一套状态特效,门控查询按设施点 key 建表。
+    facilityEffectOf: () => '',
     // 票 85:跨图层合计门控与收敛系数(降级时环仍画,只是更小更淡)。
     combinedRef: null, converge: null,
     // 静态帧闸门:镜头位姿/宿主矩形/输入计划都没变时,66ms 内的重复帧直接跳过。
@@ -281,7 +286,7 @@ function overlayComponentPlanMarkerCached(marker) {
  * 后者把「非监测点」强转成监测设备,设施点会丢设施类型图标并被降成设备档。
  */
 function overlayFacilityPlanMarkerCached(marker) {
-    const key = `${marker.type || ''}|${marker.status || ''}|${marker.tone || ''}`
+    const key = `${marker.type || ''}|${marker.status ?? ''}|${marker.tone || ''}`
     let plan = overlayFacilityPlanCache.get(key)
     if (!plan) {
         plan = planMarker({ kind: 'facility-point', status: marker.status, facilityType: marker.type, tone: marker.tone })
@@ -1289,8 +1294,13 @@ async function bootEngine(record) {
                     const deviceAnimated = gated && deviceEffectsPlan
                         ? new Map((deviceEffectsPlan.animated || []).map((row) => [row.code, row.effect]))
                         : new Map()
+                    // 维护者 2026-11-06:设施点动效键也按同一条门控建表(行键=设施点 key)。
+                    const facilityAnimated = gated && facilityEffectsPlan
+                        ? new Map((facilityEffectsPlan.animated || []).map((row) => [row.code, row.effect]))
+                        : new Map()
                     overlayFrameCache.effectOf = (marker) => markerAnimated.get(`${marker.kind}|${marker.code}`) || ''
                     overlayFrameCache.deviceEffectOf = (device) => deviceAnimated.get(device.code) || ''
+                    overlayFrameCache.facilityEffectOf = (facility) => facilityAnimated.get(facility.key || `facility-point|${facility.code}`) || ''
                     overlayFrameCache.converge = combinedEffectState.converge
                 }
                 return {
@@ -1331,6 +1341,7 @@ async function bootEngine(record) {
                     // 降级时键仍在,只把收敛系数下传,环不消失)。
                     effectOf: overlayFrameCache.effectOf,
                     deviceEffectOf: overlayFrameCache.deviceEffectOf,
+                    facilityEffectOf: overlayFrameCache.facilityEffectOf,
                     converge: overlayFrameCache.converge,
                     planOf: overlayPlanMarkerCached,
                     // 贴地定位与 billboard `CLAMP_TO_GROUND` 同口径(票 47 维护者反馈①根因):
@@ -1443,6 +1454,9 @@ export function unmountMap() {
     pendingPipeLayerPlan = null
     lastPipeLayerRefs = { pipes: null, components: null, viewMode: null, viewer: null, overlayActive: null }
     facilityMarkerRows = []
+    // 设施点动效计划随行清空(否则卸载后仍按旧行放行/计数)。
+    facilityEffectsPlan = null
+    refreshCombinedEffectState()
     selectedFacilityKey = ''
     facilitySelectionIdentity = null
     pipeComponentRows = []
@@ -3125,19 +3139,23 @@ function flushSceneApply() {
  * @returns {boolean} 是否已画到地图上
  */
 /**
- * 票 85:刷新跨图层合计降级状态。两处调用(摘要标记计划/关联设备计划变化)——
- * 阈值按三类对象合计判定,收敛系数共享同一份,避免两张计划一个降级一个不降。
+ * 票 85:刷新摘要标记、关联设备、设施点的合计状态。
+ * 所有参与图层共享阈值与收敛系数,计划更新或清层后立即重算。
  */
 function refreshCombinedEffectState() {
-    combinedEffectState = combineEffectPlans([markerEffectsPlan, deviceEffectsPlan], resolveDeviceEffectsOptions())
+    combinedEffectState = combineEffectPlans([markerEffectsPlan, deviceEffectsPlan, facilityEffectsPlan], resolveDeviceEffectsOptions())
+    paintMarkerEffects()
+    paintDeviceEffects()
     return combinedEffectState
 }
 
 /**
  * 票 85:给设备图层计划提供「另外两类对象」的异常数,让 store 侧的降级判定与视觉门控同源。
+ * 维护者 2026-11-06:设施点报警也计在同屏异常里(设施特效与设备同一套门控,不能各算各的)。
  */
 export function getEffectsAbnormalBase() {
-    return abnormalObjectCount(markerPlan.markers || [])
+    const facilityAbnormal = facilityEffectsPlan ? facilityEffectsPlan.ownAbnormalCount : 0
+    return abnormalObjectCount(markerPlan.markers || []) + facilityAbnormal
 }
 
 export function syncPointMarkersForPlan(plan, legendSource) {
@@ -3307,8 +3325,8 @@ export async function syncPipeLayerForPlan(plan) {
  * 同步**设施点图层**(票 68)。
  *
  * 与摘要标记/关联设备分开是结构性的:设施点不是监测点、也不是设备,不进摘要序列、
- * 不进设备图例与动效/重叠判定。渲染由 DOM overlay 的设施点循环承担;本函数只持有本轮
- * 计划行并写宿主读数(计数口径与图例的「可上图 N」同源,都是本轮可画的行数)。
+ * 不进设备图例或重叠判定;状态特效共享设备的门控与参数。DOM overlay 负责渲染,
+ * 本函数持有本轮计划并发布计数(与设施图例的「可上图 N」口径一致)。
  *
  * `rows` 已由 store 按设施类型 + 全局行业/片区筛好,计划层只按一级模式显隐,这里不再过滤。
  *
@@ -3317,6 +3335,15 @@ export async function syncPipeLayerForPlan(plan) {
  */
 export function syncFacilityMarkersForPlan(rows) {
     facilityMarkerRows = Array.isArray(rows) ? rows : []
+    // 状态归一来自设施合成计划;复用设备的开关、异常计数与降级,不拿色键猜状态。
+    facilityEffectsPlan = planDeviceEffects({
+        devices: facilityMarkerRows.filter(Boolean).map((row) => ({
+            code: row.key || (row.code ? `facility-point|${row.code}` : ''),
+            status: overlayFacilityPlanMarkerCached(row).effectStatus,
+        })),
+        ...resolveDeviceEffectsOptions(),
+    })
+    refreshCombinedEffectState()
     // watcher 可能早于合并后的 marker 同步;以新行重算可见选择,避免保留已筛掉的高亮。
     setFacilitySelection(facilitySelectionIdentity)
     const host = instance && isDomHost(instance.host) ? instance.host : null
@@ -3328,6 +3355,10 @@ export function syncFacilityMarkersForPlan(rows) {
             byType[row.type] = (byType[row.type] || 0) + 1
         }
         host.dataset.mapFacilityTypes = Object.entries(byType).map(([type, count]) => `${type}:${count}`).join(',')
+        // 特效读数面(走查直接读,不必翻 DOM):本轮放行的设施点动效条数 + 计入异常的条数。
+        const facilityEffects = facilityEffectsPlan || { animated: [], ownAbnormalCount: 0 }
+        host.dataset.mapFacilityEffectsAnimated = String((facilityEffects.animated || []).length)
+        host.dataset.mapFacilityEffectsAbnormal = String(facilityEffects.ownAbnormalCount || 0)
     }
     return facilityMarkerRows.length
 }
@@ -3701,7 +3732,7 @@ export function clearAssociatedDevices() {
     hoveredDeviceCode = ''
     // 票 47:动效随图层一起消失(计划清空 + 引擎侧已在 clearAssociatedDevices 里停循环)。
     deviceEffectsPlan = null
-    paintDeviceEffects()
+    refreshCombinedEffectState()
     paintDeviceReactions()
     paintDeviceState()
 }
@@ -4409,7 +4440,14 @@ function paintMarkerState() {
     host.dataset.mapMarkersPlan = String(markerPlan.total)
     host.dataset.mapMarkersStatus = keys.map((key) => `${key}:${markerPlan.byStatus[key]}`).join(',')
     host.dataset.mapMarkerShapes = [...new Set(markerPlan.markers.map((item) => item.shape))].sort().join(',')
-    // 票 45/85:摘要标记动效读数(与设备动效读数同构,且按**跨图层合计**状态写)。
+    paintMarkerEffects()
+}
+
+/** 合计动效变化就刷新,不必等点位实体重画。 */
+function paintMarkerEffects() {
+    if (!instance || !isDomHost(instance.host)) return
+    const host = instance.host
+    // 摘要、设备与设施使用同一份跨图层合计读数。
     const state = combinedEffectState
     if (state.enabled !== true) host.dataset.mapMarkerEffects = 'off'
     else if (state.degraded) host.dataset.mapMarkerEffects = 'degraded'

+ 23 - 7
src/modules/cockpit/services/markerOverlay.js

@@ -239,6 +239,8 @@ let frameEffectOf = () => ''
 let frameConverge = null
 /** 关联设备层的门控查询(票 84:三类对象的环统一走同一条门控)。 */
 let frameDeviceEffectOf = () => ''
+/** 设施点层的门控查询(维护者 2026-11-06:设施点挂与设备同一套状态特效)。 */
+let frameFacilityEffectOf = () => ''
 
 /** 状态 → 特效 profile(domain 单源;DOM 层数/亮度/辉光只消费这一份表,不另造)。 */
 function markerFxProfile(status) {
@@ -247,7 +249,7 @@ function markerFxProfile(status) {
 
 /**
  * 标记动效查询(票 70 重做):**双条件**才出特效——
- * ① 门控放行(`effectOf`:开且未降级,判定在 domain,渲染层不自行发明);
+ * ① 门控放行(`effectOf`:开关判定在 domain;超限仍挂环,只下传收敛系数);
  * ② 状态有 profile 且 profile 的动效与门控放行的动效一致(防串键)。
  * @returns {{effect:string, profile:object}|null}
  */
@@ -775,7 +777,9 @@ export function isMarkerOverlayVisible() {
  * @param {object} params.tones token 色表(tone 键 → CSS 颜色)
  * @param {Function} params.planOf marker → plan(markerPlan.planMarker 的包装)
  * @param {Function} [params.effectOf] marker → 门控后的动效键('' = 不动画;mapService 从
- *   domain `planDeviceEffects` 计划产出,**只给摘要标记行**——关联设备的特效仍在 billboard 层)
+ *   domain `planDeviceEffects` 计划产出,只给摘要标记行;关联设备使用 `deviceEffectOf`)
+ * @param {Function} [params.facilityEffectOf] 设施点行 → 门控后的动效键(维护者 2026-11-06:
+ *   设施点特效与设备同源,同样由 domain 计划判定开关/降级,渲染层不自行发明)
  * @param {Array} [params.aggregates] 重叠聚合符号行(票 76 / C1:`projectOverlapSymbols()` 产出;
  *   group 身份/计数/组内状态词/形状/色键/屏幕坐标 —— 唯一没有设备身份的一类节点)
  * @param {Function} params.projectToWindow (lon, lat) → 宿主相对窗口坐标(贴地口径由调用方保证)
@@ -871,11 +875,12 @@ export function markerLabelState(hovered) {
     return { visible: true, expanded: hovered === true }
 }
 
-export function syncMarkerOverlay({ markers, devices, aggregates, facilities, selectedFacilityKey, pipeComponents, pipeComponentsVisible, pointScales, tones, planOf, facilityPlanOf, componentPlanOf, effectOf, deviceEffectOf, converge, selectedCode, projectToWindow }) {
+export function syncMarkerOverlay({ markers, devices, aggregates, facilities, selectedFacilityKey, pipeComponents, pipeComponentsVisible, pointScales, tones, planOf, facilityPlanOf, componentPlanOf, effectOf, deviceEffectOf, facilityEffectOf, converge, selectedCode, projectToWindow }) {
     if (!hostNode || !hostNode.isConnected || typeof projectToWindow !== 'function') return
     // 每帧刷新门控查询(mapService 在计划变化时给新闭包;这里只搬运、不判定)。
     frameEffectOf = typeof effectOf === 'function' ? effectOf : () => ''
     frameDeviceEffectOf = typeof deviceEffectOf === 'function' ? deviceEffectOf : () => ''
+    frameFacilityEffectOf = typeof facilityEffectOf === 'function' ? facilityEffectOf : () => ''
     frameConverge = converge || null
     // 当前选中的对象编码(2026-10-28):**非聚合设备点**选中时要上「被选择态」同款高亮,
     // 而聚合设备的中心由引擎的 pinned 标志负责;两者语义不同,故这里另收一份。
@@ -906,7 +911,8 @@ export function syncMarkerOverlay({ markers, devices, aggregates, facilities, se
         // 计划读数指纹:状态/行业/名称/母版/内层/降饱和/色值/**动效键与层数**任一变化就重建节点。
         const fp = [marker.status, marker.industry, marker.name,
             plan.master, plan.inner.enabled, plan.inner.icon, plan.inner.diameterRatio,
-            plan.desaturate, toneHex, fx ? fx.effect : '', fx ? fx.profile.layers : 0].join(FP_SEP)
+            plan.desaturate, toneHex, fx ? fx.effect : '', fx ? fx.profile.layers : 0,
+            fx?.converge?.peak ?? 1, fx?.converge?.alpha ?? 1].join(FP_SEP)
 
         let record = pool.get(key)
         if (!record || record.fp !== fp) {
@@ -985,7 +991,8 @@ export function syncMarkerOverlay({ markers, devices, aggregates, facilities, se
         const fx = profile && profile.effect === gatedEffect ? { effect: gatedEffect, profile, converge: frameConverge } : null
         const fp = [marker.status, marker.industry, marker.name,
             plan.master, plan.inner.enabled, plan.inner.icon, plan.inner.diameterRatio,
-            plan.desaturate, toneHex, fx ? fx.effect : '', fx ? fx.profile.layers : 0].join(FP_SEP)
+            plan.desaturate, toneHex, fx ? fx.effect : '', fx ? fx.profile.layers : 0,
+            fx?.converge?.peak ?? 1, fx?.converge?.alpha ?? 1].join(FP_SEP)
 
         let record = pool.get(key)
         if (!record || record.fp !== fp) {
@@ -1108,14 +1115,23 @@ export function syncMarkerOverlay({ markers, devices, aggregates, facilities, se
         const marker = { ...facility, kind: 'facility-point' }
         const plan = facilityPlanOf(marker)
         const toneHex = tones[plan.tint] || tones.neutral
+        // 设施与设备同样校验「状态 profile + 门控键」,不能从门控键反推状态自证。
+        // 降级只收敛,不撤销环;系数由跨图层计划提供。
+        const gatedEffect = frameFacilityEffectOf(facility)
+        const facilityProfile = gatedEffect ? effectProfileFor(plan.effectStatus) : null
+        const fx = facilityProfile && facilityProfile.effect === gatedEffect
+            ? { effect: gatedEffect, profile: facilityProfile, converge: frameConverge }
+            : null
         // 指纹含设施类型/名称/状态与合成计划读数:任一变化就重建节点(与点位标记同一条纪律)。
         const fp = [marker.type, marker.status, marker.name, marker.clickable,
-            plan.master, plan.inner.enabled, plan.inner.icon, plan.inner.diameterRatio, toneHex].join(FP_SEP)
+            plan.master, plan.inner.enabled, plan.inner.icon, plan.inner.diameterRatio, toneHex,
+            fx ? fx.effect : '', fx ? fx.profile.layers : 0,
+            fx?.converge?.peak ?? 1, fx?.converge?.alpha ?? 1].join(FP_SEP)
 
         let record = pool.get(key)
         if (!record || record.fp !== fp) {
             destroyRecord(record)
-            record = { ...buildNode(key, marker, plan, toneHex, null), fp }
+            record = { ...buildNode(key, marker, plan, toneHex, fx), fp }
             pool.set(key, record)
             hostNode.appendChild(record.root)
             if (plan.inner.enabled) {

+ 67 - 0
verify/helpers/marker-dom.mjs

@@ -0,0 +1,67 @@
+/** overlay 的最小 DOM adapter:只验证节点/样式契约,不模拟浏览器布局或动画。 */
+export function markerTestDocument() {
+    class Element {
+        constructor(tag) {
+            this.tagName = tag
+            this.nodeType = 1
+            this.children = []
+            this.dataset = {}
+            this.attributes = {}
+            this.className = ''
+            this.listeners = new Map()
+            this.style = { setProperty(key, value) { this[key] = value } }
+            this.classList = { toggle: (name, enabled) => {
+                const names = new Set(this.className.split(' ').filter(Boolean))
+                if (enabled) names.add(name)
+                else names.delete(name)
+                this.className = [...names].join(' ')
+            } }
+        }
+        get parentElement() { return this.parentNode }
+        get isConnected() { return this.connected === true || Boolean(this.parentNode?.isConnected) }
+        appendChild(child) {
+            child.parentNode?.removeChild(child)
+            child.parentNode = this
+            this.children.push(child)
+            return child
+        }
+        removeChild(child) {
+            this.children = this.children.filter((item) => item !== child)
+            child.parentNode = null
+            return child
+        }
+        remove() { this.parentNode?.removeChild(this) }
+        set innerHTML(value) {
+            // overlay 的水滴 SVG 占位;不解析路径、不伪造像素证据。
+            this.html = value
+            this.firstChild = new Element('svg')
+            this.firstChild.inner = new Element('image')
+        }
+        setAttribute(key, value) { this.attributes[key] = value }
+        querySelector(selector) { return selector === '[data-marker-overlay-inner]' ? this.inner : null }
+        addEventListener(type, fn) {
+            const listeners = this.listeners.get(type) || []
+            this.listeners.set(type, [...listeners, fn])
+        }
+        removeEventListener(type, fn) {
+            this.listeners.set(type, (this.listeners.get(type) || []).filter((item) => item !== fn))
+        }
+        contains(node) { return node === this || this.children.some((child) => child.contains(node)) }
+        closest(selector) {
+            return this.className.split(' ').includes(selector.slice(1)) ? this : this.parentNode?.closest(selector)
+        }
+        getBoundingClientRect() { return { left: 0, top: 0, width: 1000, height: 800 } }
+        nodes() { return [this, ...this.children.flatMap((child) => child.nodes())] }
+        fire(type, target) {
+            for (const fn of this.listeners.get(type) || []) fn({ target, clientX: 100, clientY: 200, stopPropagation() {} })
+        }
+    }
+    const head = new Element('head')
+    const host = new Element('main')
+    head.connected = host.connected = true
+    return {
+        head, host,
+        createElement: (tag) => new Element(tag),
+        getElementById: (id) => head.nodes().find((node) => node.id === id) || null,
+    }
+}

+ 63 - 0
verify/t116-facility-effects.mjs

@@ -0,0 +1,63 @@
+import assert from 'node:assert/strict'
+import { test } from 'node:test'
+import { planMarker } from '../src/modules/cockpit/domain/markerPlan.js'
+import { facilityStatusTone } from '../src/modules/cockpit/domain/entityDetail.js'
+import { effectProfileFor, planDeviceEffects, combineEffectPlans } from '../src/modules/cockpit/domain/deviceEffects.js'
+import { facilityPointsWithDetails, facilityPointMarkers } from '../src/modules/cockpit/domain/facilityPoints.js'
+
+test('维护中设施使用蓝色工单状态,并复用设备旋转环参数', () => {
+    const plan = planMarker({ kind: 'facility-point', facilityType: 'WATER_PLANT', status: '1' })
+    assert.equal(plan.tint, 'lv3')
+    assert.equal(facilityStatusTone('1'), 'lv3')
+    assert.equal(plan.effectStatus, 'WORK_ORDER')
+    assert.equal(plan.inner.icon, 'facilityPlant')
+    const profile = effectProfileFor(plan.effectStatus)
+    assert.deepEqual([profile.effect, profile.layers, profile.periodMs, profile.glow], ['spin', 1, 18000, false])
+})
+
+test('报警、显式预警与正常设施按真实状态取特效,未知码不按色键猜测', () => {
+    for (const [status, tint, effectStatus, effect] of [
+        ['2', 'lv1', 'ALARM', 'pulse'], ['WARNING', 'lv2', 'WARNING', 'blink'],
+        [0, 'lv4', 'NORMAL', ''], ['OFFLINE', 'neutral', 'OFFLINE', ''],
+        [undefined, 'neutral', '', ''], ['3', 'neutral', '', ''], ['constructor', 'neutral', '', ''],
+    ]) {
+        const plan = planMarker({ kind: 'facility-point', facilityType: 'PUMP_STATION', status })
+        assert.equal(plan.tint, tint)
+        assert.equal(plan.effectStatus, effectStatus)
+        assert.equal(effectProfileFor(plan.effectStatus)?.effect || '', effect)
+    }
+    const overridden = planMarker({ kind: 'facility-point', status: '0', tone: 'lv1' })
+    assert.equal(effectProfileFor(overridden.effectStatus), null)
+})
+
+test('详情状态优先,设施原始状态和实体主键不被视觉归一覆盖', () => {
+    const rows = [{ key: 'WATER_PLANT|A', id: 'A', code: 'WP001', type: 'WATER_PLANT', status: '2' }]
+    const points = facilityPointsWithDetails(rows, {
+        'WATER_PLANT|A': { ok: true, detail: { status: '1', coordinates: { longitude: 110.38, latitude: 28.43 } } },
+    })
+    const [row] = facilityPointMarkers(points)
+    assert.equal(row.key, 'facility-point|WATER_PLANT|A')
+    assert.equal(row.status, '1')
+    assert.equal(row.tone, 'lv3')
+    assert.equal(planMarker({ kind: row.kind, facilityType: row.type, status: row.status }).effectStatus, 'WORK_ORDER')
+    assert.equal(rows[0].status, '2')
+})
+
+test('设施与设备共用开关和阈值,维修不计异常,跨图层超限只收敛', () => {
+    const devices = ['1', '2', 'WARNING', '0', 'unknown'].map((status, index) => ({
+        code: `facility-point|${index}`,
+        status: planMarker({ kind: 'facility-point', status }).effectStatus,
+    }))
+    const off = planDeviceEffects({ devices, enabled: false })
+    assert.deepEqual(off.animated, [])
+    const on = planDeviceEffects({ devices, enabled: true, abnormalLimit: 2 })
+    assert.deepEqual(on.animated.map((row) => row.effect), ['spin', 'pulse', 'blink'])
+    assert.equal(on.ownAbnormalCount, 2)
+    assert.equal(on.degraded, false)
+    const other = planDeviceEffects({ devices: [{ code: 'device-A', status: 'ALARM' }], enabled: true })
+    const combined = combineEffectPlans([on, other], { enabled: true, abnormalLimit: 2 })
+    assert.equal(combined.abnormalCount, 3)
+    assert.equal(combined.animatedCount, 4)
+    assert.equal(combined.degraded, true)
+    assert.deepEqual(combined.converge, { peak: 0.6, alpha: 0.5 })
+})

+ 128 - 0
verify/t116-facility-overlay.mjs

@@ -0,0 +1,128 @@
+import assert from 'node:assert/strict'
+import { test } from 'node:test'
+import { planMarker } from '../src/modules/cockpit/domain/markerPlan.js'
+import { mountMarkerOverlay, syncMarkerOverlay, unmountMarkerOverlay } from '../src/modules/cockpit/services/markerOverlay.js'
+import { markerTestDocument } from './helpers/marker-dom.mjs'
+
+const tones = { lv1: '#ff3b52', lv2: '#ffb020', lv3: '#01abea', lv4: '#27d17a', neutral: '#5680b0' }
+const facility = (status, id = '1') => ({
+    key: `facility-point|WATER_PLANT|${id}`, kind: 'facility-point', code: 'WP001',
+    name: '水厂', type: 'WATER_PLANT', entityId: id, detailCategory: 'WATER_PLANT', clickable: true,
+    status, longitude: 110.38, latitude: 28.43,
+})
+const planOf = (row) => planMarker({ kind: row.kind, status: row.status, facilityType: row.type, industry: row.industry, tone: row.tone })
+
+function mount(t) {
+    const previous = globalThis.document
+    const document = markerTestDocument()
+    globalThis.document = document
+    assert.equal(mountMarkerOverlay(document.host), true)
+    t.after(() => {
+        unmountMarkerOverlay()
+        if (previous === undefined) delete globalThis.document
+        else globalThis.document = previous
+    })
+    return {
+        document,
+        sync: (rows, effectOf, extra = {}) => syncMarkerOverlay({
+            facilities: rows, facilityPlanOf: planOf, planOf, tones,
+            facilityEffectOf: effectOf, projectToWindow: () => ({ x: 100, y: 200 }), ...extra,
+        }),
+        node: (row) => document.host.nodes().find((node) => node.dataset.markerOverlayKey === row.key),
+    }
+}
+
+test('正常设施不能被错误或过期的门控键挂上报警环', (t) => {
+    const scene = mount(t)
+    const row = { ...facility('0'), tone: 'lv1' }
+    scene.sync([row], () => 'pulse')
+    assert.equal(scene.node(row).dataset.markerRing, 'no')
+    assert.equal(scene.node(row).dataset.markerEffect, undefined)
+})
+
+test('同一设施在超限降级与恢复时更新已有环的大小和透明度', (t) => {
+    const scene = mount(t)
+    const row = facility('2')
+    scene.sync([row], () => 'pulse')
+    assert.equal(scene.node(row).dataset.markerEffectConverged, 'no')
+    scene.sync([row], () => 'pulse', { converge: { peak: 0.6, alpha: 0.5 } })
+    const degraded = scene.node(row)
+    assert.equal(degraded.dataset.markerEffectConverged, 'yes')
+    assert.equal(degraded.dataset.markerEffectAlpha, '0.5')
+    const ringHost = degraded.children.find((node) => node.className.includes('marker-overlay-fx--'))
+    assert.equal(ringHost.style.opacity, '0.5')
+    assert.equal(ringHost.style.transform, 'scale(0.6)')
+    scene.sync([row], () => 'pulse', { converge: null })
+    assert.equal(scene.node(row).dataset.markerEffectConverged, 'no')
+    assert.equal(scene.node(row).dataset.markerEffectAlpha, '1')
+})
+
+test('设施报警、预警、维修与同态设备的实际环节点和动画配置一致', (t) => {
+    const scene = mount(t)
+    for (const [status, deviceStatus, effect, layers, period, color, glow] of [
+        ['1', 'WORK_ORDER', 'spin', 1, '18000ms', '#01abea', 0],
+        ['2', 'ALARM', 'pulse', 4, '1800ms', '#ff3b52', 1],
+        ['WARNING', 'WARNING', 'blink', 3, '1400ms', '#ffb020', 0],
+    ]) {
+        const row = facility(status)
+        scene.sync([row], () => effect, {
+            devices: [{ code: 'd1', status: deviceStatus, industry: 'WATER_SUPPLY', x: 100, y: 200 }],
+            deviceEffectOf: () => effect,
+        })
+        const node = scene.node(row)
+        const device = scene.document.host.nodes().find((item) => item.dataset.markerOverlayKey === 'device|d1')
+        assert.equal(node.dataset.markerTone, color)
+        for (const key of ['markerEffect', 'markerEffectLayers', 'markerEffectAlpha', 'markerEffectConverged', 'markerDrawWidth']) {
+            assert.equal(node.dataset[key], device.dataset[key])
+        }
+        const rings = node.children[0].children.filter((item) => item.className === 'marker-overlay-fx-ring')
+        assert.equal(rings.length, layers)
+        assert.equal(rings[0].style.animationDuration, period)
+        assert.equal(rings[0].style.animationName, effect === 'spin' ? 'markerFxSpin' : `markerFxRing--${effect}--0`)
+        assert.equal(node.children[0].children.filter((item) => item.className === 'marker-overlay-fx-glow').length, glow)
+    }
+})
+
+test('特效开关与设施状态切换不遗留旧环,编码重复的设施按主键独立更新', (t) => {
+    const scene = mount(t)
+    const left = facility('1', 'A')
+    const right = facility('2', 'B')
+    const effects = (row) => row.key === left.key ? 'spin' : 'pulse'
+    scene.sync([left, right], effects)
+    assert.equal(scene.node(left).dataset.markerEffect, 'spin')
+    assert.equal(scene.node(right).dataset.markerEffect, 'pulse')
+    scene.sync([left, right], () => '')
+    assert.equal(scene.node(left).dataset.markerRing, 'no')
+    assert.equal(scene.node(right).dataset.markerEffect, undefined)
+    scene.sync([left, right], effects)
+    assert.equal(scene.node(left).dataset.markerEffect, 'spin')
+    scene.sync([{ ...left, status: '0' }, right], effects)
+    assert.equal(scene.node(left).dataset.markerRing, 'no')
+    assert.equal(scene.node(right).dataset.markerRing, 'yes')
+    scene.sync([], effects)
+    assert.equal(scene.node(left).style.display, 'none')
+    assert.equal(scene.node(right).style.display, 'none')
+})
+
+test('跨图层降级与恢复同时更新设备和监测点已有节点', (t) => {
+    const scene = mount(t)
+    const extra = {
+        devices: [{ code: 'd2', status: 'ALARM', x: 100, y: 200 }],
+        deviceEffectOf: () => 'pulse',
+        markers: [{ kind: 'monitoring-point', code: 'MP1', status: 'ALARM', tone: 'lv1', longitude: 110.38, latitude: 28.43 }],
+        effectOf: () => 'pulse',
+    }
+    scene.sync([], () => '', extra)
+    scene.sync([], () => '', { ...extra, converge: { peak: 0.6, alpha: 0.5 } })
+    for (const key of ['device|d2', 'monitoring-point|MP1']) {
+        const node = scene.document.host.nodes().find((item) => item.dataset.markerOverlayKey === key)
+        assert.equal(node.dataset.markerEffectConverged, 'yes')
+        assert.equal(node.dataset.markerEffectAlpha, '0.5')
+    }
+    scene.sync([], () => '', extra)
+    for (const key of ['device|d2', 'monitoring-point|MP1']) {
+        const node = scene.document.host.nodes().find((item) => item.dataset.markerOverlayKey === key)
+        assert.equal(node.dataset.markerEffectConverged, 'no')
+        assert.equal(node.dataset.markerEffectAlpha, '1')
+    }
+})

+ 77 - 0
verify/t116-facility-service.mjs

@@ -0,0 +1,77 @@
+import assert from 'node:assert/strict'
+import { test } from 'node:test'
+import { __setInstanceForTest, syncFacilityMarkersForPlan, syncPointMarkersForPlan, syncAssociatedDevices, clearAssociatedDevices, getEffectsAbnormalBase, unmountMap } from '../src/modules/cockpit/services/mapService.js'
+import { planDeviceEffects } from '../src/modules/cockpit/domain/deviceEffects.js'
+import { loadCockpitConfig, resetCockpitConfigForTest } from '../src/modules/cockpit/services/cockpitConfig.js'
+import { markerTestDocument } from './helpers/marker-dom.mjs'
+
+test('设施数量跨过降级阈值时,所有图层的宿主读数立即同步并能恢复', (t) => {
+    const document = markerTestDocument()
+    __setInstanceForTest({ host: document.host })
+    t.after(() => { __setInstanceForTest(null); unmountMap() })
+    syncPointMarkersForPlan({ markers: [] })
+    const rows = Array.from({ length: 31 }, (_, index) => ({
+        key: `facility-point|WATER_PLANT|${index}`, code: 'WP001', type: 'WATER_PLANT', status: '2',
+    }))
+    syncFacilityMarkersForPlan(rows)
+    assert.equal(getEffectsAbnormalBase(), 31)
+    assert.equal(document.host.dataset.mapFacilityEffectsAnimated, '31')
+    assert.equal(document.host.dataset.mapMarkerEffects, 'degraded')
+    assert.equal(document.host.dataset.mapDeviceEffects, 'degraded')
+    assert.equal(document.host.dataset.mapMarkerEffectsAbnormal, '31')
+    syncFacilityMarkersForPlan([])
+    assert.equal(getEffectsAbnormalBase(), 0)
+    assert.equal(document.host.dataset.mapMarkerEffects, 'on')
+    assert.equal(document.host.dataset.mapDeviceEffectsAnimated, '0')
+})
+
+test('移除关联设备后,剩余设施立即退出跨图层降级', async (t) => {
+    resetCockpitConfigForTest()
+    await loadCockpitConfig({ scope: { __COCKPIT_CONFIG__: { map: { effects: { enabled: true, abnormalLimit: 1 } } } }, env: {} })
+    const document = markerTestDocument()
+    __setInstanceForTest({
+        host: document.host, viewer: {},
+        engine: {
+            syncAssociatedDevices: () => ({ rendered: 1, skipped: 0 }),
+            applyDeviceEffects: () => true,
+            clearAssociatedDevices() {},
+        },
+    })
+    t.after(() => {
+        __setInstanceForTest(null)
+        clearAssociatedDevices()
+        unmountMap()
+        resetCockpitConfigForTest()
+    })
+    syncFacilityMarkersForPlan([{ key: 'facility-point|PUMP_STATION|P1', type: 'PUMP_STATION', status: '2' }])
+    const devices = [{ code: 'D1', status: 'ALARM', assetMode: 'shape' }]
+    syncAssociatedDevices(devices, { effects: planDeviceEffects({ devices, enabled: true, abnormalLimit: 1 }), overlapGroups: [] })
+    assert.equal(document.host.dataset.mapDeviceEffectsAbnormal, '2')
+    assert.equal(document.host.dataset.mapDeviceEffects, 'degraded')
+    clearAssociatedDevices()
+    assert.equal(document.host.dataset.mapDeviceEffectsAbnormal, '1')
+    assert.equal(document.host.dataset.mapMarkerEffects, 'on')
+})
+
+test('设施门控只读自身状态,重复业务码按实体键计数,开关可关闭再开启', async (t) => {
+    const document = markerTestDocument()
+    __setInstanceForTest({ host: document.host })
+    t.after(() => { __setInstanceForTest(null); unmountMap(); resetCockpitConfigForTest() })
+    const rows = Object.freeze([
+        Object.freeze({ key: 'facility-point|WATER_PLANT|A', code: 'WP001', type: 'WATER_PLANT', status: '1', tone: 'lv2' }),
+        Object.freeze({ key: 'facility-point|WATER_PLANT|B', code: 'WP001', type: 'WATER_PLANT', status: '2' }),
+        Object.freeze({ key: 'facility-point|PUMP_STATION|C', code: '', type: 'PUMP_STATION', status: '1' }),
+        Object.freeze({ key: 'facility-point|WATER_PLANT|D', code: '', type: 'WATER_PLANT', status: 'unknown', tone: 'lv1' }),
+    ])
+    for (const enabled of [false, true, false]) {
+        resetCockpitConfigForTest()
+        await loadCockpitConfig({ scope: { __COCKPIT_CONFIG__: { map: { effects: { enabled } } } }, env: {} })
+        syncFacilityMarkersForPlan(rows)
+        assert.equal(document.host.dataset.mapFacilityEffectsAnimated, enabled ? '3' : '0')
+        assert.equal(document.host.dataset.mapMarkerEffects, enabled ? 'on' : 'off')
+        assert.equal(document.host.dataset.mapFacilityEffectsAbnormal, '1')
+        assert.equal(getEffectsAbnormalBase(), 1)
+    }
+    assert.equal(rows[0].status, '1')
+    assert.equal(rows[0].tone, 'lv2')
+})

+ 1 - 1
verify/t65-facility-detail-labels.mjs

@@ -54,7 +54,7 @@ const plant = normalizeEntityDetail({ kind: 'facility', id: '1', category: 'WATE
     attributes: { designCapacity: 10, currentCapacity: 8.5 },
 })
 assert.equal(plant.statusText, '维护中')
-assert.equal(plant.tone, 'lv2')
+assert.equal(plant.tone, 'lv3')
 assert.equal(fieldOf(plant, '设计能力').value, '10 万m³/d')
 assert.equal(fieldOf(plant, '当前能力').value, '8.5 万m³/d')