Bladeren bron

feat(cockpit): 监测点标记/特效/标签链路收口 —— 以当前版本为基准(票 70)

【基准声明】监测点相关代码(DOM 标记覆盖层、光环特效、聚合四态、标签规则、
设备图层的落地时序)**自本提交起视为完成**,以当前工作区版本作为**最高共识基准**。

一、特效强度恢复到已验收规格(2026-10-27 维护者报「特效非常微弱」)
- ALARM 环不透明度峰值 0.5 → 1.0;WARNING 0.42 → 0.85;工单旋转环 0.52 → 0.95→0.68
- 报警辉光 恒定 0.32 → 0.32→0.85 呼吸 + 亮度 1.2→1.9
- 环体由「白色细带」恢复为**状态色彩色环** + 彩色外发光 box-shadow 6/16px,
  关键帧补回自发光亮度包络 2.0→0.8
- 全部强度值收回单源 `domain/markerMetrics.js`(glowAlphaMin/Peak、glowBrightnessMin/Peak、
  ringBrightnessStart/End、spinAlphaStart/End),渲染层只消费

二、关联设备点落地时序(2026-10-27 报「点监测点定位后子设备点要等一会/刷新才出现」)
- `cameraInMotion()` 判据由 `settled`(阈值 1e-7°/0.5m,阻尼尾段要 1.1–1.5s 才算停)
  改为**驱动标志** dragging/flying/flyingView —— 阻尼尾段不再算「镜头在动」
- 推迟队列的 700ms 上限改挂**真定时器**(此前只在「下一次调度」时惰性判定,
  地图空闲时永远不到点),flush 与 unmount 两处清理
- 新增只读位 `isDeviceApplyPending()`:store 的计划指纹短路改为「计划没变**且**没有未落地订单」
  才跳过 —— 订单被清层吃掉时可自愈,不再必须点刷新

三、标签规则 / hover / 图层顺序 / 非聚合设备(2026-10-28 维护者状态机)
- 圈内设备(fan)顶部标签:常态隐藏,**只有被 hover 的那一台**显示并展开全名
- ③ 设备常态:hover **设备点**也触发展开预览环(与聚合点常态同款);②④ 固定态优先
- 标签搬进**独立图层** `marker-overlay-labels`(z-index 6 > 节点最高 3)并逐帧随节点位移 ——
  节点自带 transform 会自成层叠上下文,标签留在节点内必然被别的图标遮挡
- 非聚合设备点(k=1 关联设备 / 独立未关联设备)**不继承聚合规则**:标签常显(截断)、
  hover 展开全名、选中改用「被选择态同款」高亮(帧状态新增 selectedCode)
- 标签判据抽成纯函数(aggregateLabelState / deviceLabelState / markerLabelState)
  + 唯一出口 applyLabelState;退役失效的 CSS 后代选择器,hover 改由 JS 跟踪

四、顺带修复
- 引擎不得认识业务状态名:`ringLayersFor('ALARM')` → 单源状态无关的 `maxRingLayers()`(t09 F5)

【验证】按 AGENTS.md 未运行 build/dev/浏览器调试;离线闸门:
t70-label-rules 23/23、t70-fx-strength 14/14、t70-device-apply-latency 13/13、
t45 40/40 + 变异 11/11、t87 池隐藏 8/8 + 变异 2/2、t76 四态、t60 23/23 + 变异 10/10、
t43 40/40、t44 47/47、t47-overlap-effects(DOM 承载)、t09 38/38 全绿。
与改动文件耦合的 82 条离线闸门经 HEAD 基线逐条比对:16 红全部为既有/其它票在制,本次零新增回归。
本提交同时冻结这些 cockpit 文件在工作区的当前在制状态(维护者要求以当前版本为基准)。
ChenJZ 21 uur geleden
bovenliggende
commit
5f2ddcd047
44 gewijzigde bestanden met toevoegingen van 4431 en 835 verwijderingen
  1. 23 0
      src/modules/cockpit/CockpitShell.vue
  2. 3 3
      src/modules/cockpit/components/EquipmentDetailSections.vue
  3. 1 1
      src/modules/cockpit/components/EventDetailCard.vue
  4. 17 6
      src/modules/cockpit/components/MapCanvas.vue
  5. 2 11
      src/modules/cockpit/components/MapInfoPanel.vue
  6. 468 81
      src/modules/cockpit/components/PanelBlocks.vue
  7. 8 1
      src/modules/cockpit/components/WarningResponsePane.vue
  8. 173 0
      src/modules/cockpit/domain/chartFit.js
  9. 100 0
      src/modules/cockpit/domain/chartTip.js
  10. 81 0
      src/modules/cockpit/domain/detailFrontier.js
  11. 9 34
      src/modules/cockpit/domain/deviceAssets.js
  12. 84 190
      src/modules/cockpit/domain/deviceEffects.js
  13. 6 3
      src/modules/cockpit/domain/equipmentDetailView.js
  14. 3 0
      src/modules/cockpit/domain/eventDetail.js
  15. 10 3
      src/modules/cockpit/domain/events.js
  16. 34 0
      src/modules/cockpit/domain/fields.js
  17. 116 8
      src/modules/cockpit/domain/infrastructurePanels.js
  18. 247 0
      src/modules/cockpit/domain/ledgerLoader.js
  19. 130 0
      src/modules/cockpit/domain/lodTiers.js
  20. 21 3
      src/modules/cockpit/domain/mapLocate.js
  21. 15 4
      src/modules/cockpit/domain/mapSummary.js
  22. 385 0
      src/modules/cockpit/domain/markerMetrics.js
  23. 38 24
      src/modules/cockpit/domain/markerPlan.js
  24. 269 77
      src/modules/cockpit/domain/monitoringPanels.js
  25. 12 0
      src/modules/cockpit/domain/overviewPanels.js
  26. 184 5
      src/modules/cockpit/domain/panelBlocks.js
  27. 5 1
      src/modules/cockpit/domain/panelViews.js
  28. 1 1
      src/modules/cockpit/domain/pointAnchor.js
  29. 44 0
      src/modules/cockpit/domain/pointLocate.js
  30. 64 0
      src/modules/cockpit/domain/renderResolution.js
  31. 2 1
      src/modules/cockpit/domain/statusPie.js
  32. 11 1
      src/modules/cockpit/domain/warningResponsePanels.js
  33. 169 15
      src/modules/cockpit/services/cesiumEngine.js
  34. 33 0
      src/modules/cockpit/services/cockpitConfig.js
  35. 217 38
      src/modules/cockpit/services/mapService.js
  36. 7 7
      src/modules/cockpit/services/markerAssets.js
  37. 518 164
      src/modules/cockpit/services/markerOverlay.js
  38. 502 57
      src/modules/cockpit/services/regionLoading.js
  39. 131 1
      src/modules/cockpit/services/tilesetOptions.js
  40. 32 2
      src/modules/cockpit/stores/deviceLayerStore.js
  41. 37 2
      src/modules/cockpit/stores/eventStore.js
  42. 35 20
      src/modules/cockpit/stores/infrastructureStore.js
  43. 36 2
      src/modules/cockpit/stores/mapStore.js
  44. 148 69
      src/modules/cockpit/stores/monitoringStore.js

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

@@ -418,6 +418,7 @@
                         :view="panelViewOf(panelId)"
                         @locate="locateOnMap"
                         @open-card="openLedgerCard"
+                        @card-action="handleCardAction"
                         @badge-needed="loadLedgerBadge"
                         @panel-action="handlePanelAction"
                     />
@@ -454,6 +455,7 @@
                         :view="panelViewOf(panelId)"
                         @locate="locateOnMap"
                         @open-card="openLedgerCard"
+                        @card-action="handleCardAction"
                         @badge-needed="loadLedgerBadge"
                         @panel-action="handlePanelAction"
                     />
@@ -528,6 +530,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
 import { storeToRefs } from 'pinia'
 import { MODES, columnsForMode, labelForPanel, modeLabel, panelGrow, panelSubtitle } from './domain/panelCatalog.js'
 import { panelView, hasPanelView } from './domain/panelViews.js'
+import { CARD_ACTION } from './domain/panelBlocks.js'
 // 票 67:全局筛选(当前范围/行业)生效面的**单一措辞来源**(面板口径说明 + 管线图层那句)。
 import { PIPE_DISTRICT_NOTE } from './domain/filterCaliber.js'
 // 票 65:「预警效率」面板标题随预警/报警切换,词表复用 domain 单一词条。
@@ -876,6 +879,26 @@ function openLedgerCard(pointCode) {
     return monitoringStore.openLedgerPoint(pointCode)
 }
 
+/** 统一实体卡片分派:只复用既有入口,设施详情缺失时给显式反馈。 */
+function handleCardAction(action) {
+    if (!action || !action.code) return false
+    if (action.kind === CARD_ACTION.POINT) return selectMonitoringPoint(action.code, { mode: mode.value })
+    if (action.kind === CARD_ACTION.DEVICE) return deviceLayerStore.selectDevice(action.code)
+    if (action.kind === CARD_ACTION.EVENT) {
+        eventStore.openEvent((action.row && action.row.identityPayload) || action.row)
+        return true
+    }
+    if (action.kind === CARD_ACTION.FACILITY) {
+        infrastructureStore.setLocateFeedback({
+            result: LOCATE_RESULT.UNAVAILABLE,
+            pointCode: action.code,
+            reasonText: '设施无详情',
+        })
+        return false
+    }
+    return false
+}
+
 /**
  * 票 14:监测分布面板的 `filter-status` 出口已随「面板自带筛选入口」一并移除——
  * 状态筛选的唯一入口是地图顶部状态栏(`MapCanvas` 直接写 filterStore),

+ 3 - 3
src/modules/cockpit/components/EquipmentDetailSections.vue

@@ -121,7 +121,7 @@
                         </b>
                         <span class="equipment-section__meta">
                             <span v-if="metric.status">{{ metric.status }}</span>
-                            <span data-sampled-at>采样时间 {{ metric.sampledAt || '—' }}</span>
+                            <span data-sampled-at>{{ metric.sampledAtLabel }} {{ metric.sampledAt || '—' }}</span>
                         </span>
                     </article>
                     <p v-if="!rowsOf(panel).length" class="equipment-section__hint" data-section-empty-list>
@@ -131,7 +131,7 @@
 
                 <!--
                     预警 / 报警:条目默认可折叠(票 46)—— 折叠行给时间·名称·等级·状态·量值,
-                    展开看详情(数据时间 / 状态 / 来源 / 关联设备,缺值给占位符)。
+                    展开看详情(事件时间 / 状态 / 来源 / 关联设备,缺值给占位符)。
                     票 46 返工第 1 条:每页最多 10 条(上一页/下一页 + 页码;先筛后翻)。
                     分类 chips 在上面共用那一块。
                 -->
@@ -181,7 +181,7 @@
 
                 <!--
                     工单状态:状态名(缺则按状态字典补)+ 关键时间 + 每张工单自己的状态时间线(票 12)。
-                    票 46:条目默认可折叠,展开保留数据时间/状态/来源/关联对象 + 计划与实际结案时间。
+                    票 46:条目默认可折叠,展开保留事件时间/状态/来源/关联对象 + 计划与实际结案时间。
                     「没有动作记录」那句仍由 `timeline.hasContent` 门控(票 12 的 F9 守卫逐字钉住)。
                     票 59:接入统一分页(每页 ≤10,先分类后翻页)。
                 -->

+ 1 - 1
src/modules/cockpit/components/EventDetailCard.vue

@@ -51,7 +51,7 @@
                     </dd>
                 </div>
                 <div class="event-detail__row">
-                    <dt>时间</dt>
+                    <dt>{{ view.eventTimeLabel }}</dt>
                     <dd class="event-detail__time" data-event-detail-time>{{ view.eventTime }}</dd>
                 </div>
                 <div class="event-detail__row">

+ 17 - 6
src/modules/cockpit/components/MapCanvas.vue

@@ -152,13 +152,24 @@ const markerStatusText = computed(() => {
 const filterStore = useFilterStore()
 const { pointStatus: selectedStatuses } = storeToRefs(filterStore)
 
+// 票 45:附加状态行的**双轴解释**(保持图例可解释)。
+// 「无关联设备」的计数是后端八桶的**类别口径**(该类全部对象,含业务异常者),
+// 而地图颜色已按设备自身状态拆轴:黄绿 = 无业务异常的正常态,报警/预警/工单/离线按业务色。
+const AXIS_NOTES = {
+    UNASSOCIATED: ';颜色随设备业务状态(黄绿=无业务异常;报警/预警/工单/离线按业务色,母版仍为镂空)',
+    UNKNOWN: ';状态未发布保留本行,不猜测业务状态',
+}
+
 const statusRows = computed(() => {
     const distribution = mapStore.statusDistribution
+    const axes = mapStore.statusAxes
     return legendVisuals().map((visual) => {
         const selected = selectedStatuses.value.includes(visual.key)
-        // 分布未取到(后端还没发布八桶计数)→ 计数 null,界面显示「—」,**不补 0**
-        // 「后端没给」与「确实是 0」是两件事(票 05 的纪律)。
-        const count = distribution.available ? distribution.counts[visual.key] || 0 : null
+        // 业务轴统计所有对象的业务状态(含未关联设备自身 deviceStatus);
+        // UNASSOCIATED 另计附加轴,故一台设备可同时贡献「预警」与「无关联设备」两行。
+        const axisCounts = visual.key === 'UNASSOCIATED' ? axes.attachmentCounts : axes.businessCounts
+        const count = distribution.available ? axisCounts[visual.key] || 0 : null
+        const note = AXIS_NOTES[visual.key] || ''
         return {
             key: visual.key,
             tone: visual.tone,
@@ -170,10 +181,10 @@ const statusRows = computed(() => {
             selected,
             title:
                 count === null
-                    ? `按「${visual.label}」筛选(可多选);全局状态分布未取到,计数暂不可见`
+                    ? `按「${visual.label}」筛选(可多选);全局状态分布未取到,计数暂不可见${note}`
                     : selected
-                      ? `取消「${visual.label}」筛选(计数 ${count} 为全局未筛口径,不随筛选变化)`
-                      : `按「${visual.label}」筛选(可多选;计数 ${count} 为全局未筛口径)`,
+                      ? `取消「${visual.label}」筛选(计数 ${count} 为全局未筛口径,不随筛选变化)${note}`
+                      : `按「${visual.label}」筛选(可多选;业务轴与附加轴分开统计)${note}`,
         }
     })
 })

+ 2 - 11
src/modules/cockpit/components/MapInfoPanel.vue

@@ -41,9 +41,8 @@
             实例 {{ instanceId }} · {{ basemapLabel }}
         </p>
 
-        <div class="map-info__pipe-colors" aria-label="管道颜色语义" data-pipe-color-legend>
-            <span v-for="key in PIPE_COLOR_KEYS" :key="key" :style="{ color: PIPE_PALETTE[key].color }">● {{ PIPE_PALETTE[key].label }}</span>
-        </div>
+        <!-- 票 48:管线颜色图例已升级为一张图正下方的持久读数条(PipeColorLegend),
+             折叠面板里不再保留第二份(两处图例会互相漂移,且折叠面板不满足「持久显示")。 -->
         <p class="map-info__note" data-pipe-model-note>{{ PIPE_MODEL_NOTE }}</p>
         <p v-if="pipeModelState" class="map-info__line" data-pipe-model-status role="status">
             管道模型 {{ pipeModelState.instances }} 实例 / {{ pipeModelState.batches }} 批 ·
@@ -74,8 +73,6 @@ import { onBeforeUnmount, onMounted, ref } from 'vue'
 import CockpitIcon from './CockpitIcon.vue'
 import MapCoordinateBlocked from './MapCoordinateBlocked.vue'
 import { PIPE_MODEL_NOTE } from '../domain/pipeModels.js'
-import { PIPE_PALETTE } from '../domain/pipeVisual.js'
-const PIPE_COLOR_KEYS = ['GAS','WATER_SUPPLY','SEWAGE','RAINWATER','MANHOLE','DRAINAGE_UNKNOWN']
 
 const pipeModelState = ref(null)
 let modelHost = null
@@ -164,12 +161,6 @@ onBeforeUnmount(() => {
     max-width: 100%;
 }
 
-.map-info__pipe-colors {
-    display: flex;
-    flex-wrap: wrap;
-    gap: calc(8 * var(--u, 1px));
-}
-
 .map-info__layers {
     display: flex;
     flex-wrap: wrap;

+ 468 - 81
src/modules/cockpit/components/PanelBlocks.vue

@@ -33,7 +33,16 @@
   `data-locate-note`,另带 `data-summary="<键>"` 供按条目定位。
 -->
 <template>
-    <div ref="rootRef" class="panel-blocks" :data-panel-blocks="view.name" :data-panel-id="view.name">
+    <div
+        ref="rootRef"
+        class="panel-blocks"
+        :data-panel-blocks="view.name"
+        :data-panel-id="view.name"
+        :data-panel-design-only="view.designOnly ? '' : null"
+        :data-trend-fit-height="trendFitHeight || null"
+        :data-trend-vertical-breathing="trendFitHeight ? '9% 5%' : null"
+        :style="trendFitStyle"
+    >
         <!-- 面板级状态(色键与文案只来自 domain/sectionState.js)。票 56 二轮:整条收成折叠入口。 -->
         <header v-if="summaryItems.length" class="panel-blocks__head">
             <!--
@@ -70,13 +79,20 @@
         <!-- 还没取过数:说"正在取数",不说"没有数据"(那是在报告一件还没问过的事) -->
         <p v-if="pending" class="panel-blocks__placeholder" data-panel-pending>{{ view.placeholder }}</p>
 
-        <!-- 有内容:按块分派 -->
-        <template v-else-if="view.hasContent">
+        <!--
+            有内容:按块分派。
+            票 72(维护者 2026-10-27 反馈第 2 条):空/失败/不可用时**也走这一支**——
+            `designOnly` 的视图结构与有数据时一样,只是数值位是占位符(数值位由 domain
+            统一标 `placeholder`,渲染层不判断面板、不判断状态)。
+        -->
+        <template v-else-if="view.hasContent || view.designOnly">
             <section
-                v-for="(block, index) in view.blocks"
+                v-for="(block, index) in renderBlocks"
                 :key="`${block.kind}-${index}`"
                 class="panel-block"
                 :data-panel-block="block.kind"
+                :data-panel-supplemental="block.supplemental ? '' : null"
+                :class="{ 'is-supplemental-measuring': block.supplemental && supplementalMeasuring && !supplementalVisible }"
             >
                 <!--
                     块标题行(票 11 视觉对齐):左侧竖条 + 标题,右侧可选小字(`block.aside`)。
@@ -146,6 +162,7 @@
                             class="panel-kpi__value panel-kpi__value--link"
                             :class="{ 'is-muted': isPlaceholder(item.value) }"
                             :data-kpi-link="item.link"
+                            :data-panel-placeholder-value="isPlaceholder(item.value) ? '' : null"
                             :text="kpiReading(item)"
                             :hint="`${item.label}:${item.linkHint || '查看明细'}`"
                             @click="emit('panel-action', { kind: 'link', key: item.link })"
@@ -157,6 +174,7 @@
                             tag="b"
                             class="panel-kpi__value"
                             :class="[{ 'is-muted': isPlaceholder(item.value) }, item.valueTone ? `is-${item.valueTone}` : '']"
+                            :data-panel-placeholder-value="isPlaceholder(item.value) ? '' : null"
                             :text="kpiReading(item)"
                             :hint="item.hint"
                         >
@@ -234,13 +252,13 @@
                         <span v-if="row.icon" class="panel-card__badge" aria-hidden="true">
                             <CockpitIcon :name="row.icon" size="calc(15 * var(--u, 1px))" />
                         </span>
-                        <b class="panel-card__count" data-card-count>{{ row.count }}<em v-if="block.unit"> {{ block.unit }}</em></b>
+                        <b class="panel-card__count" data-card-count :data-panel-placeholder-value="slotAttr(row)">{{ slotCount(row) }}<em v-if="block.unit"> {{ block.unit }}</em></b>
                         <EllipsisText class="panel-card__label" :text="row.label" />
                         <!-- 小字要能吃两行,所以不套共用省略组件(它固定 nowrap);
                              完整原文仍走 title,与共用省略方法是同一条纪律的另一形态。 -->
                         <small v-if="row.note" class="panel-card__note" :title="row.noteTitle || row.note">{{ row.note }}</small>
                     </component>
-                    <p v-if="!block.rows.length" class="panel-block__empty" data-card-empty>该维度当前没有记录</p>
+                    <p v-if="!block.rows.length && !view.designOnly" class="panel-block__empty" data-card-empty>该维度当前没有记录</p>
                 </div>
 
                 <!--
@@ -279,9 +297,9 @@
                         <i v-if="block.variant === 'rank'" class="panel-bar__rank" aria-hidden="true">{{ rowIndex + 1 }}</i>
                         <EllipsisText class="panel-bar__label" :text="row.label" />
                         <i class="panel-bar__track"><i class="panel-bar__fill" :style="{ width: `${Math.round(row.ratio * 100)}%`, background: barColor(row.tone) }"></i></i>
-                        <span class="panel-bar__count" data-bar-count>{{ row.count }}<em v-if="block.unit">{{ block.unit }}</em></span>
+                        <span class="panel-bar__count" data-bar-count :data-panel-placeholder-value="slotAttr(row)">{{ slotCount(row) }}<em v-if="block.unit">{{ block.unit }}</em></span>
                     </component>
-                    <p v-if="!block.rows.length" class="panel-block__empty" data-bar-empty>该维度当前没有记录</p>
+                    <p v-if="!block.rows.length && !view.designOnly" class="panel-block__empty" data-bar-empty>该维度当前没有记录</p>
                 </div>
 
                 <!--
@@ -319,9 +337,9 @@
                         >
                             <EllipsisText class="panel-bar__label" :text="row.label" />
                             <i class="panel-bar__track"><i class="panel-bar__fill" :style="{ width: `${Math.round(row.ratio * 100)}%`, background: barColor(row.tone) }"></i></i>
-                            <span class="panel-bar__count" data-dimbar-count>{{ row.count }}</span>
+                            <span class="panel-bar__count" data-dimbar-count :data-panel-placeholder-value="slotAttr(row)">{{ slotCount(row) }}</span>
                         </div>
-                        <p v-if="!currentDim(block).rows.length" class="panel-block__empty" data-dimbar-empty>{{ block.emptyText }}</p>
+                        <p v-if="!currentDim(block).rows.length && !view.designOnly" class="panel-block__empty" data-dimbar-empty>{{ block.emptyText }}</p>
                     </div>
                     <p v-else class="panel-block__empty" data-dimbar-empty>{{ block.emptyText }}</p>
                 </div>
@@ -477,7 +495,7 @@
                             <EllipsisText class="panel-trend__label" :text="point.label" />
                         </div>
                     </template>
-                    <p v-if="!block.points.length" class="panel-block__empty" data-trend-empty>该窗口没有事件记录</p>
+                    <p v-if="!block.points.length && !view.designOnly" class="panel-block__empty" data-trend-empty>该窗口没有事件记录</p>
                 </div>
 
                 <!--
@@ -522,7 +540,7 @@
                         >
                             <i class="panel-stacked__swatch" :style="{ '--seg-color': stackedColor(row) }" aria-hidden="true"></i>
                             <EllipsisText class="panel-stacked__label" :text="row.label" />
-                            <b class="panel-stacked__count" data-stacked-count>{{ row.count }}</b>
+                            <b class="panel-stacked__count" data-stacked-count :data-panel-placeholder-value="slotAttr(row)">{{ slotCount(row) }}</b>
                         </li>
                     </ul>
                 </div>
@@ -546,7 +564,7 @@
                     :data-donut-layout="block.layout || null"
                     data-donut-list
                 >
-                    <template v-if="block.total > 0">
+                    <template v-if="block.total > 0 || block.placeholder">
                         <div class="panel-donut__wrap">
                             <svg class="panel-donut__chart" viewBox="0 0 100 100" role="group" :aria-label="block.label">
                                 <path
@@ -574,7 +592,7 @@
                                     @blur="hideChartTip(); clearChartHover(chartScope(index, block), slice.key)"
                                 ></path>
                             </svg>
-                            <b class="panel-donut__total" data-donut-total>{{ block.total }}</b>
+                            <b class="panel-donut__total" data-donut-total :data-panel-placeholder-value="block.placeholder ? '' : null">{{ slotTotal(block) }}</b>
                         </div>
                         <ul class="panel-donut__legend" data-donut-legend>
                             <component
@@ -593,7 +611,7 @@
                             >
                                 <i class="panel-donut__swatch" :style="{ background: barColor(row.tone) }" aria-hidden="true"></i>
                                 <EllipsisText class="panel-donut__label" :text="row.label" />
-                                <b class="panel-donut__count" data-donut-count>{{ row.count }}</b>
+                                <b class="panel-donut__count" data-donut-count :data-panel-placeholder-value="slotAttr(row)">{{ slotCount(row) }}</b>
                             </component>
                         </ul>
                     </template>
@@ -609,7 +627,7 @@
                     联动,浮层与放大两侧同步;外层 svg `role="group"` 让名称可达。
                 -->
                 <div v-else-if="block.kind === 'pie'" class="panel-pie" data-pie>
-                    <template v-if="block.ready && block.total > 0">
+                    <template v-if="(block.ready && block.total > 0) || block.placeholder">
                         <svg class="panel-pie__chart" viewBox="0 0 100 100" role="group" :aria-label="block.label">
                             <path
                                 v-for="slice in block.slices"
@@ -655,7 +673,7 @@
                                     StatusTag 的 tone 类解析出完全相同的 token,顶部图例也是这么取色的。
                                 -->
                                 <StatusTag :tone="row.tone" :label="row.label" :icon="row.icon || ''" />
-                                <b class="panel-pie__count" data-pie-count>{{ row.count }}</b>
+                                <b class="panel-pie__count" data-pie-count :data-panel-placeholder-value="slotAttr(row)">{{ slotCount(row) }}</b>
                             </li>
                         </ul>
                     </template>
@@ -669,52 +687,45 @@
                 <dl v-else-if="block.kind === 'rows'" class="panel-rows" data-row-list>
                     <div v-for="row in block.rows" :key="row.key" class="panel-row" :data-row="row.key">
                         <dt>{{ row.label }}</dt>
-                        <dd :class="{ 'is-muted': isPlaceholder(row.value) }">{{ row.value }}</dd>
+                        <dd
+                            :class="{ 'is-muted': isPlaceholder(row.value) }"
+                            :data-panel-placeholder-value="row.placeholder || isPlaceholder(row.value) ? '' : null"
+                        >{{ row.value }}</dd>
                     </div>
                 </dl>
-
                 <!--
-                    条目清单:两种形态共用这一块 —— 普通条目(带可定位点位)与**台账卡片**
-                    (整张卡可点,走与地图相同的两级详情)。判据是行上有没有 `code`。
-                    票 40:清单卡升级为「事件卡片」形态(样式参考当前预警面板的事件卡,
-                    维护者 2026-10-16:卡片高度不需要那么高,主要参考设计及交互效果)。
-                    整卡可点:有 `code` 的卡(台账监测点)打开两级详情;没有 `code` 的卡
-                    (运行概况异常设备)走既有 `locate` 接缝 —— 诚实的三种「点不动」反馈
-                    + 设备级详情,不新造第二条路径。卡内按钮自己 `.stop`,不二次触发。
-                    票 64:行数由 `windowOf(block)` 给 —— domain 声明 `paginate` 的块
-                    每页 ≤10(统一分页规范),其余块交出原数组,画法与行为一字不改。
+                    条目清单统一使用显式对象身份:point/device/event/facility 才绑定整卡动作,
+                    缺编码或未知类型保持普通卡片,并显示无法展开的原因。卡内定位按钮使用
+                    @click.stop,与整卡详情动作互不冲突。票 64 的分页窗口行为保持不变。
                 -->
                 <div v-else-if="block.kind === 'list'" class="panel-list" data-item-list>
                     <article
                         v-for="row in windowOf(block).rows"
                         :key="row.key"
                         class="panel-item"
-                        :class="{ 'panel-item--clickable': row.code || (row.points && row.points.length) }"
+                        :class="{ 'panel-item--clickable': cardAction(row).kind !== CARD_ACTION.NONE }"
                         :data-item="row.key"
-                        :data-ledger-card="row.code || null"
-                        @click="onCardClick(row, $event)"
+                        :data-ledger-card="cardAction(row).kind === CARD_ACTION.POINT ? cardAction(row).code : null"
+                        :data-card-action="cardAction(row).kind"
+                        :title="cardAction(row).reason || null"
+                        :tabindex="cardAction(row).kind !== CARD_ACTION.NONE ? 0 : null"
+                        @keydown.enter="cardAction(row).kind !== CARD_ACTION.NONE ? onCardClick(row, $event) : null"
+                        @click="cardAction(row).kind !== CARD_ACTION.NONE ? onCardClick(row, $event) : null"
                     >
                         <div class="panel-item__head">
-                            <!-- 有编码的行整张卡可点(台账);没有编码的只有点位按钮可点(票 17 的形态)。
-                                 票 37:标题与下面三行说明一样走**共用省略方法**(越界省略 + hover 读全)。 -->
-                            <EllipsisText
-                                v-if="row.code"
-                                tag="button"
-                                type="button"
-                                class="panel-item__title panel-item__title--button"
-                                :text="row.name"
-                                hint="点击打开两级详情"
-                                @click.stop="emit('open-card', row.code)"
-                            />
-                            <EllipsisText v-else class="panel-item__title" :text="row.title" />
+                            <!-- 整卡是否可点只认 domain 的显式 identity;标题与说明共用省略方法。 -->
+                            <EllipsisText class="panel-item__title" :text="row.name || row.title" />
                             <!-- 色键必带文字(票 06:标签不能只靠颜色);文案由 domain 给 -->
                             <StatusTag v-if="row.tone && row.toneLabel" :tone="row.tone" :label="row.toneLabel" />
                         </div>
                         <EllipsisText v-if="row.meta && row.meta.length" tag="p" class="panel-item__meta" :text="row.meta.join(' · ')" />
-                        <!-- data-data-time 是走查断言面(票 15 的 t15 闸门按源码钉住 `data-data-time>数据时间` 这个形状,
-                             属性与正文必须同行);文本走插槽,title 仍拿完整原文(票 37 的共用省略方法)。 -->
-                        <EllipsisText v-if="row.dataTime" tag="p" class="panel-item__meta" :text="`数据时间 ${row.dataTime}`" data-data-time>数据时间 {{ row.dataTime }}</EllipsisText>
+                        <!-- data-data-time 是走查断言面(票 15 的 t15 闸门按源码钉住这个形状,
+                             属性与正文必须同行);文本走插槽,title 仍拿完整原文(票 37 的共用省略方法)。
+                             标签由 domain 给(`row.dataTimeLabel`,取 `fields.js` 的 `TIME_LABELS` 单点词表),
+                             渲染层不认「数据时间」这个词、也不自己拼一个(票 62)。 -->
+                        <EllipsisText v-if="row.dataTime" tag="p" class="panel-item__meta" :text="`${row.dataTimeLabel} ${row.dataTime}`" data-data-time>{{ row.dataTimeLabel }} {{ row.dataTime }}</EllipsisText>
                         <EllipsisText v-if="row.reason" tag="p" class="panel-item__meta panel-item__reason" data-ledger-reason :text="row.reason" />
+                        <p v-if="cardAction(row).reason" class="panel-item__meta panel-item__reason" data-card-unavailable>{{ cardAction(row).reason }}</p>
                         <!-- 徽标(票 18 的台账 N+1):取到才画,没取到显示"取数中/未取到",**不画 0** -->
                         <p v-if="row.badge" class="panel-item__badge" data-ledger-badge>
                             设备 {{ row.badge.total }} · 报警 {{ row.badge.alarm }} · 预警 {{ row.badge.warning }} · 离线 {{ row.badge.offline }}
@@ -767,6 +778,18 @@
                     -->
                     <p v-if="block.pageNote" class="panel-block__page-note" data-list-page-note>{{ block.pageNote }}</p>
                 </div>
+
+                <!--
+                    票 72:块级占位符 —— 数据驱动类别的块(分布/清单/趋势)在**没有数据**时
+                    本来就没有类别名可画(球桶是后端给的)。这时不编造假类别,但也不让块空成一片:
+                    标题/单位/图例位照画,数值位由这一行占位符顶上。
+                    有值的块(KPI 卡、固定类别行)走各自的数值位,不会重复画这一行。
+                -->
+                <p
+                    v-if="view.designOnly && !blockDrawsValues(block)"
+                    class="panel-block__placeholder"
+                    data-panel-placeholder-value
+                >{{ designPlaceholder }}</p>
             </section>
 
             <!--
@@ -790,11 +813,23 @@
         <!-- 没内容:如实说(失败/空/不可用各有措辞,来自 domain) -->
         <p v-else class="panel-blocks__placeholder" data-panel-placeholder>{{ view.placeholder }}</p>
 
-        <!-- 图表 hover 详情浮层(票 37 走查扩展):锚在悬停元素上方,pointer-events:none 不挡交互 -->
+        <!-- 图表 hover 详情浮层(票 37 走查扩展):锚在悬停元素上方,pointer-events:none 不挡交互。
+             票 88:坐标是**夹取后**的 left/top(domain/chartTip.js 算),这里不再用 transform 自己偏移——
+             否则夹取结果会被 CSS 再推一次,边缘必露。
+             标签默认单行、靠左右夹取贴边展示;只有整行都放不进面板(chartTip.wrapped)时才上
+             max-width + `is-wrapped` 允许折行 —— 唯一的兜底,目的是不把字切掉。 -->
         <div
             v-if="chartTip"
+            ref="chartTipRef"
             class="chart-tip"
-            :style="{ left: `${chartTip.x}px`, top: `${chartTip.y}px`, '--tip-color': chartTip.color }"
+            :class="{ 'is-wrapped': chartTip.wrapped }"
+            :style="{
+                left: `${chartTip.left}px`,
+                top: `${chartTip.top}px`,
+                maxWidth: chartTip.wrapped ? `${chartTip.maxWidth}px` : undefined,
+                '--tip-color': chartTip.color,
+            }"
+            :data-tip-wrapped="chartTip.wrapped ? '' : null"
             data-chart-tip
         >
             <i class="chart-tip__swatch" aria-hidden="true"></i>
@@ -805,9 +840,11 @@
 </template>
 
 <script setup>
-import { computed, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
+import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
+import { chartTipMaxWidth, clampChartTip } from '../domain/chartTip.js'
+import { fitTrendBlock, fitTrendLayout, trendFitInputs } from '../domain/chartFit.js'
 import { DETAIL_PLACEHOLDER } from '../domain/detailFlow.js'
-import { sectionPending } from '../domain/panelBlocks.js'
+import { CARD_ACTION, cardActionFor, sectionPending } from '../domain/panelBlocks.js'
 import { pagedRows, readListPage, resetListPages, writeListPage } from '../domain/pagination.js'
 import CockpitIcon from './CockpitIcon.vue'
 import EllipsisText from './EllipsisText.vue'
@@ -823,29 +860,52 @@ const props = defineProps({
 })
 
 // 票 62:`ledger-more` 已退役(台账装载是自动链,界面不再有「加载下一页」入口)。
-const emit = defineEmits(['locate', 'open-card', 'badge-needed', 'panel-action', 'list-page'])
+const emit = defineEmits(['locate', 'open-card', 'card-action', 'badge-needed', 'panel-action', 'list-page'])
 
-/**
- * 票 40:整卡可点(交互参考「当前预警」的事件卡片)。
- *   · 有 `code` 的卡(台账监测点)→ 打开两级详情(与标题按钮同一出口,不新造路径);
- *   · 没有 `code` 的卡(运行概况异常设备)→ 走既有 `locate` 接缝:诚实的三种
- *     「点不动」反馈 + 设备级详情(`monitoringStore.locateCode` 的既有分流)。
- * 卡内按钮已 `.stop`,走到这里的就是卡片空白处的点击;再兜一层 closest('button')
- * 防将来有人忘了加 stop —— 双保险便宜,误触发的代价(飞镜头)不便宜。
- */
+/** 统一卡片点击:身份由 domain 显式声明,渲染层不再拿 code 猜对象类型。 */
 function onCardClick(row, event) {
     if (event && event.target && typeof event.target.closest === 'function' && event.target.closest('button')) return
-    if (row.code) {
-        emit('open-card', row.code)
-        return
-    }
-    const point = (row.points || [])[0]
-    if (point) emit('locate', point.pointCode)
+    const action = cardActionFor(row)
+    if (action.kind === CARD_ACTION.NONE) return
+    emit('card-action', { ...action, row })
+}
+
+function cardAction(row) {
+    return cardActionFor(row)
 }
 
 /** 还没取过数(界面说"正在取数",不说"没有数据")。 */
 const pending = computed(() => sectionPending(props.view.section))
 
+// 票 63:补充统计块只在面板体有真实竖向富余时进入 DOM。
+// 判据只看尺寸,不认 panelId;首帧/SSR 未测量默认隐藏,正常屏不会因此增加滚动高度。
+const supplementalVisible = ref(false)
+const supplementalMeasuring = ref(false)
+let supplementalObserver = null
+let supplementalMeasureQueued = false
+
+function syncSupplementalVisibility() {
+    const root = rootRef.value
+    const host = root && root.closest('.cockpit-panel__body')
+    if (!root || !host) {
+        supplementalVisible.value = false
+        return
+    }
+    const supplemental = (props.view.blocks || []).filter((block) => block.supplemental)
+    if (!supplemental.length || supplementalMeasureQueued) return
+    supplementalMeasureQueued = true
+    // 两阶段:先让补充块以 visibility:hidden 进入布局,下一 tick 直接比较真实 scrollHeight。
+    // 真实高度天然包含类别数造成的块高、section 间 gap 与 margin,不再依赖 150px 估算。
+    supplementalMeasuring.value = true
+    nextTick(() => {
+        supplementalMeasureQueued = false
+        const currentRoot = rootRef.value
+        const currentHost = currentRoot && currentRoot.closest('.cockpit-panel__body')
+        supplementalVisible.value = Boolean(currentRoot && currentHost && currentRoot.scrollHeight <= currentHost.clientHeight + 1)
+        supplementalMeasuring.value = false
+    })
+}
+
 // 票 56(第二轮):所有面板顶部的摘要说明统一折叠,**只有点击**才展开/收起。
 // 只有一份状态(`summaryOpen`):hover / focus 都不碰它,所以鼠标掠过不会弹开、
 // 移开也不会把点击展开的内容收掉。展开态是纯展示状态,不进 store(面板私有、即挂即弃)。
@@ -1008,6 +1068,42 @@ function isPlaceholder(text) {
     return text === DETAIL_PLACEHOLDER
 }
 
+// ---- 票 72:空态保设计(数值位占位符,不画 0)----
+//
+// 判定**只有一处**:domain 的 `emptyDesignView()` 把无数据的视图标成 `designOnly`
+// 并把每个数值位标上 `placeholder`。渲染层只回答"这一格画 `—` 还是画计数",
+// 不判断分区状态、更不按 panelId 特判(票 18 的纪律:组件不认识任何模式/面板)。
+
+/** 空态占位符字符(与缺值占位符同一个,不另造第二个)。 */
+const designPlaceholder = DETAIL_PLACEHOLDER
+
+/** 行计数:无数据的行画占位符;**真实 0 仍画 0**。 */
+function slotCount(row) {
+    if (!row) return designPlaceholder
+    return row.placeholder ? designPlaceholder : row.count
+}
+
+/** 合计(环形图中心):同上,真实 0 画 0。 */
+function slotTotal(block) {
+    if (!block) return designPlaceholder
+    if (block.placeholder) return designPlaceholder
+    return block.total
+}
+
+/** 数值位的 `data-panel-placeholder-value` 断言面(有值时不出现)。 */
+function slotAttr(row) {
+    return row && row.placeholder ? '' : null
+}
+
+/** 这个块有没有自己的数值位(没有则画块级占位符 —— 结构在位、数值位不空着)。 */
+function blockDrawsValues(block) {
+    if (!block) return false
+    if (block.kind === 'kpi') return (block.items || []).length > 0
+    if (block.kind === 'trend') return (block.points || []).length > 0
+    if (block.kind === 'dimbars') return (block.dimensions || []).some((dim) => (dim.rows || []).length > 0)
+    return (block.rows || []).length > 0
+}
+
 /**
  * 一条 KPI 读数的**完整原文**(值 + 单位),供共用省略方法当 `title` 用(票 37)。
  * 显示仍走模板里的子元素(单位是独立的 `<em>`,有自己的弱化样式),
@@ -1050,9 +1146,18 @@ function pieToneColor(tone, key) {
 // 样式不可控且与「放大」反馈不同步;面板内自绘浮层与 hover 是同一帧的。
 // 纯展示层状态:不进 store(面板私有、即挂即弃,不构成全局条件)。
 
-/** 当前悬停的图表详情(`{ label, value, color, x, y }`;null = 无悬停)。 */
+/**
+ * 当前悬停的图表详情;null = 无悬停。
+ *
+ * 字段分两半:`anchorX/anchorY` 是锚点(悬停元素上缘中点,留着做渲染后二次夹取),
+ * `left/top` 是**夹取后**的最终坐标;`maxWidth` 是可用宽度、`wrapped` 只在「整行放不下」时为 true。
+ * `naturalWidth` 是第一级测量量到的**单行自然宽度**(见 placeChartTip 的两级测量说明)。
+ */
 const chartTip = ref(null)
 
+/** 浮层元素本身:渲染后用它量**真实**宽高(标签换行后的高度只能量、不能估)。 */
+const chartTipRef = ref(null)
+
 /**
  * 票 43(第三轮根因):联动状态必须用 **shallowRef**,不能用 `ref`。
  * `ref(null)` 存入 `{owner: block}` 后会被 Vue **深层响应式代理**——读回的
@@ -1094,26 +1199,107 @@ function isChartHovered(scope, key) {
     return !!current && current.scope === scope && current.key === key
 }
 
-/** 图表元素悬停 → 记录详情与浮层锚点(相对 `.panel-blocks` 根,避免随滚动漂移)。 */
+/**
+ * 图表元素悬停 → 记录详情与浮层锚点(相对 `.panel-blocks` 根,避免随滚动漂移),
+ * 并在**渲染后**按实测尺寸夹取位置(票 73)。
+ *
+ * 为什么必须二次测量:浮层的宽高由标签文字在 max-width 内的真实排版决定 ——
+ * 同一句长标签可能一行也可能两行,高度能差一行;而此刻元素还没渲染出来,量不到。
+ * CSS 也没法把「自身尺寸」喂回给它自己的坐标(那正是旧 `translate(-50%, -100%)` 的做法,
+ * 它对面板边界一无所知)。所以第一帧只按锚点粗放,`nextTick` 里量一次、调
+ * `clampChartTip(...)` 写回最终坐标。
+ *
+ * 为什么这不会闪一下:`nextTick` 的回调在浏览器**绘制之前**跑完(同一个任务内的微任务),
+ * 粗放那一帧不会被画出来;若指针已移开(`chartTip` 被置 null),回调直接早退。
+ */
 function showChartTip(event, label, value, color) {
     const root = rootRef.value
     if (!root || !event.currentTarget) return
     const rect = root.getBoundingClientRect()
     const host = event.currentTarget.getBoundingClientRect()
-    const x = host.left + host.width / 2 - rect.left
-    const y = host.top - rect.top
-    // 面板底部/顶部被滚动裁切时,把浮层移到元素下方,避免悬停详情跑出可视区域。
-    const top = Math.max(8, y)
+    // 锚点=悬停元素**上缘中点**。
+    const anchorX = host.left + host.width / 2 - rect.left
+    const anchorY = host.top - rect.top
     chartTip.value = {
         label,
         value,
         color,
-        x,
-        y: top,
+        anchorX,
+        anchorY,
+        // 粗放值:只用来让元素出现在锚点附近(不会绘制),真实坐标由 placeChartTip 写回。
+        left: Math.round(anchorX),
+        top: Math.round(anchorY),
+        // 可用宽度先算好(只在极端情形充当内联 max-width);naturalWidth 待第一级测量填。
+        maxWidth: chartTipMaxWidth(root.clientWidth),
+        wrapped: false,
+        naturalWidth: 0,
     }
+    nextTick(placeChartTip)
 }
 
-/** 离开图表元素:收起浮层(除非已移入浮层本身——浮层 pointer-events:none,不需要)。 */
+/**
+ * 浮层真正会被谁裁掉:从面板根往上找**第一个** overflow 不是 visible 的祖先
+ * (面板体 `.cockpit-panel__body` 是 `overflow-y: auto` 的滚动容器),把「仍可见的那一段」
+ * 换算成面板内坐标。找不到裁切祖先时就是整个面板。
+ *
+ * 为什么不能直接用面板根的高度:面板内容比可视区高时,面板根可以很长,而看得见的只是
+ * 滚动窗口那一段 —— 只按面板根夹取,浮层照样会被滚动容器切掉(维护者看到的「出框」换个方向)。
+ */
+function chartTipVisibleViewport(root) {
+    const rootRect = root.getBoundingClientRect()
+    let clip = root.parentElement
+    while (clip) {
+        const style = getComputedStyle(clip)
+        if ((style.overflowY && style.overflowY !== 'visible')
+            || (style.overflowX && style.overflowX !== 'visible')) break
+        clip = clip.parentElement
+    }
+    if (!clip) return { viewTop: 0, viewBottom: rootRect.height }
+    const rect = clip.getBoundingClientRect()
+    return {
+        viewTop: Math.max(0, rect.top - rootRect.top),
+        viewBottom: Math.min(rootRect.height, rect.bottom - rootRect.top),
+    }
+}
+
+/**
+ * 渲染后把浮层夹进面板:**测量在这里,数学在 `domain/chartTip.js`**。
+ * 组件不做夹取算术(两份算术必然漂移),只把实测值喂进纯函数、把结果写回样式。
+ *
+ * 为什么需要**两级测量**(票 88 的单行契约让它变成两步):
+ *   一级 —— 量「整行自然宽度」。此刻浮层是单行、没有 max-width,量到的才是真实文案宽度;
+ *          纯函数拿它判断「整行放不放得进面板」。**必须把这个值记住**:一旦按极端情形上了
+ *          max-width,再量到的宽度已经被收窄,用它判折行就永远判不出来。
+ *   二级 —— 只有极端情形(整行放不下)刚给了 max-width → 标签折行、高度变了,
+ *          上缘位置必须按**折行后的真实高度**重算,否则浮层会压住锚点或越出上沿。
+ *          所以只在这种情况下再排一次 nextTick 重量(一次即收敛:wrapped 不会再翻回去)。
+ */
+function placeChartTip() {
+    const tip = chartTip.value
+    const root = rootRef.value
+    const element = chartTipRef.value
+    if (!tip || !root || !element) return
+    // 面板尺寸取 clientWidth/clientHeight:绝对定位的包含块是**padding box**,它们正是这个盒子;
+    // getBoundingClientRect 是 border box(含边框与滚动条),会把可用的上限算宽一点。
+    const measured = element.getBoundingClientRect()
+    const naturalWidth = tip.naturalWidth > 0 ? tip.naturalWidth : measured.width
+    const visible = chartTipVisibleViewport(root)
+    const next = clampChartTip({
+        panelWidth: root.clientWidth,
+        panelHeight: root.clientHeight,
+        tipWidth: naturalWidth,
+        tipHeight: measured.height,
+        anchorX: tip.anchorX,
+        anchorY: tip.anchorY,
+        viewTop: visible.viewTop,
+        viewBottom: visible.viewBottom,
+    })
+    const justWrapped = next.wrapped && !tip.wrapped
+    chartTip.value = { ...tip, naturalWidth, ...next }
+    if (justWrapped) nextTick(placeChartTip)
+}
+
+/** 离开图表元素:收起浮层(浮层 pointer-events:none,不需要考虑指针进浮层;挂起的 nextTick 会因 null 早退)。 */
 function hideChartTip() {
     chartTip.value = null
 }
@@ -1206,7 +1392,155 @@ function observeCards() {
     }
 }
 
+// ---- 票 64:折线图按容器可用高度拉伸(宽屏填充),并按实测尺寸重算纵轴 ----
+//
+// 为什么高度必须由渲染层量、而不能只靠 CSS flex:1:面板高度是外壳按**内容权重**用 flex
+// 分配的(CockpitPanel.vue),撑起面板高度的是块标题/KPI/清单这些块,折线图不是。
+// 祖先链里没有一个是"高度已定、内容可伸缩"的容器 —— 若把 .cockpit-panel__content 到图表
+// 这一链改成 flex,量到的内容高度会变成**分配到的面板高度**,权重自我强化,票 11 的
+// 「面板高度按内容自适应」当场失效。所以按票面允许的另一半做:**观察容器尺寸**,
+// 把「还剩多少高度」算出来写回 CSS 变量,由样式决定画多高(阈值与判据都在
+// domain/chartFit.js,组件只量不算)。
+//
+// 收敛性("观察器 + 写样式"最容易抖的地方):净空间必须由**不随图表高度变化**的量算出 ——
+//   净空间 = 面板体内容高 − 其它块的自然高(= root.scrollHeight − 各折线块当前高度)
+// 图表长高多少,其它块就少算多少,于是下一轮量到的净空间分毫不变:一次收敛,不会来回抖。
+
+/** 折线布局预算:高度 + 真实落到图表内部的上/下 padding。0 高度 = 正常屏不接管。 */
+const trendFitLayout = ref({ heightPx: 0, paddingTopPx: 0, paddingBottomPx: 0 })
+const trendFitHeight = computed(() => trendFitLayout.value.heightPx)
+
+/** 每块折线图的实测盒(供 domain 重算刻度):**渲染顺序**序号 → { heightPx, widthPx }。 */
+const trendBoxes = ref({})
+
+let trendObserver = null
+/** 写高度与重量绘图区之间的重入闸:样式是异步生效的,量要在下一帧再读。 */
+let trendFitting = false
+
+/**
+ * view.blocks 里折线块(含多序列)的序号 —— 与 DOM 里 .panel-trend--line 的出现顺序
+ * 一一对应(同一个 v-for、同一个判据;渲染顺序=块顺序,所以两个下标能对上)。
+ */
+function visibleBlocks() {
+    return (props.view.blocks || []).filter((block) => !block.supplemental || supplementalVisible.value || supplementalMeasuring.value)
+}
+
+const lineTrendIndexes = computed(() =>
+    visibleBlocks().reduce((list, block, index) => {
+        if (block.kind === 'trend' && block.chart === 'line' && (block.linePoints || []).length >= 2) list.push(index)
+        return list
+    }, []),
+)
+
+/**
+ * 真正渲染的块:折线块按实测盒重算几何,其余块**原样**(引用不变,既有渲染零影响)。
+ * 还没量到尺寸时不套 fitTrendBlock(它原样返回),首帧就是 domain 的定值。
+ */
+const renderBlocks = computed(() => {
+    const blocks = visibleBlocks()
+    const indexes = lineTrendIndexes.value
+    if (!indexes.length || !Object.keys(trendBoxes.value).length) return blocks
+    const fitted = blocks.slice()
+    indexes.forEach((blockIndex, order) => {
+        const box = trendBoxes.value[order]
+        if (box) fitted[blockIndex] = fitTrendBlock(blocks[blockIndex], box)
+    })
+    return fitted
+})
+
+/** 折线高度写回样式(0 时不下发变量,CSS 的 min-height 说了算)。
+ * 回调的语义是:宽屏拉伸时从可用净高里给图表**顶部留 9%、底部留 5%**;
+ * 上方小标题与点顶数值更易拥挤,故上多下少。不是按 panelId 定高;比例归 domain。 */
+const trendFitStyle = computed(() => trendFitHeight.value > 0 ? {
+    '--trend-fit-height': trendFitHeight.value + 'px',
+    '--trend-fit-padding-top': trendFitLayout.value.paddingTopPx + 'px',
+    '--trend-fit-padding-bottom': trendFitLayout.value.paddingBottomPx + 'px',
+} : null)
+
+/**
+ * 面板体可用高度扣掉其它块自然高后均分。**测量在这里,算式在 fitTrendLayout()**:
+ * 高度拿满净预算;顶部 9% / 底部 5% 作为图表内部 padding 明确写进 CSS。
+ * 这修掉了「只缩短块高、剩余空间落在块下方,标题到绘图区间距毫无变化」的真机缺陷。
+ */
+function syncTrendHeight(charts, root) {
+    const host = root.closest('.cockpit-panel__body')
+    if (!host) return
+    const style = getComputedStyle(host)
+    const next = fitTrendLayout(trendFitInputs({
+        hostHeightPx: host.clientHeight,
+        paddingTopPx: parseFloat(style.paddingTop),
+        paddingBottomPx: parseFloat(style.paddingBottom),
+        contentHeightPx: root.scrollHeight,
+        chartHeightsPx: charts.map((element) => element.offsetHeight),
+    }))
+    const current = trendFitLayout.value
+    if (Math.abs(next.heightPx - current.heightPx) > 1
+        || Math.abs(next.paddingTopPx - current.paddingTopPx) > 1
+        || Math.abs(next.paddingBottomPx - current.paddingBottomPx) > 1) {
+        trendFitLayout.value = next
+    }
+}
+
+/** 密度:量每块折线图的绘图区高度与横轴宽度,交给 domain 重算刻度与日期标签。 */
+function syncTrendBoxes(charts) {
+    const boxes = charts.map((chart) => {
+        const plot = chart.querySelector('[data-trend-chart]')
+        const axis = chart.querySelector('[data-trend-axis]')
+        return {
+            heightPx: plot ? plot.clientHeight : 0,
+            widthPx: axis ? axis.clientWidth : 0,
+        }
+    })
+    const previous = trendBoxes.value
+    const changed = boxes.length !== Object.keys(previous).length
+        || boxes.some((box, order) => {
+            const before = previous[order]
+            return !before || before.heightPx !== box.heightPx || before.widthPx !== box.widthPx
+        })
+    if (changed) trendBoxes.value = boxes
+}
+
+/**
+ * 一次量到收敛:先定高度(写回 CSS),下一帧再量绘图区(那时已是新高度)。
+ * 写高度会让 root 变高 → 观察器再触发一轮;那一轮算出的净空间与上一轮相同 ⇒
+ * next 不变 ⇒ 不再写样式 ⇒ 观察器自然停下。
+ */
+function syncTrendFit() {
+    const root = rootRef.value
+    if (!root || trendFitting) return
+    const charts = [...root.querySelectorAll('.panel-trend--line')]
+    if (!charts.length) {
+        if (trendFitHeight.value !== 0) trendFitLayout.value = { heightPx: 0, paddingTopPx: 0, paddingBottomPx: 0 }
+        return
+    }
+    trendFitting = true
+    syncTrendHeight(charts, root)
+    nextTick(() => {
+        trendFitting = false
+        const current = rootRef.value
+        if (!current) return
+        syncTrendBoxes([...current.querySelectorAll('.panel-trend--line')])
+    })
+}
+
 onMounted(() => {
+    // 票 64:折线自适应跟着**面板体**的高度走(宽屏栏内并排、窗口缩放、收起再展开都会变)。
+    // 挂在 onMounted 最前面:下面 IntersectionObserver 缺失时那条早退分支只与徽标有关。
+    const trendRoot = rootRef.value
+    if (typeof ResizeObserver === 'function' && trendRoot) {
+        trendObserver = new ResizeObserver(() => syncTrendFit())
+        trendObserver.observe(trendRoot)
+        const host = trendRoot.closest('.cockpit-panel__body')
+        if (host) trendObserver.observe(host)
+    }
+    syncTrendFit()
+    syncSupplementalVisibility()
+    if (typeof ResizeObserver === 'function' && trendRoot) {
+        supplementalObserver = new ResizeObserver(syncSupplementalVisibility)
+        const host = trendRoot.closest('.cockpit-panel__body')
+        if (host) supplementalObserver.observe(host)
+    }
+
     if (typeof IntersectionObserver === 'undefined') {
         // 没有 IntersectionObserver 时**退化为全要**:徽标是可选增强,不该因此整块不显示。
         // 票 62:只覆盖**当前页窗口**的行(DOM 窗口与视图窗口同一份 `windowOf`)。
@@ -1237,10 +1571,15 @@ onMounted(() => {
     observeCards()
 })
 
-// 分页追加、或换筛选后重渲染:新卡片要补进观察集。
+// 分页追加、或换筛选后重渲染:新卡片要补进观察集;
+// 块集合变了折线图也跟着变,重量一次(票 64)。
 watch(
     () => props.view.blocks,
-    () => observeCards(),
+    () => {
+        observeCards()
+        supplementalVisible.value = false
+        nextTick(() => { syncSupplementalVisibility(); syncTrendFit() })
+    },
     { deep: false },
 )
 
@@ -1249,11 +1588,22 @@ onBeforeUnmount(() => {
         observer.disconnect()
         observer = null
     }
+    if (trendObserver) {
+        trendObserver.disconnect()
+        trendObserver = null
+    }
+    if (supplementalObserver) {
+        supplementalObserver.disconnect()
+        supplementalObserver = null
+    }
     requested.clear()
 })
 </script>
 
 <style scoped>
+/* 两阶段真实测量:占布局但不可见,决定放得下后下一帧正式显示。 */
+.panel-block.is-supplemental-measuring { visibility: hidden; pointer-events: none; }
+
 .panel-summary-disclosure {
     position: relative;
     /* 头部是 flex-wrap 容器:折叠入口独占一行(`flex-basis: 100%`),长句才能在栏宽内换行,
@@ -2020,9 +2370,13 @@ onBeforeUnmount(() => {
     display: grid;
     grid-template-rows: minmax(0, 1fr) auto;
     gap: calc(2 * var(--u, 1px));
-    /* 2026-10-23 压矮:78u→64u(报警处置面板底部折线;点数与数值标注不变)。 */
-    height: calc(64 * var(--u, 1px));
-    padding: calc(6 * var(--u, 1px)) 0 calc(1 * var(--u, 1px));
+    /* 票 64:宽屏时高度拿满净预算,呼吸空间**明确落到内部 padding**。
+       旧实现只把 14% 从 height 扣掉,CSS grid 把剩余空间放在块后面,标题→绘图区间距仍是固定 6u,
+       所以目视毫无差别;真机反馈环已实测该根因。正常屏仍回退 6u / 1u,不改原观感。 */
+    height: var(--trend-fit-height, calc(64 * var(--u, 1px)));
+    min-height: calc(64 * var(--u, 1px));
+    padding: var(--trend-fit-padding-top, calc(6 * var(--u, 1px))) 0
+        var(--trend-fit-padding-bottom, calc(1 * var(--u, 1px)));
 }
 
 .panel-trend__chart {
@@ -2625,6 +2979,7 @@ onBeforeUnmount(() => {
 }
 
 .panel-block__empty,
+.panel-block__placeholder,
 .panel-block__page-note,
 .panel-blocks__placeholder,
 .panel-blocks__missing,
@@ -2639,6 +2994,19 @@ onBeforeUnmount(() => {
     color: var(--lv2, #ffb020);
 }
 
+/* 票 72:无数据时的块级占位符 —— 就画在数值本该在的位置,只画一个占位符字符,
+   不画一句"没有数据"把整块顶掉。等宽且加虚线上框,读起来就是"这里本该有个数"。 */
+.panel-block__placeholder {
+    min-height: calc(18 * var(--u, 1px));
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    border: 1px dashed rgba(86, 128, 176, 0.35);
+    border-radius: calc(3 * var(--u, 1px));
+    font-variant-numeric: tabular-nums;
+    letter-spacing: 0.08em;
+}
+
 /* ================================================================
    图表 hover 交互(2026-10-14 维护者走查要求:悬停放大 + 数据详情浮层)。
    放大 = 各图自己的一档 transform(过渡 0.15s,出离即还原);
@@ -2755,7 +3123,11 @@ onBeforeUnmount(() => {
     filter: brightness(1.3);
 }
 
-/* 共享详情浮层:锚点在悬停元素上缘中点,自身再向上偏移并水平居中。
+/* 共享详情浮层(票 88 第二版):坐标由 domain/chartTip.js 夹取后写成内联 left/top,
+   这里**不再**用 transform 自己偏移 —— 那会把夹取结果再推一次,面板边缘必露。
+   默认**单行**:靠近左右边缘时靠横向夹取贴边展示(维护者 2026-10-27 二次反馈:
+   「直接在标签靠近要被裁切的边缘时,靠紧边缘展示即可」—— 第一版的换行/上翻下方观感更差,已撤销)。
+   所以基础规则里**没有** max-width:上了 max-width 就量不到单行自然宽度,判定不出极端情形。
    pointer-events:none —— 浮层不拦截鼠标,移开即消失;z-index 盖过图表但不盖面板头。 */
 .chart-tip {
     position: absolute;
@@ -2768,11 +3140,17 @@ onBeforeUnmount(() => {
     border-radius: calc(8 * var(--u, 1px));
     background: rgba(2, 10, 24, 0.92);
     box-shadow: 0 4px 14px rgba(0, 0, 0, 0.5), 0 0 8px color-mix(in srgb, var(--tip-color, #01abea) 55%, transparent);
-    transform: translate(-50%, calc(-100% - 6px));
     pointer-events: none;
     white-space: nowrap;
 }
 
+/* 唯一的兜底:整行都放不进面板(极端情形)才允许折行,宁可两行也不许把字切掉。
+   8px = 2 × CHART_TIP_EDGE,与纯函数的 maxWidth 是同一条规则(t86 钉住这个等式)。 */
+.chart-tip.is-wrapped {
+    max-width: calc(100% - 8px);
+    white-space: normal;
+}
+
 .chart-tip__swatch {
     flex: none;
     width: calc(7 * var(--u, 1px));
@@ -2785,9 +3163,18 @@ onBeforeUnmount(() => {
     font-size: var(--fs-caption, 10.5px);
 }
 
+/* 折行只属于极端情形:anywhere 才能让 min-content 收到 1 个字,flex 才压得下去。 */
+.chart-tip.is-wrapped .chart-tip__label {
+    min-width: 0;
+    overflow-wrap: anywhere;
+}
+
 .chart-tip__value {
     color: var(--text-strong, #fff);
     font-family: var(--num, monospace);
     font-size: var(--fs-caption, 10.5px);
+    /* 数值整体不折行、也不被标签挤扁(nowrap + flex:none 两半缺一不可)。 */
+    white-space: nowrap;
+    flex: none;
 }
 </style>

+ 8 - 1
src/modules/cockpit/components/WarningResponsePane.vue

@@ -31,7 +31,7 @@
             {{ rangeLabel }} · {{ eventStore.loading ? '正在更新…' : '当前统计窗口' }}
         </p>
         <p v-if="!eventStore.loading && section && section.reason && /历史接口回显/.test(section.reason)" class="warning-response-pane__range-status" role="alert">{{ section.reason }}</p>
-        <PanelBlocks v-if="view && !eventStore.loading" :key="`${panelId}-${eventStore.mode}-${eventStore.range}`" :view="view" @list-page="onListPage" @open-card="openWorkOrder" />
+        <PanelBlocks v-if="view && !eventStore.loading" :key="`${panelId}-${eventStore.mode}-${eventStore.range}`" :view="view" @list-page="onListPage" @open-card="openWorkOrder" @card-action="onCardAction" />
         <!--
             票 65:工单时间线浮层。报警效率面板的工单卡片点开后在这里看轨迹。
             为什么不在面板里展开:卡片列表本身已经很长,内联展开会把下方内容推走;
@@ -121,6 +121,13 @@ const timelineFailed = computed(() => trackState.value === 'failed')
  * 而卡片上的状态与描述本来就已经在手上,先画出来才是即时反馈。
  * 轨迹取不到时如实说「未取到」,不把"没取到"画成"这张工单没有动作记录"(票 12/票 11 纪律)。
  */
+function onCardAction(action) {
+    const code = action && action.row && action.row.workOrderCode
+        ? action.row.workOrderCode
+        : action && action.code
+    return openWorkOrder(code)
+}
+
 async function openWorkOrder(code) {
     const list = (view.value.blocks || []).find((block) => block.kind === 'list')
     const row = ((list && list.rows) || []).find((item) => item.code === code)

+ 173 - 0
src/modules/cockpit/domain/chartFit.js

@@ -0,0 +1,173 @@
+/**
+ * 折线图的**尺寸自适应重算**(票 64:宽屏填充高度并重算纵轴)。
+ *
+ * 分工:几何形状归 domain,可用尺寸归渲染层。
+ *   · `trendBlock()` 只按数据算归一化点列(它不知道容器多大,也不该知道),
+ *     给出的 `yTicks`/`axisIndices` 是**正常屏的定值**(3 档纵轴、最多 4 个日期标签);
+ *   · 渲染层量到容器真实像素后,把 `heightPx`/`widthPx` 喂进本模块,
+ *     由它回答「这个高度放几档刻度、这个宽度留几个日期标签」。
+ *
+ * 为什么判据是像素而不是 `--u`:刻度与日期标签的字号是固定像素(`--fs-caption` 10.5px),
+ * 不随 `--u` 缩放;「会不会挤在一起」是排版事实,按真实像素判才成立。
+ * 也**没有**视口断点:宽屏与否由渲染层量到的可用高度决定,本模块只认那个数。
+ */
+
+/** 一格网格的最小高度(px):10.5px 等宽标签 + 上下一线呼吸感,再低就互相压。 */
+export const TICK_MIN_PX = 26
+
+
+/**
+ * 测量 → 算式 的接线(票 64):渲染层量到的几个数 → `fitTrendHeight()` 的入参。
+ *
+ * 为什么这一步也要放 domain:`othersPx` 的定义是"**其它块**的自然高",
+ * 它必须从内容总高里**扣掉折线块自己当前的高度** —— 不扣的话富余恒 ≤ 0、
+ * 整个特性静默失效(屏上只是"没生效",没有任何报错)。这种静默失效必须能被离线断言
+ * (见 t64 闸门的 C10),所以定义不能留在组件里靠字符串守卫看着。
+ */
+export function trendFitInputs({ hostHeightPx, paddingTopPx = 0, paddingBottomPx = 0, contentHeightPx, chartHeightsPx = [] } = {}) {
+    const heights = chartHeightsPx.map((value) => Number(value) || 0)
+    return {
+        budgetPx: (Number(hostHeightPx) || 0) - (Number(paddingTopPx) || 0) - (Number(paddingBottomPx) || 0),
+        othersPx: (Number(contentHeightPx) || 0) - heights.reduce((sum, value) => sum + value, 0),
+        count: heights.length,
+    }
+}
+
+/** 宽屏折线图顶部呼吸空间:可用净高的 9%(小标题/点顶数值在上方,必须比下方更宽松)。 */
+export const TREND_TOP_BREATHING_RATIO = 0.09
+
+/** 宽屏折线图底部呼吸空间:可用净高的 5%(X 轴已有独立标签行,下方可少留)。 */
+export const TREND_BOTTOM_BREATHING_RATIO = 0.05
+
+/** 上下呼吸空间合计(14%):比上一轮 12% 小幅增加,并把新增空间集中到上方。 */
+export const TREND_VERTICAL_BREATHING_RATIO = TREND_TOP_BREATHING_RATIO + TREND_BOTTOM_BREATHING_RATIO
+
+/**
+ * 宽屏折线的真实布局预算:呼吸空间必须**落到图表内部的 padding**,不能只从总高扣掉。
+ *
+ * 返回 0 表示不接管(正常屏仍由 64u 下限与既有 padding 管);接管时:
+ *   · heightPx = 每张折线拿满自己的可用净高(只留 1px 取整护栏);
+ *   · paddingTopPx / paddingBottomPx = 按净高 9% / 5% 明确写进 CSS。
+ * 这样留下的空间就在标题与绘图区之间,而不是落到整个块的下方。
+ */
+export function fitTrendLayout({ budgetPx, othersPx, count } = {}) {
+    const budget = Number(budgetPx) || 0
+    const others = Number(othersPx) || 0
+    const charts = Math.max(1, Math.floor(Number(count) || 0))
+    const available = (budget - others) / charts
+    if (!(available > 1)) return { heightPx: 0, paddingTopPx: 0, paddingBottomPx: 0 }
+    return {
+        heightPx: Math.floor(available) - 1,
+        paddingTopPx: Math.floor(available * TREND_TOP_BREATHING_RATIO),
+        paddingBottomPx: Math.floor(available * TREND_BOTTOM_BREATHING_RATIO),
+    }
+}
+
+/**
+ * 单块折线图该分到多高(px)。**0 = 不接管**,由样式的 `min-height` 兜住正常屏设计高度。
+ * 保留这个导出给既有调用/闸门;新渲染层用 `fitTrendLayout()` 同时拿高与上下 padding。
+ *
+ * 这是票 64 要求 3(「不得把正常屏的图表撑高挤出滚动条」)的**唯一算式**,
+ * 所以放在 domain 里而不是组件里:可以离线断言,也不会和渲染层长出第二份算术。
+ *
+ * @param {{budgetPx?: number, othersPx?: number, count?: number}} box
+ *   budgetPx 面板体可用高度(内容盒);othersPx 同面板其它块占掉的高度;
+ *   count 同面板折线块个数(富余要均分,摊多了就是滚动条)。
+ */
+export function fitTrendHeight(box = {}) {
+    return fitTrendLayout(box).heightPx
+}
+
+/** 一个横轴日期标签(如 `MM-DD`,5 个等宽字符)的最小占位宽度(px)。 */
+export const AXIS_LABEL_MIN_PX = 46
+
+/**
+ * 刻度条数档位 —— 只放能整除 100 的档:3 档=0/半程/满、5 档=四分、9 档=八分。
+ * 不放 4/6/7 档:它们的刻度值是 max 的 1/3、1/6 这类除不尽的数,
+ * 屏上就变成 `2.33 / 4.67` 这种「精确但没用」的读数(票 11 的等宽数字会被撑宽)。
+ */
+const TICK_COUNTS = [3, 5, 9]
+
+/**
+ * 该高度能放下几档刻度,返回自上而下(0% → 100%)的比例,最后一项恒为 100。
+ * 单调:高度只增不减 ⇒ 档位只会往更大档走(3 → 5 → 9),不会来回跳。
+ */
+export function tickRatios(heightPx) {
+    const fit = Math.floor((Number(heightPx) || 0) / TICK_MIN_PX)
+    let count = TICK_COUNTS[0]
+    for (const candidate of TICK_COUNTS) {
+        if (fit >= candidate) count = candidate
+    }
+    return Array.from({ length: count }, (_, i) => (i / (count - 1)) * 100)
+}
+
+/**
+ * 刻度标签的可读化取整:**只取整标签,不改几何**(`pct` 仍是真实比例),
+ * 且恒有 `value ≤ max`(pct ≤ 100)——不外包络、不外插。
+ * 大数取整、小数留一位(与既有的 `max × 1/2` 同口径,如 max=3 时中档仍是 1.5)。
+ */
+function tickValue(max, pct) {
+    const raw = (max * pct) / 100
+    return max >= 10 ? Math.round(raw) : Math.round(raw * 10) / 10
+}
+
+/**
+ * 折线纵轴刻度:零序列只画底部 0(与 `trendBlock()` 的既有口径一字不差),
+ * 非零序列按可用高度给 3/5/9 档。
+ * @param {number} max 归一化用的最大值(多序列时由调用方给联合最大值)
+ * @param {number} heightPx 绘图区实测高度
+ */
+export function trendTicks(max, heightPx) {
+    const top = Number(max) || 0
+    if (!(top > 0)) return [{ value: 0, pct: 0 }]
+    return tickRatios(heightPx).map((pct) => ({ value: tickValue(top, pct), pct }))
+}
+
+/**
+ * 横轴日期标签抽稀:**放得下就全显**,放不下按等步长抽稀,首尾必显。
+ *
+ * 首尾必显是时间轴的语义要求:抽掉的中间点读者能靠位置与悬停补,两端缺了就读不出窗口起止。
+ * @param {number} count 点数(桶数,来自后端,本层不重采样)
+ * @param {number} widthPx 横轴实测宽度
+ */
+export function axisLabelIndices(count, widthPx) {
+    const n = Math.max(0, Math.floor(Number(count) || 0))
+    if (n <= 1) return n === 1 ? [0] : []
+    const capacity = Math.max(2, Math.floor((Number(widthPx) || 0) / AXIS_LABEL_MIN_PX))
+    if (n <= capacity) return Array.from({ length: n }, (_, i) => i)
+    const step = Math.ceil((n - 1) / (capacity - 1))
+    const indices = []
+    for (let i = 0; i < n - 1; i += step) indices.push(i)
+    indices.push(n - 1)
+    return indices
+}
+
+/** 抽稀步长的「代表性」值:末段可能更密(首尾必显导致),取首个间隔即可。 */
+function axisStep(indices) {
+    return Array.isArray(indices) && indices.length >= 2 ? indices[1] - indices[0] : 1
+}
+
+/**
+ * 把一个折线块按实测容器尺寸重算几何。非折线块、或还没量到尺寸时**原样返回**
+ * (首帧=domain 的定值,正常屏观感与改动前一致;量到之后才接管)。
+ *
+ * 只改几何三个字段(`yTicks`/`axisIndices`/`axisEvery`):
+ * 点列、日期、色键、面积与标注规则一概不动(票 64 的边界:不改数据语义)。
+ * @param {object} block `trendBlock()` 产出的折线块
+ * @param {{heightPx?: number, widthPx?: number}} [box] 实测尺寸(绘图区高度 / 横轴宽度)
+ */
+export function fitTrendBlock(block, box = {}) {
+    if (!block || block.kind !== 'trend' || block.chart !== 'line') return block
+    const heightPx = Number(box.heightPx) || 0
+    const widthPx = Number(box.widthPx) || 0
+    if (heightPx <= 0 && widthPx <= 0) return block
+    const axisIndices = widthPx > 0
+        ? axisLabelIndices((block.points || []).length, widthPx)
+        : block.axisIndices
+    return {
+        ...block,
+        yTicks: heightPx > 0 ? trendTicks(block.multiSeries ? block.seriesMax : block.max, heightPx) : block.yTicks,
+        axisIndices,
+        axisEvery: axisStep(axisIndices),
+    }
+}

+ 100 - 0
src/modules/cockpit/domain/chartTip.js

@@ -0,0 +1,100 @@
+/**
+ * 图表 hover 详情浮层的定位与夹取(票 88,原票 73/83)。
+ *
+ * 维护者 2026-10-27 反馈第 3 条:折线图等图表的 hover 详情「标签过长时在面板边缘被切割出框」。
+ * 根因不在标签本身,而在**没有人算浮层的位置**:`.chart-tip` 以前只用
+ * `transform: translate(-50%, calc(-100% - 6px))` 把浮层水平居中、向上让一个身位,
+ * 既不知道面板边界、也不知道自己有多大;再加上 `white-space: nowrap` 让宽度随文案无限增长,
+ * 标签一长就从左右两侧伸出面板,被面板的 `overflow` 裁掉。
+ *
+ * **第二版(维护者 2026-10-27 再次反馈,否掉第一版的「换行 + 上翻下方」)**:
+ * 「解决方式太不优雅了,观感反而更差。直接在标签靠近要被裁切的边缘时,靠紧边缘展示即可。」
+ * 于是本文件的口径改成:
+ *   ① 浮层**始终在悬停元素上方**,上缘放不下就贴住可视窗口上沿(不翻到锚点下方);
+ *   ② 标签**默认单行**,靠近左右边缘时靠**水平夹取**贴边展示(这就是「靠紧边缘展示」);
+ *   ③ 只保留一个兜底:整行都放不进面板(极端情形)时才给 `max-width` 并允许该情形折行 ——
+ *      宁可折行,也不许把字切掉。
+ *
+ * 本文件是**纯函数层**(票 05 的静态守卫:不 import service、不碰 DOM、不发请求):
+ * 输入面板可视区、浮层**实测**尺寸与锚点,输出浮层左上角坐标 + 可用宽度 + 是否需要折行兜底。
+ * 组件的职责只剩三件——量(getBoundingClientRect)、调(本函数)、用(写回样式)——
+ * 于是夹取数学能脱浏览器断言:边界六类输入 + 超宽极端情形在 t86 里逐条钉住。
+ *
+ * 坐标一律相对 `.panel-blocks` 根(浮层的定位基准,与 `showChartTip` 的锚点算法同源)。
+ */
+
+/** 浮层与锚点之间的间隙(取代旧 CSS `calc(-100% - 6px)` 里的 6px,成为唯一来源)。 */
+export const CHART_TIP_GAP = 6
+
+/** 浮层与面板边缘的最小留白:「靠紧边缘」也留 4px,贴死了在深色面板上看起来像溢出。 */
+export const CHART_TIP_EDGE = 4
+
+const finite = (value) => (Number.isFinite(value) ? value : 0)
+
+/**
+ * 浮层可用的最大宽度:面板宽减去两侧留白。
+ *
+ * 它同时是「整行放不下」的判据(自然宽 > 它才折行),也是折行时写进 `max-width` 的值;
+ * 与 `.chart-tip.is-wrapped` 里的 `max-width: calc(100% - 2 * CHART_TIP_EDGE)` 是同一条规则的
+ * 两处表达(CSS 读不到 JS 常量,一致性由 t86 断言两者相等来钉住)。
+ */
+export function chartTipMaxWidth(panelWidth) {
+    const width = Math.max(0, finite(panelWidth))
+    return Math.max(0, Math.floor(width - 2 * CHART_TIP_EDGE))
+}
+
+/**
+ * 把浮层夹进面板可视区。
+ *
+ * 三条判据:
+ *   ① 横向:水平居中于锚点,再夹到 `[CHART_TIP_EDGE, 面板宽 - 浮层宽 - CHART_TIP_EDGE]`
+ *      —— 靠近左右边缘时贴边,不越界;
+ *   ② 纵向:**始终在锚点上方**(锚点=悬停元素上缘);上方放不下就贴可视窗口上沿,
+ *      绝不翻到锚点下方(第一版翻下方被维护者否掉:观感更差);
+ *   ③ 折行兜底:只有自然宽 > 可用宽(整行都放不进面板)才 `wrapped = true`,
+ *      此时浮层宽度按可用宽收住,组件据此允许折行。
+ *
+ * @param {object} input
+ * @param {number} input.panelWidth  面板可视宽(相对定位根的 padding box 宽)
+ * @param {number} input.panelHeight 面板可视高
+ * @param {number} input.tipWidth    浮层**自然**宽(单行、未受 max-width 约束时渲染后实测)
+ * @param {number} input.tipHeight   浮层**实测**高(折行兜底时为折行后的高度)
+ * @param {number} input.anchorX     锚点 X(悬停元素上缘中点,相对面板根)
+ * @param {number} input.anchorY     锚点 Y(悬停元素上缘,相对面板根)
+ * @param {number} [input.viewTop]   可视窗口上缘(相对面板根;滚动容器裁切后仍可见的那一段)。
+ *                                   缺省 0=整个面板都可见。
+ * @param {number} [input.viewBottom] 可视窗口下缘(相对面板根;缺省=面板高)。
+ * @returns {{left: number, top: number, maxWidth: number, wrapped: boolean}}
+ *          `wrapped` 只在极端情形为 true;`maxWidth` 是可用宽度(组件只在 wrapped 时用它做内联上限)。
+ */
+export function clampChartTip(input = {}) {
+    const panelWidth = Math.max(0, finite(input.panelWidth))
+    const panelHeight = Math.max(0, finite(input.panelHeight))
+    const anchorX = finite(input.anchorX)
+    const anchorY = finite(input.anchorY)
+    const maxWidth = chartTipMaxWidth(panelWidth)
+    const naturalWidth = Math.max(0, finite(input.tipWidth))
+    const tipHeight = Math.max(0, finite(input.tipHeight))
+    // 可视窗口:面板体是滚动容器时,「面板高」并不等于「看得见的那一段」——
+    // 只按面板高算,浮层照样会被滚动容器的 overflow 切掉(同一个症状换个方向)。
+    const viewTop = Math.min(Math.max(0, finite(input.viewTop)), panelHeight)
+    const viewBottom = Math.min(Math.max(viewTop, Number.isFinite(input.viewBottom) ? input.viewBottom : panelHeight), panelHeight)
+
+    // 极端兜底:整行都放不进面板才折行;其余情况一律单行(靠下面的横向夹取贴边)。
+    // maxWidth 为 0(面板比两侧留白还窄)时折行也救不了,按不折处理。
+    const wrapped = maxWidth > 0 && naturalWidth > maxWidth
+    const tipWidth = Math.min(naturalWidth, maxWidth)
+
+    // ① 横向:居中后夹取。上限用 floor,保证取整不会把右边缘重新推出留白带外。
+    const leftMax = Math.max(0, Math.floor(panelWidth - tipWidth - CHART_TIP_EDGE))
+    const leftMin = Math.min(CHART_TIP_EDGE, leftMax)
+    const left = Math.min(Math.max(Math.round(anchorX - tipWidth / 2), leftMin), leftMax)
+
+    // ② 纵向:始终在锚点上方;上缘放不下就贴可视窗口上沿(不翻下方)。
+    //    topMax 只用来兜「浮层比可视窗口还高」的极端输入,正常情形它不会生效。
+    const topMax = Math.max(viewTop, Math.floor(viewBottom - tipHeight - CHART_TIP_EDGE))
+    const above = Math.round(anchorY - CHART_TIP_GAP - tipHeight)
+    const top = Math.max(Math.min(above, topMax), Math.min(viewTop + CHART_TIP_EDGE, topMax))
+
+    return { left, top, maxWidth, wrapped }
+}

+ 81 - 0
src/modules/cockpit/domain/detailFrontier.js

@@ -0,0 +1,81 @@
+/**
+ * 细模覆盖前沿(票 61):决定"细模这一帧要覆盖到多远"。
+ *
+ * 为什么需要它 —— 票 59 之后仍能看到「同一距离一半精模一半火柴盒」:
+ * 旧调度是**按区域取最近 N 个**(`maxRegions: 2`)且半径写死 700m。
+ * 区域是 113 个互不重叠的空间块(半径中位 826m),所以"取 2 个"必然在
+ * 相邻区域之间切出一道硬边:边界这侧是精模、那侧还是粗模,而两侧到镜头
+ * 的距离一样。**判据错在"个数",不在"距离"**。
+ *
+ * 这里把控制量换成**半径**,并让半径随"相机是否在动 / 内容是否在流"伸缩:
+ *   · 镜头在动 → 收缩到近景(保住帧率,别在移动期灌内容);
+ *   · 镜头静止且内容已静默 → 逐拍放大(由近及远渐进,静态观感逐步变好);
+ *   · 内容仍在流 / 预算已满 → 保持不动(不再加码,避免自己把帧率拖垮)。
+ *
+ * 纯函数:输入当前状态、输出下一拍半径与动作,脱浏览器可断言。
+ */
+
+/** 默认前沿参数(可被 `map.detail` 覆盖)。 */
+export const DETAIL_FRONTIER_DEFAULTS = {
+    /** 移动期收缩到的下限半径(米)。 */
+    minRadius: 600,
+    /** 静置时能扩到的上限半径(米)。 */
+    maxRadius: 6000,
+    /** 每拍放大倍率(几何增长:越远步子越大,观感上"由近及远"更均匀)。 */
+    growFactor: 1.18,
+    /** 两拍放大之间的最短间隔(毫秒)—— 给内容落地留时间窗口。 */
+    growIntervalMs: 400,
+    /** 移动期收缩倍率(<1;乘在当前半径上)。 */
+    shrinkFactor: 0.35,
+    /** 相机停下多久之后才算"不再移动"(毫秒)。 */
+    moveWindowMs: 220,
+}
+
+/** 半径收敛到合法区间;脏值退回下限(绝不返回 NaN —— 那会让选择器一个都不选)。 */
+function clampRadius(value, defaults) {
+    const raw = Number(value)
+    const min = Number.isFinite(defaults.minRadius) && defaults.minRadius > 0 ? defaults.minRadius : 600
+    const max = Number.isFinite(defaults.maxRadius) && defaults.maxRadius >= min ? defaults.maxRadius : min
+    if (!Number.isFinite(raw)) return min
+    return Math.min(max, Math.max(min, raw))
+}
+
+/**
+ * 推进一步细模前沿。
+ *
+ * @param {object} state
+ * @param {number} state.radius     当前半径(米)
+ * @param {boolean} state.moving    镜头是否在动(调用方按 moveWindowMs 判定)
+ * @param {boolean} state.busy      是否有区域内容仍在 pending/processing
+ * @param {boolean} state.saturated 预算是否已满(再加远区域也装不下)
+ * @param {number} state.elapsedMs  距上一次放大的毫秒数
+ * @param {object} [state.options]  覆盖 DETAIL_FRONTIER_DEFAULTS
+ * @returns {{radius:number, action:'shrink'|'grow'|'hold'}}
+ */
+export function stepDetailFrontier(state = {}) {
+    const defaults = { ...DETAIL_FRONTIER_DEFAULTS, ...(state.options || {}) }
+    const current = clampRadius(state.radius, defaults)
+
+    // 1) 镜头在动:立刻收缩,把移动期的主线程/上传预算让出来。
+    if (state.moving === true) {
+        const next = clampRadius(current * (Number.isFinite(defaults.shrinkFactor) ? defaults.shrinkFactor : 0.35), defaults)
+        return { radius: next, action: next < current ? 'shrink' : 'hold' }
+    }
+
+    // 2) 内容还在流:按兵不动。此时再加半径 = 自己把尖峰叠起来。
+    if (state.busy === true) return { radius: current, action: 'hold' }
+
+    // 3) 预算已满:再放大也只会空转(选择器会截断),保持现状。
+    if (state.saturated === true) return { radius: current, action: 'hold' }
+
+    // 4) 已到上限:停在 maxRadius。
+    if (current >= defaults.maxRadius) return { radius: defaults.maxRadius, action: 'hold' }
+
+    // 5) 静置且静默:按拍放大。间隔未到就等下一拍(不能每帧都涨)。
+    const elapsed = Number(state.elapsedMs)
+    const interval = Number.isFinite(defaults.growIntervalMs) ? defaults.growIntervalMs : 400
+    if (!Number.isFinite(elapsed) || elapsed < interval) return { radius: current, action: 'hold' }
+
+    const grown = current * (Number.isFinite(defaults.growFactor) && defaults.growFactor > 1 ? defaults.growFactor : 1.18)
+    return { radius: clampRadius(grown, defaults), action: grown > current ? 'grow' : 'hold' }
+}

+ 9 - 34
src/modules/cockpit/domain/deviceAssets.js

@@ -21,6 +21,8 @@
  */
 import { deviceVisualForStatus } from './deviceStatus.js'
 import { markerSvg } from './pointStatus.js'
+// 票 82:设备位置符号的本体宽来自单源几何模块(旧 18/14 尺寸常量已退役)。
+import { MARKER_BODY_WIDTH } from './markerMetrics.js'
 
 /** 位置符号的**模式**(本期只可能落到前两者;`model` 留给未来真模型接入)。 */
 export const ASSET_MODE = {
@@ -67,36 +69,9 @@ export function assetReasonText(reason) {
     return ASSET_REASON_TEXT[reason] || ''
 }
 
-/**
- * 尺寸层级(规格验收第 2 条:**监测点始终大于设备**,动态缩放不反转)。
- *
- * 数值由实现与人工走查校准(规格**不预设**),但"点比设备大"这个**关系**是硬约束,
- * 所以把它做成一个可断言的纯函数而不是散在各处的字面量。
- */
-export const MARKER_SIZE = {
-    /** 监测点标记边长(CSS px)。 */
-    point: 18,
-    /** 设备位置符号边长(CSS px)—— 必须小于 `point`。 */
-    device: 14,
-    /** 尺寸随相机的动态缩放上限/下限(乘在基准尺寸上)。 */
-    minScale: 0.6,
-    maxScale: 2.4,
-}
-
-/** 该模式下的渲染边长(含动态缩放)。`scale` 会被夹进合法区间。 */
-export function markerSizeFor(kind, scale = 1) {
-    const base = kind === 'monitoring-device' ? MARKER_SIZE.device : MARKER_SIZE.point
-    const safe = Number.isFinite(scale) ? Math.min(MARKER_SIZE.maxScale, Math.max(MARKER_SIZE.minScale, scale)) : 1
-    return Math.round(base * safe * 100) / 100
-}
-
-/**
- * 尺寸层级是否成立(**可断言**:任何允许的缩放下,监测点都严格大于设备)。
- * 这条是规格验收第 2 条的判据本体,不是"两边各自小心"。
- */
-export function sizeHierarchyHolds(scale = 1) {
-    return markerSizeFor('monitoring-point', scale) > markerSizeFor('monitoring-device', scale)
-}
+// 尺寸(本体宽 / pad / 绘制宽)来自单源 `domain/markerMetrics.js`(票 72 规格 / 票 82)。
+// 旧 18/14 尺寸常量与四个死导出(动态缩放 / 层级校验 / 最小最大缩放)已退役:
+// 它们不决定任何标记的屏幕尺寸,只有闸门还在引用。
 
 /**
  * 设备位置符号的标签(规格:**默认不显示长文本**;hover/选中显示**名称为主、编码为辅**)。
@@ -153,7 +128,7 @@ export function planDeviceAsset({ device, imageUrl = '', imageFailed = false, mo
         return {
             mode: ASSET_MODE.IMAGE,
             shape,
-            size: MARKER_SIZE.device,
+            size: MARKER_BODY_WIDTH.device,
             image: imageUrl,
             // 真模型不可用不影响"正式图片可用"这条结论,但原因要如实带出(详情要能说明)。
             reason: modelReason,
@@ -172,7 +147,7 @@ export function planDeviceAsset({ device, imageUrl = '', imageFailed = false, mo
         return {
             mode: ASSET_MODE.ICON,
             shape,
-            size: MARKER_SIZE.device,
+            size: MARKER_BODY_WIDTH.device,
             image: icon,
             reason,
             reasonText: assetReasonText(reason),
@@ -184,7 +159,7 @@ export function planDeviceAsset({ device, imageUrl = '', imageFailed = false, mo
     return {
         mode: ASSET_MODE.FALLBACK,
         shape: 'circle',
-        size: MARKER_SIZE.device,
+        size: MARKER_BODY_WIDTH.device,
         image: renderIcon(renderIconImpl, 'circle', color) || '',
         reason: ASSET_REASON.ICON_FAILED,
         reasonText: assetReasonText(ASSET_REASON.ICON_FAILED),
@@ -201,7 +176,7 @@ export function planDeviceAsset({ device, imageUrl = '', imageFailed = false, mo
  */
 function renderIcon(impl, shape, color) {
     try {
-        const svg = (typeof impl === 'function' ? impl : markerSvg)(shape, color, MARKER_SIZE.device)
+        const svg = (typeof impl === 'function' ? impl : markerSvg)(shape, color, MARKER_BODY_WIDTH.device)
         return typeof svg === 'string' && svg ? svg : ''
     } catch (error) {
         return ''

+ 84 - 190
src/modules/cockpit/domain/deviceEffects.js

@@ -1,23 +1,22 @@
 /**
  * 设备状态**动效**计划(票 47;决策票 07 的 `## Answer`)。
  *
- * 决策票 07 已锁死的语义(本文件只实现,不重议):
- *   · 只有**三种需处置状态**有动效:`ALARM` 扩散脉冲 / `WARNING` 闪烁 / `WORK_ORDER` 旋转环;
- *   · `NORMAL` 与 `OFFLINE` **无动效**(五态全动会把画面淹没、优先级语义反而丢掉);
- *   · 未知状态(`UNKNOWN`)**也无动效** —— 状态都不知道,更不能给它编一个"正在报警"的样子;
- *   · 同屏**异常设备超过上限**(默认 30)时**动画全停**,降级为静态异常标记 + 一行「N 台异常」计数;
- *     降级后**仍看得出有异常**(静态标记 + 计数),绝不静默删点或变成"什么都没画"。
+ * 本文件只负责**门控与计划**(哪些对象参与动效、同屏异常超限降级、文案);
+ * 光环的**参数**(峰值/起始/层数/周期/alpha/相位)已收进单源 `domain/markerMetrics.js`
+ * (票 72 规格 / 票 84 实施)——本文件是它的消费者,不再持有任何环数值。
  *
- * 三层证据里的**第 1 层**(领域纯逻辑)由本文件承担:映射唯一、未知不猜、门控与降级可断言。
- * 它**不能单独作为验收** —— 映射表对不等于画面在动;"真在跑"的证据在引擎侧的跨帧快照
- * 与真实帧差(见 `verify/t47-device-effects*.mjs`)。
+ * 票 84 留痕:旧 `RING_MAX_SCALE`(3.8)、`RING_PERIOD_MS`、`GLOW_*`、`EFFECT_PERIOD_MS`、
+ * `ringPhaseAt/ringScaleAt/ringAlphaAt/ringFrameAt/ringEntityCount/glowStrengthAt/glowScaleAt`
+ * 与 `EFFECT_PROFILE` 全部退役——它们在 billboard 环退役(票 76 C5)后已无生产消费方,
+ * 只给闸门用就是死 API;新参数与纯函数住在 `markerMetrics`,由 DOM 的 CSS 生成与走查断言消费。
  *
  * 纯函数、不碰 Cesium/Vue/DOM。
  */
 import { deviceVisualForStatus } from './deviceStatus.js'
 // 「需要处置」的判据**只有一个住址**(票 46 已建):`ALARM`/`WARNING`。
-// 本文件不另造一份"什么算异常"的定义 —— 那正是两张表各说各话的来源。
 import { isAttentionDeviceStatus } from './modeLayerPlan.js'
+// 光环参数单源(票 84):状态 → 动效 profile 的唯一出口。
+import { MARKER_COEFFICIENTS, ringEffectFor } from './markerMetrics.js'
 
 /** 动效键(决策票 07 的三个;正常/离线/未知都是"无动效",不设第四个键)。 */
 export const DEVICE_EFFECT = {
@@ -26,41 +25,9 @@ export const DEVICE_EFFECT = {
     SPIN: 'spin',
 }
 
-/**
- * 视觉优先级档位:只给业务处置态特效,且明确报警 > 预警 > 工单。
- * `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 留实现与走查现场校准。
- * 票 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]: 3400,
-}
-
 /** 该设备状态对应哪个动效;无动效回空串(**不是** `null`,便于字符串判空)。 */
 export function deviceEffectOf(status) {
     const visual = deviceVisualForStatus(status)
@@ -73,59 +40,75 @@ export function hasDeviceEffect(device) {
 }
 
 /**
- * 动效相位(0..1,纯函数)。
+ * 状态 → 动效 profile(**单源派生**:字段全部来自 `markerMetrics.RING_EFFECT`)。
  *
- * 为什么把它做成**纯函数**而不是在引擎里算:走查要证明"同一设备的逐点属性跨帧确实变化",
- * 而这需要能对**任意时间点**算出期望值 —— 引擎里算就只能拿两份 JS 数据互比(恒真断言)。
- * @param {string} effect 动效键
- * @param {number} timeMs 绝对时间(毫秒)
- * @returns {number} 0..1 的相位;无动效回 0
+ * `scale`/`alpha` 保留旧字段名(DOM 与走查读数沿用),语义分别是**峰值倍率**与**峰值 alpha**;
+ * `startScale`/`periodMs`/`spinDegPerSec` 是本轮新增的渲染真值。
+ * @returns {{status:string,effect:string,layers:number,scale:number,alpha:number,glow:boolean,periodMs:number,startScale:number,spinDegPerSec:number}|null}
  */
-export function effectPhaseAt(effect, timeMs) {
-    const period = EFFECT_PERIOD_MS[effect]
-    if (!Number.isFinite(period) || period <= 0) return 0
-    const t = Number.isFinite(timeMs) ? timeMs : 0
-    const raw = (t % period) / period
-    return raw < 0 ? raw + 1 : raw
-}
-
-/** 脉冲强度(0..1 的三角波:由内向外扩张渐隐的节奏)。 */
-export function pulseStrengthAt(timeMs) {
-    const phase = effectPhaseAt(DEVICE_EFFECT.PULSE, timeMs)
-    return phase <= 0.5 ? phase * 2 : (1 - phase) * 2
+export function effectProfileFor(status) {
+    const ring = ringEffectFor(status)
+    if (!ring) return null
+    return {
+        status: ring.status,
+        effect: ring.effect,
+        layers: ring.layers,
+        scale: ring.peakScale,
+        alpha: ring.alphaPeak,
+        glow: ring.glow,
+        periodMs: ring.periodMs,
+        startScale: ring.startScale,
+        spinDegPerSec: ring.spinDegPerSec || 0,
+    }
 }
 
-/** 闪烁强度(方波:亮/暗两段,段间保留一点过渡避免频闪刺眼)。 */
-export function blinkStrengthAt(timeMs) {
-    const phase = effectPhaseAt(DEVICE_EFFECT.BLINK, timeMs)
-    return phase < 0.5 ? 1 : 0.25
+/**
+ * 生成动效计划(纯函数,门控与降级的唯一判定处)。
+ *
+ * 降级语义(票 85 改写 07 的「动画全停」):超限后**不消失、只收敛** —— 参与对象仍逐台挂环,
+ * 收敛系数为峰值 ×0.6、alpha ×0.5、周期不变;计数口径 = 参与动效的对象数(三类对象合计),
+ * **工单处置不计入**(它不是"异常")。
+ *
+ * @param {object} input
+ * @param {Array} input.devices 领域设备行(含 `code`/`status`)
+ * @param {boolean} input.enabled `map.effects.enabled` 归一化后的结果(缺省跟随构建形态)
+ * @param {number} [input.abnormalLimit] 同屏异常设备上限(缺省 30)
+ * @returns {{enabled:boolean, limit:number, ownAbnormalCount:number, abnormalCount:number,
+ *   degraded:boolean, converge:{peak:number,alpha:number}|null,
+ *   animated:Array<{code:string,status:string,effect:string}>}}
+ */
+/** 参与动效且计入降级的对象数(`ALARM`/`WARNING`;工单处置不计入)。 */
+export function abnormalObjectCount(rows = []) {
+    return (Array.isArray(rows) ? rows : []).filter((item) => item && isAttentionDeviceStatus(item.status)).length
 }
 
-/** 旋转角(弧度,0..2π 线性推进)。 */
-export function spinAngleAt(timeMs) {
-    return effectPhaseAt(DEVICE_EFFECT.SPIN, timeMs) * Math.PI * 2
+/** 降级收敛系数(单源:峰值 ×0.6、alpha ×0.5、周期不变)。 */
+export function degradeConverge() {
+    return { peak: MARKER_COEFFICIENTS.degradePeak, alpha: MARKER_COEFFICIENTS.degradeAlpha }
 }
 
 /**
  * 生成动效计划(纯函数,门控与降级的唯一判定处)。
  *
+ * 票 85 改写:降级**不再清空 `animated`** —— 参与对象照样逐台挂环,只是整体收敛
+ * (峰值 ×0.6、alpha ×0.5、周期不变)。「降级后什么都没画」比"画面忙一点"更糟。
+ *
  * @param {object} input
  * @param {Array} input.devices 领域设备行(含 `code`/`status`)
- * @param {boolean} input.enabled `map.effects.enabled` 归一化后的结果(缺省跟随构建形态)
- * @param {number} [input.abnormalLimit] 同屏异常设备上限(缺省 30)
- * @returns {{enabled:boolean, limit:number, abnormalCount:number, degraded:boolean,
- *   animated:Array<{code:string,status:string,effect:string}>, staticAbnormal:Array<string>}}
+ * @param {boolean} input.enabled `map.effects.enabled` 归一化后的结果
+ * @param {number} [input.abnormalLimit] 同屏异常对象上限(缺省 30,跨三类对象合计)
+ * @param {number} [input.extraAbnormalCount] 其他图层(另外两类对象)的异常数,用于跨图层合计
  */
-export function planDeviceEffects({ devices = [], enabled = false, abnormalLimit = DEFAULT_ABNORMAL_LIMIT } = {}) {
+export function planDeviceEffects({ devices = [], enabled = false, abnormalLimit = DEFAULT_ABNORMAL_LIMIT, extraAbnormalCount = 0 } = {}) {
     const rows = (Array.isArray(devices) ? devices : []).filter((item) => item && item.code)
     const limit = Number.isFinite(abnormalLimit) && abnormalLimit >= 0 ? Math.floor(abnormalLimit) : DEFAULT_ABNORMAL_LIMIT
+    const extra = Number.isFinite(extraAbnormalCount) && extraAbnormalCount > 0 ? Math.floor(extraAbnormalCount) : 0
     // 异常=「需要处置」(`ALARM`/`WARNING`),判据来自票 46 的单一住址,本文件不另定义。
-    const abnormalCount = rows.filter((item) => isAttentionDeviceStatus(item.status)).length
-    // 降级判据用**异常台数**(决策票 07 原句"同屏异常设备超过 30 台时降级");
-    // 降级后**动画全停**(票 08 现场校准记录:23 台异常 → 已降级、动效台数 0),
-    // 而不是"只给前 30 台动画" —— 后者会让值班人员以为后面的设备没问题。
+    const ownAbnormalCount = abnormalObjectCount(rows)
+    const abnormalCount = ownAbnormalCount + extra
     const degraded = enabled === true && abnormalCount > limit
-    const animated = enabled === true && !degraded
+    // 降级时**保留** animated(只收敛不消失):渲染层拿 `converge` 把峰值/alpha 压下去。
+    const animated = enabled === true
         ? rows.filter((item) => hasDeviceEffect(item)).map((item) => ({
             code: item.code,
             status: item.status,
@@ -135,11 +118,32 @@ export function planDeviceEffects({ devices = [], enabled = false, abnormalLimit
     return {
         enabled: enabled === true,
         limit,
+        ownAbnormalCount,
         abnormalCount,
         degraded,
+        converge: degraded ? degradeConverge() : null,
         animated,
-        // 降级时**仍要看得出有异常**:这些设备保留静态异常标记(引擎侧不改它们的静态色/形)。
-        staticAbnormal: degraded ? rows.filter((item) => isAttentionDeviceStatus(item.status)).map((item) => item.code) : [],
+    }
+}
+
+/**
+ * 跨图层合计(票 85):三类对象分散在「摘要标记」与「关联设备」两张计划里,
+ * 降级阈值必须按**合计**判定,否则同一屏里两张计划各自都不超限、加起来却超了。
+ * 返回合并后的门控与收敛系数,供渲染闭包与宿主读数使用。
+ */
+export function combineEffectPlans(plans = [], { enabled = false, abnormalLimit = DEFAULT_ABNORMAL_LIMIT } = {}) {
+    const valid = (Array.isArray(plans) ? plans : []).filter(Boolean)
+    const limit = Number.isFinite(abnormalLimit) && abnormalLimit >= 0 ? Math.floor(abnormalLimit) : DEFAULT_ABNORMAL_LIMIT
+    const abnormalCount = valid.reduce((sum, plan) => sum + (plan.ownAbnormalCount || 0), 0)
+    const animatedCount = valid.reduce((sum, plan) => sum + (Array.isArray(plan.animated) ? plan.animated.length : 0), 0)
+    const degraded = enabled === true && abnormalCount > limit
+    return {
+        enabled: enabled === true,
+        limit,
+        abnormalCount,
+        animatedCount,
+        degraded,
+        converge: degraded ? degradeConverge() : null,
     }
 }
 
@@ -150,114 +154,4 @@ export function planDeviceEffects({ devices = [], enabled = false, abnormalLimit
 export function degradedAbnormalNote(plan) {
     if (!plan || plan.degraded !== true) return ''
     return `${plan.abnormalCount} 台异常`
-}
-
-// ================================================================ 科技感光环(决策票 07 新增方向)
-
-/**
- * 光环层数:**多层同心扩散环**(决策票 07 维护者给的参考图观感:3–4 层由内向外扩张渐隐)。
- * 三个需处置状态都用同一套层数 —— 差异由**节奏与色相**表达,不由层数表达
- * (层数不同会让"哪台更紧急"被误读成"环多的更严重")。
- */
-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]: 3700,
-}
-
-/** 中心辉光的呼吸周期(毫秒):比环慢,避免与环同频显得呆板。 */
-export const GLOW_PERIOD_MS = 2400
-
-/** 光环最大放大倍数(相对设备图标尺寸)。表现层数值,留现场校准。 */
-export const RING_MAX_SCALE = 3.8
-
-/** 中心辉光最大放大倍数(相对设备图标尺寸)。 */
-export const GLOW_MAX_SCALE = 1.9
-
-/**
- * 单层光环的相位与强度(0..1)。
- *
- * 每层**均匀错开**一个 `1/RING_LAYERS` 的相位,形成"连续向外扩散"的观感;
- * 强度用 `(1 - phase)` 做**渐隐**(越外越淡),这正是参考图里"扩张渐隐"的节奏。
- * @param {number} layer 层序号(0 = 最内层)
- * @param {number} timeMs 绝对时间
- * @param {string} effect 动效键(决定周期)
- */
-export function ringPhaseAt(layer, timeMs, effect) {
-    const period = RING_PERIOD_MS[effect] || RING_PERIOD_MS[DEVICE_EFFECT.PULSE]
-    const offset = (Number.isFinite(layer) ? layer : 0) / RING_LAYERS
-    const t = Number.isFinite(timeMs) ? timeMs : 0
-    const raw = ((t % period) / period + offset) % 1
-    return raw < 0 ? raw + 1 : raw
-}
-
-/** 单层光环的放大倍数:从 1 扩到 `RING_MAX_SCALE`(由内向外)。 */
-export function ringScaleAt(layer, timeMs, effect) {
-    const phase = ringPhaseAt(layer, timeMs, effect)
-    return 1 + (RING_MAX_SCALE - 1) * phase
-}
-
-/** 单层光环的不透明度:由内向外渐隐(放缓的幂次,让环在扩张中保持可见)。 */
-export function ringAlphaAt(layer, timeMs, effect, baseAlpha = 1) {
-    const phase = ringPhaseAt(layer, timeMs, effect)
-    // 幂次 1.35 而不是 2:首版用平方衰减,环一扩散就淡到看不见(真机截图抓到偏弱)。
-    const fade = Math.pow(1 - phase, 1.35)
-    return Math.max(0, Math.min(1, baseAlpha * fade))
-}
-
-/** 中心辉光的呼吸强度(0..1,正弦,比环更柔)。 */
-export function glowStrengthAt(timeMs) {
-    const t = Number.isFinite(timeMs) ? timeMs : 0
-    const phase = (t % GLOW_PERIOD_MS) / GLOW_PERIOD_MS
-    return (1 - Math.cos(phase * Math.PI * 2)) / 2
-}
-
-/** 中心辉光的放大倍数(1 .. GLOW_MAX_SCALE,随呼吸)。 */
-export function glowScaleAt(timeMs) {
-    return 1 + (GLOW_MAX_SCALE - 1) * glowStrengthAt(timeMs)
-}
-
-/**
- * 该状态是否带**中心辉光**(最高优先级最亮)。
- *
- * 单独成函数是为了让引擎**不必知道业务状态名**(t09 F5:引擎只认形状/键,
- * 状态→视觉的映射只有 domain 一份)。引擎问的是"这台有没有辉光",不是"它是不是报警"。
- */
-export function hasGlow(status) {
-    return deviceEffectOf(status) === DEVICE_EFFECT.PULSE
-}
-
-/** 某台设备的光环实体数量(多层环 + 可能的辉光);引擎据此判断是否需要重建。 */
-export function ringEntityCount(status) {
-    return deviceEffectOf(status) ? RING_LAYERS + (hasGlow(status) ? 1 : 0) : 0
-}
-
-/**
- * 生成某个设备在某一时刻的**光环帧**(纯函数;引擎只负责把结论写成 billboard 属性)。
- *
- * 走查可以按任意时间点算出期望值 —— 这是"跨帧快照"能成为证据的前提
- * (否则只能拿两份 JS 数据互比,是恒真断言)。
- * @returns {{rings:Array<{layer:number,scale:number,alpha:number}>, glow:{scale:number,alpha:number}|null}}
- */
-export function ringFrameAt(status, timeMs, { baseAlpha = 1 } = {}) {
-    const effect = deviceEffectOf(status)
-    if (!effect) return { rings: [], glow: null }
-    const rings = Array.from({ length: RING_LAYERS }, (_, layer) => ({
-        layer,
-        scale: ringScaleAt(layer, timeMs, effect),
-        alpha: ringAlphaAt(layer, timeMs, effect, baseAlpha),
-    }))
-    // 中心辉光只给**最高优先级**(报警):三个状态都加辉光会让画面糊成一团,
-    // 而"最要紧的那台最亮"正是优先级语义要传达的。
-    const glow = hasGlow(status)
-        ? { scale: glowScaleAt(timeMs), alpha: 0.32 * glowStrengthAt(timeMs) * baseAlpha }
-        : null
-    return { rings, glow }
-}
+}

+ 6 - 3
src/modules/cockpit/domain/equipmentDetailView.js

@@ -31,6 +31,7 @@
  * —— 都是既有单源,本票不发明第二张表。
  */
 import { DETAIL_PLACEHOLDER } from './detailFlow.js'
+import { TIME_LABELS } from './fields.js'
 import { SECTION_STATE, section } from './sectionState.js'
 import { WORK_ORDER_STATES, workOrderStateName } from './workOrder.js'
 import { tracksByOrder } from './workOrderTimeline.js'
@@ -221,6 +222,8 @@ export function metricRows(data) {
         unit: metric.unit || '',
         status: metric.status || '',
         sampledAt: metric.sampledAt || '',
+        // 设备采集/上报该指标的时刻——标签取自 `TIME_LABELS` 单点词表(票 62)。
+        sampledAtLabel: TIME_LABELS.sampled,
         missing: metric.missing || [],
     }))
 }
@@ -455,7 +458,7 @@ export function categoryNote(chips, categoryKey) {
 /**
  * 展开详情的一行(值缺失一律占位符 —— 票 16「不把没给画成空白/0」)。
  * `keepEmpty` 的项是票 46 范围第 3 条点名要**始终保留**的四件事:
- * 数据时间 / 状态 / 来源 / 关联对象;其余项有值才画(不铺一排 `—` 噪音)。
+ * 事件时间 / 状态 / 来源 / 关联对象;其余项有值才画(不铺一排 `—` 噪音)。
  */
 function detailRow(label, value, keepEmpty = false) {
     const text = valueText(value)
@@ -467,7 +470,7 @@ function detailRow(label, value, keepEmpty = false) {
 export function eventDetailRows(row) {
     const current = row || {}
     return detailRowList([
-        detailRow('数据时间', current.eventTime, true),
+        detailRow(TIME_LABELS.event, current.eventTime, true),
         detailRow('状态', current.status, true),
         detailRow('来源', current.type, true),
         detailRow('关联设备', current.deviceName || current.deviceCode, true),
@@ -485,7 +488,7 @@ export function eventDetailRows(row) {
 export function workOrderDetailRows(row) {
     const current = row || {}
     return detailRowList([
-        detailRow('数据时间', current.createTime, true),
+        detailRow(TIME_LABELS.workOrderCreated, current.createTime, true),
         detailRow('状态', current.stateName, true),
         detailRow('来源', current.source || current.typeName, true),
         detailRow('关联对象', current.associationText, true),

+ 3 - 0
src/modules/cockpit/domain/eventDetail.js

@@ -3,6 +3,7 @@
  * 事件详情保留列表行的领域字段;定位只认后端 coordinateValidation.renderable。
  */
 import { eventLevelTag, eventStatusText } from './eventList.js'
+import { TIME_LABELS } from './fields.js'
 import { LOCATE_RESULT, LOCATE_TEXT, findMapObject, locateCameraTarget } from './mapLocate.js'
 import { blockReason } from './coordinate.js'
 
@@ -82,6 +83,8 @@ export function eventDetailView(event) {
         value: valueText(current.value, current.unit),
         level,
         eventTime: current.eventTime || PLACEHOLDER,
+        // 事件发生时刻(预警发布 / 报警创建)——标签取自 `TIME_LABELS` 单点词表(票 62)。
+        eventTimeLabel: TIME_LABELS.event,
         updatedTime: current.updatedTime || '',
         status: statusOf(current) || PLACEHOLDER,
         workOrderCode: current.workOrderCode || '',

+ 10 - 3
src/modules/cockpit/domain/events.js

@@ -17,11 +17,18 @@ export const EVENT_FAMILY = {
     ALARM: 'ALARM',
 }
 
-/** 事件等级配色由后端给(`levelColor`:1→RED/2→YELLOW/3→BLUE/4→GREEN);认不出时回空串,不猜。 */
-const KNOWN_LEVEL_COLORS = ['RED', 'YELLOW', 'BLUE', 'GREEN']
+/**
+ * 事件等级 → 颜色名(后端 `CockpitEventLevelColor` 的冻结映射:1→RED/2→YELLOW/3→BLUE/4→GREEN);
+ * `levelColor` 认不出时回空串,不猜。
+ *
+ * **顺序即等级号**(票 66 起有第二个消费点):`eventList.eventLevelFromBucket` 用它把
+ * 只有颜色名的 `LEVEL` 桶反查成 1–4 级 —— 改顺序前先看那一处。
+ * 导出而不留在文件里:颜色名单只该有一份,票 66 不抄第二份。
+ */
+export const LEVEL_COLORS = ['RED', 'YELLOW', 'BLUE', 'GREEN']
 
 function toLevelColor(value) {
-    return KNOWN_LEVEL_COLORS.includes(value) ? value : ''
+    return LEVEL_COLORS.includes(value) ? value : ''
 }
 
 /**

+ 34 - 0
src/modules/cockpit/domain/fields.js

@@ -99,6 +99,40 @@ export function fieldReader(source, prefix = '') {
     return { raw, missing, str, optionalStr, num, optionalNum, bool, list, optionalList, obj, optionalObj }
 }
 
+/**
+ * **时间类读数的唯一词表**(票 62)。
+ *
+ * 为什么单点给出:同一个词一旦分散在各个面板的 projection 里,迟早漂移成
+ * 「同一句话在三个地方指三个时间」。这张表把**每种时间语义的正式叫法**钉死,
+ * 各面板从这里取词,不得各写一版(票面要求 1)。
+ *
+ * 五种语义彼此不同,**不得共用同一个词**(票面要求 2):
+ *
+ * | 语义 | 后端字段 | 含义 |
+ * | --- | --- | --- |
+ * | 入库时间 | `createdAt` | 源记录 `create_time`(数据生成/入库时刻) |
+ * | 事件时间 | `eventTime` | 预警发布时刻 / 报警创建时刻(业务发生时刻) |
+ * | 工单创建时间 | 工单 `createTime` | 工单单据的创建时刻 |
+ * | 快照时间 | 信封 `updatedAt` | 后端快照的生成时刻(不是业务数据时刻) |
+ * | 采样时间 | `sampledAt` | 设备采集/上报该指标的刻 |
+ *
+ * 旧词「数据时间」已退役:它把这五种语义混成一个词,是值班人员读不懂的根因。
+ */
+export const TIME_LABELS = {
+    /** 入库时间 · 单条语境(一条记录自己的入库时刻)。 */
+    ingested: '数据入库时间',
+    /** 入库时间 · 集合语境(范围内最新一条的入库时刻)。 */
+    latestIngested: '最新入库时间',
+    /** 事件时间(业务发生时刻:预警发布 / 报警创建)。 */
+    event: '事件时间',
+    /** 工单单据创建时刻。 */
+    workOrderCreated: '工单创建时间',
+    /** 后端快照生成时刻(与业务数据时刻无关)。 */
+    snapshot: '快照时间',
+    /** 设备采集/上报该指标的时刻。 */
+    sampled: '采样时间',
+}
+
 /**
  * 把后端坐标校验结果翻成领域 `location`(票 07 的输入)。
  *

+ 116 - 8
src/modules/cockpit/domain/infrastructurePanels.js

@@ -24,6 +24,7 @@ import { collectMissing, valueText } from './equipmentDetailView.js'
 import { industryLabel, normalizeDistrict, districtLabel } from './filterOptions.js'
 import {
     barsBlock,
+    CARD_ACTION,
     childHasContent,
     codeText,
     dimensionBarsBlock,
@@ -42,6 +43,7 @@ import {
     sectionHasContent,
     sectionPlaceholder,
     sectionPending,
+    supplementalBlock,
 } from './panelBlocks.js'
 
 // 票 18:这些工具已搬到 `panelBlocks.js`,这里**原样转出**,票 17 的既有导入点不受影响。
@@ -300,6 +302,9 @@ export function archivePanel(data, section) {
         donutBlock('行业分类', sortByCountDesc(current.industries || [], (row) => row.count)),
         barsBlock('片区分布', sortByCountDesc(current.regions || [], (row) => row.count)),
         barsBlock('档案摘要 · 建成年限', current.ages || []),
+        supplementalBlock(barsBlock('设施类型分布', sortByCountDesc((current.facilityTypes || []).map((row) => ({
+            code: row.code, label: row.name, count: row.count,
+        })), (row) => row.count))),
     ]
 
     const reference = current.ageReference || {}
@@ -345,6 +350,24 @@ function operationPageScope(active, extra) {
     return `${active.code || ''}|${district}`
 }
 
+/**
+ * 票 73(维护者 2026-10-23 裁定 A):指标行标题带设施编码。
+ *
+ * 为什么必须带:同页 10 行 `metricName` 全同(燃气首屏第 2–10 行连读数都是 1.00 m、
+ * `loadState`/`freshness` 也全同),行上唯一能分开它们的身份就是设施编码
+ * (票 49 裁定的 `pointCodes`,实测 6362 条恒单元素)。标题仍是**单源**:只在本函数拼,
+ * 渲染层不拼第二份。缺编码时退回指标名——不留「 · 」悬空分隔符。
+ *
+ * **编码在前、指标名在后**(维护者 2026-10-23 追加裁定):`.panel-item__title` 是单行
+ * nowrap + 共用省略,省略号切的是**文本尾部**——把 16 字符编码放在指标名后面,
+ * 恰好把唯一可辨识的尾号切掉(票 73 首次实施实测反馈「最后几位隐藏,显示不直观」)。
+ * 编码放最前则无论栏多窄都先保住身份,指标名放后面(同页同名时它本就重复,被省略不损失信息)。
+ */
+function metricTitle(metric) {
+    const facilityCode = (metric.pointCodes || [])[0] || ''
+    return facilityCode ? `${facilityCode} · ${metric.name}` : metric.name
+}
+
 /**
  * 运行信息面板视图。
  * 验收四项:供应 / 输送 / 承载能力 + **运行指标**(票 64:原措辞「指标占位」是占位期遗留词,
@@ -398,7 +421,11 @@ export function operationPanel(data, section, extra) {
                 // 保证**块内每行身份唯一**;行序由后端给、前端不重排(票 10/11 立场),
                 // 所以序号在同一条件下稳定。
                 key: `${active.code || ''}|${metric.code}|${index}`,
-                title: metric.name,
+                // 票 73 裁定 A:标题带设施编码且**编码在前**(同页十行同名;编码放后面会被省略号切掉尾号)。
+                title: metricTitle(metric),
+                identityKind: CARD_ACTION.FACILITY,
+                identityCode: (metric.pointCodes || [])[0] || '',
+                identityReason: (metric.pointCodes || [])[0] ? '设施无详情' : '缺少设施编码,无法展开',
                 tone: loadTone(metric.loadState),
                 // 色键必须**带文字**(票 06 的立场:标签不能只靠颜色,灰度/色盲下也要读得出)。
                 toneLabel: codeText(metric.loadState),
@@ -496,18 +523,54 @@ export function managementPanel(data, section) {
         section,
         hasContent: sectionHasContent(section),
         blocks,
+        // 票 68(维护者 2026-10-19 反馈第 3 条):公开「这些数从哪来、覆盖什么范围、与别处为何对得上」。
+        // 三条必须说清(措辞纪律:只说业务含义与范围,**不出现接口字段名**——读者是值班人员):
+        //   1. 计数来源:设施=基础设施档案目录(与「基础档案」**同一个目录、同一个适配器**,
+        //      后端 BusinessCockpitInfrastructureBaseArchivesAdapter 同时实现基础档案与设施目录);
+        //      设备=设备台账里能归入四个正式行业的监测设备;
+        //   2. 「片区 × 行业」是什么:一行一个组合,两维不再拆第二遍;
+        //   3. 与「基础档案」的设施总数**同源同口径**(2026-10-22 实测两处均 6776)——
+        //      同一批设施实体,不各自维护一套总数。
+        // 还要解释一处**看起来像缺陷的真实现象**:设施没有片区来源,设施全部落「未分区」,
+        // 于是城南/城北会出现「有设备、没有设施」的行(票 34/38 片区契约迁移后的既知事实)。
+        sourceNote:
+            '设施计数取自基础设施档案目录(与「基础档案」是同一份目录、同一批设施实体),设备计数取自设备台账中能归入燃气/供水/排水/井盖四类的监测设备;「片区 × 行业」一行就是一个组合,只有既属于该片区又属于该行业的对象才计入,两个维度不重复拆分。设施的片区取档案目录登记的片区(当前设施档案没有片区来源,所以设施都记在「未分区」),设备的片区取设备自身登记 —— 因此会出现「有设备、没有设施」的片区行。设施总数与「基础档案」同源,两处不各自维护一套。',
         locate: [],
         locateNote: '片区与行业计数为聚合口径,本面板没有可定位对象',
         missing: panelMissingNote(current),
         placeholder: sectionPlaceholder(section),
     }
 }
-/** 应急保障三段 → 条目列表块(每段的 state 决定它是内容、空还是不可用)。 */
-function emergencySectionBlock(label, child, emptyText) {
+/**
+ * 应急保障三段 → 条目列表块(每段的 state 决定它是内容、空还是不可用)。
+ *
+ * **票 63(批次五)**:三段接入统一分页(规范条文 1/2/4 的前端集合形态)。
+ * 真实数据已证明这不是空转——实测 `protectionTargets` **20 条**(两页)、
+ * `emergencyResources` 9 条、`hazardSources` 0 条;20 条那一段此前是一屏 20 张卡。
+ *
+ * 三条纪律落在这里:
+ *   1. **三段各自独立页码**:`pageScope` 带上段标识(`hazardSources`/`protectionTargets`/
+ *      `emergencyResources`),翻危险源的页不会动到防护目标 —— 渲染层的页码槽按
+ *      「面板名 + 块标题」寻址,标题本来就不同,但把段码写进 `pageScope` 才是
+ *      真正按段复位的依据(换筛选时三段一起回第 1 页)。
+ *   2. **段内行次序不动**:`pagedRows` 只切窗口,不排序(票 10/11 立场)。
+ *   3. **定位按钮不受影响**:`points` 挂在被窗口化之后的行上,翻到第 2 页看到的是
+ *      第 11–20 条的定位按钮,行为与翻页前完全一致。
+ *
+ * `stateText` 语义不变(`UNAVAILABLE`/`EMPTY` 分得开,且**优先于**空列表提示渲染)。
+ * @param {string} label 段标题(也是渲染层页码槽的键之一)
+ * @param {object} child 该段的后端分区
+ * @param {string} emptyText 空态文案
+ * @param {string} sectionKey 段码(`sections` 上的键;用于 `pageScope` 与断言面)
+ */
+function emergencySectionBlock(label, child, emptyText, sectionKey) {
     const items = (child && child.items) || []
     const rows = items.map((item) => ({
         key: item.code,
         title: item.name,
+        identityKind: CARD_ACTION.FACILITY,
+        identityCode: item.code,
+        identityReason: item.code ? '设施无详情' : '缺少设施编码,无法展开',
         meta: [
             codeText(item.category),
             industryLabel(item.industry) || codeText(item.industry),
@@ -526,13 +589,38 @@ function emergencySectionBlock(label, child, emptyText) {
     }))
 
     return {
-        ...listBlock(label, childHasContent(child && child.state) ? rows : [], emptyText),
+        ...listBlock(label, childHasContent(child && child.state) ? rows : [], emptyText, {
+            // 前端集合分页:数据已一次装齐,pager 只切窗口、零请求。
+            paginate: true,
+            // 页码归属键=段码 + 全局条件:换行业/片区/类别时三段一起回第 1 页,
+            // 定时刷新(条件不变)页码保持(条文 5/6)。标量键,不接受对象身份(票 43 教训)。
+            pageScope: emergencySectionScope(sectionKey, child),
+        }),
         // 该段自己的成败(后端 `UNAVAILABLE` 与 `EMPTY` 分得开,界面也说得出区别)。
         state: (child && child.state) || '',
         stateText: child && child.state === 'UNAVAILABLE' ? '该段数据未取到' : child && child.state === 'EMPTY' ? '该段当前没有对象' : '',
+        // 断层面:段码 + 该段真实条数(走查与闸门按段取数,不靠标题字符串猜)。
+        sectionKey,
+        total: rows.length,
     }
 }
 
+/**
+ * 应急保障某一段的**页码归属键**(票 63)。
+ *
+ * 只用**标量**:段码 + 该段收到的条数 + 筛选回显(类别/行业/片区)。
+ * 不把行内容/对象身份编进来——视图计划每次重算都是新对象,按对象记会让每次刷新
+ * 都读成"换了筛选"(票 43 第四轮记过的教训,票 64 又踩过一次)。
+ * 条数也进键:同一段的数据**变短**时(例如换类别后从 20 条降到 3 条),
+ * 页码槽跟着换,越界自然回到第 1 页,不用另外写一条夹回逻辑。
+ * @param {string} sectionKey 段码
+ * @param {object} child 该段分区(提供条数与 state)
+ */
+function emergencySectionScope(sectionKey, child) {
+    const count = ((child && child.items) || []).length
+    return `${sectionKey || ''}|${count}|${(child && child.state) || ''}`
+}
+
 /**
  * 应急保障面板视图。
  * 验收三项:危险源 / 防护目标 / 应急资源 —— 后端的三个 section 正好对应。
@@ -547,9 +635,9 @@ export function emergencyPanel(data, section) {
             { key: 'protectionTargetCount', label: '防护目标', value: valueText(current.totals && current.totals.protectionTargetCount), unit: '处' },
             { key: 'emergencyResourceCount', label: '应急资源', value: valueText(current.totals && current.totals.emergencyResourceCount), unit: '项' },
         ]),
-        emergencySectionBlock('危险源', sections.hazardSources, '没有危险源对象'),
-        emergencySectionBlock('防护目标', sections.protectionTargets, '没有防护目标'),
-        emergencySectionBlock('应急资源', sections.emergencyResources, '没有应急资源'),
+        emergencySectionBlock('危险源', sections.hazardSources, '没有危险源对象', 'hazardSources'),
+        emergencySectionBlock('防护目标', sections.protectionTargets, '没有防护目标', 'protectionTargets'),
+        emergencySectionBlock('应急资源', sections.emergencyResources, '没有应急资源', 'emergencyResources'),
         barsBlock('区域分布', sortByCountDesc(current.areas || [], (row) => row.count)),
     ]
 
@@ -557,6 +645,15 @@ export function emergencyPanel(data, section) {
         ((sections[key] || {}).items || []).map((item) => ({ item, key })),
     )
 
+    // 票 68(维护者 2026-10-19 反馈第 3 条):应急保障也要说清「这些对象从哪来」。
+    // 关联编码的说明是**条件句**(不只写当前实测):真实接口的三类对象目前一律不带编码
+    // (后端适配器对三类对象都下发空列表,票 49 已记),所以界面此刻没有可定位对象——
+    // 但这句话不能写成恒真的「没有编码」,否则后端日后真给出编码时文案就开始撒谎。
+    const anyPointCode = allItems.some(({ item }) => (item.pointCodes || []).length > 0)
+    const pointCodeNote = anyPointCode
+        ? '对象带的关联编码是设施编码(不是一张图的监测点编码),可据此在图上查找对应设施。'
+        : '对象带的关联编码是设施编码(不是一张图的监测点编码);当前三类对象都没有带编码,所以本面板没有可定位对象。'
+
     return {
         name: 'emergency',
         label: '应急保障',
@@ -566,7 +663,18 @@ export function emergencyPanel(data, section) {
         locate: allItems.flatMap(({ item }) =>
             locateEntries(item.code, item.name, { code: item.code, name: item.name }, item.pointCodes),
         ),
-        locateNote: '危险源 / 防护目标 / 应急资源的关联点位可定位',
+        // 票 68 更正:原文案「关联点位可定位」在两处站不住——① 真实数据没有可定位对象(点了没东西);
+        // ② 这些编码是**设施**编码(票 49 裁定),叫「点位」会把设施说成监测点。改为按实测条件如实说。
+        locateNote: anyPointCode
+            ? '三类对象带的关联编码是设施编码(不是一张图的监测点编码),定位按该编码在图上查找对应设施'
+            : '三类对象当前都没有关联设施编码,本面板没有可定位对象',
+        // 票 68:三段各自的业务来源 + 关联编码语义 + 关联事件口径(三段各一句,逐句可核)。
+        // 措辞纪律:只说业务含义与范围,**不出现接口字段名**(读者是值班人员,不是接口调用方)。
+        sourceNote: [
+            '三类对象来自各自的业务台账、不合并:应急资源=设备台账里的监测设备;防护目标=燃气风险评估里登记的防护目标;危险源=隐患台账里能关联到设备的隐患(关联不到设备的不计入,所以这一段为空不等于隐患库为空)。',
+            pointCodeNote,
+            '关联事件=对象所在设备上尚未解除的预警、尚未关闭的报警与未结束的工单,按对象逐条加总(同一台设备的事件可能同时计在设备对象与它关联的危险源上),不是全城去重后的事件总数;只有能回联到设备的对象才有这类计数,防护目标不挂设备,它的关联事件恒为 0。',
+        ].join(''),
         // 关联事件总量是后端给的口径,界面照实带出(不自己加总)。
         eventNote: current.eventSummary
             ? `关联事件:预警 ${valueText(current.eventSummary.warningCount)} · 报警 ${valueText(current.eventSummary.alarmCount)} · 工单 ${valueText(current.eventSummary.workOrderCount)}`

+ 247 - 0
src/modules/cockpit/domain/ledgerLoader.js

@@ -0,0 +1,247 @@
+/**
+ * 监测点台账的**自动渐进装载链**(票 62,批次四)。
+ *
+ * 为什么单独成层:票 18 的台账是「人工点『加载下一页』」,一页 500 条、每次追加≈500 张卡;
+ * 票 57/62 把它改成**与一张图点位数据同款**的自动链(首载/条件变化 → 第 1 页 → 自动连翻到
+ * settled),而界面只对**已装载集合**做页码分页。链路的进度、快照锁定、失败保留语义
+ * 都是纯状态迁移,放在这里可脱浏览器断言;store 只管「发请求 + 换纪元」。
+ *
+ * 三条硬纪律(都是票面 Acceptance 原句,收口在这里):
+ *   1. **第 2 页起必须带第 1 页锁定的快照标记**(后端 `validatePagination`:page>1 无
+ *      `snapshotAt` 直接 400)。token 来自响应回显(后端执行票 62 补齐),不是前端自己
+ *      挑时间戳 —— 自己挑就是「猜一个后端可能不认的值」。
+ *   2. **下一批失败不回滚已装载内容**:失败只改 `phase`/`reason`,`points`/`equipment`
+ *      原样保留,界面据此说「还有一批没取到」而不是把用户已经看到的卡片抹掉。
+ *   3. **失败不自动无限重试**(决策 04):链路停住,恢复靠新鲜度周期或手动入口。
+ *
+ * 与 `domain/mapSummary.js` 装载器的关系:同一套纪律(快照锁定 / 迟到响应作废 /
+ * 失败保持页码),但**不共用实现** —— 地图摘要合并的是「对象」且带数量护栏,
+ * 台账要的是**完整序列**(供页码分页),两者的裁剪语义不同,硬合并会长出开关。
+ */
+
+/** 台账装载链的阶段(与 `mapSummary` 同词汇,界面文案可复用)。 */
+export const LEDGER_PHASE = {
+    IDLE: 'idle',
+    LOADING: 'loading',
+    COMPLETE: 'complete',
+    EMPTY: 'empty',
+    FAILED: 'failed',
+    EXPIRED: 'expired',
+}
+
+/** 「不用再翻了」的两态。 */
+export const LEDGER_SETTLED_PHASES = [LEDGER_PHASE.COMPLETE, LEDGER_PHASE.EMPTY]
+
+/** 是否还在装载(界面据此标「正在装载…」,但**不隐藏已装载内容**)。 */
+export function ledgerLoading(state) {
+    return (state && state.phase) === LEDGER_PHASE.LOADING
+}
+
+/** 链路是否已经停在终态(不再自动翻页)。 */
+export function ledgerSettled(state) {
+    return LEDGER_SETTLED_PHASES.includes((state && state.phase) || '')
+}
+
+/** 链路失败/过期时「已装载」的口径仍然有效(不回滚是纪律 2)。 */
+export function ledgerFailed(state) {
+    return (state && (state.phase === LEDGER_PHASE.FAILED || state.phase === LEDGER_PHASE.EXPIRED)) === true
+}
+
+/** 初值:还没取过任何一页。`page=0`=一页都没成,`nextPage=1`=下一次请求第 1 页。 */
+export function emptyLedgerState(extra = {}) {
+    return {
+        phase: LEDGER_PHASE.IDLE,
+        snapshot: '',
+        page: 0,
+        nextPage: 1,
+        pageSize: 0,
+        total: 0,
+        loaded: 0,
+        points: [],
+        equipment: [],
+        reason: '',
+        ...extra,
+    }
+}
+
+/**
+ * 把一页并进合并态(纯函数)。
+ *
+ * 去重按 `kind|code`:后端同快照下分页稳定,正常不会重叠;但**重放同一页**(用户切走
+ * 再切回、或迟到响应被接受)时不得让同一张卡出现两次 —— 页卡没有 `:key` 唯一性保护,
+ * 重复键会让整块列表渲染错位(票 64 在运行指标上踩过同一类坑)。
+ *
+ * @param {object|null} prev 上一次合并态(`null`=新一次装载)
+ * @param {object} page 一页领域批次 `{ snapshotAt, page, pageSize, total, hasMore, points, equipment }`
+ */
+export function mergeLedgerPage(prev, page = {}) {
+    const base = prev && Array.isArray(prev.points) ? prev : emptyLedgerState()
+    const seen = new Set([...base.points, ...base.equipment].map((item) => `${item.kind}|${item.code}`))
+    const fresh = (list) =>
+        (Array.isArray(list) ? list : []).filter((item) => {
+            const key = `${item && item.kind}|${item && item.code}`
+            if (key.endsWith('|')) return true
+            if (seen.has(key)) return false
+            seen.add(key)
+            return true
+        })
+
+    const points = [...base.points, ...fresh(page.points)]
+    const equipment = [...base.equipment, ...fresh(page.equipment)]
+    const loadedPage = Number.isFinite(page.page) ? page.page : base.page + 1
+    const total = Number.isFinite(page.total) ? page.total : base.total
+    const hasMore = page.hasMore === true
+
+    return {
+        ...base,
+        // 快照标记以「本次装载的第一次响应」为准;后续页带同一个标记请求,正常不会变。
+        snapshot: base.snapshot || page.snapshotAt || '',
+        page: loadedPage,
+        nextPage: hasMore ? loadedPage + 1 : loadedPage,
+        pageSize: Number.isFinite(page.pageSize) && page.pageSize > 0 ? page.pageSize : base.pageSize,
+        total,
+        loaded: points.length + equipment.length,
+        points,
+        equipment,
+        reason: '',
+        // 全部翻完(后端说没有下一页)才结算;空序列是 EMPTY,不是 COMPLETE。
+        phase: hasMore ? LEDGER_PHASE.IDLE : total === 0 ? LEDGER_PHASE.EMPTY : LEDGER_PHASE.COMPLETE,
+    }
+}
+
+/** 失败:**只改阶段与原因**,已装载的 points/equipment 原样保留(纪律 2)。 */
+export function failLedgerState(prev, reason = '') {
+    const base = prev && Array.isArray(prev.points) ? prev : emptyLedgerState()
+    return { ...base, phase: LEDGER_PHASE.FAILED, reason: typeof reason === 'string' ? reason : '' }
+}
+
+/** 快照失效:与失败分开记(界面说「快照已失效,重新加载」而不是「加载失败」)。 */
+export function expireLedgerState(prev) {
+    const base = prev && Array.isArray(prev.points) ? prev : emptyLedgerState()
+    return { ...base, phase: LEDGER_PHASE.EXPIRED, reason: 'snapshot-expired' }
+}
+
+/** 从「已装载」推进到「正在取下一页」(读状态时少一层判断)。 */
+export function markLedgerLoading(prev) {
+    const base = prev && Array.isArray(prev.points) ? prev : emptyLedgerState()
+    return { ...base, phase: LEDGER_PHASE.LOADING }
+}
+
+/** 该台账状态还要不要继续自动翻(store 的自动链循环条件)。 */
+export function ledgerAutoStops(state) {
+    return ledgerSettled(state) || ledgerFailed(state)
+}
+
+/**
+ * 合并态 → 界面读数(页码分页要的三个数 + 如实页脚)。
+ *
+ * 页脚口径(票面原句「已装载 M · 共 N 条(含未关联设备)」):
+ *   · `M` 取**实际已装载**条数(`points+equipment`),不是 `page*pageSize`(条文 7 的反面);
+ *   · `N` 取后端 `pagination.total`,它横跨「监测点 + 未关联设备」一条总序列,所以必须
+ *     明说是含未关联设备的;
+ *   · 链路未 settled 时补「正在装载」/「还有 N 条未装载」,失败时补如实失败原因。
+ * @param {object} state 合并态
+ */
+export function ledgerView(state) {
+    const current = state && Array.isArray(state.points) ? state : emptyLedgerState()
+    const total = Number.isFinite(current.total) && current.total >= 0 ? current.total : null
+    const loaded = current.loaded
+    // 总页数按后端回显的页大小算(拿不到页大小就是 null —— N/A 比编一个页数诚实,条文 7)。
+    const pageSize = Number.isFinite(current.pageSize) && current.pageSize > 0 ? current.pageSize : null
+    const pageCount = total && pageSize ? Math.ceil(total / pageSize) : null
+    const parts = []
+    if (total !== null) {
+        parts.push(`已装载 ${loaded} · 共 ${total} 条(含未关联设备)`)
+    }
+    if (ledgerLoading(current)) parts.push('正在装载…')
+    else if (current.phase === LEDGER_PHASE.EXPIRED) parts.push('快照已失效,需重新装载')
+    else if (current.phase === LEDGER_PHASE.FAILED) parts.push('下一批未取到,已装载内容保留')
+    else if (current.phase === LEDGER_PHASE.IDLE && total !== null && loaded < total) parts.push(`还有 ${total - loaded} 条未装载`)
+    return {
+        pageNote: parts.length ? `${parts[0]}${parts.slice(1).map((item) => ` · ${item}`).join('')}` : '',
+        total,
+        loaded,
+        pageCount,
+        settled: ledgerSettled(current),
+        failed: ledgerFailed(current),
+        loading: ledgerLoading(current),
+    }
+}
+
+/**
+ * 台账装载器(票 62;与 `createMapSummaryLoader` 同一套职责划分)。
+ *
+ * 装载器自身持一层 token(同一批加载里的并发保护);**跨链**的作废归 store 的纪元。
+ *
+ * **页大小不在这里定**:台账页大小是后端契约(`/monitoring/distribution` 默认 500),
+ * 前端硬编码一个数就成了第二份口径 —— 后端改默认值时前端会悄悄要错页。所以查询串
+ * **不带 `pageSize`**,由后端说了算;装载器只在响应里读回它(`pageSize` 仅用于读数展示)。
+ * @param {object} options `{ fetchPage }`
+ *   `fetchPage({ page, snapshotAt })` → `{ ok, data, reason }`
+ */
+export function createLedgerLoader(options = {}) {
+    const fetchPage = options.fetchPage
+
+    let state = emptyLedgerState()
+    let token = 0
+
+    function readState() {
+        return {
+            ...state,
+            points: [...state.points],
+            equipment: [...state.equipment],
+        }
+    }
+
+    /** 翻下一页。settled 后是空操作;失败后可以再次调用(重试**同一页**,不静默丢数据)。 */
+    async function loadNext() {
+        if (!fetchPage) return readState()
+        if (ledgerAutoStops(state)) return readState()
+
+        const wanted = state.nextPage
+        // 纪律 1:第 2 页起必须带第 1 页锁定的快照;缺 token 就**停住**,不拿猜的值去换 400。
+        if (wanted > 1 && !state.snapshot) {
+            state = failLedgerState(state, 'snapshot-token-missing')
+            return readState()
+        }
+        const query = { page: wanted }
+        if (state.snapshot) query.snapshotAt = state.snapshot
+
+        token += 1
+        const mine = token
+        state = markLedgerLoading(state)
+
+        let result = null
+        try {
+            result = await fetchPage(query)
+        } catch (error) {
+            result = { ok: false, reason: 'unreachable', error: String((error && error.message) || error) }
+        }
+        // 并发点两次不得重复翻页:只认最后一次。
+        if (mine !== token) return readState()
+
+        if (!result || result.ok !== true) {
+            const reason = (result && result.reason) || 'unavailable'
+            state = reason === 'snapshot-expired' ? expireLedgerState(state) : failLedgerState(state, reason)
+            return readState()
+        }
+
+        const data = result.data || {}
+        // 快照换了:这一页属于另一份快照,拼进去就是两份数据混在一起 ⇒ 如实标失效。
+        if (state.snapshot && data.snapshotAt && data.snapshotAt !== state.snapshot) {
+            state = expireLedgerState(state)
+            return readState()
+        }
+        state = mergeLedgerPage(state, { ...data, page: wanted })
+        return readState()
+    }
+
+    /** 换新快照:丢掉已装载集合,从第 1 页重来(下一次请求不带 `snapshotAt`)。 */
+    async function refresh() {
+        state = emptyLedgerState()
+        token += 1
+        return loadNext()
+    }
+
+    return { loadNext, refresh, getState: readState }
+}

+ 130 - 0
src/modules/cockpit/domain/lodTiers.js

@@ -0,0 +1,130 @@
+/**
+ * LOD 层级策略(票 61 第十六轮:为五级 LOD 做准备)。
+ *
+ * ## 为什么要有这一层
+ *
+ * 旧实现把「精模覆盖到多远」压成一个数字(`fineScreenSpaceError`)。三级够用,但层级一多,
+ * 那个数字就表达不了「第 3 级在哪、第 4 级在哪」——每加一级都得再发明一个旋钮。
+ * 这里改成**按距离声明层级**:每一级说清「进到这个距离就用我」,屏幕空间误差由距离
+ * 反推(`sse = ge × viewportHeight ÷ (distance × 2·tan(fovy/2))`)。
+ *
+ * ## 当前资产的真实层级
+ *
+ * 全城粗模每个区域只有**一块** `ge=0` 单网格,区域 tileset 是 `preview(ge=48) → fine(ge=0)`。
+ * 也就是说资产目前只给得出**三种外观**:
+ *
+ *     coarse(白盒) → preview(有屋顶无窗) → fine(窗户立面)
+ *
+ * 所以下面五级里,第 0/1 级会落到同一份 coarse、第 3/4 级会落到同一份 fine ——
+ * **不是代码没生效,是资产还没有对应的几何**。等 [票 90](五级模型建模)产出新代次后,
+ * 运行时不需要再改:层级表已经按五级表达好了。
+ *
+ * ## 契约(给建模票的靶子)
+ *
+ * 资产层只要提供 5 个几何误差档,运行时就会按 `activateWithinM` 逐级切换。
+ */
+
+/** 视口高度与视场角在工程里的实测口径(1440×810 画布、fovy 60°)。 */
+export const VIEWPORT_HEIGHT_PX = 810
+/**
+ * Cesium 屏幕空间误差式子的分母系数:`2 · tan(fovy/2)`。
+ * fovy=60° ⇒ 2·tan(30°) = 1.1547。**这个 2 不能漏** —— 漏了整条曲线会差一倍
+ * (56 会被算成 112,精模半径从 600m 掉到 300m)。
+ */
+export const FOVY_SSE_FACTOR = 2 * Math.tan(Math.PI / 6)
+
+/**
+ * 默认五级。`activateWithinM` = 这一级的**距离上界**(进入这个距离以内就用它)。
+ * 从远到近排列,各上界严格递减;比最远一级还远的物体用最远一级,比最近一级还近的用最近一级。
+ *
+ * 距离带(默认):
+ *     > 3000      → far(全城体块)
+ *     1600~3000   → regional(分区粗模)
+ *     600~1600    → preview(有屋顶无窗 = 中模)
+ *     250~600     → medium(简化立面,**需要票 90 的资产**)
+ *     ≤ 250       → fine(全细节)
+ */
+export const DEFAULT_LOD_TIERS = [
+    { id: 0, name: 'far',      geometry: 'coarse',  activateWithinM: 6000 },
+    { id: 1, name: 'regional', geometry: 'coarse',  activateWithinM: 3000 },
+    { id: 2, name: 'preview',  geometry: 'preview', activateWithinM: 1600 },
+    { id: 3, name: 'medium',   geometry: 'preview', activateWithinM: 600 },
+    { id: 4, name: 'fine',     geometry: 'fine',    activateWithinM: 250 },
+]
+
+/** 屏幕空间误差:Cesium 用的那条式子的纯函数版本(脱浏览器可断言)。 */
+export function screenSpaceErrorAt(geometricError, distanceM) {
+    const ge = Number(geometricError)
+    const d = Number(distanceM)
+    if (!Number.isFinite(ge) || !Number.isFinite(d) || d <= 0) return null
+    return (ge * VIEWPORT_HEIGHT_PX) / (d * FOVY_SSE_FACTOR)
+}
+
+/** 反解:想在 distanceM 处刚好下钻到某个 geometricError 的层,阈值该取多少。 */
+export function thresholdForDistance(geometricError, distanceM) {
+    const sse = screenSpaceErrorAt(geometricError, distanceM)
+    return sse === null ? null : sse
+}
+
+/**
+ * 把外部配置收成合法层级表:
+ *   · 距离必须是有限正数(最近一级允许 0);
+ *   · 必须**从远到近**严格递减(否则「进到更近反而切回粗模」);
+ *   · 至少两级。非法输入整表退回默认,不半懂不懂地改一半。
+ */
+export function normalizeLodTiers(input) {
+    if (!Array.isArray(input) || input.length < 2) return DEFAULT_LOD_TIERS.map(t => ({ ...t }))
+    const cleaned = []
+    for (const raw of input) {
+        if (!raw || typeof raw !== "object") return DEFAULT_LOD_TIERS.map(t => ({ ...t }))
+        const d = Number(raw.activateWithinM)
+        // 距离必须是**正**数:0 会让「上界 ≥ 距离」对所有距离成立,等于把全场景
+        // 一键切到最精细级 —— 那是静默的预算灾难,宁可整表退回默认。
+        if (!Number.isFinite(d) || d <= 0) return DEFAULT_LOD_TIERS.map(t => ({ ...t }))
+        cleaned.push({
+            id: cleaned.length,
+            name: typeof raw.name === "string" && raw.name ? raw.name : `tier-${cleaned.length}`,
+            geometry: typeof raw.geometry === "string" && raw.geometry ? raw.geometry : "preview",
+            activateWithinM: d,
+        })
+    }
+    for (let i = 1; i < cleaned.length; i += 1) {
+        if (!(cleaned[i].activateWithinM < cleaned[i - 1].activateWithinM)) {
+            return DEFAULT_LOD_TIERS.map(t => ({ ...t }))
+        }
+    }
+    return cleaned
+}
+
+/**
+ * 某距离该用哪一级:取第一个「激活距离 ≥ 距离」的级;贴脸取最后一级。
+ * 距离非法时返回最粗一级(宁可粗也不炸)。
+ */
+export function tierForDistance(distanceM, tiers = DEFAULT_LOD_TIERS) {
+    const list = Array.isArray(tiers) && tiers.length ? tiers : DEFAULT_LOD_TIERS
+    const d = Number(distanceM)
+    // 脏距离退最粗一级:宁可粗,也不要因为 NaN 让某处突然变精(那会打穿预算)。
+    if (!Number.isFinite(d)) return list[0]
+    // 取「上界 ≥ 距离」里**最小**的那个 —— 那就是这条距离该用的最精细一级。
+    // 比最远一级还远时没有任何候选 ⇒ 用最远一级(全城体块本来就覆盖到那里)。
+    let best = null
+    for (const tier of list) {
+        if (tier.activateWithinM >= d && (!best || tier.activateWithinM < best.activateWithinM)) best = tier
+    }
+    return best || list[0]
+}
+
+/**
+ * 由层级表推出「下钻到精模」的屏幕空间误差阈值:
+ * 取 `geometry === fine` 的那一级,用它上一级的距离边界反解。
+ * 找不到 fine 级就返回 null(调用方保留自己的默认)。
+ */
+export function fineThresholdFromTiers(tiers, fineGeometricError = 48) {
+    const list = Array.isArray(tiers) && tiers.length ? tiers : DEFAULT_LOD_TIERS
+    const index = list.findIndex(t => t.geometry === "fine")
+    if (index < 0) return null
+    // 精模进入的距离 = 它上一级的边界(上一级在那之前还够用)
+    const boundary = index > 0 ? list[index - 1].activateWithinM : 600
+    if (!Number.isFinite(boundary) || boundary <= 0) return null
+    return thresholdForDistance(fineGeometricError, boundary)
+}

+ 21 - 3
src/modules/cockpit/domain/mapLocate.js

@@ -2,8 +2,9 @@
  * 面板可定位对象 → 地图定位(票 17;票 18/19 的台账与事件定位复用同一条接缝)。
  *
  * 为什么单独成一层:面板拿到的**只有点位编码**(后端这些聚合切片不返回经纬度,
- * 见 `infrastructure/operating-information` 的 `pointCodes`、`infrastructure/emergency-support`
- * 的 `pointCodes`),而地图上的坐标在 `mapStore` 已加载的地图摘要里。所以"点面板里的对象联动地图"
+ * 见 `infrastructure/emergency-support` 的 `pointCodes`;`operating-information` 的
+ * `pointCodes` 实为设施编码、票 50 起不再喂本接缝),而地图上的坐标在 `mapStore`
+ * 已加载的地图摘要里。所以"点面板里的对象联动地图"
  * 本质是一次**回联 + 判定**,不是"再取一次数":
  *   1. 从已加载的地图摘要里按编码找对象;
  *   2. 准入判据**只认后端 `renderable`**(票 07 的 `isRenderable()` —— 前端绝不重算经纬度);
@@ -17,6 +18,7 @@
  */
 import { isRenderable, blockReason } from './coordinate.js'
 import { MAP_OBJECT_KIND } from './mapSummary.js'
+import { TERRAIN_LOCATE_REASON } from './pointLocate.js'
 
 /** 定位结果的四种情形(也是 `data-locate-result` 的取值,供走查断言)。 */
 export const LOCATE_RESULT = {
@@ -26,10 +28,25 @@ export const LOCATE_RESULT = {
     UNAVAILABLE: 'unavailable',
 }
 
-/** 给人读的三种失败文案(不泄漏接口词汇)。 */
+/** 给人读的失败文案(不泄漏接口词汇)。 */
 export const LOCATE_TEXT = {
     MISSING: '该点位不在已加载的一张图范围内',
     UNAVAILABLE: '地图尚未就绪,稍后重试',
+    TERRAIN: '地形尚未就绪,定位待确认,请稍后重试',
+    COORDINATE: '该对象坐标不可用,暂无法定位',
+}
+
+/**
+ * 地形感知接缝(`locateMonitoringPoint`)回传的失败原因码 → 界面文案。
+ * 票 53:三种失败(地图未就绪 / 坐标不可用 / 地形未就绪)各有说法,值班人员才分得清
+ * 是自己点错、数据有问题还是稍后重试即可。认不出的原因码宁可回最笼统那一句,
+ * 也不静默(沿用票 07「内部原因码不上界面」的立场:这里只出中文)。
+ * @param {string} reason `domain/pointLocate.js` 的 `TERRAIN_LOCATE_REASON` 之一
+ */
+export function locateFailureText(reason) {
+    if (reason === TERRAIN_LOCATE_REASON.COORDINATE) return LOCATE_TEXT.COORDINATE
+    if (reason === TERRAIN_LOCATE_REASON.MAP) return LOCATE_TEXT.UNAVAILABLE
+    return LOCATE_TEXT.TERRAIN
 }
 
 /**
@@ -118,5 +135,6 @@ export function planLocate({ objects, pointCode, mapReady = true } = {}) {
 export function locateCameraTarget(object) {
     const current = object || {}
     if (!Number.isFinite(current.longitude) || !Number.isFinite(current.latitude)) return null
+    // 仅用于非监测点的旧中心换焦路径;完整点位定位由 mapService 采样真实地形。
     return { lon: current.longitude, lat: current.latitude, height: 0 }
 }

+ 15 - 4
src/modules/cockpit/domain/mapSummary.js

@@ -25,7 +25,7 @@ import { POINT_STATUS_FILTER_KEYS } from './filterOptions.js'
  */
 export function toStatusDistribution(raw) {
     const list = Array.isArray(raw && raw.pointStatusDistribution) ? raw.pointStatusDistribution : null
-    const unavailable = () => ({ available: false, counts: {} })
+    const unavailable = () => ({ available: false, counts: {}, businessCounts: {}, attachmentCounts: {} })
     if (!list) return unavailable()
     const counts = {}
     for (const item of list) {
@@ -36,7 +36,14 @@ export function toStatusDistribution(raw) {
         counts[status] = count
     }
     if (!POINT_STATUS_FILTER_KEYS.every((key) => Object.hasOwn(counts, key))) return unavailable()
-    return { available: true, counts }
+    // `pointStatusDistribution` 的 UNASSOCIATED 是附加轴总量,不应再与业务轴混成同一池。
+    // 业务轴统计仍保留后端发布的监测点六态;附加轴另存,供界面并行展示。
+    const businessCounts = {}
+    for (const key of POINT_STATUS_FILTER_KEYS) {
+        if (key !== 'UNASSOCIATED') businessCounts[key] = counts[key]
+    }
+    const attachmentCounts = { UNASSOCIATED: counts.UNASSOCIATED }
+    return { available: true, counts, businessCounts, attachmentCounts }
 }
 
 /** 地图对象的两种来源(CONTEXT.md:监测点优先作为可点击对象,未关联设备是设备)。 */
@@ -80,6 +87,7 @@ export function toMonitoringPoint(raw) {
  */
 export function toUnassociatedEquipment(raw) {
     const reader = fieldReader(raw, 'unassociatedEquipment')
+    // 坐标按契约是必给的:缺失要记一笔(票 07 据此区分「缺失」与「非法」),值仍保持 null。
     const longitude = reader.num('longitude')
     const latitude = reader.num('latitude')
 
@@ -88,8 +96,11 @@ export function toUnassociatedEquipment(raw) {
         code: reader.str('deviceCode'),
         networkId: raw?.networkId == null ? '' : String(raw.networkId),
         name: reader.str('deviceName'),
-        // 未关联设备没有点位状态:留空而不是借用设备的在线态(0/NORMAL 都算伪造)。
-        status: '',
+        // 票 45(维护者裁定:附加状态不抢业务状态):后端发布的设备自身状态**原样带出**
+        // (实测契约:`deviceStatus` ∈ 设备五态 ALARM/WARNING/WORK_ORDER/NORMAL/OFFLINE,
+        // 缺失/空 = 状态未发布)。此前本字段被丢弃(status: ''),一台预警中的设备在图上
+        // 只显示「无关联设备」——附加状态抢占业务状态的直接成因。前端不重算,只渲染。
+        status: reader.optionalStr('deviceStatus'),
         statusReasons: [],
         deviceCodes: [],
         // 批次 3:未关联设备用其行业图标(镂空母版 + 内层)。

+ 385 - 0
src/modules/cockpit/domain/markerMetrics.js

@@ -0,0 +1,385 @@
+/**
+ * 地图标记几何的**唯一来源**(票 72 规格;票 82 实施)。
+ *
+ * ── 为什么要有这一层 ──
+ * 本体宽、描边 pad、绘制宽此前分散在 `markerPlan` / `markerAssets` / `markerOverlay` /
+ * `deviceAssets` 四处各写一份;其中 pad 还是从**监测点**的换算比派生的,设备跟着吃了
+ * 同一个 2.86(自洽值应为 2.22),把「点比设备大」的层级在屏幕上从 1.286 压到 1.225。
+ * 本模块是那个单源:改档位只改这里。
+ *
+ * 纯函数、纯数据,不碰 Cesium / DOM / Vue。
+ * 对外只有两类查询:
+ *   · `markerMetricsFor(kind)` —— 按对象种类取几何(本体宽 / pad / 绘制宽);
+ *   · `MARKER_COEFFICIENTS` —— 所有档共用的全局系数(选中倍率、环峰值/起始/层数、降级)。
+ *
+ * 光环参数只在本层**声明**,消费方(票 84)从 `deviceEffects` / DOM 读取,渲染层不另写数值。
+ */
+import { EMPHASIS_STYLE } from './mapEmphasis.js'
+
+/**
+ * 母版源几何(512 画布实测;与 `markerPlan.MASTER_GEOMETRY` 同一枚母版)。
+ * 尺寸关系在此单源:bbox 的宽高、描边外扩、白描边宽度。
+ */
+export const MASTER_SOURCE = {
+    /** 画布内不透明包围盒宽(源像素)。 */
+    bboxWidth: 378,
+    /** 画布内不透明包围盒高(源像素)。 */
+    bboxHeight: 475,
+    /** 描边外扩(源像素):源图裁剪窗与 DOM viewBox 共用同一份,不许各写 40。 */
+    outlinePad: 40,
+    /** 白色描边外扩(源像素)。 */
+    outlineWidth: 28,
+}
+
+/** 母版高宽比(高 / 宽)。 */
+export const MASTER_ASPECT = MASTER_SOURCE.bboxHeight / MASTER_SOURCE.bboxWidth
+
+/**
+ * pad 比例 = 源 pad ÷ 源宽(**全局唯一声明**)。
+ * 各档 pad = 本体宽 × 该比例:监测点 2.86、监测设备 2.22。
+ */
+export const PAD_RATIO = MASTER_SOURCE.outlinePad / MASTER_SOURCE.bboxWidth
+
+/** 档位本体宽(屏显 CSS px)。监测设备档同时服务关联与未关联设备。 */
+export const MARKER_BODY_WIDTH = {
+    point: 27,
+    device: 21,
+}
+
+/** 全局系数(规范 58 §八 / 票 72;渲染层只消费,不另造数值)。 */
+export const MARKER_COEFFICIENTS = {
+    /** 选中态放大倍率;与图标强调**共用同一个常量**,全仓不再出现第二个 1.55 字面量。 */
+    selectedBoost: EMPHASIS_STYLE.markerScale,
+    /** 光环峰值倍率(相对绘制宽)。 */
+    ringPeak: 2.6,
+    /** 光环起始倍率(环必须画在图标之下,起始小于 1 也不会露出硬洞)。 */
+    ringStart: 0.55,
+    /** 降级收敛系数:只收敛不消失(取代 07 的「降级后动画全停」)。 */
+    degradePeak: 0.6,
+    degradeAlpha: 0.5,
+    /** 中心辉光峰值倍率(只给报警)。 */
+    glowPeak: 1.9,
+    /**
+     * 发光强度包络(2026-10-27 恢复已验收强度)。
+     *
+     * 由来:票 84 把环的 opacity 峰值压到 0.5/0.42、辉光压到 0.32、并把环的彩色外发光换成
+     * 白色硬边带 —— 维护者走查(2026-10-27)报「特效变得非常微弱」。这里把**已被验收过的**
+     * 强度值收回单源,渲染层只消费,不再散落在 CSS 里写死。
+     *   · `alphaPeak`:每层起始不透明度上限(报警 1 / 预警 0.85,见 `RING_EFFECT`);
+     *   · `glowAlphaMin/Peak`:中心辉光呼吸的两端(对齐已验收关键帧 0.32 → 0.85);
+     *   · `glowBrightnessMin/Peak`:辉光亮度提亮的两端(对齐 1.2 → 1.9);
+     *   · `ringBrightnessStart/End`:环自发光亮度包络(对齐已验收关键帧 2.0 → 0.8,
+     *     发射瞬间要「亮一下」才有能量感,只靠 opacity 会显得灰)。
+     */
+    glowAlphaMin: 0.32,
+    glowAlphaPeak: 0.85,
+    glowBrightnessMin: 1.2,
+    glowBrightnessPeak: 1.9,
+    ringBrightnessStart: 2,
+    ringBrightnessEnd: 0.8,
+    /** 工单处置(只旋转不扩散)的不透明度两端:对齐已验收关键帧 0.95 → 0.68。 */
+    spinAlphaStart: 0.95,
+    spinAlphaEnd: 0.68,
+}
+
+/** 对象种类 → 档位。重叠聚合符号**没有独立档**:等于设备档(票 72 D9)。 */
+const TIER_OF_KIND = {
+    'monitoring-point': 'point',
+    'monitoring-device': 'device',
+    'unassociated-equipment': 'device',
+    'overlap-aggregate': 'device',
+}
+
+const round2 = (value) => Math.round(value * 100) / 100
+const metricsCache = new Map()
+
+
+
+/** 对象种类 → 档位名(认不出来按设备档,与调用方既有默认一致)。 */
+export function tierOf(kind) {
+    return TIER_OF_KIND[kind] || 'device'
+}
+
+/**
+ * 按对象种类取几何(本体宽 / 高 / pad / 绘制宽高)。
+ *
+ * 派生读数(验收直接断言这些值):
+ *   · 监测点:pad 2.86、绘制宽 32.72;
+ *   · 监测设备:pad 2.22、绘制宽 25.44;
+ *   · 绘制比 = 1.286(「点比设备大」在屏幕上真的成立)。
+ *
+ * @param {string} kind `monitoring-point` / `monitoring-device` / `unassociated-equipment` / `overlap-aggregate`
+ */
+export function markerMetricsFor(kind) {
+    const tier = tierOf(kind)
+    let metrics = metricsCache.get(tier)
+    if (!metrics) {
+        // 票 86:DEV 档位倍率只作用于渲染几何(默认 1 = 正式路径不变)。
+        const knobs = markerDevKnobs()
+        const multiplier = tier === 'point' ? knobs.pointTier : knobs.deviceTier
+        const bodyWidth = round2(MARKER_BODY_WIDTH[tier] * multiplier)
+        const pad = round2(bodyWidth * PAD_RATIO)
+        const height = round2(bodyWidth * MASTER_ASPECT)
+        metrics = {
+            tier,
+            bodyWidth,
+            height,
+            pad,
+            drawWidth: round2(bodyWidth + pad * 2),
+            drawHeight: round2(height + pad * 2),
+        }
+        metricsCache.set(tier, metrics)
+    }
+    return metrics
+}
+
+/** 环峰值外径 = 绘制宽 × 环峰值倍率(监测点 85.07 / 设备 66.14)。 */
+export function ringPeakDiameterFor(kind) {
+    return round2(markerMetricsFor(kind).drawWidth * MARKER_COEFFICIENTS.ringPeak)
+}
+
+/** 选中态环峰值外径 = 环峰值外径 × 选中倍率(监测点 131.86 / 设备 102.52)。 */
+export function ringSelectedPeakDiameterFor(kind) {
+    return round2(ringPeakDiameterFor(kind) * MARKER_COEFFICIENTS.selectedBoost)
+}
+
+/** 降级态环峰值外径 = 绘制宽 × 环峰值 × 降级系数(设备 39.69;按未舍入的乘积链算,避免二次舍入漂移)。 */
+export function ringDegradedPeakDiameterFor(kind) {
+    const { ringPeak, degradePeak } = MARKER_COEFFICIENTS
+    return round2(markerMetricsFor(kind).drawWidth * ringPeak * degradePeak)
+}
+
+/** 状态 → 光环动效参数;正常/离线/未知返回 null(不挂环)。 */
+export function ringEffectFor(status) {
+    return RING_EFFECT[status] || null
+}
+
+/** 状态 → 光环层数;无光环状态回 0(正常/离线/未知都不挂环)。 */
+export function ringLayersFor(status) {
+    const profile = ringEffectFor(status)
+    return profile ? profile.layers : 0
+}
+
+/**
+ * 所有光环状态中的**最大层数**(诊断读数面专用)。
+ *
+ * 为什么要有它(2026-10-27,票 70 顺带修):引擎的诊断读数此前写成 `ringLayersFor('ALARM')` ——
+ * 把业务状态名带进了 Cesium 引擎,违反「引擎只认 shape/tone、不认识业务状态」这条守卫
+ * (t09 的 F5)。读数要的是「本套光环最多几层」,与具体状态无关,这里给一个状态无关的查询。
+ */
+export function maxRingLayers() {
+    return Object.values(RING_EFFECT).reduce((max, profile) => Math.max(max, profile.layers || 0), 0)
+}
+
+// ================================================================ 光环动效参数(第二类查询:按状态取动效参数)
+
+/** 光环质感包(票 72 §质感参数):可见带占比与粒子数量。 */
+export const RING_STYLE = {
+    /** 硬边高亮带占半径的比例(规格 5–8%,取中值 6%)。 */
+    bandWidth: 0.06,
+    /** 可见带内沿(半径比例)。 */
+    bandInner: 0.88,
+    /** 可见带外沿。 */
+    bandOuter: 0.93,
+    /** 火花数量(共享一张纹理,靠径向推进)。 */
+    sparks: 3,
+    /** 刻度环层数(刻度烘焙进纹理,靠旋转)。 */
+    tickRings: 2,
+    /** 刻度环/工单齿轮转速(°/s):规格区间 12–25,取中值 20(一圈 18 秒)。 */
+    tickDegPerSec: 20,
+}
+
+/**
+ * 光环动效参数(按对象**自身状态**分级)。
+ *
+ * 口径(票 72 / 票 84 实施):
+ *   · 峰值包络 2.6、起始 0.55 对所有扩散态统一;工单处置**只旋转不扩散**(scale 固定 1)。
+ *   · 层数 4/3/1 —— 严重程度由层数与节奏表达,不由尺寸表达。
+ *   · 工单旋转周期由 12–25°/s 反推(取 20°/s → 18s);旧 `RING_PERIOD_MS.SPIN=2600ms`
+ *     (≈138°/s,与转速要求直接冲突)作废。
+ *   · 中心辉光只给报警。
+ *   · `alphaPeak`(每层起始不透明度)2026-10-27 恢复为**已验收强度**:报警 1、预警 0.85。
+ *     此前被压到 0.5 / 0.42,维护者走查直接报「特效非常微弱」。
+ *     多层叠加不会炸白:层 0 峰值最高、外层按 `RING_ALPHA_EXPONENTS` 更快衰减,
+ *     且峰值只出现在各自相位起点(层间错开 1/layers)。
+ */
+export const RING_EFFECT = {
+    ALARM: {
+        status: 'ALARM',
+        effect: 'pulse',
+        layers: 4,
+        periodMs: 1800,
+        startScale: MARKER_COEFFICIENTS.ringStart,
+        peakScale: MARKER_COEFFICIENTS.ringPeak,
+        alphaPeak: 1,
+        glow: true,
+        glowScale: MARKER_COEFFICIENTS.glowPeak,
+        glowPeriodMs: 2400,
+    },
+    WARNING: {
+        status: 'WARNING',
+        effect: 'blink',
+        layers: 3,
+        periodMs: 1400,
+        startScale: MARKER_COEFFICIENTS.ringStart,
+        peakScale: MARKER_COEFFICIENTS.ringPeak,
+        alphaPeak: 0.85,
+        glow: false,
+        blinkPeriodMs: 2600,
+    },
+    WORK_ORDER: {
+        status: 'WORK_ORDER',
+        effect: 'spin',
+        layers: 1,
+        periodMs: 18000,
+        startScale: 1,
+        peakScale: 1,
+        // 只旋转不扩散的那一层:不透明度峰值为**已验收值 0.95**(渲染层 `markerFxSpin`
+        // 从 0.95 呼吸到 0.68)。此前写成 0.52 会让读数面(`data-marker-effect-alpha`)
+        // 与真实画面不一致 —— 单源就必须是画面的真值。
+        alphaPeak: 0.95,
+        glow: false,
+        spinDegPerSec: 20,
+    },
+}
+
+/** alpha 随相位衰减的幂次:内层缓和(1.0–1.2)、外层更快消失(1.6–2.2)。 */
+export const RING_ALPHA_EXPONENTS = [1.0, 1.2, 1.8, 2.2]
+
+/** 层序号 → alpha 衰减幂次(超出层数时夹到最外层)。 */
+export function ringAlphaExponentFor(layer) {
+    const list = RING_ALPHA_EXPONENTS
+    const index = Math.min(Math.max(0, Math.floor(Number(layer) || 0)), list.length - 1)
+    return list[index]
+}
+
+/** 字符串 → [0,1) 确定性偏移(FNV-1a;同 id 每次同值,不同 id 分散)。 */
+export function ringPhaseOffsetOf(id) {
+    const text = String(id == null ? '' : id)
+    let hash = 2166136261
+    for (let index = 0; index < text.length; index += 1) {
+        hash ^= text.charCodeAt(index)
+        hash = Math.imul(hash, 16777619)
+    }
+    return ((hash >>> 0) % 1000) / 1000
+}
+
+/**
+ * 单层光环的相位(0..1):层间均分错开 1/layers,形成连续外扩。
+ * 工单处置不扩散,相位只用于旋转(仍返回 0..1 供匀速旋转采样)。
+ */
+export function ringPhaseAt(layer, timeMs, status) {
+    const profile = ringEffectFor(status)
+    if (!profile) return 0
+    const t = Number.isFinite(timeMs) ? timeMs : 0
+    const layerFraction = (Number.isFinite(layer) ? layer : 0) / profile.layers
+    const raw = ((t % profile.periodMs) / profile.periodMs + layerFraction) % 1
+    return raw < 0 ? raw + 1 : raw
+}
+
+/** 单层光环的放大倍数:扩散态 0.55→2.6,工单处置恒 1(只旋转不扩散)。 */
+export function ringScaleAt(status, layer, timeMs) {
+    const profile = ringEffectFor(status)
+    if (!profile || profile.effect === 'spin') return 1
+    const phase = ringPhaseAt(layer, timeMs, status)
+    // 票 86:峰值倍率是 DEV 旋钮(默认 = MARKER_COEFFICIENTS.ringPeak,正式路径不变)。
+    const peakScale = markerDevKnobs().ringPeak
+    return profile.startScale + (peakScale - profile.startScale) * phase
+}
+
+/** 确定性闪烁系数:0.9 + 0.1·sin(2πt/2600ms + phase),禁止逐帧随机。 */
+export function blinkOpacityAt(timeMs, phaseOffset01 = 0) {
+    const t = Number.isFinite(timeMs) ? timeMs : 0
+    return 0.9 + 0.1 * Math.sin((2 * Math.PI * t) / 2600 + phaseOffset01 * 2 * Math.PI)
+}
+
+/**
+ * 单层光环的不透明度:峰值 alpha × (1-相位)^幂次;闪烁态再乘确定性闪烁系数。
+ * 峰值取各状态 `alphaPeak`(报警 1 / 预警 0.85 / 工单 0.52)——2026-10-27 从被压低的
+ * 0.5/0.42 恢复:环是「有没有事」的第一眼信号,压暗等于把状态语义也压没了。
+ */
+export function ringAlphaAt(status, layer, timeMs, { degradeAlpha = 1 } = {}) {
+    const profile = ringEffectFor(status)
+    if (!profile) return 0
+    if (profile.effect === 'spin') return profile.alphaPeak * degradeAlpha
+    const phase = ringPhaseAt(layer, timeMs, status)
+    const fade = Math.pow(1 - phase, ringAlphaExponentFor(layer))
+    const flicker = profile.effect === 'blink' ? blinkOpacityAt(timeMs, phase) : 1
+    const alpha = profile.alphaPeak * fade * flicker * degradeAlpha
+    // 票 86:alpha 下限是 DEV 旋钮(默认 0 = 不额外抬升)。
+    const floor = markerDevKnobs().alphaFloor
+    return Math.max(floor, Math.max(0, Math.min(1, alpha)))
+}
+
+/** 中心辉光的呼吸强度(0..1,正弦;周期 2400ms)。 */
+export function glowStrengthAt(timeMs) {
+    const period = RING_EFFECT.ALARM.glowPeriodMs
+    const t = Number.isFinite(timeMs) ? timeMs : 0
+    const phase = ((t % period) / period + 1) % 1
+    return (1 - Math.cos(phase * Math.PI * 2)) / 2
+}
+
+/** 中心辉光的放大倍数(1 .. 1.9,随呼吸)。 */
+export function glowScaleAt(timeMs) {
+    return 1 + (RING_EFFECT.ALARM.glowScale - 1) * glowStrengthAt(timeMs)
+}
+
+// ================================================================ DEV / A-B 旋钮(票 86;仅开发/验收)
+
+/** 六个 DEV/A-B 旋钮的**默认值**(只在这里声明一次;仅开发/验收可用,不写部署配置)。 */
+export const MARKER_DEV_KNOB_DEFAULTS = Object.freeze({
+    /** 监测点档倍率。 */
+    pointTier: 1,
+    /** 监测设备档倍率。 */
+    deviceTier: 1,
+    /** 环峰值倍率。 */
+    ringPeak: MARKER_COEFFICIENTS.ringPeak,
+    /** 选中倍率。 */
+    selectedBoost: MARKER_COEFFICIENTS.selectedBoost,
+    /** alpha 下限(0 = 不额外抬升)。 */
+    alphaFloor: 0,
+    /** 可见带占比(5–8% 区间,默认取中值 6%)。 */
+    bandWidth: RING_STYLE.bandWidth,
+})
+
+/** 合法区间:越界夹取、非法值忽略(旋钮是现场调试工具,不因手滑炸画面)。 */
+const KNOB_RANGES = {
+    pointTier: [0.5, 3],
+    deviceTier: [0.5, 3],
+    ringPeak: [1, 4],
+    selectedBoost: [1, 2.5],
+    alphaFloor: [0, 0.8],
+    bandWidth: [0.01, 0.2],
+}
+
+let devKnobOverrides = null
+
+/** 当前生效的六旋钮(默认值 + 现场覆盖)。 */
+export function markerDevKnobs() {
+    return devKnobOverrides ? { ...MARKER_DEV_KNOB_DEFAULTS, ...devKnobOverrides } : { ...MARKER_DEV_KNOB_DEFAULTS }
+}
+
+/**
+ * 设置 DEV 旋钮(部分更新)。只影响渲染参数,不改业务与正式默认值;
+ * 非法/未知键被忽略,越界值夹到合法区间。
+ */
+export function setMarkerDevKnobs(partial = {}) {
+    const next = { ...markerDevKnobs() }
+    for (const [key, value] of Object.entries(partial || {})) {
+        const range = KNOB_RANGES[key]
+        if (!range) continue
+        const number = Number(value)
+        if (!Number.isFinite(number)) continue
+        next[key] = Math.min(range[1], Math.max(range[0], number))
+    }
+    devKnobOverrides = next
+    metricsCache.clear()
+    return { ...next }
+}
+
+/** 恢复六旋钮默认值(走查结束/生产路径永不调用)。 */
+export function resetMarkerDevKnobs() {
+    devKnobOverrides = null
+    metricsCache.clear()
+    return markerDevKnobs()
+}

+ 38 - 24
src/modules/cockpit/domain/markerPlan.js

@@ -13,13 +13,16 @@
  *   母版 512×512 画布,bbox 378×475(宽高比 1.2566),尖端 x=49.87%、y=1.0;
  *   圆帽帽心相对高度 0.397、相对宽 49.9%;内层直径 = 60% 圆帽区域(≈0.60 × 母版宽)。
  */
+import { isBusinessAttentionStatus } from './pointStatus.js'
+import { MASTER_ASPECT, MASTER_SOURCE, markerMetricsFor } from './markerMetrics.js'
 
-/** 母版素材属性(实测登记,两个母版同形同包围盒)。 */
+
+/** 母版素材属性(实测登记,两个母版同形同包围盒)。尺寸关系来自单源 `markerMetrics.MASTER_SOURCE`。 */
 export const MASTER_GEOMETRY = {
     /** 画布内不透明包围盒(px,512 画布) */
-    bbox: { x: 67, y: 19, width: 378, height: 475 },
+    bbox: { x: 67, y: 19, width: MASTER_SOURCE.bboxWidth, height: MASTER_SOURCE.bboxHeight },
     /** 高 / 宽 */
-    aspect: 475 / 378,
+    aspect: MASTER_ASPECT,
     /** 尖端(锚点)相对 bbox:x 49.87%、y 100% */
     anchor: { x: 0.4987, y: 1.0 },
     /** 圆帽帽心相对 bbox:x 49.9%、y 0.397 */
@@ -28,16 +31,9 @@ export const MASTER_GEOMETRY = {
     innerDiameterRatio: 0.8,
 }
 
-/** 宽度基准(规格 10:监测点宽 18、设备宽 14;高度按母版比例换算)。
- *  2026-10-12 维护者走查反馈「图标有点小」,整体 ×1.5:27 / 21。
- *  第三轮:描边占 billboard 余量(`OUTLINE_PAD`),billboard 绘制宽 = 本体宽 + 2×pad 屏显值;
- *  本体屏显宽仍为 MARKER_WIDTH(描边是余量不挤占本体)。 */
-export const MARKER_WIDTH = { point: 27, device: 21 }
-
-/** 与 markerAssets.js 的 OUTLINE_PAD 一致(源像素);billboard 绘制尺寸要按它外扩。 */
-export const MARKER_OUTLINE_PAD = 40
-/** 源像素 → 本体屏显像素的换算比(本体 378 源像素 = 27 屏显像素)。 */
-export const MASTER_SOURCE_TO_WIDTH = 27 / 378
+// 宽度基准、pad 比例、绘制宽**全部**来自单源 `domain/markerMetrics.js`(票 72 规格 / 票 82)。
+// 本层不再持有任何尺寸常量:旧宽度基准与 pad 换算常量已退役(票 82),
+// 消费方只需继续读 `plan.size` / `plan.draw`(返回形状不变)。
 
 /**
  * 内层行业/设备图标映射(`CockpitIcon` 同一套语义名 → markerAssets 的 manifest key)。
@@ -107,15 +103,10 @@ export function planMarker({ kind, status, industry = '', unassociated = false,
     const innerIcon = innerIconFor(industry)
     const innerEnabled = !empty && innerIcon !== ''
 
-    // 尺寸(宽度基准;高度按母版实测比例)。监测点恒大于设备。
-    const width = isPoint ? MARKER_WIDTH.point : MARKER_WIDTH.device
-    const height = Math.round(width * MASTER_GEOMETRY.aspect * 100) / 100
-
-    // 批次 3 第三轮:描边占 billboard 余量——billboard 绘制尺寸 = 本体 + 2×pad 屏显值,
-    // 锚点/尖端仍按**本体**几何(pad 只是描边画布余量)。pad 屏显值 = 源 pad ÷ 换算比。
-    const padScreen = Math.round((MARKER_OUTLINE_PAD * MASTER_SOURCE_TO_WIDTH) * 100) / 100
-    const drawWidth = Math.round((width + padScreen * 2) * 100) / 100
-    const drawHeight = Math.round((height + padScreen * 2) * 100) / 100
+    // 尺寸全部来自单源 `markerMetrics`:本体宽、描边 pad、绘制宽。pad 按**本档**比例缩放,
+    // 设备不再吃监测点的换算比(设备 pad 2.22、绘制宽 25.44;监测点 2.86 / 32.72)。
+    // 锚点/尖端仍按**本体**几何(pad 只是描边画布余量)。
+    const { bodyWidth: width, height, pad: padScreen, drawWidth, drawHeight } = markerMetricsFor(kind)
 
     // 锚点 = 尖端(相对 bbox 的比例,宽度基准下高度随比例缩放)。
     const anchor = { ...MASTER_GEOMETRY.anchor }
@@ -128,8 +119,11 @@ export function planMarker({ kind, status, industry = '', unassociated = false,
         center: { ...MASTER_GEOMETRY.capCenter },
     }
 
-    // 降饱和:只有未关联设备走(规格 10:降饱和降亮度,与状态语义无关)。
-    const desaturate = unassoc
+    // 降饱和:未关联设备的附加弱化。票 45 裁定「附加状态不抢业务状态」的**反向**也要成立——
+    // 业务异常(报警/预警/工单处置)的未关联设备**不降饱和**:一台正在报警的设备被画成灰的,
+    // 等于附加状态把业务严重性又抢走了一次(规格 10 的降饱和原本以「无业务异常」为前提)。
+    // 正常(附加黄绿)、离线、未发布仍降饱和——它们确实只是「没挂到点位上」这层信息。
+    const desaturate = unassoc && !isBusinessAttentionStatus(status)
 
     // 文字标签偏移(px,Cesium pixelOffset 正值向下):BOTTOM 锚定下标记从尖端向上画,
     // 标签须在**图标上方**——偏移 = -(本体高 + 描边余量 + 间距)。
@@ -159,3 +153,23 @@ export function planToDataset(plan) {
         markerDesaturate: plan.desaturate ? 'yes' : 'no',
     }
 }
+
+/**
+ * 合成计划 → **几何读数**(票 83 / 规格接缝二):实际渲染的本体宽 / 绘制宽 / pad。
+ *
+ * 为什么要单独一个读数面:`planToDataset` 只证明「选了哪种母版/有没有内层」,
+ * 走查无法据此核对「屏幕上这条标记到底多宽」。几何读数把渲染真值摊平成字符串,
+ * 闸门拿它跟单源 `markerMetrics` 的派生值对账,**不读 DOM 内部结构**。
+ *
+ * 两位小数统一格式(27 → "27.00"):可比对、不随浮点尾巴抖动。
+ *
+ * @param {object} plan `planMarker()` 的输出
+ * @returns {{markerWidth:string, markerDrawWidth:string, markerPad:string}}
+ */
+export function markerGeometryDataset(plan) {
+    return {
+        markerWidth: plan.size.width.toFixed(2),
+        markerDrawWidth: plan.draw.width.toFixed(2),
+        markerPad: plan.draw.pad.toFixed(2),
+    }
+}

+ 269 - 77
src/modules/cockpit/domain/monitoringPanels.js

@@ -11,7 +11,8 @@
  *
  * 纯函数:不 import service、不碰 DOM、不自己发请求。
  *
- * 数据时间只认 createdAt(记录创建时间),不使用 updatedAt 或 sampledAt 兜底。
+ * 「入库时间」只认 createdAt(源记录 create_time),不使用 updatedAt 或 sampledAt 兜底。
+ * 全部时间类措辞取自 `fields.js` 的 `TIME_LABELS`(票 62 的唯一词表),本文件不另写一版。
  * 点位状态中文来自 pointStatus;片区只消费正式 district 编码,空值显未分区。
  * 监测概览/运行概况不接收片区筛选,监测分布接收 district。
  *
@@ -22,16 +23,16 @@
  *   演示载荷 `docs/cockpit/monitoring-{overview,distribution,operating-situation}-demo.html`。
  *   BigDecimal 在线上是字符串(`"0.32"`),所以走 `panelBlocks.js` 的 `numberish`。
  */
-import { fieldReader } from './fields.js'
+import { fieldReader, TIME_LABELS } from './fields.js'
 import { DETAIL_PLACEHOLDER } from './detailFlow.js'
 import { valueText } from './equipmentDetailView.js'
-import { shortEventTime } from './eventList.js'
 import { industryLabel, normalizeDistrict, districtLabel, districtFilterLabel } from './filterOptions.js'
 import { legendVisuals, visualForStatus } from './pointStatus.js'
 import { planStatusPie } from './statusPie.js'
 import { abnormalCountRows } from './abnormalCounts.js'
 import {
     barsBlock,
+    CARD_ACTION,
     codeText,
     donutBlock,
     kpiBlock,
@@ -80,9 +81,9 @@ export function metricStatusText(code) {
     return METRIC_STATUS_TEXT[value] || value
 }
 
-/** 数据时间和设备采样时间的语义说明。 */
+/** 入库时间与设备采样时间的语义说明(票 62:随唯一词表改名,措辞同步)。三种时间彼此不同:入库时间(createdAt,源记录写库时刻)/采样时间(sampledAt,设备采集时刻)/快照时间(信封 updatedAt,后端快照生成时刻)。 */
 export const METRIC_FRESHNESS_GAP =
-    '数据时间=记录创建时间,不是设备采样时刻;缺失时显示未知,不使用接口响应或快照时间代替'
+    `${TIME_LABELS.latestIngested}=源记录写库时刻,不是设备采样时刻;缺失时显示占位符「—」,不使用接口响应或快照时间代替`
 
 /**
  * 全局状态分布**未取到**时的如实说明(票 14;决策 02:状态栏与饼图共用同一份
@@ -110,6 +111,11 @@ export const REGION_SCOPE_NOTE = '片区筛选按后端片区编码作用于监
  *
  * 端点入参只有 `industry` 与 `deviceType`(控制器逐行读过)——
  * **不收 `region`,也不收 `pointStatus`**,所以本面板的筛选面就是行业 + 类型。
+ *
+ * 票 62 追加需求:本端点还回出**逐设备台账** `devices[]`(与 `counts.equipmentCount`
+ * 同源同口径,行数恒等于该计数)。运行概况面板的「设备台账」清单读的就是这一份 ——
+ * 它替代了原先借用 `/monitoring/situation`「异常设备清单」的做法:那张清单只装异常四态
+ * 设备(WARNING/ALARM/EXPIRED/OFFLINE),既不是台账、条数也与设备总数对不上。
  */
 export function toMonitoringOverview(raw) {
     const reader = fieldReader(raw, 'monitoringOverview')
@@ -155,6 +161,8 @@ export function toMonitoringOverview(raw) {
                 staleCount: numberish(row.raw.staleCount),
             }
         }),
+        // 票 62 追加需求:逐设备台账(行数恒等于 `equipmentCount`,与上面那个计数同源)。
+        devices: reader.optionalList('devices').map(toLedgerDevice),
         // 新鲜度:本端点只有信封级(见文件头第 ① 条)。
         freshness: freshness.optionalStr('status'),
         freshUpdatedAt: freshness.optionalStr('updatedAt'),
@@ -261,6 +269,9 @@ export function toMonitoringDistribution(raw) {
     return {
         createdAt: reader.optionalStr('createdAt'),
         updatedAt: reader.optionalStr('updatedAt'),
+        // 票 62:快照标记(执行票同期由后端补齐回显)。台账自动链第 2 页起必须原样回带它,
+        // 否则后端 400;token 只认后端回显,不由前端挑时间戳去猜。
+        snapshotAt: reader.optionalStr('snapshotAt'),
         freshUpdatedAt: freshness.optionalStr('updatedAt'),
         freshness: freshness.optionalStr('status'),
         regions: regionRows,
@@ -359,12 +370,15 @@ export function toMonitoringSituation(raw) {
     const range = reader.optionalObj('range') || {}
 
     // 指标状态分布:后端按固定字典顺序给六态(含 0),本层不重排、不丢行。
+    // 票 92:「整个分布没取到」≠「某一桶是 0」——前者六档全缺(count 为 null,界面画占位符),
+    // 后者该档照实计 0(六态全铺是既有口径)。混在一起就是「没数据」被画成「0 台设备」。
+    const distributionProvided = Array.isArray(reader.raw.metricStatusDistribution)
     const metricStatusMap = new Map(reader.optionalList('metricStatusDistribution').map((item) => {
         const row = fieldReader(item, 'metricStatusDistribution')
-        return [row.optionalStr('status'), numberish(row.raw.count) || 0]
+        return [row.optionalStr('status'), numberish(row.raw.count) ?? 0]
     }))
     const metricStatuses = ['NORMAL', 'WARNING', 'ALARM', 'EXPIRED', 'OFFLINE', 'UNKNOWN']
-        .map((status) => ({ status, count: metricStatusMap.get(status) || 0 }))
+        .map((status) => ({ status, count: distributionProvided ? (metricStatusMap.get(status) ?? 0) : null }))
 
     const coverage = reader.optionalList('industryCoverage').map((item) => {
         const row = fieldReader(item, 'industryCoverage')
@@ -411,11 +425,28 @@ export function toMonitoringSituation(raw) {
 
 // ================================================================ 视图计划
 
-/** 票 54:数据时间屏上短读,完整 ISO 进入 title;缺值先占位。 */
-function createdAtKpi(value) {
-    const full = valueText(value)
-    if (full === DETAIL_PLACEHOLDER) return { key: 'createdAt', label: '数据时间', value: full, full }
-    return { key: 'createdAt', label: '数据时间', value: shortEventTime(value) || full, full }
+/**
+ * 逐设备台账行(`/monitoring/overview` 的 `devices[]`)→ 领域行(票 62 追加需求)。
+ * 与 `toAbnormalEquipment` 刻意分开:那张是**异常口径**(四态 + 阈值事由),
+ * 这张是**设备台账**(全部智能设备 + 监测状态 + 所属点位),两者不可互换。
+ */
+function toLedgerDevice(raw) {
+    const reader = fieldReader(raw, 'overviewDevice')
+    const type = fieldReader(reader.raw.deviceType, 'overviewDevice.deviceType')
+    return {
+        // 设备编码(不是点位编码):本清单是**设备**台账。
+        code: reader.optionalStr('deviceCode'),
+        name: reader.optionalStr('deviceName'),
+        typeCode: type.optionalStr('code'),
+        typeName: type.optionalStr('name'),
+        industry: reader.optionalStr('industry'),
+        // 监测状态(后端五态之一);缺失留空,视图层按「未发布」如实说(不猜成正常)。
+        status: reader.optionalStr('monitoringState'),
+        pointCode: reader.optionalStr('monitoringPointCode'),
+        sampledAt: reader.optionalStr('sampledAt'),
+        createdAt: reader.optionalStr('createdAt'),
+        missing: reader.missing,
+    }
 }
 
 /** 该状态在指标状态分布里的计数。 */
@@ -424,6 +455,30 @@ function metricCount(statuses, code) {
     return found ? found.count : null
 }
 
+/**
+ * 设备状态四态 → 环形图行(票 39 第 7 条)。
+ *
+ * **只保留读得出的计数**:`numberish` 认不出回 `null`(字段缺失/载荷形状不对),
+ * 而 `donutBlock` 内部按 `Number(count) || 0` 建扇区与图例——把 `null` 交出去就是
+ * 画一个「在线 0 台」的假读数(票 27 修的是同一类缺陷:`undefined` 冒充有效值)。
+ * 真实的 `0` 是有效读数,照旧保留(图例行在、不产扇区)。
+ * 四态一个都没读出来时 `rows` 为空、`total=0`,渲染层走既有空态(`data-donut-empty`),
+ * **不会**画一个「合计 0」的空环——「后端没给状态」与「状态全是 0」在界面上分得开。
+ * 顺序固定为在线→离线→滞后→未知,**不套 `sortByCountDesc`**(四态是语义字典序,
+ * 非排名类别;同异常桶 t44 的 FIXED 立场)。
+ */
+function deviceStatusRows(deviceStatus) {
+    const source = deviceStatus || {}
+    return [
+        { code: 'online', label: '在线', count: numberish(source.online) },
+        { code: 'offline', label: '离线', count: numberish(source.offline) },
+        { code: 'stale', label: '数据滞后', count: numberish(source.stale) },
+        { code: 'unknownOrMissing', label: '未知/缺失', count: numberish(source.unknownOrMissing) },
+    ].filter((row) => row.count !== null)
+}
+
+/* 票 92:旧「数据入库时间」KPI(createdAtKpi)已随概览/运行概况的 KPI 改版退役 —— 两处消费点
+   都不再展示时间类读数(票面 6 张清单里没有时间卡),按「无消费点不提交」纪律删除。 */
 /**
  * 监测概览面板视图。
  * 验收四项:监测点/设备规模、在线率、类型分布、最近采集时间。
@@ -450,16 +505,32 @@ export function monitoringOverviewPanel(data, section) {
                     ? `在线 ${valueText(current.onlineCount)} / 设备 ${valueText(current.onlineDenominator)}`
                     : '当前范围内没有可参与计算的设备,在线率不适用',
             },
-            // 采集时间:本端点只有信封级时间(见文件头第 ① 条),措辞与"采集"区分开。
-            createdAtKpi(current.createdAt),
+            // 离线设备:取**本端点自己的设备状态**(status.offlineCount),与运行概况那张卡
+            // (来自 /monitoring/situation 的**指标**状态 OFFLINE 档)不是同一口径——
+            // 同名词不同义,所以各自卡上把口径说清(维护者 2026-10-27 裁定:第 4 张卡取离线设备)。
+            {
+                key: 'offline',
+                label: '离线设备',
+                value: valueText(deviceStatus.offline),
+                unit: '台',
+                hint: '按设备在线状态统计(本端点口径)',
+            },
         ], { layout: 'two-by-two' }),
-        rowsBlock('设备状态', [
-            { key: 'online', label: '在线', value: valueText(deviceStatus.online) },
-            { key: 'offline', label: '离线', value: valueText(deviceStatus.offline) },
-            { key: 'stale', label: '数据滞后', value: valueText(deviceStatus.stale) },
-            { key: 'unknownOrMissing', label: '未知/缺失', value: valueText(deviceStatus.unknownOrMissing) },
-            { key: 'denominator', label: '在线率分母', value: valueText(deviceStatus.denominator) },
-        ]),
+        // 票 39(第 7 条):设备状态由纯键值行改**环形图**(复用既有块,不新造渲染分支)。
+        //   · 选型按仓库自己记录的图表选型表(`donutBlock` 头注):「类别占比、类别少」
+        //     → 环形图;四态=4 个互斥类别,且本面板已有一条横条(类型分布)、维护者
+        //     2026-10-13 明说「条形图太多易疲劳」——堆叠条仍是一条横条,形态不解疲劳。
+        //   · **四态照实**:在线/离线/数据滞后/未知缺失各一行图例带读数,不折算成
+        //     「故障」(后端逐设备走 ONLINE/OFFLINE/STALE 判定链,其余落 unknownOrMissing,
+        //     四桶互斥且穷尽——`CockpitMonitoringOverviewService` 第 165–175 行,服务逐行读过)。
+        //   · 0 计数态**保留图例行不画扇区**(`donutBlock` 既有语义):「确实没有离线设备」
+        //     与「没这项数据」在图上分得开,读数为 0 是真实值。
+        //   · 「在线率分母」不再是独立一行:环形合计含「未知/缺失」,而后端在线率
+        //     分母=在线+离线+滞后(**不含**未知/缺失,服务第 185 行)。分母摊在 KPI 的
+        //     hint(`在线 X / 设备 Y`)上与在线率同口径;画进图例就是制造第二个口径的数。
+        //   · 缺值仍是占位符纪律:四态计数经 `numberish` 认不出回 null → 该行**整行不出**
+        //     (见 `deviceStatusRows`),绝不把 null 交给 `donutBlock` 补成假 0(票 27 同类)。
+        donutBlock('设备状态分布(按设备数)', deviceStatusRows(deviceStatus)),
         barsBlock(
             '设备类型分布',
             // 票 37:按设备数**从多到少**降序;并列时保持后端给的次序(后端已排好则是恒等变换)。
@@ -489,7 +560,7 @@ export function monitoringOverviewPanel(data, section) {
         // 概览是聚合口径(后端只给分类型计数,不给逐点位坐标),没有可定位对象。
         locate: [],
         locateNote: '监测概览是分类型聚合口径(后端不返回逐点位坐标),本面板没有可定位对象',
-        // 采集时间与数据时间的区别如实说明(文件头第 ① 条)。
+        // 入库时间与采样时间的区别如实说明(文件头第 ① 条 + `TIME_LABELS`)。
         gap: METRIC_FRESHNESS_GAP,
         industryNote: current.selectedIndustry ? `当前行业:${industryLabel(current.selectedIndustry) || current.selectedIndustry}` : '当前行业:全部',
         // 票 37:生效类型必须看得见 —— 否则读者只会看到"类型分布少了几行",
@@ -574,19 +645,25 @@ export function monitoringDistributionPanel(data, section, extra = {}) {
     })
 
     const blocks = [
-        // 2026-10-13 维护者反馈:条形图太多易疲劳——行业行改环形图(行业语义色),
-        // 片区/设备类型保留条形(多类别排名比较)。
-        donutBlock('覆盖情况 · 行业', industryRows),
-        barsBlock('覆盖情况 · 片区', regionRows),
-        barsBlock('覆盖情况 · 设备类型', typeRows),
+        // 票 39(第 8 条):每块标题 = **「分节 · 维度(条长口径)」**。
+        // 维护者反馈第 8 条的原话是「图与数据指代对象不明确」——同一个面板里既有按
+        // **点位数**排的覆盖分布、又有**全局未筛**的状态分布,三种口径混在一屏而不写在
+        // 标题上,读者无从判断每张图数的是什么、数的是点位还是设备。
+        // 分节词(覆盖情况 / 状态分布 / 口径)让三族各自成节,括号里的口径说明
+        // 「这条长度量的是什么」;悬停行的 hint 仍给设备数(图与数同源,不另算一份)。
+        // 条形/环形的分工沿用 2026-10-13 的既有结论(行业=类别少→环形;
+        // 片区/类型=多类别排名→横条),本票不动图形形态,只把指代写清楚。
+        donutBlock('覆盖情况 · 行业(按点位数)', industryRows),
+        barsBlock('覆盖情况 · 片区(按点位数)', regionRows),
+        barsBlock('覆盖情况 · 设备类型(按点位数)', typeRows),
         ...(extra.statusDistribution && extra.statusDistribution.available
-            ? [barsBlock('监测点异常统计(全局未筛)', abnormalCountRows(
+            ? [barsBlock('状态分布 · 异常监测点(全局未筛)', abnormalCountRows(
                 Object.entries(extra.statusDistribution.counts || {}).map(([status, count]) => ({ status, count })), 'point'))]
             : []),
         // 状态分布饼图:消费全局未筛分布并高亮当前选中;**不可点**(筛选入口只在顶部状态栏)。
         {
             kind: 'pie',
-            label: '点位状态分布(全局未筛)',
+            label: '状态分布 · 点位状态(全局未筛)',
             ready: pie.ready,
             hasSelection: pie.hasSelection,
             total: pie.total,
@@ -594,14 +671,14 @@ export function monitoringDistributionPanel(data, section, extra = {}) {
             rows: pie.rows,
             note: pie.ready ? '' : STATUS_DISTRIBUTION_UNPUBLISHED,
         },
-        rowsBlock('口径', [
+        rowsBlock('口径 · 生效范围与总数', [
             { key: 'pointTotal', label: '监测点总数', value: valueText(current.pagination && current.pagination.total) },
             { key: 'industry', label: '生效行业', value: current.selectedIndustry ? industryLabel(current.selectedIndustry) || current.selectedIndustry : '全部' },
             { key: 'district', label: '生效片区', value: districtFilterLabel({ district: current.selectedDistrict, districtFilter: current.districtFilter }) },
             // 票 37:类型是全局筛选条件之一,生效范围必须与本模式其它条件一样看得见
             // (否则读者只看到"类型分布少了几行",不知道条件从哪来、怎么撤)。
             { key: 'deviceType', label: '生效类型', value: current.selectedDeviceType ? deviceTypeLabel(current.deviceTypes, current.selectedDeviceType) : '全部' },
-            { key: 'createdAt', label: '数据时间', value: valueText(current.createdAt) },
+            { key: 'createdAt', label: TIME_LABELS.latestIngested, value: valueText(current.createdAt) },
             // 票 14:状态多值化——回显是逗号串,逐桶翻人话;空 = 全部。
             {
                 key: 'pointStatus',
@@ -637,8 +714,18 @@ export function monitoringDistributionPanel(data, section, extra = {}) {
 /**
  * 运行概况面板视图。
  * 验收三项:事件趋势、离线设备、异常态势。
+ *
+ * **票 62 追加需求(2026-10-22 维护者)**:本面板不再展示「异常设备清单」,改展示
+ * **设备台账**——按当前数据应列出全部 9 台监测设备,而不是只列异常四态的 8 台。
+ * 台账数据来自 `/monitoring/overview` 的 `devices[]`(与那边的 `counts.equipmentCount`
+ * 同源同口径),由外壳作为 `extra.devices` 搬进来;本面板自己的分区
+ * (`/monitoring/situation`)继续供趋势、指标状态与异常统计——**异常口径不丢,只是
+ * 不再用它当台账**。两者不是一回事:异常清单只装 WARNING/ALARM/EXPIRED/OFFLINE 四态设备。
+ * @param {object} data `toMonitoringSituation()` 的结果
+ * @param {object} section 分区
+ * @param {object} [extra] `{ devices }`(`toMonitoringOverview().devices`,外壳给)
  */
-export function operationOverviewPanel(data, section) {
+export function operationOverviewPanel(data, section, extra = {}) {
     const current = data || {}
     // 7 天窗口的桶是「日」粒度,标签按日期显示(只影响显示,不改桶)。
     const daily = isDailyWindow(current)
@@ -652,16 +739,38 @@ export function operationOverviewPanel(data, section) {
 
     const offlineCount = metricCount(current.metricStatuses, 'OFFLINE')
     // 离线设备:从异常清单里挑出离线的那一档。**只挑,不推断**——状态来自后端 `status`。
+    // 票 62:这段只服务 `offlineNote`(顶部摘要那行),**不再画成清单**。
     const offlineDevices = (current.abnormalEquipment || []).filter((item) => item.status === 'OFFLINE')
+    // 票 62:设备台账数据源(与 `counts.equipmentCount` 同源,行数恒等于它)。
+    const devices = Array.isArray(extra.devices) ? extra.devices : []
+    // 票 92(维护者 2026-10-27):KPI 改为 6 张、3×2 排版。全部取自本面板自己的分区
+    // `/monitoring/situation`(不跨面板取数),数值真实且分区独立:
+    //   · 设备总数 = 六档指标状态计数之和(后端按设备粒度给,总和 = 设备台数);
+    //   · 在线设备 = 正常 + 预警 + 报警(正在上报的设备;与监测概览设备轴「在线」口径一致,已实测比对);
+    //   · 离线设备 = 指标状态 OFFLINE 档(沿用既有取数);
+    //   · 预警/报警设备 = WARNING / ALARM 档(沿用既有取数,改名);
+    //   · 总异常设备 = 异常设备清单总数(pagination.total = 预警+报警+离线+过期)。
+    // 旧「数据入库时间」卡退役(不在 6 张清单里)。
+    // 分布没取到时(六档 count 全 null)→ 总数/在线/离线/预警/报警全走占位符,绝不画 0(票 16)。
+    const distributionProvided = (current.metricStatuses || []).some((row) => row.count !== null)
+    const deviceTotal = distributionProvided
+        ? current.metricStatuses.reduce((sum, row) => sum + (row.count || 0), 0)
+        : null
+    const onlineCount = distributionProvided
+        ? metricCount(current.metricStatuses, 'NORMAL')
+            + metricCount(current.metricStatuses, 'WARNING')
+            + metricCount(current.metricStatuses, 'ALARM')
+        : null
 
     const blocks = [
         kpiBlock([
-            createdAtKpi(current.createdAt),
+            { key: 'deviceCount', label: '设备总数', value: valueText(deviceTotal), unit: '台', hint: '按指标状态统计(六档之和)' },
+            { key: 'online', label: '在线设备', value: valueText(onlineCount), unit: '台', hint: '正常 + 预警 + 报警的设备(正在上报)' },
             { key: 'offlineCount', label: '离线设备', value: valueText(offlineCount), unit: '台' },
-            { key: 'alarmCount', label: '报警中', value: valueText(metricCount(current.metricStatuses, 'ALARM')), unit: '台' },
-            { key: 'warningCount', label: '预警中', value: valueText(metricCount(current.metricStatuses, 'WARNING')), unit: '台' },
-            { key: 'abnormalCount', label: '异常设备', value: valueText(current.pagination && current.pagination.total), unit: '台' },
-        ]),
+            { key: 'warningCount', label: '预警设备', value: valueText(metricCount(current.metricStatuses, 'WARNING')), unit: '台' },
+            { key: 'alarmCount', label: '报警设备', value: valueText(metricCount(current.metricStatuses, 'ALARM')), unit: '台' },
+            { key: 'abnormalCount', label: '总异常设备', value: valueText(current.pagination && current.pagination.total), unit: '台', hint: '预警 + 报警 + 离线 + 过期' },
+        ], { layout: 'three' }),
         barsBlock('设备/指标异常统计', abnormalCountRows(current.metricStatuses, 'metric')),
         // 预警与报警**分开画**:后端注释明确两者不混合,合成一条就是把两类事件混成一份。
         // 2026-10-13 图表选型:时间趋势用折线。
@@ -675,29 +784,7 @@ export function operationOverviewPanel(data, section) {
                 count: row.count || 0,
             })),
         ),
-        listBlock(
-            '异常设备清单',
-            (current.abnormalEquipment || []).map((item) => ({
-                key: item.code,
-                title: item.name,
-                dataTime: valueText(item.createdAt),
-                tone: abnormalTone(item.status),
-                // 色键必带文字(票 06):状态词来自指标状态字典,退化成"未知"也不留空标签。
-                toneLabel: metricStatusText(item.status),
-                meta: [
-                    item.typeName,
-                    industryLabel(item.industry) || codeText(item.industry),
-                    districtLabel(item.district),
-                    ...item.statusReasons,
-                ].filter(Boolean),
-                // 设备编码即该设备在地图上的对象编码(未关联设备以自身编码上图),
-                // 但**只有已加载到一张图里**才飞得动 —— 三种"点不动"由 `domain/mapLocate.js` 分开。
-                points: item.code
-                    ? [{ key: `${item.code}|locate`, label: item.name, pointCode: item.code, objectCode: item.code, objectName: item.name }]
-                    : [],
-            })),
-            '当前窗口没有异常设备',
-        ),
+        deviceLedgerBlock(devices),
     ]
 
     return {
@@ -706,10 +793,12 @@ export function operationOverviewPanel(data, section) {
         section,
         hasContent: sectionHasContent(section),
         blocks,
-        locate: (current.abnormalEquipment || []).flatMap((item) =>
-            locateEntries(item.code, item.name, { code: item.code, name: item.name }, item.code ? [item.code] : []),
+        // 台账每行是一台**设备**,设备编码即它在地图上的对象编码(未关联设备以自身编码上图);
+        // 只有已加载到一张图里才飞得动 —— 三种"点不动"由 `domain/mapLocate.js` 分开。
+        locate: devices.flatMap((item) =>
+            locateEntries(item.code, deviceLabel(item), { code: item.code, name: deviceLabel(item) }, item.code ? [item.code] : []),
         ),
-        locateNote: '异常设备可定位;设备坐标由一张图回联,未加载到的设备不下发飞行',
+        locateNote: '设备台账可定位;设备坐标由一张图回联,未加载到的设备不下发飞行',
         // 窗口口径:后端回显的实际生效区间(本面板的 24 小时 / 7 天切换写在界面上,见票面 Q8)。
         rangeNote: current.rangeStart && current.rangeEnd ? `统计窗口:${current.rangeStart} 至 ${current.rangeEnd}` : '',
         offlineNote: offlineDevices.length
@@ -728,7 +817,11 @@ function isDailyWindow(current) {
     return end - start > 36 * 3600 * 1000
 }
 
-/** 异常状态 → 色键(报警最重、其次预警、离线与过期走中性)。 */
+/**
+ * 异常状态 → 色键(报警最重、其次预警、离线与过期走中性)。
+ * 票 62:异常清单已从运行概况撤下(改展示设备台账),本函数仍服务于
+ * `offlineNote` 的语义与 `toAbnormalEquipment` 的既有消费点,故保留。
+ */
 function abnormalTone(status) {
     if (status === 'ALARM') return 'lv1'
     if (status === 'WARNING') return 'lv2'
@@ -736,16 +829,93 @@ function abnormalTone(status) {
     return 'neutral'
 }
 
+/** 设备台账行的展示名:有名称用名称,没名称用编码(**不编造**,也不留空行)。 */
+function deviceLabel(item) {
+    return (item && (item.name || item.code)) || DETAIL_PLACEHOLDER
+}
+
+/**
+ * 设备监测状态 → 色键(票 62)。按语义分档,与 `abnormalTone` 同一套色;
+ * 在线/正常走成功色,滞后走关注色,离线/未发布走中性(**不把"没状态"画成正常**)。
+ */
+function deviceStateTone(status) {
+    if (status === 'ALARM') return 'lv1'
+    if (status === 'WARNING' || status === 'STALE') return 'lv2'
+    if (status === 'ONLINE' || status === 'NORMAL') return 'lv4'
+    return 'neutral'
+}
+
+/**
+ * 设备监测状态码 → 人话(票 62)。
+ * `ONLINE` 是设备轴上的补充取值(指标状态字典里没有它);其余复用六态字典,
+ * 认不出的回原码、空值如实说「状态未发布」——不猜成正常。
+ */
+function deviceStateText(status) {
+    const value = typeof status === 'string' ? status.trim() : ''
+    if (!value) return '状态未发布'
+    if (value === 'ONLINE') return '在线'
+    return metricStatusText(value)
+}
+
+/**
+ * 运行概况的**设备台账**清单块(票 62 追加需求,2026-10-22 维护者)。
+ *
+ * 与原先「异常设备清单」是**结构性差别**,不只是换个标题:
+ *   · **数据源**:`/monitoring/overview` 的 `devices[]`(全部智能设备,行数 ≡ `counts.equipmentCount`),
+ *     不是 `/monitoring/situation` 的 `abnormalEquipment[]`(只装 WARNING/ALARM/EXPIRED/OFFLINE);
+ *   · **每行内容**:名称(主键展示)+ 设备编码、类型、行业、监测状态、所属点位、数据入库时间;
+ *   · **总数口径**:读设备台账行数本身(= `equipmentCount`),页脚明说「共 N 台监测设备」;
+ *   · 仍是前端集合分页 10/页(规范三分法),翻页**零请求**。
+ * @param {Array} devices 领域设备行(`toMonitoringOverview().devices`)
+ */
+function deviceLedgerBlock(devices) {
+    const rows = (devices || []).map((item) => ({
+        key: item.code || `${item.typeCode}|${item.pointCode}`,
+        title: deviceLabel(item),
+        identityKind: CARD_ACTION.DEVICE,
+        identityCode: item.code,
+        identityReason: item.code ? '' : '缺少设备编码,无法展开',
+        dataTime: valueText(item.createdAt),
+        // 标签由 domain 给(`TIME_LABELS` 单点词表):渲染层不认「时间」这个词,更不自己拼。
+        dataTimeLabel: TIME_LABELS.ingested,
+        tone: deviceStateTone(item.status),
+        // 色键必带文字(票 06):状态词认不出也回原码,不留空标签、不猜成正常。
+        toneLabel: deviceStateText(item.status),
+        meta: [
+            item.code,
+            item.typeName,
+            industryLabel(item.industry) || codeText(item.industry),
+            item.pointCode ? `点位 ${item.pointCode}` : '未关联点位',
+        ].filter(Boolean),
+        // 设备编码即该设备在地图上的对象编码(未关联设备以自身编码上图)。
+        points: item.code
+            ? [{ key: `${item.code}|locate`, label: deviceLabel(item), pointCode: item.code, objectCode: item.code, objectName: deviceLabel(item) }]
+            : [],
+    }))
+    return listBlock('设备台账', rows, '当前范围内没有监测设备', {
+        paginate: true,
+        // 页码归属键:只看设备全集的身份(行数)——换筛选导致集合变化时自然复位。
+        // 标量键,不接受对象身份(票 43 教训:视图计划每次重算都是新对象)。
+        pageScope: `devices|${rows.length}`,
+        pageNote: rows.length ? `共 ${rows.length} 台监测设备(当前筛选范围)` : '',
+    })
+}
+
 /**
  * 监测点台账面板视图。
  * 验收第 4 条:以滚动卡片列出当前行业筛选内的监测点;点击卡片走与地图相同的两级详情。
  *
- * 台账与监测分布**共用一次取数**(Q4 裁定),所以卡片就是分布响应里的 `monitoringPoints`;
- * 分页由端点自己的 `pagination` 决定,本层只把"还有没有下一页"如实带出。
+ * **票 62 改写(批次四)**:数据不再取「分布这一次响应的第 1 页切片」,而是 store 的
+ * **自动渐进装载链**(`domain/ledgerLoader.js`)已经装好的**完整序列**;
+ *   · 装载与一张图点位数据同款(条件变化 → 新快照 → 自动连翻到 settled);
+ *   · 界面只对**已装载集合**做统一页码分页(10/页、页码直达),翻页是**纯视图动作**;
+ *   · 「加载下一页监测点」按钮与 `ledger-more` 接缝**退役**(自动链自己会翻完);
+ *   · 页脚如实给「已装载 M · 共 N 条(含未关联设备)」+ 装载中/失败标注。
  *
- * **设备徽标不在这里**(Q10 裁定走 N+1):本端点不给设备编码,
- * 徽标由 store 逐点位取 `/monitoring-points/{code}` 的 `deviceStatistics` 后补进卡片视图。
- * @param {object} data `toMonitoringDistribution()` 的结果
+ * **设备徽标仍走 N+1**(Q10/Q14 不变):本端点不给设备编码,徽标由 store 逐点位取。
+ * 票 62 的改进是**只为当前页窗口内的卡片补拉**——窗口化之后一屏最多 10 张卡,
+ * 不再"一页 500 张卡进视口就打出 500 个请求"。
+ * @param {object} data `toMonitoringDistribution()`/装载链条目(读 `points`/`equipment`/`pagination`)
  * @param {object} section 分区
  * @param {object} [badges] `{ [pointCode]: { state, total, alarm, warning, offline } }`(store 给)
  */
@@ -765,7 +935,12 @@ export function monitoringPointLedgerPanel(data, section, badges = {}) {
             key: point.code,
             code: point.code,
             name: point.name,
+            identityKind: CARD_ACTION.POINT,
+            identityCode: point.code,
+            identityReason: point.code ? '' : '缺少点位编码,无法展开',
             dataTime: valueText(point.createdAt),
+            // 同上:标签由 domain 给,渲染层只拼值。
+            dataTimeLabel: TIME_LABELS.ingested,
             // 状态词与色键来自票 09 的唯一一张表(认不出的状态落「状态未发布」,不猜成正常)。
             status: visual.key,
             tone: visual.tone,
@@ -790,8 +965,22 @@ export function monitoringPointLedgerPanel(data, section, badges = {}) {
         }
     })
 
+    // 票 62:页脚口径由 store 的装载链给(它才知道「已装载多少 / 总数多少 / 还在不在装」);
+    // 装载链没给(走查注入的裸载荷)时退回既有措辞,保持 t27 的防回归判据不变。
+    const pageNote = typeof current.ledgerNote === 'string' && current.ledgerNote
+        ? current.ledgerNote
+        : total === null
+          ? ''
+          : `已列出 ${cards.length} 个点位 · 本端点序列共 ${total} 条(含未关联设备)`
+
     const blocks = [
-        listBlock('监测点', cards, '当前筛选范围内没有监测点'),
+        listBlock('监测点', cards, '当前筛选范围内没有监测点', {
+            // 票 62:已装载集合做前端集合分页(翻页零请求)。
+            paginate: true,
+            // 页码归属键只用条件(换筛选/换快照即复位);装载链继续往集合里追加时**不复位**
+            // ——用户停在第 3 页读台账,自动链补完后面的批次不该把人弹回第 1 页(条文 6)。
+            pageScope: `${current.snapshotAt || ''}|${pagination.pageSize || ''}`,
+        }),
     ]
 
     return {
@@ -807,12 +996,9 @@ export function monitoringPointLedgerPanel(data, section, badges = {}) {
         locateNote: '点击卡片进入与地图一致的两级详情(点位摘要 → 设备卡片);本面板不移动相机',
         // 分页口径:本端点分页窗口横跨「监测点 + 未关联设备」,所以 total 含设备,
         // 卡片数会小于 total —— 这个差值说明白,免得读成"还有一些点没列出来"。
-        // 分母缺失(`total === null`)时整句不出:宁可不说,也不报一个假读数。
-        pageNote:
-            total === null
-                ? ''
-                : `已列出 ${cards.length} 个点位 · 本端点序列共 ${total} 条(含未关联设备)${pagination.hasMore ? ' · 还有下一页' : ''}`,
-        hasMore: pagination.hasMore === true,
+        pageNote,
+        // 票 62:「加载下一页」按钮退役(自动链负责装载),`hasMore` 恒 false 让渲染层不画按钮。
+        hasMore: false,
         page: pagination.page,
         missing: panelMissingNote(current),
         placeholder: sectionPlaceholder(section),
@@ -838,7 +1024,13 @@ export function monitoringPanelView(panelId, data, section, badges) {
     return build(data, section, badges)
 }
 
-/** 台账当前该请求的下一页(`null` = 不用再翻)。纯函数,store 与断言共用。 */
+/**
+ * 台账当前该请求的下一页(`null` = 不用再翻)。纯函数,断言与旧调用共用。
+ *
+ * 票 62:装载链的推进已收进 `domain/ledgerLoader.js`(它同时管快照锁定与失败纪律);
+ * 本函数保留为**载荷层面的只读判据**(「这份响应还有没有下一页」),不再被 store 用作
+ * 推进手段 —— 两处各推一次会把「谁决定下一页」变成两个答案。
+ */
 export function nextLedgerPage(data) {
     const pagination = (data && data.pagination) || null
     if (!pagination || pagination.page === null) return 1

+ 12 - 0
src/modules/cockpit/domain/overviewPanels.js

@@ -34,6 +34,8 @@ import {
     ratioText,
     sectionHasContent,
     sectionPlaceholder,
+    stackedBlock,
+    supplementalBlock,
     trendBlock,
 } from './panelBlocks.js'
 
@@ -511,6 +513,10 @@ export function pipeNetworkPanel(data, section) {
             })),
             { layout: 'stack-below' },
         ),
+        supplementalBlock(rankingBarsBlock('各类管线里程', (current.types || [])
+            .filter((row) => numberish(row.lengthKm) !== null)
+            .map((row) => ({ code: row.code, label: row.label, count: row.lengthKm }))
+            .sort((a, b) => b.count - a.count), { limit: 0, unit: 'km' })),
     ]
 
     return {
@@ -571,6 +577,12 @@ export function warningStudyPanel(data, section) {
             unit: '起',
             aside: rankingAside,
         }),
+        supplementalBlock(stackedBlock('未解除预警状态', [
+            { key: 'PENDING', label: '待接收', count: current.unresolved?.statuses?.PENDING },
+            { key: 'RECEIVED', label: '已接收', count: current.unresolved?.statuses?.RECEIVED },
+            { key: 'PROCESSING', label: '处置中', count: current.unresolved?.statuses?.PROCESSING },
+            { key: 'HANDLED', label: '已处置', count: current.unresolved?.statuses?.HANDLED },
+        ])),
     ]
 
     return {

+ 184 - 5
src/modules/cockpit/domain/panelBlocks.js

@@ -24,6 +24,32 @@ import { SECTION_STATE } from './sectionState.js'
 import { collectMissing, missingNote } from './equipmentDetailView.js'
 import { industryLabel } from './filterOptions.js'
 import { ringSlicePath } from './ringGeometry.js'
+
+/** 卡片行可进入的详情对象类型。 */
+export const CARD_ACTION = Object.freeze({
+    POINT: 'point',
+    DEVICE: 'device',
+    EVENT: 'event',
+    FACILITY: 'facility',
+    NONE: 'none',
+})
+const CARD_ACTION_KINDS = Object.freeze([CARD_ACTION.POINT, CARD_ACTION.DEVICE, CARD_ACTION.EVENT, CARD_ACTION.FACILITY])
+
+/**
+ * 列表行 → 统一详情动作。
+ *
+ * 身份必须由 domain 显式给出;渲染层不再用 `row.code` 猜它是监测点。缺编码、未知类型
+ * 或自检失败一律返回 none,让界面保留普通卡片形态并给出如实说明。
+ */
+export function cardActionFor(row) {
+    const source = row && typeof row === 'object' ? row : {}
+    const kind = typeof source.identityKind === 'string' ? source.identityKind.trim() : ''
+    const code = typeof source.identityCode === 'string' ? source.identityCode.trim() : ''
+    if (source.identityValid === false || !CARD_ACTION_KINDS.includes(kind) || !code) {
+        return { kind: CARD_ACTION.NONE, code: '', reason: source.identityReason || '缺少编码,无法展开' }
+    }
+    return { kind, code, reason: source.identityReason || '' }
+}
 /**
  * 码 → 中文的**枚举直译表**(跨模式共用的那一部分)。
  *
@@ -198,6 +224,158 @@ export function sectionPlaceholder(section) {
     return ''
 }
 
+
+// ================================================================ 高屏补充块(票 63)
+
+/**
+ * 将一个真实统计块标为「只在面板确有竖向富余时出现」。
+ * 渲染层按面板体实测高度与基线内容高度决定是否纳入 DOM;普通屏默认不增加内容高度。
+ */
+export function supplementalBlock(block, minExtraPx = 150) {
+    return { ...block, supplemental: true, minExtraPx: Math.max(1, Number(minExtraPx) || 150) }
+}
+
+/**
+ * 高屏补充块可见判据。非法/未测量输入一律隐藏;边界包含等号。
+ * 纯函数供渲染层与逻辑闸门共用,避免用视口宽度或面板 id 猜空间。
+ */
+export function supplementalBlocksVisible({ hostHeightPx, baselineContentPx, minExtraPx }) {
+    const host = Number(hostHeightPx)
+    const baseline = Number(baselineContentPx)
+    const required = Number(minExtraPx)
+    if (![host, baseline, required].every(Number.isFinite) || host <= 0 || baseline < 0 || required <= 0) return false
+    return host - baseline >= required
+}
+
+// ================================================================ 空态保设计(票 72)
+
+/**
+ * 空/失败/不可用时**数值位**的占位符(票 72,维护者 2026-10-27 反馈第 2 条)。
+ *
+ * 复用既有的缺值占位符(`—`,来自 `detailFlow.js`),**不另造第二个字符**:
+ * 「没有这个数」在全仓只有一种写法,走查与读屏都只认它。
+ * 与 `0` 的区别是**语义**而不是排版:`0` 断言"真的是零",无数据时写 0 就是伪造读数
+ * (与票 05/11 的「不得显示虚假零值」同一条纪律)。
+ */
+export const EMPTY_VALUE = DETAIL_PLACEHOLDER
+
+/** 该文本是不是"没有这个数"的占位符(不是 0,也不是空串)。 */
+export function isPlaceholderText(text) {
+    return text === DETAIL_PLACEHOLDER
+}
+
+/**
+ * 无数据时**保留面板设计**的视图(票 72)。
+ *
+ * 三种状态口径(与 `sectionPlaceholder` 同一套判定,不另立第二份):
+ *   · 还没取过数(pending)→ 原样返回:界面仍说"正在取数…"(那是一件还没问过的事);
+ *   · 有值(SUCCESS / STALE)→ 原样返回:过期是**有值但旧**,保旧值(票 16 的立场);
+ *   · 其余(EMPTY / FAILED / UNAVAILABLE,含分区缺失)→ `designOnly: true`:
+ *     块结构(标题/单位/图例/坐标轴/静态类别名)照旧,**数值位一律占位符**。
+ *
+ * 为什么收在注册表出口这一处(`panelViews.js` 的 `panelView()`)而不是逐面板各写一遍:
+ * 面板已经 15 个、还会再加;逐面板写必然漏,而漏掉的表现正是"这块面板没数据时又变回一句文案"。
+ * 收在这里则新面板**自动继承**,闸门再按 `implementedPanelIds()` 做正向普查(漏实现即红)。
+ *
+ * @param {object} view 已由各模式 domain 算好的视图计划
+ * @param {object|null} section 分区(`domain/sectionState.js` 的 `section()`)
+ * @returns {object} 原视图,或带 `designOnly` 的空态视图
+ */
+export function emptyDesignView(view, section) {
+    if (!view) return view
+    if (sectionPending(section)) return view
+    if (sectionHasContent(section)) return view
+    return {
+        ...view,
+        // 分区状态一字不改:`hasContent` 仍为 false(状态标识与摘要都按真实状态说),
+        // 只是内容区改画设计骨架。
+        hasContent: false,
+        designOnly: true,
+        blocks: (view.blocks || []).map(emptyDesignBlock),
+    }
+}
+
+/**
+ * 单个块的空态化:结构/标题/类别名/单位/坐标轴**一律不动**,只把数值位换成占位符。
+ * 数据驱动的行(如"按类型分布")没有数据时本来就没有类别名可画 —— 那部分由渲染层的
+ * 块级占位符兜底,不在这里编造假类别。
+ */
+function emptyDesignBlock(block) {
+    if (!block || typeof block !== 'object') return block
+    const design = { ...block, placeholder: true }
+    switch (block.kind) {
+        case 'kpi':
+            // 读数与差值("较昨日 +6")在没有数的时候同样是断言,得一起撤。
+            design.items = (block.items || []).map((item) => ({
+                ...item,
+                value: EMPTY_VALUE,
+                full: '',
+                delta: undefined,
+                deltaText: '',
+            }))
+            break
+        case 'cards':
+        case 'bars':
+            design.rows = placeholderRows(block.rows)
+            break
+        case 'stacked':
+            design.total = null
+            design.rows = placeholderRows(block.rows).map((row) => ({
+                ...row, ratio: 0, start: 0, startPct: 0, widthPct: 0,
+            }))
+            break
+        case 'pie':
+            // 饼图的八行图例是**固定桶**(点位状态),没有数据也要在位:只撤计数与扇区几何。
+            design.ready = true
+            design.total = null
+            design.slices = []
+            design.rows = placeholderRows(block.rows)
+            break
+        case 'donut':
+            design.total = null
+            design.slices = []
+            design.rows = placeholderRows(block.rows)
+            break
+        case 'dimbars':
+            design.dimensions = (block.dimensions || []).map((dim) => ({
+                ...dim,
+                rows: placeholderRows(dim.rows),
+            }))
+            break
+        case 'rows':
+            design.rows = (block.rows || []).map((row) => ({
+                ...row, value: EMPTY_VALUE, placeholder: true,
+            }))
+            break
+        case 'trend':
+            // 时间轴骨架要按"当前窗口的桶"画,而桶是后端给的 —— 没有数据时连桶都没有。
+            // 这里不伪造一串空桶(那会画出一条全 0 的假线),只清掉点/线/序列,
+            // 由渲染层画块级占位符;桶来源恢复后自然长回来。
+            design.max = null
+            design.points = []
+            design.linePoints = []
+            design.series = []
+            design.areaPoints = ''
+            break
+        case 'list':
+            // 清单的行就是数据本身;块标题与占位符位由渲染层画。
+            design.rows = []
+            break
+        default:
+            break
+    }
+    return design
+}
+
+/** 行集合的空态化:类别名/色键/可点动作全留,只把计数换成占位符。 */
+function placeholderRows(rows) {
+    return (Array.isArray(rows) ? rows : []).map((row) => ({
+        ...row,
+        count: null,
+        placeholder: true,
+    }))
+}
+
 // ================================================================ 四种块构造器
 
 /**
@@ -227,9 +405,10 @@ export const KPI_ICONS = {
     warningTotal: 'warning',
     alarmTotal: 'alarm',
     warningCount: 'warning',
-    // 票 65:预警效率面板预警族的两个**真实**读数(预警不挂工单 ⇒ 关联/未关联是实况计数)。
-    warningLinked: 'warning',
-    warningMissing: 'unknownOrMissing',
+    // 票 78:预警效率面板预警族的新读数(票 74 契约)——已解除率与平均处置时长。
+    // 语义图标复用既有词表:已解除率=resolved(同「预警-handled」),时长=time(同结案时长)。
+    warningResolvedRate: 'resolved',
+    warningDuration: 'time',
     alarmCount: 'alarm',
     abnormalCount: 'abnormalCount',
     offlineCount: 'offline',
@@ -282,8 +461,8 @@ export const KPI_ICON_COLORS = {
     unknownOrMissing: 'var(--lv2, #ffb020)',
     warningTotal: 'var(--lv2, #ffb020)',
     warningCount: 'var(--lv2, #ffb020)',
-    warningLinked: 'var(--lv2, #ffb020)',
-    warningMissing: 'var(--muted, #5680b0)',
+    // 票 78:已解除率走「已解除」的语义色(与「预警-rate」同源);时长不另配色(同响应/结案时长)。
+    warningResolvedRate: 'var(--lv4, #27d17a)',
     '预警-total': 'var(--lv2, #ffb020)',
     '预警-pending': 'var(--lv2, #ffb020)',
     '预警-rate': 'var(--lv4, #27d17a)',

+ 5 - 1
src/modules/cockpit/domain/panelViews.js

@@ -17,6 +17,7 @@
  * 本文件是纯函数层:不 import service、不碰 DOM。
  */
 import { withFilterCaliber } from './filterCaliber.js'
+import { emptyDesignView } from './panelBlocks.js'
 import { INFRASTRUCTURE_PANEL_BUILDERS } from './infrastructurePanels.js'
 import { MONITORING_PANEL_BUILDERS } from './monitoringPanels.js'
 import { WARNING_RESPONSE_PANEL_BUILDERS } from './warningResponsePanels.js'
@@ -80,5 +81,8 @@ export function panelView(panelId, data, section, extra) {
     // 票 67:全局筛选(当前范围/行业)**生效到哪里、没生效到哪里**由 domain/filterCaliber.js
     // 一张表说了算,在这里统一并进视图的 `gap`(口径缺口)。放在注册表这一处而不是逐面板
     // 各写一句:面板新增/改名时不会漏,闸门也能按 `implementedPanelIds()` 做正向普查。
-    return withFilterCaliber(build(data, section, extra), panelId)
+    // 票 72(维护者 2026-10-27 反馈第 2 条):空/失败/不可用时**保留面板设计** ——
+    // 结构照画、数值位占位符,而不是整块换成一句"该面板当前没有数据"。
+    // 与票 67 的口径缺口同一位置:出口统一收,面板新增时不会漏(闸门按注册表正向普查)。
+    return emptyDesignView(withFilterCaliber(build(data, section, extra), panelId), section)
 }

+ 1 - 1
src/modules/cockpit/domain/pointAnchor.js

@@ -13,7 +13,7 @@ const clamp = (value, min, max) => Math.max(min, Math.min(max, value))
  *
  * `iconSize` = 标记的**当前实际外接直径**(不是固定常量):票 76 走查反馈「进入聚合选择态时
  * 面板有时会被标记压住、有时不会」——根因就是这里恒用 18px,而选择态的聚合点会被放大
- * (DOM `--active` 放大 1.12× + 环扩散到 `EFFECT_PROFILE.scale`)。调用方把当前真实尺寸传进来,
+ * (DOM `--active` 放大 1.12× + 环扩散到 `RING_EFFECT.<status>.peakScale`)。调用方把当前真实尺寸传进来,
  * 让位距离就稳定了。
  */
 export function solvePointAnchor({ anchor, canvas, width = 480, height = 330, iconSize = 18, gap = 12, edge = 12, avoidKey = '' } = {}) {

+ 44 - 0
src/modules/cockpit/domain/pointLocate.js

@@ -29,3 +29,47 @@ export function isStaleLocate(mine, current) {
     return mine !== current
 }
 
+/** 可断言的地形感知定位目标:相机中心必须落在可靠地表上。 */
+export const TERRAIN_LOCATE_REASON = Object.freeze({
+    MAP: 'map-unavailable',
+    COORDINATE: 'coordinate-unavailable',
+    TERRAIN: 'terrain-unavailable',
+    CAMERA: 'camera-clearance',
+})
+
+/**
+ * 将对象坐标与已加载地形高程组合成相机目标,并守卫相机离地关系。
+ * 这里不接受默认高程:`null`/`undefined` 只能得到失败计划,不能变成 0 米。
+ */
+export function planTerrainLocateTarget(object, terrainHeight, view = {}) {
+    const lon = object && object.longitude
+    const lat = object && object.latitude
+    if (!Number.isFinite(lon) || !Number.isFinite(lat)) {
+        return { ok: false, reason: TERRAIN_LOCATE_REASON.COORDINATE, target: null, cameraHeight: null }
+    }
+    if (!Number.isFinite(terrainHeight)) {
+        return { ok: false, reason: TERRAIN_LOCATE_REASON.TERRAIN, target: null, cameraHeight: null }
+    }
+
+    const range = Number.isFinite(view.range) ? view.range : 0
+    const pitch = Number.isFinite(view.pitch) ? view.pitch : 0
+    // 「画面中心」= 弹簧臂中心;相机必须高于所采地表,否则「定位」就是「钻进山体」
+    // (pitch ≥ 0 的平视/仰视、range ≤ 0 都构造不出这个关系,直接失败而不是凑合飞)。
+    if (!(pitch < 0) || !(range > 0)) {
+        return { ok: false, reason: TERRAIN_LOCATE_REASON.CAMERA, target: null, cameraHeight: null }
+    }
+    const verticalOffset = Math.sin((-pitch * Math.PI) / 180) * range
+    const cameraHeight = terrainHeight + verticalOffset
+    // 守卫的最终关系:相机高度严格高于目标地表高度。
+    if (!(cameraHeight > terrainHeight)) {
+        return { ok: false, reason: TERRAIN_LOCATE_REASON.CAMERA, target: null, cameraHeight }
+    }
+
+    return {
+        ok: true,
+        reason: '',
+        target: { lon, lat, height: terrainHeight },
+        cameraHeight,
+    }
+}
+

+ 64 - 0
src/modules/cockpit/domain/renderResolution.js

@@ -0,0 +1,64 @@
+/**
+ * 渲染分辨率决策(票 61 第十四轮:移动期自适应降档)。
+ *
+ * 为什么单独成 domain:这段是纯算术,但决定了「画质」与「帧率」的平衡点 ——
+ * 塞进 cesiumEngine 里就只能靠肉眼验,抽出来才能脱浏览器断言。
+ *
+ * 背景:近景卡顿主要是**填充率**(楼占满屏幕时每像素都要跑材质/阴影/后处理)。
+ * 移动期把内部渲染分辨率压低,像素量按平方下降;停下立刻恢复满档,静态画质不受影响。
+ */
+
+/** 合法区间:低于 0.5 画面糊到不可用,高于 1 没有意义(Cesium 上限就是 1)。 */
+export const RESOLUTION_MIN = 0.5
+export const RESOLUTION_MAX = 1
+
+/**
+ * 把任意输入收成合法分辨率;非法输入返回 null(调用方据此"什么都不做",
+ * 而不是悄悄写一个猜的值)。
+ *
+ * @param {number} value
+ * @returns {number|null}
+ */
+export function clampResolution(value) {
+    // null / undefined / '' 必须当"没有值",不能走 Number() —— Number(null) === 0,
+    // 会被夹成 0.5 悄悄生效,那是把一个"未配置"变成"配置成最低档"的静默错误。
+    // 空串必须显式挡掉:Number('') === 0,会被夹成 0.5 悄悄生效。
+    // null/undefined/数组/对象由下面的类型检查挡(typeof null === 'object')。
+    if (value === '') return null
+    // 只认 number 与数字字符串:数组/对象交给 Number() 会得到 0 或 NaN,
+    // 0 被夹成 0.5 就成"未配置却悄悄生效",NaN 则被误判成合法输入。
+    const type = typeof value
+    if (type !== 'number' && type !== 'string') return null
+    const num = Number(value)
+    if (!Number.isFinite(num)) return null
+    return Math.min(RESOLUTION_MAX, Math.max(RESOLUTION_MIN, num))
+}
+
+/**
+ * 按相机是否在动决定这一帧该用的分辨率。
+ *
+ * 规则:**在动取 min(base, moving),静止一律回 base**。
+ * 用 min 而不是直接取 moving —— 部署如果把 moving 配得比 base 还高(比如 base 0.6、
+ * moving 0.9),那就不该在移动时反而升档;min 让这种配错也保持"移动不多花"的语义。
+ *
+ * @param {{base:number, moving:number, isMoving:boolean}} state
+ * @returns {number|null} 目标分辨率;参数非法返回 null
+ */
+export function motionResolution(state = {}) {
+    const base = clampResolution(state.base)
+    if (base === null) return null
+    if (state.isMoving !== true) return base
+    const moving = clampResolution(state.moving)
+    if (moving === null) return base
+    return Math.min(base, moving)
+}
+
+/**
+ * 两个分辨率是否"实质相同"(避免浮点抖动导致每帧都 resize)。
+ * 阈值取 1e-6:远小于肉眼可辨的差别,也远大于浮点误差。
+ */
+export function sameResolution(a, b) {
+    const x = Number(a), y = Number(b)
+    if (!Number.isFinite(x) || !Number.isFinite(y)) return false
+    return Math.abs(x - y) < 1e-6
+}

+ 2 - 1
src/modules/cockpit/domain/statusPie.js

@@ -44,7 +44,7 @@ function hitWedge(startDeg, endDeg) {
  *
  * @param {object} params
  * @param {{available: boolean, counts: object}} params.distribution 八桶全局分布(mapSummary 的形状)
- * @param {Array<{key,label,tone,shape}>} params.rows 完整词表行(legendVisuals() 的输出)
+ * @param {Array<{key,label,tone,shape,icon?}>} params.rows 完整词表行(legendVisuals() 的输出,含可选 icon)
  * @param {Array<string>} [params.selected] 当前选中的状态桶(高亮用;空 = 未筛不高亮)
  * @returns {{ready: boolean, total: number, slices: Array, rows: Array}}
  *   `slices` 只含计数>0 的扇区(带 SVG path);`rows` 是完整的八行图例。
@@ -90,6 +90,7 @@ export function planStatusPie({ distribution, rows, selected = [] } = {}) {
             label: row.label,
             tone: row.tone,
             shape: row.shape,
+            icon: row.icon || '',
             count: Number(counts[row.key]) || 0,
             selected: chosen.has(row.key),
         })),

+ 11 - 1
src/modules/cockpit/domain/warningResponsePanels.js

@@ -10,6 +10,7 @@ import {
     eventLevelTag,
     eventLevelTagFromBucket,
     eventListSwitchView,
+    eventRowView,
     eventTypeText,
     localizeCodeText,
     toEventItem,
@@ -19,6 +20,7 @@ import { EVENT_FAMILY } from './events.js'
 import { WORK_ORDER_STATES } from './workOrder.js'
 import { industryLabel } from './filterOptions.js'
 import {
+    CARD_ACTION,
     CODE_TEXT,
     barsBlock,
     donutBlock,
@@ -381,6 +383,10 @@ function historyEventListBlock(current) {
     const rows = events.map((item) => ({
         key: item.code,
         title: item.name || item.code,
+        identityKind: CARD_ACTION.EVENT,
+        identityCode: item.code,
+        identityPayload: eventRowView(item),
+        identityReason: item.code ? '' : '缺少事件编码,无法展开',
         tone: eventLevelTag(item.level).tone,
         toneLabel: eventLevelTag(item.level).label,
         meta: [item.code, item.eventTime, item.deviceName, item.status].filter(Boolean),
@@ -638,8 +644,12 @@ export function workOrderCardRows(orders) {
     const rows = workOrderRows(orders)
     return rows.map((row) => ({
         key: row.code,
-        // `code` 是渲染层的可点契约(点开走票 12 的工单时间线)。
+        // 工单继续走 WarningResponsePane 的专用时间线入口;显式声明 action,避免被猜成点位。
         code: row.code,
+        identityKind: CARD_ACTION.EVENT,
+        identityCode: row.code,
+        identityPayload: { workOrderCode: row.code },
+        identityReason: row.code ? '' : '缺少工单编码,无法展开',
         // 卡片抬头就是工单号:值班人员按编号对工单,不按故障描述认。
         title: row.code,
         name: row.code,

+ 169 - 15
src/modules/cockpit/services/cesiumEngine.js

@@ -37,6 +37,7 @@ import {
     Matrix4,
     OrientedBoundingBox,
     PostProcessStage,
+    PostProcessStageLibrary,
     Rectangle,
     RequestScheduler,
     SceneTransforms,
@@ -55,14 +56,13 @@ import { MAP_LABEL_STYLE, mapLabelText } from '../domain/mapLabelStyle.js'
 // 批次 3 第四轮:标签省略走 domain/ellipsis.js 的共用契约(与 DOM 侧 EllipsisText 同语义)。
 import { ellipsisForMapLabel } from '../domain/ellipsis.js'
 import { createTilesetLoadingController } from './tilesetLoading.js'
-import { setTilesetPerformance, tilesetOptions } from './tilesetOptions.js'
+import { setTilesetPerformance, tilesetOptions, detailTilesetOptions, setDetailTilesetPerformance } from './tilesetOptions.js'
 import { createRegionLoadingController, contentAdmission } from './regionLoading.js'
+import { clampResolution, motionResolution, sameResolution } from '../domain/renderResolution.js'
 import { removeSceneTileset } from './tilesetCleanup.js'
 const regionControllers = new WeakMap()
 // 票 47:动效的**相位与映射**同样在 domain(纯函数,可跨时间采样断言);
 // 本层只把相位写成 billboard 的 scale/alpha/rotation。
-// 票 76(C5):环实体与逐帧动效已退役,引擎只保留「层数」这一个读数常量(DOM 侧层数由 profile 决定)。
-import { RING_LAYERS } from '../domain/deviceEffects.js'
 // 场景调色的兜底色直接取 domain 的默认(不再手抄一份 #2fa8ff 的浮点,两处会漂)。
 // 注意:GRADE_DEFAULTS 来自 sceneGrade;parseHexColor 的 owner 是 fog.js ——
 // sceneGrade 自己也只是导入它,从这里导 parseHexColor 会是 ESM 链接错误(引擎整个起不来)。
@@ -70,10 +70,59 @@ import { GRADE_DEFAULTS } from '../domain/sceneGrade.js'
 import { parseHexColor } from '../domain/fog.js'
 // 批次 3:合成计划宿主读数 + 母版合成渲染。
 import { planToDataset } from '../domain/markerPlan.js'
+// 票 82/84:标记几何与光环层数读数都取单源(旧的 RING_LAYERS 常量已随 billboard 环退役)。
+import { MARKER_BODY_WIDTH, markerMetricsFor, maxRingLayers } from '../domain/markerMetrics.js'
 import { renderMarker } from './markerAssets.js'
 
 const DEG = Math.PI / 180
 
+// ================================================================ 场景级 bloom(票 86:默认关闭的画质开关)
+
+/** 当前 bloom 处理级(**懒建**:默认不建,只有显式开启才创建)。 */
+let sceneBloomStage = null
+/** 默认关闭:任何自动路径都不许打开(告警可读性不得依赖 bloom)。 */
+let sceneBloomEnabled = false
+
+/**
+ * 场景级 bloom 画质开关(默认**关闭**)。
+ *
+ * 票 72 的裁定:bloom 只作为走查时可对比的开关,**告警可读性不得依赖它** ——
+ * 环的亮/锐由 DOM 的硬边带与 alpha 保证,bloom 只是整体观感增强。
+ * 只由 DEV 钩子调用;不写进部署配置。
+ */
+export function setSceneBloom(viewer, enabled) {
+    const scene = viewer && viewer.scene
+    if (!scene || !scene.postProcessStages) return false
+    const next = enabled === true
+    if (next) {
+        if (!sceneBloomStage) {
+            sceneBloomStage = scene.postProcessStages.add(PostProcessStageLibrary.createBloomStage())
+            // Cesium 默认参数(对比/亮度/模糊半径)保持库默认:走查时由维护者对比开/关,不预调。
+            sceneBloomStage.uniforms.glowOnly = false
+        }
+        sceneBloomStage.enabled = true
+    } else if (sceneBloomStage) {
+        sceneBloomStage.enabled = false
+    }
+    sceneBloomEnabled = next
+    return sceneBloomEnabled
+}
+
+/** 当前 bloom 是否开启(走查读数;默认 false)。 */
+export function getSceneBloom() {
+    return sceneBloomEnabled
+}
+
+/** 卸载场景时移除 bloom 处理级(避免重建 viewer 后重复叠加)。 */
+export function disposeSceneBloom(viewer) {
+    const scene = viewer && viewer.scene
+    if (scene && sceneBloomStage && scene.postProcessStages) {
+        try { scene.postProcessStages.remove(sceneBloomStage) } catch { /* 已随场景销毁 */ }
+    }
+    sceneBloomStage = null
+    sceneBloomEnabled = false
+}
+
 /**
  * 逐帧投影的跨实体缓存(票 35 卡顿整改)。
  *
@@ -219,11 +268,39 @@ const PERFORMANCE_DEFAULTS = {
     ibl: true,
     // 同时进行的网络请求数:越小,内容"落地"越分散,主线程上传/编译的尖峰越平缓(加载更慢一点)。
     maxConcurrentRequests: 6,
+    // 相机在动时的内部渲染分辨率(静态恢复 resolutionScale)。
+    //
+    // 近景卡顿主要是**填充率**:楼占满屏幕时每像素都要跑一遍材质/阴影。移动期把内部
+    // 分辨率压到 0.6(像素量约为 0.75 的 64%),停下立刻恢复满档 —— 静态画质不变,
+    // 只有"正在动"的那几秒降档。这是自适应分辨率的标准做法,也是唯一不动观感的两全法。
+    movingResolutionScale: 0.6,
 }
 
 /** 生效中的档位(applyPerformanceOptions 写入后续的 applyTerrain/applyTilesets 读取)。 */
 let activePerformance = { ...PERFORMANCE_DEFAULTS }
 
+/**
+ * 细模覆盖档(票 61):`map.detail` 覆盖,未配置即用 domain 默认档。
+ * 与 `activePerformance` 分开,是因为它只影响"细模覆盖到多远/占多少预算",
+ * 不改变任何渲染开销档位(两者可以独立回退)。
+ */
+let activeDetail = { frontier: null, budget: null, tiersConfigured: false }
+
+/** 应用细模覆盖档(`map.detail`);脏值忽略,交给 domain 的默认档兜底。 */
+export function applyDetailOptions(options = {}) {
+    const next = options && typeof options === 'object' ? options : {}
+    activeDetail = {
+        frontier: next.frontier && typeof next.frontier === 'object' ? next.frontier : null,
+        budget: next.budget && typeof next.budget === 'object' ? next.budget : null,
+        // 配置层显式给了 tiers ⇒ 它是部署侧的最终裁定,优先于资产 manifest。
+        // 这条优先级对"低配机把 fine 收到 150m"至关重要:若 manifest 的 250m
+        // 每次加载都覆盖配置,运维旋钮就形同虚设。
+        tiersConfigured: Array.isArray(next.tiers) && next.tiers.length >= 2,
+    }
+    // 细模批次的 SSE / 中心偏置也归这里(`map.detail.fineScreenSpaceError` / `fineFoveated`)。
+    try { setDetailTilesetPerformance(next) } catch { /* 忽略 */ }
+}
+
 export const PERFORMANCE_DEFAULT_OPTIONS = { ...PERFORMANCE_DEFAULTS }
 
 /**
@@ -268,6 +345,45 @@ export function applyPerformanceOptions(viewer, options = {}) {
     activePerformance = settings
 }
 
+/**
+ * 运行时改内部渲染分辨率(票 61 第十四轮:移动期自适应降档)。
+ *
+ * 只做"值变了才写",并且写后立刻 resize —— 不做这两步会让 Cesium 的绘制缓冲与
+ * resolutionScale 脱节,表现为画面糊一格或帧率不掉。返回是否真的改了(调用方可据此跳过)。
+ *
+ * @param {Viewer} viewer
+ * @param {number} scale 0.5..1
+ * @returns {boolean} 是否发生了变化
+ */
+export function setRenderResolution(viewer, scale) {
+    if (!viewer || !viewer.scene) return false
+    const target = clampResolution(scale)
+    if (target === null) return false
+    if (sameResolution(viewer.resolutionScale, target)) return false
+    viewer.resolutionScale = target
+    try { viewer.resize() } catch { /* 无宿主尺寸时忽略 */ }
+    return true
+}
+
+/**
+ * 按"相机在动 / 静止"切换渲染分辨率(票 61 第十四轮)。
+ *
+ * 在动 ⇒ 降到 movingResolutionScale;静止 ⇒ 升回 resolutionScale。
+ * 两者都取 `map.performance` 的生效档,未配置就是代码默认(0.75 / 0.6)。
+ *
+ * @param {Viewer} viewer
+ * @param {boolean} moving
+ * @returns {boolean} 是否真的改了
+ */
+export function applyMotionResolution(viewer, moving) {
+    const base = Number.isFinite(activePerformance.resolutionScale)
+        ? activePerformance.resolutionScale : PERFORMANCE_DEFAULTS.resolutionScale
+    const movingScale = Number.isFinite(activePerformance.movingResolutionScale)
+        ? activePerformance.movingResolutionScale : PERFORMANCE_DEFAULTS.movingResolutionScale
+    // 判定与夹取都在 domain(单源、脱浏览器可断言);这里只做应用。
+    return setRenderResolution(viewer, motionResolution({ base, moving: movingScale, isMoving: moving === true }))
+}
+
 /** 叠加影像(票 21):本地精细切片画在底图之上,URL 模板里可用 `{reverseY}` 表示 TMS 的 y。 */
 function createOverlayProvider(overlay) {
     const options = { url: overlay.url }
@@ -540,15 +656,33 @@ function createTilesetCullingController(scene, plans, instances, failed, onChang
         const manifest = await response.json()
         if (model.isDestroyed() || regionAbort.signal.aborted) return
         const url = new URL(plan.regionManifest, document.baseURI)
+        // 票 91:资产在自己的 manifest 里声明层级表(`lodLevels.tiers`)。
+        // 运行时以它为准 —— "加一级/改距离带"只改资产,不必动代码;
+        // 读不到就保持当前默认表(三级资产照常工作,不报错、不阻断)。
+        if (!activeDetail.tiersConfigured && manifest.lodLevels && Array.isArray(manifest.lodLevels.tiers) && manifest.lodLevels.tiers.length) {
+            // 资产声明只是**默认**;部署配置显式给 tiers 时不覆盖(配置优先)。
+            try { setDetailTilesetPerformance({ tiers: manifest.lodLevels.tiers }) } catch { /* 非法声明忽略,用默认表 */ }
+        }
         const transform = Matrix4.fromArray(manifest.transform)
         const regions = manifest.regions.map(r => {
             const spheres = (r.admissionBounds || [r.bounds]).map(volume => {
                 const s = volume.sphere || [...volume.box.slice(0, 3), Math.hypot(volume.box[3], volume.box[7], volume.box[11])]
                 return new BoundingSphere(Matrix4.multiplyByPoint(transform, new Cartesian3(...s.slice(0, 3)), new Cartesian3()), s[3])
             })
-            return { ...r, url: new URL(r.url, url).href, admissionSpheres: spheres,
-                expectedContentUrls: r.contentUrls.map(p => new URL(p, new URL(r.url, url)).href),
-                expectedPreviewContentUrls: (r.previewContentUrls || []).map(p => new URL(p, new URL(r.url, url)).href),
+            const regionUrl = new URL(r.url, url)
+            const absolute = (list) => (list || []).map(p => new URL(p, regionUrl).href)
+            // 票 91(五级 LOD):manifest 的 `contentUrls` 只列 preview+fine,**不含 medium**
+            // (medium 在 tileset 层级里,见 region-0/tileset.json 的 ge=20 层)。
+            // 已知内容集必须是三者的**并集**,否则 medium 的 tileLoad 事件会被漏记,
+            // "细模是否已渲染"的判定也会把 medium 误当成 fine。
+            const mediumUrls = absolute(r.mediumContentUrls)
+            return { ...r, url: regionUrl.href, admissionSpheres: spheres,
+                // 预算口径:medium 与 fine 是**同时**可能驻留的,必须一起计入,
+                // 否则按 600 条 / 384MiB 选的区域实际占用会明显超出。
+                decodedGeometryBytes: (Number(r.decodedGeometryBytes) || 0) + (Number(r.decodedMediumBytes) || 0),
+                expectedContentUrls: [...absolute(r.contentUrls), ...mediumUrls],
+                expectedPreviewContentUrls: absolute(r.previewContentUrls),
+                expectedMediumContentUrls: mediumUrls,
             }        })
         const localInstances = []
         let cullingVolume, coarseRendered = false
@@ -563,11 +697,27 @@ function createTilesetCullingController(scene, plans, instances, failed, onChang
                 r.centerDistance = result.centerDistance
                 return result.distance
             },
-            load: r => Cesium3DTileset.fromUrl(r.url, { ...tilesetOptions(plan), loadSiblings: true }),
+            // 票 61 第二轮:细模批次用**专用档**,不继承粗模的 SSE。
+            //
+            // 旧写法是 `tilesetOptions(plan)` —— plan 是**粗模批次**的配置(SSE 115,为全城
+            // 少选瓦片而定)。细模层级是 preview(ge=48) → fine(ge=0),窗户/立面全在 fine;
+            // 套用 115 意味着只有 ~293m 内才细化到"有窗",之外停在 preview 的无窗灰盒子。
+            // 再加上粗模档开着的 foveated 中心偏置 ⇒ 同距离的建筑屏幕中心精细、边缘粗糙,
+            // 正是"同距离一半精模一半火柴盒"。专用档把阈值降到 24 并关掉中心偏置。
+            load: r => Cesium3DTileset.fromUrl(r.url, detailTilesetOptions(plan)),
             add: (fine) => { scene.primitives.add(fine); localInstances.push(fine); instances.push(fine); onChange?.({ ready: instances.length, instances, failed }) },
             remove: fine => { const i = instances.indexOf(fine); if (i >= 0) instances.splice(i, 1); const j = localInstances.indexOf(fine); if (j >= 0) localInstances.splice(j, 1);
                 removeSceneTileset(scene, fine) },
             requestRender: () => scene.requestRender(),
+            // 票 61:前沿参数与预算可由 map.detail 覆盖;未配置即用 domain 默认档。
+            frontier: activeDetail.frontier,
+            budget: activeDetail.budget,
+            // 判"相机在动/静止"用真实位姿(米级取整),不用区域距离兜底 ——
+            // 细微平移在区域距离上看不出来,会误判成静止而提前放大前沿。
+            readCameraKey: () => {
+                const p = camera.positionWC, d = camera.directionWC
+                return [p.x, p.y, p.z, d.x, d.y, d.z].map((v) => Math.round(v)).join(',')
+            },
         })
         region.setPipeline(regionPipeline)
         const unmask = model.tileVisible.addEventListener(tile => {
@@ -607,6 +757,8 @@ function createTilesetCullingController(scene, plans, instances, failed, onChang
         ...controller,
         setPipeline(value) { regionPipeline = !!value; for (const h of regionHandles) h.region.setPipeline(regionPipeline) },
         regionSnapshot() { return regionHandles.flatMap(h => h.region.snapshot()) },
+        /** 票 61:细模覆盖前沿读数(覆盖到多远、为什么停)。 */
+        frontierSnapshot() { return regionHandles.map(h => h.region.frontierSnapshot()).filter(Boolean) },
         detach() {
             if (detached) return
             detached = true
@@ -902,7 +1054,6 @@ void main(void)
     color = mix(color, gradeTint, tintWeight);
     out_FragColor = vec4(color, base.a);
 }`
-
 /**
  * 在场景上建**唯一**一个调色处理级。常驻(不做进出生命周期):enabled 随配置。
  *
@@ -1378,7 +1529,7 @@ export function syncAssociatedDevices(viewer, devices, colorOf, imageOf, overlap
         const color = colorOf(device)
         // 票 44:尺寸与形状都来自**单一资产接缝**(`domain/deviceAssets.js` 的计划),
         // 引擎不认识"设备该多大",也不写死尺寸字面量。
-        const size = Number.isFinite(device.size) ? device.size : 14
+        const size = Number.isFinite(device.size) ? device.size : MARKER_BODY_WIDTH.device
         const group = groupsByCode.get(device.code)
         // 批次 3:关联设备走实心母版合成计划(第三轮:绘制尺寸含描边余量 `plan.draw`)。
         const plan = planOf ? planOf(device) : null
@@ -1445,19 +1596,21 @@ export function syncAssociatedDevices(viewer, devices, colorOf, imageOf, overlap
     // 于是 `projectMarkerEntity`(要求有 billboard)与 `readAssociatedDevices` 读数照旧;
     // GPU 拾取交给 overlay 节点的点击(见 mapService.onPick)。
     const hideAggregateVisual = options.hideAggregateVisual === true
+    // 票 82:聚合档 = 设备档(单源几何),billboard 尺寸不再 `size * 1.5` 自造第三个口径。
+    const aggregateMetrics = markerMetricsFor('overlap-aggregate')
     for (const group of Array.isArray(overlapGroups) ? overlapGroups : []) {
         if (!group || group.k < 2 || !group.rows.length) continue
         const representative = group.rows.find((row) => row.status === (group.visual && group.visual.status)) || group.rows[0]
         const color = colorOf(representative)
-        const size = Number.isFinite(representative.size) ? representative.size : 14
+        const size = Number.isFinite(representative.size) ? representative.size : MARKER_BODY_WIDTH.device
         const visual = group.visual || {}
         source.entities.add({
             id: `overlap|${group.pointCode}|${group.key}`,
             position: Cartesian3.fromDegrees(representative.longitude, representative.latitude),
             billboard: {
                 image: imageOf(visual.shape, color, size),
-                width: size * 1.5,
-                height: size * 1.5,
+                width: aggregateMetrics.drawWidth,
+                height: aggregateMetrics.drawHeight,
                 scale: 1,
                 pixelOffset: new Cartesian2(0, 0),
                 heightReference: HeightReference.CLAMP_TO_GROUND,
@@ -1692,8 +1845,6 @@ export function projectOverlapSymbols(viewer) {
             label: read('overlapLabel'),
             shape: read('overlapShape') || 'circle',
             tone: read('overlapTone'),
-            // 符号真值尺寸取 billboard 宽(`size * 1.5` 的产物)——DOM 侧照此渲染,不再自己算。
-            size: numberOf(entity.billboard && entity.billboard.width, 21) || 21,
             // 状态强调真值(`setOverlapPresentation`/`nudgeOverlap` 写在 billboard 上):
             // DOM 镜像同一量,否则选中/展开时两层强调不一致。
             scale: numberOf(entity.billboard && entity.billboard.scale, 1) || 1,
@@ -2015,7 +2166,10 @@ export function readDeviceEffects(viewer) {
         running: false,
         degraded: Boolean(deviceEffectState.plan && deviceEffectState.plan.degraded),
         enabled: Boolean(deviceEffectState.plan && deviceEffectState.plan.enabled),
-        ringLayers: RING_LAYERS,
+        // 票 84 / 票 70:层数读数取单源。**用状态无关的 `maxRingLayers()`** ——
+        // 此前写 `ringLayersFor('ALARM')` 等于把业务状态名带进引擎,
+        // 违反「引擎只认 shape/tone」的守卫(t09 F5);读数要的是"本套光环最多几层"。
+        ringLayers: maxRingLayers(),
     }
     if (!source) return { ...summary, devices: [], fx: [] }
     const devices = []

+ 33 - 0
src/modules/cockpit/services/cockpitConfig.js

@@ -87,6 +87,15 @@ export function builtinConfig() {
             // 票 21:地面/管线视图的取值覆盖(不配就用 domain/viewModes.js 的默认)
             viewMode: { pitch: null, rangeFactor: null, terrainAlpha: null, tilesetAlpha: null },
 
+            // 票 59 遗留缺陷修正:`map.performance` 此前**根本不存在于白名单**,归一化时被
+            // 静默丢弃 → `applyPerformanceOptions()` 永远拿到 {},代码注释里所有"可用配置
+            // 覆盖/回退"的说法全部失效。现在显式保留:null = 用代码默认档。
+            performance: null,
+
+            // 票 61:细模覆盖档(覆盖到多远、占多少预算、移动期收缩多狠)。
+            // null 的每一项都交给 `domain/detailFrontier.js` 与 `services/regionLoading.js` 的默认档。
+            detail: { frontier: null, budget: null, tiers: null },
+
             // 票 38:区内视距雾气。null/'' = 用 `domain/fog.js` 的**规格默认**
             // (18,000/42,000 m、#bdcdd6);即"不配就是规格定的那一套",与票 21 同一立场。
             fog: {
@@ -250,6 +259,30 @@ export function normalizeConfig(raw) {
     const tianditu = isPlainObject(map.tianditu) ? map.tianditu : {}
     const cesium = isPlainObject(map.cesium) ? map.cesium : {}
 
+    // 票 59 遗留缺陷修正:`map.performance` / `map.detail` 必须**真正落到配置上**。
+    // 过去白名单重建把未知键静默丢掉,既不生效也不报 issue —— 部署方以为改了配置,
+    // 实际一行没生效。这里:认得的键原样保留(深拷贝一层,不把外部对象引用带进来);
+    // 给了但不是对象则退回 null 并**留一条 issue**(不静默)。
+    if (map.performance !== undefined && map.performance !== null) {
+        if (isPlainObject(map.performance)) config.map.performance = { ...map.performance }
+        else issues.push('map.performance 应为对象,已忽略并退回代码默认档')
+    }
+    if (map.detail !== undefined && map.detail !== null) {
+        if (isPlainObject(map.detail)) {
+            const frontier = map.detail.frontier
+            const budget = map.detail.budget
+            const tiers = map.detail.tiers
+            if (frontier !== undefined && frontier !== null && !isPlainObject(frontier)) issues.push('map.detail.frontier 应为对象,已忽略')
+            else if (isPlainObject(frontier)) config.map.detail.frontier = { ...frontier }
+            if (budget !== undefined && budget !== null && !isPlainObject(budget)) issues.push('map.detail.budget 应为对象,已忽略')
+            else if (isPlainObject(budget)) config.map.detail.budget = { ...budget }
+            // 层级表是数组;原白名单只认 frontier/budget,会把它静默丢掉。
+            // 结构合法性由 domain/lodTiers.js 统一归一化;配置层只负责"键真的能到引擎"。
+            if (tiers !== undefined && tiers !== null && !Array.isArray(tiers)) issues.push('map.detail.tiers 应为数组,已忽略')
+            else if (Array.isArray(tiers)) config.map.detail.tiers = tiers.map((tier) => isPlainObject(tier) ? { ...tier } : tier)
+        } else issues.push('map.detail 应为对象,已忽略')
+    }
+
     config.api.baseUrl = readString(api.baseUrl, config.api.baseUrl, issues, 'api.baseUrl')
     config.api.token = readString(api.token, config.api.token, issues, 'api.token')
     config.ws.url = readString(ws.url, config.ws.url, issues, 'ws.url')

+ 217 - 38
src/modules/cockpit/services/mapService.js

@@ -40,19 +40,21 @@ import {
     resolveOverlapPick,
     transitionOverlapState,
 } from '../domain/overlapDevices.js'
-import { MARKER_SIZE, planDeviceAsset } from '../domain/deviceAssets.js'
+import { planDeviceAsset } from '../domain/deviceAssets.js'
 // 批次 3:位置符号合成计划层(规格 10 的唯一新接缝)与母版合成渲染。
 import { planMarker, planToDataset } from '../domain/markerPlan.js'
+// 票 82:标记几何单源(本体宽 / pad / 绘制宽),服务层不再持有尺寸字面量。
+import { MARKER_BODY_WIDTH, markerDevKnobs, markerMetricsFor, resetMarkerDevKnobs, setMarkerDevKnobs } from '../domain/markerMetrics.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, isMarkerOverlayVisible, isMarkerOverlayMounted } from './markerOverlay.js'
+import { mountMarkerOverlay, attachOverlayRenderer, unmountMarkerOverlay, isMarkerOverlayVisible, isMarkerOverlayMounted, refreshMarkerFx } from './markerOverlay.js'
 import { DEVICE_LAYER_KIND } from '../domain/deviceLayer.js'
 import { deviceVisualForStatus } from '../domain/deviceStatus.js'
 // 票 45:摘要标记(监测点/未关联设备)动效计划 —— 与设备动效同一映射与门控。
-import { planDeviceEffects } from '../domain/deviceEffects.js'
+import { abnormalObjectCount, combineEffectPlans, planDeviceEffects } from '../domain/deviceEffects.js'
 import { isRenderable } from '../domain/coordinate.js'
 import { VIEW_MODES, planViewMode, viewHprFor } from '../domain/viewModes.js'
 import { TERRAIN_STATE, planOverlays, planTerrain, planTilesets, terrainStatusText } from '../domain/mapLayers.js'
@@ -83,6 +85,7 @@ import {
 // 一张图调色(科技蓝滤镜):计划归 domain/sceneGrade.js,引擎只照计划建处理级。
 import { planSceneGrade } from '../domain/sceneGrade.js'
 import { attachModelDiagnostics } from './modelDiagnostics.js'
+import { tierAtDistance } from './tilesetOptions.js'
 import {
     HANDOVER_BLOCK,
     HANDOVER_PHASE,
@@ -214,6 +217,8 @@ let viewMode = VIEW_MODES.GROUND
 let markerPlan = { markers: [], blocked: [], byStatus: {}, total: 0 }
 // 票 45:摘要标记(监测点/未关联设备)的动效计划(planDeviceEffects 的输出,形状同设备动效计划)。
 let markerEffectsPlan = null
+// 票 85:跨图层合计降级状态(三类对象合计计数;两张计划共用同一个阈值与收敛系数)。
+let combinedEffectState = { enabled: false, limit: 30, abnormalCount: 0, animatedCount: 0, degraded: false, converge: null }
 let markerRendered = 0
 let markerSkipped = 0
 // 票 42:关联设备临时图层的最近一次计划与结果(与摘要标记**分开**计数,互不污染)。
@@ -227,9 +232,14 @@ let deviceRows = []
  * 这里只在输入引用变化时重建,帧内全部复用。
  */
 const overlayFrameCache = {
-    rows: null, byCode: new Map(), effectsPlan: null, effectOf: () => '',
+    rows: null, byCode: new Map(), effectOf: () => '',
+    // 票 84:关联设备层的门控查询(与摘要标记同一条门控;三类对象都按自身状态挂环)。
+    deviceEffectOf: () => '',
+    // 票 85:跨图层合计门控与收敛系数(降级时环仍画,只是更小更淡)。
+    combinedRef: null, converge: null,
     // 静态帧闸门:镜头位姿/宿主矩形/输入计划都没变时,66ms 内的重复帧直接跳过。
     pose: '', rectKey: '', at: 0, lastPlan: null, lastRows: null, lastEffects: null,
+    lastDeviceEffects: null, lastCombined: null,
 }
 const overlayPlanMarkerCache = new Map()
 
@@ -504,6 +514,28 @@ function publishDevHook() {
         // 票 76(C6):A/B 显隐把手已退役(?overlay=0 与 set/toggle 那组 DEV 把手一并摘除),
         // 只留只读读数给走查确认 overlay 在位。
         getMarkerOverlay: () => isMarkerOverlayVisible(),
+        // 票 83:读 `data-*` 读数前的**可用性自检入口**(overlay 未挂载/不可见时读数无效,必须判红不判绿)。
+        isMarkerOverlayMounted: () => isMarkerOverlayMounted(),
+        isMarkerOverlayVisible: () => isMarkerOverlayVisible(),
+        // 票 86:六个 DEV/验收旋钮(点位档/设备档/环峰值/选中/alpha 下限/可见带占比)。
+        // **只作用于渲染**,不改业务;默认值 = 单源声明,正式路径不会因为旋钮存在而改变。
+        // 改完清几何缓存并重建环样式(关键帧与带型都按新参数重生成)。
+        getMarkerDevKnobs: () => markerDevKnobs(),
+        setMarkerDevKnobs: (partial) => {
+            const next = setMarkerDevKnobs(partial)
+            overlayPlanMarkerCache.clear()
+            refreshMarkerFx()
+            return next
+        },
+        resetMarkerDevKnobs: () => {
+            const next = resetMarkerDevKnobs()
+            overlayPlanMarkerCache.clear()
+            refreshMarkerFx()
+            return next
+        },
+        // 票 86:场景 bloom 画质开关(**默认关闭**;告警可读性不依赖它,纯观感对比用)。
+        setSceneBloom: (enabled) => (instance && instance.engine ? instance.engine.setSceneBloom(instance.viewer, enabled === true) : false),
+        getSceneBloom: () => (instance && instance.engine ? instance.engine.getSceneBloom() : false),
         // 票 36:主场景区(走查与人工排查读"配没配、矩形是哪一个",票 37/38 也读这一个出口)。
         getPrimaryAreaPlan: () => getPrimaryAreaPlan(),
         // 票 37:进区锁定与区域限位(走查驱动「进入 → 锁定 → 拉远被夹 → 返回全球」全过程)。
@@ -611,6 +643,8 @@ function publishDevHook() {
         getTilesetDiagnostics: () =>
             instance && instance.engine ? instance.engine.readTilesetDiagnostics(instance.tilesetInstances || []) : [],
         getTilesetRegions: () => instance?.tilesetCulling?.regionSnapshot?.() || [],
+        /** 票 61:细模覆盖前沿(覆盖半径 / 在动或静止 / 是否吃满预算)。 */
+        getDetailFrontier: () => instance?.tilesetCulling?.frontierSnapshot?.() || [],
         getTilesetLoading: () => instance?.tilesetCulling?.snapshot() || [],
         getLifecycleStats,
         projectCameraPivot: () => (instance && instance.cameraControl ? instance.cameraControl.projectPivot() : null),
@@ -761,11 +795,16 @@ async function bootEngine(record) {
         if (instance !== record) return
 
         const viewer = engine.createViewer(record.host, basemapPlan)
-        // 票 35:可选渲染开销(msaa/分辨率/FXAA)。不配置 = 不改变任何既有视觉;
-        // 需要更高帧率时在 config.json 的 map.performance 里给一项即可。
+        // 票 35:渲染开销档(msaa/分辨率/大气/地形 SSE/并发…)。
+        // 票 59 修正:`map.performance` 过去被归一化白名单静默丢弃,配置写了也不生效;
+        // 现在它真的会落地 —— 未配置即代码默认档(已降载),配置了可逐项覆盖/回退。
         if (typeof engine.applyPerformanceOptions === 'function') {
             engine.applyPerformanceOptions(viewer, (mapConfig && mapConfig.performance) || {})
         }
+        // 票 61:细模覆盖档(覆盖到多远、驻留预算)由 map.detail 给;未配置即默认档。
+        if (typeof engine.applyDetailOptions === 'function') {
+            engine.applyDetailOptions((mapConfig && mapConfig.detail) || {})
+        }
         record.engine = engine
         record.viewer = viewer
 
@@ -883,12 +922,25 @@ async function bootEngine(record) {
                 // 为什么不能只靠 `scene.postRender`:渲染帧在**隐藏标签页**里不触发(rAF 被冻结,
                 // 票 37 已记录同一环境事实),于是锚定会停在旧位置;相机状态变化是**由输入/飞行驱动**的,
                 // 与是否正在渲染无关,是更可靠的触发源。两条路径都留着:渲染帧管逐帧跟随,这里管"变化即更新"。
+                // 票 61 第十四轮:移动期自适应降档(近景填充率是卡顿主因)。
+                // 静止必须恢复满档,否则画质会永久停在降档状态。
+                if (state && typeof state.settled === 'boolean' && viewer) {
+                    try { engine.applyMotionResolution(viewer, state.settled === false) } catch { /* 忽略 */ }
+                }
                 for (const handler of cameraStateHandlers) handler(state)
             },
         })
         if (isDev()) record.detachModelDiagnostics = attachModelDiagnostics(viewer.scene, record.host, () => ({
-            viewMode, engineState, regions: record.tilesetCulling?.regionSnapshot?.() || [],
+            viewMode, engineState,
+            // 票 61 第十六轮:每个区域标出「按五级层级表它该属于哪一级」,
+            // 复看时能直接看出中景是不是真的走了中模、而不是全精模。
+            regions: (record.tilesetCulling?.regionSnapshot?.() || []).map((region) => ({
+                ...region,
+                tier: tierAtDistance(Number.isFinite(region.distance) ? region.distance : Infinity).name,
+            })),
             tilesets: record.tilesetCulling?.snapshot?.() || [],
+            // 票 61:细模覆盖前沿 —— 直接回答"现在覆盖到多远、为什么停在这"。
+            detailFrontier: record.tilesetCulling?.frontierSnapshot?.() || [],
             camera: record.cameraControl?.getState?.() || null,
         }), { isMoving: () => record.cameraControl?.getState?.()?.settled === false })
         // 票 37:相机控件挂上后立刻把生效限位算一遍并下发 —— 进区状态可能在引擎就绪之前
@@ -997,6 +1049,8 @@ async function bootEngine(record) {
                 const inputsChanged = overlayFrameCache.lastPlan !== markerPlan
                     || overlayFrameCache.lastRows !== deviceRows
                     || overlayFrameCache.lastEffects !== markerEffectsPlan
+                    || overlayFrameCache.lastDeviceEffects !== deviceEffectsPlan
+                    || overlayFrameCache.lastCombined !== combinedEffectState
                 // 维护者反馈:限到 30Hz 后 DOM 图标"不跟手、观感卡顿"。**镜头一旦在动就必须逐帧同步**
                 // (billboard 是 GPU 逐帧的,DOM 慢半拍就会明显脱离)。
                 // 只有"镜头没动且输入没变"时跳过重复帧;但静态期仍保留 ~8Hz 兜底同步,
@@ -1010,19 +1064,29 @@ async function bootEngine(record) {
                 overlayFrameCache.lastPlan = markerPlan
                 overlayFrameCache.lastRows = deviceRows
                 overlayFrameCache.lastEffects = markerEffectsPlan
+                overlayFrameCache.lastDeviceEffects = deviceEffectsPlan
+                overlayFrameCache.lastCombined = combinedEffectState
                 // 票 35 卡顿整改:下面三样原先每帧重建(Map/Map/闭包 + 逐标记 planMarker),
                 // 现在按"输入引用变没变"复用 —— 计划与设备行只在业务数据变化时才换引用。
                 if (overlayFrameCache.rows !== deviceRows) {
                     overlayFrameCache.rows = deviceRows
                     overlayFrameCache.byCode = new Map(deviceRows.map((row) => [row.code, row]))
                 }
-                if (overlayFrameCache.effectsPlan !== markerEffectsPlan) {
-                    overlayFrameCache.effectsPlan = markerEffectsPlan
-                    const plan = markerEffectsPlan
-                    const animated = plan && plan.enabled === true && plan.degraded !== true
-                        ? new Map((plan.animated || []).map((row) => [row.code, row.effect]))
+                // 票 84/85:门控按**跨图层合计**状态重建(降级时不再清空动效键,只把 converge 下传)。
+                if (overlayFrameCache.combinedRef !== combinedEffectState) {
+                    overlayFrameCache.combinedRef = combinedEffectState
+                    // 票 85:降级**不取消**动效 —— 仍按 enabled 放行,收敛系数由 converge 下传;
+                    // 若这里再判 degraded,环会直接消失(2026-10-27 走查实测缺陷)。
+                    const gated = combinedEffectState.enabled === true
+                    const markerAnimated = gated && markerEffectsPlan
+                        ? new Map((markerEffectsPlan.animated || []).map((row) => [row.code, row.effect]))
+                        : new Map()
+                    const deviceAnimated = gated && deviceEffectsPlan
+                        ? new Map((deviceEffectsPlan.animated || []).map((row) => [row.code, row.effect]))
                         : new Map()
-                    overlayFrameCache.effectOf = (marker) => animated.get(`${marker.kind}|${marker.code}`) || ''
+                    overlayFrameCache.effectOf = (marker) => markerAnimated.get(`${marker.kind}|${marker.code}`) || ''
+                    overlayFrameCache.deviceEffectOf = (device) => deviceAnimated.get(device.code) || ''
+                    overlayFrameCache.converge = combinedEffectState.converge
                 }
                 return {
                     markers: markerPlan.markers,
@@ -1045,10 +1109,16 @@ async function bootEngine(record) {
                     // 票 76(C3):强调量由**服务层**产出(`highlightMapObject()` 记录的目标 +
                     // domain 同一份 `shouldEmphasize`),DOM 不再回读被隐藏的 billboard scale。
                     pointScales: pointEmphasisScales(),
+                    // 2026-10-28(维护者规格④):当前选中对象编码 —— overlay 用它给**非聚合设备点**
+                    // (独立未关联设备 / k=1 关联设备)上「被选择态」同款高亮;
+                    // 聚合设备的中心态另由引擎 `pinned` 标志表达,两者不混用。
+                    selectedCode: getSelectedDevice(),
                     tones: colors,
-                    // 票 45:摘要标记的动效键(门控/降级判定在 domain 的计划里;设备不取 ——
-                    // 关联设备的特效仍在 billboard 层,DOM 不画第二套环,同位双环会糊)。
+                    // 票 45/84/85:摘要标记与关联设备各自的动效键(门控/降级判定都在 domain 计划里;
+                    // 降级时键仍在,只把收敛系数下传,环不消失)。
                     effectOf: overlayFrameCache.effectOf,
+                    deviceEffectOf: overlayFrameCache.deviceEffectOf,
+                    converge: overlayFrameCache.converge,
                     planOf: overlayPlanMarkerCached,
                     // 贴地定位与 billboard `CLAMP_TO_GROUND` 同口径(票 47 维护者反馈①根因):
                     // 首版按椭球 0 米投影,地形区里 DOM 标记恒落在 billboard 标记的**地面以下**。
@@ -1163,6 +1233,17 @@ export function unmountMap() {
 
     clearDevHook()
 
+    // 推迟队列与上限定时器随地图生命周期清掉:否则卸载后定时器仍会触发一次落地,
+    // 打到已销毁的 viewer 上(票 70 / 2026-10-27)。
+    if (deferredSceneApplyTimer !== null) {
+        clearTimeout(deferredSceneApplyTimer)
+        deferredSceneApplyTimer = null
+    }
+    deferredSceneTasks.clear()
+    deferredSceneApplyAt = 0
+    // 订单位一并复位:否则卸载后 store 会以为「还有未落地的订单」而反复重发同步。
+    deviceApplyPending = false
+
     // 票 30:先停自转循环,再拆其它资源(否则 rAF 会打到已销毁的 viewer 上)。
     stopSpin()
 
@@ -2704,10 +2785,28 @@ let pipeSyncDeferred = false
 let pipeSyncDeferredAt = 0
 let pipeSyncDeferredPlan = null
 
-/** 相机是否在运动(飞行或拖拽尚未落定)。 */
+/**
+ * 相机是否**正被动画驱动**(用户拖拽 / 换中心飞行 / 整段视角飞行)。
+ *
+ * 2026-10-27 修(票 70,维护者报「点监测点定位后子设备点要等一会 / 点几次刷新才出现」):
+ * 判据此前是 `getState().settled === false` —— 而 `settled` 把「阻尼收敛尾段」也算成在动
+ * (阈值 1e-7° / 0.5m,定位那一下要约 1.1–1.5s 才为真)。推迟队列的**两个**补跑点都要求
+ * `!cameraInMotion()`,于是设备实体被推到收敛那一帧才创建。
+ *
+ * 真正需要避让的是「画面正在被动画改写」;阻尼尾段不属于此列 —— 它本来也在 700ms 上限之后
+ * 被允许落地。开发把 `cameraControl.getState()` 已经给了三个驱动标志(`dragging/flying/flyingView`),
+ * 这里直接读它们;替身只提供 `settled` 时退回旧判据(测试替身不必补新字段)。
+ */
 function cameraInMotion() {
     const state = instance && instance.cameraControl ? instance.cameraControl.getState() : null
-    return Boolean(state && state.settled === false)
+    if (!state) return false
+    const hasDriveFlags = typeof state.dragging === 'boolean'
+        || typeof state.flying === 'boolean'
+        || typeof state.flyingView === 'boolean'
+    if (hasDriveFlags) {
+        return state.dragging === true || state.flying === true || state.flyingView === true
+    }
+    return state.settled === false
 }
 
 // 任务按"类"分槽:点位标记与关联设备图层是两类独立的落地,**不能互相覆盖**。
@@ -2715,22 +2814,61 @@ function cameraInMotion() {
 const deferredSceneTasks = new Map()
 let deferredSceneApplyAt = 0
 const SCENE_APPLY_MAX_DEFER_MS = 700
+/**
+ * 上限兜底的**真定时器**(2026-10-27 修,票 70)。
+ *
+ * 此前上限只在「下一次 `scheduleSceneApply` 被调用」时惰性判定 —— 地图空闲、没有新落地任务时
+ * (典型:点了点位、定位完成、用户不再操作)它永远不会到点,于是队列一直挂着,
+ * 只有 `force` 路径(刷新地图 / 换点 / 翻页)才会因 `overdue` 立即补跑。
+ * 这解释了维护者「要等一会,或者点几次刷新地图才出现」的第二半。
+ */
+let deferredSceneApplyTimer = null
+
+/**
+ * 关联设备层的「已下单但尚未落地」只读位(2026-10-27,票 70)。
+ *
+ * 为什么需要:`syncAssociatedDevices` 是**先入队再返回 true**,于是 store 侧的 `painted`
+ * 只代表「已下单」。若这份订单随后被清层吃掉(换点/翻页先撤后取),store 的计划指纹
+ * (`deviceLayerStore` 的 `lastPlanKey` 短路)会认为「同一份计划已经画过」而不再重发 ——
+ * 只有 `force` 路径(刷新地图)能救,这正是「必须点刷新」的结构性原因。
+ * 有了这个位,store 的短路改为「计划没变**且**没有未落地的订单」才跳过。
+ */
+let deviceApplyPending = false
+
+/** 关联设备层是否有尚未落地的订单(store 的指纹短路据此放行自愈)。 */
+export function isDeviceApplyPending() {
+    return deviceApplyPending
+}
 
 /** 是否还有排队中的落地任务(渲染帧回调据此决定要不要补跑)。 */
 function hasDeferredSceneApply() {
     return deferredSceneTasks.size > 0
 }
 
-/** 提交一个场景落地任务;镜头在动时推迟到落定。`key` 相同的任务只保留最后一次。 */
+/** 提交一个场景落地任务;镜头在动时推迟到落定或上限到点。`key` 相同的任务只保留最后一次。 */
 function scheduleSceneApply(key, task) {
     deferredSceneTasks.set(key, task)
     if (!deferredSceneApplyAt) deferredSceneApplyAt = engineNow()
-    const overdue = engineNow() - deferredSceneApplyAt >= SCENE_APPLY_MAX_DEFER_MS
-    if (!cameraInMotion() || overdue) flushSceneApply()
+    const elapsed = engineNow() - deferredSceneApplyAt
+    if (!cameraInMotion() || elapsed >= SCENE_APPLY_MAX_DEFER_MS) {
+        flushSceneApply()
+        return
+    }
+    if (deferredSceneApplyTimer === null && typeof setTimeout === 'function') {
+        const remaining = Math.max(0, SCENE_APPLY_MAX_DEFER_MS - elapsed)
+        deferredSceneApplyTimer = setTimeout(() => {
+            deferredSceneApplyTimer = null
+            flushSceneApply()
+        }, remaining)
+    }
 }
 
-/** 真正执行排队中的落地任务(相机落定时由渲染帧回调调)。 */
+/** 真正执行排队中的落地任务(上限到点、渲染帧补跑、或相机停下时调)。 */
 function flushSceneApply() {
+    if (deferredSceneApplyTimer !== null) {
+        clearTimeout(deferredSceneApplyTimer)
+        deferredSceneApplyTimer = null
+    }
     if (!deferredSceneTasks.size) {
         deferredSceneApplyAt = 0
         return
@@ -2760,6 +2898,22 @@ function flushSceneApply() {
  * @param {object} [legendSource] 可选的图例行(默认由计划现算),便于界面直接复用同一份
  * @returns {boolean} 是否已画到地图上
  */
+/**
+ * 票 85:刷新跨图层合计降级状态。两处调用(摘要标记计划/关联设备计划变化)——
+ * 阈值按三类对象合计判定,收敛系数共享同一份,避免两张计划一个降级一个不降。
+ */
+function refreshCombinedEffectState() {
+    combinedEffectState = combineEffectPlans([markerEffectsPlan, deviceEffectsPlan], resolveDeviceEffectsOptions())
+    return combinedEffectState
+}
+
+/**
+ * 票 85:给设备图层计划提供「另外两类对象」的异常数,让 store 侧的降级判定与视觉门控同源。
+ */
+export function getEffectsAbnormalBase() {
+    return abnormalObjectCount(markerPlan.markers || [])
+}
+
 export function syncPointMarkersForPlan(plan, legendSource) {
     const markers = (plan && Array.isArray(plan.markers)) ? plan.markers : []
     markerPlan = {
@@ -2776,6 +2930,7 @@ export function syncPointMarkersForPlan(plan, legendSource) {
         devices: markers.map((item) => ({ code: `${item.kind}|${item.code}`, status: item.status })),
         ...resolveDeviceEffectsOptions(),
     })
+    refreshCombinedEffectState()
 
     if (!instance || !instance.engine || !instance.viewer) {
         paintMarkerState()
@@ -2796,9 +2951,9 @@ export function syncPointMarkersForPlan(plan, legendSource) {
                 instance.viewer,
                 markerPlan.markers,
                 (marker) => colors[marker.tone] || colors.neutral,
-                // 票 44:监测点尺寸也取自**同一个尺寸层级定义**(`MARKER_SIZE.point`),
-                // 这样「点始终大于设备」只在一处成立,不会两边各写一个字面量而悄悄反转。
-                (shape, color) => markerSvg(shape, color, MARKER_SIZE.point),
+                // 票 82:监测点兜底尺寸取自**单源几何模块**(`MARKER_BODY_WIDTH.point`)——
+                // 「点始终大于设备」只在一处成立,不会两边各写一个字面量而悄悄反转。
+                (shape, color) => markerSvg(shape, color, MARKER_BODY_WIDTH.point),
                 // 批次 3:合成计划层——每个标记按 (kind/status/industry) 产出双层水滴合成计划,
                 // 由 `renderMarker` 异步烘焙成 billboard 位图;首帧走 `markerFallbackSvg` 同步兜底。
                 (marker) => planMarker({ kind: 'monitoring-point', status: marker.status, industry: marker.industry, tone: marker.tone }),
@@ -3115,14 +3270,18 @@ export function syncAssociatedDevices(devices, options = {}) {
     }
 
     // 降峰:关联设备层同样是"每个设备一次 planDeviceAsset + 一次实体创建",
-    // 镜头在动时把整批落地推迟到落定(计划与 overlapGroups 已同步更新,DOM 立即反映)。
+    // 镜头在被动画驱动时把整批落地推迟到停下(计划与 overlapGroups 已同步更新,DOM 立即反映)。
+    // 2026-10-27(票 70):下单时置 `deviceApplyPending`,任务体首行清掉 ——
+    // store 侧据此判断「上一份计划是否真的落地过」,避免指纹短路把订单锁死。
+    deviceApplyPending = true
     scheduleSceneApply('devices', () => {
+        deviceApplyPending = false
         if (!instance || !instance.engine || !instance.viewer) return
         const colors = toneColors()
         const planned = deviceRows.map((device) => {
             // 票 45:特效基准随标记**绘制宽**(plan.draw.width),不再是 14px 图标尺寸——
             // 光环要罩得住图标本体(「特效不被图标尺寸压制」);引擎的 ensureRingEntities 读它。
-            const drawWidth = planMarker({ kind: 'monitoring-device', status: device.status, industry: device.industry, tone: device.tone }).draw.width
+            const drawWidth = markerMetricsFor('monitoring-device').drawWidth
             if (device.assetMode !== undefined) return { ...device, tone: device.tone || deviceVisualTone(device), drawWidth }
             const asset = planDeviceAsset({ device, color: colors[deviceVisualTone(device)] || colors.neutral })
             return {
@@ -3162,6 +3321,7 @@ export function syncAssociatedDevices(devices, options = {}) {
         deviceSkipped = result.skipped
         // 票 47:动效计划随图层一起下发(门控判定在 domain,本层只搬运)。
         deviceEffectsPlan = options.effects || null
+        refreshCombinedEffectState()
         const selectedGroup = overlapGroups.find((group) => group.k > 1 && group.rows.some((row) => row.code === selectedDeviceCode))
         // 票 76(四态,2026-10-26 修):重同步后**保持当前固定态**,不要一律置 ④。
         // 之前这里无条件 `SELECTED`(= 固定),把 ③ 设备常态在每次设备层同步时抬成 ④,
@@ -3206,12 +3366,14 @@ function applyDeviceEffectsToEngine() {
 function paintDeviceEffects() {
     if (!instance || !isDomHost(instance.host)) return
     const host = instance.host
-    const plan = deviceEffectsPlan
-    if (!plan || plan.enabled !== true) host.dataset.mapDeviceEffects = 'off'
-    else if (plan.degraded) host.dataset.mapDeviceEffects = 'degraded'
+    // 票 85:读数按**跨图层合计**状态写(三类对象一套阈值、一套收敛系数)。
+    const state = combinedEffectState
+    if (state.enabled !== true) host.dataset.mapDeviceEffects = 'off'
+    else if (state.degraded) host.dataset.mapDeviceEffects = 'degraded'
     else host.dataset.mapDeviceEffects = 'on'
-    host.dataset.mapDeviceEffectsAbnormal = String(plan ? plan.abnormalCount : 0)
-    host.dataset.mapDeviceEffectsAnimated = String(plan && plan.animated ? plan.animated.length : 0)
+    host.dataset.mapDeviceEffectsAbnormal = String(state.abnormalCount)
+    host.dataset.mapDeviceEffectsAnimated = String(state.animatedCount)
+    host.dataset.mapDeviceEffectsConverge = state.converge ? `${state.converge.peak}/${state.converge.alpha}` : ''
 }
 
 /**
@@ -3651,7 +3813,23 @@ export function hoverDeviceAt(clientX, clientY) {
     // 从聚合入口移开:只**结束预览**,不能动固定态与中心设备。
     // 2026-10-26 修(事件冲突):此前直接调 `collapseOverlap()` —— 它会清 `overlapExpandedGroup`
     // 并把状态打回 collapsed,于是 ③ 设备常态被 hover 一次就「掉出选择能力」(点不动了)。
-    if (overlapPreviewGroup) endOverlapPreview()
+    //
+    // 2026-10-28(维护者规格③):**hover 设备点也要展开预览环** ——
+    // ③ 设备常态的中心设备本身就是"替换了聚合点的设备点",它保留聚合点的 hover 特性。
+    // 因此在结束预览之前先判:指针下的设备是否属于某个 k>1 的聚合组。
+    const hoverDeviceCode = target && target.kind === 'associated-device' ? target.code : ''
+    const hoverGroup = hoverDeviceCode
+        ? overlapGroups.find((item) => item.k > 1 && item.rows.some((row) => row.code === hoverDeviceCode))
+        : null
+    if (hoverGroup && overlapExpandedGroup !== hoverGroup.key) {
+        // 固定态优先:已经固定(②④)就不再改状态,只让设备身份继续走下面(面板/ tooltip 照常)。
+        if (!overlapExpandedGroup && overlapPreviewGroup !== hoverGroup.key) {
+            endOverlapPreview()
+            expandOverlap(hoverGroup.key, false)
+        }
+    } else if (!hoverGroup && overlapPreviewGroup) {
+        endOverlapPreview()
+    }
     const effective = domHoverDeviceHit(target)
     const code = effective ? effective.code : ''
     if (code === hoveredDeviceCode) return code
@@ -3863,13 +4041,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:摘要标记动效读数(与设备动效读数同构:on|off|degraded + 异常数 + 动效数)。
-    const plan = markerEffectsPlan
-    if (!plan || plan.enabled !== true) host.dataset.mapMarkerEffects = 'off'
-    else if (plan.degraded) host.dataset.mapMarkerEffects = 'degraded'
+    // 票 45/85:摘要标记动效读数(与设备动效读数同构,且按**跨图层合计**状态写)。
+    const state = combinedEffectState
+    if (state.enabled !== true) host.dataset.mapMarkerEffects = 'off'
+    else if (state.degraded) host.dataset.mapMarkerEffects = 'degraded'
     else host.dataset.mapMarkerEffects = 'on'
-    host.dataset.mapMarkerEffectsAbnormal = String(plan ? plan.abnormalCount : 0)
-    host.dataset.mapMarkerEffectsAnimated = String(plan && plan.animated ? plan.animated.length : 0)
+    host.dataset.mapMarkerEffectsAbnormal = String(state.abnormalCount)
+    host.dataset.mapMarkerEffectsAnimated = String(state.animatedCount)
+    host.dataset.mapMarkerEffectsConverge = state.converge ? `${state.converge.peak}/${state.converge.alpha}` : ''
 }
 
 // ---- 票 10:点位点选(点地图上的标记打开两级详情) ----

+ 7 - 7
src/modules/cockpit/services/markerAssets.js

@@ -17,6 +17,8 @@
  */
 import { resolveAsset } from '../domain/runtimeAssets.js'
 import { MASTER_GEOMETRY } from '../domain/markerPlan.js'
+// 票 82:描边宽与外扩 pad 来自单源几何模块(此前本地又声明一份 28/40)。
+import { MASTER_SOURCE } from '../domain/markerMetrics.js'
 
 /** 母版 manifest key(实心/镂空,512×512 白剪影)。 */
 const MASTER_KEYS = { solid: 'marker.master.solid', hollow: 'marker.master.hollow' }
@@ -30,10 +32,8 @@ const DESATURATE = { saturate: 0.55, brightness: 0.72 }
 /** 内层染色:形状处「白 → 浅蓝 #9adcff」渐变(亮度反转提亮,见 recolorInner)。 */
 const INNER_TINT = { light: [0x9a, 0xdc, 0xff], white: [255, 255, 255] }
 
-/** 白描边宽度(源图像素,512 画布)。走查第三轮:屏幕可见 ≈1.5px → 源像素 = 1.5 × (512/27) ≈ 28。
- *  描边外扩后裁剪窗 pad = OUTLINE_PAD(40 ≥ 28),保证描边不被裁剪窗切掉(前两版的 bug)。 */
-const OUTLINE_WIDTH = 28
-const OUTLINE_PAD = 40
+// 白描边宽度 / 裁剪窗 pad 一律引用单源 `MASTER_SOURCE`(票 82 消除的三处重复之一):
+// 屏幕可见 ≈1.5px → 源像素 ≈28;外扩 pad 40 ≥ 28,保证描边不被裁剪窗切掉(前两版的 bug)。
 
 const imgCache = new Map() // url → Promise<ImageBitmap|HTMLImageElement>
 
@@ -301,14 +301,14 @@ async function renderMarkerUncached(plan, tintHex) {
     }
 
     // 白描边(整枚符号外圈,形态学膨胀——见 withWhiteOutline 注释)
-    const outlined = withWhiteOutline(full, OUTLINE_WIDTH)
+    const outlined = withWhiteOutline(full, MASTER_SOURCE.outlineWidth)
 
-    // 裁剪窗 = bbox **四周外扩 OUTLINE_PAD**:把描边一起裁进来(前两版裁剪窗=bbox 本身,
+    // 裁剪窗 = bbox **四周外扩单源 pad**:把描边一起裁进来(前两版裁剪窗=bbox 本身,
     // 描边向外扩的部分全被切掉——「一半有一半没有、部分被切割」的根因)。
     // 输出**直接缩到屏显尺寸**(`plan.draw`,本体 + 2×pad 屏显值):GPU 1:1 贴图不参与
     // mipmap 缩小——Cesium 对 billboard 纹理默认 LINEAR_MIPMAP_LINEAR,源图远大于显示尺寸时
     // 缩小走 mip 高层会发虚(走查「看着糊」的渲染根因);1:1 贴图最锐利。
-    const pad = OUTLINE_PAD
+    const pad = MASTER_SOURCE.outlinePad
     const cropX = bbox.x - pad
     const cropY = bbox.y - pad
     const cropW = bbox.width + pad * 2

+ 518 - 164
src/modules/cockpit/services/markerOverlay.js

@@ -6,7 +6,7 @@
  *     水滴形按实测母版几何拟合:帽圆 (188.5,189) r189(逐行验证误差 <1px)+
  *     下半收尖三次贝塞尔(对 512px 母版左缘 57 点最小二乘拟合,最大残差 4.75 源像素
  *     ≈ 0.34 屏显像素;拟合探针见 .scratch/cockpit-real-data-wayfinding/research/fit-pin-bezier.mjs)。
- *     镂空孔 = 实测圆 (188.5,189) r75。viewBox 外扩 40 与 billboard 裁剪窗(OUTLINE_PAD)同形。
+ *     镂空孔 = 实测圆 (188.5,189) r75。viewBox 外扩与 billboard 裁剪窗同形(单源 pad)。
  *   · **内层行业图标 = 素材**(`markerAssets.renderInnerIconUrl`:与 billboard 同一
  *     `recolorInner` 去色重染规则,但**不下采样**——256px 原生分辨率 data URL,
  *     DOM 里按帽径向下采样,永远锐利)。
@@ -28,9 +28,25 @@ import { renderInnerIconUrl } from './markerAssets.js'
 // 与标记/设备一样走自绘水滴母版;domain 的 `markerSvg` 导入随之移除。
 // 票 45:特效随标记 DOM 化 —— 周期/层数/放大倍数与 billboard 特效同一份 domain 常量(节奏单源)。
 // 票 70(重做):本行导入的每一枚都必须有消费者(不留死导入,票面 Acceptance 第 3 条)。
-import { DEVICE_EFFECT, effectProfileFor, RING_PERIOD_MS, GLOW_PERIOD_MS, EFFECT_PERIOD_MS } from '../domain/deviceEffects.js'
+import { DEVICE_EFFECT, effectProfileFor } from '../domain/deviceEffects.js'
 // 票 76(2026-10-26):设备点的色键取设备状态词条(domain 单源),DOM 不自己映射状态→颜色。
 import { deviceVisualForStatus } from '../domain/deviceStatus.js'
+// 票 82/84:几何与光环参数都来自单源 `markerMetrics`;DOM 只把结论翻译成 CSS/DOM。
+import {
+    MASTER_SOURCE,
+    MARKER_COEFFICIENTS,
+    RING_EFFECT,
+    markerDevKnobs,
+    RING_STYLE,
+    blinkOpacityAt,
+    glowScaleAt,
+    glowStrengthAt,
+    ringAlphaAt,
+    ringPhaseOffsetOf,
+    ringScaleAt,
+} from '../domain/markerMetrics.js'
+// 票 83:几何读数(本体宽 / 绘制宽 / pad)与计划读数同一出口,走查不读 DOM 内部结构。
+import { markerGeometryDataset } from '../domain/markerPlan.js'
 
 /**
  * 类名单源:注入的特效 CSS(下方模板串)与 DOM 构建(buildNode/mount)共用同一组常量。
@@ -40,96 +56,158 @@ import { deviceVisualForStatus } from '../domain/deviceStatus.js'
 const OVERLAY_HOST_CLASS = 'marker-overlay-host'
 const NODE_CLASS = 'marker-overlay-node'
 const LABEL_CLASS = 'marker-overlay-label'
+/** 标签独立图层(2026-10-28):标签要盖在所有图标之上,必须脱离节点自身的层叠上下文。 */
+const LABEL_LAYER_CLASS = 'marker-overlay-labels'
+/** 标签的零尺寸锚点(随节点位移;真正的标签盒子在它内部)。 */
+const LABEL_WRAP_CLASS = 'marker-overlay-label-wrap'
 const FX_CLASS = 'marker-overlay-fx'
 const FX_RING_CLASS = 'marker-overlay-fx-ring'
 const FX_GLOW_CLASS = 'marker-overlay-fx-glow'
+const FX_SPARK_CLASS = 'marker-overlay-fx-spark'
+const FX_TICK_CLASS = 'marker-overlay-fx-tick'
+
+/** 关键帧采样相位(9 个停点足够平滑,且让「任何时刻总有一层在前 1/4」可断言)。 */
+const FX_SAMPLE_PHASES = [0, 0.125, 0.25, 0.375, 0.5, 0.625, 0.75, 0.875, 1]
+/** 刻度环/工单齿轮的旋转周期:由 RING_STYLE.tickDegPerSec 反推(20°/s → 18s)。 */
+const FX_TICK_PERIOD_MS = Math.round((360 / RING_STYLE.tickDegPerSec) * 1000)
+
+/** 单层环的关键帧名:每层 alpha 指数不同,所以按 (effect, layer) 生成,不共用一套曲线。 */
+function fxRingKeyframeName(effect, layer) {
+    return `markerFxRing--${effect}--${layer}`
+}
+
+/** 从单源纯函数采样生成环的关键帧(scale/opacity/brightness 都来自 markerMetrics)。 */
+function fxRingKeyframes(status, profile, layer) {
+    const stops = FX_SAMPLE_PHASES.map((phase) => {
+        const timeMs = phase * profile.periodMs
+        const scale = ringScaleAt(status, layer, timeMs).toFixed(3)
+        const alpha = ringAlphaAt(status, layer, timeMs).toFixed(3)
+        // 自发光亮度包络(2026-10-27 恢复已验收规格 2.0 → 0.8):
+        // 只靠 opacity 会显得「灰」——发射瞬间必须亮一下才有能量感。
+        const brightness = (
+            MARKER_COEFFICIENTS.ringBrightnessStart
+            - (MARKER_COEFFICIENTS.ringBrightnessStart - MARKER_COEFFICIENTS.ringBrightnessEnd) * phase
+        ).toFixed(2)
+        return `    ${(phase * 100).toFixed(1)}% { transform:translate(-50%,-50%) scale(${scale}); opacity:${alpha}; filter:brightness(${brightness}); }`
+    })
+    return `@keyframes ${fxRingKeyframeName(profile.effect, layer)} {\n${stops.join('\n')}\n}`
+}
+
+/** 工单处置:只旋转不扩散(scale 固定 1),不透明度按已验收包络 0.95 → 0.68 呼吸。 */
+function fxSpinKeyframes(profile) {
+    const { spinAlphaStart, spinAlphaEnd } = MARKER_COEFFICIENTS
+    return `@keyframes markerFxSpin {\n    from { transform:translate(-50%,-50%) rotate(0deg); opacity:${spinAlphaStart}; }\n    to { transform:translate(-50%,-50%) rotate(360deg); opacity:${spinAlphaEnd}; }\n}`
+}
+
+/** 中心辉光(只给报警):尺寸/亮度/不透明度同样从单源纯函数与系数采样。 */
+function fxGlowKeyframes() {
+    const profile = RING_EFFECT.ALARM
+    const { glowAlphaMin, glowAlphaPeak, glowBrightnessMin, glowBrightnessPeak } = MARKER_COEFFICIENTS
+    const stops = FX_SAMPLE_PHASES.map((phase) => {
+        const timeMs = phase * profile.glowPeriodMs
+        const strength = glowStrengthAt(timeMs)
+        // 已验收包络:不透明度 0.32 → 0.85、亮度 1.2 → 1.9。
+        // 此前被压成固定 0.32 且无亮度提亮 —— 报警的「核心在烧」观感就是这么丢的。
+        const alpha = (glowAlphaMin + (glowAlphaPeak - glowAlphaMin) * strength).toFixed(3)
+        const brightness = (glowBrightnessMin + (glowBrightnessPeak - glowBrightnessMin) * strength).toFixed(2)
+        return `    ${(phase * 100).toFixed(1)}% { transform:translate(-50%,-50%) scale(${glowScaleAt(timeMs).toFixed(3)}); opacity:${alpha}; filter:brightness(${brightness}); }`
+    })
+    return `@keyframes markerFxGlow {\n${stops.join('\n')}\n}`
+}
+
+/** 环关键帧(票 86:DEV 旋钮变更后要能重建,所以不是模块级常量)。 */
+function ringKeyframesCss() {
+    return [
+        ...Object.values(RING_EFFECT)
+            .filter((profile) => profile.effect !== 'spin')
+            .flatMap((profile) => Array.from({ length: profile.layers }, (_, layer) => fxRingKeyframes(profile.status, profile, layer))),
+        fxSpinKeyframes(RING_EFFECT.WORK_ORDER),
+        fxGlowKeyframes(),
+    ].join('\n')
+}
 
 /**
- * 特效层样式(票 45):keyframes 无法用行内 cssText 表达,所以注入一次 <style>。
- * 环带 = 径向渐变(透明度 52% → 高亮 70% → 渐隐),对比更强烈;阴影更小但扩散节奏更快。
- * 锚点是**节点原点**即图标尖端落地位置(不是帽心),这样辐射环直接标记地面目标点。
- *
- * 票 70 走查反馈②(2026-10-21):扩散/旋转峰值统一读 `--fx-profile-scale`
- * (domain EFFECT_PROFILE 单源——预警此前 1.22 峰值探不出图标 = 「看不出动态」的根因);
- * 齿轮环 keyframes 的 scale 改为 profile 驱动(此前 0.9/1.12 写死,profile 的 scale/alpha 无人消费);
- * 中心辉光尺寸/亮度配合报警存在感提升。
+ * 特效层样式(票 45 起,票 84 改写):
+ *   · 环带 = **近白硬边带**(不再是把状态色烘进平滑渐变),锐利才读作「能量」;
+ *   · scale/opacity 关键帧由 `markerMetrics` 的纯函数采样生成(起始 0.55 / 峰值 2.6 /
+ *     alpha 峰值 0.35–0.5 / 内层慢外层快的 alpha 指数)——渲染层不写死任何环数值;
+ *   · 相位去同步:层间均分 + 每对象哈希偏移,靠 `animation-delay`(负值 = 开播即处于该相位);
+ *   · 粒子感:3 个火花(径向推进)+ 2 层刻度环(反向旋转),数量来自 `RING_STYLE`;
+ *   · 锚点 = 节点原点(图标尖端落地位置),环直接标记地面目标点;
+ *   · **不做** reduced-motion 降级(票 72 D10:维护者明确「不做冗余设计」)。
  */
-const FX_CSS = `
-/* 票 76(标签规格,2026-10-26 终版):标签**常态显示**(监测点 / 普通设备点 / 中心设备 / 聚合点)。
-   圈内设备(--fan)**一律不显示顶部标签** —— 维护者裁定:
-     · 环内设备密集,顶部标签互相压叠、还会被图案与特效淹没看不清;
-     · 它们的完整名称由**右下角 tooltip**(paintMarkerTooltip,跟随鼠标)承担,一份就够。
-   因此这里不是「hover 才显示」,而是**根本没有**(连 hover 也不显示)。 */
-.${NODE_CLASS}--fan .${LABEL_CLASS} { display:none !important; }
-/* 选择态标识(2026-10-26 二次修正):发光用**点位本体颜色语义**(--marker-tone = 状态色),
-   不再用白光。两处都生效、两种进入方式共用:
-     ① 聚合点被点开(选择态);
-     ② 选中设备替换中心后,中心设备本身也是选择态(可再点回选择态)。
-   --marker-tone 由节点构建时写入(见 buildNode),不靠 currentColor 兜底 ——
-   母版白色描边会让 currentColor 落成白色,这正是上一版「还是白光」的原因。 */
+function buildFxCss() {
+    return `
+/* 票 76 曾对圈内设备(--fan)隐藏顶部标签;2026-10-27 维护者走查要求**子设备点也要标签**,
+   因此该隐藏规则移除(标签常态显示,hover 仍读 title 全名)。 */
+/* 选择态标识:发光用**点位本体颜色语义**(--marker-tone = 状态色),不用白光。
+   两处都生效:聚合点被点开、以及选中设备替换中心后中心设备本身。 */
 .${NODE_CLASS}--active { z-index:2; }
-/* 票 76(四态,2026-10-26 修「第 3 步点不动」):聚合节点在设备节点**之后**追加到 DOM,
-   天然盖在中心设备上方 —— 聚合节点的 pointer-events:auto 会把对中心设备的点击全部吃掉。
-   ③④ 的中心设备必须压过聚合节点,否则「点中心设备进 ④」永远不触发。
-   中心设备给比 --active 更高的层级,且聚合节点在中心存在时降为 pointer-events:none。 */
 .${NODE_CLASS}--center { z-index:3; }
 .${NODE_CLASS}--aggregate.${NODE_CLASS}--replaced { pointer-events:none; }
 .${NODE_CLASS}--active svg {
     filter:drop-shadow(0 0 6px var(--marker-tone, currentColor)) drop-shadow(0 0 14px var(--marker-tone, currentColor)) brightness(1.18);
 }
-/* 票 76(四态规格 2026-10-26):② 与 ④ 都是「被选择态」 —— 发光 + **隐藏自身标签**
-   (② 隐藏聚合标签、④ 隐藏中心设备标签),hover 圈内设备才出全名。 */
-.${NODE_CLASS}--active .${LABEL_CLASS} { display:none; }
-.${NODE_CLASS}--active:hover .${LABEL_CLASS} { display:block; }
-.${FX_CLASS} { position:absolute; pointer-events:none; transform-origin:50% 50%; }
+/* 2026-10-28:标签显隐**不再走 CSS 后代选择器** —— 标签已搬进独立图层(不再是节点的后代),
+   而且规格里「圈内设备 hover 才显示」「选择态隐藏标签」「非聚合设备点常显」是**状态**规则,
+   统一由 applyLabelState() 驱动(见 syncMarkerOverlay 的三处调用)。
+   旧的两条 --active + 标签 的 display 规则已退役(它们现在匹配不到任何元素)。 */
+.${FX_CLASS} { position:absolute; pointer-events:none; transform-origin:50% 50%; will-change:transform; }
 .${FX_RING_CLASS} {
     position:absolute; left:50%; top:50%;
     border-radius:50%;
-    background:radial-gradient(circle, rgba(0,0,0,0) 52%, currentColor 70%, rgba(0,0,0,0) 98%);
+    /* 彩色注能环(2026-10-27 恢复已验收规格):环体用**状态色**(currentColor),
+       内沿到外沿按可见带旋钮收口;此前换成白色硬边带后,维护者走查报「特效非常微弱」——
+       白带在浅底图与薄雾上几乎没有对比度,而彩色环才有「能量」语义。 */
+    background:radial-gradient(circle,
+        rgba(0,0,0,0) calc(var(--fx-band-in, 88%) - 16%),
+        currentColor var(--fx-band-in, 88%),
+        rgba(0,0,0,0) calc(var(--fx-band-out, 93%) + 7%));
     transform:translate(-50%,-50%);
-    animation:markerFxExpand var(--fx-period,1800ms) linear infinite;
-    /* 减小静态光环,但增强扩散对比 */
+    animation-timing-function:linear; animation-iteration-count:infinite;
+    /* 彩色外发光(已验收值 6px/16px):这是「亮度感」的主要来源之一,不能省。 */
     box-shadow:0 0 6px currentColor, 0 0 16px currentColor;
     will-change:transform,opacity;
 }
-.${FX_GLOW_CLASS} {
-    position:absolute; left:50%; top:50%;
-    border-radius:50%;
-    background:radial-gradient(circle, currentColor 0%, color-mix(in srgb, currentColor 42%, transparent) 30%, rgba(0,0,0,0) 72%);
-    box-shadow:0 0 18px currentColor, 0 0 40px currentColor;
-    transform:translate(-50%,-50%);
-    animation:markerFxGlow var(--fx-glow-period,2400ms) ease-in-out infinite;
-    will-change:transform,opacity;
-}
-.${FX_CLASS}--blink .${FX_RING_CLASS} {
-    animation:markerFxExpand var(--fx-blink-period,1400ms) linear infinite,
-              markerFxFlicker ${EFFECT_PERIOD_MS.BLINK}ms steps(2, jump-none) infinite;
-}
 .${FX_CLASS}--spin .${FX_RING_CLASS} {
     background:none;
     border:2px dashed currentColor;
-    animation:markerFxSpin var(--fx-spin-period,3700ms) linear infinite;
+    filter:drop-shadow(0 0 3px currentColor);
 }
-@keyframes markerFxExpand {
-    0% { transform:translate(-50%,-50%) scale(0.1); opacity:1; filter:brightness(2); }
-    35% { opacity:0.85; filter:brightness(1.6); }
-    70% { opacity:0.4; filter:brightness(1.1); }
-    100% { transform:translate(-50%,-50%) scale(var(--fx-profile-scale, 1)); opacity:0; filter:brightness(0.8); }
+.${FX_SPARK_CLASS} {
+    position:absolute; left:50%; top:50%;
+    width:3px; height:3px; border-radius:50%;
+    background:currentColor; box-shadow:0 0 4px currentColor;
+    animation:markerFxSpark var(--fx-period,1800ms) linear infinite;
+    will-change:transform,opacity;
 }
-@keyframes markerFxGlow {
-    0%, 100% { transform:translate(-50%,-50%) scale(0.75); opacity:0.32; filter:brightness(1.2); }
-    50% { transform:translate(-50%,-50%) scale(1.45); opacity:0.85; filter:brightness(1.9); }
+@keyframes markerFxSpark {
+    0% { opacity:0; transform:translate(-50%,-50%) rotate(var(--fx-spark-angle,0deg)) translateY(0) scale(0.6); }
+    20% { opacity:0.85; }
+    100% { opacity:0; transform:translate(-50%,-50%) rotate(var(--fx-spark-angle,0deg)) translateY(calc(var(--fx-box,32px) * -0.42)) scale(1); }
 }
-@keyframes markerFxFlicker {
-    0%, 100% { opacity:1; }
-    50% { opacity:0.28; }
+.${FX_TICK_CLASS} {
+    position:absolute; left:50%; top:50%;
+    border-radius:50%;
+    border:1px dashed currentColor;
+    opacity:0.16;
+    transform:translate(-50%,-50%);
+    animation:markerFxTick ${FX_TICK_PERIOD_MS}ms linear infinite;
 }
-@keyframes markerFxSpin {
-    from { transform:translate(-50%,-50%) rotate(0deg) scale(calc(var(--fx-profile-scale, 1) * 0.9)); opacity:0.95; }
-    to { transform:translate(-50%,-50%) rotate(360deg) scale(calc(var(--fx-profile-scale, 1) * 1.12)); opacity:0.68; }
+@keyframes markerFxTick { to { transform:translate(-50%,-50%) rotate(360deg); } }
+.${FX_GLOW_CLASS} {
+    position:absolute; left:50%; top:50%;
+    border-radius:50%;
+    background:radial-gradient(circle, currentColor 0%, color-mix(in srgb, currentColor 42%, transparent) 30%, rgba(0,0,0,0) 72%);
+    transform:translate(-50%,-50%);
+    animation:markerFxGlow var(--fx-glow-period,2400ms) ease-in-out infinite;
+    /* 中心辉光的外发光(已验收值 18px/40px):报警的「核心在烧」观感全靠它。 */
+    box-shadow:0 0 18px currentColor, 0 0 40px currentColor;
+    will-change:transform,opacity;
 }
-@media (prefers-reduced-motion: reduce) { .${FX_CLASS} { display:none; } }
+${ringKeyframesCss()}
 `
+}
 const FX_STYLE_ID = 'marker-overlay-fx-styles'
 
 /** 注入一次特效样式(幂等:已存在就跳过)。 */
@@ -138,12 +216,29 @@ function ensureFxStyles() {
     if (document.getElementById(FX_STYLE_ID)) return
     const style = document.createElement('style')
     style.id = FX_STYLE_ID
-    style.textContent = FX_CSS
+    style.textContent = buildFxCss()
     document.head.appendChild(style)
 }
 
+/**
+ * 票 86:DEV 旋钮变更后重建环样式与节点(关键帧/带型都按新参数重新生成)。
+ * 只在开发钩子里调用;生产路径不会走到。
+ */
+export function refreshMarkerFx() {
+    const style = typeof document !== 'undefined' ? document.getElementById(FX_STYLE_ID) : null
+    if (style) style.textContent = buildFxCss()
+    for (const record of pool.values()) {
+        if (record.root && record.root.parentNode) record.root.parentNode.removeChild(record.root)
+    }
+    pool.clear()
+}
+
 /** 每帧由 `syncMarkerOverlay` 注入的门控查询(mapService 从 domain 动效计划产出)。 */
 let frameEffectOf = () => ''
+/** 票 85:降级收敛系数(null = 未降级);同一屏所有环共享。 */
+let frameConverge = null
+/** 关联设备层的门控查询(票 84:三类对象的环统一走同一条门控)。 */
+let frameDeviceEffectOf = () => ''
 
 /** 状态 → 特效 profile(domain 单源;DOM 层数/亮度/辉光只消费这一份表,不另造)。 */
 function markerFxProfile(status) {
@@ -161,51 +256,84 @@ function fxOf(marker) {
     if (!effect) return null
     const profile = markerFxProfile(marker.status)
     if (!profile || profile.effect !== effect) return null
-    return { effect, profile }
+    return { effect, profile, converge: frameConverge }
 }
 
 /**
- * 环层数=状态 profile 的 layers(报警 4 / 预警 3 / 工单 2;辉光不计入层读数,只给报警)。
- * C3f 口径:层数只从 profile 消费,DOM 侧不许写死。
+ * 环层数=状态 profile 的 layers(报警 4 / 预警 3 / 工单 1)。
+ * 层数只从 profile 消费,DOM 侧不许写死。
  */
 function markerEffectLayers(profile) {
     return profile.layers
 }
 
 /**
- * 特效 DOM(票 70 重做):固定 `markerEffectLayers(profile)`(+报警辉光 1)个节点,
- * CSS 动画驱动、JS 不逐帧更新。容器几何:`left:${-box / 2}px` 使其**中心 = 节点原点**
- * (图标尖端落地点),辐射环直接标记地面目标;`currentColor` 由 tone 染色(与标记本体同色)。
- * 周期变量全部来自 domain 常量(节奏单源):环 `RING_PERIOD_MS`、辉光 `GLOW_PERIOD_MS`。
+ * 特效 DOM(票 70 重做,票 84 改写):固定 `profile.layers` 个环节点 + 3 火花 + 2 刻度环
+ * (+报警辉光 1)。CSS 动画驱动、JS 不逐帧更新。容器几何:`left:-box/2` 使其中心 = 节点原点。
+ * 相位偏移按 `phaseKey`(kind|code)哈希,保证同屏对象不同节拍、同 id 可复算。
  */
-function buildFxLayers(fx, plan, toneHex) {
-    const { effect, profile } = fx
-    const period = RING_PERIOD_MS[effect] || RING_PERIOD_MS[DEVICE_EFFECT.PULSE]
+function buildFxLayers(fx, plan, toneHex, phaseKey = '') {
+    const { profile } = fx
+    // 票 85:降级时环不消失 —— 整组环按收敛系数缩小(峰值 ×0.6)并压暗(alpha ×0.5),周期不变。
+    const converge = fx.converge || null
+    const convergePeak = converge ? converge.peak : 1
+    const convergeAlpha = converge ? converge.alpha : 1
     const box = plan.draw.width
+    const phaseOffset = ringPhaseOffsetOf(phaseKey)
     const host = document.createElement('div')
-    host.className = `${FX_CLASS} ${FX_CLASS}--${effect}`
+    host.className = `${FX_CLASS} ${FX_CLASS}--${profile.effect}`
     host.style.cssText = `position:absolute;left:${-box / 2}px;top:${-box / 2}px;width:${box}px;height:${box}px;color:${toneHex};`
-    host.style.setProperty('--fx-period', `${period}ms`)
-    host.style.setProperty('--fx-glow-period', `${GLOW_PERIOD_MS}ms`)
-    host.style.setProperty('--fx-profile-scale', String(profile.scale))
-    if (effect === DEVICE_EFFECT.BLINK) host.style.setProperty('--fx-blink-period', `${period}ms`)
-    if (effect === DEVICE_EFFECT.SPIN) host.style.setProperty('--fx-spin-period', `${period}ms`)
-    // 均分错相:第 i 层延迟 -period*i/layers(负延迟=开播即处于各自相位,形成连续外扩)。
+    // 收敛量写进 dataset:走查能读出"这条环是不是降级态",选中 boost 也乘在它上面。
+    host.dataset.fxBaseScale = String(convergePeak)
+    host.dataset.fxConverged = converge ? 'yes' : 'no'
+    host.style.opacity = String(convergeAlpha)
+    if (convergePeak !== 1) host.style.transform = `scale(${convergePeak})`
+    host.style.setProperty('--fx-box', `${box}px`)
+    host.style.setProperty('--fx-period', `${profile.periodMs}ms`)
+    host.style.setProperty('--fx-start-scale', String(profile.startScale))
+    host.style.setProperty('--fx-peak-scale', String(profile.scale))
+    host.style.setProperty('--fx-alpha-peak', String(profile.alpha))
+    host.style.setProperty('--fx-glow-period', `${RING_EFFECT.ALARM.glowPeriodMs}ms`)
+    // 票 86:可见带占比是 DEV 旋钮(内沿固定 88%,外沿 = 内沿 + 占比)。
+    const bandWidth = markerDevKnobs().bandWidth
+    host.style.setProperty('--fx-band-in', `${Math.round(RING_STYLE.bandInner * 100)}%`)
+    host.style.setProperty('--fx-band-out', `${Math.round((RING_STYLE.bandInner + bandWidth) * 100)}%`)
     for (let layer = 0; layer < markerEffectLayers(profile); layer += 1) {
         const ring = document.createElement('div')
         ring.className = FX_RING_CLASS
         ring.style.width = `${box}px`
         ring.style.height = `${box}px`
-        ring.style.animationDelay = `${-(period / markerEffectLayers(profile)) * layer}ms`
+        ring.style.animationName = profile.effect === DEVICE_EFFECT.SPIN ? 'markerFxSpin' : fxRingKeyframeName(profile.effect, layer)
+        ring.style.animationDuration = `${profile.periodMs}ms`
+        // 层间均分错相 + 每对象哈希偏移;负延迟 = 开播即处于该相位。
+        ring.style.animationDelay = `${-(profile.periodMs * ((layer / markerEffectLayers(profile)) + phaseOffset))}ms`
         host.appendChild(ring)
     }
-    // 中心辉光只给报警(EFFECT_PROFILE.glow;票 07 优先级语义,不给预警/工单)。
+    if (profile.effect !== DEVICE_EFFECT.SPIN) {
+        for (let index = 0; index < RING_STYLE.sparks; index += 1) {
+            const spark = document.createElement('div')
+            spark.className = FX_SPARK_CLASS
+            spark.style.setProperty('--fx-spark-angle', `${(360 / RING_STYLE.sparks) * index}deg`)
+            spark.style.animationDelay = `${-(profile.periodMs * ((index / RING_STYLE.sparks) + phaseOffset))}ms`
+            host.appendChild(spark)
+        }
+    }
+    for (let index = 0; index < RING_STYLE.tickRings; index += 1) {
+        const tick = document.createElement('div')
+        tick.className = FX_TICK_CLASS
+        const size = Math.round(box * (0.55 + index * 0.17))
+        tick.style.width = `${size}px`
+        tick.style.height = `${size}px`
+        tick.style.animationDirection = index % 2 === 1 ? 'reverse' : 'normal'
+        host.appendChild(tick)
+    }
+    // 中心辉光只给报警(票 07 优先级语义,不给预警/工单)。
     if (profile.glow) {
         const glow = document.createElement('div')
         glow.className = FX_GLOW_CLASS
-        const g = Math.round(box * 0.7)
-        glow.style.width = `${g}px`
-        glow.style.height = `${g}px`
+        const size = Math.round(box * 0.7)
+        glow.style.width = `${size}px`
+        glow.style.height = `${size}px`
         host.appendChild(glow)
     }
     return host
@@ -231,6 +359,9 @@ function buildAggregateNode(key, row, toneHex, plan) {
     root.dataset.markerOverlayCode = row.group
     root.dataset.markerOverlayAggregate = String(row.k)
     root.dataset.markerOverlayPointCode = row.pointCode || ''
+    // 票 83:聚合节点同样挂几何读数(聚合档 = 设备档);聚合符号恒不挂环。
+    Object.assign(root.dataset, markerGeometryDataset(plan))
+    root.dataset.markerRing = 'no'
     root.style.cssText = [
         'position:absolute',
         'left:0', 'top:0',
@@ -296,51 +427,64 @@ function buildAggregateNode(key, row, toneHex, plan) {
         svg.appendChild(group)
     }
 
-    const label = document.createElement('div')
-    label.className = LABEL_CLASS
-    label.textContent = `聚合 ${row.k} 台${row.label ? ` · 组内${row.label}` : ''}`
-    label.title = label.textContent
-    label.style.cssText = [
-        'position:absolute',
-        `bottom:${h + pad + 3}px`,
-        'left:0',
-        'transform:translateX(-50%)',
-        'box-sizing:content-box',
-        'max-width:170px',
-        'overflow:hidden',
-        'white-space:nowrap',
-        'text-overflow:ellipsis',
-        'padding:4px 9px',
-        'border-radius:8px',
-        'border:1px solid rgba(1,171,234,0.55)',
-        'background:rgba(2,20,40,0.82)',
-        'color:#d7ecff',
-        'font:600 12px "Microsoft YaHei UI","Microsoft YaHei","PingFang SC","Noto Sans SC",sans-serif',
-        'pointer-events:none',
-    ].join(';')
-    root.appendChild(label)
-    return { root, icon: svg }
+    // 聚合标签同样挂在**独立图层**(2026-10-28):与设备/点位标签一致,永远盖在图标之上。
+    let labelWrap = null
+    let labelBox = null
+    const labelText = `聚合 ${row.k} 台${row.label ? ` · 组内${row.label}` : ''}`
+    if (labelLayer) {
+        labelWrap = document.createElement('div')
+        labelWrap.className = LABEL_WRAP_CLASS
+        labelWrap.style.cssText = 'position:absolute;left:0;top:0;width:0;height:0;pointer-events:none;'
+        labelBox = document.createElement('div')
+        labelBox.className = LABEL_CLASS
+        labelBox.textContent = labelText
+        labelBox.title = labelText
+        labelBox.style.cssText = [
+            'position:absolute',
+            `bottom:${h + pad + 3}px`,
+            'left:0',
+            'transform:translateX(-50%)',
+            'box-sizing:content-box',
+            'max-width:170px',
+            'overflow:hidden',
+            'white-space:nowrap',
+            'text-overflow:ellipsis',
+            'padding:4px 9px',
+            'border-radius:8px',
+            'border:1px solid rgba(1,171,234,0.55)',
+            'background:rgba(2,20,40,0.82)',
+            'color:#d7ecff',
+            'font:600 12px "Microsoft YaHei UI","Microsoft YaHei","PingFang SC","Noto Sans SC",sans-serif',
+            'pointer-events:none',
+        ].join(';')
+        labelWrap.appendChild(labelBox)
+        labelLayer.appendChild(labelWrap)
+    }
+    return { root, icon: svg, labelWrap, labelBox, labelMaxWidth: '170px' }
 }
 
 /**
  * 自绘水滴母版(bbox 坐标系:x∈[0,378]、y∈[0,475],尖端 (188.5,474))。
- * viewBox 外扩 40(同 billboard OUTLINE_PAD 裁剪窗)→ "-40 -40 458 555"。
+ * viewBox 外扩 = 单源 `MASTER_SOURCE.outlinePad`(40)推导(不再写死字面量)。
  *
  * 描边(浏览器走查修正):billboard 的 `withWhiteOutline` 是**向外膨胀** 28 源像素、
  * 母版本身保持 378 全宽;SVG `stroke` 默认沿路径**中心线**(外 14 / 内 14)会把彩色母版
  * 蚀掉一圈、白环也只出一半——同尺寸下 DOM 标记明显比 billboard 小。
  * 用 `paint-order="stroke fill"` 把描边压到填充**之下**:彩色母版全尺寸保留,
- * 白色纯外扩 `stroke-width/2` = 28 → 与 billboard 的 OUTLINE_WIDTH 同值(外沿 28 < pad 40 不被裁)。
+ * 白色纯外扩 `stroke-width/2` = 28 → 与 billboard 的描边同值(外沿 28 < pad 40 不被裁)。
  */
 const PIN_OUTLINE = 'M188.5 0A189 189 0 0 0 111.6 361.7C157.3 382 173.6 435.5 188.5 474C203.4 435.5 219.7 382 265.4 361.7A189 189 0 0 0 188.5 0Z'
 const PIN_HOLE = 'M263.5 189a75 75 0 1 0-150 0 75 75 0 1 0 150 0Z'
-const PIN_STROKE = 'stroke="#ffffff" stroke-width="56" stroke-linejoin="round" paint-order="stroke fill"'
+// 描边宽度 = 源描边外扩 ×2(stroke 沿路径中心线,向外外扩一半)——单源不在字符串里写死 56。
+const PIN_STROKE = `stroke="#ffffff" stroke-width="${MASTER_SOURCE.outlineWidth * 2}" stroke-linejoin="round" paint-order="stroke fill"`
+/** SVG viewBox:bbox 四周外扩描边 pad(与 billboard 裁剪窗同形)。 */
+const MARKER_VIEWBOX = `${-MASTER_SOURCE.outlinePad} ${-MASTER_SOURCE.outlinePad} ${MASTER_SOURCE.bboxWidth + MASTER_SOURCE.outlinePad * 2} ${MASTER_SOURCE.bboxHeight + MASTER_SOURCE.outlinePad * 2}`
 
 function markerSvg(master, toneHex) {
     const body = master === 'hollow'
         ? `<path d="${PIN_OUTLINE}${PIN_HOLE}" fill="${toneHex}" fill-rule="evenodd" ${PIN_STROKE}/>`
         : `<path d="${PIN_OUTLINE}" fill="${toneHex}" ${PIN_STROKE}/>`
-    return `<svg viewBox="-40 -40 458 555" width="100%" height="100%" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">${body}<image data-marker-overlay-inner="" x="0" y="0" width="0" height="0"/></svg>`
+    return `<svg viewBox="${MARKER_VIEWBOX}" width="100%" height="100%" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">${body}<image data-marker-overlay-inner="" x="0" y="0" width="0" height="0"/></svg>`
 }
 
 /** 节点池:key → { root, inner, fp };标记消失时节点隐藏不销毁,计划读数变化时重建。 */
@@ -352,13 +496,24 @@ const FP_SEP = '\u0001'
 let hostNode = null
 let hostRecord = null
 let postRenderRemove = null
+/** 标签独立图层(宿主子节点);`null` = 尚未挂载。 */
+let labelLayer = null
+/**
+ * 当前被指针悬停的节点 key(2026-10-28)。
+ *
+ * 为什么由 JS 跟踪而不是 CSS `:hover`:标签搬进独立图层后不再是节点的后代,
+ * `节点:hover .标签` 这类选择器已经匹配不到;而"hover 才显示全名"是维护者规格的一部分。
+ */
+let hoveredLabelKey = ''
+/** 当前选中对象编码(帧状态给入;非聚合设备点的选择态高亮用)。 */
+let markerSelectedCode = ''
 /** 临时 A/B 开关(票 47 走查):false = 隐藏 DOM 层、露出底下 billboard(之前版本)。 */
 // 票 76(C6):A/B 显隐开关已退役 —— overlay 挂载即生效,billboard 视觉不再有「对照组」。
 
 /**
  * 单个标记节点(特效层 + 标记 SVG + 可选标签),一次构建、每帧只更新 transform。
- * @param {object|null} fx `fxOf(marker)` 的产出(null = 无动效;关联设备恒 null——
- *   它们的特效留在 billboard 层,DOM 不画第二套环,同位双环会糊,票 72 遗留决策一约束)。
+ * @param {object|null} fx `fxOf(marker)` / 设备门控的产出(null = 无动效)。
+ *   票 84 起三类对象(监测点 / 关联设备 / 未关联设备)都按**自身状态**挂环,环的视觉唯一载体是 DOM。
  */
 function buildNode(key, marker, plan, toneHex, fx) {
     const root = document.createElement('div')
@@ -372,11 +527,19 @@ function buildNode(key, marker, plan, toneHex, fx) {
     root.dataset.markerAnchor = `${plan.anchor.x},${plan.anchor.y}`
     root.dataset.markerDesaturate = plan.desaturate ? 'yes' : 'no'
     root.dataset.markerTone = toneHex
+    // 几何读数面(票 83):本体宽 / 绘制宽 / pad —— 由合成计划单源摊平,不写死数值。
+    Object.assign(root.dataset, markerGeometryDataset(plan))
     // 特效读数面(票 70 重做 / t45 C3d):无动效不挂键;挂 = 动效键 + profile 层数 + alpha 真值。
+    // 票 83 追加 `markerRing`(yes/no):三类对象统一用这一个键读「有没有环」。
+    root.dataset.markerRing = fx ? 'yes' : 'no'
     if (fx) {
         root.dataset.markerEffect = fx.effect
         root.dataset.markerEffectLayers = String(markerEffectLayers(fx.profile))
-        root.dataset.markerEffectAlpha = String(fx.profile.alpha)
+        // 票 85:读数写**生效值**(降级时已乘收敛系数),走查不必自己再算。
+        const alphaKnobFloor = markerDevKnobs().alphaFloor
+        const alphaEffective = Math.max(alphaKnobFloor, fx.converge ? fx.profile.alpha * fx.converge.alpha : fx.profile.alpha)
+        root.dataset.markerEffectAlpha = String(Number(alphaEffective.toFixed(3)))
+        root.dataset.markerEffectConverged = fx.converge ? 'yes' : 'no'
     }
 
     const w = plan.size.width
@@ -398,8 +561,9 @@ function buildNode(key, marker, plan, toneHex, fx) {
 
     // 特效层在标记本体之下(先挂 fx、SVG 后画盖住环心,环带在图标外扩散可见)。
     // 锚点是节点原点即尖端落地点(buildFxLayers 的 -box/2 偏移保证中心=原点)。
-    if (fx) {
-        root.appendChild(buildFxLayers(fx, plan, toneHex))
+    const fxHost = fx ? buildFxLayers(fx, plan, toneHex, key) : null
+    if (fxHost) {
+        root.appendChild(fxHost)
     }
 
     const wrap = document.createElement('div')
@@ -431,15 +595,21 @@ function buildNode(key, marker, plan, toneHex, fx) {
         innerEl.setAttribute('height', String(diameter))
     }
 
-    // 标签:真 DOM。pointer-events:none —— 不拦截事件,相机拖拽与 Canvas 拾取照常穿过。
-    // 限宽取计划层 `labelMaxWidth`(宽 × 2.6,billboard 同一单源)——浏览器走查修正:
-    // 首版硬编码 220px,比 billboard 截断宽 3 倍,两层标签并现时一长一短明显不一致。
-    if (marker.name) {
-        const label = document.createElement('div')
-        label.className = LABEL_CLASS
-        label.textContent = marker.name
-        label.title = marker.name // hover 读全(与 DOM 侧 EllipsisText 同语义)
-        label.style.cssText = [
+    // 标签:真 DOM,且**挂在独立图层**(2026-10-28 维护者报「多点位时标签被图标遮挡」)。
+    // 节点自身带 `transform`(定位)→ 自成层叠上下文 → 标签留在节点内部就永远盖不到别的节点之上。
+    // 限宽取计划层 `labelMaxWidth`(宽 × 2.6,billboard 同一单源);hover 时放开限宽 = 显示全名。
+    let labelWrap = null
+    let labelBox = null
+    if (marker.name && labelLayer) {
+        labelWrap = document.createElement('div')
+        labelWrap.className = LABEL_WRAP_CLASS
+        // 零尺寸锚点:位移与节点同一次 transform,标签几何靠内层 `bottom/translateX(-50%)` 表达。
+        labelWrap.style.cssText = 'position:absolute;left:0;top:0;width:0;height:0;pointer-events:none;'
+        labelBox = document.createElement('div')
+        labelBox.className = LABEL_CLASS
+        labelBox.textContent = marker.name
+        labelBox.title = marker.name // hover 读全(与 DOM 侧 EllipsisText 同语义)
+        labelBox.style.cssText = [
             'position:absolute',
             `bottom:${h + pad + 3}px`,
             'left:0',
@@ -458,10 +628,11 @@ function buildNode(key, marker, plan, toneHex, fx) {
             'font:600 12px "Microsoft YaHei UI","Microsoft YaHei","PingFang SC","Noto Sans SC",sans-serif',
             'pointer-events:none',
         ].join(';')
-        root.appendChild(label)
+        labelWrap.appendChild(labelBox)
+        labelLayer.appendChild(labelWrap)
     }
 
-    return { root, inner: innerEl, svg }
+    return { root, inner: innerEl, svg, fxHost, labelWrap, labelBox, labelMaxWidth: `${plan.labelMaxWidth}px` }
 }
 
 /**
@@ -484,6 +655,19 @@ export function mountMarkerOverlay(mapHost, { onPick, onHover } = {}) {
             'z-index:1',
         ].join(';')
         hostNode.style.display = ''
+        // 票 70(2026-10-28 维护者报「多点位时标签被坐标点图标遮挡」):
+        // 标签必须**独立成层**,不能留在节点内部 —— 节点带 `transform`(定位用)因而自成层叠上下文,
+        // 其子元素无论 z-index 多高都出不了这个上下文,于是后画的节点会盖住先画节点的标签。
+        // 这一层铺满宿主、`pointer-events:none`,层级高于所有节点(含 `--center` 的 3)。
+        labelLayer = document.createElement('div')
+        labelLayer.className = LABEL_LAYER_CLASS
+        labelLayer.style.cssText = [
+            'position:absolute', 'inset:0',
+            'pointer-events:none',
+            'z-index:6',
+        ].join(';')
+        // 标签层是宿主的子节点、**先挂**(节点后追加不影响 —— 层级由 z-index 决定)。
+        hostNode.appendChild(labelLayer)
         // 票 76(C4 parity 现场把手):DOM 单层比 billboard 双层暗(镂空孔/边缘不再叠亮层)。
         // `?pinBrightness=1.2` 只做整体亮度补偿(不碰色相/饱和度);定档后写成默认常量并撤掉把手。
         try {
@@ -529,9 +713,12 @@ export function mountMarkerOverlay(mapHost, { onPick, onHover } = {}) {
             if (typeof onHover !== 'function') return
             const node = event.target && event.target.closest ? event.target.closest(`.${NODE_CLASS}`) : null
             if (!node) {
+                hoveredLabelKey = ''
                 onHover(null)
                 return
             }
+            // 2026-10-28:hover 的节点 key 一并记下 —— 独立图层里的标签靠它决定「这一台是否展开全名」。
+            hoveredLabelKey = node.dataset ? (node.dataset.markerOverlayKey || '') : ''
             onHover({
                 kind: node.dataset.markerOverlayKind || '',
                 code: node.dataset.markerOverlayCode || '',
@@ -539,6 +726,7 @@ export function mountMarkerOverlay(mapHost, { onPick, onHover } = {}) {
         }
         hostNode.addEventListener('pointermove', emitHover)
         hostNode.addEventListener('pointerleave', () => {
+            hoveredLabelKey = ''
             if (typeof onHover === 'function') onHover(null)
         })
         mapHost.appendChild(hostNode)
@@ -580,10 +768,106 @@ export function isMarkerOverlayVisible() {
  *   group 身份/计数/组内状态词/形状/色键/屏幕坐标 —— 唯一没有设备身份的一类节点)
  * @param {Function} params.projectToWindow (lon, lat) → 宿主相对窗口坐标(贴地口径由调用方保证)
  */
-export function syncMarkerOverlay({ markers, devices, aggregates, pointScales, tones, planOf, effectOf, projectToWindow }) {
+/**
+ * 计划内但本轮画不出来的节点(坐标非法 / 投影失败)也要隐藏。
+ *
+ * 为什么必须显式隐藏:它们仍在 `wanted` 里,不会被池级隐藏循环收走;若继续 `continue`,
+ * 节点会停在最后一次成功投影的位置 —— 与"被筛掉却定格"是同一类幽灵标记缺陷。
+ */
+function hidePooledNode(key) {
+    const record = pool.get(key)
+    if (!record) return
+    hideRecord(record)
+}
+
+/** 隐藏一条记录(节点 + 标签,标签在独立图层里必须一起收)。 */
+function hideRecord(record) {
+    if (!record) return
+    if (record.root && record.root.style.display !== 'none') record.root.style.display = 'none'
+    if (record.labelWrap && record.labelWrap.style.display !== 'none') record.labelWrap.style.display = 'none'
+}
+
+/** 销毁一条记录的 DOM(节点 + 标签锚点)。重建时必须两个都清,否则标签会留在图层里变幽灵。 */
+function destroyRecord(record) {
+    if (!record) return
+    if (record.root && record.root.parentNode) record.root.parentNode.removeChild(record.root)
+    if (record.labelWrap && record.labelWrap.parentNode) record.labelWrap.parentNode.removeChild(record.labelWrap)
+}
+
+/**
+ * 标签显隐与「hover 展开全名」(2026-10-28 维护者规格)。
+ *
+ * 规则只有一个出口,调用方按**状态**给两个布尔量,渲染层不自己判状态:
+ *   · 聚合点:① 常态显示;① hover 预览 / ② 被选择 / ③④ 被替换 → 隐藏。
+ *   · 圈内设备(fan):常态一律隐藏;**只有被 hover 的那一台**显示(且展开为全名)。
+ *   · 中心设备:③ 设备常态显示;④ 设备被选择隐藏(与 ② 同性质)。
+ *   · 非聚合设备点(k=1 关联设备 / 独立未关联设备):**始终显示**(截断),hover 展开全名。
+ */
+function applyLabelState(record, visible, expanded) {
+    if (!record || !record.labelWrap || !record.labelBox) return
+    const wantedDisplay = visible === true ? 'block' : 'none'
+    if (record.labelWrap.style.display !== wantedDisplay) record.labelWrap.style.display = wantedDisplay
+    if (!visible) return
+    const wantedWidth = expanded === true ? 'none' : record.labelMaxWidth
+    if (record.labelBox.style.maxWidth !== wantedWidth) record.labelBox.style.maxWidth = wantedWidth
+}
+
+/** 标签跟随节点位移(与节点同一次 transform;不逐帧读布局)。 */
+function placeLabel(record, x, y) {
+    if (!record || !record.labelWrap) return
+    const transform = `translate(${x}px, ${y}px)`
+    if (record.labelWrap.style.transform !== transform) record.labelWrap.style.transform = transform
+}
+
+// ================================================================ 标签判据(纯函数,唯一住址)
+//
+// 2026-10-28 维护者给出的状态机把标签规则讲清楚了,而这块此前在三轮走查里反复翻烧饼
+// (圈内一律不建 → 圈内也要 → 常态隐藏、hover 显示)。所以判据**抽成纯函数**:
+// 渲染层只调用,不在模板/CSS 里再写一遍;闸门可以离线断言,不必起浏览器。
+//
+// | 对象 | 常态 | hover | 备注 |
+// | --- | --- | --- | --- |
+// | 聚合点(聚合标签) | ① 显示 | 隐藏(并展开预览环) | ② 固定 / ③④ 被替换也隐藏 |
+// | 圈内设备(fan) | 隐藏 | **显示全名**(仅被 hover 的那一台) | 环内密集,常态不显示 |
+// | 中心设备 | ③ 显示 | 显示全名 | ④ 被选择 → 隐藏(与 ② 同性质) |
+// | 非聚合设备点 / 点位 / 未关联设备 | 显示(截断) | 显示全名 | 聚合规则不得外溢到这里 |
+
+/**
+ * 聚合点标签:只有 ① 常态(未预览、未固定、未被设备替换)才显示。
+ * @param {{pinned?:boolean, preview?:boolean, replaced?:boolean}} row 引擎投影行
+ */
+export function aggregateLabelState(row) {
+    const item = row || {}
+    const visible = item.pinned !== true && item.preview !== true && item.replaced !== true
+    return { visible, expanded: false }
+}
+
+/**
+ * 关联设备点标签。
+ * @param {{fan?:boolean, pinned?:boolean}} device 引擎投影行
+ * @param {boolean} hovered 指针是否在该节点上
+ */
+export function deviceLabelState(device, hovered) {
+    const item = device || {}
+    if (item.fan === true) return { visible: hovered === true, expanded: hovered === true }
+    if (item.pinned === true) return { visible: false, expanded: false }
+    return { visible: true, expanded: hovered === true }
+}
+
+/** 点位 / 未关联设备(摘要标记):**始终显示**,hover 展开全名。 */
+export function markerLabelState(hovered) {
+    return { visible: true, expanded: hovered === true }
+}
+
+export function syncMarkerOverlay({ markers, devices, aggregates, pointScales, tones, planOf, effectOf, deviceEffectOf, converge, selectedCode, projectToWindow }) {
     if (!hostNode || !hostNode.isConnected || typeof projectToWindow !== 'function') return
     // 每帧刷新门控查询(mapService 在计划变化时给新闭包;这里只搬运、不判定)。
     frameEffectOf = typeof effectOf === 'function' ? effectOf : () => ''
+    frameDeviceEffectOf = typeof deviceEffectOf === 'function' ? deviceEffectOf : () => ''
+    frameConverge = converge || null
+    // 当前选中的对象编码(2026-10-28):**非聚合设备点**选中时要上「被选择态」同款高亮,
+    // 而聚合设备的中心由引擎的 pinned 标志负责;两者语义不同,故这里另收一份。
+    markerSelectedCode = typeof selectedCode === 'string' ? selectedCode : ''
     // 票 35 卡顿整改:Set 复用(原先每帧 new 一个);指纹改成廉价拼接(原先每标记
     // JSON.stringify 一次,纯分配)。DOM 写入也改成"只有真的变了才写"。
     wantedKeys.clear()
@@ -598,10 +882,10 @@ export function syncMarkerOverlay({ markers, devices, aggregates, pointScales, t
     for (const marker of markers || []) {
         const key = `${marker.kind}|${marker.code}`
         wanted.add(key)
-        if (!Number.isFinite(marker.longitude) || !Number.isFinite(marker.latitude)) continue
+        if (!Number.isFinite(marker.longitude) || !Number.isFinite(marker.latitude)) { hidePooledNode(key); continue }
 
         const windowPos = projectToWindow(marker.longitude, marker.latitude)
-        if (!windowPos || !Number.isFinite(windowPos.x) || !Number.isFinite(windowPos.y)) continue
+        if (!windowPos || !Number.isFinite(windowPos.x) || !Number.isFinite(windowPos.y)) { hidePooledNode(key); continue }
 
         const plan = planOf(marker)
         const toneHex = tones[marker.tone] || tones.neutral
@@ -614,7 +898,7 @@ export function syncMarkerOverlay({ markers, devices, aggregates, pointScales, t
 
         let record = pool.get(key)
         if (!record || record.fp !== fp) {
-            if (record && record.root.parentNode) record.root.parentNode.removeChild(record.root)
+            destroyRecord(record)
             record = { ...buildNode(key, marker, plan, toneHex, fx), fp }
             pool.set(key, record)
             hostNode.appendChild(record.root)
@@ -639,7 +923,23 @@ export function syncMarkerOverlay({ markers, devices, aggregates, pointScales, t
         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
+        // 票 84:选中态环整体放大 —— boost 与图标强调共用 EMPHASIS_STYLE.markerScale(同一个 1.55)。
+        if (record.fxHost) {
+            const base = Number(record.fxHost.dataset.fxBaseScale || 1)
+            const factor = base * (markerScale && markerScale !== 1 ? markerScale : 1)
+            const fxTransform = factor !== 1 ? `scale(${factor})` : ''
+            if (record.fxHost.style.transform !== fxTransform) record.fxHost.style.transform = fxTransform
+        }
         if (record.root.style.display !== 'block') record.root.style.display = 'block'
+        // 标签(2026-10-28):点位与未关联设备**始终显示**(截断),hover 才展开全名;
+        // 若它是当前选中对象(独立设备被选中)则由下面的 `--active` 正규负责发光,标签仍在。
+        placeLabel(record, windowPos.x, windowPos.y)
+        const markerLabel = markerLabelState(hoveredLabelKey === key)
+        applyLabelState(record, markerLabel.visible, markerLabel.expanded)
+        // 选择态发光(2026-10-28 维护者规格④):**非聚合的设备点**(独立未关联设备)被选中时,
+        // 用与「被选择态」同款的 --active 高亮。聚合设备中心由设备循环的 pinned 负责。
+        const markerActive = marker.kind === 'unassociated-equipment' && markerSelectedCode !== '' && marker.code === markerSelectedCode
+        record.root.classList.toggle(`${NODE_CLASS}--active`, markerActive)
     }
 
     // 关联设备(票 47 维护者反馈②根因:设备是独立 billboard 图层,此前 DOM 只渲染
@@ -648,17 +948,15 @@ export function syncMarkerOverlay({ markers, devices, aggregates, pointScales, t
     for (const device of devices || []) {
         const key = device.key || `device|${device.code}`
         wanted.add(key)
-        if (!Number.isFinite(device.x) || !Number.isFinite(device.y)) continue
+        if (!Number.isFinite(device.x) || !Number.isFinite(device.y)) { hidePooledNode(key); continue }
 
-        // 票 76(标签规格 2026-10-26 终版,维护者裁定):**圈内设备不建顶部标签**。
-        // 理由:环内设备密集,顶部标签互相压叠且会被图案/特效淹没;它们的全名由右下角
-        // tooltip(`paintMarkerTooltip`)承担,一份就够。
-        // 实现:`fan` 行直接把 name 置空 → `buildNode` 不创建 label 节点(比 CSS 隐藏更彻底,
-        // 也不会因为后续 `--fan` 类切换而重新冒出来)。
+        // 2026-10-27 维护者走查修正:圈内子设备点**也要顶部标签**(此前被置空 + CSS 隐藏,
+        // 表现为"关联监测点的子设备点没有名称")。标签与普通设备点同一条渲染路径。
         const marker = {
             kind: 'associated-device',
             code: device.code,
-            name: device.fan === true ? '' : (device.group ? (device.name || '') : ''),
+            // 2026-10-27 走查修正:子设备点(fan)也要顶部标签 —— 不再清空名称。
+            name: device.name || '',
             status: device.status || '',
             industry: device.industry || '',
         }
@@ -666,21 +964,20 @@ export function syncMarkerOverlay({ markers, devices, aggregates, pointScales, t
         // 票 76(2026-10-26):色键由**设备状态**决定(domain 单源)——设备行不单独携带 tone,
         // 否则状态色与摘要标记两套口径。
         const toneHex = tones[deviceVisualForStatus(device.status).tone] || tones.neutral
-        // 票 70(C3c 语义):关联设备循环**恒不画 DOM 环**(fx 恒 null)——它们的特效留在
-        // billboard 层(ensureRingEntities 链路),同位双环会糊。
-        // 票 76(特效归属,维护者裁定 A):聚合本身不挂环;**只有中心(被选中)设备**按自身状态挂环。
-        // 2026-10-26 修:`DEVICE_EFFECT` 是**动效键名表**(PULSE/BLINK/SPIN),不是「状态 → 动效」映射 ——
-        // 此前写成 `DEVICE_EFFECT[device.status]` 恒为 undefined,中心设备因此一直没有特效。
+        // 票 84(取代票 76 C6 的「只有中心设备挂环」):关联设备按**自身状态**挂环,
+        // 且必须走与摘要标记同一条**门控**(`frameDeviceEffectOf`:关特效/降级时一律不挂)。
+        // 聚合符号仍恒不挂环(聚合是"一组",不冒充设备告警身份)。
         // 状态 → profile 的唯一入口是 `effectProfileFor()`(domain 单源)。
-        const profile = device.selected ? effectProfileFor(device.status) : null
-        const fx = profile ? { effect: profile.effect, profile } : null
+        const gatedEffect = frameDeviceEffectOf(device)
+        const profile = gatedEffect ? effectProfileFor(device.status) : null
+        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)
 
         let record = pool.get(key)
         if (!record || record.fp !== fp) {
-            if (record && record.root.parentNode) record.root.parentNode.removeChild(record.root)
+            destroyRecord(record)
             record = { ...buildNode(key, marker, plan, toneHex, fx), fp }
             pool.set(key, record)
             hostNode.appendChild(record.root)
@@ -704,13 +1001,37 @@ export function syncMarkerOverlay({ markers, devices, aggregates, pointScales, t
         if (record.svg.style.transform !== deviceScale) record.svg.style.transform = deviceScale
         const deviceOpacity = device.alpha !== undefined && device.alpha !== 1 ? String(device.alpha) : ''
         if (record.root.style.opacity !== deviceOpacity) record.root.style.opacity = deviceOpacity
+        // 票 84:选中(中心)设备的环整体放大 —— 与图标强调共用同一个 1.55 常量。
+        if (record.fxHost) {
+            const base = Number(record.fxHost.dataset.fxBaseScale || 1)
+            const factor = base * (device.selected ? markerDevKnobs().selectedBoost : 1)
+            const fxTransform = factor !== 1 ? `scale(${factor})` : ''
+            if (record.fxHost.style.transform !== fxTransform) record.fxHost.style.transform = fxTransform
+        }
         if (record.root.style.display !== 'block') record.root.style.display = 'block'
-        // 票 76(标签规格):圈内设备(`fan`)标签改 hover-only;中心设备与普通设备点维持常态标签。
+        // ==================== 标签与选择态(2026-10-28 维护者状态机)====================
+        // 标签规则(唯一出口 applyLabelState;这里只按状态给两个布尔量):
+        //   · 圈内设备(fan,处于「环可见」的 ①hover预览/②固定/③hover预览/④固定」):
+        //     常态**一律隐藏**,只有被 hover 的那一台显示并展开全名 —— 你报的「环展开时顶部标签没隐藏」。
+        //   · ③ 设备常态的中心设备:显示(它此时就是一枚正常设备点);hover 展开全名。
+        //   · ④ 设备被选择的中心设备:隐藏(与 ② 聚合被选择态同性质)。
+        //   · 非聚合设备点(k=1 关联设备):**始终显示** —— 聚合规则不得外溢到它们。
+        placeLabel(record, device.x - hostOffset.x, device.y - hostOffset.y)
+        const isHovered = hoveredLabelKey === key
+        const deviceLabel = deviceLabelState(device, isHovered)
+        applyLabelState(record, deviceLabel.visible, deviceLabel.expanded)
+        // 圈内设备标记(CSS/走查读这个类;标签显隐已由上面统一驱动,不再靠 CSS 后代选择器)。
         record.root.classList.toggle(`${NODE_CLASS}--fan`, device.fan === true)
         // 票 76(四态规格 2026-10-26)设备节点:
-        //   ③ 设备常态      中心=设备点,无发光,环只在 hover 预览(`fan` 标签 hover-only)
+        //   ③ 设备常态      中心=设备点,无发光,环只在 hover 预览(标签常态显示)
         //   ④ 设备被选择态  与聚合点被选择态相同:状态色发光 + 隐藏自身标签
-        record.root.classList.toggle(`${NODE_CLASS}--active`, device.pinned === true)
+        // 2026-10-28:非聚合设备点(k=1 关联设备)被选中时也要上**同款高亮** ——
+        // 走的是「设备选择事件」这条统一逻辑,与聚合规则无关。
+        // 注意排除 `device.selected`(③ 的中心设备**正是**当前选中对象,但它不是选择态、不发光)、
+        // 以及 `fan`(圈内设备的高亮由所在组的 `pinned` 决定)。
+        const deviceActive = device.pinned === true
+            || (markerSelectedCode !== '' && device.code === markerSelectedCode && device.selected !== true && device.fan !== true)
+        record.root.classList.toggle(`${NODE_CLASS}--active`, deviceActive)
         // 票 76(四态,2026-10-26):③④ 的中心设备要**压过聚合节点**(聚合节点后追加、会吃掉点击),
         // 否则「点中心设备进 ④」永远触发不了(走查第 3 步完全失效的直接原因)。
         record.root.classList.toggle(`${NODE_CLASS}--center`, device.selected === true)
@@ -721,7 +1042,7 @@ export function syncMarkerOverlay({ markers, devices, aggregates, pointScales, t
     for (const row of aggregates || []) {
         const key = row.key || `overlap|${row.pointCode}|${row.group}`
         wanted.add(key)
-        if (!Number.isFinite(row.x) || !Number.isFinite(row.y)) continue
+        if (!Number.isFinite(row.x) || !Number.isFinite(row.y)) { hidePooledNode(key); continue }
 
         const toneHex = tones[row.tone] || tones.neutral
         // 票 76(统一设备点位渲染):聚合符号也走 `planOf` 合成计划(母版/内层/几何与设备点同源),
@@ -733,7 +1054,7 @@ export function syncMarkerOverlay({ markers, devices, aggregates, pointScales, t
         ].join(FP_SEP)
         let record = pool.get(key)
         if (!record || record.fp !== fp) {
-            if (record && record.root.parentNode) record.root.parentNode.removeChild(record.root)
+            destroyRecord(record)
             record = { ...buildAggregateNode(key, row, toneHex, plan), fp }
             pool.set(key, record)
             hostNode.appendChild(record.root)
@@ -752,22 +1073,22 @@ export function syncMarkerOverlay({ markers, devices, aggregates, pointScales, t
         if (record.icon.style.filter !== aggFilter) record.icon.style.filter = aggFilter
         record.root.classList.toggle(`${NODE_CLASS}--active`, active)
         // 票 76(四态):① 的 hover 预览与 ② 的固定态都隐藏聚合标签(让出设备环);
-        // ③④ 中心已是设备点 → 整枚聚合节点让位。
-        const labelHidden = row.pinned === true || row.preview === true
-        const label = record.root.querySelector(`.${LABEL_CLASS}`)
-        if (label) {
-            const wantedDisplay = labelHidden ? 'none' : 'block'
-            if (label.style.display !== wantedDisplay) label.style.display = wantedDisplay
-        }
+        // ③④ 中心已是设备点 → 整枚聚合节点让位(标签随之隐藏)。
+        // 2026-10-28:标签在**独立图层**,显隐由 `applyLabelState` 统一驱动(不再内联查 DOM 子元素)。
+        placeLabel(record, row.x - hostOffset.x, row.y - hostOffset.y)
+        const aggregateLabel = aggregateLabelState(row)
+        applyLabelState(record, aggregateLabel.visible, aggregateLabel.expanded)
         record.root.style.display = row.replaced ? 'none' : 'block'
         // ③④ 中心已是设备点:聚合节点虽然隐藏,但保险起见再降掉 pointer-events,
         // 避免它(或其子元素)在过渡帧里吃掉对中心设备的点击。
         record.root.classList.toggle(`${NODE_CLASS}--replaced`, row.replaced === true)
     }
 
-    // 本轮不在计划里的:隐藏(池复用,不销毁)
+    // 本轮不在计划里的:隐藏(池复用,不销毁)。
+    // ⚠️ 这一轮**必须**在所有计划遍历之后:放进某个循环里会漏掉「点位/设备被筛掉」的节点,
+    // 它们会停在最后一次投影位置变成幽灵标记(2026-10-27 实测缺陷,勿再合并进循环)。
     for (const [key, record] of pool.entries()) {
-        if (!wanted.has(key) && record.root.style.display !== 'none') record.root.style.display = 'none'
+        if (!wanted.has(key)) hideRecord(record)
     }
 }
 
@@ -791,6 +1112,35 @@ export function overlayPoolSize() {
     return pool.size
 }
 
+/** 某个池节点的 display 读数('' / 'block' / 'none')——幽灵标记回归闸门读它。 */
+export function overlayNodeDisplayOf(key) {
+    const record = pool.get(key)
+    return record ? String(record.root.style.display || '') : ''
+}
+
+/** 某个池节点的标签文本(子设备点标签回归闸门读它)。 */
+export function overlayNodeLabelTextOf(key) {
+    const record = pool.get(key)
+    if (!record) return ''
+    // 2026-10-28:标签已搬进独立图层(`record.labelBox`),不再挂在节点内部。
+    return record.labelBox ? String(record.labelBox.textContent || '') : ''
+}
+
+/**
+ * 某个池节点标签的**显隐读数**('' 表示从未设置 / 'block' / 'none')。
+ * 走查与闸门用它断言「环展开时圈内设备标签确实隐藏、hover 那一台才显示」。
+ */
+export function overlayLabelDisplayOf(key) {
+    const record = pool.get(key)
+    return record && record.labelWrap ? String(record.labelWrap.style.display || '') : ''
+}
+
+/** 某个池节点标签是否处于**展开全名**(max-width 放开)状态。 */
+export function overlayLabelExpandedOf(key) {
+    const record = pool.get(key)
+    return Boolean(record && record.labelBox && record.labelBox.style.maxWidth === 'none')
+}
+
 /** 卸载(离开 /cockpit 用):摘 postRender、清节点池与宿主、移除特效样式(t45 C3b 生命周期)。 */
 export function unmountMarkerOverlay() {
     if (postRenderRemove) {
@@ -804,5 +1154,9 @@ export function unmountMarkerOverlay() {
     if (hostNode && hostNode.parentNode) hostNode.parentNode.removeChild(hostNode)
     hostNode = null
     hostRecord = null
+    // 标签图层与 hover 记忆一并清掉(宿主已移除,引用留着只会拖住 DOM)。
+    labelLayer = null
+    hoveredLabelKey = ''
+    markerSelectedCode = ''
     pool.clear()
 }

+ 502 - 57
src/modules/cockpit/services/regionLoading.js

@@ -1,4 +1,143 @@
-/** Use real content footprints: a broad region may be visible while every building is outside. */
+/**
+ * 细模区域调度(票 61:由近及远的静态渐进 + 移动期保帧率)。
+ *
+ * ## 为什么重写选择器
+ *
+ * 票 59 之后仍能看到「同样距离,一半是精模、一半是火柴盒」,甚至「更远的精细、
+ * 更近的却是火柴盒」。用真实 v7 manifest(113 个区域、半径中位 826m)回放旧算法,
+ * 原因是两条写死的闸门叠在一起:
+ *
+ *   1. `maxRegions = 2` —— 同时只允许 **2 个**区域换成精模。近景机位 700m 内有 4 个
+ *      区域、彼此到镜头距离几乎一样,注定有 2 个永远是火柴盒。**判据错在"个数"。**
+ *   2. `maxDistance = 700` —— 半径大到 1747m 的区域,其**表面**可能落在 700m 内,
+ *      而**中心**在 1.7km 外。按表面距排序时,一个"大而远"的区域会挤掉"小而近"的,
+ *      于是出现"更近的反而没精模"。
+ *
+ * 现在改成:**控制量是半径,不是个数**;半径由 `domain/detailFrontier.js` 按
+ * "镜头在动 / 内容在流 / 预算满没满"伸缩;排序改按**中心距**(真实远近)。
+ * 于是有两个可断言的不变量:
+ *
+ *   · **近不劣于远**:不存在「某区域表面更近却没被选中,而更远的被选中」(`active` 的
+ *     迟滞只作用在**半径**上,不作用在排序上,所以它永远不能翻转远近次序);
+ *   · **由近及远收敛**:镜头静止时半径按拍放大,直到 `maxRadius` 或预算耗尽。
+ */
+import { stepDetailFrontier, DETAIL_FRONTIER_DEFAULTS } from '../domain/detailFrontier.js'
+
+/** 预算默认档(可被 `map.detail` 覆盖)。解码几何估算 ≈ 常驻内存量级。 */
+export const REGION_BUDGET_DEFAULTS = {
+    /** 同时驻留的内容条数上限。 */
+    maxContents: 1200,
+    /** 同时驻留的解码几何字节上限。
+     *
+     * 这是**规划估算**(manifest 里按"整区全量加载"记),不是实际 GPU 驻留 —— 真实驻留由
+     * Cesium 的 `cacheBytes`(默认 64 MiB)单独封顶。给 384 MiB 是因为实测 decoded 估算
+     * 约为磁盘/GPU 的 ~4.5 倍,384 MiB 规划 ≈ 覆盖主城近景一圈;真机 60fps 有 headroom。
+     * 低配机要更省就在 `map.detail.budget.maxDecodedBytes` 里调低。 */
+    maxDecodedBytes: 384 * 1024 * 1024,
+}
+
+/** 前沿默认档(继续沿用 domain 那份,避免两处各写一套数字)。 */
+export const REGION_FRONTIER_DEFAULTS = { ...DETAIL_FRONTIER_DEFAULTS }
+
+function finiteOr(value, fallback) {
+    const num = Number(value)
+    return Number.isFinite(num) ? num : fallback
+}
+
+/**
+ * 排序键 = **内容表面到镜头的距离**(`distance`)。
+ *
+ * 为什么不是中心距:区域半径从 37m 到 1747m 相差近 50 倍,大区域的中心可能远在
+ * 1.7km 外,但它贴着镜头的那片内容就在脚下。真正决定"这块楼近不近"的是**内容表面**。
+ * 也刻意**不再**用旧实现那种 `distance + 0.15*centerDistance` 的加权混合:两个量纲
+ * 混在一起后排序不可解释,且 `active` 的 -150m 加分能直接压过 150m 的真实远近差。
+ */
+function sortKeyOf(region) {
+    const surface = Number(region.distance)
+    if (Number.isFinite(surface) && surface >= 0) return surface
+    const center = Number(region.centerDistance)
+    return Number.isFinite(center) && center >= 0 ? center : Infinity
+}
+
+/**
+ * 选出这一拍要换成精模的区域(按"由近及远"顺序)。
+ *
+ * @param {Array} regions 区域条目(`distance`=到内容球面的距离,`centerDistance`=到球心)
+ * @param {object} [options]
+ * @param {boolean} [options.pipeline] 管线视图:一个细模都不要(既有语义)
+ * @param {number} [options.now] 当前时刻(重试冷却用)
+ * @param {number} [options.radius] 覆盖前沿半径(米)
+ * @param {number} [options.retainFactor] 已加载区域的保留倍率(迟滞,避免边界抖动反复卸载)
+ * @param {number} [options.maxRegions] 兼容旧闸门的显式人数上限(默认不限)
+ */
+export function selectDetailRegions(regions, options = {}) {
+    const opts = options || {}
+    if (opts.pipeline === true) return []
+    const list0 = Array.isArray(regions) ? regions.filter(Boolean) : []
+    const now = finiteOr(opts.now, 0)
+    const radius = Math.max(1, finiteOr(opts.radius, 1200))
+    const retainFactor = Math.max(1, finiteOr(opts.retainFactor, 1.4))
+    const maxRegions = finiteOr(opts.maxRegions, Infinity)
+    const maxContents = finiteOr(opts.maxContents, REGION_BUDGET_DEFAULTS.maxContents)
+    const maxDecodedBytes = finiteOr(opts.maxDecodedBytes, REGION_BUDGET_DEFAULTS.maxDecodedBytes)
+
+    const candidates = list0.filter((region) => {
+        if (finiteOr(region.retryAt, 0) > now) return false
+        // 单区就超预算的条目永远装不下:先剔掉,免得它反复参与排序。
+        if (finiteOr(region.contentCount, 0) > maxContents) return false
+        if (finiteOr(region.decodedGeometryBytes, 0) > maxDecodedBytes) return false
+        // 已加载的区域给一点迟滞:边界上轻微移动不该把刚加载好的细模抖掉。
+        const reach = region.active === true ? radius * retainFactor : radius
+        return finiteOr(region.distance, Infinity) <= reach
+    })
+
+    // **先按中心距**排序:这才是"由近及远"。表面距只作为同中心距时的次序,
+    // 保证结果确定(等价于旧闸门对稳定顺序的期望)。
+    candidates.sort((a, b) => {
+        const ka = sortKeyOf(a), kb = sortKeyOf(b)
+        if (ka !== kb) return ka - kb
+        // 同表面距时按中心距(大区域贴边的情况里,中心更近的那块内容更"正对"镜头)。
+        const ca = finiteOr(a.centerDistance, Infinity), cb = finiteOr(b.centerDistance, Infinity)
+        if (ca !== cb) return ca - cb
+        return finiteOr(a.id, 0) - finiteOr(b.id, 0)
+    })
+
+    const selected = []
+    let contents = 0
+    let bytes = 0
+    for (const region of candidates) {
+        if (selected.length >= maxRegions) break
+        const nextContents = contents + finiteOr(region.contentCount, 0)
+        const nextBytes = bytes + finiteOr(region.decodedGeometryBytes, 0)
+        // 预算不够就**跳过**这一条,继续看后面的(后面可能更小、装得下)。
+        if (nextContents > maxContents || nextBytes > maxDecodedBytes) continue
+        selected.push(region.id)
+        contents = nextContents
+        bytes = nextBytes
+    }
+    return selected
+}
+
+/**
+ * 上一拍的选择是否已经吃满预算(吃满 ⇒ 前沿再放大也只是空转)。
+ * 单独成函数,便于闸门直接断言"满了就该停"。
+ */
+export function isSelectionSaturated(selectedRegions, options = {}) {
+    const opts = options || {}
+    const maxContents = finiteOr(opts.maxContents, REGION_BUDGET_DEFAULTS.maxContents)
+    const maxDecodedBytes = finiteOr(opts.maxDecodedBytes, REGION_BUDGET_DEFAULTS.maxDecodedBytes)
+    const list = Array.isArray(selectedRegions) ? selectedRegions : []
+    let contents = 0
+    let bytes = 0
+    for (const region of list) {
+        contents += finiteOr(region && region.contentCount, 0)
+        bytes += finiteOr(region && region.decodedGeometryBytes, 0)
+    }
+    // 95% 视为吃满:留一点余量给"再放一个很小的区域",避免在边界上永远判不满。
+    return contents >= maxContents * 0.95 || bytes >= maxDecodedBytes * 0.95
+}
+
+/** 内容真实覆盖的准入距离(保留原语义:按内容球面而非区域大球判定)。 */
 export function contentAdmission(spheres, visible, centerDistance) {
     let distance = Infinity, center = Infinity
     for (const sphere of spheres) {
@@ -8,65 +147,371 @@ export function contentAdmission(spheres, visible, centerDistance) {
     }
     return { distance, centerDistance: center }
 }
-/** Budget whole detail regions, never partial replacement of one coarse ownership set. */
-export function selectDetailRegions(regions, {pipeline=false,now=0,maxRegions=2,maxContents=180,maxDecodedBytes=96*1024*1024,maxDistance=700}={}) {
-    if(pipeline)return []
-    const list=regions.filter(r=>r.distance<=maxDistance+(r.active?100:0)&&r.retryAt<=now&&r.contentCount<=maxContents&&(r.decodedGeometryBytes||0)<=maxDecodedBytes)
-    const priority=r=>r.distance+0.15*(r.centerDistance||0)-(r.active?150:0)
-    list.sort((a,b)=>priority(a)-priority(b)||a.id-b.id)
-    const selected=[];let contents=0,bytes=0
-    for(const r of list){if(selected.length>=maxRegions)break;if(contents+r.contentCount>maxContents||bytes+(r.decodedGeometryBytes||0)>maxDecodedBytes)continue;selected.push(r.id);contents+=r.contentCount;bytes+=r.decodedGeometryBytes||0}
-    return selected
-}
-export function createRegionLoadingController({regions,load,add,remove,distance,beforeRefresh=()=>{},canStart=()=>true,requestRender=()=>{},onFailure=()=>{},now=()=>performance.now(),setTimer=setTimeout,clearTimer=clearTimeout}) {
-    const entries=regions.map(r=>({...r,phase:'pending',instance:null,loaded:new Set(),pending:0,processing:0,visible:false,visibleThisFrame:false,fineVisible:false,fineVisibleThisFrame:false,shown:false,retryAt:0,distance:Infinity}))
-    const byId=new Map(entries.map(e=>[e.id,e]))
-    let pipeline=false,disposed=false,timer=null,inflight=null
-    function schedule(){if(disposed||timer!==null)return;timer=setTimer(()=>{timer=null;refresh()},200)}
-    function mask(e,value){if(e.shown!==value){e.shown=value;requestRender()}}
-    function unload(e){mask(e,false);if(e.instance){e.detaches.forEach(d=>d());remove(e.instance)}e.instance=null;e.phase='pending';e.loaded.clear();e.visible=e.visibleThisFrame=e.fineVisible=e.fineVisibleThisFrame=false;e.pending=e.processing=0}
-    async function start(e){
-        if(inflight||disposed)return
-        const token={entry:e};inflight=token;e.phase='initializing'
-        try{const model=await load(e)
-            if(disposed||pipeline||!desired().includes(e.id)){remove(model);e.phase='pending';return}
-            e.instance=model;model.show=false;model.preloadWhenHidden=true;e.phase='streaming';e.detaches=[]
-            const listen=(event,fn)=>{if(event)e.detaches.push(event.addEventListener(fn))}
-            listen(model.tileLoad,t=>{const url=t.content?.url;if(url&&(!e.expectedContentUrls||e.expectedContentUrls.includes(url)))e.loaded.add(url);schedule()})
-            listen(model.tileUnload,t=>{if(t.content?.url)e.loaded.delete(t.content.url);mask(e,false);schedule()})
-            listen(model.tileVisible,t=>{if(t.content?.trianglesLength>0){e.visibleThisFrame=true;const url=t.content.url;if(e.expectedContentUrls?.includes(url)&&!e.expectedPreviewContentUrls?.includes(url))e.fineVisibleThisFrame=true}})
-            listen(model.loadProgress,(pending,processing)=>{e.pending=pending;e.processing=processing;if((pending||processing)&&!previewReady(e))mask(e,false);schedule()})
-            listen(model.tileFailed,error=>{mask(e,false);e.phase='failed';e.retryAt=now()+15000;onFailure(e,error);schedule()})
-            add(model,e);requestRender()
-        }catch(error){e.phase='failed';e.retryAt=now()+15000;mask(e,false);if(!disposed)onFailure(e,error)}
-        finally{if(inflight===token&&e.phase!=='streaming')inflight=null;schedule()}
+
+/**
+ * 细模区域加载控制器。
+ *
+ * 与旧版的差别(票 61):
+ *   · 选择器改**半径驱动**(`selectDetailRegions` 见上),不再是"最多 2 个";
+ *   · 加一条**前沿控制器**:镜头在动 → 收缩;静止且内容静默 → 按拍放大(由近及远);
+ *   · 前沿状态进 `snapshot()`,维护者能直接看到"现在覆盖到多远、为什么停"。
+ *
+ * ## 为什么不再严格串行
+ *
+ * 旧实现一次只允许 **一个** region 在装(`inflight` 单槽)。这条纪律本意是"不把上传尖峰
+ * 叠起来",但它和新的覆盖语义冲突:4 个同距离区域要排队装,每个几十条内容,排在后面的
+ * 能等上半分钟 —— 用户看到的就是"同一片楼,有的精模有的火柴盒"**持续很久**。
+ *
+ * 改成**有限并发**(默认 2):既把收敛时间砍半,又不至于把上传尖峰叠成一坨。
+ * 真正的峰值保护仍在别处:镜头在动时前沿收缩(少选)、内容在流时前沿不放大(不叠加)、
+ * 网络并发由 `RequestScheduler` 兜底。
+ */
+export function createRegionLoadingController({
+    regions, load, add, remove, distance,
+    beforeRefresh = () => {}, canStart = () => true, requestRender = () => {}, onFailure = () => {},
+    now = () => performance.now(), setTimer = setTimeout, clearTimer = clearTimeout,
+    frontier = null, budget = null, movingWindowMs = DETAIL_FRONTIER_DEFAULTS.moveWindowMs,
+    // 相机位姿指纹(入射方给:cesiumEngine 有 camera 对象)。缺省时退回"区域距离指纹",
+    // 但那只在相机真的改变了区域距离时才变,静止微动会漏判 —— 正式路径必须注入。
+    readCameraKey = null,
+    // 同时进行中的 region 装载数上限(见上方"为什么不再严格串行")。
+    maxConcurrentLoads = 2,
+}) {
+    const entries = regions.map(r => ({
+        ...r,
+        contentUrlIndex: r.expectedContentUrls ? new Set(r.expectedContentUrls) : null,
+        previewUrlIndex: new Set(r.expectedPreviewContentUrls || []),
+        phase: 'pending', instance: null, loaded: new Set(), pending: 0, processing: 0,
+        visible: false, visibleThisFrame: false, fineVisible: false, fineVisibleThisFrame: false,
+        shown: false, retryAt: 0, distance: Infinity, centerDistance: Infinity, active: false,
+        // previewSeen 与 loaded 是两份账:loaded 会被 tileUnload(视锥剔除/内存回收)扣减,
+        // previewSeen 是单调累计、永不清除 —— 用它判"这片 preview 曾经到过",不受剔除影响。
+        previewSeen: new Set(),
+        // 票 91:medium 层(五级 LOD 的中间级,ge=20)。它与 fine 是**两个不同的层**,
+        // "细模已渲染"的判定必须把 medium 排除,否则会在只画了中模时就撤掉粗模。
+        mediumUrlIndex: r.expectedMediumContentUrls ? new Set(r.expectedMediumContentUrls) : null,
+        // 上次「可用」/「真的渲染到屏幕上」的时刻:用于两个保持期(防闪烁,见 syncVisibility)。
+        usableAt: -Infinity,
+        coveredAt: -Infinity,
+    }))
+    const byId = new Map(entries.map(e => [e.id, e]))
+
+    const budgetOptions = { ...REGION_BUDGET_DEFAULTS, ...(budget || {}) }
+    const frontierOptions = { ...REGION_FRONTIER_DEFAULTS, ...(frontier || {}) }
+
+    // 初值取 -Infinity:注入时钟从 0 开始时(闸门/测试常见),若初值为 0 则
+    // `at - last < 180` 恒成立,onRender 会被永久挡住、detailReady 永远是 false。
+    let pipeline = false, disposed = false, timer = null, lastRenderRefreshAt = -Infinity
+    /** 正在装载(initializing/streaming 且未完成)的 region 集合;空 = 可以再开新的。 */
+    const loading = new Set()
+    const loadLimit = Math.max(1, Math.trunc(finiteOr(maxConcurrentLoads, 2)))
+    /**
+     * 到位状态的**保持期**(毫秒)。这是闪烁的正面解法。
+     *
+     * `arrivedOf` 依赖 Cesium 的 `tilesLoaded`,而相机一动、瓦片进出、内存回收都会让它
+     * 在 true/false 之间抖。之前 `show` 直接等于它,于是某个距离上精模/粗模反复切换 = 闪。
+     * 现在:一旦到位,**至少保持这么久**;期间真掉了也不立刻回退,只有持续失效才让粗模回来。
+     * 保持期内 `show` 与 `mask` 用的是同一个 `usable`,所以互斥关系不破、不会重叠。
+     */
+    const USABLE_HOLD_MS = 900
+    // 前沿状态:半径 + 上次放大时刻 + 上次相机位姿(判"在动")。
+    let radius = finiteOr(frontierOptions.minRadius, 600)
+    let lastGrowAt = now()
+    let lastPoseKey = ''
+    // 初始为 -Infinity(从未移动过):启动时镜头是静止的,必须允许立即装载;
+    // 若初始化成 now(),控制器一出生就被误判成在动,首批装载被移动闸门挡住。
+    let lastMoveAt = -Infinity
+    let lastAction = 'hold'
+    let lastSaturated = false
+
+    function schedule() { if (disposed || timer !== null) return; timer = setTimer(() => { timer = null; refresh() }, 200) }
+    function mask(e, value) { if (e.shown !== value) { e.shown = value; requestRender() } }
+    function unload(e) {
+        mask(e, false)
+        if (e.instance) { e.detaches.forEach(d => d()); remove(e.instance) }
+        e.instance = null; e.phase = 'pending'; e.loaded.clear()
+        e.visible = e.visibleThisFrame = e.fineVisible = e.fineVisibleThisFrame = false
+        e.pending = e.processing = 0
+    }
+
+    async function start(e) {
+        if (disposed || loading.size >= loadLimit) return
+        loading.add(e)
+        e.phase = 'initializing'
+        try {
+            const model = await load(e)
+            if (disposed || pipeline || !desired().includes(e.id)) { remove(model); e.phase = 'pending'; loading.delete(e); return }
+            e.instance = model
+            model.show = false
+            model.preloadWhenHidden = true
+            e.phase = 'streaming'
+            e.detaches = []
+            const listen = (event, fn) => { if (event) e.detaches.push(event.addEventListener(fn)) }
+            listen(model.tileLoad, t => {
+                const url = t.content?.url
+                if (url && (!e.contentUrlIndex || e.contentUrlIndex.has(url))) e.loaded.add(url)
+                if (url && e.previewUrlIndex.has(url)) e.previewSeen.add(url)
+                schedule()
+            })
+            // **不在这里 mask(false)**:tileUnload 在相机移动/内存回收时非常频繁,
+            // 每来一次就把粗模掀回来,观感就是"精模粗模反复闪"。遮罩只由 syncVisibility
+            // 统一决定(带保持期),卸载只记事实。
+            listen(model.tileUnload, t => { if (t.content?.url) e.loaded.delete(t.content.url); schedule() })
+            listen(model.tileVisible, t => {
+                if (t.content?.trianglesLength > 0) {
+                    e.visibleThisFrame = true
+                    const url = t.content.url
+                    // 「细模可见」=这一帧画的是 fine 层:在已知内容集里、且**既不是 preview
+                    // 也不是 medium**(票 91:medium 是独立中间级,不能算作细模)。
+                    const isPreview = e.previewUrlIndex.has(url)
+                    const isMedium = e.mediumUrlIndex ? e.mediumUrlIndex.has(url) : false
+                    if (e.contentUrlIndex?.has(url) && !isPreview && !isMedium) e.fineVisibleThisFrame = true
+                }
+            })
+            listen(model.loadProgress, (pending, processing) => {
+                e.pending = pending; e.processing = processing
+                // 同理:开新流不代表"现在不可用"。这里掀遮罩会让每次翻页/换片都闪一下。
+                // 保持期在 syncVisibility 里统一管,流式状态只记账。
+                schedule()
+            })
+            listen(model.tileFailed, error => { mask(e, false); e.phase = 'failed'; e.retryAt = now() + 15000; onFailure(e, error); schedule() })
+            add(model, e)
+            requestRender()
+        } catch (error) {
+            e.phase = 'failed'; e.retryAt = now() + 15000; mask(e, false)
+            if (!disposed) onFailure(e, error)
+        } finally {
+            // 只有真的进入 streaming 才算"装上了";失败/被取消的立刻让出并发槽。
+            if (e.phase !== 'streaming') loading.delete(e)
+            schedule()
+        }
+    }
+
+    function previewReady(e) {
+        // 读 previewSeen(单调累计),不是 loaded(会被剔除扣减)。
+        // 这就是「近处 13.6m 的区域加载了 61 片 fine 却一直白盒」的修复:剔除两片 preview
+        // 不该让已经显形过的区域永久打回白盒。
+        return !!e.expectedPreviewContentUrls?.length && e.expectedPreviewContentUrls.every(url => e.previewSeen.has(url))
+    }
+
+    /**
+     * 这一区域"内容已到位"的**唯一判据**(onRender 与 refresh 共用,杜绝两处口径漂移)。
+     *
+     * 刻意不用 `loaded.size === contentCount`:`loaded` 会被 `tileUnload` 扣减
+     * (视锥剔除、内存回收都会触发),用它判"装完了"会让已到位的区域永远到不了 ready,
+     * 从而永久占住并发槽。`tilesLoaded && pending===0 && processing===0` 是 Cesium
+     * 自己的"当前视图所需内容都已就绪"口径,天然容忍剔除。
+     */
+    function arrivedOf(e) {
+        if (!e.instance) return false
+        if (e.pending !== 0 || e.processing !== 0) return false
+        // 只认 Cesium 的 `tilesLoaded`:它是"当前视图所需瓦片都已处理完"的权威口径,
+        // 且会随剔除/回收自然回落 —— 回落正是"该释放了"的信号。
+        //
+        // 刻意**不再** OR 上 `previewReady`:那个判据读的是单调累计的 previewSeen,
+        // 一旦为真就永远为真,会让区域"到过位"就再也不释放(遮罩永远不还、粗模永远不回)。
+        // 防抖动交给 syncVisibility 的保持期,不靠把到位判据做假。
+        return e.instance.tilesLoaded === true
     }
-    function previewReady(e){return !!e.expectedPreviewContentUrls?.length&&e.expectedPreviewContentUrls.every(url=>e.loaded.has(url))}
-    function desired(){return selectDetailRegions(entries.map(e=>({...e,active:!!e.instance||e.phase==='initializing'})),{pipeline,now:now()})}
-    function refresh(){
-        if(disposed)return
-        if(!pipeline){beforeRefresh();for(const e of entries)e.distance=distance(e)}
-        const wanted=desired()
-        for(const e of entries){
-            if(!wanted.includes(e.id)||e.phase==='failed'){
-                if(e.instance)unload(e)
-                if(inflight?.entry===e&&e.phase!=='initializing')inflight=null
+
+    /**
+     * 把"显形 / 遮罩 / 到位"一次性同步到实例上。
+     *
+     * 为什么是单点:这套逻辑曾经同时写在 onRender 与 refresh 两处,两遍各自漂移
+     * (一处双向、一处单向)。收敛成一个函数后,**整个模块里改 show/mask 的只有这一处**,
+     * 变异测试一处就能抓住,也不会再出现两条路径口径不一致。
+     *
+     * @param {object} e 区域条目
+     */
+    function syncVisibility(e) {
+        if (!e.instance) return
+        const arrived = arrivedOf(e)
+        const at = now()
+        // 「现在可用」刷新时刻;不可用时不一定立刻失效 —— 看下面的保持期。
+        const usableNow = usableOf(e)
+        if (usableNow) e.usableAt = at
+        const usable = usableNow || (at - e.usableAt < USABLE_HOLD_MS)
+        // **show 必须双向跟随 usable**(关键修复:重叠缺陷)。
+        //
+        // 旧写法只开不关(`if (usable && !show) show = true`)—— 一个区域曾经"到位"后
+        // `show` 就一直为真。之后相机移动 / 内容被剔除,`usable` 掉回 false、粗模遮罩
+        // 撤掉,但细模 tileset 还在渲染它已缓存的瓦片 → **同一栋楼粗模白盒 + 细模精模同框**。
+        //
+        // 双向之后:`usable` 在 ⇒ 细模渲染、粗模隐藏;`usable` 不在 ⇒ 细模整片不画、
+        // 粗模回来补位。两者永远互斥,同帧切换,既没有重叠也没有空洞。
+        if (e.instance.show !== usable) { e.instance.show = usable; requestRender() }
+        if (arrived) { e.phase = 'ready'; loading.delete(e) }
+        // 遮罩同样要带保持期:`e.visible` 是"这一帧真的画了没有",它也会抖
+        // (瓦片进出/剔除时某些帧没画)。只按它算遮罩,粗模就会在这些空帧里闪回来 ——
+        // 即使 show 没变,观感照样是闪。所以记"上次真的覆盖到"的时刻,保持期内继续隐藏。
+        if (usable && e.visible) e.coveredAt = at
+        const covered = usable && (e.visible || (at - e.coveredAt < USABLE_HOLD_MS))
+        mask(e, covered)
+    }
+
+    /**
+     * 这一区域"可以显形"的判据 —— 比 `arrivedOf` **更严**,多一层具体内容的保护。
+     *
+     * 为什么不能直接用 `arrivedOf`:`tilesLoaded` 只说明"当前视图所需的瓦片都处理完了",
+     * 它**不保证**这个区域自己的内容(尤其 preview 那几片)已经到齐。若此时就撤掉粗模
+     * 遮罩,用户会看到这片楼先塌成一个空洞、过几百毫秒才补上——比晚一点显形更难看。
+     *
+     * 因此:**配了 preview 的区域必须先等 preview 到齐**(preview 是这几片楼的低保真替身,
+     * 到齐即可显示,不必等 fine);没配 preview 的区域才可以直接用 `tilesLoaded` 判。
+     */
+    function usableOf(e) {
+        if (!e.instance) return false
+        // 「见过至少一片 preview」即可显形:
+        //   · 不是「全部到齐」—— 全部到齐读的是会被剔除扣减的 loaded,一片被剔除就永久白盒;
+        //   · 不是「0 片也行」—— 还没见过任何 preview 的区域显形就是露粗模底下的空洞。
+        // 这是正确性而不是性能问题:它决定粗模遮罩什么时候撤,撤早了露洞、撤晚了白盒。
+        const expectsPreview = !!e.expectedPreviewContentUrls?.length
+        if (expectsPreview && e.previewSeen.size === 0) return false
+        return arrivedOf(e)
+    }
+
+    /** 当前这一拍的选择(把前沿半径与预算一起交给选择器)。 */
+    function desired() {
+        for (const e of entries) e.active = !!e.instance || e.phase === 'initializing'
+        return selectDetailRegions(entries, { pipeline, now: now(), radius, ...budgetOptions })
+    }
+
+    /** 推进前沿:在动就收缩,静置静默就按拍放大。 */
+    function advanceFrontier() {
+        const pose = readPoseKey()
+        // 首次拿到指纹只是"建立基线",不是"移动":若没有它,第一次从空指纹到真实
+        // 位姿会被误判成一次移动,把启动期不该有的移动窗口强行挂上。
+        if (lastPoseKey === '') { lastPoseKey = pose }
+        else if (pose !== lastPoseKey) { lastPoseKey = pose; lastMoveAt = now() }
+        const busy = entries.some(e => e.instance && (e.pending > 0 || e.processing > 0))
+        const pickedEntries = desired().map(id => byId.get(id)).filter(Boolean)
+        const saturated = isSelectionSaturated(pickedEntries, budgetOptions)
+        const step = stepDetailFrontier({
+            radius,
+            moving: now() - lastMoveAt < movingWindowMs,
+            busy,
+            saturated,
+            elapsedMs: now() - lastGrowAt,
+            options: frontierOptions,
+        })
+        if (step.action === 'grow' && step.radius > radius) lastGrowAt = now()
+        if (step.radius !== radius) { radius = step.radius; requestRender() }
+        lastAction = step.action
+        lastSaturated = saturated
+    }
+
+    /**
+     * 相机位姿的廉价指纹 —— **只用于判"在动/静止"**,不参与任何几何或排序。
+     *
+     * 正式路径注入 `readCameraKey`(引擎侧按相机位置/朝向取整)。没注入时退回区域距离
+     * 指纹:它只在相机真的把某块区域推远拉近时才变,细微平移会漏判成"静止"——
+     * 那种情况下前沿会误放大,所以在引擎里必须显式注入。
+     */
+    function readPoseKey() {
+        if (typeof readCameraKey === 'function') {
+            try { return String(readCameraKey() ?? '') } catch { /* 取不到就退回下面的兜底 */ }
+        }
+        const parts = []
+        for (let i = 0; i < entries.length && i < 8; i += 1) {
+            const d = entries[i].distance
+            parts.push(Number.isFinite(d) ? Math.round(d) : -1)
+        }
+        return parts.join(',')
+    }
+
+    function refresh() {
+        if (disposed) return
+        if (!pipeline) {
+            beforeRefresh()
+            for (const e of entries) e.distance = distance(e)
+            advanceFrontier()
+        }
+        const wanted = desired()
+        for (const e of entries) {
+            if (!wanted.includes(e.id) || e.phase === 'failed') {
+                if (e.instance) unload(e)
+                loading.delete(e)
                 continue
             }
-            if(e.instance){const complete=e.loaded.size===e.contentCount&&e.pending===0&&e.processing===0&&e.instance.tilesLoaded
-                const usable=complete||previewReady(e)
-                if(usable&&!e.instance.show){e.instance.show=true;requestRender()}
-                mask(e,usable&&e.visible&&e.instance.show)
-                if(usable&&e.pending===0&&e.processing===0&&e.instance.tilesLoaded){e.phase='ready';if(inflight?.entry===e)inflight=null}
+            if (e.instance) syncVisibility(e)
+        }
+        // 串行槽已换成"有限并发":只要没到并发上限就继续开下一个(不再等前一个全装完)。
+        // 内容仍在流的区域不阻塞别的区域开装 —— 前沿收缩已经管住了"在动时少选"。
+        //
+        // **移动期不开新装载**:相机在动时每一次 region 初始化都会带着一批瓦片请求与
+        // 上传,直接撞在移动帧上(这就是近景移动卡顿的来源)。
+        //
+        // 但闸门只挡 **start 新 tileset 实例** 这一步;卸载(unload)与显隐(syncVisibility)
+        // 必须在每帧照常——否则移动中扫过一片建筑时,已移出前沿的旧区域赖着不走,新进入的
+        // 又不开,观感比"移动期暂不上新"更糟。
+        const moving = now() - lastMoveAt < movingWindowMs
+        if (!moving && loading.size < loadLimit && !pipeline && canStart()) {
+            const pending = wanted.map(id => byId.get(id))
+                .filter(candidate => candidate && !candidate.instance && candidate.phase !== 'initializing' && !loading.has(candidate))
+            for (const candidate of pending) {
+                if (loading.size >= loadLimit) break
+                void start(candidate)
             }
         }
-        const busy=entries.some(e=>e.instance&&(e.pending>0||e.processing>0))
-        if(!inflight&&!busy&&!pipeline&&canStart()){const e=wanted.map(id=>byId.get(id)).find(e=>!e.instance&&e.phase!=='initializing');if(e)void start(e)}
-        if(!pipeline)schedule()
+        if (!pipeline) schedule()
     }
-    return {refresh,onRender(){if(pipeline||disposed)return;for(const e of entries){e.visible=e.visibleThisFrame;e.visibleThisFrame=false;e.fineVisible=e.fineVisibleThisFrame;e.fineVisibleThisFrame=false}refresh()},
-        setPipeline(value){if(pipeline===!!value)return;pipeline=!!value;refresh()},
-        detailReady(id){return byId.get(id)?.shown===true},
-        snapshot(){return entries.filter(e=>e.instance||e.phase==='initializing').map(e=>({id:e.id,phase:e.phase,contents:e.contentCount,loaded:e.loaded.size,pending:e.pending,processing:e.processing,decodedGeometryEstimate:e.decodedGeometryBytes,distance:e.distance,previewLoaded:e.expectedPreviewContentUrls?.filter(url=>e.loaded.has(url)).length||0,previewContents:e.expectedPreviewContentUrls?.length||0,fineLoaded:[...e.loaded].filter(url=>e.expectedContentUrls?.includes(url)&&!e.expectedPreviewContentUrls?.includes(url)).length,fineVisibleLastRender:e.fineVisible,coarseMasked:e.shown}))},
-        detach(){disposed=true;if(timer!==null)clearTimer(timer);for(const e of entries)unload(e)}}
-}
+
+    return {
+        refresh,
+        onRender() {
+            if (pipeline || disposed) return
+            // 逐帧:采集"这一帧谁真的被渲染了",并维护"粗模遮罩"这一项**廉价的**状态。
+            //
+            // 为什么遮罩不能等节流:`detailReady()` 读的就是它。若把它塞进 180ms 的节流
+            // 分支,注入时钟不前进的场合(闸门/测试)会**永远**不更新 —— 表现为
+            // "细模已经渲染了,粗模却一直不撤"。这跟性能无关,是正确性问题。
+            // 真正贵的是 refresh() 里的排序/分配/装载决策,那个才需要节流。
+            for (const e of entries) {
+                e.visible = e.visibleThisFrame; e.visibleThisFrame = false
+                e.fineVisible = e.fineVisibleThisFrame; e.fineVisibleThisFrame = false
+                if (!e.instance) continue
+                // **显形与遮罩必须在每帧路径上完成,不能留在节流分支里**:
+                // 留在那里意味着细模最多晚 180ms 才显形;更要命的是注入时钟不前进的
+                // 场合(闸门/合成用例)会永远不显形 —— 那是正确性问题,不是性能问题。
+                // 逻辑收敛在 syncVisibility 单点(与 refresh 共用),这里只做逐帧调用。
+                syncVisibility(e)
+            }
+            // 昂贵段:排序 / 是否还能再开一个 region / 前沿推进 —— 按 ~5Hz 节流。
+            const at = now()
+            if (at - lastRenderRefreshAt < 180) return
+            lastRenderRefreshAt = at
+            refresh()
+        },
+        setPipeline(value) { if (pipeline === !!value) return; pipeline = !!value; refresh() },
+        detailReady(id) { return byId.get(id)?.shown === true },
+        /** 前沿读数(维护者面板/闸门都用这一份,不另算)。 */
+        frontierSnapshot() {
+            return {
+                radius: Math.round(radius),
+                action: lastAction,
+                saturated: lastSaturated,
+                moving: now() - lastMoveAt < movingWindowMs,
+                detailRegions: entries.filter(e => e.instance || e.phase === 'initializing').length,
+            }
+        },
+        snapshot() {
+            return entries.filter(e => e.instance || e.phase === 'initializing').map(e => ({
+                id: e.id, phase: e.phase, contents: e.contentCount, loaded: e.loaded.size,
+                pending: e.pending, processing: e.processing, decodedGeometryEstimate: e.decodedGeometryBytes,
+                distance: e.distance, centerDistance: e.centerDistance,
+                previewLoaded: e.expectedPreviewContentUrls?.filter(url => e.loaded.has(url)).length || 0,
+                previewContents: e.expectedPreviewContentUrls?.length || 0,
+                // 票 91:medium 单独报数,便于区分"只到中模"与"已到精模"。
+                mediumLoaded: e.expectedMediumContentUrls?.filter(url => e.loaded.has(url)).length || 0,
+                mediumContents: e.expectedMediumContentUrls?.length || 0,
+                fineLoaded: [...e.loaded].filter(url => e.contentUrlIndex?.has(url) && !e.previewUrlIndex.has(url) && !(e.mediumUrlIndex && e.mediumUrlIndex.has(url))).length,
+                fineVisibleLastRender: e.fineVisible, coarseMasked: e.shown,
+            }))
+        },
+        detach() {
+            disposed = true
+            if (timer !== null) clearTimer(timer)
+            for (const e of entries) unload(e)
+        },
+    }
+}

+ 131 - 1
src/modules/cockpit/services/tilesetOptions.js

@@ -1,4 +1,5 @@
 /** Cesium streaming policy; pure so configuration and the actual engine call share tests. */
+import { DEFAULT_LOD_TIERS, normalizeLodTiers, fineThresholdFromTiers, tierForDistance } from '../domain/lodTiers.js'
 
 /**
  * 瓦片流式策略的可调档(票 35 第二轮)。
@@ -23,6 +24,135 @@ const tuning = {
     dynamicScreenSpaceError: false,
 }
 
+/**
+ * 细模区域批次的专用档(票 61 第二轮)。
+ *
+ * ## 为什么细模不能复用粗模的 tilesetOptions
+ *
+ * 细模区域 tileset 的层级是:`root ge=1000000(空) → preview ge=48 → fine ge=0`。
+ * 也就是说 —— **窗口/立面这些细节全在 fine 层,preview 只是"有屋顶、没窗"的替身**。
+ * Cesium 判"要不要继续往 fine 下钻"用的是同一套屏幕空间误差:
+ *
+ *     sse = geometricError × drawingBufferHeight ÷ (distance × 2·tan(fovy/2))
+ *
+ * 而 `loadSiblings` 那处调用传的是**粗模批次**的 plan,因此细模继承了粗模的
+ * `maximumScreenSpaceError = 115`。代入 ge=48、H=810、fovy=60°:
+ *
+ *     SSE=115 → 只有  293m 以内才细化到 fine(有窗);之外停在 preview(无窗的灰盒子)
+ *     SSE= 96 → 只有  351m
+ *     SSE= 32 → 只有 1052m
+ *
+ * 这就是"稍远一点就变成没窗户的火柴盒"的原因。粗模的 115 是为**全城批次**定的
+ * (少选瓦片、保帧率),对"近景细模"这个用途根本不合适。
+ *
+ * ## 另一个来源:foveatedScreenSpaceError
+ *
+ * 粗模档开着 `foveatedScreenSpaceError: true` —— 屏幕中心优先细化、边缘放宽阈值。
+ * 对全城批次这是好事(人眼看中心),但对细模区域它造成一个**反直觉**的现象:
+ * **同样距离的建筑,屏幕中间的变精模、屏幕边上的还是火柴盒**。维护者报的
+ * "同样距离一半精模一半火柴盒"正是这条 —— 与距离无关,与屏幕位置有关。
+ * 细模批次改为**关闭**,让同一距离的建筑获得同等细节。
+ */
+const DETAIL_DEFAULTS = {
+    screenSpaceError: 56,
+    foveated: false,
+    /** 层级表(票 61 第十六轮)。五级默认见 domain/lodTiers.js。 */
+    tiers: null,
+}
+
+const detailTuning = {
+    /**
+     * 细模细化阈值 —— 这条**就是"精模覆盖到多远"**的旋钮(可按部署覆盖)。
+     *
+     * 区域 tileset 是三级:coarse(全城白盒) → preview(ge=48, **与 fine 同一套材质**)
+     * → fine(ge=0, 窗户/立面细节)。Cesium 按屏幕空间误差逐级下钻,所以阈值直接决定:
+     *
+     *     阈值 24 → fine 覆盖到 ~1403m(近中景全是精模 ⇒ 太重,近景掉帧的主因)
+     *     阈值 40 →  ~842m
+     *     阈值 56 →  ~601m  ← 新默认:精模只保近景,中景由 preview 承担
+     *     阈值 96 →  ~351m(精模只贴脸,中景偏粗)
+     *
+     * 取 56:把"精模"限定在约 600m 内,600m~覆盖前沿之间显示 preview —— preview 与 fine
+     * **材质同名同色**(Plaster_Sand / Plaster_Ivory / Glass_Dark / Door_Walnut…),屋顶
+     * 也共用同一张 SatelliteRoofAtlas,所以中→近的切换是"同一栋楼变细",不是"换了个东西"。
+     *
+     * 注意这不是"降质":**总覆盖范围由 detailFrontier 的半径控制,这里只决定分层**。
+     * 想要更多精模就调小(更吃性能),想要更稳就调大。
+     */
+    ...DETAIL_DEFAULTS,
+}
+
+/**
+ * `map.detail` 覆盖细模批次档。
+ *
+ * **可复位**:作为"配置归一化 + 热更新"的落点,它必须能把值改回来。若只认
+ * `Number.isFinite`,传 `{}` 或 `null` 就永远改不回默认 —— 部署侧把阈值调大试一次,
+ * 就再也回不去了。这里显式接受 null/undefined = 回默认。
+ */
+export function setDetailTilesetPerformance(options = {}) {
+    const next = options && typeof options === 'object' ? options : {}
+    if (next.fineScreenSpaceError === null || next.fineScreenSpaceError === undefined) {
+        detailTuning.screenSpaceError = DETAIL_DEFAULTS.screenSpaceError
+    } else if (Number.isFinite(next.fineScreenSpaceError)) {
+        detailTuning.screenSpaceError = Math.max(2, Math.min(256, next.fineScreenSpaceError))
+    }
+    if (next.fineFoveated === null || next.fineFoveated === undefined) detailTuning.foveated = DETAIL_DEFAULTS.foveated
+    else if (typeof next.fineFoveated === 'boolean') detailTuning.foveated = next.fineFoveated
+    // 层级表:传数组就归一化(非法整表退回默认),传 null 就清掉(回落到显式阈值)。
+    if (next.tiers === null || next.tiers === undefined) detailTuning.tiers = null
+    else detailTuning.tiers = normalizeLodTiers(next.tiers)
+}
+
+/** 当前生效的层级表(没配置就是默认五级)。给诊断与闸门读同一份。 */
+export function lodTiersInEffect() {
+    return detailTuning.tiers || DEFAULT_LOD_TIERS
+}
+
+/** 某个距离当前会落在哪一级(诊断/闸门用;纯函数委托给 domain)。 */
+export function tierAtDistance(distanceM) {
+    return tierForDistance(distanceM, lodTiersInEffect())
+}
+
+export function detailTilesetPerformance() {
+    return { ...detailTuning }
+}
+
+/**
+ * 细模区域批次的 options。与粗模共用流式纪律(缓存/预取/连续 LOD),
+ * 但**误差阈值与中心偏置独立** —— 这两项直接决定"近景是不是精模"。
+ */
+export function detailTilesetOptions(plan = {}) {
+    const continuous = tuning.discreteLod === null ? plan.progressiveRefinement === true : tuning.discreteLod !== true
+    // 维护者可用 `tilesetScreenSpaceErrorScale` 整体降载;细模同样吃这个系数(弱机少解内容),
+    // 但基数从 detailTuning.screenSpaceError 起算,绝不继承粗模的 115。
+    // 票 61 第十六轮:阈值优先由**层级表**推导(这样"精模进到多近"只在一处声明,
+    // 加级/改级不需要再发明旋钮);没有层级表时才回落到显式的 fineScreenSpaceError。
+    const derived = detailTuning.tiers ? fineThresholdFromTiers(detailTuning.tiers) : null
+    const baseThreshold = Number.isFinite(derived) ? derived : detailTuning.screenSpaceError
+    const scaled = Math.round(baseThreshold * tuning.screenSpaceErrorScale)
+    return {
+        skipLevelOfDetail: !continuous,
+        baseScreenSpaceError: 4096,
+        skipScreenSpaceErrorFactor: 32,
+        skipLevels: 2,
+        immediatelyLoadDesiredLevelOfDetail: false,
+        loadSiblings: false,
+        cullRequestsWhileMoving: true,
+        cullRequestsWhileMovingMultiplier: continuous ? 30 : 90,
+        dynamicScreenSpaceError: tuning.dynamicScreenSpaceError,
+        preloadWhenHidden: false,
+        preloadFlightDestinations: false,
+        // 同距离同细节:细模关掉中心偏置(粗模仍保留,那是全城批次的取舍)。
+        foveatedScreenSpaceError: detailTuning.foveated === true,
+        foveatedConeSize: 0.1,
+        foveatedTimeDelay: 0.6,
+        foveatedMinimumScreenSpaceErrorRelaxation: 0,
+        maximumScreenSpaceError: scaled,
+        cacheBytes: tuning.cacheBytes,
+        maximumCacheOverflowBytes: tuning.maximumCacheOverflowBytes,
+    }
+}
+
 /** 由服务层按 `map.performance` 覆盖;未给的项保持默认档。 */
 export function setTilesetPerformance(options = {}) {
     const next = options || {}
@@ -70,4 +200,4 @@ export function tilesetOptions(plan = {}) {
         cacheBytes: tuning.cacheBytes,
         maximumCacheOverflowBytes: tuning.maximumCacheOverflowBytes,
     }
-}
+}

+ 32 - 2
src/modules/cockpit/stores/deviceLayerStore.js

@@ -47,6 +47,9 @@ import {
     setSelectedDevice,
     syncAssociatedDevices,
     syncDeviceReactions as syncDeviceReactionsService,
+    getEffectsAbnormalBase,
+    // 票 70(2026-10-27):上一份计划是否已落地 —— 指纹短路的自愈条件。
+    isDeviceApplyPending,
 } from '../services/mapService.js'
 import { TERRAIN_STATE } from '../domain/mapLayers.js'
 import { useDetailStore } from './detailStore.js'
@@ -126,6 +129,24 @@ export const useDeviceLayerStore = defineStore('deviceLayerStore', () => {
      *     详情独有按「未命中当前筛选」归类(另有 N 台),而不是「与当前快照不一致」。
      *     只有快照对象确实已加载时才生效(翻页没到该点位时无从判断命中)。
      */
+    // 引擎未就绪时的重试(最多 40 次 × 250ms ≈ 10s,覆盖冷启动;成功后清零)。
+    let engineRetryTimer = null
+    let engineRetryCount = 0
+    const ENGINE_RETRY_LIMIT = 40
+    function clearEngineRetry() {
+        if (engineRetryTimer) clearTimeout(engineRetryTimer)
+        engineRetryTimer = null
+        engineRetryCount = 0
+    }
+    function scheduleEngineRetry(options = {}) {
+        if (engineRetryTimer || engineRetryCount >= ENGINE_RETRY_LIMIT) return
+        engineRetryCount += 1
+        engineRetryTimer = setTimeout(() => {
+            engineRetryTimer = null
+            syncForSelection({ ...options, force: true })
+        }, 250)
+    }
+
     function syncForSelection(options = {}) {
         const detailStore = useDetailStore()
         const mapStore = useMapStore()
@@ -248,14 +269,19 @@ export const useDeviceLayerStore = defineStore('deviceLayerStore', () => {
 
         // 票 47:设备状态动效计划(映射/门控/30 台降级都在 domain,本层只提供输入)。
         // 只对**已绘制**的设备算动效 —— 清单里那些没上图的设备没有实体可动。
-        const effects = planDeviceEffects({ devices: drawn, ...resolveDeviceEffectsOptions() })
+        // 票 85:降级阈值按三类对象**合计**判定,所以把另外两类(监测点/未关联设备)的异常数带进来。
+        const effects = planDeviceEffects({ devices: drawn, ...resolveDeviceEffectsOptions(), extraAbnormalCount: getEffectsAbnormalBase() })
         effectsPlan.value = effects
 
         const key = planKeyOf(code, drawn, options.maxFan || 10, effects)
 
         // 同点位的同一份计划**不重画**(翻页/无关重渲染都不会让设备层闪烁)。
         // `force` 用于「快照换了、旧图层必须先撤下」这条路径(规格要求先撤后取)。
-        if (!options.force && key === lastPlanKey) {
+        //
+        // 票 70(2026-10-27):短路必须让开「已下单但还没落地」的情况 ——
+        // `syncAssociatedDevices` 是先入队再返回 true,若那份订单随后被清层吃掉,
+        // 指纹相同会让后续同步永久跳过,只剩 force(刷新地图)能救。
+        if (!options.force && key === lastPlanKey && !isDeviceApplyPending()) {
             return { total: plan.total, drawn: drawn.length }
         }
         lastPlanKey = key
@@ -264,6 +290,10 @@ export const useDeviceLayerStore = defineStore('deviceLayerStore', () => {
         const mine = token
         // 地图服务是同步下发,但**序号纪律仍然要在**:换点后旧的一次同步不得再写读数。
         painted.value = syncAssociatedDevices(drawn, { maxFan: options.maxFan, overlapGroups: groups, effects })
+        // 2026-10-27 走查修正:引擎未就绪时 sync 返回 false,但上面已写入 lastPlanKey ——
+        // 若不同时安排重试,之后同一点位会被"计划未变"短路掉,表现为「子设备点要刷新地图才出现」。
+        if (painted.value) clearEngineRetry()
+        else scheduleEngineRetry(options)
         // 图层的实体是**整份重建**的,所以重建后必须刷新一次反应 —— 否则选中/hover/弱化
         // 会停在重建前的值(新实体默认 scale 1、无外环),表现为"翻一页选中态就没了"。
         syncDeviceReactions()

+ 37 - 2
src/modules/cockpit/stores/eventStore.js

@@ -77,6 +77,16 @@ export const useEventStore = defineStore('eventStore', () => {
     /** 当前切换状态:`WARNING`(预警)或 `ALARM`(报警)。默认预警(`/events/current` 的 mode 必填)。 */
     const mode = ref(EVENT_FAMILY.WARNING)
 
+    /**
+     * 「预警类型」本地筛选(G11,票 07 的落点),`{ code, label }`;空 code = 未筛选。
+     *
+     * 为什么是**本地**筛选:`/events/current` 不收 type 参数(后端未冻结),而且当前列表
+     * 本身只含"可解析设备"的子集。所以这里只在**已加载行**上做减法,不冒充服务端筛选,
+     * 覆盖范围由 `currentEventsView` 的 `filterView` 如实回显(票 07 的缺口说明)。
+     * 切族/重置时清空:类型码是预警族的词汇,挂到报警列表上只会在另一个族里静默漏筛。
+     */
+    const warningTypeFilter = ref({ code: '', label: '' })
+
     /** 当前事件分区(`/events/current` 的整体响应;成败由内部事件分区状态表达)。 */
     const sections = ref(initialSections())
 
@@ -220,12 +230,31 @@ export const useEventStore = defineStore('eventStore', () => {
 
     /** 当前「事件 + 切换」视图(领域层现算;组件与走查都读它)。 */
     const view = computed(() => {
+        // G11:类型筛选只在预警族生效(报警列表没有这套类型码)。
+        const filter = mode.value === EVENT_FAMILY.WARNING ? warningTypeFilter.value : { code: '', label: '' }
+        const options = { warningType: filter.code, warningTypeLabel: filter.label }
         // 已取到数据:从领域对象读事件分区(行 + 内部状态)。
-        if (currentDomain.value) return currentEventsView(currentDomain.value)
+        if (currentDomain.value) return currentEventsView(currentDomain.value, undefined, options)
         // 还没取过 / 整体失败:用整个分区驱动占位措辞(pending → 正在取数;failed → 加载失败)。
-        return currentEventsView(null, sections.value.currentEvents)
+        return currentEventsView(null, sections.value.currentEvents, options)
     })
 
+    /**
+     * 设置/清除预警类型本地筛选(G11)。**不发请求**:只是换一份已加载行的视图。
+     * @param {string} code warning_type 码(空串 = 清除)
+     * @param {string} [label] 展示名(缺省回 code)
+     */
+    function setWarningTypeFilter(code, label = '') {
+        const next = typeof code === 'string' ? code.trim() : ''
+        warningTypeFilter.value = next ? { code: next, label: label || next } : { code: '', label: '' }
+        return warningTypeFilter.value
+    }
+
+    function clearWarningTypeFilter() {
+        warningTypeFilter.value = { code: '', label: '' }
+        return warningTypeFilter.value
+    }
+
     /**
      * 取预警处置四块面板。切换 mode/range/筛选时四块共享一次条件,迟到响应整批丢弃;
      * 条件变化同时把两个服务端页码回第 1 页(**定时刷新不带条件 ⇒ 页码保持**)。
@@ -479,6 +508,8 @@ export const useEventStore = defineStore('eventStore', () => {
      */
     async function setMode(next) {
         if (!isKnownEventMode(next) || next === mode.value) return sections.value.currentEvents
+        // 类型筛选是预警族的词汇,切族即失效(避免报警列表被一条预警类型静默筛空)。
+        clearWarningTypeFilter()
         return loadPanels({ mode: next })
     }
 
@@ -489,6 +520,7 @@ export const useEventStore = defineStore('eventStore', () => {
         sections.value = initialSections()
         currentDomain.value = null
         range.value = '7d'
+        warningTypeFilter.value = { code: '', label: '' }
         currentPage.value = 1
         historyPage.value = 1
         selectedEvent.value = null
@@ -521,6 +553,9 @@ export const useEventStore = defineStore('eventStore', () => {
         setHistoryPage,
         setWorkOrderPage,
         setMode,
+        warningTypeFilter,
+        setWarningTypeFilter,
+        clearWarningTypeFilter,
         reset,
     }
 })

+ 35 - 20
src/modules/cockpit/stores/infrastructureStore.js

@@ -18,7 +18,8 @@ import { defineStore } from 'pinia'
 import { SECTION_STATE, section } from '../domain/sectionState.js'
 import { REQUEST_REASON } from '../domain/requestReason.js'
 import { INFRASTRUCTURE_PANEL_IDS } from '../domain/infrastructurePanels.js'
-import { LOCATE_RESULT, LOCATE_TEXT, locateCameraTarget, planLocate } from '../domain/mapLocate.js'
+import { LOCATE_RESULT, locateFailureText, planLocate } from '../domain/mapLocate.js'
+import { TERRAIN_LOCATE_REASON } from '../domain/pointLocate.js'
 import { MAP_OBJECT_KIND } from '../domain/mapSummary.js'
 import {
     fetchBaseArchives,
@@ -26,7 +27,7 @@ import {
     fetchManagementInformation,
     fetchOperatingInformation,
 } from '../services/infrastructureApi.js'
-import { getCameraState, locateMonitoringPoint, setCameraFocus } from '../services/mapService.js'
+import { getCameraState, locateMonitoringPoint } from '../services/mapService.js'
 import { useDetailStore } from './detailStore.js'
 import { useMapStore } from './mapStore.js'
 
@@ -113,16 +114,19 @@ export const useInfrastructureStore = defineStore('infrastructureStore', () => {
      *
      * 三步,顺序有意为之:
      *   1. **先判定**能否定位(纯函数:找不到 / 后端说不可上图 / 地图未就绪,三种分得开);
-     *   2. 判定通过再动相机。**监测点走 `locateMonitoringPoint`(完整视角定位)**,与点地图
-     *      上同一个点位是**同一条相机语义**(`pointSelection` 内部用的就是它)——
+     *   2. 判定通过再动相机,两类对象都走**地形感知接缝** `locateMonitoringPoint`
+     *      (票 53:中心高程取采样地表,取不到就失败,不以椭球 0 冒充成功),
+     *      **分流保留**:监测点 = 点位级完整视角(`pointSelection` 同一条相机语义),
+     *      非监测点 = `centerOnly` 只换中心、保持 heading/range(ADR 0008 的中心规则)。
      *      票 38 实测的「没反应」根因之一:旧写法 `setCameraFocus` 按 ADR 0008 只换中心、
-     *      保持 heading/range,行星球尺度下镜头几乎无感,而点地图上的同一个点位是真的会飞;
-     *      同一件事两种手感,慢的那个就被读成缺陷。非监测点对象仍走中心换焦。
-     *   3. 相机没吃下指令(引擎未接上 / 地形高度取不到 / 被更新指令顶掉)时如实降级成
-     *      「地图尚未就绪」,**不假装定位成功**;成功才开两级详情(设备图层由外壳既有的
+     *      行星球尺度下镜头几乎无感,而点地图上的同一个点位是真的会飞;
+     *      同一件事两种手感,慢的那个就被读成缺陷。
+     *   3. 相机没吃下指令(引擎未接上 / 地形采样取不到 / 被更新指令顶掉)时**按原因**如实降级
+     *      (地图未就绪 / 坐标不可用 / 地形待确认三种说法,票 53 验收第 3 条),
+     *      **不假装定位成功**;成功才开两级详情(设备图层由外壳既有的
      *      selection 监听按同一条件 force 同步,本层不越权)。
      * @param {string} pointCode 目标编码(点位编码,或**关联设备编码** —— 后者由 `planLocate` 回联宿主监测点)
-     * @param {object} [deps] 注入点(单测用):`{ objects, mapReady, focus, flyPoint, openPoint, openDevice }`
+     * @param {object} [deps] 注入点(单测用):`{ objects, mapReady, flyPoint, openPoint, openDevice }`
      */
     async function locatePoint(pointCode, deps = {}) {
         const mapStore = useMapStore()
@@ -131,7 +135,6 @@ export const useInfrastructureStore = defineStore('infrastructureStore', () => {
         const objects = deps.objects || mapStore.summary.objects
         // 就绪判据用相机状态:引擎还没接上时 `getCameraState()` 回 null(票 03 的交接点)。
         const mapReady = deps.mapReady === undefined ? !!getCameraState() : !!deps.mapReady
-        const focus = deps.focus || setCameraFocus
         const flyPoint = deps.flyPoint || locateMonitoringPoint
         const openPoint = deps.openPoint || ((code) => detailStore.openPoint(code))
         const openDevice = deps.openDevice || ((code) => detailStore.selectDevice(code))
@@ -140,16 +143,18 @@ export const useInfrastructureStore = defineStore('infrastructureStore', () => {
         locate.value = { result: plan.result, pointCode: plan.pointCode, reasonText: plan.reasonText }
         if (plan.result !== LOCATE_RESULT.OK) return locate.value
 
-        const target = locateCameraTarget(plan.object)
-        if (!target) {
-            locate.value = { result: LOCATE_RESULT.UNAVAILABLE, pointCode: plan.pointCode, reasonText: LOCATE_TEXT.UNAVAILABLE }
-            return locate.value
-        }
-        const applied = plan.object.kind === MAP_OBJECT_KIND.MONITORING_POINT
-            ? await flyPoint(plan.object)
-            : focus(target)
+        // 默认按「地图未就绪」口径:接缝没给原因时不替用户猜成地形问题(票 53 验收第 3 条)。
+        let failureReason = TERRAIN_LOCATE_REASON.MAP
+        const applied = await flyPoint(plan.object, {
+            centerOnly: plan.object.kind !== MAP_OBJECT_KIND.MONITORING_POINT,
+            onFailure: (reason) => { failureReason = reason },
+        })
         if (applied !== true) {
-            locate.value = { result: LOCATE_RESULT.UNAVAILABLE, pointCode: plan.pointCode, reasonText: LOCATE_TEXT.UNAVAILABLE }
+            locate.value = {
+                result: LOCATE_RESULT.UNAVAILABLE,
+                pointCode: plan.pointCode,
+                reasonText: locateFailureText(failureReason),
+            }
             return locate.value
         }
 
@@ -161,6 +166,16 @@ export const useInfrastructureStore = defineStore('infrastructureStore', () => {
         return locate.value
     }
 
+    /** 写入非定位类的显式反馈(例如设施详情尚未实现)。 */
+    function setLocateFeedback(value) {
+        locate.value = {
+            result: (value && value.result) || LOCATE_RESULT.UNAVAILABLE,
+            pointCode: (value && value.pointCode) || '',
+            reasonText: (value && value.reasonText) || '',
+        }
+        return locate.value
+    }
+
     /** 收起定位反馈(浮层到点自动消失或手动关闭时由外壳调用;成功结果本来就不显示)。 */
     function clearLocate() {
         locate.value = { result: '', pointCode: '', reasonText: '' }
@@ -175,5 +190,5 @@ export const useInfrastructureStore = defineStore('infrastructureStore', () => {
         locate.value = { result: '', pointCode: '', reasonText: '' }
     }
 
-    return { sections, loading, industry, locate, configure, setSection, loadPanels, locatePoint, clearLocate, reset }
+    return { sections, loading, industry, locate, configure, setSection, loadPanels, locatePoint, setLocateFeedback, clearLocate, reset }
 })

+ 36 - 2
src/modules/cockpit/stores/mapStore.js

@@ -26,6 +26,7 @@ import { defineStore } from 'pinia'
 import {
     MAP_SUMMARY_PHASE,
     MAP_SUMMARY_SETTLED_PHASES,
+    MAP_OBJECT_KIND,
     createMapSummaryLoader,
     emptyMapSummary,
     summaryView,
@@ -234,7 +235,40 @@ export const useMapStore = defineStore('mapStore', () => {
      * 顶部状态栏与监测分布饼图**共用这一份**(决策 02:全局未筛全集,筛选时不变);
      * `available:false` = 后端还没发布这一段,界面如实降级、不补 0。
      */
-    const statusDistribution = computed(() => summary.value.statusDistribution || { available: false, counts: {} })
+    const statusDistribution = computed(() => summary.value.statusDistribution || { available: false, counts: {}, businessCounts: {}, attachmentCounts: {} })
+
+    /**
+     * 两条统计轴:业务状态只数监测点;附加状态单独数未关联设备。
+     * 不从 `counts` 里二次猜测或把两类对象相加——对象来源已由后端发布,
+     * 这里仅按 kind 分桶,保证顶部图例能同时显示「报警 1」与「预警 3 / 工单 1 / 离线 3」的并行事实。
+     */
+    const statusAxes = computed(() => {
+        const distribution = statusDistribution.value
+        const objects = summary.value.objects || []
+        const businessCounts = {}
+        const attachmentCounts = { UNASSOCIATED: 0 }
+        for (const item of objects) {
+            if (!item || !item.kind) continue
+            if (item.kind === MAP_OBJECT_KIND.UNASSOCIATED_EQUIPMENT) {
+                // 未关联设备同时属于附加轴和业务轴:deviceStatus 仍必须计入业务状态。
+                attachmentCounts.UNASSOCIATED += 1
+                const status = item.status || 'UNKNOWN'
+                businessCounts[status] = (businessCounts[status] || 0) + 1
+            } else if (item.kind === MAP_OBJECT_KIND.MONITORING_POINT) {
+                const status = item.status || 'UNKNOWN'
+                businessCounts[status] = (businessCounts[status] || 0) + 1
+            }
+        }
+        return {
+            available: distribution.available,
+            businessCounts: distribution.available && Object.keys(businessCounts).length === 0
+                ? distribution.businessCounts || {}
+                : businessCounts,
+            attachmentCounts: distribution.available && attachmentCounts.UNASSOCIATED === 0
+                ? distribution.attachmentCounts || {}
+                : attachmentCounts,
+        }
+    })
 
     /**
      * 票 15:新到的一批对象 → 补上已缓存的推送状态。
@@ -334,5 +368,5 @@ export const useMapStore = defineStore('mapStore', () => {
         return loader ? loader.hasObject(kind, code) : false
     }
 
-    return { summary, view, statusDistribution, coordinate, locationVerdict: locationVerdictOf, loaded, busy, settled, refreshing, filters, hasSnapshotObject, setup, advance, refresh, applyFilters, applyPointStates, reset }
+    return { summary, view, statusDistribution, statusAxes, coordinate, locationVerdict: locationVerdictOf, loaded, busy, settled, refreshing, filters, hasSnapshotObject, setup, advance, refresh, applyFilters, applyPointStates, reset }
 })

+ 148 - 69
src/modules/cockpit/stores/monitoringStore.js

@@ -20,21 +20,24 @@
  * ④ **异常设备定位**:复用票 17 立的那条共享接缝(`domain/mapLocate.js`),
  *    不新写一套判定。
  */
-import { ref } from 'vue'
+import { reactive, ref } from 'vue'
 import { defineStore } from 'pinia'
 import { SECTION_STATE, section } from '../domain/sectionState.js'
 import { REQUEST_REASON } from '../domain/requestReason.js'
-import { MONITORING_PANEL_IDS, nextLedgerPage, shouldLoadBadge } from '../domain/monitoringPanels.js'
+import { MONITORING_PANEL_IDS, shouldLoadBadge } from '../domain/monitoringPanels.js'
+// 票 62:台账自动渐进装载链(与 mapStore 的自动链同款纪律,实现独立)。
+import { createLedgerLoader, ledgerLoading, ledgerView } from '../domain/ledgerLoader.js'
 // 票 37:类型码的归一收口(数据值无静态白名单,认不出回空串=不筛)。
 import { normalizeDeviceType } from '../domain/filterOptions.js'
-import { LOCATE_RESULT, LOCATE_TEXT, locateCameraTarget, planLocate } from '../domain/mapLocate.js'
+import { LOCATE_RESULT, locateFailureText, planLocate } from '../domain/mapLocate.js'
+import { TERRAIN_LOCATE_REASON } from '../domain/pointLocate.js'
 import {
     fetchMonitoringDistribution,
     fetchMonitoringOverview,
     fetchMonitoringSituation,
 } from '../services/monitoringApi.js'
 import { fetchMonitoringPointDetail } from '../services/detailApi.js'
-import { getCameraState, setCameraFocus } from '../services/mapService.js'
+import { getCameraState, locateMonitoringPoint } from '../services/mapService.js'
 import { useDetailStore } from './detailStore.js'
 import { useMapStore } from './mapStore.js'
 // 票 41:监测点选择的统一入口(与地图点选共用同一个函数)。
@@ -84,9 +87,16 @@ export const useMonitoringStore = defineStore('monitoringStore', () => {
     /** 台账正在补拉徽标的点位集合(避免同一张卡滚动时反复发请求)。 */
     const badgeInFlight = ref({})
 
-    /** 台账正在加载下一页。 */
+    /** 台账正在加载下一页(保留导出:界面与闸门读「是否还有一批在途」)。 */
     const ledgerBusy = ref(false)
 
+    /**
+     * 台账装载链的合并态(票 62):`{ phase, snapshot, page, nextPage, total, loaded, points, equipment, reason }`。
+     * 台账分区(`sections.monitoringPointLedger`)由它派生 —— 分区仍是界面读的那一份,
+     * 装载链是它的数据来源,两者不再各持一套进度。
+     */
+    const ledger = reactive({ state: null })
+
     /** 当前生效的行业筛选(**前端键**;到后端码的映射归 services)。 */
     const industry = ref('')
 
@@ -108,8 +118,14 @@ export const useMonitoringStore = defineStore('monitoringStore', () => {
     /** 请求序号:换筛选/重取时旧的在途响应作废。 */
     let token = 0
 
-    /** 台账分页序号:换筛选时旧的在途"下一页"作废。 */
-    let ledgerToken = 0
+    /** 台账装载纪元(票 62):每次新链(首载/换筛选/手动刷新)作废旧链,迟到写入整批丢弃。 */
+    let ledgerEpoch = 0
+
+    /** 台账装载器(幂等;`setup` 时按当前条件建一次)。 */
+    let ledgerLoader = null
+
+    /** 自动链两批之间的间隔(票 62:给渲染与交互留一口气,不一次打满请求)。 */
+    const LEDGER_AUTO_BATCH_GAP_MS = 16
 
     /** 请求注入(config / fetchImpl):取一次让三块共用,单测与走查都靠它。 */
     let requestOptions = {}
@@ -168,7 +184,6 @@ export const useMonitoringStore = defineStore('monitoringStore', () => {
 
         token += 1
         const mine = token
-        ledgerToken += 1
         loading.value = true
 
         const results = await Promise.all(
@@ -189,75 +204,125 @@ export const useMonitoringStore = defineStore('monitoringStore', () => {
         const next = { ...sections.value }
         for (const [panelId, value] of results) next[panelId] = { ...value, loaded: true }
 
-        // 台账与分布共用同一个响应(见函数头 ①)。
-        const distribution = next.monitoringDistribution
-        next.monitoringPointLedger = { ...distribution, loaded: true }
-
         // 换筛选就丢掉上一批徽标:留着会把 A 行业的设备数挂在 B 行业的点位上(同一条 token 纪律)。
         badges.value = {}
         badgeInFlight.value = {}
-        ledgerBusy.value = false
 
         sections.value = next
         loading.value = false
+
+        // 票 62:台账不再读分布的这一次响应(分布只回第 1 页切片、也不保证装载完整),
+        // 改由**自己的自动装载链**取全(下方 `loadLedgerChain`)。两者并发:台账慢不拖住其它三块。
+        const chain = loadLedgerChain('refresh')
+        await chain
         return sections.value
     }
 
-    /**
-     * 台账「加载下一页」(票 18 验收第 4 条的滚动卡片)。
-     *
-     * 第 2 页起必须带**第 1 页锁定的快照**(后端 `validatePagination` 的硬要求),
-     * 所以这里从当前分区的 `pagination` 取页号、从同一份数据取快照 —— 组件不出现 `page`/`snapshotAt`。
-     * 新一页的点位**追加**到台账分区,分布分区**不动**(它的聚合是全量口径,与翻页无关)。
-     * @returns {Promise<object>} 台账分区
-     */
-    async function loadMoreLedger() {
-        const page = nextLedgerPage(sections.value.monitoringPointLedger && sections.value.monitoringPointLedger.data)
-        if (page === null || ledgerBusy.value) return sections.value.monitoringPointLedger
-
-        ledgerToken += 1
-        const mine = ledgerToken
-        ledgerBusy.value = true
-
-        const active = activeOptions()
-        const current = sections.value.monitoringPointLedger
+    // ============================================================ 票 62:台账自动渐进装载链
+    //
+    // 四条纪律(票面 Acceptance 逐条落在这里):
+    //   ① **纪元**:每次新链 `++ledgerEpoch`,旧链的迟到写入整批丢弃(与 mapStore 同款);
+    //   ② 条件变化 → 新快照从第 1 页重来;定时刷新走 `reuseLedgerSnapshot`,**不重放全链**(票 33 教训);
+    //   ③ 第 2 页起带第 1 页锁定的 `snapshotAt`(后端硬要求,token 只认响应回显);
+    //   ④ 失败不回滚已装载内容(`domain/ledgerLoader.js` 保证),也不无限自动重试(决策 04)。
+
+    /** 建/复用装载器:`fetchPage` 每次现取条件,筛选变了也不必重建。 */
+    function ensureLedgerLoader() {
+        if (ledgerLoader) return ledgerLoader
+        ledgerLoader = createLedgerLoader({
+            // 页大小交给后端默认值(不在前端硬编码第二份口径)。
+            fetchPage: (query) => fetchMonitoringDistribution({ ...activeOptions(), ...query, raw: true }),
+        })
+        return ledgerLoader
+    }
 
-        let result = null
-        try {
-            result = await fetchMonitoringDistribution({ ...active, page })
-        } catch (error) {
-            result = section(SECTION_STATE.FAILED, { reason: REQUEST_REASON.UNREACHABLE, data: null })
+    /** 合并态 → 台账分区(界面读的还是同一个 `sections.monitoringPointLedger`)。 */
+    function syncLedgerSection(state) {
+        ledger.state = state
+        const view = ledgerView(state)
+        const data = {
+            // 兼容既有面板视图的字段名(`points`/`equipment`/`pagination` 是它读的那几个)。
+            points: state.points,
+            equipment: state.equipment,
+            snapshotAt: state.snapshot,
+            // 页码分页要的三个数:`total` 连同分页视图一起由 `domain/ledgerLoader.js` 给
+            // (`totalPages` 也由它算——store 不另写一份 ceil 算术,条文 11 的单源纪律)。
+            pagination: {
+                page: state.page,
+                pageSize: state.pageSize,
+                total: view.total,
+                totalPages: view.pageCount,
+                hasMore: false,
+            },
+            loaded: view.loaded,
+            ledgerPhase: state.phase,
+            ledgerNote: view.pageNote,
+        }
+        const state_ = state.phase === 'failed' || state.phase === 'expired'
+            ? SECTION_STATE.FAILED
+            : view.loaded === 0 && state.phase === 'empty'
+              ? SECTION_STATE.EMPTY
+              : SECTION_STATE.SUCCESS
+        sections.value = {
+            ...sections.value,
+            monitoringPointLedger: { ...section(state_, { data, reason: state.reason }), loaded: true, retained: view.loaded > 0 },
         }
+        ledgerBusy.value = ledgerLoading(state)
+    }
 
-        // 换筛选后旧的在途"下一页"作废:它属于上一个范围,拼进新台账就是两份数据混在一起。
-        if (mine !== ledgerToken) {
-            ledgerBusy.value = false
-            return sections.value.monitoringPointLedger
+    /**
+     * 跑一条台账链。
+     * @param {'refresh'|'reuse'} kind `refresh`=换新快照并自动连翻到 settled;
+     *        `reuse`=还在同一快照有效期内,**只补没取完的部分**(定时刷新走这条,不重放全链)
+     */
+    async function loadLedgerChain(kind = 'refresh') {
+        const loader = ensureLedgerLoader()
+        const mine = ++ledgerEpoch
+        const started = kind === 'refresh' ? await loader.refresh() : loader.getState()
+        if (mine !== ledgerEpoch) return sections.value.monitoringPointLedger
+        syncLedgerSection(started)
+
+        let state = started
+        while (mine === ledgerEpoch && !ledgerSettledForAuto(state)) {
+            await new Promise((resolve) => setTimeout(resolve, LEDGER_AUTO_BATCH_GAP_MS))
+            if (mine !== ledgerEpoch) break
+            state = await loader.loadNext()
+            if (mine !== ledgerEpoch) break
+            syncLedgerSection(state)
+            // 失败/过期不自动重试(决策 04):停链,等新鲜度周期或手动入口。
+            if (state.phase === 'failed' || state.phase === 'expired') break
         }
+        return sections.value.monitoringPointLedger
+    }
 
-        ledgerBusy.value = false
+    /** 自动链的循环条件(settled 或失败都停)。 */
+    function ledgerSettledForAuto(state) {
+        return ['complete', 'empty', 'failed', 'expired'].includes(state && state.phase)
+    }
 
-        if (result.state !== SECTION_STATE.SUCCESS && result.state !== SECTION_STATE.STALE) {
-            // 下一页失败**不回滚已列出的卡片**:把它们清掉等于把用户已经看到的信息抹了。
-            // 只在台账分区上标一个附加的失败提示,由界面说明"还有下一页没取到"。
-            const merged = { ...current, data: current.data ? { ...current.data, pageFailed: true } : null }
-            sections.value = { ...sections.value, monitoringPointLedger: merged }
-            return merged
-        }
+    /**
+     * 票 62:定时刷新走这条 —— **不换快照、不重放全链**。
+     *
+     * 票 33 的教训:30s tick 若每次重放全量自动链,会让整块面板周期性抖动(mapStore 那边
+     * 的直接后果是画面抽搐)。这里只做一件事:链路还没 settled 且没失败时,把剩下的批次补完;
+     * 已 settled 的台账**一个请求都不发**。
+     */
+    async function reuseLedgerSnapshot() {
+        const state = ledger.state
+        if (!state) return loadLedgerChain('refresh')
+        if (ledgerSettledForAuto(state)) return sections.value.monitoringPointLedger
+        return loadLedgerChain('reuse')
+    }
 
-        const merged = {
-            ...current,
-            state: current.state,
-            data: {
-                ...result.data,
-                // 追加:新一页在前一页之后(后端同快照下分页稳定,不会重叠)。
-                points: [...((current.data && current.data.points) || []), ...(result.data.points || [])],
-                equipment: [...((current.data && current.data.equipment) || []), ...(result.data.equipment || [])],
-                pageFailed: false,
-            },
-        }
-        sections.value = { ...sections.value, monitoringPointLedger: merged }
-        return merged
+    /**
+     * 票 62:「加载下一页监测点」按钮已退役 —— 保留同名入口只为兼容既有调用点与走查脚本,
+     * 行为改为「再推一批」(只在链路未 settled 时有效,settled 后是空操作)。
+     * @returns {Promise<object>} 台账分区
+     */
+    async function loadMoreLedger() {
+        const state = ledger.state
+        if (!state || ledgerSettledForAuto(state)) return sections.value.monitoringPointLedger
+        return loadLedgerChain('reuse')
     }
 
     /**
@@ -317,10 +382,11 @@ export const useMonitoringStore = defineStore('monitoringStore', () => {
      * 点运行概况里的异常设备 → 联动地图(复用票 17 立的那条共享接缝 ④)。
      *
      * 三步,顺序有意为之:先判定能否定位(三种"点不动"分得开)→ 判定通过再动相机
-     * → 相机下发成功再打开两级详情。相机没吃下指令时如实降级成「地图尚未就绪」,
-     * **不假装定位成功**。
+     * → 相机下发成功再打开两级详情。票 53:相机走**地形感知接缝** `locateMonitoringPoint`
+     * (中心高程取采样地表,取不到就失败,不以椭球 0 冒充成功),按失败原因如实给说法
+     * (地图未就绪 / 坐标不可用 / 地形待确认),**不假装定位成功**。
      * @param {string} code 目标编码(异常设备是设备维度,但未关联设备也以自身编码上图)
-     * @param {object} [deps] 注入点(单测用):`{ objects, mapReady, focus, openPoint }`
+     * @param {object} [deps] 注入点(单测用):`{ objects, mapReady, flyPoint, openPoint }`
      */
     async function locateCode(code, deps = {}) {
         const mapStore = useMapStore()
@@ -328,16 +394,24 @@ export const useMonitoringStore = defineStore('monitoringStore', () => {
 
         const objects = deps.objects || mapStore.summary.objects
         const mapReady = deps.mapReady === undefined ? !!getCameraState() : !!deps.mapReady
-        const focus = deps.focus || setCameraFocus
+        const flyPoint = deps.flyPoint || locateMonitoringPoint
         const openPoint = deps.openPoint || ((value) => detailStore.openPoint(value))
 
         const plan = planLocate({ objects, pointCode: code, mapReady })
         locate.value = { result: plan.result, pointCode: plan.pointCode, reasonText: plan.reasonText }
         if (plan.result !== LOCATE_RESULT.OK) return locate.value
 
-        const target = locateCameraTarget(plan.object)
-        if (!target || focus(target) !== true) {
-            locate.value = { result: LOCATE_RESULT.UNAVAILABLE, pointCode: plan.pointCode, reasonText: LOCATE_TEXT.UNAVAILABLE }
+        // 默认按「地图未就绪」口径:接缝没给原因时不替用户猜成地形问题(票 53 验收第 3 条)。
+        let failureReason = TERRAIN_LOCATE_REASON.MAP
+        const applied = await flyPoint(plan.object, {
+            onFailure: (reason) => { failureReason = reason },
+        })
+        if (applied !== true) {
+            locate.value = {
+                result: LOCATE_RESULT.UNAVAILABLE,
+                pointCode: plan.pointCode,
+                reasonText: locateFailureText(failureReason),
+            }
             return locate.value
         }
 
@@ -350,7 +424,9 @@ export const useMonitoringStore = defineStore('monitoringStore', () => {
     /** 仅测试用:回到未加载状态。 */
     function reset() {
         token += 1
-        ledgerToken += 1
+        ledgerEpoch += 1
+        ledgerLoader = null
+        ledger.state = null
         sections.value = Object.fromEntries(MONITORING_PANEL_IDS.map((panelId) => [panelId, pending()]))
         badges.value = {}
         badgeInFlight.value = {}
@@ -380,6 +456,9 @@ export const useMonitoringStore = defineStore('monitoringStore', () => {
         setSection,
         loadPanels,
         loadMoreLedger,
+        reuseLedgerSnapshot,
+        loadLedgerChain,
+        ledger,
         loadLedgerBadge,
         openLedgerPoint,
         locateCode,