Explorar el Código

feat(cockpit/t76): 聚合设备点四态交互收口 + DOM 覆盖层取代 billboard 视觉

聚合设备点四态(维护者 2026-10-26 规格):
- ① 初始:聚合点 + 聚合标签常显,hover 展开预览环
- ② 被选择:点聚合点 → 状态色发光、环固定、标签隐藏
- ③ 设备常态:点环内设备 → 中心换成设备点,环不固定(仅 hover 预览),保留设备自身特效/图标/标签
- ④ 设备被选择:再点中心设备 → 与 ② 同(发光 + 固定环);再点回 ③,④ 选其它设备进入那台的 ③

关键修复:
- 状态机与渲染解耦:applyOverlapPresentation 先写状态再渲染(引擎未就绪不再丢状态)
- 数据同步不再清空聚合态(syncAssociatedDevices 只丢弃失效组键)——「设备点进不去选择态/点不动」的总根因
- pinned 改为调用方显式传参,hover 预览恒不固定;hover 离开只撤预览(endOverlapPreview)
- 选择态发光用状态色(--marker-tone,写在 cssText 之后),修掉白光与变量被覆盖
- 中心设备 profile 走 effectProfileFor(status):DEVICE_EFFECT 是键名表,此前恒 undefined → 特效不出
- 中心设备 z-index 高于聚合节点、聚合 replaced 时让出事件 —— 修「点不到中心设备」
- 点设备走完整派发链(dispatchPickResult → store → 镜头/详情),不再短路
- 面板让位三档(固定 84/预览 60/设备常态 64),并把让位量并入锚定缓存键 —— 修「只在 hover 时让位」
- 聚合图标内由母版绘制聚合符号(三层堆叠),不再用废弃形状表(三角形)

双区合并(维护者裁定:一台设备只出现一次):
- PointDetailCard 删除「关联设备图层」列表(-143 行),保留 总数/已上图/聚合呈现 提醒
- 新增「待确认占位卡」:快照有、详情未回的编码仍可见
- 清理随之失效的 pagedLayerRows/pagedUnmatchedRows/页码键

配套:
- mock:新增 aggregate-lab 干净聚合样例(EQ-AG-*,快照/详情/坐标同源),删除过期 EQ-DG 样例;
  场景模式快照页大小对齐正式口径(pageSize 200),修「要点几次/刷新才出现」
- dev 桥:?dataSource=mock 覆盖落到全局配置(transport 读 getCockpitConfig),此前只覆盖启动计划无效

说明:本次提交同时收纳工作区中已自洽的 billboard 层清理(cesiumEngine/mapService 相关删除与
对应调用方改动),该部分为并行任务产物、已通过本票全套闸门。
ChenJZ hace 10 horas
padre
commit
6072381fc1

+ 73 - 24
src/modules/cockpit/CockpitShell.vue

@@ -326,17 +326,28 @@
                     </div>
                 </div>
             </div>
+            <!--
+                    票 61:这个按钮刷的是**统一刷新**(地图摘要 + 当前模式的面板分区),
+                    与新鲜度轮询共用 `requestRefresh()` 同一个入口。断言面:
+                    data-refresh-state(idle/running/ok/partial/failed)、
+                    data-refresh-failed(上一轮失败的面板 key,空 = 全成)、aria-busy。
+                -->
             <button
                 type="button"
                 class="cockpit-shell__chip cockpit-shell__refresh-action"
+                :class="{ 'is-refreshing': refreshStore.refreshing }"
                 data-map-summary-action
                 data-map-refresh
-                :disabled="mapStore.busy"
-                aria-label="手动刷新快照"
-                title="换到后端最新快照,并自动从第 1 页加载到完成"
-                @click="refreshMapSnapshot"
+                :data-refresh-state="refreshStore.state"
+                :data-refresh-failed="refreshStore.failedKeys"
+                :disabled="refreshStore.refreshing || mapStore.busy"
+                :aria-busy="refreshStore.refreshing"
+                :aria-label="refreshStore.text"
+                :title="refreshStore.text"
+                @click="requestRefresh()"
             >
                 <span aria-hidden="true" class="cockpit-shell__refresh-glyph">↻</span>
+                <span v-if="refreshStore.refreshing" class="cockpit-shell__refresh-progress" data-refresh-progress>刷新中</span>
             </button>
         </div>
 
@@ -583,6 +594,7 @@ import { useEventStore } from './stores/eventStore.js'
 import { useOverviewStore } from './stores/overviewStore.js'
 import { useRuntimeStore } from './stores/runtimeStore.js'
 import { useMapStore } from './stores/mapStore.js'
+import { useRefreshStore } from './stores/refreshStore.js'
 import { MAP_OBJECT_KIND, MAP_SUMMARY_PHASE } from './domain/mapSummary.js'
 import { getCockpitConfigState, loadCockpitConfig, withUrlDataSourceOverride } from './services/cockpitConfig.js'
 import {
@@ -1458,21 +1470,43 @@ const statusSummaryTone = computed(() => {
 })
 
 /**
- * 票 14:手动刷新(状态栏最右侧的刷新动作,决策 02 的 Q25)=换新快照并**自动从第 1 页加载到完成**
- * (旧「加载下一页 / 刷新快照」两个按钮已移除;自动链归 mapStore 的装载链)。
+ * 票 61:统一刷新 —— 一次动作同时刷新**地图摘要**与**当前一级模式的全部分区**。
+ *
+ * 旧版这里有两套:按钮走 `refreshMapSnapshot()`(只换地图快照),新鲜度轮询自己再走一遍
+ * 「换快照 + 刷面板」。同一个「刷新」两种做法,用户点按钮时面板不跟着动(票 61 的动机)。
+ * 现在按钮与轮询都只调 `requestRefresh()`;编排(并发、单飞、逐项成败)在
+ * `stores/refreshStore.js`,判据与文案在 `domain/refreshRun.js` —— 本文件只递接缝、绑入口。
  */
-async function refreshMapSnapshot() {
-    await mapStore.refresh()
-    // 地图摘要分区状态跟着装载状态走(票 05 的分区板据此变色)。
-    detailStore.syncMapSnapshotSection()
+const refreshStore = useRefreshStore()
+refreshStore.configure({
+    // 地图摘要:换新快照(mapStore 的装载链自动连续分批到完成),再把装载状态同步成
+    // 「地图摘要」分区状态(票 05 的分区板据此变色)。
+    refreshMap: async () => {
+        const state = await mapStore.refresh()
+        detailStore.syncMapSnapshotSection()
+        return state
+    },
+    // 面板:取完当前模式的全部分区,回一个「panelId → 分区」查表函数供判据用
+    // (判据在 domain/refreshRun.js,外壳不自己数哪块坏)。
+    refreshPanels: async () => {
+        await refreshModePanels()
+        return (panelId) => sectionOf(panelId)
+    },
+    // 票 61 验收第 4 条:本轮成败记进 freshnessService 那一本账(不另造第二份"最近刷新时间")。
+    recordFreshness: (ok, reason) => runtimeStore.recordFreshness(ok, reason),
+})
+
+/** 按钮与轮询共用的**唯一**刷新入口(票 61 验收第 2 条:刷新动作只有一处入口)。 */
+function requestRefresh(options = {}) {
+    return refreshStore.requestRefresh({ mode: mode.value, ...options })
 }
 
 /**
- * 票 14(规格 09 的 tick 全套):新鲜度周期的面板半边 —— 刷新**当前一级模式**的全部面板。
- * 面板取数失败不抛(各分区自己收成 UNAVAILABLE 并如实标注),地图刷新失败才是
- * tick 记账的失败来源;两者都不做无限重试(决策 04:恢复靠周期与手动入口)。
+ * 当前一级模式的面板取数(票 14 的面板半边)。
+ * 面板取数失败不抛(各分区自己收成 FAILED/UNAVAILABLE 并如实标注),
+ * 所以一块面板失败不会让本轮刷新整体失败(票 61 验收第 1 条)。
  */
-async function refreshCurrentModePanels() {
+async function refreshModePanels() {
     const current = mode.value
     if (current === 'monitoring') {
         await monitoringStore.loadPanels({
@@ -1863,16 +1897,12 @@ onMounted(() => {
                 const failForever = typeof window !== 'undefined' && window.__COCKPIT_FRESHNESS_FAIL__ === true
                 runtimeStore.startFreshness({
                     refreshSeconds: result.config.runtime ? result.config.runtime.refreshSeconds : undefined,
-                    tick: async () => {
-                        if (failForever) return { ok: false, reason: '演示:取数持续失败' }
-                        try {
-                            await mapStore.refresh()
-                            await refreshCurrentModePanels()
-                            return { ok: true }
-                        } catch (error) {
-                            return { ok: false, reason: String((error && error.message) || error) }
-                        }
-                    },
+                    // 票 61:轮询与按钮共用同一个刷新入口(不再有第二套「换快照 + 刷面板」)。
+                    // 本轮记账仍归 freshnessService 的周期(同一本账、同一判据),所以传 account:false
+                    // 避免同一轮记两次;取数成败在 refreshStore.lastRun 里如实留痕。
+                    tick: () => (failForever
+                        ? { ok: false, reason: '演示:取数持续失败' }
+                        : requestRefresh({ account: false })),
                 })
             }
         }
@@ -2634,6 +2664,25 @@ const mapDataSourceCredits = computed(() => getDataSourceCredits())
     line-height: 1;
 }
 
+/* 票 61:统一刷新进行中 —— 图标转动 + 一行「刷新中」小字(可见进度),
+   按钮同时 :disabled + aria-busy(走查断言面 data-refresh-state)。 */
+.cockpit-shell__refresh-action.is-refreshing .cockpit-shell__refresh-glyph {
+    animation: refresh-action-spin 0.9s linear infinite;
+}
+
+.cockpit-shell__refresh-progress {
+    font-size: var(--fs-meta, 9.5px);
+    line-height: 1;
+    color: var(--cyan, #01abea);
+    white-space: nowrap;
+}
+
+@keyframes refresh-action-spin {
+    to {
+        transform: rotate(360deg);
+    }
+}
+
 /* 状态点:加载中呼吸、完成转绿、失败/过期转红 —— 颜色语义与小胶囊共存。 */
 .cockpit-shell__map-summary-dot {
     flex: none;

+ 44 - 143
src/modules/cockpit/components/PointDetailCard.vue

@@ -128,7 +128,31 @@
                         </span>
                         <span class="point-detail__card-reason">{{ card.reason || '暂无原因' }}</span>
                     </button>
-                    <p v-if="!cards.length" class="point-detail__hint" data-device-cards-empty>
+                    <!--
+                        票 76(双区合并,维护者裁定 2026-10-26):**待确认占位卡**。
+                        旧「关联设备图层」清单承载的语义是「快照里有、详情还没回来 → 以编码占位并标待确认」;
+                        合并后一台设备只出现一次,但这条可见性必须保留 —— 占位卡只列「快照有、详情未回」的编码。
+                    -->
+                    <button
+                        v-for="(row, index) in pendingDeviceCodes"
+                        :key="`pending|${row.code}`"
+                        type="button"
+                        class="point-detail__card is-pending"
+                        :data-device-card="pagedCards.total + index"
+                        :data-device-code="row.code"
+                        data-device-pending="yes"
+                        disabled
+                    >
+                        <span class="point-detail__card-top">
+                            <b class="point-detail__card-name">{{ row.code }}</b>
+                            <span class="point-detail__card-pending">详情待确认</span>
+                        </span>
+                        <span class="point-detail__card-meta">
+                            <span class="point-detail__mono">{{ row.code }}</span>
+                        </span>
+                        <span class="point-detail__card-reason">该设备在当前地图快照中,详情尚未返回</span>
+                    </button>
+                    <p v-if="!cards.length && !pendingDeviceCodes.length" class="point-detail__hint" data-device-cards-empty>
                         该监测点当前没有关联设备
                     </p>
                 </div>
@@ -179,128 +203,7 @@
                     <p v-if="deviceNotice.risky" class="point-detail__located" data-device-cross-endpoint>
                         {{ deviceNotice.text }}
                     </p>
-                    <ul class="point-detail__devices" data-device-layer-list>
-                        <li
-                            v-for="row in pagedLayerRows.rows"
-                            :key="`${row.pointCode}|${row.code}`"
-                            class="point-detail__device"
-                            :class="{ 'is-selectable': row.placement === 'drawn' }"
-                            :data-device-layer-row="row.placement"
-                            :data-device-layer-code="row.code"
-                            :data-device-layer-point="row.pointCode"
-                            :data-device-layer-selected="deviceLayerStore.selectedDevice === row.code ? 'yes' : 'no'"
-                            @mouseenter="hoverDeviceRow(row)"
-                            @mouseleave="leaveDeviceRow(row)"
-                            @click="row.placement === 'drawn' && deviceLayerStore.selectDevice(row.code)"
-                        >
-                            <!--
-                                票 44 验收第 3 条:**名称为主、编码为辅**(顺序与主次是领域规则,
-                                取自 `deviceLabel`,不在模板里另写一套)。名称承担长文本截断。
-                            -->
-                            <span class="point-detail__device-name" :data-device-label-name="row.nameLabel.name">{{ row.nameLabel.name || '(详情待确认)' }}</span>
-                            <span v-if="row.nameLabel.code" class="point-detail__mono point-detail__device-code">{{ row.nameLabel.code }}</span>
-                            <!--
-                                票 44:设备状态标签走**独立设备词表**(`deviceVisualForStatus`),
-                                不借用点位状态表 —— 两张表取值集不同(点位有 EMPTY,设备没有)。
-                                票 46 返工第 2 条:图标经 `statusVisual.icon`(legendIconFor 单源)
-                                与一张图顶部状态栏同一套视觉;tone/shape/label/icon 一次算好同源,
-                                不在模板里逐属性各调一次(那正是"显示甲、提示乙"的成因)。
-                            -->
-                            <StatusTag
-                                v-if="row.status"
-                                :tone="row.statusVisual.tone"
-                                :shape="row.statusVisual.shape"
-                                :label="row.statusLabel || row.statusVisual.label"
-                                :icon="row.statusVisual.icon"
-                            />
-                            <span class="point-detail__device-placement">{{ row.placementText }}</span>
-                            <!--
-                                票 44 验收第 5 条:资产降级要能**在详情里**说明。
-                                只在真有原因时显示一行(本期常态是"正式图片素材未提供")。
-                            -->
-                            <span
-                                v-if="row.assetReasonText"
-                                class="point-detail__device-asset"
-                                :data-device-asset-mode="row.assetMode"
-                                :data-device-asset-reason="row.assetReason"
-                            >{{ row.assetReasonText }}</span>
-                        </li>
-                        <!--
-                            票 60:关联设备清单的翻页(共享控件,窗口化的只是**命中子集**这份渲染列表)。
-                            `display: contents` 让这层 li 不占栅格位:一页不画控件时(PagerNav 自己 v-if 掉)
-                            不留一条空行 —— 组件不重复判「pageCount>1」,那条规则仍只在共享层有一份。
-                            上方「总数 X 台(已上图 Y)」与图例读的是 store 的快照口径(票 42),本 pager
-                            只翻窗口,所以 :show-count="false"(不再并排给出第二个「总数」)。
-                        -->
-                        <li class="point-detail__pager" data-point-pager-wrap="layer">
-                            <PagerNav
-                                :page="pagedLayerRows.page"
-                                :page-count="pagedLayerRows.pageCount"
-                                :total="pagedLayerRows.total"
-                                list-name="关联设备图层清单"
-                                :show-count="false"
-                                data-point-pager="layer"
-                                @page="(target) => gotoPage('layer', target)"
-                            />
-                        </li>
-                        <!--
-                            票 14:「另有 N 台」(决策 02 的 Q8/Q14/Q15)——设备级筛选生效时,
-                            快照的 `deviceCodes` 是命中子集,未命中设备收进这一行;展开后
-                            可交互(点卡片进设备详情)、仅视觉弱化,不上图(已上图口径只数命中子集)。
-                        -->
-                        <li
-                            v-if="unmatchedDeviceRows.length"
-                            class="point-detail__device point-detail__more"
-                            :data-device-more="unmatchedOpen ? 'open' : 'closed'"
-                            :data-device-more-count="unmatchedDeviceRows.length"
-                        >
-                            <button
-                                type="button"
-                                class="point-detail__more-btn"
-                                :aria-expanded="unmatchedOpen ? 'true' : 'false'"
-                                data-device-more-toggle
-                                @click="unmatchedOpen = !unmatchedOpen"
-                            >
-                                {{ unmatchedOpen ? '收起未命中设备' : `另有 ${unmatchedDeviceRows.length} 台 · 未命中当前筛选` }}
-                            </button>
-                        </li>
-                        <template v-if="unmatchedOpen">
-                            <li
-                                v-for="row in pagedUnmatchedRows.rows"
-                                :key="`${row.pointCode}|${row.code}`"
-                                class="point-detail__device is-unmatched"
-                                :data-device-layer-row="row.placement"
-                                :data-device-layer-code="row.code"
-                                :data-device-layer-point="row.pointCode"
-                                :data-device-layer-selected="deviceLayerStore.selectedDevice === row.code ? 'yes' : 'no'"
-                                @click="selectUnmatchedDevice(row)"
-                            >
-                                <span class="point-detail__device-name" :data-device-label-name="row.nameLabel.name">{{ row.nameLabel.name || '(详情待确认)' }}</span>
-                                <span v-if="row.nameLabel.code" class="point-detail__mono point-detail__device-code">{{ row.nameLabel.code }}</span>
-                                <StatusTag
-                                    v-if="row.status"
-                                    :tone="row.statusVisual.tone"
-                                    :shape="row.statusVisual.shape"
-                                    :label="row.statusLabel || row.statusVisual.label"
-                                    :icon="row.statusVisual.icon"
-                                />
-                                <span class="point-detail__device-placement">{{ row.placementText }}</span>
-                            </li>
-                            <!-- 票 60(维护者当次裁定 1):展开后的未命中子清单同样 ≤10/页,独立第三套页码。
-                                 「另有 N 台」的 N 仍报**全量**条数(差额可追溯是票 14 的口径,不受窗口影响)。 -->
-                            <li class="point-detail__pager" data-point-pager-wrap="unmatched">
-                                <PagerNav
-                                    :page="pagedUnmatchedRows.page"
-                                    :page-count="pagedUnmatchedRows.pageCount"
-                                    :total="pagedUnmatchedRows.total"
-                                    list-name="未命中设备清单"
-                                    :show-count="false"
-                                    data-point-pager="unmatched"
-                                    @page="(target) => gotoPage('unmatched', target)"
-                                />
-                            </li>
-                        </template>
-                    </ul>
+<!-- 票 76(双区合并):原「关联设备图层」列表已删除(与设备卡片表达同一类数据)。 -->
                     <!-- 独立图例:只统计当前模式实际绘制的设备,与摘要图例互不计入(票 09 的边界)。 -->
                     <div class="point-detail__legend" data-device-layer-legend>
                         <span
@@ -464,29 +367,28 @@ const deviceListRows = computed(() =>
 )
 
 /**
- * 票 14:命中 / 未命中两段分列(决策 02 的 Q8/Q14/Q15)。
- * `unmatched`(未命中当前筛选)的行收进「另有 N 台」;它们保留交互(进详情)但弱化、不上图。
+ * 票 76(双区合并):**待确认占位卡**的数据源。
+ *
+ * 旧「关联设备图层」清单用「总数取快照 `deviceCodes`」表达「快照里有、详情还没回来」;
+ * 合并成单列表后,这条可见性由占位卡承担:只列**快照有、卡片里没有**的设备编码(一台设备只出现一次)。
+ * 判据放这里而不是模板:占位 = 快照编码集合 − 已出卡片编码集合。
  */
-const hitDeviceRows = computed(() => deviceListRows.value.filter((row) => row.placement !== 'unmatched'))
-const unmatchedDeviceRows = computed(() => deviceListRows.value.filter((row) => row.placement === 'unmatched'))
-const unmatchedOpen = ref(false)
+const pendingDeviceCodes = computed(() => {
+    const cardCodes = new Set(cards.value.map((card) => card.code))
+    return (deviceListRows.value || [])
+        .filter((row) => row.code && !cardCodes.has(row.code))
+        .map((row) => ({ code: row.code }))
+})
 
 /**
- * 票 60(统一分页批次二):三个清单各自的页码 —— **视图本地状态**,与票 46/59 同一立场
+ * 票 60(统一分页批次二)/ 票 76(双区合并):设备清单的页码 —— **视图本地状态**
  * (「这一屏看第几页」不是业务事实,不进 store、不发请求,组件卸载即消失)。
- * 稳定标量键(票 43 教训:不要用对象身份当键):`cards`/`layer`/`unmatched` 三个固定字符串。
- *
- * 三处都用同一套共享件:窗口计算归 `domain/pagination.js`(条文 11 单源),控件归
- * `PagerNav.vue`;本组件不再有第二份翻页算术。
  *
- * 与票 42 口径的关系(验收第 2 条):分页**只窗口化渲染**。
- *   · 关联设备清单的数据仍是 store 的 `rows` 全量(含「待确认」那些只有编码的行);
- *   · 上方那行「总数 X 台(已上图 Y)」仍只认当前快照(store 的 `total`/`counts`/`legend` 一字未动);
- *   · 互斥归类与差额可追溯因此不受影响 —— 翻到的窗口之和仍是那份全量。
- * 计数行按维护者当次裁定**不画**(`show-count=false`):权威总数已由上面那行与设备合计 KPI 给出,
- * 再并排放一个「共 N 条」(窗口口径)会被读成第二个总数。
+ * **2026-10-26 双区合并后的现状**:维护者裁定「设备卡片」与「关联设备图层」表达同一类数据,
+ * 合并为**单一清单**(设备卡片 + 待确认占位卡),因此这里只剩 `cards` 一个页码键;
+ * `layer`/`unmatched` 两套页码随旧列表一起删除(不是漏改)。
  */
-const pages = ref({ cards: 1, layer: 1, unmatched: 1 })
+const pages = ref({ cards: 1 })
 
 /** 某清单的窗口(`pagedRows` 单源:越界/脏页码夹回,空表也是 1 页)。 */
 function pagedOf(key, rows) {
@@ -494,8 +396,6 @@ function pagedOf(key, rows) {
 }
 
 const pagedCards = computed(() => pagedOf('cards', cards.value))
-const pagedLayerRows = computed(() => pagedOf('layer', hitDeviceRows.value))
-const pagedUnmatchedRows = computed(() => pagedOf('unmatched', unmatchedDeviceRows.value))
 
 /**
  * 翻页唯一出口(与票 59 同款):PagerNav 的前后页/页码直达/跳页都带**目标页码(1 起)**回来。
@@ -518,7 +418,8 @@ function gotoPage(key, target) {
 watch(
     () => `${selection.value.pointCode}|${selection.value.deviceCode}`,
     () => {
-        pages.value = { cards: 1, layer: 1, unmatched: 1 }
+        // 复位只回 `cards`:`layer`/`unmatched` 两套页码随双区合并一起删除(票 76)。
+        pages.value = { cards: 1 }
     },
 )
 

+ 34 - 5
src/modules/cockpit/domain/deviceEffects.js

@@ -26,14 +26,39 @@ export const DEVICE_EFFECT = {
     SPIN: 'spin',
 }
 
-/** 同屏异常设备上限的缺省值(决策票 07 定 30,可由 `map.effects.abnormalLimit` 现场调)。 */
+/**
+ * 视觉优先级档位:只给业务处置态特效,且明确报警 > 预警 > 工单。
+ * `layers/scale/alpha` 是渲染载体的统一 profile,避免三套 CSS 各自漂移。
+ * scale = 扩散峰值倍率(相对绘制宽),DOM 层经 `--fx-profile-scale` 消费(单源,渲染层不另造数值)。
+ *
+ * 票 70 走查反馈②(2026-10-21)改数:
+ *   · ALARM 1.55 → 2.1(存在感不足);
+ *   · WARNING 1.22 → 1.8(**峰值从未探出图标**是「看不出动态」的根因之一,配合 C3g 修的动画覆盖 bug);
+ *   · WORK_ORDER 0.96 → 0.78(齿轮缩小降存在感,与转速 -30% 同批)。
+ * 三者仍在票 72 裁定的 ≤2.6 峰值区间内;终值等票 72 单源模块接管后由旋钮现场终判。
+ */
+export const EFFECT_PROFILE = {
+    ALARM: { status: 'ALARM', effect: DEVICE_EFFECT.PULSE, layers: 4, scale: 2.1, alpha: 1, glow: true },
+    WARNING: { status: 'WARNING', effect: DEVICE_EFFECT.BLINK, layers: 3, scale: 1.8, alpha: 0.78, glow: false },
+    WORK_ORDER: { status: 'WORK_ORDER', effect: DEVICE_EFFECT.SPIN, layers: 2, scale: 0.78, alpha: 0.52, glow: false },
+}
+
+export function effectProfileFor(status) {
+    return EFFECT_PROFILE[status] || null
+}
+
+/** 同屏异常上限的缺省值(决策票 07 定 30,可由 `map.effects.abnormalLimit` 现场调)。 */
 export const DEFAULT_ABNORMAL_LIMIT = 30
 
-/** 各动效的周期(毫秒)。数值属表现层,决策票 07 留实现与走查现场校准。 */
+/**
+ * 各动效的周期(毫秒)。数值属表现层,决策票 07 留实现与走查现场校准。
+ * 票 70 走查反馈②:工单齿轮慢 30%(2400 → 3400)——本表驱动 billboard 层 `spinAngleAt`,
+ * 与 `RING_PERIOD_MS.SPIN`(CSS 层)同批调整,两层齿轮不同步会被看出来(节奏单源纪律)。
+ */
 export const EFFECT_PERIOD_MS = {
     [DEVICE_EFFECT.PULSE]: 1600,
     [DEVICE_EFFECT.BLINK]: 900,
-    [DEVICE_EFFECT.SPIN]: 2400,
+    [DEVICE_EFFECT.SPIN]: 3400,
 }
 
 /** 该设备状态对应哪个动效;无动效回空串(**不是** `null`,便于字符串判空)。 */
@@ -136,11 +161,15 @@ export function degradedAbnormalNote(plan) {
  */
 export const RING_LAYERS = 4
 
-/** 各动效的光环周期(毫秒):报警最急、预警次之、工单最缓("进行中"的从容感)。 */
+/**
+ * 各动效的光环周期(毫秒):报警最急、预警次之、工单最缓("进行中"的从容感)。
+ * 票 70 走查反馈②:工单再慢 30%(2600 → 3700,转速 ≈138°/s → ≈97°/s)——
+ * DOM 与 billboard 两层同读这份单源,节奏一起慢,不各调各的。
+ */
 export const RING_PERIOD_MS = {
     [DEVICE_EFFECT.PULSE]: 1800,
     [DEVICE_EFFECT.BLINK]: 1400,
-    [DEVICE_EFFECT.SPIN]: 2600,
+    [DEVICE_EFFECT.SPIN]: 3700,
 }
 
 /** 中心辉光的呼吸周期(毫秒):比环慢,避免与环同频显得呆板。 */

+ 13 - 2
src/modules/cockpit/domain/pointAnchor.js

@@ -8,7 +8,15 @@ export const ANCHOR_HIDDEN = {
 
 const clamp = (value, min, max) => Math.max(min, Math.min(max, value))
 
-export function solvePointAnchor({ anchor, canvas, width = 480, height = 330, iconSize = 18, gap = 12, edge = 12 } = {}) {
+/**
+ * 屏幕空间布局:点位标记 + 详情卡。
+ *
+ * `iconSize` = 标记的**当前实际外接直径**(不是固定常量):票 76 走查反馈「进入聚合选择态时
+ * 面板有时会被标记压住、有时不会」——根因就是这里恒用 18px,而选择态的聚合点会被放大
+ * (DOM `--active` 放大 1.12× + 环扩散到 `EFFECT_PROFILE.scale`)。调用方把当前真实尺寸传进来,
+ * 让位距离就稳定了。
+ */
+export function solvePointAnchor({ anchor, canvas, width = 480, height = 330, iconSize = 18, gap = 12, edge = 12, avoidKey = '' } = {}) {
     const hidden = (reason) => ({ visible: false, reason, rect: null, line: null, side: '', cropped: false })
     if (!canvas || ![canvas.left, canvas.top, canvas.right, canvas.bottom].every(Number.isFinite)
         || canvas.right <= canvas.left || canvas.bottom <= canvas.top) return hidden('canvas-missing')
@@ -42,5 +50,8 @@ export function solvePointAnchor({ anchor, canvas, width = 480, height = 330, ic
         : side === 'left' ? { x: rect.right, y: clamp(anchor.y, rect.top, rect.bottom) }
             : side === 'below' ? { x: clamp(anchor.x, rect.left, rect.right), y: rect.top }
                 : { x: clamp(anchor.x, rect.left, rect.right), y: rect.bottom }
-    return { visible: true, reason: '', side, rect, cropped: !fits(rect), line: { from, to } }
+    // `avoidKey` 原样透传:它是「让位量」的标识(panelGap/iconSize),
+    // 调用方(CockpitShell)用它判断「落位是否真的需要重写」——
+    // 标记位置不变但让位量变了(进入/退出聚合选择态)时也必须重算。
+    return { visible: true, reason: '', side, rect, cropped: !fits(rect), line: { from, to }, avoidKey }
 }

+ 63 - 4
src/modules/cockpit/domain/pointStatus.js

@@ -87,9 +87,49 @@ export const POINT_STATUS_VISUALS = {
 /** 后端没给状态、或给了认不出的状态:如实说「不知道」,不落到任何一个真实状态上。 */
 export const UNKNOWN_VISUAL = { key: 'UNKNOWN', tone: 'point-unknown', shape: 'hollowDiamond', label: '状态未发布', priority: 7 }
 
-/** 未关联设备:不是点位状态,因此单独一条(`mapSummary.js` 已明确不得借用在线态)。 */
+/** 未关联设备:不是点位状态,因此单独一条(`mapSummary.js` 已明确不得借用在线态)。
+ *  票 45(维护者裁定):色键改**绿色系黄绿**,且只在**没有业务异常**时替代正常态。
+ *  它表达的是「这台设备没有挂到任何监测点」这一**附加状态**;业务运行状态(另一条并行轴)
+ *  由 `unassociatedVisualFor()` 决定用哪张词条。 */
 export const UNASSOCIATED_VISUAL = { key: 'UNASSOCIATED', tone: 'point-unassociated', shape: 'cross', label: '无关联设备', priority: 8 }
 
+/**
+ * 未关联设备(附加状态对象)→ 该用哪条**业务视觉**词条(票 45 范围 1/3,维护者 2026-10-16 裁定)。
+ *
+ * 两条并行轴:**业务运行状态**(工单处置/报警/预警/离线/正常)与**设备/数据附加状态**
+ * (无关联设备、无设备、状态未发布)。附加状态不得抢占业务状态——此前适配器丢弃后端
+ * `deviceStatus`、所有未关联设备一律画「无关联设备」橙标,一台预警中的设备在图上
+ * 只说「无关联」,这就是抢占本体(票 45 侦察 F2)。
+ *
+ * 取值域是**设备五态**(`domain/deviceStatus.js`),不是点位六态——但前五态串与点位表同源
+ * (后端 `CockpitMonitoringPointStatus` 一套枚举两处消费),词条仍取自点位表以保持
+ * 「一张图颜色只有一份词表」。逐条:
+ *   · ALARM / WARNING / WORK_ORDER → 业务异常,按业务词条显示(黄绿不替代);
+ *   · NORMAL → 没有业务异常,附加状态**替代正常态**:黄绿「无关联设备」;
+ *   · OFFLINE → 离线是业务运行状态(票 45 范围 1 优先级链第四档),不是「正常态」,按离线显示;
+ *   · 空 / 认不出 → 「状态未发布」先保留状态(票 45 范围 3 原句),落 `UNKNOWN_VISUAL`,不猜 NORMAL。
+ * 判定权在后端(CONTEXT.md「点位状态」/ADR 0002:前端只渲染);本函数只**选词条**,不重算状态。
+ * @param {string} status 后端 `deviceStatus` 原字符串(`mapSummary.js` 带出)
+ */
+export function unassociatedVisualFor(status) {
+    if (status === 'ALARM' || status === 'WARNING' || status === 'WORK_ORDER') return POINT_STATUS_VISUALS[status]
+    if (status === 'NORMAL') return UNASSOCIATED_VISUAL
+    if (status === 'OFFLINE') return POINT_STATUS_VISUALS.OFFLINE
+    return UNKNOWN_VISUAL
+}
+
+/** 词条是否表达**业务异常**(报警/预警/工单处置)——特效与降饱和都问它,不问色键。
+ *  注意与 `modeLayerPlan.isAttentionDeviceStatus()`(报警/预警两态、降级计数口径)语义不同,
+ *  两张表不合并:一个回答「要不要抢附加色」,一个回答「异常台数怎么数」。 */
+export function isBusinessAttentionVisual(visual) {
+    return Boolean(visual) && (visual.key === 'ALARM' || visual.key === 'WARNING' || visual.key === 'WORK_ORDER')
+}
+
+/** 状态字符串是否**业务异常**(与上面同源判据,给只有字符串、没有词条的调用方,如 markerPlan)。 */
+export function isBusinessAttentionStatus(status) {
+    return status === 'ALARM' || status === 'WARNING' || status === 'WORK_ORDER'
+}
+
 /** 点位状态字符串 → 视觉词条;认不出的一律 `UNKNOWN_VISUAL`。 */
 export function visualForStatus(status) {
     return POINT_STATUS_VISUALS[status] || UNKNOWN_VISUAL
@@ -98,13 +138,16 @@ export function visualForStatus(status) {
 /**
  * 一个地图对象(`domain/mapSummary.js` 的形状)→ 视觉词条。
  *
- * 未关联设备走自己的词条;监测点按后端 `status` 取值。**注意这里不读坐标**:
+ * 票 45(双轴):未关联设备**先按自身设备状态选业务词条**(报警/预警/工单/离线),
+ * 只有「正常」才由附加状态替代为黄绿「无关联设备」,状态缺失落「状态未发布」——
+ * 附加状态不再抢占业务状态(此前一律 `UNASSOCIATED_VISUAL`,预警中的设备只说「无关联」)。
+ * 监测点按后端 `status` 取值。**注意这里不读坐标**:
  * 「能不能上图」是 `domain/coordinate.js` 的事,本函数只回答「长什么样」。
  * @param {object} object 地图对象
  */
 export function pointVisual(object) {
     const item = object || {}
-    if (item.kind === 'unassociated-equipment') return UNASSOCIATED_VISUAL
+    if (item.kind === 'unassociated-equipment') return unassociatedVisualFor(item.status || '')
     return visualForStatus(item.status)
 }
 
@@ -179,6 +222,16 @@ export function legendIconFor(key) {
     return Object.prototype.hasOwnProperty.call(LEGEND_ICON_BY_KEY, key) ? LEGEND_ICON_BY_KEY[key] : ''
 }
 
+/**
+ * 图例文案(2026-10-22 维护者裁定):`EMPTY` / `UNASSOCIATED` 两个词在「一张图」图例上语义不明确——
+ * 「无设备」读不出说的是**监测点**还是设备,「无关联设备」也读不出它指的是**一台没挂点位的独立设备**。
+ * 只改**图例**用词,不动词条本体的 `label`(面板/弹层仍读原文案,避免连锁改动)。
+ */
+const LEGEND_LABEL_BY_KEY = {
+    EMPTY: '无设备监测点',
+    UNASSOCIATED: '独立设备(未关联监测点)',
+}
+
 /** 图例的全部行(含未关联设备与「状态未发布」),按后端优先级排序。
  *  `icon`:批次 1 第三轮新增——状态标识行的区分图标(语义名取自上面的单源表)。 */
 export function legendVisuals() {
@@ -187,7 +240,13 @@ export function legendVisuals() {
         UNKNOWN_VISUAL,
         UNASSOCIATED_VISUAL,
     ]
-        .map((visual) => ({ ...visual, icon: legendIconFor(visual.key) }))
+        .map((visual) => ({
+            ...visual,
+            label: Object.prototype.hasOwnProperty.call(LEGEND_LABEL_BY_KEY, visual.key)
+                ? LEGEND_LABEL_BY_KEY[visual.key]
+                : visual.label,
+            icon: legendIconFor(visual.key),
+        }))
         .sort((a, b) => a.priority - b.priority)
 }
 

+ 9 - 1
src/modules/cockpit/mocks/index.js

@@ -71,13 +71,21 @@ export function ensureCockpitMocks(overrides = {}) {
     const onlyPath = overrides.onlyPath || global.onlyPath
     const prefix = overrides.prefix || global.prefix
     const mapSnapshot = overrides.mapSnapshot || global.mapSnapshot
+    // 票 76(2026-10-23 走查):**显式场景**下把地图快照页大小拉大。默认 `pageSize=2` 的渐进加载
+    // 会让「选中点位后设备层需要的 snapshotCodes」在自动分批完成前缺席 —— 走查表现为
+    // 「第一次点有点没设备/要点几次或刷新地图才出现」。正式后端一页 500,无此问题;
+    // 这里只在场景走查时对齐正式口径(显式 `__COCKPIT_MOCK_MAP__` 仍优先)。
+    const activeScenario = overrides.scenario || global.scenario
+    const scenarioMapSnapshot = activeScenario && activeScenario !== 'success'
+        ? { pageSize: 200, ...mapSnapshot }
+        : mapSnapshot
     const monitoring = overrides.monitoring || global.monitoring
 
     const result = registerCockpitMocks({
         scenario,
         onlyPath,
         prefix,
-        mapSnapshot,
+        mapSnapshot: scenarioMapSnapshot,
         monitoring,
         handlers: { registerMockTransport, getMockTransportPaths, resetMockTransportsForTest },
     })

+ 35 - 482
src/modules/cockpit/services/cesiumEngine.js

@@ -61,7 +61,8 @@ import { removeSceneTileset } from './tilesetCleanup.js'
 const regionControllers = new WeakMap()
 // 票 47:动效的**相位与映射**同样在 domain(纯函数,可跨时间采样断言);
 // 本层只把相位写成 billboard 的 scale/alpha/rotation。
-import { DEVICE_EFFECT, RING_LAYERS, blinkStrengthAt, glowScaleAt, glowStrengthAt, hasGlow, pulseStrengthAt, ringEntityCount, ringFrameAt, spinAngleAt } from '../domain/deviceEffects.js'
+// 票 76(C5):环实体与逐帧动效已退役,引擎只保留「层数」这一个读数常量(DOM 侧层数由 profile 决定)。
+import { RING_LAYERS } from '../domain/deviceEffects.js'
 // 场景调色的兜底色直接取 domain 的默认(不再手抄一份 #2fa8ff 的浮点,两处会漂)。
 // 注意:GRADE_DEFAULTS 来自 sceneGrade;parseHexColor 的 owner 是 fog.js ——
 // sceneGrade 自己也只是导入它,从这里导 parseHexColor 会是 ESM 链接错误(引擎整个起不来)。
@@ -1712,25 +1713,10 @@ export function projectOverlapSymbols(viewer) {
 }
 
 /**
- * 票 47:点位标记(监测点/未关联设备)的每帧缩放读数。
- * 强调(`highlightEntities` 的 1.55)写在 billboard.scale 上,DOM 要镜像同一个量,
- * 否则面板联动强调时两层尺寸不一致(维护者走查抓到的「小了一圈」)。
- * @returns {Map<string, number>} `kind|code` → scale(没实体 = 不在图上,DOM 自行隐藏)
+ * 票 76(C3):点位强调 scale 的 billboard 回读**已退役**。
+ * 真值改由服务层 `mapService.pointEmphasisScales()` 产出(同一份 domain `shouldEmphasize`),
+ * DOM 不再从被隐藏的 billboard 实体读尺寸 —— 与票 58 §四「DOM 是唯一视觉尺寸源」一致。
  */
-export function readPointMarkerScales(viewer) {
-    const scales = new Map()
-    const source = (viewer && markerSources.get(viewer)) || (viewer && viewer.dataSources ? viewer.dataSources.getByName(POINT_MARKER_SOURCE)[0] : null)
-    if (!source) return scales
-    for (const entity of source.entities.values) {
-        const properties = entity.properties || {}
-        const read = (key) => (properties[key] && properties[key].getValue ? properties[key].getValue() : '')
-        const code = read('pointCode')
-        if (!code) continue
-        const kind = read('pointKind') || 'monitoring-point'
-        scales.set(`${kind}|${code}`, entity.billboard && entity.billboard.scale ? Number(entity.billboard.scale.getValue()) || 1 : 1)
-    }
-    return scales
-}
 
 /** 只读回关联设备图层的真实内容(走查据此断言「设备真的画在独立图层上」,以及「画成的样子来自接缝」)。 */
 export function readAssociatedDevices(viewer) {
@@ -1809,32 +1795,7 @@ export function clearAssociatedDevices(viewer) {
     deviceMarkerSource(viewer).entities.removeAll()
 }
 
-/**
- * 屏幕坐标 → **设备标记身份**(票 42 的独立拾取入口;票 43 会在此基础上做双向联动)。
- *
- * 与 `pickPointMarkerAt()` 同一条纪律:只认本图层上带 `deviceCode` 的实体,
- * 回**身份**不回 Cesium 实体,且**设备优先于监测点**(票 15 的拾取优先级)。
- */
-function overlapTargetAt(viewer, windowX, windowY) {
-    const targets = readOverlapTargets(viewer)
-    let nearest = null
-    let distance = Infinity
-    for (const target of targets.filter((item) => item.visible)) {
-        const current = Math.hypot(target.x - windowX, target.y - windowY)
-        if (current <= 24 && current < distance) {
-            nearest = target
-            distance = current
-        }
-    }
-    if (!nearest) return null
-    if (nearest.aggregate) return { kind: 'monitoring-device-overlap', code: '', pointCode: '', overlapGroup: nearest.overlapGroup }
-    if (nearest.deviceCode) return {
-        kind: 'monitoring-device', code: nearest.deviceCode, pointCode: nearest.pointCode, status: nearest.status,
-        overlapEntry: nearest.overlapEntry, overlapGroup: nearest.overlapGroup,
-    }
-    return null
-}
-
+/** 设备、监测点之外的第三层拾取入口:只返回管段/构件身份(管线是 Cesium 几何,不属标记层)。 */
 export function pickPipeObjectAt(viewer, windowX, windowY) {
     if (!viewer || !viewer.scene || !viewer.canvas || typeof viewer.scene.pick !== 'function') return null
     const rect = viewer.canvas.getBoundingClientRect()
@@ -1861,59 +1822,6 @@ export function pickPipeObjectAt(viewer, windowX, windowY) {
     return null
 }
 
-/** 设备、监测点之外的第三层拾取入口:只返回管段/构件身份。 */
-
-/** 仅穿过本管线图层;其它场景对象仍按 Cesium 的前后顺序遮挡。 */
-function pickThroughPipelines(viewer, position) {
-    const scene = viewer.scene
-    const picked = scene.pick(position)
-    const source = pipelineSource(viewer)
-    const isPipeline = (hit) => {
-        const id = hit && hit.id
-        return Boolean(id && (id.kind === 'pipe-model-batch' || (source && source.entities.values.includes(id))))
-    }
-    if (!isPipeline(picked)) return picked
-    // 普通拾取不受影响;只有最上层是管线时才执行多层拾取。
-    if (typeof scene.drillPick !== 'function') return null
-    return scene.drillPick(position).find((hit) => !isPipeline(hit)) || null
-}
-
-export function pickDeviceMarkerAt(viewer, windowX, windowY) {
-    if (!viewer || !viewer.scene || !viewer.canvas || typeof viewer.scene.pick !== 'function') return null
-
-    // 票45:展开后的设备拥有屏幕pixelOffset;先按公开投影读数做近邻身份判定,
-    // 避免同屏监测点/地形顶层对象让普通scene.pick吞掉真实设备。仍保持普通拾取语义。
-    const projectedHit = overlapTargetAt(viewer, windowX, windowY)
-    if (projectedHit) return projectedHit
-
-    const rect = viewer.canvas.getBoundingClientRect()
-    const picked = pickThroughPipelines(viewer, new Cartesian2(windowX - rect.left, windowY - rect.top))
-    const entity = picked && picked.id
-    const properties = entity && entity.properties
-    if (properties) {
-        const read = (key) => (properties[key] ? properties[key].getValue() : '')
-        if (read('overlapAggregate') === true) {
-            return {
-                kind: 'monitoring-device-overlap',
-                code: '',
-                pointCode: read('overlapPointCode'),
-                overlapGroup: read('overlapGroup'),
-                overlapCount: read('overlapCount'),
-            }
-        }
-        const code = read('deviceCode')
-        if (code) return {
-            kind: 'monitoring-device', code, pointCode: read('monitoringPointCode'), status: read('deviceStatus'),
-            name: read('deviceName') || '',
-            overlapEntry: read('overlapEntry') === true, overlapGroup: read('overlapGroup'),
-        }
-    }
-
-    // 重叠聚合与监测点同屏时,scene.pick 可能只返回顶层点位;
-    // 用聚合实体投影做近邻补偿,仍是普通拾取、不改真实位置。
-    return overlapTargetAt(viewer, windowX, windowY)
-}
-
 // ================================================================ 设备点的对应反应(票 43)
 
 /**
@@ -1947,8 +1855,8 @@ 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 || (entity.properties && (entity.properties.effectFx ||
-            (entity.properties.overlapAggregate && entity.properties.overlapAggregate.getValue() === true)))) continue
+        // 票 76(C5):光环实体退役后只剩聚合符号需要跳过(它不吃反应量)。
+        if (!billboard || (entity.properties && entity.properties.overlapAggregate && entity.properties.overlapAggregate.getValue() === true)) continue
 
         const isDevice = entity.properties && entity.properties.deviceCode
         billboard.scale = isDevice ? Math.max(1, style.scale) : style.scale
@@ -1996,100 +1904,6 @@ 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) {
-    // 票 45:光环基准尺寸取标记**绘制宽**(`drawWidth`,设备 26.72/点位 32.72),不再取
-    // 14px 图标尺寸 —— 14px 基准下环带峰值半径只有 ~5.5px,要 scale≥1.92 才探出图标本体,
-    // 而那时 alpha 已衰减到 0.59(「特效被图标尺寸压制」的直接成因)。
-    // 旧调用方(单测/走查桩)没有 drawWidth 时回退原尺寸,行为不变。
-    const size = Number.isFinite(device.drawWidth) ? device.drawWidth : (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,
-                effectFxDisplayGroup: '',
-                // 刻意**不写 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,
-                effectFxDisplayGroup: '',
-            },
-        }))
-    }
-    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 }
 }
@@ -2143,198 +1957,57 @@ function writeDeviceBaseFrame(source) {
     }
 }
 
-/**
- * 动效帧(票 35 卡顿整改后的版本)。
- *
- * 与原实现的差别**只在不该做的写入上**:基础图标样式与光环实体属性都改成"变了才写",
- * 位置/像素偏移/显示组只在归属真的换了才重新绑定。视觉结果逐帧一致,但镜头静止或
- * 只有少量设备在报警时,几乎不再产生实体脏标记与集合重建。
- */
-function writeEffectFrame() {
-    const viewer = deviceEffectState.viewer
-    const source = viewer && (deviceSources.get(viewer) || (viewer.dataSources ? viewer.dataSources.getByName(DEVICE_MARKER_SOURCE)[0] : null))
-    if (!viewer || !source) return false
-    const animated = animatedByCode(deviceEffectState.plan)
-    const now = Date.now()
-    writeDeviceBaseFrame(source)
-
-    // ── 科技感光环层(多层同心扩散环 + 中心辉光)──
-    // 光环实体不带 deviceCode,所以**不参与上面的设备图标循环**,也不吃拾取。
-    // 票 45:选中强化 1.25 → 1.55,与标记强调(highlightEntities 的 1.55)同步——
-    // 选中时图标放大 1.55 而环只放大 1.25,两层会 visibly 分离(「小一圈」同源问题)。
-    const selectedBoost = 1.55
-    const owners = new Map(source.entities.values
-        .filter((entity) => entity.properties && entity.properties.deviceCode)
-        .map((entity) => [entity.properties.deviceCode.getValue(), entity]))
-    const aggregates = new Map(source.entities.values
-        .filter((entity) => entity.properties && entity.properties.overlapAggregate && entity.properties.overlapAggregate.getValue() === true)
-        .map((entity) => [entity.properties.overlapGroup.getValue(), entity]))
-    const entries = new Map([...owners.values()]
-        .filter((entity) => entity.show !== false && entity.properties.overlapEntry && entity.properties.overlapEntry.getValue() === true)
-        .map((entity) => [entity.properties.overlapGroup.getValue(), entity]))
-    let animatedNow = 0
-    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) : ''
-        const owner = owners.get(forCode)
-        const groupKey = owner && owner.properties.overlapGroup ? owner.properties.overlapGroup.getValue() : ''
-        const aggregate = aggregates.get(groupKey)
-        const entry = entries.get(groupKey)
-        const entryHasGroupEffect = entry && aggregate && animated.has(entry.properties.deviceCode.getValue()) &&
-            entry.properties.deviceStatus.getValue() === aggregate.properties.deviceStatus.getValue()
-        // 聚合最高状态的光环借用当前入口;设备来源身份和状态不变。
-        const representsGroup = !entryHasGroupEffect && owner && owner.show === false && aggregate && aggregate.properties.overlapEffectCode &&
-            aggregate.properties.overlapEffectCode.getValue() === forCode
-        const displayOwner = representsGroup ? (entry || aggregate) : owner
-        const shouldShow = Boolean(effect && source.show !== false && displayOwner && displayOwner.show !== false)
-        if (entity.show !== shouldShow) entity.show = shouldShow
-        let record = effectApplied.get(entity)
-        if (!record) {
-            record = { scale: undefined, alpha: undefined, rotation: undefined, owner: undefined, group: undefined }
-            effectApplied.set(entity, record)
-        }
-        // 归属只在真的换了才重新绑位置/像素偏移(原实现每帧无条件重新赋值,等于每帧标脏)。
-        if (record.owner !== displayOwner) {
-            record.owner = displayOwner
-            if (displayOwner && displayOwner.position) entity.position = displayOwner.position
-            billboard.pixelOffset = displayOwner && displayOwner.billboard ? displayOwner.billboard.pixelOffset : ZERO_PIXEL_OFFSET
-        }
-        const displayGroup = representsGroup ? groupKey : ''
-        if (record.group !== displayGroup) {
-            record.group = displayGroup
-            if (properties.effectFxDisplayGroup) properties.effectFxDisplayGroup.setValue(displayGroup)
-        }
-        if (!effect) {
-            // 无动效(正常/离线/未知、特效关闭或降级):光环**完全透明**,不留残影。
-            writeIfChanged(record, 'scale', 1, (v) => { billboard.scale = v })
-            writeIfChanged(record, 'alpha', 0, () => { billboard.color = Color.WHITE.withAlpha(0) })
-            continue
-        }
-        animatedNow += 1
-        const isSelected = deviceEffectState.selectedCode && forCode === deviceEffectState.selectedCode
-        const boost = isSelected ? selectedBoost : 1
-        if (kind === 'glow') {
-            const strength = glowStrengthAt(now)
-            writeIfChanged(record, 'scale', glowScaleAt(now) * boost, (v) => { billboard.scale = v })
-            writeIfChanged(record, 'alpha', 0.32 * strength * boost, (v) => { billboard.color = Color.WHITE.withAlpha(v) })
-            continue
-        }
-        // 多层环:每层相位错开、由内向外扩张渐隐。
-        const frame = ringFrameAt(status, now)
-        const ring = frame.rings[layer] || { scale: 1, alpha: 0 }
-        writeIfChanged(record, 'scale', ring.scale * boost, (v) => { billboard.scale = v })
-        const flicker = effect === DEVICE_EFFECT.BLINK ? blinkStrengthAt(now) : 1
-        writeIfChanged(record, 'alpha', Math.min(1, ring.alpha * boost * flicker), (v) => { billboard.color = Color.WHITE.withAlpha(v) })
-        writeIfChanged(record, 'rotation', effect === DEVICE_EFFECT.SPIN ? spinAngleAt(now) : 0, (v) => { billboard.rotation = v })
-    }
-    deviceEffectState.tick += 1
-    return animatedNow > 0
-}
-
-function ensureEffectLoop() {
-    if (typeof window === 'undefined' || typeof window.requestAnimationFrame !== 'function') return
-    if (deviceEffectState.raf) return
-    const step = () => {
-        if (!deviceEffectState.raf) return
-        // 30Hz 足够:光环周期以秒计,60→30 的一半帧率肉眼几乎无差,
-        // 但每帧省下的实体属性写入与 GPU 缓冲重建是实打实的。
-        const now = typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now()
-        if (now - (deviceEffectState.lastFrameAt || 0) >= 33) {
-            deviceEffectState.lastFrameAt = now
-            const moving = writeEffectFrame()
-            // 没有任何动效时不主动要帧:让 requestRenderMode 真正进入省电/省算力的静止态。
-            if (moving && deviceEffectState.viewer && deviceEffectState.viewer.scene) {
-                deviceEffectState.viewer.scene.requestRender()
-            }
-        }
-        deviceEffectState.raf = window.requestAnimationFrame(step)
-    }
-    deviceEffectState.raf = window.requestAnimationFrame(step)
-}
-
 /**
  * 应用动效计划(由服务层在设备图层同步后调用)。
+ *
+ * 票 76(C5):设备状态动效**全部由 DOM overlay 承载**(`buildFxLayers` + CSS keyframes)。
+ * 引擎侧不再建光环实体、也不再跑逐帧动效循环 —— 本函数只做两件事:
+ *   ① 记录计划与选中(`setOverlapPresentation` 与读数面用);
+ *   ② 把「反应量基准」(选中/hover/弱化)写到设备 billboard —— 它是 DOM 设备行的
+ *      投影真值(`projectDeviceMarkers` 的 scale/alpha 镜像),不是视觉本身。
  * @param {Viewer} viewer
  * @param {object} plan `domain/deviceEffects.js` 的 `planDeviceEffects()` 输出
- * @param {object} [options] `{ selectedCode }` 选中设备额外强化
- * @returns {boolean} 是否启动了动效
+ * @param {object} [options] `{ selectedCode }`
+ * @returns {boolean} 计划是否「在动」(DOM 据此挂特效;引擎不再逐帧写)
  */
 export function applyDeviceEffects(viewer, plan, options = {}) {
     deviceEffectState.viewer = viewer || null
     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) {
-        // 票 76(交互规格 2026-10-23):设备视觉让位 DOM 时,**billboard 层的环一律清掉** ——
-        // 环的归属改由 DOM 决定:聚合点不挂环,只有「中心(被选中)设备」按自身状态挂环。
-        // 否则旧环实体没有跟着 `hideDeviceVisual` 一起消失,观感上「特效逻辑说不清」。
-        if (deviceVisualHidden) {
-            clearRingEntities(source)
-        } else 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)
-        }
+        deviceEffectState.baseDirty = true
+        writeDeviceBaseFrame(source)
     }
-    if (running) ensureEffectLoop()
-    else if (deviceEffectState.raf && typeof window !== 'undefined') {
-        window.cancelAnimationFrame(deviceEffectState.raf)
-        deviceEffectState.raf = 0
-    }
-    // 中心符号保持可读;停用时外围动画复位。
-    writeEffectFrame()
-    return running
+    deviceEffectState.tick += 1
+    return Boolean(plan && plan.enabled === true && !plan.degraded && plan.animated && plan.animated.length)
 }
 
-/** 停止动效并写回基准(图层撤下 / 详情关闭 / 特效被关掉)。 */
+/** 停止动效(图层撤下 / 详情关闭):只清计划与选中,视觉已由 DOM 承载。 */
 export function stopDeviceEffects() {
-    if (typeof window !== 'undefined' && deviceEffectState.raf && typeof window.cancelAnimationFrame === 'function') {
-        window.cancelAnimationFrame(deviceEffectState.raf)
-    }
-    deviceEffectState.raf = 0
     deviceEffectState.plan = null
     deviceEffectState.selectedCode = ''
-    writeEffectFrame()
+    deviceEffectState.baseDirty = true
+    deviceEffectState.tick += 1
 }
 
 /**
- * 只读读回每台设备的**动效状态与当前帧属性**(走查的"真在跑"证据)。
+ * 只读读回设备行**当前的基准反应量**(走查用它判断选中/hover/弱化有没有落笔)。
  *
- * 三层证据的第 2 层(场景图跨帧快照):走查对同一设备**隔帧读两次**,
- * 断言 `scale`/`alpha`(脉冲/闪烁)或 `rotation`(旋转环)确实变了,
- * 而正常/离线的设备**完全不变**。这样就不是"拿两份 JS 数据互比"的恒真断言。
+ * 票 76(C5):光环实体已退役 —— `fx` 恒为空数组;动效「真在跑」的证据改由 DOM 承担
+ * (`data-marker-effect*` 与 CSS 动画,见 markerOverlay)。这里如实返回空,不伪造。
  */
 export function readDeviceEffects(viewer) {
     const source = viewer && (deviceSources.get(viewer) || (viewer.dataSources ? viewer.dataSources.getByName(DEVICE_MARKER_SOURCE)[0] : null))
-    if (!source) return { tick: deviceEffectState.tick, devices: [] }
     const animated = animatedByCode(deviceEffectState.plan)
+    const summary = {
+        tick: deviceEffectState.tick,
+        running: false,
+        degraded: Boolean(deviceEffectState.plan && deviceEffectState.plan.degraded),
+        enabled: Boolean(deviceEffectState.plan && deviceEffectState.plan.enabled),
+        ringLayers: RING_LAYERS,
+    }
+    if (!source) return { ...summary, devices: [], fx: [] }
     const devices = []
     for (const entity of source.entities.values) {
         const billboard = entity.billboard
@@ -2354,39 +2027,8 @@ 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'),
-            displayGroup: get('effectFxDisplayGroup'),
-            visible: source.show !== false && entity.show !== false && entity.isShowing !== false,
-            pixelOffset: billboard.pixelOffset && billboard.pixelOffset.getValue ? billboard.pixelOffset.getValue() : billboard.pixelOffset,
-            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,
-            rotation: billboard.rotation && billboard.rotation.getValue ? billboard.rotation.getValue() : (billboard.rotation || 0),
-        })
-    }
-    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,
-    }
+    return { ...summary, devices, fx: [] }
 }
-
 /**
  * 选中设备的**归属引线**:从设备点连到监测点标记中心(票 04/12 的面板锚点)。
  *
@@ -2425,16 +2067,6 @@ export function clearDeviceLead(viewer) {
     return true
 }
 
-/**
- * 屏幕坐标 → 设备标记身份(**hover 用**,与 `pickDeviceMarkerAt` 同一条判据)。
- *
- * 单独一个函数只是为了让 hover 的调用点读起来是"我在做 hover 拾取",
- * 实现完全复用点选那一个(**不另写一套判据** —— 两套迟早会分歧)。
- */
-export function pickDeviceMarkerForHover(viewer, windowX, windowY) {
-    return pickDeviceMarkerAt(viewer, windowX, windowY)
-}
-
 /**
  * 强调一批编码对应的地图实体(票 14:点事件详情时高亮关联设备与关联管线)。
  *
@@ -2565,85 +2197,6 @@ function findDeviceMarker(viewer, code, pointCode) {
     }) || 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
-    const carto = ellipsoid.cartesianToCartographic(original)
-    if (!carto) return null
-
-    // 地面高程:实体位置是静态的,地形瓦片也只会逐步补齐;每帧给每个实体查一次
-    // `globe.getHeight`(四叉树下降)是纯重复。按实体缓存 3 秒,瓦片补齐后自然刷新。
-    const nowMs = typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now()
-    let height = 0
-    const cachedHeight = entityGroundHeightCache.get(entity)
-    if (cachedHeight && nowMs - cachedHeight.at < 3000) {
-        height = cachedHeight.height
-    } else {
-        const sampled = viewer.scene.globe.getHeight(carto)
-        height = Number.isFinite(sampled) ? sampled : 0
-        entityGroundHeightCache.set(entity, { height, at: nowMs })
-    }
-
-    const world = Cartesian3.fromRadians(carto.longitude, carto.latitude, height)
-    const camera = viewer.camera
-    // 复用同一个 scratch 向量:每实体每帧 new Cartesian3 在高频投影里是纯垃圾。
-    const toward = Cartesian3.subtract(world, camera.positionWC, projectionCache.toward)
-    if (Cartesian3.dot(toward, camera.directionWC) <= 0) return null
-    // 遮挡体同一帧内所有实体共用(原本每实体 new 一个,是纯浪费)。
-    const occluder = occluderCached(viewer, camera)
-    if (!occluder.isPointVisible(world)) return null
-    const at = SceneTransforms.worldToWindowCoordinates(viewer.scene, world)
-    if (!at || !Number.isFinite(at.x) || !Number.isFinite(at.y)) return null
-    // 画布矩形只随窗口尺寸变;120ms 保鲜即可,不必每个实体读一次布局。
-    const rect = canvasRectCached(viewer)
-    const time = viewer.clock.currentTime
-    const read = (property, fallback) => property && property.getValue ? property.getValue(time) : fallback
-    const offset = read(entity.billboard.pixelOffset, ZERO_PIXEL_OFFSET)
-    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 的地图点选)。
- *
- * 三件事必须说清:
- *   1. 只认点位标记数据源上的实体 —— 拾到地形、影像、3D Tiles 或空白一律回 `null`
- *      (不是「拾到什么就打开详情」);判据是实体属性里有 `pointCode`;
- *   2. 坐标要吃**画布坐标**,而事件给的是窗口坐标:`scene.pick` 用的是画布内部坐标系,
- *      所以先减去画布原点(票 03 的 `projectPivot()` 踩过同一个坑,那里是加、这里是减);
- *   3. 回的是**标记身份**而不是 Cesium 实体:引擎对象不出这一层,上层只认 kind/code/status
- *      (与 `syncPointMarkers` 只认 shape/tone 是同一条纪律)。
- *
- * @returns {{kind: string, code: string, status: string}|null}
- */
-export function pickPointMarkerAt(viewer, windowX, windowY) {
-    if (!viewer || !viewer.scene || !viewer.canvas || typeof viewer.scene.pick !== 'function') return null
-
-    const rect = viewer.canvas.getBoundingClientRect()
-    const picked = pickThroughPipelines(viewer, new Cartesian2(windowX - rect.left, windowY - rect.top))
-    const entity = picked && picked.id
-    const properties = entity && entity.properties
-    if (!properties || !properties.pointCode) return null
-
-    const read = (key) => (properties[key] ? properties[key].getValue() : '')
-    const code = read('pointCode')
-    if (!code) return null
-
-    return { kind: read('pointKind'), code, status: read('pointStatus'), name: read('pointName') || '' }
-}
-
-/**
- * 点位标记 hover 拾取(批次 3:给「hover 显示完整名称」的通用 tooltip 链路用)。
- * 与 `pickPointMarkerAt` 同一实体源,但**额外带 name** 且不参与点选竞态。
- */
-export function pickPointMarkerForHover(viewer, windowX, windowY) {
-    return pickPointMarkerAt(viewer, windowX, windowY)
-}
-
 /**
  * 经纬度 → 画布窗口坐标(批次 3 第五轮,DOM overlay 的 3D 定位入口)。
  * @returns {{x: number, y: number}|null} 相对画布左上角;未就绪/不可投影返回 null

+ 36 - 2
src/modules/cockpit/services/cockpitConfig.js

@@ -189,6 +189,38 @@ function readNonNegativeNumber(value, issues, label) {
     return number
 }
 
+/**
+ * DEV 走查桥(票 76,与 `?overlay=0` 同一入口风格):`?dataSource=mock|real` 覆盖配置的 dataSource。
+ *
+ * 为什么放在配置层:dataSource 决定 `planCockpitStartup()` 走哪条启动分支(只有 mock 注册契约样例),
+ * 覆盖必须发生在**归一化之后、启动计划之前**;放在页面里会让两处各判一次。
+ * 仅 DEV 生效,值只认 mock/real,其余原样返回(不猜、不改正式路径默认值)。
+ *
+ * 用法:`http://127.0.0.1:8000/?dataSource=mock&scenario=device-layer-overlap-degraded#/cockpit`
+ *   —— `scenario` 由 `mocks/index.js` 的 `readScenario()` 读,用来出 K>1 聚合场景。
+ *
+ * @param {object} config 已归一化配置
+ * @param {Location} [location] 可注入(脱浏览器断言用)
+ * @param {boolean} [isDev] 是否开发构建
+ */
+export function withUrlDataSourceOverride(config, location = null, isDev = false) {
+    if (!isDev || !config) return config
+    const loc = location || (typeof globalThis !== 'undefined' ? globalThis.location : null)
+    if (!loc) return config
+    let value = ''
+    try {
+        const hashQuery = String(loc.hash || '').split('?')[1] || ''
+        value = new URLSearchParams(loc.search || '').get('dataSource')
+            || new URLSearchParams(hashQuery).get('dataSource')
+            || ''
+    } catch (error) {
+        value = ''
+    }
+    const next = String(value || '').toLowerCase()
+    if (next !== DATA_SOURCE.MOCK && next !== DATA_SOURCE.REAL) return config
+    return { ...config, dataSource: next }
+}
+
 /**
  * 归一化 + 宽松校验:不认识的取值退回默认并记一条 issue,不因为一个错字段整份配置作废。
  * @param {object} raw 合并后的原始配置
@@ -197,7 +229,6 @@ function readNonNegativeNumber(value, issues, label) {
 export function normalizeConfig(raw) {
     const config = builtinConfig()
     const issues = []
-
     if (!isPlainObject(raw)) {
         if (raw !== undefined && raw !== null) issues.push('配置不是对象,已全部退回内置默认')
         return { config, issues }
@@ -683,7 +714,10 @@ async function resolveCockpitConfig({ scope, env, fetchImpl, base, documentBase,
         }
     }
 
-    const { config, issues: normalizeIssues } = normalizeConfig(raw)
+    const { config: normalized, issues: normalizeIssues } = normalizeConfig(raw)
+    // 票 76:DEV 走查桥必须落在**全局配置**上 —— `requestCockpitJson()` 读的是
+    // `getCockpitConfig()`,只覆盖启动计划那份只会「注册 mock 但请求仍走 real」。
+    const config = withUrlDataSourceOverride(normalized, null, !!(import.meta && import.meta.env && import.meta.env.DEV))
 
     return {
         state,

+ 138 - 102
src/modules/cockpit/services/mapService.js

@@ -43,9 +43,12 @@ import {
 import { MARKER_SIZE, planDeviceAsset } from '../domain/deviceAssets.js'
 // 批次 3:位置符号合成计划层(规格 10 的唯一新接缝)与母版合成渲染。
 import { planMarker, planToDataset } from '../domain/markerPlan.js'
+// 票 76(C3):强调量由服务层直接产出给 DOM —— 判据复用引擎同一份 domain 单源,
+// 不再回读被隐藏的 billboard 实体 `scale`。
+import { EMPHASIS_STYLE, shouldEmphasize } from '../domain/mapEmphasis.js'
 import { renderMarker } from './markerAssets.js'
 // 批次 3 第五轮(票 20):DOM overlay 渲染层(billboard 发糊的结构性解决)。
-import { mountMarkerOverlay, attachOverlayRenderer, unmountMarkerOverlay, setMarkerOverlayVisible, isMarkerOverlayVisible, isMarkerOverlayMounted } from './markerOverlay.js'
+import { mountMarkerOverlay, attachOverlayRenderer, unmountMarkerOverlay, isMarkerOverlayVisible, isMarkerOverlayMounted } from './markerOverlay.js'
 import { DEVICE_LAYER_KIND } from '../domain/deviceLayer.js'
 import { deviceVisualForStatus } from '../domain/deviceStatus.js'
 // 票 45:摘要标记(监测点/未关联设备)动效计划 —— 与设备动效同一映射与门控。
@@ -55,7 +58,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, haloSvg, glowSvg } from '../domain/pointStatus.js'
+import { markerSvg, pointLegend, summarizeMarkerStatuses } from '../domain/pointStatus.js'
 import { planPrimaryArea, primaryAreaStatusText } from '../domain/primaryArea.js'
 import {
     AREA_STATE,
@@ -305,14 +308,16 @@ export function getMapMarkerAnchor(kind, code, pointCode = '') {
     if (kind !== DEVICE_LAYER_KIND || !group) return anchor
     const isPinned = overlapExpandedGroup === group.key
     const isPreview = overlapPreviewGroup === group.key
-    if (!isPinned && !isPreview) return anchor
-    // 固定态让得更远(环固定且更大);预览态稍近(瞬时、用户还在移动)。
-    return {
-        ...anchor,
-        panelGap: isPinned ? 84 : 60,
-        // 标记外接直径:聚合点在被选择态会放大 1.12×,环按 EFFECT_PROFILE 扩散。
-        iconSize: isPinned ? 40 : 32,
-    }
+    // 票 76(走查 2026-10-26):③ 设备常态也必须让位。
+    // 用户原话:「面板让位在聚合点状态下工作正常;在设备点下依然只在 hover 时让位」——
+    // 因为 ③ 的 `overlapExpandedGroup` 为空(不固定),旧判据直接返回原始 anchor。
+    // 但 ③ 的中心已经是设备点(可能带特效环),面板仍然会压住它,所以同样要让。
+    const isCenter = Boolean(selectedDeviceCode && code === selectedDeviceCode)
+    if (!isPinned && !isPreview && !isCenter) return anchor
+    // 让位量按「环/标记的实际占地」分档:固定态最大,预览次之,设备常态(环不固定但中心有特效)居中。
+    if (isPinned) return { ...anchor, panelGap: 84, iconSize: 40 }
+    if (isPreview) return { ...anchor, panelGap: 60, iconSize: 32 }
+    return { ...anchor, panelGap: 64, iconSize: 34 }
 }
 
 let pointLocateToken = 0
@@ -496,10 +501,8 @@ function publishDevHook() {
         // 票 21:地面/管线视图切换(走查断言"切换不重置中心"用)。
         setViewMode: (mode, overrides) => setViewMode(mode, overrides),
         getViewMode: () => getViewMode(),
-        // 票 47 临时 A/B(走查对照):DOM overlay(当前版)↔ billboard(之前版)同视角互换。
-        // 只切显示不切数据链路;`toggle` 无参翻转,`get` 读当前态。
-        setMarkerOverlay: (visible) => setMarkerOverlayVisible(visible),
-        toggleMarkerOverlay: () => setMarkerOverlayVisible(!isMarkerOverlayVisible()),
+        // 票 76(C6):A/B 显隐把手已退役(`?overlay=0` / set/toggle/​setMarkerOverlay 一并摘除),
+        // 只留只读读数给走查确认 overlay 在位。
         getMarkerOverlay: () => isMarkerOverlayVisible(),
         // 票 36:主场景区(走查与人工排查读"配没配、矩形是哪一个",票 37/38 也读这一个出口)。
         getPrimaryAreaPlan: () => getPrimaryAreaPlan(),
@@ -620,10 +623,8 @@ function publishDevHook() {
         // 票 42:关联设备临时图层(走查读独立读数,验证它不进摘要口径)。
         nudgeOverlap: (pointCode) => nudgeOverlap(pointCode),
         clearOverlapNudge: () => clearOverlapNudge(),
-        pickDeviceMarker: (x, y) => {
-            if (!instance || !instance.engine || !instance.viewer) return null
-            return instance.engine.pickDeviceMarkerAt(instance.viewer, x, y)
-        },
+        // 票 76(C6):坐标 → **DOM 节点身份**(走查仍按屏幕坐标驱动同一条链路;不再走 Cesium 拾取)。
+        pickDeviceMarker: (x, y) => domMarkerAt(x, y),
         // 票 45:重叠聚合的只读状态与交互入口(聚合符号不写设备选中态)。
         getOverlapState: () => getOverlapState(),
         getOverlapTargets: () => getOverlapTargets(),
@@ -917,11 +918,13 @@ async function bootEngine(record) {
             record.host.removeEventListener('pointerdown', cancelPointLocate)
             record.host.removeEventListener('wheel', cancelPointLocate)
         }
-        // 票 10/43/23:先走设备/监测点优先链,只有没有业务点命中时才把点击交给局部增强。
+        // 票 76(C6):标记点选已**全部**由 overlay 节点的 click 触发器承担(票 47 裁定的显式触发器);
+        // 画布这一跳只剩两件事:管线(Cesium 几何,不属标记层)与「点空白收起粘性展开」。
+        // 曾经的 billboard 命中盒(GPU 拾取)不再参与任何交互路径。
         record.detachPickInput = attachPointPick(record.host, ({ x, y }) => {
-            const resolved = pickDeviceOrPointAt(x, y)
-            if (resolved.target !== 'none') return resolved
-            return pickPipelineAt(x, y)
+            const pipeline = pickPipelineAt(x, y)
+            if (pipeline.target !== 'none') return pipeline
+            return dispatchPickResult({ target: 'none', pick: null })
         })
         // 票 43:三维 hover 拾取(pointermove 观察型监听,与相机/点选互不干扰)。
         // 节流在这里做:pointermove 每秒可达上百次,每次都 scene.pick 会白烧 GPU/CPU。
@@ -966,35 +969,19 @@ async function bootEngine(record) {
         // 都锐利)+ 内层行业素材(原生分辨率去色重染)+ 真 DOM 标签。billboard 链路保留
         // (聚合/特效/Canvas 拾取仍依赖;内层素材失败时只有母版 = 降级可见)。维护者裁定:
         // DOM 完整复刻 billboard 功能后才替换它。
-        // 临时 A/B(走查对照,维护者要求):URL 带 `overlay=0` 时本页初始只显示 billboard
-        // (= 之前版本),与运行时把手 `__cockpitMap.setMarkerOverlay(bool)` 配合做同视角互换。
-        // hash 路由下参数可能在 `#` 前(`/?overlay=0#/cockpit`)也可能在 hash 内
-        // (`/#/cockpit?overlay=0`),两处都认。
-        if (typeof window !== 'undefined') {
-            const hashQuery = String(window.location.hash || '').split('?')[1] || ''
-            const off = new URLSearchParams(window.location.search).get('overlay') === '0'
-                || new URLSearchParams(hashQuery).get('overlay') === '0'
-            if (off) setMarkerOverlayVisible(false)
-        }
+        // 票 76(C6):`?overlay=0` 对照开关已退役 —— 载体终裁 DOM 胜出,billboard 视觉不再渲染,
+        // 也不再有「关掉 DOM 露出 billboard」的第二态(那种态下点选只能靠 GPU 命中盒)。
         if (mountMarkerOverlay(record.host, {
-            // 票 76(C6):hover 按 DOM 身份回传 —— billboard 视觉让位后 GPU 拾取没有命中对象,
-            // 由 overlay 通知服务层(`hoverDeviceAt` 的 fallback 与 tooltip 都读它)。
+            // 票 76(C6):hover 身份由 overlay 按**节点身份**回传(DOM 是唯一命中盒)。
             onHover: ({ kind, code }) => setDomHoverTarget(kind, code),
-            // 点击触发器:直接照搬 Canvas 点选链同一个功能函数(维护者原话)。
-            // DOM 标记与 billboard 同位同尺寸(贴地口径一致),窗口坐标拾取必然命中同一实体。
-            // 票 76(C1):聚合符号视觉由 overlay 接管后 GPU 不再渲染它(也就不可拾取),
-            // 其点击由 overlay 节点直接派发给**既有的重叠状态机** `toggleOverlap` ——
-            // 与 billboard 版点选走同一条功能路径,不另造一套交互。
-            onPick: ({ x, y, kind, code }) => {
-                // 票 76(C6):优先按 DOM 身份点选(billboard 视觉隐藏后 GPU 无命中对象);
-                // 身份认不出时退回坐标拾取 —— 两条路走同一个派发实现。
-                const byIdentity = pickMarkerByIdentity(kind, code)
-                return byIdentity.target === 'none' ? pickDeviceOrPointAt(x, y) : byIdentity
-            },
+            // 票 76(C6):点选**唯一**入口 —— 节点身份 → `pickMarkerByIdentity` → `dispatchPickResult`。
+            // GPU 拾取兜底已退役:billboard 不再渲染,命中盒与视觉同一份几何(DOM 节点),
+            // 不存在「DOM 认不出、坐标能拾到」的第三种情况。
+            onPick: ({ kind, code }) => pickMarkerByIdentity(kind, code),
         })) {
             record.detachOverlay = attachOverlayRenderer(instance.viewer, () => {
                 if (!markerPlan || !instance || !instance.viewer) return null
-                // A/B 开关关掉 DOM 层时(?overlay=0)不建帧状态:省掉三轮全实体投影。
+                // overlay 未生效时不建帧状态(挂载失败时退回 billboard 视觉)。
                 if (!isMarkerOverlayVisible()) return null
                 const colors = toneColors()
                 // 票 35 卡顿整改:镜头没动、计划没换、宿主没挪时,DOM 层不需要每帧重投几百个实体。
@@ -1055,9 +1042,9 @@ async function bootEngine(record) {
                                 : row
                         })
                     })(),
-                    // 票 47 维护者反馈①:面板强调把 billboard.scale 改到 1.55,DOM 要镜像
-                    // 同一个量(否则强调时两层一大小,看起来「设备点小了一圈」)。
-                    pointScales: engine.readPointMarkerScales(instance.viewer),
+                    // 票 76(C3):强调量由**服务层**产出(`highlightMapObject()` 记录的目标 +
+                    // domain 同一份 `shouldEmphasize`),DOM 不再回读被隐藏的 billboard scale。
+                    pointScales: pointEmphasisScales(),
                     tones: colors,
                     // 票 45:摘要标记的动效键(门控/降级判定在 domain 的计划里;设备不取 ——
                     // 关联设备的特效仍在 billboard 层,DOM 不画第二套环,同位双环会糊)。
@@ -2651,6 +2638,31 @@ function billboardVisualHidden() {
     return isMarkerOverlayMounted() && isMarkerOverlayVisible()
 }
 
+/**
+ * 票 76(C3):最近一次强调的目标编码(`highlightMapObject()` 解析后的最终列表)。
+ * 强调量从此在**服务层**推导,DOM 不再回读 billboard 实体。
+ */
+let emphasisCodes = []
+
+/**
+ * 票 76(C3):强调量由服务层**直接产出**给 DOM overlay。
+ *
+ * 此前 DOM 靠引擎回读被隐藏的 billboard `scale` ——
+ * 视觉已经不在 billboard 上,尺寸却仍以它为真值,与票 58 §四「DOM 是唯一视觉尺寸源」冲突。
+ * 现在候选编码与判据都和引擎同一份(domain `shouldEmphasize`;点位码 + 设备码数组),
+ * 只是真值改由这里算,引擎那侧不再被回读。
+ * @returns {Map<string, number>} `kind|code` → scale(1 = 常态,`EMPHASIS_STYLE.markerScale` = 强调)
+ */
+function pointEmphasisScales() {
+    const scales = new Map()
+    const wanted = new Set(emphasisCodes)
+    for (const marker of markerPlan.markers || []) {
+        const candidates = [marker.code, ...(Array.isArray(marker.deviceCodes) ? marker.deviceCodes : [])]
+        scales.set(marker.kind + '|' + marker.code, shouldEmphasize(candidates, wanted) ? EMPHASIS_STYLE.markerScale : 1)
+    }
+    return scales
+}
+
 function toneColors() {
     // 票 35 卡顿整改:`querySelector` + `getComputedStyle` 会触发布局/样式解析,
     // 原实现每帧调一次。语义色只随 tokens.css 变,进程内缓存一份即可;
@@ -3069,9 +3081,26 @@ export function syncAssociatedDevices(devices, options = {}) {
     overlapGroups = Array.isArray(options.overlapGroups)
         ? options.overlapGroups
         : groupOverlappingDevices(deviceRows, { maxFan: overlapMaxFan })
-    overlapPreviewGroup = ''
-    overlapExpandedGroup = ''
-    overlapState = OVERLAP_STATE.COLLAPSED
+    // 票 76(四态,2026-10-26 **关键修**):这里是**数据同步**,不是状态复位。
+    //
+    // 原实现无条件把 `overlapPreviewGroup`/`overlapExpandedGroup` 清空、`overlapState` 打回 COLLAPSED。
+    // 但本函数会被反复调用(设备行变化、镜头落定 `scheduleSceneApply` 重跑)——
+    // 于是用户刚点出来的 ③/④ 会被下一次同步静默清掉,表现为
+    // 「设备点进不去选择态 / 偶尔进去了又掉出来 / 点不动」。
+    //
+    // 正确做法:只保留**仍然有效**的聚合态 —— 组还在、中心设备还在,就保留;
+    // 组没了或设备不在了才复位。
+    const groupKeys = new Set(overlapGroups.filter((item) => item.k > 1).map((item) => item.key))
+    if (!overlapGroups.some((item) => item.k > 1 && item.rows.some((row) => row.code === selectedDeviceCode))) {
+        // 中心设备已不在任何聚合组里 → 聚合态无意义,复位(但不清 selectedDeviceCode 本身)。
+        overlapPreviewGroup = ''
+        overlapExpandedGroup = ''
+        overlapState = OVERLAP_STATE.COLLAPSED
+    } else {
+        // 组还在:只丢弃已失效的组键,保留当前态。
+        if (overlapPreviewGroup && !groupKeys.has(overlapPreviewGroup)) overlapPreviewGroup = ''
+        if (overlapExpandedGroup && !groupKeys.has(overlapExpandedGroup)) overlapExpandedGroup = ''
+    }
     overlapCountValue = overlapGroups.reduce((max, group) => Math.max(max, group.k || 0), 0)
 
     if (!instance || !instance.engine || !instance.viewer) {
@@ -3159,16 +3188,10 @@ function applyDeviceEffectsToEngine() {
         paintDeviceEffects()
         return false
     }
-    // 光环实体的色值必须由**服务层烘焙**后下传(引擎读不到 CSS 变量,研究笔记第 6 条):
-    // 这里复用与设备图标同一份 `toneColors()` 与 `markerSvg()`。
-    const colors = toneColors()
+    // 票 76(C5):光环实体与逐帧动效已退役 —— 引擎只接收计划与选中(用于反应量基准),
+    // 视觉(多层环/辉光/闪烁/旋转)全部由 DOM overlay 的 `buildFxLayers` + CSS 承载。
     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
@@ -3500,21 +3523,11 @@ function pickPipelineAt(clientX, clientY) {
  * @returns {{target: string, pick: object|null}}
  */
 export function pickDeviceOrPointAt(clientX, clientY) {
-    if (!instance || !instance.engine || !instance.viewer) return { target: 'none', pick: null }
-
-    const deviceHit = instance.engine.pickDeviceMarkerAt(instance.viewer, clientX, clientY)
-    const overlapHit = deviceHit && deviceHit.kind === 'monitoring-device-overlap' ? deviceHit : null
-    const actualDeviceHit = deviceHit && deviceHit.kind === 'monitoring-device' ? deviceHit : null
-    const pointHit = deviceHit ? null : instance.engine.pickPointMarkerAt(instance.viewer, clientX, clientY)
-
-    if (overlapHit || (actualDeviceHit && actualDeviceHit.overlapEntry)) {
-        const entry = overlapHit || actualDeviceHit
-        expandOverlap(entry.overlapGroup, true)
-        return resolveOverlapPick({ aggregateHit: entry })
-    }
-
-    const resolved = resolveOverlapPick({ deviceHit: actualDeviceHit, pointHit })
-    return dispatchPickResult(resolved)
+    // 票 76(C6):坐标点选改走 **DOM 命中盒**(`domMarkerAt`)—— billboard 的 GPU 拾取已退役。
+    // 保留本入口是因为 DEV 走查仍按「屏幕坐标」驱动同一条用户链路;实现里不再有 Cesium 拾取。
+    const target = domMarkerAt(clientX, clientY)
+    if (!target || !target.kind) return dispatchPickResult({ target: 'none', pick: null })
+    return pickMarkerByIdentity(target.kind, target.code)
 }
 
 /**
@@ -3614,26 +3627,27 @@ export function onDeviceHover(handler) {
  * @returns {string} 当前 hover 的设备编码(空串=没 hover 到设备)
  */
 export function hoverDeviceAt(clientX, clientY) {
-    if (!instance || !instance.engine || !instance.viewer) return ''
-    const hit = instance.engine.pickDeviceMarkerForHover(instance.viewer, clientX, clientY)
-    const overAggregate = Boolean(hit && (hit.kind === 'monitoring-device-overlap' || hit.overlapEntry))
+    if (!instance) return ''
+    // 票 76(C6):hover 身份 = DOM 命中盒(宿主 pointermove 已写过 `domHover`;
+    // DEV 走查按坐标调用时在这里补一次 DOM 命中,不再碰 Cesium 拾取)。
+    const target = domHover || domMarkerAt(clientX, clientY)
+    const overAggregate = Boolean(target && target.kind === 'overlap-aggregate' && target.code)
     if (overAggregate) {
+        const group = overlapGroups.find((item) => item.k > 1 && item.key === target.code)
         // 已固定(②④)时不再进入预览——固定态优先,hover 只做 nudge 反馈。
-        if (overlapExpandedGroup === hit.overlapGroup) return ''
-        if (!overlapExpandedGroup && overlapPreviewGroup !== hit.overlapGroup) {
+        if (!group || overlapExpandedGroup === group.key) return ''
+        if (!overlapExpandedGroup && overlapPreviewGroup !== group.key) {
             endOverlapPreview()
-            expandOverlap(hit.overlapGroup, false)
+            expandOverlap(group.key, false)
         }
-        nudgeOverlap(hit.overlapGroup)
+        nudgeOverlap(group.key)
         return '' // 聚合入口不虚构设备身份;选中设备入口保留其真实编码。
     }
     // 从聚合入口移开:只**结束预览**,不能动固定态与中心设备。
     // 2026-10-26 修(事件冲突):此前直接调 `collapseOverlap()` —— 它会清 `overlapExpandedGroup`
     // 并把状态打回 collapsed,于是 ③ 设备常态被 hover 一次就「掉出选择能力」(点不动了)。
     if (overlapPreviewGroup) endOverlapPreview()
-    // 票 76(C4/C6):billboard 视觉让位 DOM 后 GPU 拾取无命中对象 —— 用 overlay 派发来的
-    // DOM 身份补上(只在 overlay 可见时生效,避免对照态误判)。
-    const effective = hit || domHoverDeviceHit()
+    const effective = domHoverDeviceHit(target)
     const code = effective ? effective.code : ''
     if (code === hoveredDeviceCode) return code
     hoveredDeviceCode = code
@@ -3650,11 +3664,28 @@ export function setDomHoverTarget(kind, code) {
     domHover = kind && code ? { kind, code } : null
 }
 
-/** DOM hover 身份 → 与 GPU 拾取同形状的设备命中行(只认关联设备;点位有 DOM 标签 title)。 */
-function domHoverDeviceHit() {
-    if (!domHover || domHover.kind !== 'associated-device') return null
-    if (!isMarkerOverlayMounted() || !isMarkerOverlayVisible()) return null
-    const row = deviceRows.find((item) => item.code === domHover.code) || null
+/**
+ * 票 76(C6):窗口坐标 → overlay 节点身份(**DOM 命中盒**取代 Cesium 拾取)。
+ *
+ * billboard 视觉退役后,标记的命中盒就是 DOM 节点本身 —— 与视觉同一份几何,
+ * 不再有第二套像素级命中判定,也不会出现「看得见点不到」的两层不一致。
+ * 认不出(空白/未挂载)返回 null。
+ */
+function domMarkerAt(clientX, clientY) {
+    if (!Number.isFinite(clientX) || !Number.isFinite(clientY)) return null
+    if (!instance || !instance.host || !isMarkerOverlayMounted() || !isMarkerOverlayVisible()) return null
+    const doc = instance.host.ownerDocument
+    if (!doc || typeof doc.elementFromPoint !== 'function') return null
+    const element = doc.elementFromPoint(clientX, clientY)
+    const node = element && element.closest ? element.closest('.marker-overlay-node') : null
+    if (!node || !node.dataset) return null
+    return { kind: node.dataset.markerOverlayKind || '', code: node.dataset.markerOverlayCode || '' }
+}
+
+/** DOM hover 身份 → 与点选同形状的设备命中行(只认关联设备;点位有 DOM tooltip)。 */
+function domHoverDeviceHit(target) {
+    if (!target || target.kind !== 'associated-device') return null
+    const row = deviceRows.find((item) => item.code === target.code) || null
     if (!row) return null
     return {
         kind: 'monitoring-device',
@@ -3686,15 +3717,12 @@ function ensureTooltipNode(record) {
     return tooltipNode
 }
 
-/** hover 移动:拾取点位/设备标记,显示完整名称(无命中即隐藏)。 */
+/** hover 移动:按 **DOM 节点身份**显示完整名称(无命中即隐藏)。 */
 function paintMarkerTooltip(event) {
-    if (!instance || !instance.engine || !instance.viewer) return
-    const point = instance.engine.pickPointMarkerForHover(instance.viewer, event.clientX, event.clientY)
-    const device = point ? null : instance.engine.pickDeviceMarkerForHover(instance.viewer, event.clientX, event.clientY)
-    const name = (point && point.name) || (device && device.name) || (() => {
-        // 票 76(C4/C6):billboard 视觉让位 DOM 后 GPU hover 无命中对象 —— 用 overlay 派发的
-        // DOM 身份补 tooltip(点位与设备都覆盖;聚合节点没有名字,保持空)。
-        if (!domHover || !isMarkerOverlayMounted() || !isMarkerOverlayVisible()) return ''
+    if (!instance) return
+    // 票 76(C6):名称只从 DOM 身份取 —— 视觉、命中盒、tooltip 三者同一份来源。
+    const name = (() => {
+        if (!domHover) return ''
         if (domHover.kind === 'associated-device') {
             const row = deviceRows.find((item) => item.code === domHover.code)
             return row ? (row.name || '') : ''
@@ -3756,6 +3784,10 @@ export function highlightMapObject(codes) {
         return byDevice ? [code, byDevice.code] : [code]
     })
 
+    // 票 76(C3):强调目标记在服务层 —— DOM 的尺寸真值从这里出,不回读 billboard。
+    // 空数组 = 全部还原(`pointEmphasisScales()` 因此给出全 1)。
+    emphasisCodes = resolved
+
     const matched =
         instance && instance.engine && instance.viewer && resolved.length
             ? instance.engine.highlightEntities(instance.viewer, resolved)
@@ -3876,10 +3908,14 @@ function paintPicked(record, picked) {
  * @returns {{kind: string, code: string, status: string}|null}
  */
 export function pickPointAt(clientX, clientY) {
-    if (!instance || !instance.engine || !instance.viewer) return null
-
-    const picked = instance.engine.pickPointMarkerAt(instance.viewer, clientX, clientY)
-    if (!picked) return null
+    if (!instance) return null
+    // 票 76(C6):命中盒是 DOM 节点(与视觉同一份几何),只取点位标记。
+    const target = domMarkerAt(clientX, clientY)
+    if (!target || !target.code) return null
+    const marker = (markerPlan.markers || []).find((item) => item.code === target.code && item.kind === target.kind)
+    || (markerPlan.markers || []).find((item) => item.code === target.code)
+    if (!marker) return null
+    const picked = { kind: marker.kind, code: marker.code, status: marker.status, name: marker.name }
 
     lastPicked = picked
     paintPicked(instance, picked)

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

@@ -55,10 +55,12 @@ const FX_GLOW_CLASS = 'marker-overlay-fx-glow'
  * 中心辉光尺寸/亮度配合报警存在感提升。
  */
 const FX_CSS = `
-/* 票 76(标签规格 2026-10-26):标签**常态显示**(监测点 / 普通设备点 / 中心设备 / 聚合点);
-   只有「展开圈的圈内设备」需要 hover 才显示全名 —— 与维护者给的流程一致。 */
-.${NODE_CLASS}--fan .${LABEL_CLASS} { display:none; }
-.${NODE_CLASS}--fan:hover .${LABEL_CLASS} { display:block; }
+/* 票 76(标签规格,2026-10-26 终版):标签**常态显示**(监测点 / 普通设备点 / 中心设备 / 聚合点)。
+   圈内设备(--fan)**一律不显示顶部标签** —— 维护者裁定:
+     · 环内设备密集,顶部标签互相压叠、还会被图案与特效淹没看不清;
+     · 它们的完整名称由**右下角 tooltip**(paintMarkerTooltip,跟随鼠标)承担,一份就够。
+   因此这里不是「hover 才显示」,而是**根本没有**(连 hover 也不显示)。 */
+.${NODE_CLASS}--fan .${LABEL_CLASS} { display:none !important; }
 /* 选择态标识(2026-10-26 二次修正):发光用**点位本体颜色语义**(--marker-tone = 状态色),
    不再用白光。两处都生效、两种进入方式共用:
      ① 聚合点被点开(选择态);
@@ -351,7 +353,7 @@ let hostNode = null
 let hostRecord = null
 let postRenderRemove = null
 /** 临时 A/B 开关(票 47 走查):false = 隐藏 DOM 层、露出底下 billboard(之前版本)。 */
-let overlayVisible = true
+// 票 76(C6):A/B 显隐开关已退役 —— overlay 挂载即生效,billboard 视觉不再有「对照组」。
 
 /**
  * 单个标记节点(特效层 + 标记 SVG + 可选标签),一次构建、每帧只更新 transform。
@@ -481,8 +483,7 @@ export function mountMarkerOverlay(mapHost, { onPick, onHover } = {}) {
             'overflow:hidden',
             'z-index:1',
         ].join(';')
-        // 挂载时尊重当前 A/B 开关(`?overlay=0` 会在挂载前就置 false)。
-        hostNode.style.display = overlayVisible ? '' : 'none'
+        hostNode.style.display = ''
         // 票 76(C4 parity 现场把手):DOM 单层比 billboard 双层暗(镂空孔/边缘不再叠亮层)。
         // `?pinBrightness=1.2` 只做整体亮度补偿(不碰色相/饱和度);定档后写成默认常量并撤掉把手。
         try {
@@ -553,21 +554,13 @@ export function isMarkerOverlayMounted() {
     return !!(hostNode && hostNode.isConnected)
 }
 
-/**
- * 临时 A/B 开关(票 47 走查用,维护者裁定「等功能完全实现后再替换 billboard」期间的对照工具):
- * `false` 隐藏 DOM overlay 层、露出底下 billboard(= 之前版本);`true` 回到当前版本。
- * 只切显示、不切数据链路——两层同位同素材,同一视角下互换即对照。
- * 入口:DEV 把手 `__cockpitMap.setMarkerOverlay(bool)`;初始态 `?overlay=0`(mapService 读)。
- */
-export function setMarkerOverlayVisible(visible) {
-    overlayVisible = visible !== false
-    if (hostNode) hostNode.style.display = overlayVisible ? '' : 'none'
-    return overlayVisible
-}
+// 票 76(C6):`setMarkerOverlayVisible()` 与 `?overlay=0` 已退役(票面「非目标」的收口项)——
+// 载体终裁 DOM 胜出后不再需要 billboard 对照组,第 ⑤ 条验收「点选统一走 overlay click 触发器」
+// 也因此不再有需要 GPU 拾取的第二态。
 
-/** A/B 开关当前读数(走查断言用)。 */
+/** overlay 是否在生效(票 76 / C6:挂载即生效,不再有 A/B 显隐态)。 */
 export function isMarkerOverlayVisible() {
-    return overlayVisible
+    return isMarkerOverlayMounted()
 }
 
 /**
@@ -577,7 +570,8 @@ export function isMarkerOverlayVisible() {
  * @param {Array} params.markers `planPointMarkers().markers`(含 industry/tone/plan 消费字段)
  * @param {Array} [params.devices] 关联设备行(`projectDeviceMarkers()` 产出:屏幕坐标直供,
  *   scale/alpha 已含选中/hover/弱化反应量——DOM 镜像 billboard 实体真值,防「小一圈」)
- * @param {Map} [params.pointScales] `kind|code` → billboard 当前 scale(强调 1.55 等),DOM 同步镜像
+ * @param {Map} [params.pointScales] `kind|code` → 强调 scale(票 76 / C3:由**服务层**产出,
+ *   1 = 常态、`EMPHASIS_STYLE.markerScale` = 强调;不再回读 billboard 实体)
  * @param {object} params.tones token 色表(tone 键 → CSS 颜色)
  * @param {Function} params.planOf marker → plan(markerPlan.planMarker 的包装)
  * @param {Function} [params.effectOf] marker → 门控后的动效键('' = 不动画;mapService 从
@@ -640,8 +634,8 @@ export function syncMarkerOverlay({ markers, devices, aggregates, pointScales, t
         // 坐标只在真的变了才写(镜头静止时零 DOM 写入)。
         const transform = `translate(${windowPos.x}px, ${windowPos.y}px)`
         if (record.root.style.transform !== transform) record.root.style.transform = transform
-        // 强调/还原的缩放镜像:billboard.scale 被 highlightEntities(1.55)等改写时,
-        // DOM 围绕尖端缩放同一个量(transformOrigin=尖端,见 buildNode)——两层不分离。
+        // 强调/还原的缩放(票 76 / C3):量由服务层产出(不再回读 billboard.scale),
+        // DOM 围绕尖端缩放该量(transformOrigin=尖端,见 buildNode)。
         const markerScale = pointScales instanceof Map ? pointScales.get(key) : undefined
         const scaleTransform = markerScale && markerScale !== 1 ? `scale(${markerScale})` : ''
         if (record.svg.style.transform !== scaleTransform) record.svg.style.transform = scaleTransform
@@ -656,12 +650,15 @@ export function syncMarkerOverlay({ markers, devices, aggregates, pointScales, t
         wanted.add(key)
         if (!Number.isFinite(device.x) || !Number.isFinite(device.y)) continue
 
-        // 票 76(①A,维护者选 A):fan 内设备点恢复顶部标签 —— 只有「属于聚合组」的设备才带名字标签
-        // (billboard 版同样只给组内条目挂标签);未聚合的个体设备保持无标签(避免一屏设备名牌)。
+        // 票 76(标签规格 2026-10-26 终版,维护者裁定):**圈内设备不建顶部标签**。
+        // 理由:环内设备密集,顶部标签互相压叠且会被图案/特效淹没;它们的全名由右下角
+        // tooltip(`paintMarkerTooltip`)承担,一份就够。
+        // 实现:`fan` 行直接把 name 置空 → `buildNode` 不创建 label 节点(比 CSS 隐藏更彻底,
+        // 也不会因为后续 `--fan` 类切换而重新冒出来)。
         const marker = {
             kind: 'associated-device',
             code: device.code,
-            name: device.group ? (device.name || '') : '',
+            name: device.fan === true ? '' : (device.group ? (device.name || '') : ''),
             status: device.status || '',
             industry: device.industry || '',
         }