Explorar o código

chore(t35): 收口清理过期模型与本地工作区,修复渲染/管线性能

维护者要求票 35 收口:清理临时与过期产物,只保留关键回退版本。

清理(磁盘释放约 7.4GB):
- cad-bim 删除 60 个过期模型目录(5.97GB):city-regions-v1..v5、city-continuous-*、
  city-far-shell-*、city-hybrid-v1、instanced-source/converted-*、lod-pilot(-aligned/-roofed)、
  lod-batch 各 -aligned/-roofed/原始批、lod-near-estate-pilot-v1..v5、city-regions-roof-pilot-v1 等。
- 保留运行时依赖闭包 11 个目录(1.28GB):city-regions-v7、v6、estate-windows-v1 与
  8 个 -roofed2 批次。v7 manifest 直接引用 v6 与这些批次,删任一个都会导致线上 404。
- 删除 oldtown 的 680 个 lod-preview GLB(1.32GB,git 跟踪);运行时从不引用。
- 删除 10 个 oldtown 时代一次性脚本(2026-09-30,产物已被 cad-bim 取代):
  seat-oldtown / generate-oldtown-fill / prepare-oldtown-dem / sample-oldtown-browser /
  summarize-city-coverage / verify-city-models / preview-district / make-root-anchor /
  draw-model-coverage / oldtown-geometry(.test)。
- .scratch 全部取消跟踪(1431 文件,331.9MB)并加入 .gitignore;本地磁盘内容保留。
  (维护者要求测试类与 matt-skills 过程产物不传到服务器。)
- 删除未跟踪的临时产物:tools/ticket54*、stage_ticket54*.py、nul、dist、.cache、logs。

性能修复(本地未提交改动的本轮部分):
- cesiumPipeModels:地面视图不再采样地形/拼 GLTF/上传管线模型,切回地面即释放批次。
- markerAssets:标记合成按母版/内层/尺寸/配色缓存,同批不再重复烘焙。
- cesiumEngine:标记分帧落地预算、动效写入"变了才写"、投影复用遮挡体与画布矩形。
- mapService:场景落地分帧/分槽排队,管线同步同引用短路,语义色缓存。
- markerOverlay:指纹改为廉价拼接,样式"变了才写"。
- tilesetLoading/Options:细模保持期,LOD 策略回退为配置驱动(离散为运维开关)。

未包含:工作区中更早的 36 个 src 未提交改动(非本票范围,保持未提交)。

由于项目规则要求,已跳过 build/debug 验证。
ChenJZ hai 10 horas
pai
achega
6ae4792e30
Modificáronse 100 ficheiros con 12 adicións e 13425 borrados
  1. 12 0
      .gitignore
  2. 0 7
      .scratch/acl-reports/acl-report-3dda1f7cc47f4526b521d9c7ebf5d616.jsonl
  3. 0 7
      .scratch/acl-reports/acl-report-dee0fae4950e4217b34a6c867f29b7a8.jsonl
  4. 0 7
      .scratch/acl-reports/acl-report-ebc7560f68d4457e8c6c08fdb01530ee.jsonl
  5. 0 32
      .scratch/cockpit-point-locate-and-device-link/issues/01-research-device-position-data-and-rendering-primitives.md
  6. 0 36
      .scratch/cockpit-point-locate-and-device-link/issues/02-research-device-status-effect-mechanisms.md
  7. 0 111
      .scratch/cockpit-point-locate-and-device-link/issues/03-locate-camera-view-contract.md
  8. 0 113
      .scratch/cockpit-point-locate-and-device-link/issues/04-panel-anchor-following-form.md
  9. 0 42
      .scratch/cockpit-point-locate-and-device-link/issues/05-device-point-layer-trigger-and-lifecycle.md
  10. 0 52
      .scratch/cockpit-point-locate-and-device-link/issues/06-panel-to-scene-device-correspondence.md
  11. 0 139
      .scratch/cockpit-point-locate-and-device-link/issues/07-device-status-effect-visual-language.md
  12. 0 92
      .scratch/cockpit-point-locate-and-device-link/issues/08-prototype-anchor-panel-and-device-effects.md
  13. 0 77
      .scratch/cockpit-point-locate-and-device-link/issues/09-task-backend-device-coordinate-contract.md
  14. 0 95
      .scratch/cockpit-point-locate-and-device-link/issues/10-device-model-asset-definition.md
  15. 0 93
      .scratch/cockpit-point-locate-and-device-link/issues/11-overlapping-device-points.md
  16. 0 111
      .scratch/cockpit-point-locate-and-device-link/issues/12-panel-anchor-following-spec.md
  17. 0 74
      .scratch/cockpit-point-locate-and-device-link/issues/13-mode-layer-visibility-matrix.md
  18. 0 128
      .scratch/cockpit-point-locate-and-device-link/issues/14-associated-monitoring-device-layer-spec.md
  19. 0 97
      .scratch/cockpit-point-locate-and-device-link/issues/15-panel-device-correspondence-spec.md
  20. 0 145
      .scratch/cockpit-point-locate-and-device-link/issues/16-device-model-and-asset-spec.md
  21. 0 106
      .scratch/cockpit-point-locate-and-device-link/issues/17-overlapping-device-points-spec.md
  22. 0 170
      .scratch/cockpit-point-locate-and-device-link/issues/18-mode-layer-visibility-spec.md
  23. 0 71
      .scratch/cockpit-point-locate-and-device-link/map.md
  24. 0 208
      .scratch/cockpit-point-locate-and-device-link/prototypes/anchor-panel-review.md
  25. 0 521
      .scratch/cockpit-point-locate-and-device-link/research/device-position-data-and-rendering-primitives.md
  26. 0 249
      .scratch/cockpit-point-locate-and-device-link/research/device-status-effect-mechanisms.md
  27. 0 70
      .scratch/cockpit-point-locate-and-device-link/spec.md
  28. 0 148
      .scratch/cockpit-point-locate-and-device-link/verify/build-edges.ps1
  29. 0 154
      .scratch/cockpit-point-locate-and-device-link/verify/t08-anchor-layout.mjs
  30. 0 20
      .scratch/cockpit-point-locate-and-device-link/verify/t08-find-devserver.mjs
  31. 0 38
      .scratch/cockpit-point-locate-and-device-link/verify/t08-probe-devserver.mjs
  32. 0 58
      .scratch/cockpit-point-locate-and-device-link/verify/t08-sfc-static-check.mjs
  33. 0 136
      .scratch/cockpit-primary-area-guardrails/issues/01-primary-area-config-contract.md
  34. 0 85
      .scratch/cockpit-primary-area-guardrails/issues/02-camera-limit-numeric-contract.md
  35. 0 86
      .scratch/cockpit-primary-area-guardrails/issues/03-camera-contract-composition-and-entry-state.md
  36. 0 35
      .scratch/cockpit-primary-area-guardrails/issues/04-research-cesium-ground-fog-mechanisms.md
  37. 0 39
      .scratch/cockpit-primary-area-guardrails/issues/05-research-overlay-rectangle-pop-mechanisms.md
  38. 0 46
      .scratch/cockpit-primary-area-guardrails/issues/06-research-three-fog-shader-reuse.md
  39. 0 166
      .scratch/cockpit-primary-area-guardrails/issues/07-fog-implementation-spec.md
  40. 0 112
      .scratch/cockpit-primary-area-guardrails/issues/08-coarse-to-fine-transition-solution.md
  41. 0 97
      .scratch/cockpit-primary-area-guardrails/map.md
  42. 0 55
      .scratch/cockpit-primary-area-guardrails/prototypes/fog-review.md
  43. 0 476
      .scratch/cockpit-primary-area-guardrails/prototypes/prototype-coarse-to-fine-transition.html
  44. 0 45
      .scratch/cockpit-primary-area-guardrails/research/cesium-ground-fog-mechanisms.md
  45. 0 45
      .scratch/cockpit-primary-area-guardrails/research/overlay-rectangle-pop-mechanisms.md
  46. 0 189
      .scratch/cockpit-primary-area-guardrails/research/three-fog-shader-reuse.md
  47. 0 74
      .scratch/cockpit-primary-area-guardrails/spec-fog.md
  48. 0 68
      .scratch/cockpit-primary-area-guardrails/spec.md
  49. 0 108
      .scratch/cockpit-primary-area-guardrails/verify/build-edges.ps1
  50. 0 79
      .scratch/cockpit-real-data-wayfinding/handoff/baselines/map-monitoring.md
  51. 0 141
      .scratch/cockpit-real-data-wayfinding/handoff/baselines/pipe-network-type-namespace.md
  52. 0 79
      .scratch/cockpit-real-data-wayfinding/handoff/baselines/warning-settings-lifecycle.md
  53. 0 164
      .scratch/cockpit-real-data-wayfinding/handoff/baselines/work-order-rate.md
  54. 0 143
      .scratch/cockpit-real-data-wayfinding/handoff/baselines/work-order-read-model-district.md
  55. 0 118
      .scratch/cockpit-real-data-wayfinding/handoff/baselines/work-order-realtime-sync.md
  56. 0 23
      .scratch/cockpit-real-data-wayfinding/handoff/geometry-read-model-review.md
  57. 0 57
      .scratch/cockpit-real-data-wayfinding/handoff/geometry-read-model.md
  58. 0 121
      .scratch/cockpit-real-data-wayfinding/handoff/manual-verify-pipe-type-namespace.md
  59. 0 50
      .scratch/cockpit-real-data-wayfinding/handoff/review-pipe-type-namespace.md
  60. 0 41
      .scratch/cockpit-real-data-wayfinding/handoff/t18-current-preflight.md
  61. 0 34
      .scratch/cockpit-real-data-wayfinding/handoff/t18-evidence-readiness.md
  62. 0 83
      .scratch/cockpit-real-data-wayfinding/handoff/t18-human-takeover.md
  63. 0 85
      .scratch/cockpit-real-data-wayfinding/handoff/work-order-association-verification.md
  64. 0 26
      .scratch/cockpit-real-data-wayfinding/implementation/t29/apply-patch.py
  65. 0 77
      .scratch/cockpit-real-data-wayfinding/implementation/t29/baseline/pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitMapSnapshotController.java
  66. 0 84
      .scratch/cockpit-real-data-wayfinding/implementation/t29/baseline/pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitMonitoringDistributionController.java
  67. 0 166
      .scratch/cockpit-real-data-wayfinding/implementation/t29/baseline/pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitDeviceFactResolverTest.java
  68. 0 602
      .scratch/cockpit-real-data-wayfinding/implementation/t29/baseline/pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMonitoringDistributionControllerTest.java
  69. 0 0
      .scratch/cockpit-real-data-wayfinding/implementation/t29/baseline/pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitPointStatusContractTest.java
  70. 0 0
      .scratch/cockpit-real-data-wayfinding/implementation/t29/baseline/pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitPointStatusSnapshotSerializationTest.java
  71. 0 296
      .scratch/cockpit-real-data-wayfinding/implementation/t29/baseline/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/BusinessCockpitMapSnapshotAdapter.java
  72. 0 128
      .scratch/cockpit-real-data-wayfinding/implementation/t29/baseline/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMapSnapshot.java
  73. 0 95
      .scratch/cockpit-real-data-wayfinding/implementation/t29/baseline/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMapSnapshotResponse.java
  74. 0 407
      .scratch/cockpit-real-data-wayfinding/implementation/t29/baseline/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMapSnapshotService.java
  75. 0 45
      .scratch/cockpit-real-data-wayfinding/implementation/t29/baseline/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitUnassociatedEquipmentSummary.java
  76. 0 181
      .scratch/cockpit-real-data-wayfinding/implementation/t29/baseline/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/BusinessCockpitMonitoringDistributionAdapter.java
  77. 0 402
      .scratch/cockpit-real-data-wayfinding/implementation/t29/baseline/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringDistributionResponse.java
  78. 0 844
      .scratch/cockpit-real-data-wayfinding/implementation/t29/baseline/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringDistributionService.java
  79. 0 259
      .scratch/cockpit-real-data-wayfinding/implementation/t29/baseline/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringDistributionSnapshot.java
  80. 0 135
      .scratch/cockpit-real-data-wayfinding/implementation/t29/baseline/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/point/BusinessCockpitDeviceFactResolver.java
  81. 0 69
      .scratch/cockpit-real-data-wayfinding/implementation/t29/baseline/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/point/CockpitMonitoringPointDeviceSnapshot.java
  82. 0 0
      .scratch/cockpit-real-data-wayfinding/implementation/t29/baseline/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/point/CockpitPointStatusQuery.java
  83. 0 29
      .scratch/cockpit-real-data-wayfinding/implementation/t29/browser-verification.md
  84. 0 21
      .scratch/cockpit-real-data-wayfinding/implementation/t29/commit-backend.py
  85. 0 254
      .scratch/cockpit-real-data-wayfinding/implementation/t29/edit-staged.py
  86. 0 128
      .scratch/cockpit-real-data-wayfinding/implementation/t29/manifest.json
  87. 0 46
      .scratch/cockpit-real-data-wayfinding/implementation/t29/merge-tests.py
  88. 0 79
      .scratch/cockpit-real-data-wayfinding/implementation/t29/pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitMapSnapshotController.java
  89. 0 84
      .scratch/cockpit-real-data-wayfinding/implementation/t29/pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitMonitoringDistributionController.java
  90. 0 187
      .scratch/cockpit-real-data-wayfinding/implementation/t29/pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitDeviceFactResolverTest.java
  91. 0 602
      .scratch/cockpit-real-data-wayfinding/implementation/t29/pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMonitoringDistributionControllerTest.java
  92. 0 191
      .scratch/cockpit-real-data-wayfinding/implementation/t29/pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitPointStatusContractTest.java
  93. 0 46
      .scratch/cockpit-real-data-wayfinding/implementation/t29/pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitPointStatusSnapshotSerializationTest.java
  94. 0 298
      .scratch/cockpit-real-data-wayfinding/implementation/t29/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/BusinessCockpitMapSnapshotAdapter.java
  95. 0 136
      .scratch/cockpit-real-data-wayfinding/implementation/t29/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMapSnapshot.java
  96. 0 102
      .scratch/cockpit-real-data-wayfinding/implementation/t29/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMapSnapshotResponse.java
  97. 0 431
      .scratch/cockpit-real-data-wayfinding/implementation/t29/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMapSnapshotService.java
  98. 0 49
      .scratch/cockpit-real-data-wayfinding/implementation/t29/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitUnassociatedEquipmentSummary.java
  99. 0 182
      .scratch/cockpit-real-data-wayfinding/implementation/t29/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/BusinessCockpitMonitoringDistributionAdapter.java
  100. 0 393
      .scratch/cockpit-real-data-wayfinding/implementation/t29/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringDistributionResponse.java

+ 12 - 0
.gitignore

@@ -1,5 +1,11 @@
 .DS_Store
 .cache
+/logs
+
+# 票 35 收口(2026-10-26):.scratch 是本地工作区(issue 追踪、verify 脚本、离线证据),
+# 不进版本库 —— 维护者要求测试类与 matt-skills 过程产物不传到服务器。
+# 需要长期保留的产物应提升到 src/、docs/ 或 tools/ 下再提交。
+/.scratch/
 
 /dist
 
@@ -11,6 +17,12 @@
 /src/.fes
 /src/.fes-production
 /src/.fes-test
+
+# 面板渲染层闸门(t11)编译出的中间产物,运行时可重建
+.scratch/cockpit-overview-redesign/verify/.ssr
+
+# 只读库探针工具的编译产物(Q.java 每次运行前再编译)
+*.class
 .env.local
 
 #不排除 排除alienkitty 和 oimophysics  基于:https://github.com/alienkitty/alien.js 库

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 7
.scratch/acl-reports/acl-report-3dda1f7cc47f4526b521d9c7ebf5d616.jsonl


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 7
.scratch/acl-reports/acl-report-dee0fae4950e4217b34a6c867f29b7a8.jsonl


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 7
.scratch/acl-reports/acl-report-ebc7560f68d4457e8c6c08fdb01530ee.jsonl


+ 0 - 32
.scratch/cockpit-point-locate-and-device-link/issues/01-research-device-position-data-and-rendering-primitives.md

@@ -1,32 +0,0 @@
-# 调研 —— 点位下设备的位置数据来源与三维呈现原语
-
-Type: research
-Status: resolved
-Labels: wayfinder:research
-Blocked by: None
-
-## 进度:100%
-
-已关闭。产物:[`research/device-position-data-and-rendering-primitives.md`](../research/device-position-data-and-rendering-primitives.md)。只做只读调研;后端与前端源码未改任何文件、未跑 build/dev。
-
-## Question
-
-需求②要求「点击监测点定位后,展示该监测点下所有设备点位的模型及坐标图标」。本票只查**事实**(能做什么、数据在哪),不给建议、不做设计。
-
-1. **数据来源**:现有一张图相关端点里,哪一个能**一次**给出「某监测点下所有设备的经纬度 + 坐标校验 + 状态」?已知(本图 map.md 已复核):`/monitoring-points/{code}` 的 `devices[]`(后端 `CockpitMonitoringPointDeviceCard`)**没有经纬度**;`/monitoring/distribution` 只给「监测点 marker + 未关联设备 marker」;`/equipments/{deviceCode}/archive` 带 `longitude/latitude` 但要逐台取。请读后端源码(`E:\pipe-ner-server`,重点 `cockpit/point/`、`cockpit/monitoring/`、`cockpit/equipment/`、`cockpit/map/` 与 `docs/cockpit/README.md`)给出确切结论:**能 / 不能**;若不能,缺哪个字段、挂在哪个响应类上、要动的类名与端点。
-2. **`deviceCodes` 的语义**:`CockpitMonitoringPointSummary.deviceCodes`(BT-1 / Gogs #59)在哪些端点回传、是否含离线设备、是否受分页或状态过滤影响、空值语义。
-3. **设备与点位的空间关系**:设备的经纬度与所属监测点的经纬度是否同一个值(点位是设备的空间承载对象)?后端有没有「同井/同杆/相对点位」这类相对位置或安装信息?给出源码证据(类名 + 字段名)。
-4. **呈现原语**:Cesium 1.145.0 里呈现「设备点位 + 坐标图标 + 文字标签」,`Entity`(billboard + label)、`BillboardCollection`、`PointPrimitiveCollection` 三者各自的:单点开销、批量更新开销、能否**单独拾取**(不与监测点标记抢)、能否挂 `heightReference` 贴地。给出版本内的结论与出处(随包 d.ts / 官方文档 / 社区实测)。
-5. **设备级资产清单**:仓库内(`public/`、`public/plugins/`、`src/plugins/`、`tools/`、`src/modules/cockpit/styles/`)现有可用的设备级图标/模型资产清单;有没有现成的 glTF/GLB 设备模型(区别于票 34 的城市体块 3D Tiles);`domain/icons.js` 与 `domain/pointStatus.js` 的 `SHAPES` 现有哪些形状。
-
-产物写到 `research/device-position-data-and-rendering-primitives.md`,每条结论都要带来源(文件路径 + 行号,或 URL)。
-
-## Answer
-
-**结论:不能一次拿到(缺口在数据模型,不在响应包装);`deviceCodes` 已交付但前端零消费;设备与点位各存一份独立坐标且后端无相对位置信息;三个 Cesium 原语都能单独拾取、只有 `PointPrimitive` 不能贴地;仓库无设备级模型资产。** 产物:[`research/device-position-data-and-rendering-primitives.md`](../research/device-position-data-and-rendering-primitives.md)(每条结论带类名+行号或 URL)。
-
-1. **数据来源:不能。** 20 个 `/api/cockpit/v1/` 端点无一能在「某监测点下所有设备」这一粒度上给全经纬度 + 校验 + 状态。`/monitoring-points/{code}` 的 `devices[]`(`CockpitMonitoringPointDeviceCard`,11 字段)无坐标,且缺在**上游模型** `CockpitMonitoringPointDeviceSnapshot`——两条 `SourceMonitoringPoint.devices`(`CockpitMapSnapshot.java:75`、`CockpitMonitoringDistributionSnapshot.java:107`)都用它;后两者只给**未关联**设备的坐标;`/monitoring/situation` 的 `abnormalEquipment[]` 有设备坐标但只含异常设备、且无 `monitoringPointCode`;带完整坐标的只有 `/equipments/{deviceCode}/archive`,逐台 = N+1。**缺的字段**:设备级 `longitude/latitude/coordinateValidation`;**要动的类**:`CockpitMonitoringPointDeviceSnapshot` → `CockpitMonitoringPointDeviceCard` → `CockpitMonitoringPointStatusResolver.cards()`(若要覆盖 map/distribution 还要补两处 `new CockpitMonitoringPointDeviceSnapshot(...)`);**端点不必新增**。Gogs #59 交付的是 `deviceCodes`(编码清单)而非坐标,票 09 设想的「#59 已带坐标而前端漏读」可以排除。
-2. **`deviceCodes`**:只在 `map-snapshot` 的 `monitoringPoints[].deviceCodes` 回传(`distribution` 的 `MonitoringPointMarker` 无此字段);构造链无任何状态过滤 → **含离线设备**;在生成摘要时算完再分页 → **不受分页影响**;`source.getDevices()==null` 时退化为 `Collections.emptyList()` → 空值是 `[]` 而非 `null`;`distinct()` 保持源顺序、**未排序**。受 `industry/region/deviceType` 影响的方式是「点位被筛掉则其 deviceCodes 一并消失」。前端全 `src/` 对它 **0 引用**。
-3. **空间关系**:`monitor_point` 与 `equipment_base` 各存**独立**的 `longitude/latitude`,关联表 `equipment_point_rel` 只有 `equipment_id/point_id`(无偏移/高度/朝向/井杆列);全后端 grep 同井/同杆/杆塔/相对位置/安装高度/朝向 → **0 命中** → **没有相对位置信息**。后端只在类注释里声明「已关联设备由它的监测点描述」,**不保证也不校验两点相等**;而走已关联路径时响应里根本取不到设备坐标,所以「是否同一个值」在当前契约下**不可观测**。前端另受 `CONTEXT.md` 的 `Avoid_: 人工坐标偏移` 约束。
-4. **呈现原语**:三者 GPU 侧都是批渲染(`BillboardCollection` 整集合一次 instanced draw;`PointPrimitiveCollection` 一顶点一点、一次 draw),draw call 不随点数线性增长;Entity 的额外代价是 JS 对象 + 每帧 Visualizer update + 标签字形生成(`LabelCollection` 自己建在两个 `BillboardCollection` 上)。三者都能逐元素拾取(各带 `id`,pick color 逐实例);`scene.pick` 只回**最上层一个**(默认 3×3 拾取矩形),重叠时必须 `drillPick`。**只有 `PointPrimitive` 不支持 `heightReference`**——Entity 的 `point` 一旦贴地,引擎内部用无图 billboard 顶替它(`PointVisualizer.js:119-128`)。仓库现状:Entity + billboard、`CLAMP_TO_GROUND` + `disableDepthTestDistance: Infinity`、`scene.pick` → `picked.id`(Entity) → `entity.properties.pointCode` 判据;未用任何 Collection 原语、未用 `drillPick`。
-5. **资产**:**无设备级 glTF/GLB**(45 个 3D 文件全是建筑/车辆/船只/卫星/人体/道具/倾斜摄影瓦片;唯一设备形状的 glTF 只在 `src/plugins/zoneFreeScene/**` 里以 `oss.icegl.cn` 远程 URL 存在)。`FE/` 下 0 个图片文件,图标是内联 SVG(**不是** canvas 光栅化);`SHAPES` 8 个键全是几何原语(`triangle/diamond/square/circle/ring/hollowSquare/hollowDiamond/cross`),**无任何设备形状**;语义色只在 `tokens.css:34-38`(`--lv1..--lv4/--neutral`)。票 34 的城市体块是另一条线,产物在 repo 外的 `D:/GIS/GIS/cityMassing`。

+ 0 - 36
.scratch/cockpit-point-locate-and-device-link/issues/02-research-device-status-effect-mechanisms.md

@@ -1,36 +0,0 @@
-# 调研 —— 设备状态特效在 Cesium 的可用手段与开销预算
-
-Type: research
-Status: resolved
-Labels: wayfinder:research
-Blocked by: None
-
-## 进度:100%
-
-已完成:Cesium 1.145.0 上设备状态特效的五项事实全部核实完毕(可用原语的能力边界、批量做法与 10/50/200 的开销量级、与限距雾后处理的同集合共存与执行顺序、色与形的单一来源在引擎侧的可行边界、拾取与层级),产物落在 [`research/device-status-effect-mechanisms.md`](../research/device-status-effect-mechanisms.md),每条结论带随包 d.ts/源码路径与行号或官方文档链接;查不到的项已在产物里显式列出。只读调研:未改 `src/`、未装依赖、未跑 build/dev/截图。
-
-## Question
-
-需求⑤要求「定位到设备时,设备点位周围产生对应的特效(对应设备状态:报警、预警、工单、正常、离线等)」。本票只查**事实**:Cesium 1.145.0 上这类特效怎么做、代价多大、与既定的雾气后处理是否打架。
-
-1. **可用原语与能力边界**:逐项给出「能否按实体定位、能否只影响一个点、能否被拾取、是否每帧重算」——
-   - `Entity` + `CallbackProperty` 驱动的动画 billboard / `ellipse` / `cylinder`;
-   - `CustomDataSource` 与 `Primitive` + `GeometryInstance` + `Appearance`;
-   - `PostProcessStage` / `PostProcessStageCollection`;
-   - 粒子系统(`ParticleSystem`,注意它挂在 `Scene.primitives` 上,是**全局**而非按实体)。
-2. **批量做法与开销**:有没有「一次 draw call 画 N 个脉冲」的路子(自定义 `Primitive` + `Appearance` 的 instancing、`BillboardCollection` 的 scale 动画、shader 里按 `attribute` 驱动)?给同屏 10 / 50 / 200 个特效点的量级判断(有实测数据最好,没有就写清是推断并说明依据)。
-3. **与限距雾气的共存**(关键):`.scratch/cockpit-primary-area-guardrails` 的 07 已定「自建 Cesium depth 后处理级视距雾(路线 B)」,产物在本地图 `research/three-fog-shader-reuse.md`。请核对:同一 `PostProcessStageCollection` 里再挂设备特效级是否可行?`PostProcessStage` 的 `execute` 顺序语义是什么?该图 06 已更正「`Context.depthTexture` 是**布尔能力标志**而非纹理对象」——设备特效若也要读深度,路径是什么?
-4. **色与形的单一来源**:特效的颜色/形状怎么仍然只从 `styles/tokens.css` 的语义色(`--lv1..--lv4/--neutral`)与 `domain/pointStatus.js` 那张表来(票 06/09 的纪律)。引擎侧有哪些地方**读不到 CSS 变量**、必须像 `markerSvg()` 那样由服务层烘焙(列出具体 API)。
-5. **拾取与层级**:半透明面片/粒子/扩散圈会不会挡住监测点与设备的拾取?`Scene.pick` 与 `Scene.drillPick` 在多个 primitive 叠加时的行为,以及「特效不该吃点击」有哪些做法。
-
-产物写到 `research/device-status-effect-mechanisms.md`,每条结论带来源(随包 d.ts 路径 + 行号、官方文档 URL、或仓库内文件)。
-
-## Answer
-
-**结论:状态特效走「`BillboardCollection` 逐点 `scale`/`color` 动画」为主、「单个自定义 `Primitive`(N 个 `GeometryInstance` + 同一 `Appearance` + batch table)一次 draw call 画 N 个」为升级路径;`Entity` + `CallbackProperty` 只留给 1–3 个聚焦设备;与限距雾同处一个 `PostProcessStageCollection` 可行,但顺序必须显式定为「先特效、后雾」。** 全文与逐条证据:[`research/device-status-effect-mechanisms.md`](../research/device-status-effect-mechanisms.md)。
-
-1. **原语边界(问题 1)**:`Entity` + `CallbackProperty` 能做「按实体定位、只影响一点、可拾取」,但**每帧重算且代价极高**——非常量属性让 `EllipseGeometryUpdater._isDynamic` 判真(`EllipseGeometryUpdater.js:222-239`),走「每实体一个 updater」的 `DynamicGeometryBatch`(`DynamicGeometryBatch.js:14-22`),而 `DynamicGeometryUpdater.update` 每帧 `removeAndDestroy` 再 `new Primitive({asynchronous:false})` 重建(`DynamicGeometryUpdater.js:76-85, 139-153`);社区实测 25 个 CallbackProperty 圆柱 ≈8 FPS、200 个 ≈0 FPS。`CustomDataSource` 只是容器,**不产生渲染原语**。`Primitive` + `GeometryInstance` + `Appearance` 是「一次 draw call 画 N 个」的正统路径(N 个实例合并成一个几何,draw command 数 = `_va.length`,逐实例数据走 batch table 纹理按 `batchId` 查,`Primitive.js:538-543, 1868, 887`)。`PostProcessStage` 是**全屏**后处理,**无法只影响一个点**(除非用只对 3D Tiles feature 有效的 `selected` + `czm_selected`,对点位图标无效)。`ParticleSystem` 挂在 `Scene.primitives` 上、内部自建一个 `BillboardCollection`(`ParticleSystem.js:710, 807`),靠 `modelMatrix`/`emitterModelMatrix` 定位、**没有位置参数、没有 pick 路径**,N 个点就是 N 份粒子模拟。
-2. **批量与开销(问题 2)**:「一次 draw call 画 N 个」**成立**,两条实证路径:① 自定义 `Primitive`(逐实例数据走 batch table 纹理,`Primitive.js:817-963`);② 1.145 的 `BufferPointCollection` 显式一次 `DrawCommand` 画完(`renderBufferPointCollection.js:278-294`),但它 `@experimental`(`BufferPointCollection.js:56-57`)且只能改 size/color,**不建议押注**。10/50/200 的量级判断:`BillboardCollection`(整集合一次 drawElements,逐点脏更新 `BillboardCollection.js:1466-1507`)在 **200 个点仍属安全区**——官方 10,000 个静态 billboard 266 FPS、动画 138 FPS([Billboard Instancing](https://cesium.com/blog/2015/10/28/billboard-instancing/));`CallbackProperty` 椭圆路线 200 个**不可行**。**本票未在本机实测,10/50/200 是依据绘制结构 + 上述可引实测的推断**,真数需在实现票实测。
-3. **与雾气共存(问题 3)**:同一 `scene.postProcessStages` **可以**再挂一个设备特效级(`add()` 无上限、只有名字唯一约束,`PostProcessStageCollection.js:436-466`)。`execute` 顺序 = **加入顺序**(无插入/排序 API,`_index = stages.length`),骨架固定为「AO → Bloom → 色调映射 → 自建 stage 按序链式传色 → FXAA」(`:799-834`);**颜色链式、深度不链式**——每级拿到的 `depthTexture` 都是场景那一张(`:801-827`),所以两级读同一份深度,但**谁后跑谁在最上层**。→ 顺序应定为**先特效、后雾气**,否则侧视时会出现「雾盖住了县城、设备特效却亮在雾上面」,违反该图 07 的门控诉求(`07-fog-implementation-spec.md:20-24`);两级开关要一次原子对齐(`enabled` 是逐级独立的,`PostProcessStage.js:178-182`)。读深度的正确路径 = `PostProcessStage` 默认提供的 `sampler2D depthTexture`(`PostProcessStage.js:32-33, 526-531`)+ `czm_readDepth`;**但点位特效通常不需要读深度**,正常深度测试即可。注意能力探测是**文本正则**(`PostProcessStage.js:397-404`),着色器里必须写成 `uniform sampler2D depthTexture`。
-4. **色与形的单一来源(问题 4)**:**CSS 变量在 WebGL 路径一律读不到**,所以任何引擎侧颜色都必须像 `markerSvg()` 一样由服务层烘焙(现成入口 `mapService.js:631-651` 的 `toneColors()` → `:670-676`)。必须烘焙的具体 API:`billboard.image`(SVG 作为图片加载时读不到页面 CSS,`pointStatus.js:218`)、`Billboard#color`、`Material` 的 uniform(`Material.js:1255-1278` 要求 number/Color,字符串会被当作图片或 stage 输出名)、`ColorGeometryInstanceAttribute`、`BufferPointMaterial.color/outlineColor`、`PostProcessStage.uniforms`、以及自建 GLSL 里的颜色常量(只能走 `uniforms`)。形状仍只来自 `pointStatus.js` 的 `SHAPES`(`:42-59`),引擎层不新增第二张业务映射表(`syncPointMarkers` 只认 `shape`/`color` 是同一条纪律,`cesiumEngine.js:303-319`)。
-5. **拾取与层级(问题 5)**:特效吃不吃点击**只取决于有没有 pickId,与「半透明」无关**。拾取通道只在命令有 `derivedCommands.picking`(由 `command.pickId` 派生,`Scene.js:1810-1817`)或 `depth` 时才画(`executeIdCommand`,`Scene.js:2379-2404`),**没有 pickId 的实体命令被整条跳过**。做法:① 首选 `Primitive({ allowPicking: false })`(`Primitive.js:76, 2055-2059`)或 `BufferPointCollection` 默认 `allowPicking=false`(`BufferPointCollection.js:159`),`ParticleSystem` / `PostProcessStage` 本就无拾取路径;② 特效放独立 collection 且**不带 `pointCode` 属性**(现有拾取口径就是「有 `pointCode` 才算点位」,`cesiumEngine.js:417-431`);③ 做成**空心环**(billboard 拾取看非透明像素)。**最易踩的坑**:不透明特效即使没有 pickId,也有 `depthOnly` 派生命令(`Scene.js:2400-2403`),会在拾取通道写深度挡住背后的设备点——**特效面片必须走半透明 pass**(`Appearance.js:152-153`)。`Scene.pick` 只回最靠近相机的一个;`Scene.drillPick` 是「拾一个藏一个再拾」的多次拾取(`Picking.js:895-941`),可穿透但代价是 N 次 pick pass,且靠改 `show` 实现,与特效独立开关会互相干扰,不推荐。

+ 0 - 111
.scratch/cockpit-point-locate-and-device-link/issues/03-locate-camera-view-contract.md

@@ -1,111 +0,0 @@
-# 镜头「定位到监测点」的视角契约
-
-Type: grilling
-Status: resolved
-Labels: wayfinder:grilling
-Blocked by: None
-
-## 进度:100%
-
-**已收口(resolved,2026-09-18)**:Q1–Q8 全部确认;独立复审完成并并入修正(中心高程取实测地形、断言口径补 height 并去冗余、±200 m 复核口补推导、全球态点选不进区记为边界)+ ADR / 票 04 改后原文全文落票面。下游落盘已执行:ADR 0008 原位修订、决策地图票 04 同步、`CONTEXT.md` 补词条、本图收口。决策要点见 `## Answer`。
-
-## Question
-
-需求①原句:「点击监测点位后,首先应该将镜头移动定位到该监测点位置,首先要能够在三维场景下观察该监测点的状况」——即点一下就该**看得清**这个点位及其周边,而不是只把旋转中心挪过去。
-
-现状与冲突(本图 map.md 事实 3):
-
-- 决策地图票 04 已锁:选中对象时中心改为该对象的**地表高程点**,**保持当前 heading 与 range**(只换中心);取消选中不回退中心,只有左键平移才回退。
-- `services/cameraControl.js` 的 `setFocus()` 就是这个语义;只有 `applyViewPreset()`(票 30 引入)能连 hpr 一起换。
-- 当前 range 是县域级(票 30 home `pitch −55 / range 12000`),因此「只换中心」到不了「能观察该监测点」。
-
-请裁决并**定量**:
-
-1. **定位后的相机三元组**:中心(点位地表点?高程取椭球 0 还是实测地形——票 30 已引入 `sampleTerrainMostDetailed` 的实测口径)、pitch、range(多少米算「看清一个监测点」?是否要分「点位级 / 设备级」两档,与需求⑤的「定位到设备」对齐)。
-2. **heading 保留还是取预设**:保留的话,从任意角度点一个点位会不会落到「背光 / 被楼挡住 / 贴着地形」的视角(票 34 的体块是示意几何,票 21 的 3D Tiles 也在变)。若取预设,预设值是多少、由配置给还是写死。
-3. **与主场景区限位的合成**:`.scratch/cockpit-primary-area-guardrails` 的 02(已 resolved)定了 `range ∈ [120, range_max]`,`range_max = 1651 / (2·tan(fovY/2))`(16:9 ≈ 2,542 m,1:1 ≈ 1,430 m)、相机离地高度 `h ≥ 正下方地形 + 10 m`,并写明「最低仍须能贴近看设备」。本票定的定位 range 与它们**取交集**后是多少?是否存在「在县城视角下能定位、在全球/区外视角下不能」的分叉,若有,谁优先?
-4. **与票 30 的「归位」关系**:定位语义与「家」视角是否互斥?归位按钮要不要接管「回到定位前」?是否需要「返回点位清单/上一视角」?
-5. **是否收窄票 04 的决策**:若定位需要连 hpr 一起换,票 04 的「只换中心」要显式收窄为「**手动**轨道旋转 / 滚轮 / 平移时的中心规则」,并写明是否要在 ADR 0008 补一行指向。(本图只产决策;要改哪句、改成什么,写清原文与改后原文。)
-6. **可复现口径**:定位结果如何被断言(`mapService.getCameraState()` 的 pivot/hpr 期望值,或 DEV 把手),以便将来的走查脚本不靠肉眼。
-
-## Answer
-
-### 已确认决策:第 1 轮(Q1–Q4,2026-09-18 维护者逐条答复)
-
-1. **验收判据(Q1=A)**:点位级定位完成后 —— 点位标记清晰落在画面中心、周边一小片区域(街区级)可辨,**且该点位下的所有具体设备点位都可见**(与需求②联动;「能容纳全部设备点位」这个口径保留到票 05/11 复核 —— 若设备分布比画面跨度宽,数值要重议)。
-2. **机制(Q2=A)**:定位=`applyViewPreset`,**中心 + heading/pitch/range 一起换**(不是 `setFocus` 的只换中心)。因此决策地图票 04 的「只换中心」收窄为「**手动**三键(右键轨道 / 滚轮 / 左键平移)的中心规则」;ADR 0008 第 20 行那两句按第 2 轮确认的改后原文修订。
-3. **与主场景区限位(Q3=A)**:定位**不设例外**,定位 hpr 必须落在限位内(`range ∈ [120, range_max]`、`h ≥ 正下方地形 + 10 m`),被夹就**如实呈现**;不另设更保守的固定上限。
-4. **与归位(Q4=A)**:定位**不改 home**;归位永远回 home;**不新增**「回到定位前 / 上一视角历史栈」。(术语:往 `CONTEXT.md` 补「定位」「归位」两条,只锁「指哪个动作」,不进实现细节。)
-
-### 已确认决策:第 2 轮(Q5–Q8,2026-09-18 维护者逐条答复)
-
-5. **点位级定位的 hpr(Q5=a)**:`heading 0 / pitch −55 / range 500 m`;由配置给(建议 `map.locate` 一节,缺省 0 / −55 / 500),组件不硬编码,**后续可在配置中修改**。复核口保留:若票 05/11 表明设备分布超出 ±200 m(Q1=A 的「设备全可见」不成立),回来重议此数值。
-6. **设备级第二档(Q6=a)**:本票定义两档 —— 点位级(500 m)与设备级(`heading 0 / pitch −55 / range 150 m`),共用同一配置入口 `map.locate`(含 `point` / `device` 两组),**后续可在配置中修改**;票 06 引用本票数值,不另发明第二套。
-7. **取消选中 / 关闭面板后(Q7=a)**:**完全不回退** —— 镜头留在定位视角,用户自己滚轮 / 平移 / 归位离开;换一个点位时直接从当前视角飞向下一个点位的定位视角,不先回退再定位。
-8. **ADR 0008 与决策地图票 04(Q8=a)**:**原位修订** ADR 0008(新增「定位视角」语义,**不新开 ADR**);决策地图票 04 同步那一句;地图雾区「定位契约是否回写 ADR 0008 或新开一条 ADR」随之清掉。**改后原文全文(复审补落,2026-09-18)**:
-
-   ADR 0008「旋转中心」节,改后:
-   > - 选中监测点或设备时,中心是该对象的**地表高程点**(对象高程 + 0;票 30 起按实测地形)。
-   > - 选中**监测点**时(从无选中到选中),镜头平滑飞向该点位的**定位视角**:中心 + heading/pitch/range 一起换(数值与配置入口见 `.scratch/cockpit-point-locate-and-device-link` 票 03;缺省 heading 0 / pitch −55 / range 500 m;取消选中或关闭详情**不回退**,换点位直接从当前视角飞向下一个定位视角)。
-   > - 选中**非监测点对象**(未关联设备、事件等)或**手动三键操作**(右键轨道 / 滚轮 / 左键平移)时,中心规则不变:只换中心,保持当前 heading 与 range。
-   > - 取消选中不立即回退中心或视角;左键平移才把中心更新为地面拾取点。
-
-   决策地图票 04(`.scratch/web-cockpit-decision-map/issues/04`)同步句,改后:
-   > 从无选中到选中**监测点**:镜头飞向该点位的**定位视角**(中心 + hpr 一起换,契约见 `.scratch/cockpit-point-locate-and-device-link` 票 03;取消选中不回退);选中**非监测点对象**或手动操作时仍只换中心、保持 heading 与 range。
-
-**默认确认(无异议,一并落盘)**:① 定位飞行复用 `applyViewPreset` 既有阻尼 / 动画路径,不新增时长开关;② 断言口径(复审修正)= `mapService.getCameraState()` + 宿主 `dataset.camera`:定位完成 = `settled`(其经纬容差 1e-7° 已含 pivot 对齐,不再另比 1e-4°)且 hpr 匹配预设(对应档位),并断言相机离地高度 ≥ 点位地表高程 + 10 m(防入地由 guardrails 兜底,走查补一道)。
-
-**落盘状态(2026-09-18 维护者确认收口后执行)**:ADR 0008 已按上文改后原文原位修订;决策地图票 04 已同步;`CONTEXT.md` 已补「定位 / 归位」词条;本图已收口(Decisions so far + 进度 + 清雾区)。
-
-### 跨图修正(2026-09-18 已核实,guardrails 03 收口)
-
-`.scratch/cockpit-primary-area-guardrails` 的 **03(限位与既有相机契约的合成 + 进出状态机)已落 Answer**(该图待收口),并产出 **`docs/adr/0009`**:**`range_max` 由 02 的 `1651/(2·tan(fovY/2))`(16:9 ≈ 2,542 m)改为 `home.range = 12,000 m`**(归位即最远视野,再往外滚轮即夹);home 预设不改;pitch 不参与区域收紧;防入地 `h ≥ 正下方地形 + 10 m` 不变。
-
-对本票的影响:Q3=A(定位不设例外、落在限位内)**结论不变**,取值锚点改为 12,000 m —— 点位级 500 m 与设备级 150 m **两档均远低于上限,任何画布比例下都不会被夹**,走查断言可写死。实现时以 guardrails 03 与 ADR 0009 为准。
-
-### 复审补答与修正(2026-09-18 独立只读复审后并入)
-
-9. **中心高程(复审补答 —— 原 Question 第 1 问漏答)**:定位中心=点位地表**实测地形**高程(沿用票 30 的 `sampleTerrainHeight` 口径),**不取椭球 0**。理由(复审算实):椭球 0 会把旋转中心埋进沅陵约 111.6 m 的地形里,贴地(CLAMP_TO_GROUND)标记偏离画面中心,**直接违背 Q1=A 的「点位标记清晰落在画面中心」**且断言测不出(旧断言不比高度);设备级 150 m 时相机离地 ≈ 123 m、距地形只剩 ≈ 11 m(贴着 ADR 0009 的防入地 10 m 边),到 120 m 档会入地 ≈ 13 m。中心高程进断言口径(见默认确认 ②)。
-10. **「±200 m」复核口的推导(复审补)**:点位级 500 m / pitch −55° / 16:9 的屏幕竖直地面跨度 ≈ `2·500·tan(fovY/2)/sin55°` ≈ 396 m,即点位两侧各 ≈ ±200 m。复核口即「该跨度须容纳该点位下全部设备点位;若设备散出跨度外(票 05/11 数据落地时验证),回来重议 500 m」。
-11. **全球态点选定位(复审发现,跨图边界,记为已知边界、不裁决)**:guardrails 03 状态机只认「进入系统 / 归位」两条进区锁(ADR 0009)—— 全球态下点选监测点得到定位视角但**不进区**,5 秒闲置会回引导页(既定行为)。本票不裁决「点选是否进区」;若实现后体验不佳,另开票评估。定位两档均远低于 12,000 m,是否有区锁不影响本票数值。
-12. **措辞澄清(复审 note)**:「手动三键」不包含「点选」;准确表述是「选中(点选)监测点 → 定位换 hpr;手动三键(轨道 / 滚轮 / 平移)与非监测点对象 → 只换中心」—— 已并入 ADR 0008 改后原文。
-
-## Comments
-
-### 2026-09-18 第 1 轮:fact-check 与 frontier 发出
-
-事实核实(只读,均已在本仓或 `.scratch/cockpit-primary-area-guardrails` 内复核):
-
-1. `domain/camera.js` 的域层只有弹簧臂三元组 `{heading, pitch, range}`,**没有「相机离地高度 h」这个量**;`RANGE_LIMITS = { min: 120, max: 30000000 }` 是全局常量,**限位(02 的 `range_max`)尚未实现**在代码里 —— 也就是说本票的 range 目前只会被 `[120, 3×10⁷]` 夹住。
-2. 定位链路现有两条且语义不同:`cameraControl.setFocus(point)` = **只换中心**(保持 heading/pitch/range,ADR 0008 第 20 行);`cameraControl.applyViewPreset(中心, hpr, opts)` = **中心 + hpr 一起换**(票 30 为 home 预设引入)。`mapService` 只把「家/归位」接到了后者。
-3. home 预设实测口径:`pitch −55 / range 12000` 是**县域级**(代码注释:4.6 km 街区级、12 km 县域级);`isAtHome()` 的容差是 pivot 1e-4°、range 相对 2%、角度 0.5°。
-4. 主场景区限位(`.scratch/.../02`,已 resolved):`range ∈ [120, range_max]`,`range_max = 1651/(2·tan(fovY/2))`(**16:9 ≈ 2,542 m**、4:3 ≈ 1,906 m、1:1 ≈ 1,430 m,随画布 aspect 变);相机离地高度 `h ≥ 正下方地形 + 10 m`(不足时**夹 range**,不夹 pitch、不动 pivot);**pitch 不参与收紧**;侧视露出的范围外交给 07 的雾气。该图的 home 预设超标 5.8× 冲突归其票 03(尚未 resolved)。
-5. 中心高程口径:票 30 已把「家」的高程改成 `sampleTerrainMostDetailed` 实测(沅陵实测 111.6 m);票 10 的走查实测「贴地标记与高度 0 的中心差约 30px 的地形高程差」—— 即中心取椭球 0 还是实测地形,**肉眼可见**。
-6. 相机状态已有只读出口:`mapService.getCameraState()`(pivot/hpr/focus/dragging/settled)与 `formatCameraState()` 写进宿主 `dataset.camera`,`DEV` 下另有 `__cockpitMap.projectCameraPivot()` —— 断言口径现成,不必新造。
-
-第 1 轮四问已发维护者:Q1「看清」的验收判据 / Q2 定位机制(只换中心还是连 hpr 一起换)/ Q3 与主场景区限位的关系与优先级 / Q4 与票 30 归位的职责边界。
-
-### 2026-09-18 第 2 轮:Q1–Q4 答复已收,frontier 重算
-
-维护者答复:`Q1=A`(点位级定位须能看到该点位下所有设备点位)、`Q2=A`(定位=中心 + hpr 一起换)、`Q3=A`(定位不设例外、落在限位内)、`Q4=A`(定位不改 home、归位永远回 home、无历史栈)。已写入 `## Answer` 第 1 轮。
-
-第 2 轮四问已发维护者:Q5 点位级定位的 hpr 数值(heading/pitch/range 与配置入口)/ Q6 是否在本票定义「设备级」第二档视角供票 06 引用(需求⑤「定位到设备」)/ Q7 取消选中与关闭面板后镜头是否回退 / Q8 ADR 0008 与决策地图票 04 的改后原文。
-
-### 2026-09-18 跨图提示:主场景区限位数值已更新(03 取代 02 锚点)
-
-`cockpit-primary-area-guardrails` 的 **03(限位与既有相机契约的合成 + 进出状态机)已收口 Answer**(维护者 Q1–Q5 + R2-1…R2-6 确认),其中一项**取代本票事实 #4 引用的数值**:
-
-- 旧(02):`range_max = 1651 / (2·tan(fovY/2))` ≈ **2,542 m**(16:9),随画布 aspect 变。
-- 新(03):**`range_max = home.range = 12,000 m`** —— 最远视野 = 归位默认视角;home 预设不改;pitch 不参与区域收紧;防入地 `h ≥ 正下方地形 + 10 m`(不足夹 range)不变。
-
-本票 Q3「与主场景区限位合成(不设例外、落在限位内)」结论**仍然成立**,只是取值按 **12,000 m** 而非 2,542 m。后续实现以 `.scratch/cockpit-primary-area-guardrails/issues/03` 与其 `docs/adr/0009` 为准。雾的门控与 `areaState` 输入契约见该图 07(open)。
-
-### 2026-09-18 第 3 轮:Q5–Q8 答复已收,待最终确认
-
-维护者答复:`Q5=a`(点位级 0/−55/500,配置可改)、`Q6=a`(两档,设备级 0/−55/150,配置可改)、`Q7=a`(取消选中/关闭不回退)、`Q8=a`(ADR 0008 原位修订 + 票 04 同步,按第 2 轮拟文)。已写入 `## Answer` 第 2 轮;跨图修正(guardrails 03 的 `range_max = 12,000 m`)已核实并入 Answer。
-
-### 2026-09-18 第 4 轮:独立复审 + 收口
-
-按维护者要求做独立只读复审(全新上下文子代理,只读不改文件),发现并并入 `## Answer`:① 中心高程口径补答(取实测地形、不取椭球 0 —— 否则破坏 Q1=A 的点位居中验收,150/120 m 档贴地/入地);② ADR 0008 与票 04 的**改后原文全文**落票面(此前只引用未落全文);③ 断言口径去冗余(settled 的 1e-7° 已含 pivot)并补 height 断言;④ 「±200 m」复核口补推导;⑤ 全球态点选定位不进区记为已知边界(guardrails 03 状态机,本票不裁决)。
-
-维护者确认收口:`Status: claimed → resolved`、进度 100%。下游落盘已执行:ADR 0008 原位修订、决策地图票 04 同步、`CONTEXT.md` 补「定位 / 归位」、本图 Decisions so far + 进度 + 清雾区。按维护者要求,本票结论另以 `/to-spec` 固化为 `.scratch/cockpit-point-locate-and-device-link/spec.md`。

+ 0 - 113
.scratch/cockpit-point-locate-and-device-link/issues/04-panel-anchor-following-form.md

@@ -1,113 +0,0 @@
-# 监测面板锚定跟随的形态与退化策略
-
-Type: grilling
-Status: resolved
-Labels: wayfinder:grilling
-Blocked by: None
-
-## 进度:100%
-
-维护者已确认完整结论,五轮讨论覆盖目标、边界、风险、选项权衡与决策;结论见 `## Answer`,本票收口。后续 `/to-spec` 已将本票结论固化为同一张 map 的 [12: 锚定跟随规格](12-panel-anchor-following-spec.md),可供后续实施;同图 05/06/07/08 继续消费本票锚定契约。
-
-**2026-09-24 原位修订(走查后)**:维护者改变遮挡规范主意,`## Answer` 顶部新增修订节 R-1~R-4,取代原文的「避让左右栏/图例/底部」与「被遮住或放不下则暂隐」,并改定**层级低于四个详情窗、可被遮盖、只在完全离开画面时隐藏**。原文保留以追溯;实现以修订节为准。规格 [12](12-panel-anchor-following-spec.md) 已同步。
-
-下一步:按子票 12 规格(含修订节)实施并核验 1080p/4K 手感;本票保留已收口状态。
-
-## Question
-
-需求③原句:「弹出的监测面板应跟随监测点位置移动,表明该面板所属的监测点位」——面板必须**看得出它属于哪个点位**。
-
-现状(本图 map.md 事实 4):`components/PointDetailCard.vue` 是浮在左右两栏之间的**固定**浮层(`CockpitShell.vue` 里 `main` 内地图与两栏的兄弟),不读屏幕坐标;左右两栏 `z-index:2` 压在满幅地图上(票 07 记录的定位约束);一张图顶部还有图例与抬头(票 33)、底部有摘要行与引擎状态小字(票 32)。
-
-请裁决:
-
-1. **「跟随」的形态**三选一或组合:(a) 世界坐标锚定 —— 把点位投到屏幕、面板贴着它显示;(b) 屏幕固定 + 归属指示 —— 面板仍在固定位,另加引线/标签指向点位;(c) 折中(近处锚定、远处或出界时回固定位)。选哪个,理由是什么(可读性 / 遮挡 / 手感)。
-2. **投影口径**:用 `SceneTransforms.worldToWindowCoordinates` 还是 `camera` 手算?锚点是点位**地表点**还是**标记顶部**(票 10 的走查实测:贴地标记与「高度 0 的中心」相差约 30px 的地形高程差);z 在相机背面/地平线以下时怎么判(`Cartesian4.w` 或投影后判断)。
-3. **避让与翻转**:与左右两栏、顶部图例、底部摘要行与引擎状态行的避让规则;四个方向翻转的**判据**(可用几何表述,例如「面板矩形与栏矩形相交即翻转」);面板最大尺寸与滚动上限。
-4. **退化**:点位在屏外、被 3D Tiles 体块遮挡、或相机**飞行途中**时,分别贴边 / 隐藏 / 回固定位哪一种?飞行途中是否实时跟随(每帧投影的帧率成本,是否需要节流)。
-5. **改动边界**:这是纯表现层(`components/PointDetailCard.vue` + 少量服务层投影查询),还是要动 `domain/detailFlow.js` 的层级与 `stores/detailStore.js` 的选择语义?写明结论,避免实现票自由发挥。
-6. **与关闭/取消选中的对齐**:关闭面板后锚点保留还是收回?与票 04「取消选中不回退中心」是否同一套语义。
-
-## Answer
-
-### 维护者复验修订:详情按当前对象换锚(优先于旧锚点条款)
-
-维护者直接要求修改“两级详情都指向父监测点”的旧共识:点位摘要跟随监测点,点击设备点或卡片进入设备详情后,面板及其屏幕连接短线跟随所选设备视觉标记;设备 A→B 同步换锚,返回摘要恢复监测点锚点。设备的 pointCode 业务归属不变。
-
-锚点按设备自身贴地位置与展开 pixelOffset 投影,短线接当前图标外缘并保留 12 px 空隙。无有效位置的设备显示自身原因的固定详情,不借父点坐标;可上图标记暂失时暂隐并保留选择。既有相机、关闭不回退、侧栏层级与裁切规则维持。规格 12/15、CONTEXT 与实现票 47 已同步;旧 Q2 与“设备详情仍指向所属监测点”保留为历史,冲突以本节为准。
-
-> ### ⚠ 2026-09-24 原位修订(本节优先于下方原文)
->
-> 维护者在票 08 走查后对**遮挡规范**改主意,以下条款**取代**原文对应条款。原文保留不删以便追溯;凡冲突,以本节为准。
->
-> **R-1(取代「避让业务栏/图例/底部摘要」与「被 UI 遮住则暂隐」)**:浮动面板**不再把左右业务栏、顶部图例、底部摘要行、无地图位置浮块当作避让矩形**,也**不再因为标记落入这些区域而暂隐**(原文第三轮 Q12=A 作废)。面板只避开**画布边缘**。
->
-> **R-2(取代「四周都放不下则暂隐」)**:取消 `no-space` 暂隐。面板**允许被屏幕裁切**(可能出现只剩一半的情况),不再为「放不下」而整体消失。
->
-> **R-3(层级)**:浮动面板的**层级低于左右两侧的四个详情窗**(在 `--z-columns` 之下),因此**允许被它们遮盖**。维护者先前提过「在详情窗之上」,随后明确更正为「让浮动窗口层级比详情窗口低就好,这意味着浮动窗口能够被两边的四个详情窗遮盖」——**以更正后的说法为准**。
->
-> **R-4(唯一的隐藏条件)**:**只有窗口完全离开画面时才隐藏**。这是 R-2 的用意:避免「移动到某个会被详情窗遮住的位置时突然消失」这种突兀行为——被遮住是允许的,消失不是。
->
-> **仍然不变**(本节不动它们):始终贴可见标记、四向顺序、不压图标、卡片内滚动、暂隐保留两级选择与顶栏退出、无地图位置特例、关闭不回退镜头、投影口径与可见性以视觉标记为准。
->
-> **修订理由与代价(如实记录)**:新规范与参考图一致(信息卡与场景的关系),代价是极端拥挤时面板会被裁切或被侧栏盖住;维护者已明确接受该代价。`no-space` / `icon-covered` 两个暂隐原因**不再触发**,实现时应移除或标为废弃,避免留死代码。
->
-> **连带影响**:本票的派生规格 [12](12-panel-anchor-following-spec.md) 已同步修订;票 08 原型的控制台仍按老规范做(原型只记约束、不再改),实现以本节为准。
-
-### 目标与边界
-
-- 打开某监测点的两级详情时,面板始终贴着该监测点的**视觉标记中心**随画面移动,清楚表达归属;进入设备级详情仍指向所属监测点。地图上的未关联设备没有上级监测点,打开其设备详情时锚向自身的有效地图标记。
-- 面板不得盖住图标。业务对象确属**无地图位置**时仍可从台账打开注明原因的固定详情,这是没有有效地图锚点时的特例,不伪造标记或跟随关系;无有效坐标的设备也不上图。
-- 关闭详情时收起卡片、短线和面板专属指示,清除详情选择;镜头与旋转中心依 ADR 0008 留在当前视角,不因关闭而回退。点位级与设备级导航、取数语义不变。
-
-### 风险与选项权衡
-
-- 维护者选择**始终贴点**,以明确归属;固定面板加引线或近远切换到固定位置不满足这一形态。长清单与业务栏争空间时,依**右→左→下→上**尝试贴点翻转,缩到可读下限并在卡内滚动;仍放不下则暂隐,**不回固定位**。
-- 现有监测点标记贴地、18×18 CSS px 且中心对齐,忽略深度遮挡;椭球高度 0 的经纬度投影会偏离视觉标记。以贴地后的**视觉标记中心**为锚,卡片边缘距图标外缘至少 **12 CSS px**,细短线接至图标外缘,不能压住图标。3D Tiles 挡住地表而图标仍看得见时,卡片继续跟随图标;不另按地表遮挡结果让它消失。
-- 标记离屏、在相机背后或地平线后、被业务 UI 覆盖、投影无效、或四向都放不下面板时,**暂时隐藏面板**,保留选择、设备层级和已取详情;可见且空间恢复后自动重现。不沿用上帧旧位置,不因经过屏外而关闭或重取数据。
-- 暂隐时顶栏留简短状态入口,显示当前对象名称或编码及暂隐原因,提供**关闭**;只有仍有有效标记时才提供重新定位。换快照后按编码重新关联有效标记;新快照尚未查完或查找失败时保留选择与关闭入口,不持有旧 Cesium 实体。后续票 05 已裁定:同一新快照完整加载仍无该点时关闭详情并说明原因。
-
-### 投影、避让与性能决策
-
-- 使用 Cesium 世界坐标到画布坐标投影,换算成窗口坐标;显式校验贴地标记位置、相机前方与地球背面/地平线、画布边界和盖住图标的 UI。投影空值、非有限值或无效位置直接暂隐,不另写相机矩阵手算投影。可见性与用户实际看到的标记一致;3D Tiles 遮住地表不等于视觉标记不可见。
-- 左右业务栏、顶部图例和调试抬头、底部摘要与引擎状态,以及“无地图位置”浮块,都作为避让矩形。候选卡片必须在地图画布可用区域内,不与这些矩形或图标相交;按右、左、下、上尝试,画布边缘留约 **12 CSS px**;侧边可沿垂直方向滑动但连线仍指向同一图标。使用运行时实际矩形交集判定。
-- 面板宽 **300–420 CSS px**;高度上限为扣除避让区后的地图画布**可用屏幕高度**的 **80%**,也不能超过候选矩形的实际可用高度。可用高度低于约 **280 CSS px** 时暂隐;标题和关闭入口固定在卡内,长设备卡片/设备详情正文在卡内滚动。“可用屏幕高度”不指 `CONTEXT.md` 定义的地理**主场景区**。
-- 定位飞行、轨道旋转、滚轮与平移期间,卡片连续跟随可见图标。只为当前一处锚点随画面已渲染帧投影与布局,每帧最多一次;无详情、页面不可见或相机稳定且布局未变化时停止,恢复或尺寸变化后立即重算。4K 帧耗与手感交给后续实现走查核验。
-- 以当前地图对象的编码取得有效标记;引擎服务提供只读的贴地标记位置、投影与可见性查询,组件持有暂时可见/落位状态。**不改** `domain/detailFlow.js` 的两级导航、`detailStore.js` 的 `{level, pointCode, deviceCode}` 选择语义,不把每帧坐标塞进业务 store。当前仅有开发态 `projectPivot`,地图点选也还未兑现本图票 03 的定位预设;后续实现接线应服从该票已锁的真实地形与完整视角预设,不以旧代码的高度 0、只换中心行为作为目标。
-
-### 验收与后续
-
-- 走查飞行和手动移动中的贴合且不盖图标;标记离屏/被 UI 遮住时暂隐、顶栏可关闭、重入自动恢复原详情;四向翻转、滚动与无空间暂隐;侧视地表被体块挡住但图标仍见时继续跟随;换快照后按同编码重关联、查找未完成或失败时顶栏仍可退出;新快照完整确认不存在时依票 05 关闭详情。无地图位置与未关联设备各守其例外。
-- 本票只锁**监测点详情面板锚定跟随**;设备点位展开、设备与三维点位联动、状态特效分别归本图 05/06/07,视觉/手感原型归 08。若 1080p/4K 实测表明 300×280 CSS px 下限或帧性能不合适,回本票重议数值,不让实现票自行改掉“贴点 / 暂隐”的优先级。
-
-## Comments
-
-### 第一轮:目标与边界(已收到选择,待最终确认)
-
-已锁目标:点位选择后定位到点位视角(本图票 03,当前代码尚未接线);关闭详情不回退镜头(ADR 0008);详情浮层目前位于一张图与左右栏之间的固定区域,不读取点位屏幕坐标。
-
-维护者选择:Q1=A(面板始终贴点移动);Q2=两级都指向所属监测点(未关联设备没有虚构的监测点锚);Q3=详情随不可见点位隐藏;Q4=关闭时面板专属的归属指示收回,镜头按既有契约不回退。第一轮选择仍需下游边界细化,不将本轮选择当作最终结论。
-
-### 第二轮:隐藏、空间、飞行与特殊入口(已收到选择,待最终确认)
-
-维护者选择:Q5=A(临时隐藏;保留当前点位/设备层级与已取数据,点位重入视野时自动重现);Q6=B(四周不足时缩至可读下限、内部滚动,仍放不下则隐藏,不回固定位);Q7=A(飞行和手动移动期间连续跟随);Q8=A(无地图位置的监测点仍可从台账打开注明原因的固定详情,属于无锚点特例);Q9=A(地图上的未关联设备以自身标记为锚,不虚构所属监测点)。
-
-只读核实:当前标记 `HeightReference.CLAMP_TO_GROUND`、中心锚点且 `disableDepthTestDistance: Infinity`,不能直接把“地表被体块挡住”推作“标记不可见”;投影当前仅有 `worldToWindowCoordinates` 与画布原点换算,没有遮挡/背面有效性判据。现有地图点选路径仅开详情,尚未兑现本图票 03 的预设定位契约;本票讨论按已锁目标契约,现状差异留实现时解决。
-
-### 第三轮:锚点、遮挡、避让与尺寸(已收到选择,待最终确认)
-
-维护者选择:Q10=A,并特别要求贴视觉标记中心时**不能遮住标记图标**;Q11=A(可见性以用户实际看得到的标记为准,当前标记忽略 3D Tiles 深度遮挡,地表点被挡而标记可见时继续显示面板);Q12=A(标记落入业务栏或图例遮挡区时暂时隐藏、保留选择);Q13=同意依次右、左、下、上尝试,按实际矩形避让左右栏、顶部图例/标题、底部摘要/引擎状态及“无地图位置”块;Q14=认可宽 300–420 CSS px、高不超过主场景区可用高度 80%、约 280 CSS px 高度下限,长内容在卡内滚动。间距、无效投影、帧率与数据边界已进入第四轮。
-
-### 第四轮:连接线、投影、性能与状态边界(已收到选择,待最终确认)
-
-维护者选择:Q15=图标外缘与卡片至少 12 CSS px 间距,画细短线到图标外缘,不遮盖图标;Q16=沿用 Cesium 世界坐标投影,并显式校验前方/地平线后、屏内和 UI 遮挡,投影缺失/无效即临时隐藏,不沿用旧位置;Q17=仅对当前一锚点随画面帧更新,一帧至多一次,没有详情/页面不可见/镜头稳定且布局未变化时停算,恢复后重算;Q18=沿编码从当前地图对象/实际标记取锚点,投影与布局归表现层,不改 `domain/detailFlow.js` 的两级导航状态和 `detailStore.js` 的选择语义;Q19=换快照暂失标记时暂时隐藏、选中保留,同编码重新出现后自动重现,即便标记永久从快照消失也保留选择;Q20=尺寸上限按**地图画布扣除避让区后的可用屏幕高度**表达,不把 `CONTEXT.md` 的地理“主场景区”当屏幕尺寸。
-
-剩余风险已在第五轮裁定;当前 `projectPivot` 只提供窗口坐标且不够判断视觉标记的有效性,未来引擎适配需增加只读查询。
-
-### 第五轮:暂隐后的退出路径(已收到选择,待最终确认)
-
-维护者选择 Q21=A:暂隐时顶栏保留简短状态入口(对象名称或编码、暂隐原因和关闭动作);仍有有效标记时才提供重新定位,不向消失的标记发无效定位。该选择既保留 Q19 的隐藏选择,也避免卡片上的关闭按钮消失后无法退出。完整拟议结论随后在对话中供维护者核对;确认前 `## Answer` 保持待填。
-
-### 收口确认
-
-维护者已明确选择“确认,按此收口”。据此将五轮结论写入 `## Answer`,本票改为 `Status: resolved`、进度 100%。

+ 0 - 42
.scratch/cockpit-point-locate-and-device-link/issues/05-device-point-layer-trigger-and-lifecycle.md

@@ -1,42 +0,0 @@
-# 设备点位图层的触发、生命周期与「关联设备不上图」的裁决
-
-Type: grilling
-Status: resolved
-Labels: wayfinder:grilling
-Blocked by: None
-
-## 进度:100%
-
-本票的讨论与取舍已由维护者确认;`resolved` 表示决策完成,不表示设备图层或模式矩阵已经实现。设备级坐标契约仍由票 [09](09-task-backend-device-coordinate-contract.md) 推进,视觉、拾取与重叠设备分别留给票 [06](06-panel-to-scene-device-correspondence.md)、[07](07-device-status-effect-visual-language.md)、[10](10-device-model-asset-definition.md)、[11](11-overlapping-device-points.md)。
-
-下一步:本票结论已由 `/to-spec` 固化为同一张 map 的[14 号关联监测设备图层规格](14-associated-monitoring-device-layer-spec.md);现有 `spec.md` 继续属于票 03,未覆盖。后续实施须接入 `deviceCodes`、取得后端设备坐标与校验契约或采用已定清单降级,并在接线时更新旧源码注释。全图模式矩阵的其余图层由票 [13](13-mode-layer-visibility-matrix.md) 另行裁决。
-
-## Question
-
-需求②希望在选中监测点后展示该点下所有关联监测设备的位置,与已关闭实现票 10 的“关联设备不作为独立地图标记重复渲染”及其当时的结构性保证冲突。本票裁决默认摘要标记与选中态关联设备图层的边界、触发和生命周期,并确保分页、快照、图例和计数不混用。
-
-## Answer
-
-### 目标与边界
-
-- 用**关联监测设备图层**命名选中监测点后出现的独立临时显示层;它不是新的“设备点位”业务对象。默认地图摘要仍是监测点与未关联设备的分页序列,关联监测设备不写入该序列,也不改未关联设备已有的独立十字标记和直接打开设备详情的行为。“关联设备不上图”只约束**默认摘要标记计划**,不禁止选中后临时显示。
-- 地图标记或监测点台账卡片选中同一个监测点,均自动启动镜头定位与关联设备加载,二者并行;不增加“展开设备”操作,也不等镜头停稳才显示可上图设备。设备标记拥有独立的 `deviceCode` 与所属 `pointCode` 拾取身份,不冒充监测点标记。具体形状、尺寸、贴地与重叠点选择由票 06/10/11 裁决。
-- 本票只定该图层随一级模式变化的规则。总体概览、运行监测显示所选监测点全部可上图关联设备;基础设施隐藏该图层;预警处置只显示**设备自身**状态为 `ALARM`/`WARNING` 的关联设备,不借监测点聚合状态推断。设施、事件等其余图层的全图模式矩阵由票 13 裁决,不能把本票说成已实现整张图。
-
-### 风险与数据前提
-
-- `map-snapshot` 的点位 `deviceCodes` 能列出该点关联设备(含离线、不受摘要分页影响),但当前前端适配器尚未读取;点位详情设备卡片还没有设备级坐标与校验,票 09 负责推进后端契约。后端未给设备坐标时保留清单并如实标“位置待确认/不可用”,不能以监测点坐标冒充设备位置,也不能人工偏移。只有后端设备级 `coordinateValidation.renderable === true` 且坐标可用时才可上图。
-- 地图快照的 `snapshotAt` 只锁同一次地图分页;点位详情请求不携它、也可能复用缓存。因此采用**尽力展示并明确提示**“设备位置可能与当前快照不同”,不宣称跨端点同版。关联总数以**当前快照该点去重后的 `deviceCodes`** 为准;清单尚未到达或缺失时显示“待确认”,不拿详情统计或卡片数冒充总数。若快照是 `[A,B]`、详情是 `[B,C]`:B 正常展示,C 卡片标“与当前快照不一致,不能上图”,A 以编码占位并标“设备详情待确认”;设备图层只考虑 A、B,同时提示跨端点数据不同步。无法确认设备归属时不上图。
-- 现网历史只读记录为 7 个监测点、2 个点带设备、共 **3 条有效关联**,没有保留逐点原始行,不能推成单点设备台数上限;10/50/200 等仅为性能推断。默认显示所有坐标准入有效且属于当前模式的关联设备,不静默截成前 N 台或只画异常(预警处置的模式筛选除外)。真规模超预算后的聚合与“还有更多”由票 11 决定,被聚合或未实际绘制的设备不得虚报为逐台已上图。
-
-### 触发、生命周期与选项取舍
-
-- 选择**选中态临时图层**,不采用“永远不上图”或“关联设备全局常驻”。关闭详情立即收图层但不回退镜头;从监测点 A 换成 B 立即撤下 A、只加载 B,丢弃 A 的迟到结果。同一 `snapshotAt` 翻下一页只增加摘要对象,不重建当前设备图层,也不把关联设备计入摘要分页数量。
-- 手动刷新、快照失效后重启或全局筛选重载时,保留选中身份与详情,先撤下旧设备;新快照中找到该点后重新取设备。第一页没找到不等于点不存在:后台沿同一快照逐页查找,直至找到或完整结束;地图仍渐进显示。查找失败保留“待确认”并允许重试,完整结束仍无该点才关闭详情;不能仅凭摘要合并态最近 2000 个对象的保留护栏判其不存在。台账先选到尚未加载进地图的点时也先定位并开详情,设备图层待当前快照核对后再展开;完整加载确认不在当前筛选范围时关闭详情并说明原因。
-- 一级模式切换只调整图层可见性与选择态,Cesium Viewer 常驻,不因此换地图快照。三个分项之间互切清空选择并收图层;总体概览与任一分项互切保留选择身份,但目标模式不显示所选点时暂停详情及设备图层,切回总览且该点仍在当前范围时恢复,相机不回退。这是 ADR 0005“切换页面保留选中”的**显式一级模式例外**;状态刷新和 WebSocket 更新的原有保留规则继续有效。
-
-### 图例、计数和后续记录
-
-- 默认摘要的八行点位状态/未关联设备图例、`data-map-markers*` 与“点位标记”数,后续仅统计**当前模式实际可见的摘要标记**,不计关联设备。关联监测设备另设图例,只统计当前模式实际已绘制的设备;设备状态视觉规则由票 07/10 决定,不将监测点六态词表直接当作设备状态。
-- 当前快照确认的关联设备按互斥原因分成**模式隐藏、实际已上图、后端明确无地图位置、位置/状态/绘制待确认或失败**,合计解释去重的关联总数。坐标契约缺失、加载中、请求失败和引擎绘制失败均不可算成“已上图”或“明确无位置”。预警处置里设备状态未知时暂不上图,归“待确认/失败”,不猜成正常、报警或“因模式隐藏”;无效坐标设备保留在清单并给明确原因。原始设备清单完整保留,即使模式只画其中一部分。
-- 已关闭的[实现票 10](../../cockpit-shell-panel-layout/issues/10-monitoring-point-detail.md)与[点位图例票 09](../../cockpit-shell-panel-layout/issues/09-point-status-legend.md)已补记**未来语义**,不追认当时已交付关联设备图层;[ADR 0005](../../../docs/adr/0005-web-integration-and-performance.md)已同步模式切换例外。`detailFlow.js` 与 `MapCanvas.vue` 中绝对化的“地图对象永远只来自摘要”等注释,在后续设备图层接线时按本票同步修改;当前代码尚无该图层,不提前写成已实现。跨图层模式矩阵由同 map 票 [13](13-mode-layer-visibility-matrix.md) 留存。

+ 0 - 52
.scratch/cockpit-point-locate-and-device-link/issues/06-panel-to-scene-device-correspondence.md

@@ -1,52 +0,0 @@
-# 面板选设备与三维设备点的对应反应
-
-Type: grilling
-Status: resolved
-Labels: wayfinder:grilling
-Blocked by: 05
-
-## 进度:100%
-
-**已收口(resolved,2026-09-18)**:两轮十问全部裁定并落盘 `## Answer`;维护者确认收口。结论要点:hover=双侧(面板↔三维互指)、选中=外环+放大+引线(连监测点标记中心,重叠时引线退化不画)、首次进设备级飞 150 m 且设备级内切换只换中心、返回点位级回 500 m、全双向联动且锚点仍跟随监测点、其余设备压暗+缩小(阈值 ≥ 3 台)、hover 撤弱化、独立 `pickDeviceMarkerAt()` 且设备点优先、可断言字段 `data-map-device-picked` / `data-map-panel-selection` / `data-map-device-hovered`。下游落盘:本票结论由 `/to-spec` 固化为规格子票挂到同一张 map;表现层数值(引线虚实/颜色、压暗缩小程度、hover 节流)留 08 原型与实现票现场定;与票 11 的边界(引线退化归本票 / 重叠拾取归 11)随本票收口生效。
-
-## Question
-
-需求④原句:「点击监测面板中的设备后,三维场景下的设备点或模型应对应反应,表达对应关系」。
-
-请裁决:
-
-1. **反应语言**:hover 与「选中」分别怎么表现(高亮 / 放大 / 描边 / 引线 / 标签展开 / 其余设备压暗)?票 09 已把**颜色 + 形状 + 文字**三线索绑给状态(色键已被占用),对应反应**不得**复用色键语义,用什么维度区分「这是被选中的那台」(尺寸?外环?引线?标签?)。
-2. **是否同时定位镜头**:需求⑤开头写「定位到设备时」——点面板里的设备是否要把镜头定位到该设备?若是:它与需求①的点位定位是两档视角(点位级 → 设备级)吗?视角参数归票 03 还是本票(写明分工,别两票各定一半)。
-3. **双向联动**:点三维里的设备点是否反选面板卡片并滚动到该卡片?面板第二级(设备详情,实现票 11)打开时是否仍保持三维对应?关闭设备级回到点位级时如何还原。
-4. **多设备弱化**:选中一台时其余设备压暗 / 缩小 / 隐藏哪一种;有多少台时才需要弱化(阈值);与票 09 图例的计数是否一致。
-5. **拾取优先级**:设备点位与监测点标记重叠(设备坐标 ≈ 点位坐标)时谁先被拾到?票 10 已定 `pickPointMarkerAt()` 只认带 `pointCode` 的实体,设备拾取要不要新入口(`pickDeviceMarkerAt()`?),以及它相对票 22「管线交互优先级低于监测点和设备点位」的位置。
-6. **可断言口径**:宿主 `dataset` 上新增哪些只读字段(类似 `data-map-picked`),让将来的走查能对着断言而不是看图。
-
-## Answer
-
-### 已确认决策(两轮:Q1–Q6 + Q7–Q10,2026-09-18)
-
-1. **反应语言(Q1)**:
-   - **hover=双侧**:面板 hover 设备卡片 → 三维该设备点轻放大(瞬态);三维 pointermove hover 设备点 → 面板对应卡片高亮(复用票 10 的 `detailStore`/`selectDevice` 链路)。三维侧挂 pointermove 拾取,其节流/静止成本交实现,方向已接受。
-   - **选中=外环 + 放大 + 引线**:外环承接票 07 预留的「选中强化」方向,并叠加尺寸放大一档;引线从设备点连到**监测点标记中心**(即票 04/12 的面板锚点),表达「这台设备属于这个点、面板正在说它」,随锚点走。
-   - 对应反应**不得复用色键**(票 09/07 已把颜色绑给设备五态);「颜色不是唯一线索」的验收不被破坏。
-2. **镜头(数值仍归票 03 `map.locate`;本票只定行为)**:
-   - 点监测点 → 点位级定位(500 m);**首次进入设备级**(从点位级点设备卡片)→ 设备级定位飞一次(150 m);设备级内 A→B 设备切换 → **只换中心不重飞**;**返回点位级 → 回 500 m 定位视角**。返回点位不是票 03 的「取消选中不回退」(监测点仍选中,只是层级回退),两者不冲突。
-3. **双向联动(全双向)**:
-   - 三维点设备点 → 面板滚动到该卡片并选中(进设备级第二级,复用 `selectDevice`);第二级打开时三维保持该设备选中强化;「返回点位」→ 面板回第一级、三维还原为监测点选中态(设备外环/放大/引线清除)。
-   - **面板锚点仍跟随监测点标记中心**(票 04/12 与 CONTEXT.md「面板锚点」词条不动);三维强化跟随设备,锚点归属不换。
-4. **多设备弱化**:其余设备**压暗 + 缩小**;**阈值:设备数 ≥ 3 台才启用弱化**,少于 3 台全亮。弱化不影响票 07 图例计数(图例只计实际绘制)。**hover 叠加规则**:hover 只作用于非选中设备——被 hover 的撤弱化 + 轻放大(不叠外环/引线),已选中的保持强化不被 hover 干扰。
-5. **拾取(Q5)**:
-   - 新建**独立入口 `pickDeviceMarkerAt()`**(认 `deviceCode`,不混 `pointCode` 归属判据;票 05「不冒充监测点标记」由此结构性保证),点选链路**先设备点、再监测点**——正常单点设备点优先,异常回落监测点(回点位级)。未关联设备既有拾取链路不变。
-   - 重叠时的 drillPick/展开/逐台点选归票 [11](11-overlapping-device-points.md),本票不裁;引线退化归本票(见边界)。管线交互优先级低于监测点和设备点位(票 22)保持不变。
-6. **可断言口径**:宿主 `dataset` 新增只读字段,走查对断言不对图:
-   - `data-map-device-picked` = 当前选中设备 `deviceCode`(可空)
-   - `data-map-panel-selection` = `none|point|equipment`(与 `detailStore.selection` 对应)
-   - `data-map-device-hovered` = 当前三维 hover 设备 `deviceCode`(可空)
-   - 不破坏既有 `data-map-picked`(票 10 走查在用)。
-
-### 边界与分工
-
-- 引线退化:设备点与监测点坐标相同(引线长度 0)时不画引线,靠外环 + 放大表达;重叠拾取/呈现归票 11,引线退化属本票的选中表现语言。
-- 表现层细节(引线虚实/颜色、压暗缩小程度、hover 拾取节流阈值)留给 08 原型与实现票现场定,本票恰锁语义,不锁数值。
-- 本票只产决策,不实现功能;实现归属本图收口后的实现票。

+ 0 - 139
.scratch/cockpit-point-locate-and-device-link/issues/07-device-status-effect-visual-language.md

@@ -1,139 +0,0 @@
-# 设备状态特效的视觉语言、门控与并发上限
-
-Type: grilling
-Status: resolved
-Labels: wayfinder:grilling
-Blocked by: 02, 05
-
-## 进度:100%
-
-维护者已逐条裁定问题 1–7(两轮),结论见 `## Answer`,本票收口。**02 与 05 均已 resolved**,本票的问题 1/2/4/5/6 以产物 [`research/device-status-effect-mechanisms.md`](../research/device-status-effect-mechanisms.md) 为可行边界:首选 `BillboardCollection` 逐点 scale/color 动画(200 点仍在安全区)、`Entity`+`CallbackProperty` 只配 1–3 个聚焦设备、`PostProcessStage` 是全屏级不能只影响一点;色值一律要经 `toneColors()` 烘焙;特效面片必须走半透明 pass。
-
-下一步:本票结论作为 [08 原型的设备状态特效轴](08-prototype-anchor-panel-and-device-effects.md)的输入,由原型现场校准「正常/离线无特效是否够可辨」与 30 台降级阈值;实现归本图后续实现票,不在本票落地。**本票只定视觉语言、门控与并发上限,不声明特效已实现**;按 `AGENTS.md`,本轮未运行 build/dev 或浏览器验证。
-
-## Question
-
-需求⑤原句:「定位到设备时,设备点位周围应产生对应的特效(对应设备状态,报警、预警、工单、正常、离线等……)」。
-
-请裁决:
-
-1. **每个状态是什么特效**:报警 / 预警 / 工单处置 / 正常 / 离线各是什么(扩散脉冲 / 闪烁 / 旋转环 / 描边呼吸 / 无特效)?「正常」要不要有特效 —— 若五个状态全有动画,画面会被特效淹没,优先级语义反而丢掉;请给出明确取舍。
-2. **设备状态视觉的单一来源**:先核对后端设备自身状态的词表与语义色;`styles/tokens.css` 仍是色值来源,但现有 `domain/pointStatus.js` 表达的是**监测点**聚合状态,不可直接用它把设备猜成点位状态。设备自己的颜色、形状、文字要怎样形成独立且单一的视觉契约?特效是否需要“脉冲”等新维度?设备状态未发布时如何如实显示,未关联设备既有摘要标记是否保持原样?
-3. **触发门控**:特效是「设备点位图层展开即常驻」,还是「只在该设备被定位/选中时出现」?与需求①(点位定位)、需求④(设备选中)的先后关系;关闭详情后特效是否立刻消失。
-4. **并发上限与优先级**:一个点位下同时多台报警时怎么呈现(全部脉冲 / 只脉冲最高优先级 / 按屏幕距离抽稀)?同屏上限是多少、超限的降级规则是什么(降级后不能变成「什么都没画」,那会被读成「没有报警」);并发动画数如何与票 05 的独立设备图例及实际已上图数区分(票 09 八行摘要图例不混入设备)。
-5. **与限距雾气的共存**:`.scratch/cockpit-primary-area-guardrails` 的 07 与本特效同处一个后处理集合;顺序(`execute` 顺序)、开关(按高度带/按区域挂载)与冲突(雾气把远处特效糊掉是否可接受、特效要不要免雾)。
-6. **降噪与可达性**:是否遵守票 09 验收第 3 条「颜色不是唯一线索」与票 32 的生产形态降噪(正式环境不该有动画压满屏)——要不要像票 32 那样给一个配置开关(`map.ui.debug` 或 `map.effects.*`),开关的**默认值**是什么。
-7. **可断言口径**:走查怎么证明「特效确实在动、且与状态对应」(票 09 的对抗式复验教训:拿两份 JS 数据互比是恒真断言;要从渲染结果或真实帧差里取证据)。
-
-## Answer
-
-### 状态 → 特效(问题 1)
-
-设备自身状态共**五态**,取自后端 `deviceStatistics` 的五个计数键,**没有 `EMPTY`**(那是监测点聚合态专有,设备词表不含它):
-
-| 状态 | 色键 | 形状 | 特效 | 理由 |
-| --- | --- | --- | --- | --- |
-| `ALARM` 报警 | `lv1` | 三角 | **扩散脉冲** | 最高优先级,最显眼 |
-| `WARNING` 预警 | `lv2` | 菱形 | **闪烁** | 次高,需处置但要与报警区分 |
-| `WORK_ORDER` 工单处置 | `lv3` | 方块 | **旋转环** | 有人管了,用「进行中」动效 |
-| `NORMAL` 正常 | `lv4` | 圆 | **无特效** | 常态不该抢注意力 |
-| `OFFLINE` 离线 | `neutral` | 圆环 | **无特效**(置灰) | 无数据可报,动效反而误导 |
-
-**明确取舍:正常与离线不播特效。** 若五态全有动画,画面会被特效淹没、优先级语义反而丢掉;动效只用于标记**需要处置**的三态,把视觉预算集中到该看的地方。正常/离线靠静态色 + 形状区分 —— 这条是否够可辨,由 [08 原型](08-prototype-anchor-panel-and-device-effects.md)现场校准,本票不预先宣布已够。
-
-风险已记录:正常与离线在画面上完全静态,若形状/颜色不足以分辨,需回本票重议(不得由实现票自行给正常态加回动效)。
-
-### 设备状态的视觉单一来源(问题 2)
-
-- **新建 `domain/deviceStatus.js` 作平行表**:设备五态各自 `tone`(色键)+ `shape` + `label` + **新增 `effect` 维度**(动效键)。`effect` 是色 + 形无法表达的新维度,本票正式引入。
-- **与 `domain/pointStatus.js` 的关系**:不 import 其状态词表(两套业务概念不共用一张表),只可能共用 `SHAPES` 的图形片段。`pointStatus.js` 仍只表达**监测点**聚合状态。
-- **必须修掉的既有越界**:现 `domain/detailFlow.js:12` import `visualForStatus`、`:99-107` 的 `statusView()` 直接拿**点位**表给**设备**配色配形 —— 即「把设备猜成点位状态」在代码里已经发生,实现时改走 `deviceStatus.js`。这是本票对既有代码的明确要求。
-- **色值单一来源不变**:色键仍只在 `styles/tokens.css` 声明,服务层经 `toneColors()` 读出烘焙后下传(`mapService.js:634-654`);引擎侧读不到 CSS 变量,不得在 shader/引擎层写死色值。
-- **状态未发布**:落 `UNKNOWN`(「状态未发布」),与点位表同一条纪律 —— 宁可如实说不知道,也不猜成 `NORMAL`。未关联设备既有摘要十字标记**保持原样**,不因本票改变。
-
-  > **2026-10-08 就地修订(由[蓝湖素材替换票 05](../../lanhu-cockpit-asset-replacement/issues/05-point-device-marker-mapping.md) Q22=1 / Q11 / Q26=A 裁定)**:正式素材替换后,未关联设备的**十字标记被取代**,改为「**镂空徽标母版 `T_MarkPoint_White` + 内层行业图标(沿用其行业素材,如 `Q图标燃气设备`)+ 整体降饱和降亮度**」。因此本行「十字标记保持原样」只在本票当时有效,不再是正式素材下的最终视觉;未关联设备**仍有自身设备状态、仍按设备状态着色**(`CONTEXT.md` 口径不变),降饱和只作用于色相饱和度与亮度,不改变状态语义。本票其余裁定(五态映射、正常/离线无特效、门控、30 台降级、`map.effects`)不变。
-- **形状与设备模型口径的分工**:本票只定「状态 → 色/形/特效」的映射;设备「模型」是 glTF 还是图标 + 标签、资产来源与朝向仍归[票 10](10-device-model-asset-definition.md),本票不预设。
-
-### 触发门控(问题 3)
-
-- 特效**随票 05 的关联设备图层常驻**,但不无差别常驻:图层展开后**报警/预警两级**常驻动效,工单处置也常驻旋转环(见下表口径),正常/离线静态。
-- **被选中/定位的那台设备额外强化**(光圈更大 / 描边更重),与需求①定位、需求④设备选中对齐;选中不等于「只有它才有特效」。
-- **关闭详情立即收图层,特效随图层一起消失** —— 不需要单独的消失逻辑,因为特效就挂在图层上,这是票 05 生命周期契约的直接推论。
-- 常驻动画与按需渲染的关系:`CallbackProperty(isConstant=false)` 会持续请求新帧、让 `requestRenderMode` 失效(票 02 已核)。本票把这份代价**限制在异常设备**上(一个点位下通常只有少数几台),不扩展到全部设备。
-
-### 并发上限与降级(问题 4)
-
-- **不做静默截断,全部异常照画**:票 02 已核 `BillboardCollection` 在 10/50/200 点都在安全区(官方 10,000 个动画 billboard 138 FPS,200 点约 0.15 ms 摊派),性能上 200 台不是瓶颈;截断只会丢信息、换不到东西。
-- **同屏异常设备超过 30 台时降级**:动画停掉,改为**静态异常标记 + 一行「N 台异常」计数**。
-- **降级的红线段**:降级后**仍然看得出有异常**,绝不能变成「什么都没画」—— 那会被值班人员读成「没有报警」。静态异常标记 + 计数正是为守住这条。
-- 「30 台」是待现场校准的阈值,由 [08 原型](08-prototype-anchor-panel-and-device-effects.md)做成可调并现场定值。
-- **计数口径不与摘要图例混用**:关联监测设备另设独立图例,只统计当前模式**实际已绘制**的设备;[票 09 的八行摘要图例](../../cockpit-shell-panel-layout/issues/09-point-status-legend.md)不混入设备。降级后未逐台动画的设备仍计入关联总数,但不得被谎报为「逐台已上图」——沿用票 05 的互斥计数口径。
-- 现实规模备注:现网历史只读记录为 7 个监测点、2 个点带设备、**共 3 条有效关联**,故「多处同时报警」是未来风险而非当下规模;本票按风险设阈值,不按当下规模省掉。
-
-### 与限距雾气的共存(问题 5)
-
-- **直接沿用**.scratch/cockpit-primary-area-guardrails 已 resolved 的[雾票 `## Answer` 第 6 条](../../cockpit-primary-area-guardrails/issues/07-fog-implementation-spec.md):「设备三维特效随雾衰减,不开穿雾例外;面板内报警文字与状态信息保持清晰;后处理特效在雾之前合成,开关独立」。本票**不重开**该裁定。
-- **本票补一条实现推论**:本票选定的载体是场景内 `BillboardCollection`(三维渲染),**不是后处理级**,所以「先特效、后雾」已自动成立,不占用 `PostProcessStageCollection`、与雾**无排序冲突**,也不会出现「雾把远处盖住了、特效还亮在雾上面」。
-- 因此本票**不新增**后处理级特效,雾级与特效级也**不需要原子对齐开关**(票 02 §3.3 那条约束只对「特效走 post-process」的路线成立)。
-- 已知限制如实保留:透明对象的颜色不保证有匹配深度,故「特效随雾正确衰减」的远景观感仍需原型/实现走查确认,不能凭本节断言已达标。
-
-### 降噪与可达性(问题 6)
-
-- **新增 `map.effects` 配置域**,与票 32 已交付的 `map.ui.debug` **完全同构**:`cockpitConfig.js:78` 的 `ui: { debug: null }` 与 `:267-269` 的读取归一化就是模板。
-- **默认值 `null` = 跟随构建模式**:开发构建 = 特效开,生产构建 = 特效关(干净形态)。与票 32 同一条纪律 —— 正式环境不该有动画压满屏。
-- 现场若要在生产包里看特效,显式写 `true` 覆盖(沿用票 32 的理由:地产现场可能要在生产包里看诊断信息)。脏值退回默认并记 issue,不静默吞掉。
-- **关闭特效 ≠ 关闭状态显示**:关掉的是动效,静态色 + 形状 + 文字仍然在位 —— 故不违反「颜色不是唯一线索」(票 09 验收第 3 条),关特效不会让状态变得不可读。
-
-### 可断言口径(问题 7)
-
-维护者选择 **A + 人工走查**:三层证据加人工目视,缺一不可。
-
-1. **领域纯逻辑断言**:状态 → 特效映射唯一;未知状态落 `UNKNOWN`;`NORMAL`/`OFFLINE` 的 `effect` 为空。这层便宜但要防「拿两份 JS 数据互比」的恒真断言 —— 它只证明映射表对,不证明画面在动,故不能单独作为验收。
-2. **场景图快照**(关键层):走查读回 billboard 数量,并断言**同一设备的逐点 `scale`/`color` 跨帧确实变化** —— 这才是「真在跑」而非「已配置」的证据,正面避开恒真断言。可复用 `readPointMarkers(viewer)`(`cesiumEngine.js:376`)的读取模式。
-3. **真实帧差**:对同一视角截两帧算像素差异,证明画面确实变了。这一层顺带补上票 02 明确留下的空白(票 02 写「没有任何在本仓库实际场景下的实测帧率数据」,真数留实现票实测)。
-4. **人工走查**:观感(脉冲强度是否合适、正常/离线是否够可辨、30 台降级是否仍读得出「有异常」)由维护者目视裁定。
-
-按 `AGENTS.md`,本轮**不自动运行**任何 build/dev/走查;上述走查脚本作为**实现票的验收手段**交付,不在本票执行。
-
-### 未由本票裁定 / 边界
-
-- 设备「模型」是 glTF 还是图标 + 标签、资产来源与朝向:归[票 10](10-device-model-asset-definition.md)。
-- 面板选设备与三维设备点的对应反应(hover、双向联动、拾取优先级):归[票 06](06-panel-to-scene-device-correspondence.md)。
-- 同坐标/极近设备的画法与拾取:归[票 11](11-overlapping-device-points.md)。
-- 全图模式下的图层可见矩阵(基础设施模式隐藏设备图层等):票 05 已定设备图层自身的模式规则,其余图层归[票 13](13-mode-layer-visibility-matrix.md)。
-- 本票**只产决策,不实现功能**;`deviceStatus.js`、`map.effects` 配置与特效渲染均属后续实现票。
-
-## Comments
-
-### 第 1 轮:状态特效、单一来源与门控
-
-维护者选择:Q1=A(报警/预警/工单有特效,正常/离线无)、Q2=A(新建独立 `deviceStatus.js` 平行表并新增 `effect` 维度)、Q3=A(常驻但限最高两级,选中强化)。
-
-只读核实(本轮实际读到的):后端设备状态五态可由 `deviceStatistics` 的五个计数键反推(`totalDeviceCount/alarmDeviceCount/warningDeviceCount/workOrderDeviceCount/normalDeviceCount/offlineDeviceCount`,见 `domain/pointDetail.js:39-66`),**没有 EMPTY**;设备卡片的 `deviceStatus` 字段确在 `domain/pointDetail.js:24` 读取,但**前端从未声明过它的枚举** —— 换言之 Q2 的「借用点位表」不是推测,是当前代码的实际行为(`detailFlow.js:12`、`:99-107`)。
-
-### 第 2 轮:并发、雾、配置与可断言口径
-
-维护者选择:Q4=A(不截断;超 30 台降级为静态 + 计数)、Q5=A(沿用雾票已成文裁定)、Q6=A(新增 `map.effects.*`,`null` 跟随构建形态)、**Q7=A + 人工走查**(维护者加要求人工目视)。
-
-只读核实:票 32 的 `map.ui.debug` 先例确已交付(`services/cockpitConfig.js:78`、`:267-269`;`32-production-ui-cleanup.md:19,28,34-35`、`docs/cockpit-deployment.md` 2.8 节),故 Q6 是照搬而非新发明。雾票 `.scratch/cockpit-primary-area-guardrails/issues/07-fog-implementation-spec.md` 的 `## Answer` 第 6 条已写「设备三维特效随雾衰减,不开穿雾例外……后处理特效在雾之前合成,开关独立」,且该票 `Status: resolved`、进度 100% —— 故 Q5 是沿用已 resolved 的裁定,不重开。
-
-### 收口确认
-
-维护者答复「按B,先尝试对07进行收口,确认之后再动工」。七个问题已由两轮答复全部裁定,无遗留未决项,据此把结论写入 `## Answer`、本票改为 `Status: resolved`、进度 100%。
-
-**收口前后的状态差异已如实记录**:本会话启动时磁盘上本票为 `Status: open` / 进度 0% / `## Answer` 为「(待填)」,`map.md` 的任务清单为未勾选;维护者面板当时显示本票已收口,本会话未能复现该状态(`git log` 对本文件**零提交**,全盘搜索只有一个副本,仅一个 worktree)。按维护者指示,本会话先完成收口再动工 08;该差异照录,不当作本票曾收口的证据。
-
-本票结论与 [08 原型](08-prototype-anchor-panel-and-device-effects.md)的关系:本票给的是**拟议契约**,原型负责现场校准「正常/离线无特效是否够可辨」与「30 台降级阈值」两个数值/观感问题;校准结果若推翻本票任一条,回本票重议,不由实现票或原型自行改掉。
-
-### 2026-09-24 走查校准结果(来自票 08,本票契约未被推翻)
-
-原型真机走查对本票两条留白给出了**场景图跨帧快照**证据(即本票可断言口径的第 2 层,非恒真断言):
-
-1. **正常/离线无特效够不够可辨**:4 台异常设备(报警×3、预警×1)的逐点 `scale`/`color` 跨帧确实在变;3 台正常/离线**完全静止**;违规 0 项。即「动效只给需处置的三态」在真实画面上成立,正常/离线靠静态色+形仍可辨。本票 Answer 的「正常/离线无特效」**未被推翻,维持**。
-2. **30 台降级阈值**:40 台设备(23 台异常)、上限调到 10 → `已降级: true`、`动效台数: 0`(动画全停),而 `异常台数: 23` 仍在 —— **降级后仍看得出有异常**,守住本票划的红线。「30 台」作为初值成立,仍属可现场再调的阈值。
-
-### 维护者新增方向:特效需更有科技感(实现时的视觉方向,不改本票已锁语义)
-
-维护者在票 08 验收时给出一张参考图并提出「实际的特效还可以更加炫酷一点,需要增添科技感」。参考图的目标观感:**多层同心扩散环**(3–4 层、由内向外扩张渐隐)、**中心辉光/光晕**、以及能量感描边。同时参考图里出现的是**监测点级别**的大型报警扩散环(红色同心环罩住整个点位),而本票 Answer 只裁定了**设备级**特效 —— **监测点标记要不要也有自己的状态特效(尤其报警)是本票尚未裁定的一条**,实现前需回本票补一轮裁决,不得由实现票自行发明。
-
-边界:本票已锁的语义全部维持——五态映射、正常/离线无特效、门控(随图层常驻+选中强化)、30 台降级、`map.effects` 配置、billboard 载体与半透明纪律。新方向只落在**视觉表现力**上(环的层数、辉光、节奏),由实现票按参考图现场调,帧耗仍受票 02 的预算纪律与 `≤15%` 雾票性能约束管辖。

+ 0 - 92
.scratch/cockpit-point-locate-and-device-link/issues/08-prototype-anchor-panel-and-device-effects.md

@@ -1,92 +0,0 @@
-# 原型 —— 锚定面板 + 设备点位 + 状态特效的低精度可反应物
-
-Type: prototype
-Status: resolved
-Labels: wayfinder:prototype
-Blocked by: 04, 05, 07
-
-## 进度:100%
-
-维护者已授权真实走查,原型已通过现场验收,本票收口。原型本身不再继续迭代;以下新增要求转为后续实现约束,由对应票据接手。
-
-**已验证**:真实 Cesium 页面可打开;3 个监测点与 7 个设备点位可见;锚定面板跟随、右/左/下/上落位、自动飞行、设备状态特效、正常/离线静态态、并发降级均已现场走查;场景图跨帧快照证明 4 台异常设备真实动效、3 台正常/离线静止;40 台设备/23 台异常在上限 10 时正确降级且仍保留异常标记。
-
-**维护者确认的实现约束**:
-
-1. **点击定位**:最终实现必须支持用户点击监测点进行监测点定位,并能对关联设备进行设备级跳转定位。沿用票 03 的点位级 500m / 设备级 150m 视角契约,并接票 06/15 的设备拾取与双向联动。
-2. **两级详情复用既有结构**:锚定浮动窗口必须沿用现有「点位摘要」窗口的结构与 `detailFlow` 语义:点击监测点 → 点位摘要 → 设备卡片列表 → 点击设备进入设备详情 → 返回点位摘要。不得发明另一套详情层级;设备详情仍归属监测点锚点。
-3. **图标可替换**:当前原型图标仅为临时视觉占位,最终验收不以这些临时图标为准。实现必须保留独立的图标/模型资产替换接缝,归票 10 处理。
-4. **特效科技感**:参考图的多层扩散环、辉光、扫描/能量感作为后续设备/点位特效的视觉方向,归票 07 重议/补充;不在本票原型上继续加码。
-5. **初始地球视图**:进入引导的最初地球视图只展示地球整体形态,不展示监测点、设备点位或点位详情;点击进入系统后才挂载/显示业务点位。归票 30(进入引导)与票 13(图层可见性矩阵)接手。
-6. **最新遮挡规范**:以票 04/12 的 2026-09-24 修订为准:锚定浮动窗口层级低于左右四个详情窗口,允许被其遮盖;不因 `icon-covered` 或 `no-space` 突然隐藏;只有窗口完全离开画面才隐藏。原型不再同步改造。
-
-**原型已知边界**:原型的详情卡是用于视觉走查的简化卡片,未复制生产 `PointDetailCard.vue` 的完整两级交互;临时图标与临时特效不属于最终验收资产。
-
-## 分支判定
-
-维护者裁定为**界面分支为主 + 纯函数与状态面板**(`/prototype` 的 UI 分支):同表面多变体 + 底部悬浮切换栏(对应票面第 3 条的三组开关),同时把锚点求解与特效映射抽成纯函数、并外显完整状态(对应票面第 2 条的退化)。一段产物同时回答「长什么样」与「退化状态顺不顺」。
-
-## 产出与运行
-
-- 验收入口与操作清单:[`prototypes/anchor-panel-review.md`](../prototypes/anchor-panel-review.md)
-- 原型组件:`src/modules/cockpit/prototypes/AnchorPrototype.vue`
-- 锚定求解器(纯函数,值得抬进实现的那段):`src/modules/cockpit/prototypes/anchor-prototype-layout.js`
-- 设备状态 / 特效表(纯函数):`src/modules/cockpit/prototypes/anchor-prototype-effects.js`
-- 入口接线:`src/pages/cockpit/index.vue`(DEV + `?anchorPrototype=1` 才挂载,删 query 即卸载)
-- 自检:`.scratch/cockpit-point-locate-and-device-link/verify/t08-anchor-layout.mjs`(52 项)、`verify/t08-sfc-static-check.mjs`
-- 跑法:仓库现有 `yarn dev`(Fes),打开 `/#/cockpit?anchorPrototype=1`。**本会话未执行启动命令**(`AGENTS.md` 禁止自动跑 dev/build),需要维护者自己开。
-
-## Question
-
-把票 04(面板锚定跟随)、票 05(设备点位图层)与票 07(状态特效)的**候选形态**做成一个廉价、粗糙、用眼睛就能反应的产物,回答「看起来对不对、手感对不对」。这是**提高讨论保真度**的手段,不是交付实现(wayfinder 原型票的本意)。
-
-1. **形态**:维护者选**临时 Cesium 页**(而非独立静态页)。理由:锚定避让要跟真实的左右两栏、顶部图例、底部摘要行打交道,挂在 `/cockpit` 上它们才真的在场;另起路由反而要搬更多外壳代码。代价:需维护者自己跑 `yarn dev`,且本会话无法自验画面。
-2. **要同时看到**:点位锚定的面板随相机移动贴点移动(含四向翻转 / 贴边 / 暂隐三类退化);样例含三个监测点、设备台数可拉到 40;特效在动(脉冲 / 闪烁 / 旋转环)。
-3. **要能现场切换对比**:锚定形态(边直接贴 / 短引线 / 角箭头)、特效形态(五状态逐个可改:脉冲 / 闪烁 / 环 / 描边呼吸 / 无)、门控与并发、四个阈值滑杆 —— 切换即见差别。
-4. **产物与生命周期**:路径与跑法见上;结论写回票 04/07(**本会话未写**,因为需要维护者先走查);原型本身可弃(撤除方式见验收入口文末)。
-5. **边界**:原型不碰 `services/cameraControl.js` / `mapService.js` 的既有契约(已核对 diff 为空),不改票 03 的三键手感,不引入新的生产依赖。
-
-## Answer
-
-原型验收通过。维护者确认原型整体方向可用,不再继续改原型;本票产出的后续实现约束如下:
-
-1. 点击监测点必须跳到监测点;从点位摘要选择设备必须跳到设备级视角。沿用票 03 的 500m/150m 视角契约与票 06/15 的双向设备联动。
-2. 锚定浮动窗口必须复用现有 `PointDetailCard.vue` / `detailFlow.js` 的两级结构:点位摘要(含设备卡片)→设备详情→返回点位摘要;不在实现中另造详情层级。
-3. 点位与设备图标必须有可替换的资产接缝;本原型中的临时 SVG/形状不作为最终验收资产,归票 10 定义资产来源与替换口径。
-4. 参考图所示的多层扩散环、辉光、扫描/能量感作为后续特效方向,归票 07 重议/补充;本原型不再继续加码。
-5. 初始地球引导视图只展示地球整体形态,不展示监测点、设备点位或点位详情;点击进入系统后才挂载业务点位。归票 30/13 接手。
-6. 浮动窗口遮挡规范以票 04/12 的 2026-09-24 修订为准:浮动窗口层级低于左右四个详情窗,允许被其遮盖;不因 `icon-covered` 或 `no-space` 突然隐藏;只有窗口完全离开画面才隐藏。
-
-本 Answer 记录的是原型走查结论与实现输入,不宣称上述最终生产功能已实现。
-
-## Comments
-
-### 分支与做法(维护者两轮裁定)
-
-- 表面=**B 临时 Cesium 页**;分支=**A 界面分支为主 + 纯函数与状态面板**;锚定轴=**A 标定票 04 的留白**(不重开已 resolved 的 04,另加固定位作可选反例对照);特效轴=**A 五状态逐个可开关 + 门控 + 并发降级**;数据源=**A 自带样例表**(字符串取自仓库真实 mock,不接后端、不接并行改动中的 `contractMocks.js`);入口=**A 挂 `/#/cockpit?anchorPrototype=1`**;收尾=**A 停在 95% 等走查**。
-
-### 阻塞关系与本票开工前提(如实记录)
-
-- 本票头部列为 `Blocked by: 04, 05, 07`。开工时本会话在磁盘上读到 **04、05 已 resolved,而 07 为 `Status: open` / 进度 0% / `## Answer` 为「(待填)」,`map.md` 任务清单为未勾选**;维护者面板当时显示 07 已收口,且本会话**未能复现**该状态(`git log` 对 07 零提交、全盘搜索只有一个副本、仅一个 worktree)。
-- 维护者裁定「按 B,先尝试对 07 进行收口,确认之后再动工」。据此本会话先以两轮 grilling 把 07 的七个问题逐条问定、写入其 `## Answer` 并置 `resolved`,**随后才开工本票**。该差异已照录在 07 的 Comments 里,不当作 07 曾收口的证据。
-- 因此本票今日的 `Blocked by` 三项**均已 resolved**,依赖在文档层面成立。
-
-### 与票 07 的相互作用
-
-票 07 定的是**拟议契约**,本原型负责现场校准它留下的两个问题:「正常 / 离线无特效是否够可辨」与「30 台降级阈值」。若走查推翻 07 的任一条,**回 07 重议**,不由实现票或原型自行改掉。
-
-### 自检发现并已修复的问题
-
-**第一轮(首次交付前的静态自检)**:
-
-1. 求解器返回的 `rect` 只有 `width/height`、没有 `right/bottom` —— 把返回值直接喂回 `intersects()` 会**静默算错**;两种形状现已都给齐。
-2. 样例点位只有 7 台设备,而「30 台降级」这条留白需要更多台数才验得到 —— `spreadDevices()` 现按原样例循环补足并改码(补出的是**合成样例**,不是后端数据)。若不做这一步,票 07 交给本原型的阈值将**永远无法被验证**。
-3. 帧循环里每帧 `new Color()`,与票 02「每帧重建 JS 对象会让 GC 压力盖过绘制成本」的纪律冲突 —— 已加按「色键 + 0.05 量化透明度」的缓存。
-
-**第二轮(第一次走查失败后的诊断)**:见上面「进度」一节的三因分析 —— 坐标写死在视野外 24.3 km(致命)、无可判定的成功信号、端口认错。前两项已修并加回归断言,第三项待维护者确认。
-
-### 未执行
-
-- **已执行**:维护者授权后亲自跑 `yarn dev`(**端口 8000**,不是 5173)+ 浏览器实操走查 + 截图(见 [验收入口](../prototypes/anchor-panel-review.md) 的「第三次」一节);纯逻辑断言、SFC 静态编译、dev server 归属探针。
-- **未执行**:`yarn build`;GPU/帧耗时采样与票 07 第 3 层「真实帧差」的像素级量化;1080p/4K 双分辨率对比。
-- 未提交、未切分支(工作区有大量并行改动)。

+ 0 - 77
.scratch/cockpit-point-locate-and-device-link/issues/09-task-backend-device-coordinate-contract.md

@@ -1,77 +0,0 @@
-# 任务 —— 向后端确认/补齐「点位下设备的坐标与状态」契约
-
-Type: task
-Status: resolved
-Labels: wayfinder:task
-Blocked by: 01
-
-## 进度:100%
-
-**已认领(claimed);第二轮仍未通过原验收,不关闭。** 本轮静态检查后端提交 `3dd4b32`:新增 `snapshotAt` 参数、详情从地图快照路径取数、跨端点投影契约测试及 README 说明;但尚未证明同一时间戳对应冻结/复用的同一份关系数据。该提交目前只在本地 `master`,比 `origin/master` 超前 1 个提交;不能作为远端已发布证据。后端工作树另有概览模块的未提交改动,本次未触碰。已把本轮差距回馈至 Gogs #69。
-
-- **通过(静态)**:详情 API 支持可选 `snapshotAt`;传入时由 `CockpitMonitoringPointDetailService` 采用地图快照适配器路径,返回错误时间戳为 `SNAPSHOT_EXPIRED`;README 明确有参数时的设计口径、无参数的尽力模式及现网“只保留清单、不画设备位置”边界。之前设备级坐标/校验的透传仍成立。
-- **仍不通过:真实同快照一致性**:`BusinessCockpitMapSnapshotAdapter.load(snapshotAt)` 每次都会重新查 `monitorPointService.list()`、`equipmentBaseService.list()`、`getEquipmentByPoint()` 当前数据,只将传入 `snapshotAt` 写入返回快照,没有历史数据读取、冻结缓存、快照版本键或一致性事务。若两个请求之间点位关联改变,相同参数仍可能得到不同集合。
-- **新增测试的边界**:`CockpitMonitoringPointDetailSnapshotContractTest` 用 Mockito 为 `mapAdapter.load(SNAPSHOT_AT)` 配置同一个预设快照,再比较同一内存对象投影的详情和地图设备集合,验证集合转换/归一化(含离线、空码、重复)正确;不能验证生产适配器在时间间隔/关系变化后对相同 `snapshotAt` 复用一致的源数据。实际适配器缺少同快照版本语义测试。
-- **发布证据**:`3dd4b32` 为 `master` HEAD,但 `origin/master` 仍是其父提交 `50a5711`;提交仅本地领先 1,未证明推送/部署。不要声称远端已发布。
-- **坐标功能边界**:真实适配器仍无可信坐标校核源,关联设备为 `UNVERIFIED_COORDINATE/renderable=false`,只展示清单、不绘制设备位置;README 现已正式记载。这一点本轮不再单独阻塞,因为对外边界明确。
-
-**下一步**:后端需使相同 `snapshotAt` 代表可复用的同一源数据快照(版本化/缓存/事务等方案之一),新增真实适配器或快照仓储测试覆盖两个请求间关联变化的情景,之后发布提交。收到后再复验并决定是否通过;项目规则要求不自动运行构建/调试,本轮仅静态检查。
-
-## Question
-
-需求②要画「该监测点下所有设备点位」,而实现这条路完全取决于后端给不给数据。票 01 会给出确切断口;本票负责把它推成后端的一个**明确答复**(排期 / 拒绝),因为「前端要不要另想办法拿设备位置」这个决策卡在它上面。本票是 wayfinder 里唯一「做而不是决定」的类型,它的价值在于**解除一张决策票的阻塞**。
-
-要做的事:
-
-1. **提票**(若 01 确认缺字段,预期是 `CockpitMonitoringPointDeviceCard` 缺 `longitude/latitude/coordinateValidation`):按 `.scratch/cockpit-shell-panel-layout/handoff/backend-contract-gaps.md` 里 BT 系列草案的格式,向 `shaoy/pipe-ner-server`(Gogs)提一张「点位详情补设备坐标与坐标校验(含 `deviceCodes` 对齐)」的工单,写清:背景与证据(类名 + 现有字段)、要做什么、验收标准(同一 `snapshotAt` 下与既有端点的设备集合一致;坐标缺失/未验证时 `renderable=false` 且有 reason;契约测试与 `docs/cockpit/README.md` 同步)、以及解除阻塞的前端票。
-2. **走已有的口子**:`.scratch/cockpit-shell-panel-layout/handoff/backend-contract-gaps.md` 已记录 BT-1 / #59、BT-2 / #60 均已交付;本票先核对「是不是 #59 已经带了坐标而前端适配层漏读」,避免重复提票 —— 若属误读,本票的结论就是「补前端适配层」,并在此写明证据。
-3. **无答复时的兜底**:若后端拒绝或一个工作日内无答复,记录答复原文与日期,并列出前端可走的三条备选供票 05 裁决:① 逐台 `/equipments/{deviceCode}/archive`(N+1,注明并发与失败处理);② 只用点位坐标 + 设备清单、不画设备位置;③ 等后端。注意 `CONTEXT.md` 禁止「人工坐标偏移」把对象当真实点位(`Avoid_: 人工坐标偏移、把聚合点当作真实监测点)。
-4. **落记录**:Gogs 票号 / 答复原文 / 日期写进 Answer;地图摘要里读不到的设备坐标这件事不许含糊过去。
-
-HITL:需要维护者用后端仓库账号建票并跟答复(本工作区已按 `AGENTS.md` 只读阅读后端源码,不改后端文件)。
-
-## Answer
-
-### 第三轮验收:后端契约已实现,发布/联调仍待维护者执行
-
-- 后端当前远端 `master` 已包含 #69 实现提交链,最新远端 HEAD 为 `77b4a34`;前序实现从 `3dd4b32` 开始,工作区另有 overview/MonitorPoint 改动但未纳入这些提交。
-- `GET /api/cockpit/v1/monitoring-points/{monitoringPointCode}` 接受可选 `snapshotAt`;有参数时详情从地图快照读取。`BusinessCockpitMapSnapshotAdapter` 通过 Redis `SET NX` 发布首次完成快照,应用节点共享;本地缓存仅作加速,容量达到 256 时仍走 Redis。TTL 覆盖 `snapshotAt` 后 10 分钟,分数秒向上取整;请求、缓存命中和构建完成均按半开时间窗复核,构建期间过期不发布。
-- 设备集合证据已覆盖:真实适配器关系变化后的复用、独立适配器共享 Redis、地图分页边界、真实地图分页结果与详情 `devices[]` 的 deviceCode 对齐、空/重复编码去重及离线设备保留。Redis JSON 往返测试覆盖设备坐标、`UNVERIFIED_COORDINATE` 原因、`renderable=false` 与离线状态。
-- 坐标来自 `equipment_base` 设备自身字段,不复制监测点坐标、不制造偏移;当前无可信 `coordinateVerified` 来源,因此真实设备统一 `UNVERIFIED_COORDINATE/renderable=false`,正式边界是保留清单、不绘制设备位置。
-- 后端验证结果:全量 Maven 测试 `377` 项通过,0 失败;快照适配器专项测试 `13` 项通过。双轴审查未发现硬性规范或明确验收缺口。
-- 尚未完成发布证据:最新实现仅在后端本地分支,未推送/部署,也未做真实 Redis/Kingbase 联调。后续动作是已完成推送的远端 `master`(当前 `77b4a34`)部署后按 README 联调检查表复验;初次快照构建由多次数据库查询组成,未显式包在单事务中,这是部署前应关注的残余一致性风险。
-
-### 第二轮验收:仍不通过原同快照验收
-
-- 新提交 `3dd4b32` 给详情端点加入可选 `snapshotAt`,调用地图快照适配器,并对时间戳不匹配返回 `SNAPSHOT_EXPIRED`;`CockpitMonitoringPointDetailSnapshotContractTest` 覆盖给定快照对象下详情 `devices[]` 与地图 `deviceCodes` 的投影集合一致,包括离线设备、坐标缺失、空/重复编码。README 同步写明参数与交付边界。
-- 但生产 `BusinessCockpitMapSnapshotAdapter.load(snapshotAt)` 每次仍然读取当前点位、设备与关联关系,再把请求中的时间戳回写为快照时间。没有按 `snapshotAt` 取历史版本、缓存/快照 ID 复用或一致性事务。真实源数据变化时,同一时间戳的两个请求仍可能取得不同设备集合。
-- 新契约测试将 `mapAdapter.load(SNAPSHOT_AT)` mock 为同一个预设对象,证明同一对象投影相同;没有证明真实适配器/仓储在两次请求或关联变化后冻结并复用同一源数据。故原要求“同一 snapshotAt 下集合必然一致”未证实。
-- 发布状态:当前后端 `master` 的 `3dd4b32` 比 `origin/master` 超前 1 个提交;本次未见该修复已推送。工作树其他概览模块有未提交变更,本次未修改。
-- 当前可信坐标数据源不足而统一返回 `UNVERIFIED_COORDINATE/renderable=false`;README 已明确本期仅保留设备清单、不绘制设备位置。这一降级边界现已清楚记载,不再作为本轮单独未决项。
-- 已将第二轮意见发回 Gogs #69(页面仍显示开启中)。本轮未运行 Maven、构建或联调,遵循后端仓库 `AGENTS.md`。
-
-**结论与下一步**:任务继续 `claimed`。后端需实现同一 `snapshotAt` 真正映射到可复用、稳定的源数据版本,并对真实快照机制增加覆盖关联关系变化的测试;随后发布提交,再进行第三轮验收。
-
-### 第一轮结果(历史)
-
-
-
-- 2026-09-24 以维护者账号 `ChenJZ` 创建后端工单 [`Cockpit:点位详情补齐关联设备坐标与坐标校验 #69`](http://192.168.110.30:3000/shaoy/pipe-ner-server/issues/69)。#59 仅提供编码清单,设备级坐标确实曾在点位详情模型链路丢失。
-- 后端 `8006fb4`(2026-09-28,已在本地 `origin/master` 历史中,工作树检查为空)新增快照/卡片 `longitude/latitude/coordinateValidation`,由设备事实解析器读取 `equipment_base` 的设备经纬度,经 `cards()` 透传;[后端契约手册](../../../../pipe-ner-server/docs/cockpit/README.md) 已说明 WGS84、未验证降级和设备清单保留。
-- 坐标校验目前真实适配器恒传 `coordinateVerified=false`,结果为 `UNVERIFIED/UNVERIFIED_COORDINATE/renderable=false`。这准确表达了无可信校核来源,**不意味着关联设备已能上图**。
-
-### 验收结果:部分通过,整票暂不通过
-
-1. **通过**:设备身份/状态/坐标/校验的输出链路及文档;坐标来自设备本身,不以点位坐标或人工偏移代替;缺验证时禁止渲染且有原因。新增单元与 Controller 测试覆盖了坐标透传和 `UNVERIFIED` 路径(仅静态检查,未实际运行)。
-2. **未满足原验收**:详情端点没有 `snapshotAt` 参数;地图适配器 `load(snapshotAt)` 每次读实时关系,并将传入标记回显。关系变更时,详情 `devices[]` 与地图 `deviceCodes` 无法保证是同一版。提交新增测试没有两端点同一 `snapshotAt` 集合一致、详情有效/缺失/非法坐标和离线集合的组合契约断言。票 05 后续已选择“跨端点尽力展示并提示不同步”,与本票/#69 的强同版验收有冲突;不能未经维护者确认自行放宽。
-3. **功能边界未确认**:无可信设备坐标校核源导致真实关联设备全部不可上图。需后端/维护者明确本期是否接受“仅保留设备清单、位置待确认”,以及未来可信校核来源的负责人和排期。Gogs #69 在复核时仍显示开启中,原先 0 条评论;验收意见现已发在[评论 #106](http://192.168.110.30:3000/shaoy/pipe-ner-server/issues/69#issuecomment-106)。
-
-**下一步**:等待 #69 对评论 #106 的正式答复及测试/联调证据;若维护者确认用票 05 的尽力展示口径替代强同版要求,须在 #69 与本票同步修订验收,并如实声明本期无可上图设备。否则由后端补版本一致性机制和契约测试。完成后再改 `Status: resolved` 并向地图 Decisions so far 追加索引。
-
-## Comments
-
-### 后端提交静态验收
-
-### 第三轮收尾记录
-
-已完成本票后端契约验收并将状态置为 `resolved`。验收基线为远端 `master` 当前 `77b4a34`,全量 Maven 测试 377 项通过;未声称已完成真实 Redis/Kingbase 联调。设备坐标当前正式降级为 `UNVERIFIED_COORDINATE/renderable=false`,前端保留设备清单、不绘制位置。发布后需按 `docs/cockpit/README.md` 联调检查表复验;初次快照构建的多次数据库读取未显式事务化,作为部署前残余风险保留。

+ 0 - 95
.scratch/cockpit-point-locate-and-device-link/issues/10-device-model-asset-definition.md

@@ -1,95 +0,0 @@
-# 设备点位「模型」的口径与资产来源
-
-Type: grilling
-Status: resolved
-Labels: wayfinder:grilling
-Blocked by: 01, 05
-
-## 进度:100%
-
-讨论已完成,维护者已确认 Q1–Q20;`resolved` 表示本票决策完成,不表示设备位置符号、正式图片素材或真三维模型已经实现。规格已通过 [`16 号设备点位模型与资产规格`](16-device-model-and-asset-spec.md) 固化,并挂到同一张决策 map;后续实现仍受票 09 的设备级坐标/状态契约或明确清单降级约束。
-
-01 已给出硬事实:仓库没有本地设备级 glTF/GLB;现有形状簇只能作为临时视觉。08 原型验收进一步确认临时内联 SVG 不是最终视觉,必须保留可替换的正式坐标点图片素材接缝。
-
-## Question
-
-需求②原句是「展示该监测点下所有设备点位的**模型**及坐标图标」——「模型」这个词必须被定死,否则实现票要么去加载并不存在的资产,要么静默降级成图标而没人知道。
-
-## Answer
-
-### 目标与边界
-
-- 本期“模型”正式解释为**监测设备在三维场景中的位置符号**,由可替换的图片/图标素材、渲染尺寸、对象自身状态视觉和既有交互反馈组成;本期不承诺、不加载真三维设备几何模型,也不把图标模式称为真模型。
-- “设备点位”在这里是监测设备在关联监测设备图层中的空间显示,不是新的业务对象;监测点、监测设备和关联监测设备图层继续按 `CONTEXT.md` 区分。
-- 本期不制作、不采购、不承诺真三维设备资产。未来可按设备类型逐步接入真模型,资产来源可以是外部提供,也可以后续另立资产生产事项由项目方制作。
-
-### 位置符号与视觉
-
-- 监测点和监测设备使用相同样式或同一份正式坐标点图片素材;本期临时形状簇只用于开发期占位,未来统一提供正式图片素材后通过可替换资产接缝替换。
-- 通过程序控制实际渲染大小区分类型,监测点始终大于监测设备;允许随相机距离动态缩放,但具体像素值和缩放倍率留给实现与人工走查,不在本票编造固定数字。
-- 设备默认不显示长文本;悬停或选中时显示**设备名称为主、设备编码为辅**,长内容不得撑破布局或遮挡其它对象。
-- “坐标图标”只表示空间位置符号,不直接展示经纬度数字。
-- 监测点按后端发布的点位状态着色,监测设备按监测设备自身状态着色。两者可以使用相同颜色风格,但不得混用状态来源或用点位状态猜设备状态;颜色不是唯一线索,继续结合形状、文字、尺寸层级和选中反馈。
-- 设备状态视觉遵守票 07;设备 hover、选中、定位、独立拾取和面板联动遵守票 06/15。本票不重新定义这些既有反应。
-
-### 地形、坐标与遮挡
-
-- 设备位置只接受设备自身的有效经纬度和后端坐标准入结果。坐标缺失、非法或 `coordinateValidation.renderable` 为假时不上图,保留在设备清单中并显示明确的无地图位置/不可用原因。
-- 有效设备位置必须结合地形采样贴地。地形未加载、采样未完成或采样失败时不上图,设备保留在清单中并显示“地形未就绪/位置待确认”;该状态不能算作坐标无效,也不能算作已上图。
-- 不使用监测点坐标代替设备坐标,不进行人工坐标偏移,不制造没有数据依据的设备位置。
-- 位置符号和未来真模型遵守正常地形、示意体块与其它场景对象的深度关系,不开启穿透体块的特殊可见性例外。
-
-### 真模型演进与降级
-
-- 真模型未来按设备类型逐步接入;同一关联监测设备图层允许真模型与图标混合显示,未提供资产的类型继续使用图标。
-- 真模型最低接入契约为 glTF/GLB、米制、明确 up 轴、前向朝向、原点和模型基准面;接入侧由构造确定模型到设备地理坐标的 ENU 变换,不靠运行时猜测原点或朝向。
-- 三角面预算、同屏数量上限和同型号实例化策略不在本票固定,未来资产/实施事项必须按真实设备类型和规模实测确定。
-- 真模型未提供、契约不合格、加载失败或性能超预算时,单台设备退回设备专属图标,不影响其它设备或其它资产类型;图标兜底不计为真模型成功加载。
-- 地图默认不增加统一错误角标。悬停/选中时显示“图标模式/模型不可用”等可理解状态;详情卡片显示具体原因,至少区分资产未提供、加载失败、性能降级、地形未就绪和视觉资源异常。
-- 正式图标资源自身失败时使用内置通用位置符号作为最后兜底,并在详情中标明视觉资源异常;通用符号不是真模型,也不是正式素材。
-- 关联监测设备使用独立图例,不并入监测点摘要图例;位置符号缺失、资产降级、无地图位置和模式隐藏必须保持可区分,不得静默当成设备不存在。
-
-## Decision Tree Closure
-
-- Q1:C——本期位置符号,未来可升级真模型。
-- Q2:C——本期不承诺真模型资产,后续可能另立事项由项目方制作。
-- Q3:统一样式/素材,程序控制尺寸;监测点始终大于设备;状态按各自业务对象解释;默认无长标签,悬停/选中显示名称为主、编码为辅;坐标图标不显示经纬度。
-- Q4:有效设备坐标结合地形采样贴地;地形未就绪或采样失败不上图;遵守正常遮挡关系。
-- Q5:真模型缺失、失败或超预算退回图标,并在交互/详情中如实说明;图标资源失败再退回通用位置符号。
-- Q6:监测点与设备可使用同一素材,仅程序控制尺寸;沿用票 06/07 既有设备交互;未来素材只替换视觉资源。
-- Q7:A——严格等待地形;坐标有效但地形不可用时清单保留、地图不上图。
-- Q8:A——按设备类型逐步接入真模型,允许同层混合显示。
-- Q9:C——外部提供和项目方另立事项制作均可,本票不承诺美术生产。
-- Q10:B——地图保持可识别图标;悬停/选中和详情显示降级及原因,不堆统一错误角标。
-- Q11:确认本期“模型”=位置符号,不加载真三维设备模型。
-- Q12:A+C——锁定监测点大于设备点,并允许同倍率随相机距离缩放;数值由实现/走查确定。
-- Q13:确认地形未就绪与无地图位置分开;不替代坐标、不人工偏移。
-- Q14:确认 glTF/GLB、米制、up/前向/原点/基准面明确、ENU 构造变换和独立校验。
-- Q15:确认悬停/选中提示图标模式或模型不可用,详情给出具体原因,资源失败有通用符号兜底。
-- Q16:B——设备名称为主、编码为辅。
-- Q17:A——监测点按点位状态,监测设备按设备自身状态;颜色风格可同但语义不混。
-- Q18:允许不同设备类型在同一关联监测设备图层中混合真模型与图标。
-- Q19:B——性能数字与实例化策略留给未来资产/实施事项实测,超预算按图标兜底。
-- Q20:接受独立关联监测设备图例;颜色不是唯一线索。
-
-## Further Notes
-
-- 规格子票:[16: 设备点位模型与资产来源规格](16-device-model-and-asset-spec.md)。它是本票结论的实施输入,不表示功能或正式资产已交付。
-- 后续实现应同时消费 14 号关联监测设备图层规格、15 号面板与三维设备点对应规格、本票的 16 号规格,以及票 09 的设备坐标/状态契约或明确降级方案。
-- 本票只决定视觉/资产口径,不重新打开票 03 的相机数值、票 04/12 的面板锚点、票 07 的设备状态特效或票 11 的重叠设备策略。
-
-## Comments
-
-### 讨论收口(Q1–Q20)
-
-维护者已确认以上目标、边界、风险、选项权衡与决策;本票状态更新为 `resolved`。本次只完成决策正文与规格发布,未运行 build、dev 或浏览器调试。
-
-### 2026-09-24 维护者新增前提(来自票 08 原型验收,本票裁决时必须吸收)
-
-维护者在票 08 验收时明确:**原型里的临时图标(内联 SVG 形状)只是占位,最终验收不使用它们**;点位与设备图标必须预留**可替换的资产接缝**,未来换成正式视觉资产(参考图为带辉光的圆形徽标 + 行业图标:水滴/火焰/燃气等)。
-
-对本票三条子问题的直接影响:
-
-- 问题 3(走图标路线):「图标」必须是**单一替换接缝**,不是把形状字符串写死在标记绘制里。现状可复用的接缝已存在且只此一处:`domain/pointStatus.js` 的 `SHAPES` + `markerSvg(shape, color, size)`,由 `services/mapService.js:678` 的 `imageOf` 回调注入 —— 正式资产落地时只改这一个注入点(或把它升级为可加载图片/glTF 的资产表),地图标记、图例、详情卡片三处自动跟随。
-- 问题 1/2(模型 vs 图标):参考图显示目标是**图标徽标**观感(非真三维模型),且 01 已核实仓库无设备级 glTF 资产。本票裁决时仍需正式定口径,但「资产可替换」是维护者已给的硬前提,与选哪条路线正交。
-- 验收口径:正式验收**不使用**当前临时形状;资产缺失/未交付时按本票问题 5 的降级链如实呈现,不得把临时图标当作最终态宣布验收通过。

+ 0 - 93
.scratch/cockpit-point-locate-and-device-link/issues/11-overlapping-device-points.md

@@ -1,93 +0,0 @@
-# 重叠设备点位的呈现与拾取
-
-Type: grilling
-Status: resolved
-Labels: wayfinder:grilling
-Blocked by: 05
-
-## 进度:100%
-
-讨论已完成,维护者已确认 Q1–Q9(两轮 grilling);`resolved` 表示本票决策完成,不表示重叠聚合呈现或展开环已经实现。实现仍受票 [09](09-task-backend-device-coordinate-contract.md) 的设备级坐标/状态契约或明确降级约束;视觉/反应/资产由票 06/07/10 与 14/15/16 号规格约束。
-
-下一步:本票结论已通过 `/to-spec` 固化为同一张 map 的 [17 号重叠设备点位呈现与拾取规格](17-overlapping-device-points-spec.md);实现时与 14/15/16 号规格合并消费。
-
-## Question
-
-01 已查明的事实:设备与监测点**各存一份独立经纬度**(`monitor_point` 与 `equipment_base` 各自成列),关联表 `equipment_point_rel` 只有 `equipment_id/point_id` —— 后端**没有任何相对位置信息**(同井/同杆/杆塔/安装高度/朝向 grep 0 命中),且后端**不保证也不校验**设备坐标与点位坐标相等;同时 `CONTEXT.md` 明确禁止「人工坐标偏移」把对象当成真实监测点。
-
-因此「一个监测点下 N 台设备坐标相同或极近」是必须预先设计的**可能形态**;当前无逐台位置样本证明真实重叠比例。若确实同屏重叠,`scene.pick` 只回最上层一个,逐台点选需要额外的呈现/选择策略。
-
-请裁决:
-
-1. **画什么**:N 台完全同坐标时 —— ① 只画一个聚合符号(带计数)?② 按角度扇形摊开(这是**视觉分离**,必须写清它与 `CONTEXT.md`「人工坐标偏移」禁令的边界在哪一句上)?③ 只画状态最高的一台 + 「+N」徽标?④ 根本不画设备点、只用面板清单(若如此,需求②要重新谈)?
-2. **拾取**:`drillPick`(01 指出它靠改 `show` 实现、会与特效独立开关互相干扰)?聚合符号点击后**展开**成可选择的环/列表?还是面板 → 三维单向联动(三维不反选,那需求④的双向性要删掉一半)?
-3. **与票 06 的分工**:本票定「重叠时画什么、怎么点到具体那一台」;票 06 定「选中后的表现语言」。边界写清楚,别两票各定一半。
-4. **计数口径**:聚合符号的计数如何与票 05 的**独立关联设备图例**、关联总数和实际已绘制数量对应?票 09 的八行摘要图例不混入关联设备;票 05 已定默认全画坐标准入有效设备,不默认截前 N 台。
-5. **极端量级**:单点位设备台数的真实上限;超过多少台必须降级;降级后如何**如实**表达「还有更多」(不许静默丢,也不许把没画出来的算进「已上图」计数 —— 票 07 的「不上图 ≠ 不存在」同一条纪律)。
-
-## Answer
-
-### 维护者复验修订:聚合入口持续可见(优先于旧收起规则)
-
-维护者确认新流程:监测点显示名称;关联设备聚合符号显示清晰的“聚合 K 台”。hover 高亮入口并瞬态预览,点击固定展开圈且聚合入口半透明暗淡,不直接隐藏。点展开子设备后,真实设备成为中心表层入口,设备详情及连接短线锚向该设备,镜头按设备档定位;设备仍有聚合 hover/click 能力,可再展开并切换同组其他设备。清空选择或返回摘要才恢复无设备身份的聚合符号。
-
-此规则取代“点击子设备收起并隐藏所有设备”及“固定展开隐藏聚合符号”的旧条款。真实设备编码与业务归属不变,只有屏幕呈现和入口变化;不改经纬度、不新增虚构设备。K>maxFan 仍保留总数及清单,选中设备即使来自清单尾部也保证中心可见。旧条款保留为历史。
-
-### 目标与边界
-
-- 本票裁决**同一监测点下多台监测设备坐标相同或极近**时的呈现与拾取;触发前提是票 [05](05-device-point-layer-trigger-and-lifecycle.md) 的关联监测设备图层(选中监测点才加载),本票不重开 05/06/07/10 已锁语义。
-- 「重叠」指设备坐标相同或极近到屏幕不可分辨;只处理同一监测点下的设备,不接管跨点位聚合(map Out of scope 已锁)。
-- 纪律不变:**不伪造设备真实位置**。聚合符号与展开环是**纯渲染层的呈现/分离手段**——不持久化、不参与空间推理(相机居中、引线长度计算等)、不写进任何「设备真实位置」语义;`CONTEXT.md` 的「人工坐标偏移」禁令对象是伪造数据位置,视觉摊开不触碰它。
-
-### 画什么(Q1)
-
-- **静止态=一个重叠聚合符号**:取本点可绘制设备中**最高优先级状态**的色/形 + 计数徽标(=K,口径见下);与 `CONTEXT.md`「点位聚合」词条同构,但对象是关联监测设备图层(不是摘要监测点)。
-- **交互态=展开环/扇形摊开**:hover/click 聚合符号后,把 K 台设备在屏幕层摊开成可逐台点选的环/扇形(纯渲染层分离,地理坐标不变)。
-- 取舍:④(不画设备点只靠面板)被否——直接砍掉需求②;③(只画最高态+徽标)被聚合符号吸收;①+② 组合为最终形态。
-
-### 拾取(Q2)
-
-- **不用 `drillPick`**:[02 调研](../research/device-status-effect-mechanisms.md)已核它靠反复改 `show` 实现,会与票 07 特效/图层生命周期的独立开关互相干扰;摊开后每台设备各有屏幕位置,**普通 `pick` 即可**。
-- 展开环内逐台拾取走票 06 的 `pickDeviceMarkerAt()`(认 `deviceCode`)与「设备点优先、监测点兜底」链;三维点设备 → 面板反选成立,**双向性不砍**。
-- 聚合符号本身**没有 `deviceCode`,不冒充任何一台设备**;它只承担「打开展开环」这一个动作。
-
-### 折叠态交互模型(Q6)
-
-- 沿用票 06 两档语义:**hover 聚合符号=瞬态预览**(环/扇形展开、不提交任何选中,离开即收);**click 聚合符号=粘性展开**(保持展开,直到「点中某台设备 / 点空白处 / 面板返回点位」才收)。
-- 点位在设备图层存在时已处于选中态(票 05),聚合符号 click **不承担选点位**;回点位级走票 06「返回点位」。
-
-### 与面板双向联动的接缝(Q7)
-
-- **面板 hover 设备卡片 → 三维只给聚合符号弱指向(如徽标轻闪/描边),不摊开**;**面板 click 选中卡片 → 才摊开环并高亮/强化那一台**。
-- 摊开绑定「持久选中」而非「瞬态 hover」,避免面板扫卡就哗啦摊开;三维侧反选面板在环内点设备后照常成立。
-
-### 与票 06/15 的分工(Q3)
-
-- 沿用[票 15](15-panel-device-correspondence-spec.md)已写死的边界:**引线退化(重叠不画引线)属 06 的选中表现语言;重叠时的 drillPick/展开/逐台拾取归本票**。
-- 一条新增接缝:**展开态下引线照票 06 逻辑画**(连监测点标记中心,表达归属);「重叠引线退化」只约束**未摊开(地理重合)**场景。已补进票 15 边界备注,不重开 06。
-- 展开环内选中设备的反应语言(外环+放大+引线)、其余设备弱化(≥3 台压暗缩小)均沿用票 06,不在本票另造。
-
-### 计数口径(Q4/Q8)
-
-- **K=本点位上「当前可逐台绘制」的设备数**(坐标准入 `coordinateValidation.renderable === true` 且属当前模式);**N=关联总数**(当前快照去重 `deviceCodes`)。**两数语义固定,任何地方不出现单个数字冒充全量**。
-- 聚合符号徽标=K;设备图例记 K,但**文案明示「已上图(聚合呈现)K 台」**,不写「逐台上图」——被聚合的设备不得虚报为逐台已上图(票 05/07 纪律)。
-- 未进入候选集的(无坐标 / 待确认 / 模式隐藏)单独列原因、不计入 K;面板清单如实列出全部 N 台与未上图原因。
-- 降级时(K>10)聚合符号、图例、面板清单**三处同源**:徽标 K、图例「聚合呈现 K」、清单「N 总计 + 原因」;被折叠的 K−10 台在清单中逐台可见,不静默消失。
-
-### 极端量级(Q5)
-
-- **阈值=10**(维护者定;区别于票 07 特效的 30 台降级,两套机制互不影响):**K≤10 全量摊开逐台可点;K>10 降级**——不摊开全量,保持聚合符号 + 显式「K 台设备(本屏显示 K 台 / 其余见清单)」形态。
-- 阈值放入 `map` 配置域:**`map.overlap.maxFan`,缺省 10**(与 `map.effects.*` 同构);真数留后续原型/走查现场校准(同 07/08 做法)。
-- 降级纪律(票 07 同款):**不静默丢、不把未逐台画出的算进「已上图」**。
-
-### 可断言口径(Q9)
-
-- 新增只读宿主字段(走票 06 先例,不破坏既有 `data-map-picked` / `data-map-device-picked` / `data-map-panel-selection` / `data-map-device-hovered`):
-  - `data-map-overlap-state` = `collapsed|expanded|degraded`
-  - `data-map-overlap-count` = K(当前可逐台绘制数)
-- 观感数值(环半径、摊开角度、徽标样式)由实现/原型现场定,不在本票锁死。
-
-### 后续
-
-- 本票只产决策不实现;实现归本图收口后的实现票,与 [14](14-associated-monitoring-device-layer-spec.md)/[15](15-panel-device-correspondence-spec.md)/[16](16-device-model-and-asset-spec.md) 号规格合并消费,前提是票 [09](09-task-backend-device-coordinate-contract.md) 设备级坐标/状态契约或明确降级方案。
-- 结论已同步:票 15 补边界备注;`CONTEXT.md` 新增「重叠聚合符号」词条;map 任务清单与 Decisions so far 已更新;规格已由 `/to-spec` 固化为 [17 号重叠设备点位呈现与拾取规格](17-overlapping-device-points-spec.md)。

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

@@ -1,111 +0,0 @@
-# 12: 监测点详情面板锚定跟随规格(来自决策票 04)
-
-Type: spec
-Status: resolved
-Blocked by: 04
-
-## 进度:100%
-
-规格交付已完成并关闭。已对照决策票 04 的 `## Answer`、所属 map 的目的地和关联相机契约核验锚点、避让、暂隐与恢复、无地图位置特例及验收边界;此进度仅指文档规格,不代表功能已实现。
-
-**2026-09-24 修订**:跟随决策票 04 的原位修订,`## Solution` 顶部新增修订节——**不再避让业务 UI、取消 `no-space`/`icon-covered` 暂隐、层级低于四个详情窗、唯一隐藏条件是窗口完全离开画面**。下方 Implementation Decisions 的旧条款保留以追溯,实现以修订节为准。
-
-下一步:后续实现票按本规格(含修订节)沿驾驶舱外壳验收外部行为;1080p/4K 走查若发现阈值不合适,回票 04 重议。
-
-## Problem Statement
-
-值班人员点开监测点的两级详情时,现有卡片固定在两栏之间;转动、平移或定位镜头后,卡片与地图标记的空间归属关系不清楚。标记移出视野或被业务界面遮住时,固定卡片仍在原处,容易让人误以为它属于当前画面里的其他点位。长设备清单还可能压住标记或业务信息。维护者已在本图决策票 04 确认:能看见标记时面板必须贴着它走,不能遮挡图标;确实放不下时可以暂隐,但不得悄悄丢掉正在阅读的详情。
-
-## Solution
-
-### 维护者复验修订:详情按当前对象换锚(优先于旧条款)
-
-维护者明确取代“两级详情均指向监测点”:点位摘要跟随监测点;点击设备点或卡片进入设备详情后,面板及连接短线跟随所选设备视觉标记。设备 A→B 同步换锚,返回摘要恢复监测点锚点;设备的业务归属 pointCode 不变。
-
-锚点包含贴地高度与展开环 pixelOffset;短线接当前尺寸的图标外缘并保留 12 px 间隙。无有效坐标的设备显示注明自身原因的固定详情,不借父监测点坐标;可上图设备标记暂失时暂隐并保留选择。关闭不回退、已有侧栏层级与裁切规则保持。下方旧条款保留为历史,冲突以本节为准。上游决策票 04 已在权限更新后按本地 Markdown tracker 同步本次用户直接裁定。
-
-> ### ⚠ 2026-09-24 修订(跟随决策票 04 的原位修订)
->
-> 维护者在票 08 走查后改变了遮挡规范。以下三条**取代**下方 `Implementation Decisions` 中关于避让与暂隐的条款(原文保留以追溯,凡冲突以本节为准):
->
-> 1. **不再避让业务 UI**:左右业务栏、顶部图例、底部摘要行、无地图位置浮块**不再是避让矩形**;标记落入这些区域**不再触发暂隐**。
-> 2. **不再因放不下而暂隐**:取消 `no-space`(四周放不下)与 `icon-covered`(被 UI 遮住)两种暂隐。面板**允许被屏幕裁切**。
-> 3. **层级低于四个详情窗**:浮动面板在 `--z-columns` 之下,**允许被左右两侧的四个详情窗遮盖**。
->
-> **唯一的隐藏条件**:**窗口完全离开画面**(标记离屏、相机背面/地平线后、投影无效)。这是为了避免「移动到会被详情窗遮住的位置时突然消失」。
->
-> 仍然不变:始终贴可见标记、四向尝试顺序、不压图标、12 px 间距、卡内滚动、暂隐保留选择与顶栏退出、无地图位置特例、关闭不回退镜头。
->
-> 实现时 `no-space` / `icon-covered` 应移除或标为废弃,不留死代码。
-
-在一张图里打开监测点详情后,面板随所属监测点的可见标记持续移动,细短线指向图标外缘,卡片始终与图标保持可见空隙。两级详情均指向监测点;从地图点开的未关联设备则指向它自己的标记。面板自动在标记四周寻找不挡业务栏、地图信息和图标的位置,长内容在卡内滚动。标记暂时不可见或没有足够空间时,面板暂隐、选择及已取数据保留,顶栏展示对象及暂隐原因并提供关闭;条件恢复时原详情自动重现。确属无地图位置的业务对象仍可由台账打开注明原因的固定详情,不制造假锚点。关闭详情不会让镜头回退。
-
-## User Stories
-
-1. 作为值班人员,我想打开监测点摘要后看到卡片指向该点的视觉标记,以便确认卡片属于哪个监测点。
-2. 作为值班人员,我想进入该点下某台设备的第二级详情后,卡片仍指向所属监测点,以便在设备内容中保持点位归属。
-3. 作为值班人员,我想从地图打开未关联设备时卡片指向设备自己的标记,以便不误以为它属于一个监测点。
-4. 作为值班人员,我想看清完整的点位标记和图标形状,以便面板出现时仍能辨识地图状态。
-5. 作为值班人员,我想在面板与标记之间看到短的归属指示,以便两者有间距时仍能判断联系。
-6. 作为值班人员,我想镜头定位飞行时面板连续跟随标记,以便移动途中不会指错对象。
-7. 作为值班人员,我想拖动、旋转或缩放地图时面板持续跟随可见标记,以便当前详情与空间位置保持一致。
-8. 作为值班人员,我想面板优先出现在标记右边,空间不足时自动翻到左、下、上方,以便尽量保留可读详情。
-9. 作为值班人员,我想面板避开左右业务栏、图例及地图状态信息,以便查看详情不妨碍值守信息。
-10. 作为值班人员,我想长设备清单和设备详情在卡片内部滚动,以便标题、关闭入口和地图标记仍可见。
-11. 作为值班人员,我想标记移出屏幕、落到地平线后或相机背后时卡片暂隐,以便画面不留下失去归属的详情。
-12. 作为值班人员,我想标记被左右栏或图例盖住时卡片暂隐,以便看不到锚点时不会误认卡片归属。
-13. 作为值班人员,我想四周都放不下可读面板时暂隐卡片,以便面板不强行压住业务信息。
-14. 作为值班人员,我想卡片暂隐期间仍保留当前监测点、设备层级和已加载数据,以便回到视野时接着阅读。
-15. 作为值班人员,我想卡片暂隐后自动在标记重现时恢复,以便不必再次点选或重新请求详情。
-16. 作为值班人员,我想暂隐时在顶栏看到对象名称或编码和原因,以便知道当前仍有一个详情选择。
-17. 作为值班人员,我想从顶栏状态入口关闭暂隐的详情,以便标记长期缺失时仍能退出选择。
-18. 作为值班人员,我想只在标记仍有效时使用重新定位入口,以便不会对已经不存在的标记触发无效定位。
-19. 作为值班人员,我想换快照后同一编码的标记重现时恢复原详情,以便数据刷新不中断阅读。
-20. 作为值班人员,我想新快照尚未查完、标记暂时未出现时保留选择并能主动关闭,以便渐进加载期间仍可退出;快照完整查完仍无该点时按票 05 关闭详情。
-21. 作为值班人员,我想体块遮住地表但视觉标记仍显示时继续看到贴点卡片,以便卡片行为与眼前标记一致。
-22. 作为值班人员,我想从台账打开无地图位置的监测点时仍能读到注明原因的固定详情,以便坐标问题不剥夺业务信息。
-23. 作为值班人员,我想关闭详情后卡片与短线消失而镜头留在当前视角,以便继续观察刚才的区域。
-24. 作为验收人员,我想在 1080p 与 4K 画布下观察面板避让、滚动和暂隐,以便确认空间阈值与跟随手感可用。
-
-## Implementation Decisions
-
-> **2026-09-24 修订标注**:以下第 3、5 条关于「避让业务 UI 矩形」与「暂隐」的条款**已被 `## Solution` 顶部的修订节取代**,实现时以修订节为准(不再避让业务 UI;取消 `no-space`/`icon-covered`;层级低于四个详情窗;只在窗口完全离开画面时隐藏)。第 1、2、4、6 条仍然有效。
-
-- 面板锚点是**可见的贴地视觉标记中心**,不是椭球高度零的经纬度投影点,也不是图标顶部或相机旋转中心。卡片边缘距 18×18 CSS px 图标外缘至少 12 CSS px;短线只连接到图标外缘,不能覆盖图标。两级详情均以监测点编码解析当前标记;未关联设备以设备自身标记为锚。
-- 使用 Cesium 世界坐标投影并换算到窗口坐标,明确处理标记坐标缺失、投影无效或非有限、相机背面、地球背面/地平线后、画布外及标记被业务 UI 覆盖。可见性遵从实际视觉标记;当前标记忽略深度遮挡,故地表被 3D Tiles 挡住而标记仍可见时继续显示卡片。无需独立的相机矩阵手算投影或以体块地表深度作另一个隐藏判据。
-- 运行时获取地图画布、左右栏、顶部图例与调试抬头、底部摘要与引擎状态、无地图位置浮块以及图标的实际矩形。候选面板先右后左再下、上;侧边可沿垂直方向调整,但连线继续指向原标记。~~候选矩形不得与保护区或图标相交~~(← **已取代**:不再避让业务 UI),画布边缘约留 12 CSS px,不能凭固定屏幕坐标猜测。**仍不得压住图标。**
-- 面板宽 300–420 CSS px;高度不超过扣除保护区后地图画布可用**屏幕**高度的 80%,也受当前候选位置实际剩余高度限制。~~低于约 280 CSS px 可读高度时暂隐~~(← **已取代**:允许被裁切,不再因此隐藏)。标题与关闭入口保持在卡内,设备卡片与设备详情正文在卡内滚动;屏幕可用高度不等于领域词汇“主场景区”的地理范围。
-- 有可用标记却暂时不能可靠投影或安放面板时,只改变表现可见性,保留监测点与设备两级选择、已取数据和在途请求的既有语义;不拿上一帧的卡片位置充数。重入视野和布局变化后自动恢复原详情。暂隐顶栏入口显示名称或编码与原因并可关闭;仅标记有效时可重新定位。标记在新快照分页期间暂时消失时通过编码等待新标记,不保存旧引擎实体;查找失败保留待确认与关闭入口,完整加载确认该点不在当前范围时依票 05 关闭详情。
-- 确认**无地图位置**的监测点从台账打开固定详情并如实说明原因。这是无有效锚点业务对象的特例;可上图标记因视角、快照或布局暂时不可用时仍按暂隐处理,不转为固定卡片。没有有效坐标的设备不上图。
-- 定位飞行及手动平移、旋转、滚轮期间,对当前单个锚点随已渲染画面更新投影与布局,每帧最多一次;无详情、页面不可见、镜头稳定且布局未变时停止计算,恢复或尺寸变化时立即重新计算。
-- 地图服务提供只读标记位置、投影和可见性查询;详情面板与外壳管理临时布局、顶栏暂隐反馈。保留现有两级导航状态与详情选择语义,不把帧坐标写入业务 store。关闭详情收回卡片与指示、清除详情选择,但不回退相机,遵循 ADR 0008。地图点选的镜头行为应服从本图票 03 已锁的实测地形中心与完整预设视角,不能把当前只换中心且高度零的代码现状当作目标。
-
-## Testing Decisions
-
-- 主要测试接缝沿用**驾驶舱外壳的外部可观察行为**:从地图真实点选或台账打开详情,观察卡片、图标、短线和顶栏入口;操作相机与刷新快照后断言归属、可见性、层级及关闭结果。不以内部投影函数或组件私有变量作为验收条件。
-- 几何边界可用少量纯逻辑断言覆盖四向候选顺序、矩形交集、图标间距、宽高上限和无法容纳的暂隐结果;实际贴地高度、地平线、UI 遮挡、相机飞行手感与 1080p/4K 的可读性需要真实画面走查。先复用现有点位详情的真实鼠标走查与相机契约走查方式;不新增一套测试框架。
-- 验证两级详情与未关联设备锚点、无地图位置特例、关闭后相机不回退;标记从可见到屏外/背面/UI 遮挡/无空间再恢复;换快照同编码重关联和查找失败时的顶栏退出、完整查找后不存在则关闭;体块只挡地表时仍贴可见标记。只断言外部结果和数据不重复加载,不要求特定内部帧循环实现。
-- 本规格是文档产物,本次不改代码;按项目规则,本次不启动 build、dev 或浏览器调试。实现后的 4K 帧耗与体验由实现票走查,若阈值不合适应回到决策票 04 重议,不能自行把“始终贴点 / 不可贴则暂隐”改成固定面板。
-
-## Out of Scope
-
-- 关联监测设备图层的触发、生命周期与摘要语义,归本图票 05。
-- 面板与三维设备点的双向联动、设备级高亮与定位反应,归票 06;本规格仅规定详情面板的归属锚点。
-- 设备状态特效及其与雾的组合归票 07;组合手感与视觉原型归票 08。
-- 设备级坐标的后端补齐、设备模型资产、同坐标设备的拾取,分别归票 09/10/11。
-- 非监测点和未关联设备的镜头定位数值、其他图层,以及竖屏、移动端与多屏拼接。
-
-## Further Notes
-
-- 上游决策源是本图票 04 的 `## Answer`;“面板锚点”沿用领域词汇,不与 ADR 0008 的相机旋转中心混用。票 03 的定位规格已有同目录 `spec.md`,本票是独立规格子票,不覆盖它。
-- 此规格以当前已有的详情内容和驾驶舱外壳为基础。现有详情仍固定摆放,投影只在开发态相机把手处可用;实现需要接出正式只读的视觉标记查询与布局状态。规格完成不表示功能已经实现。
-
-## Answer
-
-将决策票 04 的「始终贴可见标记;贴不下则暂隐、不退回固定位置」固化为可实施与验收的规格:两级监测点详情共用监测点视觉标记锚点,未关联设备锚向自身标记;按右、左、下、上及实际保护区避让,不能可靠显示时保留选择与已取数据,顶栏提供状态与关闭入口,条件恢复后自动重现。无地图位置对象的固定详情仅作注明原因的特例;关闭不回退相机。几何边界走纯逻辑断言,实际贴点与 1080p/4K 手感留后续实现票目视验收。
-
-## Comments
-
-### 规格验收核对
-
-已核对上游决策票 04 的五轮维护者确认和 `## Answer`;本票没有 `needs-triage` 阶段,依赖 04 已 resolved。核对 ADR 0008、`CONTEXT.md` 与现有 `PointDetailCard.vue` / `mapService.js`:领域术语与关闭不回退契约一致,当前实现仍是固定面板,规格没有冒称功能落地。此处仅静态核对文档与源码;项目规则禁止未经明确要求运行 build/dev 或浏览器走查。

+ 0 - 74
.scratch/cockpit-point-locate-and-device-link/issues/13-mode-layer-visibility-matrix.md

@@ -1,74 +0,0 @@
-# 一级模式的一张图图层可见性与选择态矩阵
-
-Type: grilling
-Status: resolved
-Labels: wayfinder:grilling
-Blocked by: 05
-
-## 进度:100%
-
-本票由[票 05](05-device-point-layer-trigger-and-lifecycle.md)讨论中提出,并在维护者确认后立案。票 05 已裁决关联监测设备图层的模式可见性与选择态;本票补齐**一张图其他业务对象**的完整矩阵。经两轮 grilling 收口,完整矩阵与五要素已由维护者逐项确认(2026-09-28),见下方 `## Answer`。`resolved` 表示决策完成,**不表示设施点/管线/异常点图层已经实现**:设施点数据契约、管线折线(票 22)、设备级状态契约(票 09/#69)均未交付,本期按「如实降级」执行。
-
-下一步:结论经 `/to-spec` 固化为本 map 的规格子票(等待维护者执行);设施点的拾取/详情/视觉词表另开设施票;实现去向仍是 `.scratch/cockpit-shell-panel-layout/issues/` 34 之后。
-
-## Question
-
-总体概览希望展示设备、设施、预警报警等所有适用点位;基础设施、运行监测、预警处置则只显示各自相关对象。哪些对象属于各模式、何时仅调整可见性、对象在目标模式隐藏后怎样保留/暂停详情,以及图例和计数如何随模式改变?特别注意“设施”和“监测设备”是不同业务对象,监测点与未关联设备也不得合并为一类。
-
-票 05 的已定边界不在这里重新发明:关联监测设备选中态临时图层在总体概览/运行监测展示全部有效位置设备、基础设施隐藏、预警处置只显示设备自身报警/预警者;总体概览与分项互切保留选择身份,分项之间互切清空选择;Viewer 常驻,切模式不额外换 `snapshotAt`。若完整矩阵出现矛盾,先把冲突带回维护者裁决,不能静默覆盖票 05 或 ADR 0005。
-
-请裁决:
-
-1. 四个一级模式 × 监测点、未关联设备、基础设施与管线、预警/报警事件及其他已实现空间对象的**可见矩阵**。点位状态筛选与设备状态筛选、事件等级各归何处,不用同一个状态词表偷换。
-2. 总览显示“全部点位信息”的准确边界:坐标准入不通过的对象仍怎样在相应列表和详情说明;不同图层缺数据时不得伪装为“该模式确实没有对象”。
-3. 当前选中对象不属于目标模式时的详情暂停与恢复、镜头保持、图例计数和拾取对象优先级,与票 05 既定选择态怎样合成。
-4. 每类图层的真实数据来源、与地图摘要分页关系、失败/过期退化及后续实现票归属。只讨论模式矩阵的必要契约,不承接设施或事件渲染的具体实施。
-5. **(2026-09-24 维护者新增,来自票 08 验收)初始地球视图的可见性**:进入引导的最初地球视图**只展示地球整体形态,不展示监测点、设备点位或点位详情**;点击「点击进入系统」、镜头落到主场景后,业务点位才出现。这与本票的模式矩阵是同一个「什么时候显示什么」问题的两个状态(进入前 vs 进入后 + 四模式),本票裁决时需把它与四个一级模式合成一张完整矩阵;实现侧的进入态挂载归 [票 30](../../cockpit-shell-panel-layout/issues/30-intro-gate-and-view-presets.md) 的引导层生命周期。注意与票 05「地图/台账点选均触发定位与设备加载」的衔接:进入前没有可选对象,进入后才谈选择态。
-
-## Answer
-
-### 目标
-
-把「什么时候显示什么」锁成一张完整矩阵:**引导态 + 四个一级模式** × 一张图的全部业务对象(监测点、未关联设备、关联监测设备图层、设施点、管线折线、事件回联)。只裁决可见性与选择态合成,不承接设施/事件渲染的具体实施。设施与事件**不是独立地图标记图层**——设施走设施点/管线(后续数据契约),事件靠设备编码回联位置(票 14 机制)。
-
-### 完整矩阵(维护者 2026-09-28 确认)
-
-| 状态\对象 | 监测点 | 未关联设备 | 关联设备图层(选中态) | 设施点 | 管线折线 | 事件 |
-|---|---|---|---|---|---|---|
-| **引导态**(进入前) | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ |
-| **总体概览** | ✓ 全部 | ✓ | ✓ 全部有效(票 05) | ✓ | ✓ | 回联定位 |
-| **基础设施** | ✗ | ✗ | ✗(票 05) | ✓ | ✓ | 回联定位 |
-| **运行监测** | ✓ 全部 | ✓ | ✓ 全部有效(票 05) | ✗ | ✓ | 回联定位 |
-| **预警处置** | 仅异常点 | 仅预警/报警的 | 仅 ALARM/WARNING(票 05) | ✗ | ✓ | 回联定位 |
-
-术语(已同步 `CONTEXT.md`):**设施点**=承载设施(管网、泵站、水厂、水源地、用水户等,各类型后端独立成表)的独立空间对象,与监测点同级、不计入监测点统计;**异常点**=至少一台关联监测设备处于预警/报警状态的监测点,判定以设备状态事实为准(`/monitoring/situation` 的 `abnormalEquipment[]` 回联),不借用监测点聚合状态词表。
-
-### 决策与选项权衡(逐项)
-
-1. **矩阵行集(Q1)**:只裁真实存在/已立项的对象层。设施与事件不是图层行:事件「不创建独立地图标记」、靠 `deviceCode`/`monitoringPointCode` 回联;设施面板(基础档案/运行信息/管理信息/应急保障)是聚合 KPI 口径、当前后端不给逐设施坐标,设施点需新数据契约。
-2. **四模式可见性(Q2+Q11/Q12/Q13)**:
-   - 总体概览=全部适用对象;基础设施=只设施点+管线(监测点/未关联设备/关联设备图层全隐藏);运行监测=全部监测点+未关联设备(设备视角,设施点隐藏);预警处置=仅异常点+预警/报警的未关联设备(设施点隐藏)。
-   - 关联设备图层按票 05 已锁范围执行(概览/运行监测=全部有效、基础设施=隐藏、预警处置=仅 ALARM/WARNING),本票不重裁。
-   - **管线折线四模式常驻**(进入后任何模式都不隐藏);「地面/管线视图」是正交的全局呈现条件,只改相机预设与地形/瓦片半透明(现有 `viewModes.js` 行为),不负责隐藏管线层本身。`CONTEXT.md`「管线视图让地下管线可见」按此修订为「让管线呈现更清楚」。
-3. **回联定位(Q3+Q10)**:不做模式过滤——目标在图上(坐标准入+已加载)则 `applyViewPreset` 飞过去,不在则不下发飞行(沿用既有 `locateNote` 口径)。基础设施面板条目的回联目标语义是**设施点**(Q10=B);设施数据契约未到位时如实降级「当前模式不显示该对象,无法定位」,不拿监测点坐标冒充设施位置。运行信息/应急保障面板现有 `pointCodes` 回联属监测点口径,等设施契约到位后转设施点。
-4. **设施点(Q6+Q7)**:数据源=后端新契约(逐设施坐标);本票只锁「可见/可回联」,**不锁拾取与详情**——设施点的点选、详情、视觉词表整体另开设施票,不为不存在的详情发明选中态。
-5. **异常点判定(Q8)**:只认设备状态 **WARNING/ALARM**(`abnormalEquipment[].status`/`abnormalType`),凭快照 `point.deviceCodes` 回联到 owning 点;工单/离线不混入异常点;未关联异常设备直接按坐标画出。`abnormalEquipment` 与 `map-snapshot` 是不同端点 → 沿用票 05「尽力展示并明确提示跨端点不同步」。
-6. **异常点视觉(Q15)**:只做可见性过滤、不发明第二个「异常」形状——异常点仍按各自 `pointStatus` 视觉呈现。**备注**:现有三角/菱形等 8 形状是临时视觉,后续替换为监测点图标、用颜色+大小区分状态;这条留给票 09/图例实现票作未来修订线索,本票不承接。
-7. **异常未关联设备视觉(C2)**:跟票 07 的 `deviceStatus.js` 设备状态词表(报警/预警专属色形+特效),与同模式关联设备图层同一词表;不沿用 UNASSOCIATED 十字中性色。
-8. **图例与计数(Q9+C4)**:只统计**当前模式实际可见**的摘要标记(与票 05 互斥计数一致);图例行随模式变化。设施点/管线无数据时不虚报行,由浮标如实说「设施数据未就绪」;图例行待设施契约与视觉词表到位后在设施票补。
-9. **引导态(Q4+Q14)**:矩阵含引导态列=「进入前不展示任何业务对象」。数据可**预取**(进入前就开始拉地图摘要),但一切业务点位显示被门控:标记、图例、点位计数浮标、详情在引导态一律不出现;点「点击进入系统」后才挂载显示,预取数据直接呈现。实现侧进入态挂载归票 30 引导层生命周期。
-10. **选择态合成(Q16)**:总体概览 ↔ 任一分项=保留选择身份、目标模式不显示所选对象时暂停详情与关联设备图层、切回且对象仍在当前范围时恢复;分项 ↔ 分项=清空选择。设施点本阶段无选择态。切模式不动相机、不换快照(Viewer 常驻)。全部是票 05 已锁规则的直接外推。
-
-### 风险与边界
-
-- **数据未就绪(如实降级)**:设施点契约(Q6)、管线折线(票 22)、设备级状态契约(票 09/后端 #69)均未交付 → 本期基础设施模式地图大概率「无设施数据」,预警处置异常点依赖 `abnormalEquipment[]` 且与 `map-snapshot` 跨端点不同步。
-- **异常点口径**:只认 WARNING/ALARM,不把工单/离线/监测点聚合状态混入。
-- **pointStatus 临时视觉**:不因临时形状承诺最终形态;替换留给票 09/实现票。
-- **管线层语义**:四模式常驻 + 管线视图正交呈现,`CONTEXT.md` 已同步。
-- **设施问题**:设施点拾取/详情/视觉词表/图例行整体移交新开的设施票,本票不承接。
-
-### 产物与后续
-
-- `CONTEXT.md` 已新增「**设施点**」「**异常点**」词条(2026-09-28)。
-- 结论待 `/to-spec` 固化为本 map 的规格子票(与 14/15/16 同列);实现去向 `.scratch/cockpit-shell-panel-layout/issues/` 34 之后。
-- 设施点实施另开票;管线折线实施属票 22;设备级坐标/状态契约属票 09。

+ 0 - 128
.scratch/cockpit-point-locate-and-device-link/issues/14-associated-monitoring-device-layer-spec.md

@@ -1,128 +0,0 @@
-# 14: 关联监测设备图层规格(来自决策票 05)
-
-Type: spec
-Status: ready-for-agent
-Label: ready-for-agent
-Blocked by: 05
-Category: spec
-
-本规格固化[决策票 05](05-device-point-layer-trigger-and-lifecycle.md)的维护者确认结果,作为[同一张 map](../map.md)的子规格单。它是关联监测设备图层的实施输入,不覆盖同目录现有的 `spec.md`;现有 `spec.md` 继续只描述票 03 的监测点/设备定位视角契约。
-
-## 进度:100%
-
-规格文档已发布为 `ready-for-agent` 并挂到同一决策 map;100% 仅指规格交付,不表示关联监测设备图层已实现。下一步:后端契约票 09 明确设备级坐标、校验和状态后,后续实现按本规格接入;票 06/07/10/11 继续裁决视觉、反应、资产与重叠设备细节。未经本对话明确要求,本次不运行 build 或浏览器走查。
-
-(2026-09-24 执行核验通过:对抗式逐条对照票 05 `## Answer` 与测试接缝,规格与既有 `isRenderable()`/`deviceCodes` 代码事实一致,未覆盖票 03 的 `spec.md`,未冒称已实现;维持 100%,保持 `ready-for-agent` 规格发布态,详见 Comments。)
-
-## Problem Statement
-
-值班人员从一张图或监测点台账选中一个监测点后,需要看到该监测点关联监测设备的真实空间位置,并能继续使用两级详情和定位。当前默认地图摘要只包含监测点与未关联设备;快照里虽有点位的关联设备编码清单,前端尚未读取。点位详情已有设备自身状态卡片,但缺少设备级坐标与校验,也未接入独立设备图层。若把关联设备直接并入默认摘要,会污染分页、快照和既有图例;若把监测点坐标当作设备坐标,又会制造错误位置。
-
-现有详情接口与地图摘要没有共同的 `snapshotAt` 锁定,详情还可能复用同点位缓存,因此跨端点数据不能被假设为同一时刻。切换一级模式、刷新快照、分页和坐标失败也必须让值班人员知道对象仍存在、当前为什么没有设备图标,以及哪些数量已经确认。
-
-## Solution
-
-从值班人员视角,选中监测点即启动关联监测设备数据加载;监测点自身坐标可用于定位时,同时启动点位定位。确认无地图位置的监测点从台账打开时,展示注明原因的固定详情,不制造相机定位或虚假锚点。有效设备位置返回后,设备显示在一个独立的关联监测设备图层中;默认地图摘要仍只负责监测点和未关联设备。地图入口与监测点台账入口遵守同一套选择规则,设备图层不要求额外点击“展开设备”,也不等待相机飞行停稳。
-
-关联监测设备图层属于当前选中监测点,是临时显示层,不是新的业务对象。设备标记使用独立的设备身份和所属监测点身份,可以被独立拾取;它不冒充监测点,不回写地图摘要,不改变未关联设备的独立标记和直接进入设备详情的行为。
-
-只有当前地图快照确认属于该监测点、且后端发布的设备级坐标通过坐标准入的设备才可上图。坐标缺失、非法、未校验、归属无法确认或设备位置数据失败时,设备仍保留在设备清单中,并显示明确的待确认/不可用原因。不能使用监测点坐标替代设备坐标,也不能人工偏移制造设备位置。
-
-总体概览和运行监测显示所选监测点全部坐标准入有效的关联监测设备;基础设施隐藏该图层;预警处置只显示设备自身状态为报警或预警的关联监测设备。正常、离线、工单处置、未知状态的设备不会因监测点聚合状态而被冒充为报警设备。设备状态未知不妨碍它在总体概览、运行监测依有效坐标显示(具体视觉由票 07 裁决);在预警处置则暂不上图并归入状态待确认/失败。
-
-## User Stories
-
-1. 作为值班人员,我想从地图点击监测点后自动开始加载关联监测设备,以便不用再寻找第二个展开入口。
-2. 作为值班人员,我想从监测点台账卡片打开同一个监测点时得到相同的关联设备行为,以便地图入口和台账入口不会产生两套手感。
-3. 作为值班人员,我想点选有有效地图位置的监测点后镜头定位和设备加载并行,以便数据较慢时仍能先看到点位位置。
-4. 作为值班人员,我想设备位置返回后即可看到设备图标,以便不必等待镜头动画完全停稳。
-5. 作为值班人员,我想关联监测设备拥有独立的设备编码和所属监测点身份,以便点到设备时不会误打开另一个监测点。
-6. 作为值班人员,我想默认一张图仍以监测点和未关联设备为基础,以便关联设备不会重复计入摘要分页和默认地图标记。
-7. 作为值班人员,我想未关联设备继续保持独立标记并可直接进入设备详情,以便没有监测点归属的设备不会被错误归入某个点位。
-8. 作为值班人员,我想关闭详情后关联监测设备图层立即消失,但相机停留在当前视角,以便继续观察刚才的区域。
-9. 作为值班人员,我想从监测点 A 切换到监测点 B 时立即移除 A 的设备图层,以便不会把 A 的设备误认为属于 B。
-10. 作为值班人员,我想切换点位后迟到的 A 数据不会覆盖 B,以便快速巡检时详情和图层始终指向当前选择。
-11. 作为值班人员,我想同一地图快照翻页时当前设备图层保持不变,以便分页新增摘要对象不会造成设备图层闪烁或重取。
-12. 作为值班人员,我想刷新或快照失效时先撤下旧设备图层,以便旧快照的设备不会被误读为新范围的数据。
-13. 作为值班人员,我想新快照第一页没有旧点位时系统保留已取详情、暂隐面板并继续查找后续页,以便渐进加载期间不会错误关闭详情或丢掉正在阅读的内容。
-14. 作为值班人员,我想新快照完整加载仍找不到旧点位时看到明确说明,以便知道它已不属于当前筛选范围。
-15. 作为值班人员,我想新快照查找失败时看到“待确认”并能重试,以便网络失败不会被误判为点位消失。
-16. 作为值班人员,我想台账先选到尚未加载进地图摘要的监测点时仍能打开详情,并在点位坐标有效时定位,以便台账不依赖用户先翻完地图分页;确认无地图位置时仍能阅读注明原因的固定详情。
-17. 作为值班人员,我想台账提前选中时先在点位详情和设备清单看到“待当前快照核对”,关联监测设备图层暂不展开,以便详情卡片不会被误当成当前地图位置证据;核对归属并满足坐标准入后才上图。
-18. 作为值班人员,我想跨端点数据不同步时看到风险提示,以便知道设备位置可能与当前地图快照不同。
-19. 作为值班人员,我想当前快照的设备总数来自该点位去重后的 `deviceCodes`,以便总数与地图范围保持一致。
-20. 作为值班人员,我想设备编码清单尚未加载或缺失时看到待确认,而不是看到一个猜出来的总数。
-21. 作为值班人员,我想详情多返回的设备仍能在清单中如实看到,但被标注为与当前地图快照不一致,以便系统不静默丢弃后端信息。
-22. 作为值班人员,我想当前快照独有但详情尚未返回的设备显示编码占位和待确认,以便设备不会因详情延迟而被误报为不存在。
-23. 作为值班人员,我想没有有效设备坐标的设备仍在设备清单中并显示原因,以便“没有图标”不会被理解成“没有设备”。
-24. 作为值班人员,我想系统默认显示所有坐标准入有效的关联监测设备,以便正常设备不会被前端静默过滤。
-25. 作为值班人员,我想将来设备数量超过性能预算而需要降级时看到如实的聚合数量和“还有更多”提示,以便不会把未逐台绘制的设备误读为已经上图;具体形态由票 11 决定。
-26. 作为值班人员,我想总体概览和运行监测看到所选点位全部有效设备,以便进行完整巡检。
-27. 作为值班人员,我想基础设施模式隐藏关联监测设备图层,以便地图只呈现该模式允许的对象范围。
-28. 作为值班人员,我想预警处置模式只看到设备自身报警/预警状态的设备,以便异常巡检不会把正常设备画成报警设备。
-29. 作为值班人员,我想设备自身状态未知时看到待确认,而不是看到正常、报警或预警的猜测。
-30. 作为值班人员,我想总览与分项模式互切时保留选择身份,以便切回总览后可以恢复原来的详情和设备图层。
-31. 作为值班人员,我想目标模式不显示所选点位时详情和设备图层暂停,以便模式范围和详情归属不冲突。
-32. 作为值班人员,我想三个分项之间切换时清空选择并收起设备图层,以便不同分项不会携带上一分项的空间对象。
-33. 作为值班人员,我想一级模式切换不重建地图实例,也不额外换地图快照,以便切换保持一张图的连续性。
-34. 作为值班人员,我想默认摘要图例和关联设备图例分开,以便监测点状态不会与设备状态混为一谈。
-35. 作为值班人员,我想默认摘要标记数不包含关联设备,以便摘要分页数量、点位图例和设备数量各自有清晰含义。
-36. 作为值班人员,我想关联设备计数能区分模式隐藏、实际已上图、明确无地图位置和待确认/失败,以便差额有原因可追溯。
-37. 作为值班人员,我想设备状态视觉规则独立于监测点聚合状态,以便设备自身状态不会被点位状态词表错误替代。
-38. 作为验收人员,我想从真实地图或台账入口完成完整流程并观察外部结果,以便规格能在驾驶舱整体行为上验收,而不是依赖实现内部结构。
-
-## Implementation Decisions
-
-- 关联监测设备图层是以当前监测点为归属的选中态临时图层。它与默认地图摘要标记计划、分页总数、摘要状态图例和摘要 `data-map-markers*` 计数分离。
-- 地图摘要继续提供监测点和未关联设备;关联监测设备不写回摘要序列。未关联设备仍使用自己的身份和既有视觉,不参加关联监测设备图层的触发、收场或计数。
-- 地图标记和监测点台账都调用同一监测点选择语义。选中有有效地图位置的监测点时并行执行点位定位和关联设备加载;确认无地图位置时仍可从台账打开注明原因的固定详情,不伪造定位或标记。关闭、换点、刷新和快照重载需要使旧的临时图层与迟到响应失效。
-- 关联设备的领域记录至少需要保留设备编码、所属监测点编码、设备自身状态、设备级经纬度、设备级坐标校验结果,以及缺失或不一致原因。地图服务只接收领域对象,不向组件泄露后端原始字段。
-- 关联总数以当前地图快照中该监测点去重后的 `deviceCodes` 为准。编码清单未加载或缺失时总数为待确认;点位详情统计和卡片数量不能替代当前快照总数。
-- 当快照清单与详情卡片不一致时,两份数据如实并列:快照独有编码显示设备详情待确认,详情独有编码保留卡片但标记为当前快照不一致且禁止上图;只有同时属于当前快照清单、归属可确认且坐标准入有效的设备才进入显示候选。
-- 设备坐标准入只认后端设备级 `coordinateValidation.renderable === true`;前端不另算经纬度合法范围,也绝不把后端否决者放行。设备自身经纬度缺失或非有限、无法构成地图位置时仍不上图并如实报数据不可用,不用监测点坐标代替设备坐标,不做人为坐标偏移。
-- 设备默认不按状态或固定前 N 台静默裁剪。当前没有可信的单点位设备数量上限;若实际规模超过性能预算,聚合、重叠呈现和“还有更多”的交互由关联的重叠与模型票继续裁决。
-- 一级模式可见性固定为:总体概览和运行监测显示全部坐标准入有效的关联设备,即使设备自身状态未知也不凭状态隐藏;基础设施隐藏关联监测设备图层;预警处置只显示设备自身状态为 `ALARM` 或 `WARNING` 的设备,在此模式中未知设备状态暂不上图并进入状态待确认/失败。
-- 总览与任一分项互切保留选择身份;若目标模式隐藏当前监测点,则暂停详情浮层和关联设备图层,切回且点位仍在当前范围时恢复。三个分项之间互切清空选择。一级模式切换只调整可见性与选择态,不重建地图实例或为切换另取快照。
-- 换新快照时先撤下旧关联设备图层,保留选择身份、详情层级与已取数据;对本应可上图但新标记暂未出现的点位,沿票 12 的锚定规则暂隐详情、顶栏提示待核对并保留关闭入口。后台沿新快照逐页查找点位,第一页没有不能判定不存在;找到同编码有效标记后恢复锚定详情并重新加载设备。若核对到该点但确认**无地图位置**,则按票 12 展示注明原因的固定详情,不等待虚构的标记。完整加载仍找不到点位才关闭详情并说明原因;查找失败保持待确认、已取数据与顶栏关闭入口,并允许重试。地图摘要仍按分页渐进呈现。查找过程须覆盖本快照已取过的页与后续页,不能只查摘要合并态最近保留的对象;合并态的对象数量护栏不能作为点位不存在的证据。
-- 计数分为两套:默认摘要图例和摘要标记数只计算当前模式实际可见的摘要标记;关联设备独立报告总数、实际已上图、模式隐藏、后端明确无地图位置、位置/状态/绘制待确认或失败。设备图例只统计当前模式实际绘制的设备,各类原因应互斥并能解释已确认的关联总数。
-- 设备标记必须携带独立的设备拾取身份和所属监测点身份,不冒充监测点 `pointCode`。设备图标的形状、尺寸、贴地方式、设备高亮、状态特效、同坐标多设备展开和模型资产不在本规格中决定。
-- 该规格只定义前端行为契约;后端契约任务需补齐点位关联设备的设备级坐标与校验,并确认设备自身状态来源与编码清单的对应关系。未取得可用位置契约时只能展示设备清单和待确认原因,不能伪造空间位置。
-
-## Testing Decisions
-
-- 最高测试接缝是驾驶舱外壳的外部可观察行为:从真实地图标记或监测点台账选中监测点,观察点位定位、详情、关联监测设备图层、摘要图层、模式切换、刷新、快照查找、计数和关闭结果。验收不读取 Cesium 私有实体、不依赖组件私有变量,也不以两份相同的 JavaScript 输入互比来证明渲染成功。
-- 纯逻辑断言覆盖地图摘要分页与 `snapshotAt` 生命周期、当前快照 `deviceCodes` 去重与总数、详情/快照差异分类、坐标准入分类、旧响应丢弃、模式可见性和选择暂停/恢复。断言应只面向这些领域行为和返回状态。
-- 驾驶舱走查覆盖地图入口与台账入口一致、有效点位的相机定位与设备加载并行、确认无地图位置时注明原因的固定详情且不伪造定位、设备位置出现不阻塞定位、关闭和换点收场、同快照翻页不重建设备层、换快照后台逐页查找、完整结束后的关闭、失败时待确认提示,以及总览/分项/预警处置的可见范围。
-- 计数走查必须分别核对摘要标记数、摘要状态图例、关联设备总数、当前模式已上图数、模式隐藏数、明确无地图位置数和待确认/失败数;不得把关联设备计入摘要分页或摘要图例。
-- 回归沿用现有地图摘要、坐标校验、两级详情、真实鼠标点选和相机定位走查的测试先例;设备状态视觉、设备模型、同坐标拾取和特效沿各自下游票的验收契约。规格阶段只写验收契约,不运行 build、dev 或浏览器调试。
-
-## Out of Scope
-
-- 后端关联设备的设备级坐标与坐标校验契约的实际补齐,以及设备自身状态来源的确认;该工作由票 09 负责。
-- 设备图标的具体形状、尺寸、贴地实现、模型资产来源、设备级高亮、状态特效和雾气组合;分别由票 06、07、10 及相关实现票决定。
-- 同坐标或近距离设备的聚合、展开、逐台拾取和超量降级;由票 11 决定。
-- 设施、管线、事件、预警报警等其他地图对象的完整四模式可见矩阵;由同一张 map 的票 13 决定。
-- 默认地图摘要分页机制本身、监测点状态视觉词表、既有未关联设备标记、相机定位数值和两级详情导航基础状态机的重定义。
-- 登录、权限、工单写操作、真实数据全面接线、移动端、竖屏和多屏拼接。
-- 覆盖或改写现有票 03 的定位视角规格;本规格只引用其定位行为。
-
-## Further Notes
-
-- 本规格的唯一决策来源是票 05 的 `## Answer`;它把讨论结论转为后续实现可消费的行为契约。
-- “关联设备不上图”在本规格中只表示不进入默认摘要标记计划;选中监测点后的有效设备可以进入临时图层。无地图位置、待确认和模式隐藏分别有明确原因,不能统称为不存在。
-- 历史只读记录只有 3 条有效关联覆盖 2 个点,不能作为单点设备数量上限;当前规格选择默认全画有效设备,未来性能证据不足时回票 11 裁决。
-- 点位详情与地图摘要当前没有共同快照锁定,因此设备位置风险提示是强制行为。后端未来若提供可核对的同版契约,应在实现规格评审时重新核对本条,不得静默删除提示。
-- 本规格新增为同一决策 map 的子规格单,现有票 03 的 `spec.md` 保持不变。旧规格的 500 m 是点位级**镜头预设**;其“所有设备可见”须以设备级坐标准入、当前一级模式范围以及设备实际落在视野中为条件,不能覆盖本规格的不上图规则。设备分布超出视野时按旧规格数值校验口回票 03 复议 500 m,不在本规格私自改镜头数值。
-- 规格发布后,实施票应在关联设备坐标/状态契约完成或明确降级方案后开展。
-
-## Comments
-
-### 规格验收核对
-
-已依据维护者确认的票 05 结论、`CONTEXT.md` 领域词汇、ADR 0002/0003/0004/0005 和既有地图摘要/坐标/详情走查先例编写。测试接缝已由维护者确认为驾驶舱外壳外部行为加领域纯逻辑断言。本次只发布规格文档,没有修改票 03 的 `spec.md`,也没有运行 build、dev 或浏览器调试。
-
-### 执行核验(2026-09-24)
-
-- 阶段判断:本票无 `needs-triage` 标签;票面已发布 `ready-for-agent` 规格、进度 100%,map 任务清单已勾选且 Decisions so far 已有条目,判断处于「已完成规格交付」阶段,不进入实施(实施被票 09 后端契约门控,且本 effort 只产决策/规格)。本次不运行 build、dev 或浏览器走查(AGENTS.md 规则;规格票本就只写验收契约)。
-- 对抗式核验(对照验收标准逐条):① 交付物=规格文档,验收标准=忠实固化票 05 `## Answer`、不覆盖票 03 的 `spec.md`、测试接缝由维护者确认;已逐条对照——图层边界与「关联设备不上图=不进默认摘要」、地图/台账统一触发与并行、独立设备拾取身份、坐标准入只认 `coordinateValidation.renderable === true`(与 `domain/coordinate.js` 既有 `isRenderable()` 口径一致)、总数以当前快照去重 `deviceCodes` 为准、快照/详情差异分类([A,B] vs [B,C])、关闭/换点/换快照生命周期与迟到响应丢弃、四模式可见性(总览/运行监测全显示、基础设施隐藏、预警处置仅 `ALARM`/`WARNING`、未知状态归待确认)、互斥计数与摘要图例分离——全部固化且无冲突。② 已核实 `spec.md` 内容仍为票 03 的定位视角契约(点位级 500 m / 设备级 150 m),本票未覆盖它。③ Testing Decisions 与维护者确认的「驾驶舱外壳外部行为 + 领域纯逻辑断言」一致,未把内部实现细节写成验收条件。④ 未冒称实现:进度区与 Further Notes 均明确「100% 仅指规格交付」,源代码中 `deviceCodes` 尚未接线、无设备图层,与票面表述一致。
-- 结论:验收通过,维持 100%;保持 `ready-for-agent` 发布态(与仓库规格文档发布约定一致,见 `spec.md`);map 的 Decisions so far 与任务清单无需改动。本次未修改任何代码。

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

@@ -1,97 +0,0 @@
-# 15: 面板选设备与三维设备点的对应反应规格(来自决策票 06)
-
-Type: spec
-Status: ready-for-agent
-Label: ready-for-agent
-Blocked by: 06
-Category: spec
-
-本规格固化[决策票 06](06-panel-to-scene-device-correspondence.md)的维护者确认结果,作为[同一张 map](../map.md)的子规格单。它是「面板选设备与三维设备点互相对应反应」的实施输入,不覆盖同目录现有的 `spec.md`;现有 `spec.md` 继续只描述票 03 的监测点/设备定位视角契约。
-
-## 进度:100%
-
-规格文档已发布为 `ready-for-agent` 并挂到同一决策 map;100% 仅指规格交付,不表示对应反应功能已实现。下一步:后续实现按本规格接入,表现层数值(引线虚实/颜色、压暗缩小程度、hover 拾取节流阈值)由 08 原型与实现票现场定;重叠拾取/呈现归票 11。未经本对话明确要求,本次不运行 build 或浏览器走查。
-
-## Problem Statement
-
-值班人员选中监测点后,关联监测设备图层会展示该点下的设备点(票 05/14)。但当值班人员在面板中点设备卡片、或反过来在三维里点设备点时,三维设备点与面板之间缺乏「谁被选中、谁被指着」的对应反应,靠颜色表达又会与设备状态色键(票 07)冲突。若不定义这套反应语言,实现票会自行发明 hover/选中/弱化/拾取的语义,导致面板与三维互相不对应、色键语义被挪用、双向链接只做一半。
-
-现有相机契约已定义两档定位视角(票 03:点位级 500 m / 设备级 150 m),但「点面板设备是否真的飞设备级、何时飞、返回点位级镜头如何」未定;`pickPointMarkerAt()` 只认 `pointCode` 实体(票 10),设备点目前拾不到;宿主 `dataset` 已有 `data-map-picked` 断言先例,但缺设备级断言字段。重叠(设备坐标≈点位坐标)时 `scene.pick` 只回最上层一个,逐台展开归票 11,本规格只定正常单点的反应与拾取。
-
-## Solution
-
-### 维护者复验修订(优先于旧锚定与中心弱化条款)
-
-点位摘要跟随监测点;进入设备详情后面板及其屏幕连接短线跟随所选设备,A→B 换锚,返回摘要恢复监测点锚。设备仍属于原监测点,空间锚点变化不改变业务归属。设备—监测点的场景归属线与面板连接短线是不同指示,前者保留既有语义。
-
-中心设备图标始终不透明,不随状态特效闪烁、渐隐或旋转,不缩小到正常尺寸以下;选中/hover 的放大保留,外围光环承担状态动画。因此旧“其余中心设备压暗 + 缩小”条款不再用于地图中心符号。无位置设备按自身原因显示固定详情,不借父点坐标;展开偏移计入设备视觉锚点。此修订来自用户直接反馈,旧条款保留为历史。
-
-从值班人员视角:面板 hover 一张设备卡片,三维里对应设备点轻放大;三维里 hover 一个设备点,面板里对应卡片高亮。点击选中一台设备时,三维点显示「它是被选中的那台」——外环 + 放大 + 引线(引线连到该监测点标记中心,即面板锚点);其余设备(≥ 3 台时)压暗并缩小退后。首次进入设备级时镜头飞到设备级预设视角看清这台设备;在同一点位内切换设备只换中心不重飞;返回点位级时回到点位级视角。三维点设备点会反选面板卡片并进入设备详情第二级;「返回点位」把三维还原为监测点选中态。三维里的设备点被拾取使用独立身份,不冒充监测点(票 05),且设备点优先于监测点被拾到。走查能通过宿主只读字段读到「当前选中哪台设备、面板停在哪个层级、当前 hover 哪台」,而不是只看图。
-
-## User Stories
-
-1. 作为值班人员,我想在面板 hover 设备卡片时三维里对应设备点轻放大,以便在画面里立刻认出卡片指的是哪台设备。
-2. 作为值班人员,我想在三维里 hover 设备点时面板对应卡片高亮,以便反向也认得出指针停在哪个设备的空间位置。
-3. 作为值班人员,我想点击选中一台设备时三维点出现外环并放大一档,以便与没有选中的设备一眼区分。
-4. 作为值班人员,我想选中设备时有一条引线连到它的监测点标记中心(面板锚点),以便明白这台设备属于哪个监测点、面板正在说它。
-5. 作为值班人员,我想设备点与监测点坐标相同(重叠)时引线退化不画,以便不会出现长度为 0 的线糊在标记上。
-6. 作为值班人员,我想选中反应的视觉不使用颜色,以便不与设备状态色键(票 07)冲突、也不破坏「颜色不是唯一线索」。
-7. 作为值班人员,我想首次进入设备级时镜头飞到设备级预设视角(150 m,票 03 数值可配置),以便能看清这台被选中的设备。
-8. 作为值班人员,我想在同一点位内从设备 A 切到设备 B 时只换中心、不再飞一次镜头,以便逐台巡检时画面不反复晃动。
-9. 作为值班人员,我想从设备级返回点位级时镜头回到点位级定位视角(500 m),以便回到「看整个点位及其全部设备」的视野。
-10. 作为值班人员,我想返回点位级不等于取消选中(监测点仍选中),以便不会触发票 03 的「取消不回退」语义冲突。
-11. 作为值班人员,我想在三维里点一个设备点,面板滚动到该设备卡片并进入设备详情第二级,以便从地图侧也能直达这台设备的详情。
-12. 作为值班人员,我想面板第二级打开时三维仍保持该设备的选中强化,以便详情与三维始终指向同一台设备。
-13. 作为值班人员,我想点面板「返回点位」后三维还原为监测点选中态(设备外环/放大/引线清除),以便层级回退与视觉回退一致。
-14. 作为值班人员,我想面板锚点仍跟随监测点标记中心而不是跟随设备点,以便锚定契约(票 04/12)不因设备选中被破坏。
-15. 作为值班人员,我想选中一台设备时其余设备(≥ 3 台时)压暗并缩小,以便焦点设备清晰、其余设备仍可见可点。
-16. 作为值班人员,我想设备数少于 3 台时不启用弱化,以便小点位不会出现无谓的视觉抖动。
-17. 作为值班人员,我想 hover 到一台被压暗的设备时它撤销弱化并轻放大,以便在弱化的设备里也能认出并点击想看的这台。
-18. 作为值班人员,我想 hover 不会给设备叠加外环或引线(那是选中专属),以便「被指着」和「被选中」两档一眼可分。
-19. 作为值班人员,我想已选中的设备不被 hover 干扰,以便选中态在指针乱晃时保持稳定。
-20. 作为值班人员,我想在三维里点设备点能拾到设备(通过独立拾取入口),以便三维 → 面板的双向联动成立。
-21. 作为值班人员,我想点设备点不会误开一个监测点,以便设备身份与监测点身份(票 05)互不冒充。
-22. 作为值班人员,我想设备点优先于监测点被拾到(正常单点),以便点了设备区域就是选设备;点监测点标记则回到点位级。
-23. 作为值班人员,我想未关联设备的既有拾取链路不变,以便本规格不改变没有点位归属的设备行为。
-24. 作为验收人员,我想走查能读到当前选中设备编码、面板层级和当前 hover 设备编码,以便对断言而不是看图。
-25. 作为验收人员,我想新增字段不破坏既有 `data-map-picked` 语义,以便票 10 的既有走查继续成立。
-
-## Implementation Decisions
-
-- 反应语言分两档、可叠加规则固定:**hover 是瞬态、只作用于非选中设备**(被 hover 的撤弱化 + 轻放大,不叠外环/引线);**选中是持久态**(外环 + 放大 + 引线),不被 hover 干扰;已选中设备保持强化。对应反应一律**不用颜色**——色键语义仍只属于设备状态(票 07),「颜色不是唯一线索」验收不被破坏。
-- hover=双侧:面板卡片 hover → 三维点轻放大;三维 pointermove hover 设备点 → 面板对应卡片高亮,复用 `detailStore`/`selectDevice` 链路。三维侧 pointermove 拾取的节流/静止判断与成本控制交实现(不在本规格锁数值)。
-- 选中态引线从设备点连到**监测点标记中心**(即票 04/12 的面板锚点),随锚点走;设备点与监测点坐标相同(引线长度≈0)时**引线退化不画**,靠外环 + 放大表达。引线虚实/颜色、压暗缩小程度等表现层数值留 08 原型与实现票现场定。
-- 镜头联动(数值全部仍归票 03 `map.locate`,本规格只定行为):点监测点 → 点位级定位(500 m);**首次进入设备级**(从点位级视角点设备卡片)→ 设备级定位飞一次(150 m);设备级内 A→B 切换 → **只换中心、不重飞**;**返回点位级 → 回 500 m 定位视角**。返回点位是设备级 → 点位级的**层级回退**,不是票 03 的「取消选中不回退」(监测点仍选中),两者语义并存。
-- 双向联动=**全双向**:三维点设备点 → 面板滚动到该卡片并选中(进设备级第二级,复用 `detailStore.selectDevice`);面板第二级打开时三维保持该设备选中强化;「返回点位」→ 面板回第一级、三维还原为监测点选中态(设备外环/放大/引线清除)。**面板锚点仍跟随监测点标记中心**(票 04/12 与 CONTEXT.md「面板锚点」词条不动),三维强化跟随设备、锚点归属不换。
-- 多设备弱化:其余设备**压暗 + 缩小**;**阈值=该点位设备数 ≥ 3 台才启用**,少于 3 台全亮。弱化不改变已上图数,票 07 独立设备图例只计实际绘制,计数不受影响;本规格不新增第二个计数口径。
-- 拾取=**独立入口 `pickDeviceMarkerAt()`**(认 `deviceCode`,不混 `pointCode` 归属判据;票 05「不冒充监测点标记」由此结构性保证)。点选链路**先试设备点、再试监测点**——正常单点设备点优先、监测点兜底(点监测点标记回点位级)。未关联设备既有拾取链路不变。重叠时的 drillPick/展开/逐台点选归票 11,本规格只定正常单点;管线交互优先级低于监测点和设备点位(票 22)保持不变。
-- 可断言口径:宿主 `dataset` 新增只读、可空字段,走查对断言不对图:
-  - `data-map-device-picked` = 当前选中设备 `deviceCode`
-  - `data-map-panel-selection` = `none|point|equipment`(与 `detailStore.selection` 对应)
-  - `data-map-device-hovered` = 当前三维 hover 设备 `deviceCode`
-  - 不破坏既有 `data-map-picked`(票 10 走查在用)。
-
-## Testing Decisions
-
-- 最高测试接缝是驾驶舱外壳的外部可观察行为:从真实地图标记或监测点台账选中监测点后,用真实鼠标/指针完成「面板 hover 卡片 → 三维反应」「三维 hover 点 → 面板高亮」「点设备卡片进设备级」「三维点设备反选面板」「返回点位还原」「弱化与 hover 撤销」「设备点拾取优先于监测点」完整流程,观察面板层级、三维选中强化、引线与镜头。验收不读取 Cesium 私有实体、不依赖组件私有变量。
-- 纯逻辑断言覆盖:两级详情状态机(`none → point → equipment`)在三维反选与「返回点位」下的迁移、设备级切换只换中心的状态保持、弱化阈值(设备数 ≥ 3)判定、hover 撤弱化的作用域(只作用于非选中设备)、`pickDeviceMarkerAt()` 的身份返回(`deviceCode` 不混 `pointCode`)与设备点优先顺序。
-- 走查断言读取宿主只读字段(`data-map-device-picked` / `data-map-panel-selection` / `data-map-device-hovered`),与三维场景实际状态对照;引线出现/退化、外环/放大、压暗/缩小等观感由维护者目视裁定(与票 07 相同的「A + 人工走查」口径)。
-- 回归沿用票 10 两级详情/真实鼠标点选/`data-map-picked`、票 09 标记形状走查、票 03 相机定位断言(`getCameraState()` + `dataset.camera`)与票 05/14 设备图层生命周期走查的测试先例;设备状态特效、设备模型、同坐标拾取沿票 07/10/11 的各自验收契约。规格阶段只写验收契约,不运行 build、dev 或浏览器调试。
-
-## Out of Scope
-
-- 重叠设备(同坐标/极近)的画法、聚合展开、逐台点选与超量降级;由票 11 决定,本规格只声明「重叠时引线退化不画、正常单点设备点优先」。
-- 设备状态特效(票 07)、设备「模型」口径与资产(票 10)、设备图层的触发与生命周期(票 05/14)的重定义;本规格只在其之上定义「选中/指向」反应。
-- 相机定位数值本身(heading/pitch/range、`map.locate` 配置):全部仍归票 03;本规格只定「何时飞、何时只换中心、返回点位回哪档」的行为。
-- 面板锚定跟随的形态与退化策略(票 04/12)及其面板浮层布局;本规格只声明「锚点归属不换仍跟随监测点」。
-- 后端设备级坐标与校验契约的实际补齐(票 09)、真实数据全面接线、登录/权限/工单写操作、移动端/竖屏/多屏拼接。
-- 覆盖或改写现有票 03 的定位视角规格;本规格只引用其定位行为与数值。
-
-## Further Notes
-
-- 本规格的唯一决策来源是票 06 的 `## Answer`;它把讨论结论转为后续实现可消费的行为契约。
-- 与票 11 的边界已显式切分:**引线退化(重叠不画引线)属本规格的选中表现语言;重叠时的 drillPick/展开/逐台拾取归票 11**。实现时若先做本规格、票 11 未落地,正常单点拾取与反应可先行,重叠行为按票 11 结论补。
-- **票 11 收口后的边界补充(2026-09-25)**:**展开态(聚合符号摊开成环/扇形)下,选中设备的引线照 06 逻辑画**(连监测点标记中心,表达归属);「重叠引线退化不画」只约束**未摊开(地理重合)**场景。聚合符号/展开环/逐台拾取与 K>10 降级归票 11,本规格不重开 06。
-- 表现层数值(引线虚实/颜色、压暗缩小程度、hover 拾取节流阈值)属于 08 原型现场校准项,不在本规格锁死;校准结果若推翻票 06 任一条语义,回票 06 重议,不由实现票自行改。
-- 本规格新增为同一决策 map 的子规格单,现有票 03 的 `spec.md` 保持不变;14 号规格(关联监测设备图层)与本规格是同一实施链路的两个输入,实施时合并消费,不发生互相覆盖。
-- 规格发布后,实施票应在设备图层可用(09 设备级坐标/状态契约或明确降级)前提下开展。

+ 0 - 145
.scratch/cockpit-point-locate-and-device-link/issues/16-device-model-and-asset-spec.md

@@ -1,145 +0,0 @@
-# 16: 设备点位模型口径与资产来源规格(来自决策票 10)
-
-Type: spec
-Status: ready-for-agent
-Label: ready-for-agent
-Blocked by: 10
-Category: spec
-
-本规格固化[决策票 10](10-device-model-asset-definition.md)的维护者确认结果,作为[同一张 map](../map.md)的子规格单。它定义“模型”在本期的产品口径、位置符号视觉、正式资产替换接缝、未来真三维模型接入边界、地形关系和降级可见性;不覆盖 14 号规格的关联监测设备图层生命周期,也不覆盖 15 号规格的面板选设备与三维设备点联动。
-
-## 进度:100%
-
-规格文档已发布为 `ready-for-agent` 并挂到同一决策 map;100% 仅指规格交付,不表示设备位置符号、正式图片素材或真三维模型已经实现。当前仓库没有本地设备级 glTF/GLB,当前形状簇只可作为临时视觉;后续实现须保留可替换资产接缝,并以正式素材作为最终视觉验收依据。未经本对话明确要求,本次不运行 build、dev 或浏览器走查。
-
-## Problem Statement
-
-需求“展示该监测点下所有设备点位的模型及坐标图标”中的“模型”没有明确产品含义。若按字面实现为真三维模型,当前仓库没有可用的本地设备级 glTF/GLB,远程 demo 资产也不能成为生产依赖;若直接把图标当成真模型,又会把临时占位视觉误报为已经交付的三维能力。
-
-监测点和监测设备还需要在同一张一张图中保持可辨。两者可以使用同样式、甚至同一份素材,但它们是不同业务对象,状态来源也不同:监测点使用点位状态,监测设备使用监测设备自身状态。设备位置必须来自设备自己的有效坐标并结合地形贴地,不能使用监测点坐标或人工偏移。资产缺失、加载失败、地形未就绪和性能降级也不能让设备从清单中静默消失,或被误读为真模型已成功显示。
-
-## Solution
-
-本期将需求中的“设备点位模型”正式解释为**监测设备在三维场景中的位置符号**:由可替换的图片/图标素材、渲染尺寸、对象自身状态视觉和既有 hover/选中/定位反馈组成;本期不承诺、不加载真三维设备几何模型,也不把图标模式称为真模型。
-
-监测点和监测设备使用相同样式或未来统一提供的坐标点图片素材,通过程序控制实际渲染大小区分类型,监测点始终大于监测设备。设备图标默认不显示长文本,悬停或选中时以设备名称为主、设备编码为辅显示。未来可以按设备类型逐步接入真三维模型,同一关联监测设备图层允许真模型与图标混合呈现;每台设备都保留可靠的图标兜底和如实的降级原因。
-
-有效设备坐标结合地形采样贴地。地形未就绪或采样失败时不上图,但设备仍保留在设备清单中并提示位置待确认;坐标缺失、非法或后端 `renderable=false` 时继续按无地图位置处理。所有位置表达遵守正常场景深度关系,不为设备图标或真模型开启穿透轻量体块的特殊例外。
-
-## User Stories
-
-1. 作为值班人员,我想看到“模型”实际代表设备在三维场景中的位置符号,以便不会把图标误认为已加载的真实设备几何模型。
-2. 作为值班人员,我想在选中监测点后看到其所有具备有效位置的关联监测设备符号,以便观察点位内设备分布。
-3. 作为值班人员,我想监测点和监测设备使用统一的视觉风格,以便一张图保持一致的品牌和识别体验。
-4. 作为值班人员,我想通过大小区分监测点和监测设备,以便即使两者使用同一素材也能判断对象层级。
-5. 作为值班人员,我想监测点始终比监测设备更大,以便监测点的承载关系在不同镜头距离下保持清楚。
-6. 作为值班人员,我想图标能随相机距离进行动态缩放,以便近景可读、远景不至于占满画面。
-7. 作为值班人员,我想动态缩放不破坏监测点大于监测设备的关系,以便视觉层级不会因镜头变化反转。
-8. 作为值班人员,我想默认地图不显示设备长文本,以便设备较多时画面不被文字覆盖。
-9. 作为值班人员,我想悬停或选中设备时看到设备名称,以便快速理解设备用途。
-10. 作为值班人员,我想在设备名称旁看到设备编码作为辅助信息,以便稳定追踪和核对具体设备。
-11. 作为值班人员,我想长名称有明确的截断或换行边界,以便标签不遮挡其它地图对象。
-12. 作为值班人员,我想“坐标图标”只代表空间位置,不显示经纬度数字,以便界面保持面向值班工作的简洁信息密度。
-13. 作为值班人员,我想监测点颜色表达点位状态,以便读取后端发布的点位业务状态。
-14. 作为值班人员,我想监测设备颜色表达设备自身状态,以便设备状态不会被所属监测点的聚合状态替代。
-15. 作为值班人员,我想即使两类对象使用同一套颜色外观,图例也能说明它们的状态来源,以便不混淆点位状态与设备状态。
-16. 作为值班人员,我想设备 hover 和选中反馈继续使用既定的放大、外环、引线和状态特效,以便资产替换不会改变已经熟悉的交互。
-17. 作为值班人员,我想正式图片素材可以替换临时形状簇,以便最终视觉不被原型占位资产限制。
-18. 作为维护者,我想资产替换不改变设备拾取身份,以便地图、面板和设备详情的联动契约保持稳定。
-19. 作为维护者,我想资产替换不改变状态语义、尺寸层级和定位锚点,以便视觉升级不重新打开既有交互决策。
-    > **2026-10-08 就地修订(由[蓝湖素材替换票 05](../../lanhu-cockpit-asset-replacement/issues/05-point-device-marker-mapping.md) Q18=B / Q19=C / Q24=B 裁定)**:本条「不改变…定位锚点」与「尺寸层级」的**具体数值口径被取代**——正式水滴徽标改为**尖端锚定**(尖端落真实坐标;原为中心锚定),`MARKER_SIZE` 语义由「边长」改为「**宽度基准**」并抬高数值以保证横向占位与今天一致(监测点宽 18、设备宽 14;高度按素材 378:475 比例约 22.6 / 17.6),内层图标圆心对齐**帽心**而非画布几何中心。**不变的部分**:监测点恒大于设备的层级关系、`deviceCode` 拾取身份、设备状态语义、选中反馈与面板联动契约仍不得改变。
-20. 作为值班人员,我想有效设备位置随当前地形贴地,以便设备符号不会漂浮或埋入地表。
-21. 作为值班人员,我想地形尚未就绪时设备保留在清单里并显示位置待确认,以便暂时看不到符号不会被误解为没有设备。
-22. 作为值班人员,我想地形采样失败与坐标本身无效被区分提示,以便知道应该等待地图资源还是检查设备数据。
-23. 作为值班人员,我想坐标缺失、非法或未通过后端校验的设备不上图但保留原因,以便系统不制造虚假位置。
-24. 作为值班人员,我想系统不使用监测点坐标代替设备坐标,以便同一点位下的设备不会被错误堆到监测点上。
-25. 作为值班人员,我想系统不通过人工偏移制造设备位置,以便图上位置始终有数据依据。
-26. 作为值班人员,我想设备符号和未来真模型遵守正常场景遮挡,以便它们与地形和示意体块的空间关系可信。
-27. 作为值班人员,我想未来某一设备类型有合格真模型时可以先启用该类型,以便不必等待所有类型资产同时交付。
-28. 作为值班人员,我想同一关联监测设备图层可以同时看到真模型和图标,以便逐步升级不会阻塞其它设备类型。
-29. 作为值班人员,我想真模型缺失时仍看到设备专属图标,以便资产交付进度不会影响设备位置巡检。
-30. 作为值班人员,我想真模型加载失败时仍看到可靠图标,以便单台资产故障不会让整层白屏或静默丢点。
-31. 作为值班人员,我想性能预算不足时系统退回图标而不是静默删点,以便仍能知道设备存在。
-32. 作为值班人员,我想在悬停或选中时知道当前设备处于图标模式或真模型不可用,以便理解当前视觉表现的真实状态。
-33. 作为值班人员,我想详情卡片显示资产未提供、加载失败、性能降级、地形未就绪或视觉资源异常等具体原因,以便能区分不同问题。
-34. 作为值班人员,我想默认地图不被大量错误角标覆盖,以便设备状态和空间分布仍然易读。
-35. 作为值班人员,我想图标资源自身失败时仍有通用位置符号,以便最基础的位置表达不会因单个图片资源故障而消失。
-36. 作为维护者,我想通用位置符号的使用在详情中被记录为视觉资源异常,以便最后兜底不被误报为正式资产或真模型。
-37. 作为资产提供方,我想知道真模型的最低格式和坐标约束,以便交付的资产可以被可靠接入。
-38. 作为资产提供方,我想知道模型必须明确 up 轴、前向方向、原点和基准面,以便不同设备类型不会出现朝向或落地不一致。
-39. 作为实现人员,我想通过构造确定模型到设备地理坐标的 ENU 变换,以便运行时不需要猜测模型原点。
-40. 作为项目负责人,我想外部资产和项目方另立制作事项都能成为未来来源,以便本票不提前承诺未确认的美术工作量。
-41. 作为项目负责人,我想真模型的三角面数、同屏数量和实例化策略在实际资产与规模明确后再定,以便性能契约有真实依据。
-42. 作为验收人员,我想以驾驶舱外部行为验证位置符号、标签、地形状态和降级状态,以便验收用户实际看到的结果。
-43. 作为验收人员,我想确认正式视觉使用可替换的统一素材,而不是把临时形状簇宣布为最终交付,以便产品视觉标准得到落实。
-44. 作为验收人员,我想确认关联设备图例独立于监测点摘要图例,以便两种状态语义不被混在一处。
-
-## Implementation Decisions
-
-- “模型”在本期的正式产品定义是监测设备在三维场景中的位置符号,不是真三维几何模型。位置符号由可替换素材、渲染尺寸、对象自身状态视觉和既有交互反馈组成。
-- 本期不加载真三维设备模型,也不把图标模式命名为真模型。仓库现有内联 SVG 形状簇只能作为临时方案,不能作为最终视觉验收资产。
-- 监测点和监测设备使用相同样式或同一份正式坐标点图片素材。通过程序控制渲染大小区分类型,监测点始终大于监测设备;具体像素值、缩放倍率和相机距离曲线由实现与人工走查校准,不在本规格预设数值。
-- 资产必须有单一可替换接缝。临时形状、正式图片素材和未来其它视觉资源都通过同一资产选择层注入,不把形状字符串或资源地址散落到地图绘制、图例和详情表现中。
-- 素材替换不得改变设备的 `deviceCode` 拾取身份、所属监测点身份、设备状态语义、尺寸层级、选中反馈、定位锚点或设备与面板的联动契约。
-  > **2026-10-08 就地修订(由[蓝湖素材替换票 05](../../lanhu-cockpit-asset-replacement/issues/05-point-device-marker-mapping.md) Q18=B / Q24=B 裁定)**:其中「**定位锚点**」与「**尺寸层级的具体数值**」两项口径被取代——正式水滴徽标的锚点由中心改为**尖端**(尖端落真实坐标),尺寸基准由边长改为**宽度**(监测点宽 18 / 设备宽 14,高度按素材比例约 22.6 / 17.6)。此修订**不覆盖**本条其余部分:`deviceCode` 拾取身份、所属监测点身份、设备状态语义、选中反馈与面板联动契约仍不得改变,「监测点恒大于设备」的层级关系亦保持不变。
-- 默认不显示设备长文本。悬停或选中时显示设备名称为主、设备编码为辅;文本需要有稳定的可读边界,过长内容不得撑大地图布局或遮挡前后内容。
-- “坐标图标”只表示设备的空间位置符号,不直接显示经纬度数字,不把经纬度文本当作标签内容。
-- 颜色按业务对象自身状态解释:监测点使用后端发布的点位状态;监测设备使用监测设备自身状态。两类对象可以使用同一组色彩风格,但不得共用状态来源或用点位状态猜设备状态。
-- 设备状态的颜色、形状、文字和特效继续遵守票 07;面板 hover、三维 hover、选中外环、放大、引线、设备级定位和独立拾取继续遵守票 06/15。本规格不重新定义这些反应。
-- 设备位置只接受后端设备自身的有效经纬度和坐标准入结果。坐标缺失、非法、未通过验证或 `coordinateValidation.renderable` 为假时不上图,设备保留在关联监测设备清单中并显示明确的无地图位置/不可用原因。
-- 不使用监测点坐标代替设备坐标,不进行人工坐标偏移,不把视觉分离手段伪装成真实设备位置。
-- 有效设备位置需要结合地形采样贴地。地形未加载、采样尚未完成或采样失败时不上图,设备保留在清单中并显示地形未就绪/位置待确认;该状态不能被归类为坐标无效,也不能被算作已上图。
-- 设备位置符号和未来真模型遵守正常地形、示意体块和其它场景对象的深度关系,不开启穿透体块的特殊可见性例外。
-- 真三维模型未来按设备类型逐步接入。某一类型拥有合格资产即可独立启用,尚未提供资产的类型继续使用图标;同一关联监测设备图层允许真模型和图标混合呈现。
-- 真模型资产可以由甲方/业务方提供,也可以由项目方后续另立资产生产事项制作。本票只定义接入边界,不承诺资产生产和版本维护工作已经排期。
-- 真模型最低接入契约为 glTF/GLB、米制、明确 up 轴、前向朝向、原点和模型基准面。接入侧必须由构造确定模型到设备地理坐标的 ENU 变换,不靠运行时猜测原点或朝向。
-- 真模型的三角面预算、同屏数量上限和同型号实例化策略留给未来资产/实施事项,必须在真实设备类型和规模明确后通过实测确定。没有实测依据时不得在本规格写死数字。
-- 真模型资产未提供、格式/坐标契约不合格、加载失败或性能超预算时,单台设备退回设备专属图标,不影响其它设备和其它资产类型继续显示。
-- 降级图标仍必须保持设备的身份、状态和交互能力,不能把图标兜底计为真模型成功加载,也不能把未绘制的设备静默计入逐台真模型呈现。
-- 地图默认不增加统一错误角标。悬停或选中时显示图标模式/模型不可用等可理解状态;详情卡片显示具体原因,至少区分资产未提供、资产加载失败、性能降级、地形未就绪和视觉资源异常。
-- 正式设备图片资源自身加载失败时使用内置通用位置符号作为最后兜底,并在详情中标明视觉资源异常;通用符号不是正式素材,也不是真模型。
-- 关联监测设备使用独立图例,不并入监测点摘要图例。颜色不是唯一线索,仍需结合形状、文字、尺寸层级和票 06 的选中反馈表达对象与状态。
-- 资产状态和位置状态必须能被关联监测设备清单解释。没有图标、没有真模型或暂不上图不等于设备不存在;“未确认”“无地图位置”“资产降级”和“模式隐藏”需保持可区分,具体图层计数沿票 14/15 和票 11 的契约执行。
-
-## Testing Decisions
-
-- 最高测试接缝是驾驶舱外壳的外部可观察行为:从地图或监测点台账选中监测点,观察关联监测设备图层中的位置符号、监测点/设备大小关系、标签出现、状态语义、地形状态、降级提示和图例边界。验收不读取 Cesium 私有实体,不依赖组件私有变量。
-- 领域纯逻辑测试覆盖资产选择与降级分类:本期图标模式与未来真模型模式可区分;资产未提供、加载失败、性能超预算、图标资源失败、地形未就绪、坐标无效等原因不能混成成功或不存在;真模型可用性按设备类型独立决定。
-- 领域纯逻辑测试覆盖尺寸层级:在所有允许的相机距离和配置范围内,监测点渲染尺寸始终大于监测设备;动态缩放不得改变这一关系。
-- 领域纯逻辑测试覆盖状态来源:监测点读取点位状态,监测设备读取设备自身状态;设备不得从所属监测点状态推导。状态未知必须保持未知或待确认,不猜成正常。
-- 驾驶舱走查覆盖正式素材替换接缝:临时形状可以用于开发期验证,但最终视觉验收必须使用正式可替换的坐标点图片素材,且地图标记、独立图例和详情表现不会各自使用不同资源。
-- 驾驶舱走查覆盖同一关联设备图层的混合呈现:已配置真模型的设备显示模型,未配置或不可用的设备显示图标,并且两者的拾取身份、状态表达、选中反馈、定位锚点和清单归属一致。
-- 驾驶舱走查覆盖地形状态:地形就绪时有效设备符号贴地;地形未就绪或采样失败时设备不上图但清单显示位置待确认;无效坐标显示无地图位置;任何情形都不使用监测点坐标或人工偏移。
-- 驾驶舱走查覆盖正常场景遮挡:位置符号和真模型遵守地形与示意体块的深度关系,不穿透显示。
-- 驾驶舱走查覆盖标签:默认无长文本,悬停或选中显示名称为主、编码为辅;长内容不会撑破布局或遮挡不相关对象。
-- 驾驶舱走查覆盖降级可见性:地图不被统一错误角标污染;悬停/选中与详情卡片能说明图标模式、模型不可用和具体原因;图标资源自身失败时通用位置符号仍可见且详情记录视觉资源异常。
-- 驾驶舱走查覆盖图例边界:监测点摘要图例和关联监测设备图例分开;相同颜色外观不会导致两类业务状态被当成同一种状态。
-- 真模型未来接入的专项验收应在资产事项或实施事项中补充真实设备类型的朝向、尺寸、原点、ENU 落点、加载成功率、同屏数量和性能实测;本规格不以静态资源存在作为当前实现已完成的证据。
-- 回归沿用票 14 的关联监测设备图层生命周期、票 15 的设备拾取与双向联动、票 07 的设备状态视觉和票 11 的重叠/超量呈现验收先例。规格阶段只写验收契约,不运行 build、dev 或浏览器调试。
-
-## Out of Scope
-
-- 本期设备级真三维模型的制作、采购、授权、版本维护和全量资产交付。
-- 固定的模型三角面预算、同屏设备数量上限、实例化实现和具体性能数字。
-- 设备坐标与坐标校验后端契约的补齐,以及设备状态来源的后端改造;由票 09 和后续后端工作负责。
-- 关联监测设备图层的触发、生命周期、快照差异、模式可见性和计数体系;由票 05/14 负责。
-- 面板与三维设备点的 hover、选中、引线、设备级定位、独立拾取和双向联动;由票 06/15 负责。
-- 设备状态五态、动态特效、并发动画和特效开关;由票 07 负责。
-- 同坐标或极近设备的聚合、视觉展开、逐台拾取和极端量级降级;由票 11 负责。
-- 跨监测点的点位聚合、设施/管线/事件等其它地图图层的模型和资产口径。
-- 经纬度数字标签、管理端写操作、登录权限、移动端、竖屏和多屏拼接。
-- 覆盖或改写票 03 的点位级/设备级相机数值、票 04/12 的面板锚点契约或票 13 的完整一级模式矩阵。
-
-## Further Notes
-
-- 本规格的唯一决策来源是票 10 的 `## Answer`,并吸收票 08 原型验收留下的硬前提:临时内联 SVG 形状不是最终视觉,必须存在可替换的正式资产接缝。
-- 当前仓库没有本地设备级 glTF/GLB;未来 `src/plugins/zoneFreeScene` 中的远程 demo 资源不能直接成为驾驶舱生产资产。真模型必须经过独立资产校验和接入事项。
-- “设备点位”在本规格中只描述监测设备在关联监测设备图层中的空间显示,不新增业务对象。领域文档继续使用“监测设备”和“关联监测设备图层”区分业务对象与显示概念。
-- 点位状态与监测设备自身状态可以使用相同颜色风格,但它们必须由各自的图例、对象身份和状态来源解释;实现不得重新引入“用点位状态给设备配色”的越界。
-- 本规格发布后,后续实现应以票 09 的设备级坐标/状态契约或明确的清单降级为前提,并同时消费票 14、15 及本规格。当前规格交付不表示设备图层或正式视觉资产已经实现。
-
-## Comments
-
-### 规格验收核对
-
-已依据维护者确认的 Q1–Q20 结论、`CONTEXT.md` 领域词汇、票 05/06/07/08/14/15 的既有边界和当前仓库资产事实编写。测试接缝采用驾驶舱外壳外部行为为主、领域纯逻辑为辅;没有把具体实现文件路径或未经确认的性能数字写成规格契约。本次只发布规格文档和更新决策票状态,不运行 build、dev 或浏览器调试。

+ 0 - 106
.scratch/cockpit-point-locate-and-device-link/issues/17-overlapping-device-points-spec.md

@@ -1,106 +0,0 @@
-# 17: 重叠设备点位呈现与拾取规格(来自决策票 11)
-
-Type: spec
-Status: ready-for-agent
-Label: ready-for-agent
-Blocked by: 11
-Category: spec
-
-本规格固化[决策票 11](11-overlapping-device-points.md)的维护者确认结果,作为[同一张 map](../map.md)的子规格单。它是「重叠设备点位的呈现与拾取」的实施输入,不覆盖同目录现有的 `spec.md`;现有 `spec.md` 继续只描述票 03 的监测点/设备定位视角契约。
-
-## 进度:100%
-
-规格文档已发布为 `ready-for-agent` 并挂到同一决策 map;100% 仅指规格交付,不表示重叠聚合呈现或展开环已实现。下一步:后端契约票 09 明确设备级坐标、校验和状态后,后续实现按本规格并与 14/15/16 号规格合并接入;本规格只固化票 11 的决策,不重开票 06/07/10 已锁语义。未经本对话明确要求,本次不运行 build 或浏览器走查。
-
-## Problem Statement
-
-值班人员选中监测点后,关联监测设备图层(票 05/14)展示该点下的设备点。但后端不保证也不校验设备坐标与监测点坐标相等,且没有任何相对位置信息(同井/同杆/安装高度/朝向均不存在),因此「同一监测点下多台设备坐标相同或极近」是必须预先处置的可能形态。当前 `scene.pick` 只返回屏幕最上层一个对象:重叠设备既看不见也点不到;若按角度硬性摆开,又会触碰「人工坐标偏移」禁令、伪造设备位置。若不裁决重叠时的呈现与拾取,实现票会自行发明聚合/展开/降级语义,导致设备身份混淆、计数虚报、面板与三维双向联动在重叠点失效。
-
-## Solution
-
-### 维护者复验修订(优先于旧展开结束与聚合隐藏条款)
-
-监测点图标显示名称;聚合点使用较大的位置符号和“聚合 K 台”标签。hover 入口高亮并预览,click 固定展开,聚合符号保留且压暗。点击环中设备后,该设备回到中心表层保持可见,携带真实设备身份并成为聚合入口;面板及连接短线跟随该设备,镜头定位设备自身贴地坐标。再 hover/click 中心入口可预览/固定展开并选择另一设备。移动到同组预览子点时不收起;离开预览或点空白只收其他设备,不隐藏当前所选设备。返回摘要或清除选择恢复原聚合符号。
-
-新增 selected 状态用于持续选中入口;degraded 仍表达 K>maxFan,与 selected/expanded 的持久身份分开。展开期间详情窗位于环外,避免挡住设备点击。经纬度与归属不变,K/N 计数及清单完整性不变,选中设备+其他本屏设备合计不超过 maxFan。旧规格保留为历史,不再按旧“点击子设备全部收起隐藏”验收。
-
-值班人员选中监测点后,若该点下多台设备重叠,三维先显示一个**重叠聚合符号**(取最高优先级设备的状态色/形 + 计数徽标 K)。hover 聚合符号可瞬态预览展开环/扇形;点击后粘性展开成可逐台点选的环/扇形——这是**纯渲染层的屏幕分离**,不改变任何设备真实位置。展开环内每台设备可被普通拾取,走票 06/15 的设备优先链与选中反应语言(外环 + 放大 + 引线,展开态引线照 06 画连监测点标记中心)。面板 hover 设备卡片时三维只给聚合符号弱指向、不摊开;面板 click 选中卡片才摊开并高亮那一台。设备数超过 10 台(K>10)时降级为聚合符号 + 显式「K 台设备(本屏显示 K 台 / 其余见清单)」,面板清单始终保留全部设备。计数如实区分「当前可逐台绘制数 K」与「关联总数 N」:徽标与图例按 K(图例明示「已上图(聚合呈现)K 台」),未上图原因单独列出,被聚合或被降级折叠的设备不虚报为「逐台已上图」。
-
-## User Stories
-
-1. 作为值班人员,我想选中监测点后看到该点下重叠设备以单个聚合符号呈现,以便不会误以为该点只有一台设备或画面上什么都没有。
-2. 作为值班人员,我想聚合符号显示该点可逐台绘制设备数 K 与其中最高优先级设备状态,以便一眼判断「这堆设备里最要紧的那台处于什么状态」。
-3. 作为值班人员,我想 hover 聚合符号时看到瞬态展开预览(环/扇形),以便不点击就知道有几台、大致怎么分布。
-4. 作为值班人员,我想 hover 离开后瞬态预览立即收回,以便画面不被持续的展开环占据。
-5. 作为值班人员,我想点击聚合符号后粘性展开成可逐台点选的环/扇形,以便逐台查看和点选每一台设备。
-6. 作为值班人员,我想展开环在「点中某台设备 / 点空白处 / 面板返回点位」三种情况之一发生时收起,以便展开态有明确的结束方式。
-7. 作为值班人员,我想在展开环内点击某台设备能拾取到它(认 deviceCode),以便三维 → 面板的反选成立、进入设备级详情。
-8. 作为值班人员,我想聚合符号本身不能被当作某台设备拾取,以便不会出现「点到聚合符号却选中了一台不存在的设备」。
-9. 作为值班人员,我想展开环内选中设备仍走票 06 的外环 + 放大反应,以便重叠态与正常单点的选中手感一致。
-10. 作为值班人员,我想展开态下选中设备的引线照常连到监测点标记中心,以便即使摊开了也能看出这台设备属于哪个监测点。
-11. 作为值班人员,我想未摊开(地理重合)时不画引线,以便不会出现长度为 0 的线糊在标记上(票 06 退化规则)。
-12. 作为值班人员,我想展开环内其余设备按票 06 弱化规则(设备数 ≥3 台时压暗缩小)弱化,以便焦点设备清晰、其余仍可见可点。
-13. 作为值班人员,我想面板 hover 设备卡片时三维只给聚合符号弱指向(如徽标轻闪/描边)而不摊开,以便扫过卡片时画面不会哗啦展开。
-14. 作为值班人员,我想面板 click 选中设备卡片时才摊开环并高亮那一台,以便「摊开」绑定持久选中而不是瞬态 hover。
-15. 作为值班人员,我想在展开环内点设备后面板能反选并滚动到该卡片(全双向),以便从三维侧也能直达设备详情。
-16. 作为值班人员,我想聚合符号的计数徽标显示「当前可逐台绘制数 K」而不是关联总数 N,以便徽标数字与实际能画的设备一致。
-17. 作为值班人员,我想设备图例在聚合态写「已上图(聚合呈现)K 台」而不是「逐台上图 K 台」,以便不会被误读为每台都单独画了。
-18. 作为值班人员,我想无坐标/待确认/模式隐藏的设备不混入 K 并单独列原因,以便「没画出来」和「真的没有」可区分。
-19. 作为值班人员,我想面板清单始终保留该点全部关联设备 N 台并如实标注未上图原因,以便被聚合或被折叠的设备不会静默消失。
-20. 作为值班人员,我想设备数超过 10 台(K>10)时降级为聚合符号 + 显式「K 台设备(本屏显示 K 台 / 其余见清单)」,以便超量时仍如实表达「还有更多」。
-21. 作为值班人员,我想降级后不把未逐台画出的设备算进「已上图」计数,以便计数不被虚报。
-22. 作为值班人员,我想降级阈值可通过部署配置 `map.overlap.maxFan`(缺省 10)调整,以便现场规模变化时不用改代码。
-23. 作为值班人员,我想重叠呈现不改变设备真实位置数据,以便摊开只是屏幕层分离、不会污染任何空间语义。
-24. 作为值班人员,我想重叠呈现与票 07 设备状态特效正常共存(特效开关不受重复拾取干扰),以便重叠点位的报警/预警特效照常可见。
-25. 作为值班人员,我想预警处置模式下聚合符号只反映该模式可见设备(设备自身 ALARM/WARNING)的最高状态,以便模式过滤语义不因聚合而变。
-26. 作为值班人员,我想关闭详情后关联设备图层连同重叠聚合符号一起消失(票 05 生命周期),以便收场语义与正常单点一致。
-27. 作为值班人员,我想设备数 ≤10 时全部可摊开逐台点选,以便小点位不会被过早聚合。
-28. 作为验收人员,我想走查能读到 `data-map-overlap-state`(`collapsed|expanded|degraded`)与 `data-map-overlap-count`(=K),以便对断言而不是看图。
-29. 作为验收人员,我想新增字段不破坏既有 `data-map-picked` / `data-map-device-picked` / `data-map-panel-selection` / `data-map-device-hovered`,以便既有走查继续成立。
-30. 作为验收人员,我想聚合/展开/降级的观感数值(环半径、摊开角度、徽标样式)由实现与走查现场校准,以便本规格不锁死表现层数字。
-
-## Implementation Decisions
-
-- **重叠聚合符号**=静止态呈现:取该点「当前可逐台绘制设备」中最高优先级状态的色/形 + 计数徽标(=K)。它与 `CONTEXT.md`「点位聚合」词条同构,但作用于关联监测设备图层而非摘要监测点;它是显示概念,没有 `deviceCode`,不冒充任何一台设备。
-- **展开环/扇形**=交互态呈现:hover/click 聚合符号后把 K 台设备在屏幕层摊开成可逐台点选的环/扇形。它是**纯渲染层的屏幕分离**:不持久化、不参与空间推理(相机居中、引线长度计算等)、不写进任何「设备真实位置」语义;不触碰 `CONTEXT.md`「人工坐标偏移」禁令(该禁令对象是伪造数据位置)。
-- **拾取**:**不使用 `scene.drillPick`**(其实现靠反复改目标 `show`,与票 07 特效/图层生命周期独立开关互相干扰)。展开环内每台设备各有屏幕位置,走普通 `scene.pick` + 票 06 的独立拾取入口(认 `deviceCode`、设备点优先、监测点兜底)。
-- **折叠态交互**(沿用票 06 两档语义):hover 聚合符号=瞬态预览(离开即收、不提交选中);click 聚合符号=粘性展开(直到「点中某台设备 / 点空白处 / 面板返回点位」才收)。聚合符号 click 不承担选点位(点位在设备图层存在时已处于选中态,票 05),回点位级走票 06「返回点位」。
-- **面板接缝**:面板 hover 设备卡片 → 三维只给聚合符号弱指向(如徽标轻闪/描边),不摊开;面板 click 选中卡片 → 摊开环并高亮/强化那一台。「摊开」绑定持久选中,不绑定瞬态 hover。
-- **与票 06/15 的分工**:展开态下选中设备引线照票 06 画(连监测点标记中心,表达归属);「重叠引线退化不画」只约束**未摊开(地理重合)**场景(票 15 已补边界备注)。展开环内选中反应(外环+放大+引线)与其余设备弱化(设备数 ≥3 台压暗缩小)沿用票 06,不在本规格另造。
-- **计数口径**:K=该点「当前可逐台绘制」设备数(坐标准入 `coordinateValidation.renderable === true` 且属当前一级模式);N=关联总数(当前快照去重后的 `deviceCodes`)。聚合符号徽标=K;关联设备图例记 K 但**文案明示「已上图(聚合呈现)K 台」**,不写「逐台上图」;未进入候选集的(无坐标/待确认/模式隐藏)单独列原因、不计入 K;面板清单保留全部 N 台并如实标注原因。降级时聚合符号徽标、图例、面板清单**三处同源**(K / 聚合呈现 K / N 总计 + 原因)。K 与 N 两数语义固定,任何地方不出现单个数字冒充全量。
-- **极端量级降级**:阈值=**10**(维护者定;区别于票 07 特效的 30 台降级,两套机制互不影响)。K≤10 全量摊开逐台可点;K>10 降级——不摊开全量,保持聚合符号 + 显式「K 台设备(本屏显示 K 台 / 其余见清单)」形态,被折叠的 K−10 台在面板清单中逐台可见、不静默消失。阈值放入 `map` 配置域 `map.overlap.maxFan`(缺省 10,与 `map.effects.*` 同构),真数留原型/走查现场校准。
-- **模式与生命周期**:聚合符号随票 05 关联监测设备图层生命周期(选中出现、关闭/换点/换快照消失);预警处置模式聚合符号只反映该模式可见设备(设备自身 `ALARM`/`WARNING`)的最高状态,遵循票 05 模式过滤。
-- **可断言口径**(走票 06 先例):宿主只读字段 `data-map-overlap-state`=`collapsed|expanded|degraded`、`data-map-overlap-count`=K;不破坏既有 `data-map-picked` / `data-map-device-picked` / `data-map-panel-selection` / `data-map-device-hovered`。
-- **表现层数值**(环半径、摊开角度、徽标样式、弱化程度)不锁死,由实现与走查现场校准;校准结果若推翻本规格任一条语义,回票 11 重议,不由实现票自行改。
-
-## Testing Decisions
-
-- 最高测试接缝(沿用 14/15/16 已由维护者确认的口径):驾驶舱外壳的外部可观察行为——用真实鼠标/指针完成「选中监测点 → 聚合符号出现 → hover 瞬态预览 → click 粘性展开 → 环内拾取某台 → 面板反选 → 返回点位收起」完整流程,观察聚合符号、展开环、引线与计数。验收不读取 Cesium 私有实体、不依赖组件私有变量。
-- 纯逻辑断言覆盖:K/N 计算与互斥分类(可逐台绘制 vs 无坐标/待确认/模式隐藏)、降级阈值判定(K>10)、聚合符号最高优先级状态选取、`data-map-overlap-state` 状态机(`collapsed|expanded|degraded`)迁移、展开环三条件解除。
-- 走查断言读取宿主只读字段(`data-map-overlap-state` / `data-map-overlap-count`)与既有 `data-map-device-picked` / `data-map-panel-selection` / `data-map-device-hovered`,与三维场景实际状态对照;观感(环半径、摊开角度、徽标样式、弱化程度)由维护者目视裁定(与票 07 同款「A + 人工走查」口径)。
-- 回归沿用票 10 两级详情/真实鼠标点选/`data-map-picked`、票 06/15 设备点拾取与双向联动、票 05/14 设备图层生命周期、票 07 特效跨帧快照的测试先例;重叠点位的特效共存走查沿票 07 验收契约。
-- 规格阶段只写验收契约,不运行 build、dev 或浏览器调试(AGENTS.md)。
-
-## Out of Scope
-
-- 重叠聚合呈现、展开环与降级的实际实现;由后续实现票负责,受票 09 后端设备级坐标/状态契约或明确降级方案门控。
-- 设备状态特效(票 07)、设备「模型」口径与资产(票 10/16)、设备图层触发与生命周期(票 05/14)、面板选设备与三维对应反应(票 06/15)的重定义;本规格只在其之上定义重叠呈现与拾取。
-- 跨点位聚合(多个监测点重叠的聚合对象);map 的 Out of scope 已锁,不在本规格范围。
-- 相机定位数值、面板锚定跟随(票 03/04/12)、雾气共存(`.scratch/cockpit-primary-area-guardrails` 07)的重定义。
-- 后端设备级坐标与校验契约的实际补齐(票 09)、真实数据全面接线、登录/权限/工单写操作、移动端/竖屏/多屏拼接。
-- 覆盖或改写现有票 03 的定位视角规格;本规格只引用其定位行为。
-
-## Further Notes
-
-- 本规格的唯一决策来源是票 11 的 `## Answer`;它把讨论结论转为后续实现可消费的行为契约。
-- 与票 15 的边界已显式切分:**引线退化(未摊开不画引线)属 15/06 的选中表现语言;展开/逐台拾取/降级归本规格;展开态引线照 06 画**(票 15 已补边界备注)。
-- 「人工坐标偏移」禁令的边界:展开环/扇形是纯渲染层屏幕分离,不持久化、不参与空间推理、不写进设备真实位置语义;它不触碰 `CONTEXT.md` 禁令。
-- 计数纪律:被聚合或被降级折叠的设备不得虚报为「逐台已上图」;「不上图 ≠ 不存在」(票 07 同款纪律,`CONTEXT.md`「无地图位置」词条同源)。
-- 本规格新增为同一决策 map 的子规格单,现有票 03 的 `spec.md` 保持不变;14/15/16 与本规格是同一实施链路的多个输入,实现时合并消费。
-- 规格发布后,实施票应在设备图层可用(09 设备级坐标/状态契约或明确降级方案)前提下开展。
-
-## Comments
-
-### 规格编写说明
-
-已依据维护者确认的票 11 结论(两轮 grilling Q1–Q9)、`CONTEXT.md` 领域词汇(含新增「重叠聚合符号」词条)、ADR 0002/0003/0004/0005/0008 与既有 14/15/16 规格先例编写。测试接缝沿用维护者在 14/15/16 已确认的「驾驶舱外壳外部行为 + 领域纯逻辑断言」口径,未另开新接缝。本次只发布规格文档,没有修改票 03 的 `spec.md`,也没有运行 build、dev 或浏览器调试。

+ 0 - 170
.scratch/cockpit-point-locate-and-device-link/issues/18-mode-layer-visibility-spec.md

@@ -1,170 +0,0 @@
-# 18: 一级模式图层可见性与选择态矩阵规格(来自决策票 13)
-
-Type: spec
-Status: ready-for-agent
-Label: ready-for-agent
-Blocked by: 13
-Category: spec
-
-本规格固化[决策票 13](13-mode-layer-visibility-matrix.md)的维护者确认结果,作为[同一张 map](../map.md)的子规格单。它是「什么时候显示什么」的实施输入,不覆盖同目录现有的 `spec.md`;现有 `spec.md` 继续只描述票 03 的监测点/设备定位视角契约。
-
-## 进度:100%
-
-规格文档已发布为 `ready-for-agent` 并挂到同一决策 map;100% 仅指规格交付,**不表示设施点、管线折线或异常点图层已实现**。下一步:设施点数据契约、管线折线数据源(实现图票 22)、设备级坐标/状态契约(票 09 / 后端 #69)到位后,后续实现按本规格接入;设施点的拾取/详情/视觉词表另开设施票。未经本对话明确要求,本次不运行 build 或浏览器走查。
-
-## Problem Statement
-
-值班人员在四个一级模式(总体概览、基础设施、运行监测、预警处置)之间切换时,一张图上的业务对象范围应当随模式变化;当前实现对所有模式画出同一批地图摘要标记,没有任何按模式的可见性规则。问题集中在五个方面:
-
-1. **可见矩阵缺失**:总体概览希望展示监测点、设施、预警报警等所有适用对象;基础设施只看设施;运行监测看监测点/设备;预警处置只看异常点。设施与监测设备是不同业务对象,监测点与未关联设备也不得合并为一类。
-2. **数据来源缺口**:设施点(管网、泵站、水厂、水源地、用水户等,各类型独立成表)当前没有地图坐标数据源;管线折线数据源(票 22)未交付;设备级坐标/状态契约(票 09)未交付。不同图层缺数据时不得伪装成「该模式确实没有对象」。
-3. **初始地球视图**:进入系统引导的最初地球视图只展示地球整体形态,不展示监测点、设备点位或点位详情;点击「点击进入系统」、镜头落到主场景后,业务点位才出现。
-4. **选择态合成**:当前选中对象不属于目标模式时的详情暂停与恢复、镜头保持、图例计数和拾取对象优先级,需要与已确认的票 05 规则合成一张完整矩阵。
-5. **回联定位边界**:事件不创建独立地图标记;设施点在基础设施模式可见并作为回联目标。需要明确各对象「在图上才能定位、不在则不下发飞行」的边界。
-
-## Solution
-
-从值班人员视角,一张图在一级模式之间切换时中央地图保持常驻,显示的业务对象集合随模式变化。实现一个统一的「模式图层可见性计划」纯函数模块,作为「什么时候显示什么」的唯一权威;地图引擎和组件只消费该计划,不做第二次过滤判断。五种状态(引导态 + 四个一级模式)与各对象层的完整可见矩阵如下:
-
-| 状态\对象 | 监测点 | 未关联设备 | 关联设备图层(选中态) | 设施点 | 管线折线 | 事件 |
-|---|---|---|---|---|---|---|
-| **引导态**(进入前) | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ |
-| **总体概览** | ✓ 全部 | ✓ | ✓ 全部有效(票 05) | ✓ | ✓ | 回联定位 |
-| **基础设施** | ✗ | ✗ | ✗(票 05) | ✓ | ✓ | 回联定位 |
-| **运行监测** | ✓ 全部 | ✓ | ✓ 全部有效(票 05) | ✗ | ✓ | 回联定位 |
-| **预警处置** | 仅异常点 | 仅预警/报警 | 仅 ALARM/WARNING(票 05) | ✗ | ✓ | 回联定位 |
-
-异常点 = 至少一台关联监测设备处于预警或报警状态的监测点;判定以设备状态事实为准(`/monitoring/situation` 的 `abnormalEquipment[]` 回联到 `point.deviceCodes`),不借用监测点聚合状态词表。图例、计数与浮标只统计当前模式实际可见的标记,图例行随模式变化。回联定位不做模式过滤。总体概览与任一分项互切保留选择身份、目标模式不显示当前对象时暂停并恢复;分项之间互切清空选择。引导态可预取地图数据但不显示任何业务点位。
-
-## User Stories
-
-1. 作为值班人员,我想在总体概览模式看到全部监测点、未关联设备和设施点,以便一览全貌。
-2. 作为值班人员,我想切到基础设施模式后地图只显示设施点与管线,以便专注设施的空间范围。
-3. 作为值班人员,我想切到运行监测模式后地图重新显示全部监测点与未关联设备、设施点隐藏,以便专注设备运行侧信息。
-4. 作为值班人员,我想切到预警处置模式后地图只显示异常点与预警/报警的未关联设备,以便聚焦待处置对象。
-5. 作为值班人员,我想在引导态只看到地球整体形态、没有业务点位,以便先建立空间直觉,不被数据淹没。
-6. 作为值班人员,我想点击「进入系统」后业务点位才出现,以便把进入决策作为明确的呈现起点。
-7. 作为值班人员,我想引导期可以预先加载地图摘要,以便进入后点位立即呈现而无需长等待。
-8. 作为值班人员,我想引导态不显示业务标记、图例、点位计数浮标和详情,以便「没看到对象」不会被误读成「对象不存在」。
-9. 作为值班人员,我想一级模式切换不重建地图实例、不换地图摘要快照,以便连续值守有稳定空间上下文。
-10. 作为值班人员,我想任一模式的图例只出现该模式可见对象的状态行,以便图例与实际标记保持一致。
-11. 作为值班人员,我想数据未就绪的图层如实显示「数据未就绪」,以便缺数据不会被误读成「该模式确实没有对象」。
-12. 作为值班人员,我想预警处置模式画出异常点(含预警/报警设备的监测点),以便看清异常的空间分布。
-13. 作为值班人员,我想预警/报警的未关联设备在预警处置模式也画出来,以便正在报警的设备不会因没有监测点归属而被漏掉。
-14. 作为值班人员,我想正常/离线/工单处置设备不因监测点聚合状态被冒充成异常点,以便颜色语义不被误读。
-15. 作为值班人员,我想异常点判定以设备状态事实为准(从 `/monitoring/situation` 的异常设备回联),以便不借用监测点聚合状态词表。
-16. 作为值班人员,我想设备自身状态未知时预警处置模式不猜成正常或报警,以便不掩盖未知。
-17. 作为值班人员,我想基础设施模式下已选中的监测点详情与关联设备图层暂停、切回总览且对象仍在当前范围时恢复,以便模式范围与选择归属不冲突。
-18. 作为值班人员,我想总览与任一明细模式互切保留选择身份,以便切回总览不丢失正在看的对象。
-19. 作为值班人员,我想三个分项模式之间切换清空选择,以便不会把上一分项的对象带到下一分项。
-20. 作为值班人员,我想设施点在基础设施模式可见并作为回联目标,以便设施面板条目能定位到图上。
-21. 作为值班人员,我想设施数据契约未到位时基础设施地图如实显示「无设施数据」,以便不把缺数据误读成「确实没有设施」。
-22. 作为值班人员,我想设施点不进入监测点统计与图例,以便设施与监测点两类对象计数互不污染。
-23. 作为值班人员,我想设施点的拾取/交互与视觉词表不在本规格承诺,以便不为不存在的设施详情发明选中态。
-24. 作为值班人员,我想管线折线在进入后任何一个一级模式都可见(常驻),以便管线作为设施的一类空间对象持续有用。
-25. 作为值班人员,我想地面/管线视图(正交的全局呈现条件)只改相机预设与地表/瓦片半透明、不影响管线层是否显示,以便视图开关不与模式隐藏打架。
-26. 作为值班人员,我想事件不创建独立地图标记、而是按设备编码回联到地图位置,以便事件不与监测点标记重复。
-27. 作为值班人员,我想事件行所在的设备/监测点可定位,以便从列表条目标到图上对象。
-28. 作为值班人员,我想事件设备不在当前地图(未加载/无坐标)时如实提示且不下发飞行,以便不误导为地图上存在该点。
-29. 作为值班人员,我想基础设施面板条目的回联目标是设施点,以便切到基础设施模式有图上归属。
-30. 作为值班人员,我想设施契约到位前基础设施面板条目的定位如实说明不可定位,以便不把监测点坐标冒充设施位置。
-31. 作为验收人员,我想通过外壳可观察行为验证模式切换、图例行变化、引导态门控和回联结果,以便验收外部行为而不依赖 Cesium 私有实体。
-32. 作为实施人员,我想只有一个领域纯函数给出「模式 → 标记计划」,以便可见性决策不会在地图与组件两层各裁一遍。
-
-## Implementation Decisions
-
-### 1. 统一「模式图层计划」纯函数矩阵
-
-- 新建 `domain/modeLayerPlan.js`(建议名,实施时以契约为准)领域纯函数模块,不 import Cesium / Vue / 接口对象。
-- 输入:`{ mode, objects(地图摘要对象集), abnormalDevices(异常设备集,`/monitoring/situation` 形状), facilities(设施点集), pipelines(管线折线计划), selection(当前选择), entered(是否已完成引导) }`。
-- 输出:`{ markers, blocked, legend, locateTargets, degradedNotes }`——该模式应显示的标记、被坐标准入拦下的对象、图例行、可回联目标、以及「数据未就绪/无法定位」的如实说明。整个矩阵的裁决在这里收口。
-- 该模块是「什么时候显示什么」的唯一事实源;引擎和组件只消费计划,不在 Cesium 层重新过滤。
-- 纯函数可被浏览器外脚本直接调用断言(仿 `verify/t08-map-summary.mjs` / `t09-point-status.mjs` / `t21-view-modes.mjs` 先例)。
-
-### 2. 模式可见对象集
-
-- 总体概览:监测点全部 + 未关联设备 + 关联设备图层(选中态,票 05:全部有效)+ 设施点 + 管线。
-- 基础设施:设施点 + 管线;监测点、未关联设备、关联设备图层不可见。
-- 运行监测:监测点全部 + 未关联设备 + 关联设备图层(全部有效)+ 管线;设施点不可见。
-- 预警处置:仅异常点 + 预警/报警未关联设备 + 关联设备图层仅 ALARM/WARNING(票 05)+ 管线;设施点不可见。
-- 引导态(`entered=false`):全部对象不可见;标记、图例、点位计数浮标、详情都不出现。数据可预取但不呈现。
-- 管线折线在四个一级模式常驻;「地面/管线视图」是正交的全局呈现条件,不是本矩阵的显隐开关。
-
-### 3. 异常点判定
-
-- 异常点 = 至少一台关联监测设备状态为 WARNING/ALARM 的监测点。
-- 判定源:后端 `/monitoring/situation` 的 `abnormalEquipment[]`(设备状态事实)+ 地图摘要 `point.deviceCodes`(设备 → 监测点归属),把设备状态回联到监测点得到异常点集合。数据源未交付时模块如实降级:不猜、不画。
-- `/monitoring/situation` 与 `map-snapshot` 是不同端点:沿用票 05「尽力展示并明确提示跨端点不同步」,不静默假设两份数据同一时刻。
-
-### 4. 设施点
-
-- 设施点 = 与监测点同级的独立空间业务对象(管网、泵站、水厂、水源地、用水户等,各类型在后端独立成表);不计入监测点统计与图例。
-- 本规格只锁「可见 / 可作为回联目标」;设施点的拾取、详情、视觉词表整体另开设施票,本规格不承诺。
-- 设施数据契约未到位时,基础设施模式地图通过 `degradedNotes` 如实进入「无设施数据」降级态,不虚报设施行。
-- 设施点的图例行待设施契约与视觉词表到位后由设施票定义。
-
-### 5. 管线视图正交
-
-- 管线折线在进入后四个模式均常驻,模式矩阵不隐藏它。
-- 「地面/管线视图」是正交的全局呈现条件:只改相机预设与地表/瓦片半透明(`domain/viewModes.js` 既有行为),不管管线层本身的显示。
-- `CONTEXT.md`「管线视图让地下管线可见」已修订为本语义(「让管线呈现更清楚」,不是层显隐开关)。
-
-### 6. 事件回联
-
-- 事件不创建独立地图标记,靠 `deviceCode` / `monitoringPointCode` 回联到地图对象位置。
-- 回联定位不做模式过滤:目标在图上(坐标准入且已加载)则按既有定位契约飞行;不在图上则不下发飞行,`locateTargets` 由模式图层计划给出该模式允许的回联目标。
-
-### 7. 选择态合成
-
-- 总体概览 ↔ 任一分项:保留选择身份;目标模式不显示所选项时暂停详情与关联设备图层,切回且对象仍在当前范围时恢复。
-- 分项 ↔ 分项:清空选择。
-- 设施点:本阶段不产生选择态。
-- 切换模式不动镜头、不换地图快照,Viewer 常驻。
-
-### 8. 图例与计数
-
-- 图例只统计「当前模式实际可见」的摘要标记;图例行随模式变化。
-- 图例与标记必须一致(沿用票 09 的 `legendMatchesMarkers` 判据,指向同一计划层输出)。
-- `MapCanvas.vue` 的图例列来源改为消费模式图层计划的输出,而不是全场对象视图。
-
-### 9. 引导态
-
-- 引导态(进入前):不显示任何业务点位;点击「进入系统」后才挂载/显示,业务点位才出现。
-- 允许预取:进入前可预取地图摘要,`entered=false` 时计划输出全部为空;显示层(标记/图例/浮标/详情)被门控。
-- 实现侧进入态挂载归票 30 引导层生命周期;计划层以 `entered` 输入作门控。
-
-### 10. 领域词表
-
-- `CONTEXT.md` 已补充「设施点」「异常点」词条并修订「地面/管线视图」(见决策票 13 的 Answer):设施点与监测点同级不混统计;异常点 = 任一关联设备 WARNING/ALARM 的监测点,以设备状态事实为准。
-
-## Testing Decisions
-
-- **主 seam(已由维护者确认)**:`domain/modeLayerPlan.js` 纯函数是最高接缝,浏览器外逻辑脚本可直接断言;引擎/组件只消费计划,不在 Cesium 层重判可见性。
-- **补充 seam**:驾驶舱外壳人工走查,覆盖模式切换观感、图例行变化、引导态门控、管线常驻与回联定位手感。
-- **纯逻辑断言覆盖**:五种状态 × 各对象的可见矩阵(含「不该显示的一定不在计划里」)、异常点判定只认 WARNING/ALARM、图例与标记一致、引导态全空、回联目标、降级注释;设施集用注入 mock 断言矩阵逻辑(语义锁死、数据待接线)。
-- **测试先例**:沿用 `verify/t08-map-summary.mjs`(合并态纯逻辑)、`t09-point-status.mjs`(图例与标记一致)、`t21-view-modes.mjs`(视图条件纯函数)的断言风格;走查沿用 `verify/*-walkthrough.mjs` 的 `data-*` 可断言字段约定。
-- 不把 Cesium 私有实体、组件私有变量或两份相同 JS 输入互比作为成功判据;不做引擎层可见性重判。
-
-## Out of Scope
-
-- 设施点坐标契约、设备级坐标/状态契约(票 09 / 后端 #69)、管线折线数据源(实现图票 22)的实际补齐;后端契约缺失只阻塞实现,不改变本规格的可见性规则。
-- 设施点的拾取、详情、视觉词表与图例行定义(另开设施票)。
-- 设备状态特效、设备模型、重叠拾取等已由票 06/07/10/11 裁决的视觉与交互细节;本规格在其输出范围内引用,不重裁。
-- 位置筛选(如 `pointStatus` 状态筛选落图、行业/区域筛选)的既有契约与端点;本规格只定义模式可见性。
-- 真实数据全面接线(后端 BT-5 / Gogs #63)。
-- 登录、权限、工单写操作、移动端、竖屏和多屏拼接。
-- 覆盖或改写现有票 03 的定位视角规格与票 05/06/07/10/11 的关联设备图层契约;本规格只在既有契约之上合成模式矩阵。
-
-## Further Notes
-
-- 本规格的唯一决策来源是票 13 的 `## Answer`;它把讨论结论转为后续实现可消费的行为契约。
-- 「没有对象」与「数据未就绪」必须分开:`EMPTY`(后端确实没有)与「设施/设备数据契约未到位」是两种状态,界面与 `degradedNotes` 都要说得出区别,不能把缺数据伪装成空数据。
-- 管线折线常驻四模式是 2026-09-28 维护者在票 13 的裁决(比「仅基础设施/概览可见」选项更宽);管线视图的正交语义已同步 `CONTEXT.md`。
-- 异常点判定依赖设备级状态事实;`/monitoring/situation` 只含异常设备且与 `map-snapshot` 跨端点,跨端点不同步提示是强制行为,不得静默删除。
-- 本规格新增为同一决策 map 的子规格单(编号续 14/15/16/17 之后),现有票 03 的 `spec.md` 保持不变。规格交付不表示设施点/管线/异常点图层已实现。
-
-## Comments
-
-### 规格交付说明(2026-09-28)
-
-本规格由票 13 的 `/to-spec` 产出,忠实固化维护者确认的完整矩阵与五要素结论。测试接缝(领域纯函数 + 外壳人工走查)已由维护者确认。`CONTEXT.md` 已同步「设施点」「异常点」「地面/管线视图」三个词条。本次只发布规格文档,没有修改票 03 的 `spec.md`,没有运行 build、dev 或浏览器调试(AGENTS.md 规则;规格票只写验收契约)。

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 71
.scratch/cockpit-point-locate-and-device-link/map.md


+ 0 - 208
.scratch/cockpit-point-locate-and-device-link/prototypes/anchor-panel-review.md

@@ -1,208 +0,0 @@
-# 锚定面板 + 设备点位 + 状态特效:可丢弃原型与人工验收入口
-
-Status: awaiting-human-review
-
-## 回答的问题
-
-票 04 已锁「面板始终贴可见标记、四向避让、贴不下则暂隐、不回固定位」,票 07 已锁设备五态特效语言 —— 但两张票都把**只有眼睛能判的部分**留了白:
-
-1. **锚定手感**:卡片与图标的间距、画布边缘留白、高度占比上限、可读高度下限,这几个阈值组合起来**看起来顺不顺**?短引线该多长、四向翻转是不是「该翻的时候才翻」?
-2. **固定位对照**:票 04 排除了「不回固定位」。现场再确认一次这个选择仍然成立(默认关闭,只作反例对照)。
-3. **特效可辨性**:票 07 定了「正常/离线无特效」。**这两态在画面上是否仍然够可辨**?会不会因为不动而被读成「没数据」?
-4. **并发降级**:超 30 台异常时降级为「静态异常标记 + 计数」——降级后**是否仍读得出「这里有异常」**?
-5. **门控取舍**:「仅异常设备播动效」与「全部关闭」的画面差别。
-
-本原型**不是交付实现**,不承诺性能达标,也不改任何既有契约。它是提高讨论保真度的可反应物。
-
-## 位置与打开方式
-
-- 入口接线:`src/pages/cockpit/index.vue` —— 只增加 DEV + route query 显式选择时的动态挂载(与既有的 `fogPrototype` 同构)。
-- 原型组件:`src/modules/cockpit/prototypes/AnchorPrototype.vue`
-- **锚定求解器(纯函数,值得留的那段)**:`src/modules/cockpit/prototypes/anchor-prototype-layout.js`
-- **设备状态/特效表(纯函数)**:`src/modules/cockpit/prototypes/anchor-prototype-effects.js`
-- 用仓库现有 `yarn dev`(Fes),**不使用 pre.dev**。
-- **端口是 8000**(本会话实测:`➜ Local: http://localhost:8000/`)。**不是 5173** —— 5173 上跑着另一个项目。
-- 打开 `http://localhost:8000/#/cockpit?anchorPrototype=1`(本项目用 hash 路由 —— `src/.fes/core/routes/routeExports.js:14` 的 `createWebHashHistory` 是证据;query 在 `#/cockpit` 之后)。
-- **先点「点击进入系统」**,镜头会自动飞到样例点位(也可手动点底栏「飞到样例点位」)。
-- 没有 `anchorPrototype=1` 或非 DEV 时不挂载;删掉该 query 即卸载并恢复原状。不改运行配置,不保存参数。
-- 底栏控制台**默认折叠**成一条(只显示就绪徽标与「展开控制台」):这是刻意的 —— 它本身也是一块避让矩形,
-  摊开会从下方顶住面板、把四向挤成 `no-space`。要看开关时点「展开控制台」。
-
-### ⚠ 先确认你打开的是**本仓库**的 dev server
-
-第一次走查「找不到 UI」的一个根因是**端口认错了**:本机 5173 上跑着的是**另一个项目**
-(首页 HTML 里是 `<div id="app">`、百度地图 webgl SDK、标题「沅陵县太常片区城市地下管网管控平台」),
-而本仓库的 `index.html` 用的是 `mountElementId: 'tvt-app'`(`.fes.js:30`),标题是 `TvT.js`。
-
-本会话实测(`verify/t08-find-devserver.mjs`,只读探针):
-
-| 端口 | 结果 |
-| --- | --- |
-| 5173 | 200,但 `tvt-app=false` / 别的 app = true —— **不是本仓库** |
-| **8000** | **本仓库(`yarn dev` 的 `➜ Local`)** —— 走查用的就是它 |
-| 8719 / 8301 / 9801 | 非 200 或非页面(federation / 其他服务) |
-| 3000 / 8080 / 4173 / 7001 | 不可达 |
-
-判据:打开首页看标题是不是 **TvT.js**、HTML 里有没有 `id="tvt-app"`。
-
-### 一眼判断成功没有
-
-底栏左上有常驻的**就绪徽标**(`data-anchor-ready`):
-
-- 🟡 `⏳ 等待地图引擎…` —— 还没就绪(先点「点击进入系统」)
-- 🔴 `✖ 已挂载,但点位标记为 0` / `设备图标为 0` / `引擎已销毁` —— 挂载了但没画出来
-- 🟢 `✔ 已挂载:3 个监测点 + N 个设备图标(异常 M 台,动效 K 台)` —— **成功**
-
-底栏「状态」里另有完整自检:`引擎.viewer / 点位标记 / 设备图标` 三个计数、`home视角`
-与三个样例点位的实际经纬度。**不必再猜成没成。**
-
-### 自检脚本
-
-本轮全部通过(**本会话未运行 build/dev**):
-
-- `verify/t08-anchor-layout.mjs` —— 锚定几何 + 设备状态/特效/降级 + **「样例点位必须在归位视野内」回归**
-- `verify/t08-sfc-static-check.mjs` —— 两个 SFC 静态编译与绑定核对
-- `verify/t08-find-devserver.mjs` —— 只读探针:哪个端口才是本仓库(判据 `tvt-app`)
-- `verify/t08-probe-devserver.mjs` —— 只读探针:`.vue` 请求是否真的返回组件而非 SPA 外壳
-
-### 第二轮静态核实(上一轮声称过但未证实的点)
-
-第一次交付时有两处我只是「按约定写的」而没有证实,本次逐条查清:
-
-1. **避让选择器是否真的存在** —— 全部命中真实 DOM(不是猜的类名):
-
-   | 选择器 | 命中位置 |
-   | --- | --- |
-   | `.cockpit-shell__column.is-left` / `.is-right` | `CockpitShell.vue:207` / `:238`(左右业务栏本体) |
-   | `.cockpit-shell__topbar` | `CockpitShell.vue:15` |
-   | `.cockpit-shell__scope` | `CockpitShell.vue:75` |
-   | `.map-holo__top` / `.map-holo__bottom` | `MapCanvas.vue:24` / `:47` |
-   | `.map-blocked` | `MapCanvas.vue:157-158` |
-   | `.cockpit-intro` | `CockpitIntro.vue:14`(引导遮罩) |
-
-   注:`.cockpit-shell__column` 另有 `.cockpit-shell__wing.is-left/is-right`(`CockpitShell.vue:17-18`)是**另一个**元素(装饰翼),本原型只取前者。
-
-2. **自动飞行的时序**(查出一个真实竞态并已修)—— 原先只等「引导遮罩消失」就飞,但:
-   - 遮罩 `.cockpit-intro` 一关即调 `enterSystem()`(`CockpitShell.vue:546`);
-   - `enterSystem()` 用 `goHome({ immediate: false })`,是**阻尼收敛**而非瞬时落位;
-   - 而且它带「没下发成功就每 250ms 重试、最多 40 次(约 10 秒)」的循环(`CockpitShell.vue:550-560`)。
-
-   所以「遮罩没了」≠「归位飞完了」;抢在收敛途中飞会被后续重试**覆盖回去**,表现正是「点了进入系统,镜头没到样例点位」。已改为读相机控件自认的停稳口径 `cameraControl.getState().settled`(`services/cameraControl.js:408`,同时覆盖「无待推进飞行」与「阻尼已跟上」),并加 20 秒超时兜底(页面不可见时 rAF 不跑、阻尼不推进,`settled` 会一直为 false)。
-
-3. **飞行距离是否会被限位钳掉** —— `range: 2000` 在 `RANGE_LIMITS`(`domain/camera.js:42`,min 120 / max 3e7)之内,不会被夹;俯角 −55° 也在 `PITCH_LIMITS.ground` 内。
-
-4. **用真实 `public/cockpit/config.json` 复算**(不是用默认值):home = 110.39028164 / 28.4436787、range 12000、pitch −55 → 地面可视半宽约 **5.68 km**,三个样例点位落在 **0.83–0.87 km**,均在视野内。
-
-## 三组开关(切换即见差别)
-
-- **锚定形态**:① 边直接贴(无引线)② 短引线(默认)③ 角箭头。用底栏按钮或左右方向键切换;输入框聚焦时不劫持箭头。切换会写回 route query,可分享、刷新后保持。
-- **特效形态**:五个状态逐个可改(扩散脉冲 / 闪烁 / 旋转环 / 描边呼吸 / 无特效)。默认值就是票 07 已选定的:报警=脉冲、预警=闪烁、工单=旋转环、**正常=无、离线=无**。
-- **门控与并发**:门控开关(仅异常设备播 / 全部关闭);「动效上限」数值用于现场校准 30 台这条阈值;「设备台数」可拉到 40,用来真的看到降级。
-
-另有四个阈值滑杆(间距 / 边缘 / 高占比 / 可读高下限)与**固定位对照**开关。改任一开关,底栏「状态」里立刻能看到重算结果(落位方向、暂隐原因、面板矩形、各计数)。
-
-## 操作与验收清单
-
-1. **贴点跟随**:右键拖拽旋转、左键平移、滚轮缩放,看卡片是否始终贴着**视觉标记中心**移动、不压图标;引线是否只接到图标外缘。
-2. **四向翻转**:把点位拖到画布右缘、左缘、上下缘,确认按「右 → 左 → 下 → 上」翻转,且**该翻才翻**(不要在小幅移动时抖动换边)。
-3. **暂隐与恢复**:把点位拖出画布、或缩到标记落进左右栏/底部栏后面,确认卡片**暂隐**并在顶栏留入口(可关闭、可重新显示),把点位拖回来应自动恢复。注意暂隐原因会在状态面板里写明。
-4. **阈值手感**:拖动四个滑杆,找出你觉得「最顺」的一组值 —— 这正是本次要走查的留白。若 300×280 下限或 80% 占比在 1080p/4K 下不合适,按票 04 的约定**回票 04 重议数值**,不要就地改掉「贴点 / 暂隐」的优先级。
-5. **固定位对照**:打开该开关,与贴点形态来回对比,确认「不回固定位」这个选择**仍然成立**(或明确说明为什么该推翻 —— 那要回票 04)。
-6. **五态可辨性(票 07 的留白)**:把「正常」「离线」都设成无特效,看它们在画面上是否仍能与报警/预警区分开。若不够可辨,回票 07 重议 —— 不要由实现票自行给正常态加回动效。
-7. **并发降级**:把设备台数拉到 40、动效上限留在 30,确认降级发生(底栏状态里 `已降级: true`)。**关键**:确认降级后画面**仍然看得出这里有一堆异常**(静态异常标记 + 计数),而不是变成一片普通标记。
-8. **门控对比**:开关门控,观察「只有异常设备在动」与「全静」的画面差别,判断值班场景下哪种更合适。
-
-## 走查记录
-
-### 第三次(2026-09-24):**维护者授权后,我亲自跑 dev 并走查通过**
-
-维护者明确授权「起 dev 并自己走查」。本会话用后台作业跑 `yarn dev`(Fes,**端口 8000**),
-用浏览器工具打开 `http://localhost:8000/#/cockpit?anchorPrototype=1` 实际操作并截图。
-**这是本票第一次有真实画面证据**,也证明了前两轮「只靠静态断言」确实会漏掉真问题。
-
-#### 真跑才发现、静态断言与编译都拦不住的五个缺陷(全部已修)
-
-1. **设备图标恒为 0(最严重)** —— `new BillboardCollection()` 后直接 `add({ heightReference: CLAMP_TO_GROUND })`,
-   会在 `add()` 内部立刻走 `_updateClamping`,而那时 collection 还没被 `scene.primitives.add()` 收编、没有 scene,
-   于是抛 `DeveloperError: Height reference is not supported without a scene.`,
-   `buildDeviceMarkers()` 中途死掉。点位标记走 `viewer.entities.add`(引擎内部建集合并绑 scene),所以那 3 个是好的 ——
-   表现为「有监测点、没有设备」,纯逻辑断言(读的是 plan,不是渲染结果)与 SFC 编译**都不可能发现**。
-   修法:先 `scene.primitives.add(collection)` 再逐个 `add()`;贴地改由 `position` 的实测地形高度负责。
-2. **就绪徽标永远显示「⏳ 等待地图引擎」** —— `ready` / `readyText` 写成 `computed()`,但它们读的
-   `viewer` / `pointEntities` / `deviceHandles` 都是普通 `let`(不是 ref),**computed 没有任何响应式依赖**,
-   于是在挂载那一刻(三者皆空)算一次就永久缓存。引擎就绪、3 个点位 + 7 个设备图标全画出来了,徽标仍显示「等待」——
-   这正是走查者会读成「没成功」的假信号。修法:加 `assetsTick` ref,资产变化时自增,computed 读它建立依赖。
-3. **面板四向全判 `no-space`(原型自伤)** —— 我的控制台摊在底部约 940×180px,
-   从下方顶住候选面板,加上顶部图例、左右两栏,四向全部相交。修法:控制台收窄到 620px、
-   细项折进 `<details>`、**默认折叠**(并用 `data-collapsed` 收成一条),把地图空间还给面板。
-4. **每帧重渲染整个组件** —— `updateLayout()` 每帧无条件 `layout.value = <新对象>`,
-   Vue 于是每帧重渲染,白烧 CPU。修法:加 `sameLayout()` 比较,只在排版量真变时写回。
-5. **自动飞行的竞态**(静态阶段已发现,真跑确认)—— 引导遮罩消失 ≠ 归位飞完;
-   `enterSystem()` 走阻尼收敛且带最多约 10 秒的重试循环。修法:改读 `cameraControl.getState().settled`。
-
-#### 走查通过项(有画面/读数证据)
-
-- **画面**:沅陵真实影像与地形可见;点位与设备图标(绿圆正常 / 红三角报警 / 橙菱形预警 / 灰环离线)清晰;
-  锚定面板贴点位右侧、短引线接图标外缘、不压图标;7 台设备逐行列名并标注类型与特效。
-- **就绪徽标**:`✔ 已挂载:3 个监测点 + 7 个设备图标(异常 4 台,动效 4 台)`(绿)。
-- **坐标**:相机实际飞到 `110.3831, 28.438`,正是 MP-002 现算坐标;高度 1638 m。
-- **求解器与真实 DOM 一致**:状态面板写 `面板矩形 left 714.3 / top 244.6 / 340×330`,
-  真实 `getBoundingClientRect()` 读回 `714 / 245 / 342×332` —— 吻合(差 2px 是边框)。
-- **四向避让**(用真实 DOM 矩形扫):正中央→`right`;右半区→`left`;左半区→`right`;画布外→`icon-offscreen` 暂隐。
-  贴左右缘的锚点判 `icon-covered`(标记落进业务栏遮挡区)—— 与票 04 一致。
-- **票 07 的第二层证据(场景图跨帧快照)**:4 台异常设备(报警×3、预警×1)逐帧 `scale`/`color` **确实在变**;
-  3 台正常/离线**完全静止**;**违规 0 项**。这正是票 07 用来避开「恒真断言」的那层证据。
-- **票 07 的并发降级**:设备拉 40 台(23 台异常)、上限调到 10 → `已降级: true`、`动效台数: 0`(动画全停),
-  而 `异常台数: 23` 仍在 —— **降级后仍读得出「这里有异常」**,守住了票 07 划的红线。
-
-#### 一处**探针自身的错**(记下来免得后人误判)
-
-用 `drawImage(canvas)` 读 Cesium 画布亮度得到「平均亮度 0」,一度像是地图全黑。
-实际是 Cesium 默认 `preserveDrawingBuffer: false`,离屏抓取读不到内容 —— **画面本身是好的**(截图可见)。
-判断地图有没有内容要用截图,不要用 `drawImage`。
-
-### 第一次(2026-09-24):失败 —— 找不到设备及监测点位、找不到对应 UI
-
-**现象**:维护者报告「验收失败,找不到设备及监测点位,找不到对应的 UI,无法判断是否成功」。
-
-**诊断结论**:**三个独立原因叠加**,其中只有两个在原型代码里(已修),一个是环境问题(需维护者确认):
-
-- **代码因 ①(致命)**:样例点位坐标写死,距真实 home **24.3 km**,超出归位视角约 **5.7 km** 的可视半宽 → 画面上一个点都没有。
-- **代码因 ②**:没有可判定的成功信号 → 「没挂载」与「挂载了但空」看起来一样,故「无法判断是否成功」。
-- **环境因 ③**:本机 5173 端口上跑的是**另一个项目**,本仓库 dev server 当时未运行 → 「找不到对应的 UI」。
-
-**已修复**:①②已改(坐标由 home 现算 + 就绪徽标 + 自动飞行);③由维护者授权后本会话自己起 dev(端口 8000)走查通过。
-
-## 已执行与未执行
-
-- 已执行:**维护者授权后亲自跑 `yarn dev`(端口 8000)+ 浏览器实操走查 + 截图**(见「第三次」)、纯逻辑断言(`verify/t08-anchor-layout.mjs`,含「样例点位必须在归位视野内」的回归)、SFC 静态编译与绑定核对(`verify/t08-sfc-static-check.mjs`)、dev server 归属探针(`verify/t08-find-devserver.mjs`)、源文件读回、改动范围核对。
-- **第一次走查失败(2026-09-24)的三个真实根因**,本次全部修掉:
-    1. **点位坐标写死在视野外(最致命)**。样例原本写死 110.141–110.143 / 28.452–28.454,而本机 `public/cockpit/config.json` 的 home 是 **110.39028164 / 28.4436787** —— 相距 **24.3 km**,而归位视角(range 12 km、pitch −55°)地面可视半宽只有约 **5.7 km**。三个点位**一个都不在画面里**,看起来就是「什么都没有」。现改为 `samplePointsNear(home)` **由 home 现算**坐标(现落在 0.83–0.87 km),并加了回归断言:换任何 home 都在视野内。这一条不修,换台机器还会复现。
-    2. **看不出成功没有**。走查者无法区分「没挂载」与「挂载了但画面是空的」。现加常驻**就绪徽标**(等引擎/已挂载但计数为 0/成功,三态分明)+ 状态面板里的 `引擎.viewer / 点位标记 / 设备图标` 计数。
-    3. **要自己在地图上找三个小图标**。现默认开启「自动飞到样例点位」,并在「点击进入系统」引导退场、镜头真正停稳后再飞;也保留手动的「飞到样例点位」按钮。
-- **第三次真跑又发现并修掉五个缺陷**(详见「第三次」一节):设备图标恒为 0(`BillboardCollection` 的 `heightReference` 缺 scene 抛错)、就绪徽标因 computed 无响应式依赖而永久缓存、控制台太大导致四向 `no-space`、每帧重渲染、自动飞行竞态。
-- **端口**:本仓库 dev 是 **8000**;5173 是另一个项目(环境问题,不是代码问题)。
-- **仍未执行**:`yarn build`、GPU/帧耗时采样(票 07 第 3 层「真实帧差」的像素级量化)、1080p/4K 双分辨率对比。
-- **未改动**:`services/cameraControl.js`、`services/mapService.js`、`domain/pointStatus.js`、`styles/tokens.css` 均**未改**(`git diff --name-only` 为空),票 03 的三键手感未动,未引入新依赖(只用已在依赖里的 `cesium`)。
-
-## 待维护者确认的事项
-
-以下 6 项是**决策**,需要你拍板(画面已由我走查通过,但审美与业务取舍是你的):
-
-1. 第 4 条那组阈值的最顺手取值(间距 / 边缘 / 高占比 / 可读高下限)—— 我实测当前值可用,但「最顺」要你的眼睛。
-2. 固定位对照的结论:票 04 「不回固定位」是否仍然成立。
-3. 票 07 的留白:「正常/离线无特效」是否够可辨 —— 我在画面上能看到绿圆/灰环与红三角/橙菱形区分明显,但请你也看一眼。
-4. 30 台这条降级阈值是否合适 —— 我已验证**降级后仍读得出「有异常」**(23 台异常仍在标、动画全停),但阈值数值本身由你定。
-5. 门控默认值(`map.effects` 缺省 `null` 跟随构建形态)是否符合预期。
-6. 三种锚定形态里选哪一个(或组合)—— 默认是 ② 短引线。
-
-**已由本次走查回答、不再需要你确认的**:坐标是否在视野内(是,0.83–0.87 km)、面板能否出现(能,`right`/`below`)、四向翻转是否生效(生效)、特效是否真在动(在动,违规 0)、降级后是否仍可见(可见)。
-
-**不能凭代码存在关闭票 08 或票 07**:上面 6 项业务取舍未定,须维护者裁定后才收口。
-
-## 清理与保全
-
-- 原型未被验收前保持为未提交的可丢弃源文件;本会话未提交、未切分支(工作区有大量并行改动,避免带入他人工作)。
-- 撤除方式:删掉 `src/modules/cockpit/prototypes/{AnchorPrototype.vue,anchor-prototype-layout.js,anchor-prototype-effects.js}`,并移除 `src/pages/cockpit/index.vue` 里 `AnchorPrototype` 的挂载与 import 即可,其余文件无需改动。
-- 结论确认后:`anchor-prototype-layout.js` 与 `anchor-prototype-effects.js` 这两段纯逻辑按票 12 规格抬进实现(它们是本原型唯一值得留下的部分),完整原型另存 throwaway 分支作**一手证据**,`main` 只留已验证的决策。
-- 不把未验证原型直接当正式交付;也不因为原型能跑就宣布设备图层或特效已实现。

+ 0 - 521
.scratch/cockpit-point-locate-and-device-link/research/device-position-data-and-rendering-primitives.md

@@ -1,521 +0,0 @@
-# 调研 —— 点位下设备的位置数据来源与三维呈现原语
-
-**票:** [01 — 调研 —— 点位下设备的位置数据来源与三维呈现原语](../issues/01-research-device-position-data-and-rendering-primitives.md)
-**日期:** 2026-09-18
-**范围:** 只查事实(能做什么、数据在哪),不给建议、不做设计。
-
-**方法与只读声明:** 后端 `E:\pipe-ner-server` 全程**只读**阅读,未修改任何后端文件;按 `AGENTS.md` 未运行任何 build / dev / serve / 浏览器调试 / 截图命令。前端与 `node_modules/cesium` 亦只读。地图 `map.md` 的 `## Notes` 现状事实 1–8 未重复推导,只做独立复核与补充。
-
-**路径简写(全文沿用):**
-
-- `SYS/` = `E:\pipe-ner-server\pipe-network-service\zksy-system\src\main\java\com\zksy\`
-- `ADMIN/` = `E:\pipe-ner-server\pipe-network-service\zksy-admin\src\main\java\com\zksy\`
-- `FE/` = `E:\City-Lifeline-Cockpit-TvT\src\modules\cockpit\`
-- `CES/` = `E:\City-Lifeline-Cockpit-TvT\node_modules\cesium\Source\Cesium.d.ts`
-- `ENG/` = `E:\City-Lifeline-Cockpit-TvT\node_modules\@cesium\engine\Source\`
-
----
-
-## Bottom line
-
-1. **不能一次拿到。** 后端 20 个 `/api/cockpit/v1/` 端点里,**没有任何一个**能在「某监测点下所有设备」这一粒度上同时给出经纬度 + 坐标校验 + 状态。缺口不在响应层包装,而在**数据模型**:设备一旦按点位归集,坐标就在 `CockpitMonitoringPointDeviceSnapshot` 上被丢掉了。唯一带完整设备坐标的来源(`/equipments/{deviceCode}/archive`)是逐台取。
-2. **`deviceCodes` 已交付且可用,但前端零消费。** 它只在 `map-snapshot` 的 `monitoringPoints[].deviceCodes` 回传,**含离线设备**、**不受分页影响**、**无状态过滤**,空值是 `[]` 而非 `null`。前端 `FE/` 乃至整个 `src/` 里 `deviceCodes` **零引用**。
-3. **点位与设备各有一份独立的经纬度,且后端没有任何相对位置信息。** `equipment_point_rel` 是纯关联表,无偏移/高度/朝向/井杆字段;全后端模块 grep 相关词 **0 命中**。「设备坐标是否等于点位坐标」是**数据问题,schema 不保证**。
-4. **Cesium 1.145.0 上三者都能被单独拾取,只有 `PointPrimitive` 不能贴地。** `BillboardCollection` / `PointPrimitiveCollection` / `LabelCollection` 三者 GPU 侧都是批渲染(draw call 不随点数线性增长);Entity 的额外代价在 JS 对象与每帧 Visualizer update。Entity 的 `point.heightReference` 是**内部拿 billboard 冒充**实现的。
-5. **仓库内没有任何设备级 glTF/GLB 模型资产。** 45 个 3D 文件全是建筑/车辆/船只/卫星/人体/道具/倾斜摄影瓦片;唯一「设备形状」的 glTF 只以 `oss.icegl.cn` 远程 URL 存在于插件 demo 代码里。设备点位目前完全依赖 8 个几何 SVG 形状 + `tokens.css` 色键烘焙成 billboard 图片。`SHAPES` 里**没有任何设备形状**。
-
----
-
-## 1. 数据来源:现有一张图相关端点里,哪一个能一次给出「某监测点下所有设备的经纬度 + 坐标校验 + 状态」
-
-### 1.1 端点全景(20 个只读端点)
-
-`ADMIN/web/controller/cockpit/` 下 grep `@GetMapping` 共 29 处、覆盖 20 个不同路径(部分控制器多端点)。契约手册的端点清单在 `docs/cockpit/README.md:27-61`,其中「一张图与详情」一节在 `:50-61`。
-
-与设备坐标相关的候选只有 5 个(其余是概览/基础设施/预警处置,不含设备级坐标):
-
-| 端点 | 控制器位置 | 能否一次给出「点位下所有设备的经纬度 + 校验 + 状态」 |
-| --- | --- | --- |
-| `GET /api/cockpit/v1/monitoring-points/{code}` | `ADMIN/web/controller/cockpit/CockpitMonitoringPointDetailController.java:37` | **不能** —— 有设备清单 + 状态,**无任何经纬度** |
-| `GET /api/cockpit/v1/map-snapshot` | `ADMIN/.../CockpitMapSnapshotController.java:33` | **不能** —— 点位有坐标 + `deviceCodes`,但**没有设备明细**;带坐标的只有未关联设备 |
-| `GET /api/cockpit/v1/monitoring/distribution` | `ADMIN/.../CockpitMonitoringDistributionController.java:33` | **不能** —— 点位 marker 无设备清单,设备 marker 只含未关联设备 |
-| `GET /api/cockpit/v1/monitoring/situation` | `ADMIN/.../CockpitMonitoringSituationController.java:32` | **不能** —— `abnormalEquipment[]` 有设备坐标 + 校验 + 状态,但只含**异常**设备、且**无 `monitoringPointCode`** |
-| `GET /api/cockpit/v1/equipments/{deviceCode}/archive` | `ADMIN/.../CockpitEquipmentArchiveController.java:37` | **能拿到**,但**逐台一次**(N+1) |
-
-### 1.2 逐端点源码核对
-
-**① `/monitoring-points/{monitoringPointCode}` —— 缺经纬度,缺口在数据模型**
-
-- 响应类 `SYS/cockpit/point/CockpitMonitoringPointDetailResponse.java:7-19`,`devices` 字段声明在 `:17`,类型 `List<CockpitMonitoringPointDeviceCard>`。
-- 卡片类 `SYS/cockpit/point/CockpitMonitoringPointDeviceCard.java:5-16`,共 11 个字段:`deviceCode` `:6`、`deviceName` `:7`、`deviceTypeName` `:8`、`online` `:9`、`alarm` `:10`、`warning` `:11`、`workOrderInProgress` `:12`、`statusReasons` `:13`、`statusTime` `:14`、`deviceStatus` `:15`、`statusLabel` `:16`。**无 longitude / latitude / coordinateValidation。**
-- 卡片不是直接从数据源构造的:`SYS/cockpit/point/CockpitMonitoringPointStatusResolver.java:34-48` 的 `cards()` 从 `CockpitMonitoringPointDeviceSnapshot` 逐个映射,而快照类 `SYS/cockpit/point/CockpitMonitoringPointDeviceSnapshot.java:6-14` 同样只有那 9 个字段(无坐标)。→ **坐标在更上游的模型里就已经不存在了**,不只是响应层少写了一个 getter。
-- 点级坐标校验**是有的**:`CockpitMonitoringPointDetailResponse.java:18` 的 `coordinateValidation`,由 `SYS/cockpit/point/CockpitMonitoringPointDetailService.java:43-44` 调 `CockpitCoordinateValidator.validate(detail.getLongitude(), detail.getLatitude(), detail.isCoordinateVerified())` 得到;输入来自 `SYS/cockpit/point/CockpitMonitoringPointDetail.java:9-11` 的 `longitude` / `latitude` / `coordinateVerified`。校验器在 `SYS/cockpit/foundation/CockpitCoordinateValidator.java:11-32`(产出 `state` / `reason` / `renderable` / `crs`,见 `SYS/cockpit/foundation/CockpitCoordinateValidation.java:3-19`)。
-- **注意一个易踩的点**:该响应**不返回监测点自身的 longitude / latitude**(`CockpitMonitoringPointDetailResponse.java:8-19` 无这两个字段),只回校验结论。点位坐标得从 `map-snapshot` 的点位摘要拿。
-- 演示页佐证:`docs/cockpit/monitoring-point-detail-demo.html:89-92` 的 `device()` 工厂只造 `deviceCode/deviceName/deviceTypeName/deviceStatus/statusLabel/statusReasons/statusTime`,`:111` 的 `coordinateValidation` 是**点级**的一个对象,不在任何 device 里。
-- 适配器只有空实现:`SYS/cockpit/point/EmptyCockpitMonitoringPointDetailAdapter.java:8-10` 的 `load()` 直接 `return null`;接口 `SYS/cockpit/point/CockpitMonitoringPointDetailAdapter.java:3-4`。全后端 grep `CockpitMonitoringPointDetailAdapter` 只命中接口 + 空实现 + 两个测试的 mock → **真实数据适配器尚未落地**(与 `docs/cockpit/README.md:152` 把「数据库级联调」列为未执行项一致)。
-
-**② `/map-snapshot` —— 点位有坐标、有 `deviceCodes`,但没有设备明细**
-
-- 服务 `SYS/cockpit/map/CockpitMapSnapshotService.java:75-102`:`monitoringPoints(...)` + `unassociatedEquipment(...)` 两条列表,`total = points.size() + equipment.size()`(`:79`),分页窗口横跨这一条总序列(`:80-87`)。
-- 点位摘要 `SYS/cockpit/map/CockpitMonitoringPointSummary.java:10-21` 有 `longitude` `:19`、`latitude` `:20`、`coordinateValidation` `:21`,**但没有 `devices[]`**。
-- 未关联设备摘要 `SYS/cockpit/map/CockpitUnassociatedEquipmentSummary.java:8-16` 有 `longitude` `:14`、`latitude` `:15`、`coordinateValidation` `:16`;但它**只含未关联设备** —— `CockpitMapSnapshotService.java:278-288` 用 `associatedDeviceCodes(snapshot)`(`:291-302`,取整份快照里所有点位下的 deviceCode)把已关联设备排除掉。
-- 响应类 `SYS/cockpit/map/CockpitMapSnapshotResponse.java:15-18`:`monitoringPoints` / `unassociatedEquipment` / `coordinateValidation` / `mapFilter`。
-- 演示页 `docs/cockpit/map-snapshot-demo.html:64-70` 与之一致:点位对象只有 `pointCode/pointName/region/industry/typeCode/typeName/pointStatus/statusReasons/deviceCodes/longitude/latitude/coordinateValidation`,**没有设备明细**。
-
-**③ `/monitoring/distribution` —— 点位 marker 连设备清单都没有**
-
-- `MonitoringPointMarker` 在 `SYS/cockpit/monitoring/CockpitMonitoringDistributionResponse.java:274-311`:`pointCode/pointName/region/industry/deviceType/pointStatus/statusReasons/longitude/latitude/coordinateValidation` —— **无 `devices[]`、无 `deviceCodes`**。
-- 构造处 `SYS/cockpit/monitoring/CockpitMonitoringDistributionService.java:293-306`;点位校验在 `:269-274`,不可渲染则计数并 `continue`(`:271-274`,`invalidCoordinateCount++`)。
-- `UnassociatedEquipmentMarker` 在 `CockpitMonitoringDistributionResponse.java:313-350`,有 `longitude` `:321` / `latitude` `:322` / `coordinateValidation` `:323`;构造处 `CockpitMonitoringDistributionService.java:359-373`,校验在 `:333-338`。
-- **已关联设备的坐标在快照模型里就没有**:`SYS/cockpit/monitoring/CockpitMonitoringDistributionSnapshot.java:107` 的 `SourceMonitoringPoint.devices` 类型是 `List<CockpitMonitoringPointDeviceSnapshot>`(又是那个无坐标的类),而 `SourceSmartDevice`(同文件 `:161-256`)**才**有 `longitude` `:166` / `latitude` `:167` / `coordinateVerified` `:168` 以及 online/alarm/warning/workOrderInProgress/statusReasons/statusTime。
-- 类注释自己写明了这个契约取向:`CockpitMonitoringDistributionSnapshot.java:14-15` —— "Associated devices are described through their monitoring point; the point's typeCode is the shared smart-device type of its devices."
-
-**④ `/monitoring/situation` —— 有设备坐标,但只含异常设备、且无点位归属**
-
-- `SYS/cockpit/monitoring/CockpitMonitoringSituationResponse.java:183-232` 的 `AbnormalEquipment`:`deviceCode` `:184`、`deviceName` `:185`、`deviceType` `:186`、`industry` `:187`、`region` `:188`、`abnormalType` `:189`、`status` `:190`、`statusReasons` `:191`、`longitude` `:192`、`latitude` `:193`、`coordinateValidation` `:194`。
-- 源模型 `SYS/cockpit/monitoring/CockpitMonitoringSituationSnapshot.java:93-167` 的 `SourceMetricDevice` 也有 `longitude` `:101` / `latitude` `:102` / `coordinateVerified` `:103`。
-- 但列表名就是 `abnormalEquipment`(响应 `:21`),且**没有任何 `monitoringPointCode` 字段** → 无法按监测点归集。
-
-**⑤ `/equipments/{deviceCode}/archive` —— 有坐标,逐台**
-
-- `SYS/cockpit/equipment/CockpitEquipmentArchive.java:26-27` 的 `getLongitude()` / `getLatitude()` 委托给快照 `SYS/cockpit/equipment/CockpitEquipmentArchiveSnapshot.java:16-18`(`longitude` / `latitude` / `equipmentLocation`)。
-- 演示页 `docs/cockpit/equipment-archive-demo.html` 同端点的样例载荷一致。
-
-### 1.3 后端领域模型里设备到底有没有坐标
-
-**有,但是在「已关联」这条路上被丢掉了。**
-
-有坐标的三处快照源模型:
-
-- `SYS/cockpit/map/CockpitMapSnapshot.java:100-127` 的 `SourceSmartDevice`:`longitude` `:105`、`latitude` `:106`、`coordinateVerified` `:107`。
-- `SYS/cockpit/monitoring/CockpitMonitoringDistributionSnapshot.java:161-256` 的 `SourceSmartDevice`:除坐标外**还带状态**(`online` `:169`、`alarm` `:170`、`warning` `:171`、`workOrderInProgress` `:172`、`statusReasons` `:173`、`statusTime` `:174`)。
-- `SYS/cockpit/monitoring/CockpitMonitoringSituationSnapshot.java:93-167` 的 `SourceMetricDevice`:坐标 + 指标状态。
-
-点位侧的快照源模型也都有坐标:
-
-- `SYS/cockpit/map/CockpitMapSnapshot.java:67-98` 的 `SourceMonitoringPoint`:`longitude` `:72`、`latitude` `:73`、`coordinateVerified` `:74`、`devices` `:75`。
-- `CockpitMonitoringDistributionSnapshot.java:99-159` 的 `SourceMonitoringPoint`:`longitude` `:104`、`latitude` `:105`、`coordinateVerified` `:106`、`devices` `:107`。
-
-**结构性问题(本票最核心的发现)**:两条 `SourceMonitoringPoint.devices`(`CockpitMapSnapshot.java:75`、`CockpitMonitoringDistributionSnapshot.java:107`)的类型都是 `List<CockpitMonitoringPointDeviceSnapshot>` —— 正是那个**没有经纬度**的类。也就是说:设备坐标挂在 `SourceSmartDevice` 上;一旦设备按点位归集,就换成 `CockpitMonitoringPointDeviceSnapshot`,**坐标在这一步被丢弃**。响应层的 `CockpitMonitoringPointDeviceCard` 只是继承了这个缺失。
-
-数据库层:`SYS/base/domain/EquipmentBase.java:65-72` 有 `longitude` `:66` / `latitude` `:69` / `equipmentLocation` `:72`(`equipment_base` 表)。
-
-### 1.4 若不能,缺哪个字段、挂在哪个响应类上、要动的类名与端点
-
-**缺的字段:** 设备级 `longitude` / `latitude` / `coordinateValidation`(或等价的 `coordinateVerified` 布尔)。
-
-**要动的类(按依赖顺序,均为事实陈述的「改动面」,不是设计建议):**
-
-1. `SYS/cockpit/point/CockpitMonitoringPointDeviceSnapshot.java` —— 加 `longitude` / `latitude` / `coordinateVerified`。
-2. `SYS/cockpit/point/CockpitMonitoringPointDeviceCard.java` —— 加 `longitude` / `latitude` / `coordinateValidation`。
-3. `SYS/cockpit/point/CockpitMonitoringPointStatusResolver.java:34-48` 的 `cards()` —— 透传新字段(它是唯一的卡片构造点)。
-4. 若同时要覆盖 `/map-snapshot` 与 `/monitoring/distribution`:两处 `SourceMonitoringPoint.devices` 的**填充方**(适配器实现)也要给坐标;另有两处显式 `new CockpitMonitoringPointDeviceSnapshot(...)` 需要补参数 —— `SYS/cockpit/monitoring/CockpitMonitoringDistributionService.java:343-352` 与 `:469-473`(`effectiveDevice`)。
-5. 校验语义可直接复用 `SYS/cockpit/foundation/CockpitCoordinateValidator.java:11-32`(与点位级同一套 state/reason/renderable/crs 词表)。
-
-**端点:不需要新增。** `/monitoring-points/{monitoringPointCode}` 已经回 `devices[]`(`CockpitMonitoringPointDetailController.java:37`),补上字段后即为「一次拿全」。这正是票 09 预期要去推的那张后端工单。
-
-**另外两个可用的补充信道(都不能替代坐标):**
-
-- 设备编码 → 点位编码的映射已交付:WebSocket `cockpitDeviceStatusDelta` 帧带 `monitoringPointCode`(`ADMIN/web/websocket/DeviceStatusRedisListener.java:127`、`:187-194`,`:209` 的 `monitoringPointCode(deviceCode)` 实现;口径见 `docs/cockpit/README.md:109` 的 `equipment_base.equipment_code → equipment_point_rel → monitor_point.point_code`)。**帧里只有状态,没有坐标。** `DEVICE_CODE`/`device_code` 别名见 `docs/cockpit/README.md:10`。
-- `mapFilter.deviceCodes` 在 `map-snapshot` / `distribution` / `situation` / `events/*` / `overview/monitoring` 等端点回显(`docs/cockpit/README.md:69`),是**编码集合**,不含坐标。
-
-### 1.5 后端交付状态与既有说明(Gogs 侧)
-
-- 前端仓 `.scratch/cockpit-shell-panel-layout/handoff/backend-contract-gaps.md:14-15` 记录 **BT-1 = Gogs #59、BT-2 = Gogs #60,均已于 2026-09-18 交付并关闭**;`:64-87` 是 BT-1 的完整工单草案(其验收标准 `:82` 写明「同一 `snapshotAt` 下与 `/monitoring-points/{code}` 的 `devices[]` 一致」)。
-- **#59 交付的是 `deviceCodes`(编码清单),不是坐标** —— 证据是后端 `CockpitMonitoringPointSummary.java:14` 只有 `List<String> deviceCodes`,`:76-79` 的 `MapFilter` 亦然。故票 09 第 2 条设想的「是不是 #59 已经带了坐标而前端适配层漏读」**可以排除**:前端没有漏读,后端确实没给。
-- 后端 `docs/cockpit/` 下 26 个 demo 页 + `README.md` 已通读相关部分;**没有任何一页展示设备级坐标**(`monitoring-point-detail-demo.html:89-92`、`map-snapshot-demo.html:64-70`、`equipment-archive-demo.html` 为准)。
-- 前端仓 grep `Gogs|gogs` → **0 命中**(Gogs 链接只存在于前端仓的 `.scratch/` 手写文档里,见上)。
-
----
-
-## 2. `deviceCodes` 的语义
-
-**字段位置:** `SYS/cockpit/map/CockpitMonitoringPointSummary.java:14`(`private final List<String> deviceCodes`),getter `:45`。构造参数 `:24`。
-
-**回传端点:只有一个。** `CockpitMonitoringPointSummary` 只被 `SYS/cockpit/map/CockpitMapSnapshotResponse.java:15` 序列化 → 只有 `GET /api/cockpit/v1/map-snapshot` 的 `monitoringPoints[].deviceCodes`。
-
-- `/monitoring/distribution` 的 `MonitoringPointMarker`(`CockpitMonitoringDistributionResponse.java:274-311`)**没有**这个字段。
-- 同名但不同物:`mapFilter.deviceCodes`(`CockpitMapSnapshotResponse.java:64`、`CockpitMonitoringDistributionResponse.java:232`、`CockpitMonitoringSituationResponse.java:250`、`CockpitCurrentEventsResponse.java:104`、`CockpitMultidimensionalAnalysisResponse.java:115`、`CockpitHistoryAnalysisResponse.java:154`、`CockpitHandlingEfficiencyResponse.java:134`、`CockpitMonitoringOverviewResponse.java:201`)是**另一层语义**(生效筛选范围回显),不要与逐点 `deviceCodes` 混为一谈。
-
-**构造口径(唯一构造点):** `SYS/cockpit/map/CockpitMapSnapshotService.java:246-253`
-
-```
-246: List<String> deviceCodes = devices.stream()
-247:         .filter(Objects::nonNull)
-248:         .map(CockpitMonitoringPointDeviceSnapshot::getDeviceCode)
-249:         .filter(Objects::nonNull)
-250:         .map(String::trim)
-251:         .filter(code -> !code.isEmpty())
-252:         .distinct()
-253:         .collect(Collectors.toList());
-```
-
-逐条回答票面 4 问:
-
-| 问题 | 结论 | 证据 |
-| --- | --- | --- |
-| 在哪些端点回传 | 只有 `GET /api/cockpit/v1/map-snapshot` 的 `monitoringPoints[].deviceCodes` | `CockpitMapSnapshotResponse.java:15`;`CockpitMonitoringDistributionResponse.java:274-311` 无此字段 |
-| 是否含离线设备 | **含。** 构造链上**没有任何 online / status 过滤** | `CockpitMapSnapshotService.java:246-253`(只滤 null/空/去重);设备来源是整份 `source.getDevices()`(`:242-244`) |
-| 是否受分页影响 | **不受。** `deviceCodes` 在生成 summary 时算完(`:246-253` → 构造 `:255-268`),分页只切 summary 对象(`:79-87`)。点位被切到哪一页,它都带**该点位全量**的 deviceCodes | `:79-87`、`:246-268` |
-| 是否受状态过滤影响 | **不受。** `map-snapshot` 连 `pointStatus` 入参都没有 | 入参签名 `CockpitMapSnapshotService.java:53-54`(`page/pageSize/snapshotAt/industry/region/deviceType`);`docs/cockpit/README.md:52` |
-| 受什么筛选影响 | 受 `industry` / `region` / `deviceType` 影响,且是**通过点位**受影响:`monitoringPoints()` 先过滤点位(`:229-236`),再对存活点位算 deviceCodes。点位被筛掉 → 它的 deviceCodes 一并消失 | `:229-236`、`:231-233`(`matchesFilters(industry, region, deviceType, source.getTypeCode(), source.getRegion(), types)`) |
-| 空值语义 | **空数组 `[]`,不是 `null`。** `source.getDevices() == null` 时先退化成 `Collections.emptyList()` | `:242-244`;演示页 `docs/cockpit/map-snapshot-demo.html:64` 与 `:70` 正是 `deviceCodes:[]` |
-| 顺序 | **源顺序去重,未排序。** 用的是 Stream `distinct()`(保持首次出现顺序) | `:252` |
-
-**`mapFilter.deviceCodes` 的独立口径(易混,值得记一笔):**
-
-- `CockpitMapSnapshotService.java:321-346`:由「存活点位的 `deviceCodes` ∪ `unassociatedEquipment` 的 `deviceCode`」组成,`sorted(...)` 升序(`:344-345`、`:348-352`);`:56-57` 的类注释与 `docs/cockpit/README.md:69` 都写「过滤后全量、分页前、升序」。
-- **一处口径细节**:`unassociatedEquipment` 的排除集用的是 `associatedDeviceCodes(snapshot)`,即**未过滤的整份快照**(`:291-302`);而 `mapFilter` 只遍历**过滤后**的 points + equipment(`:327-343`)。所以被 `region`/`industry` 筛掉的点位,其关联设备的编码既不在 points 里、也不在 equipment 里 → 该编码不出现在 `mapFilter.deviceCodes`。这是既有实现的客观行为,本票只记录。
-
-**前端消费现状:零。** `FE/` 与整个 `E:\City-Lifeline-Cockpit-TvT\src` grep `deviceCodes` → **0 命中**。`FE/domain/mapSummary.js:26-44` 的 `toMonitoringPoint()` 只读 `longitude/latitude/coordinateValidation/pointCode/pointName/pointStatus/statusReasons`,**不读 `deviceCodes`**;文件头 `:4-7` 列的后端形状注释里也没有它。→ 契约已交付但前端尚未接线(这正是票 05「设备点位图层的触发与生命周期」要处理的入口)。
-
----
-
-## 3. 设备与点位的空间关系
-
-### 3.1 两张表各有独立的经纬度列
-
-| 表 | 实体类 | 坐标字段 | 位置描述 | 其它 |
-| --- | --- | --- | --- | --- |
-| `monitor_point`(关键监控点位表) | `SYS/base/domain/MonitorPoint.java`,`@TableName(value = "monitor_point", schema = "app_user")` `:23` | `longitude` `:46`、`latitude` `:49` | `location` 位置描述 `:43` | `pointId` `:28`、`pointCode` `:31`、`pointName` `:34`、`networkId` 关联管网设施ID `:37`、`pointType` `:40`、`installationDate` `:53`、`status` `:56`、`delFlag` `:77` |
-| `equipment_base`(设备基础信息表) | `SYS/base/domain/EquipmentBase.java`,`@TableName("equipment_base")` `:23` | `longitude` `:66`、`latitude` `:69` | `equipmentLocation` 设备位置描述 `:72` | `equipmentId` `:28`、`equipmentCode` `:31`、`equipmentName` `:34`、`equipmentTypeId` `:53`、`installTime` `:76`、`district` `:46`、等 |
-
-DDL 佐证:`pipe-network-service/sql/iot_equipment_management.sql:63-83`(`monitor_point`,`longitude DECIMAL(15,10)` `:70`、`latitude` `:71`、`location VARCHAR(512)` `:69`、`network_id` `:67`)。
-
-### 3.2 关联表是纯关联,没有任何相对位置信息
-
-`SYS/base/domain/EquipmentPointRel.java`:`@TableName(value = "equipment_point_rel", schema = "app_user")` `:21`,字段只有 `relId` `:27`、`equipmentId` `:30`、`pointId` `:33`、`createTime` `:37`、`delFlag` `:41`。
-
-DDL:`sql/iot_equipment_management.sql:88-97` —— `rel_id` / `equipment_id` / `point_id` / `create_time`,主键 + 两个索引,**无其它列**。
-
-→ **没有「同井 / 同杆 / 相对点位 / 安装高度 / 朝向 / 方位」这类信息。**
-
-**查证范围(明确「查不到」):**
-
-- 后端 `pipe-network-service/` 全模块 grep `同井|同杆|杆塔|relativePosition|offsetX|offsetY|mountHeight|installHeight|poleCode|wellCode` → **0 命中**。
-- `pipe-network-service/sql/` 全目录 grep `offset|height|azimuth|heading|elevation|altitude|relative` → **0 命中**。
-- 设备侧最接近「位置」的自由文本只有 `EquipmentBase.equipmentLocation` `:72`("设备位置描述")与 `MonitorPoint.location` `:43`("位置描述")——都是给人读的字符串,不是可计算的相对几何。
-
-### 3.3 「设备的经纬度与所属监测点经纬度是否同一个值」
-
-**后端没有任何地方保证或校验它们相等**,因此这**是数据问题,不是契约问题**:
-
-- 两张表各存一份独立的 `longitude`/`latitude`(§3.1),没有触发器、没有服务端校验、没有外键约束把二者绑定。
-- 后端只在一处显式声明了「关联设备的空间承载对象是它的监测点」这一**契约取向**:`SYS/cockpit/monitoring/CockpitMonitoringDistributionSnapshot.java:14-15` 的类注释 —— "Associated devices are described through their monitoring point."
-- 结构性推论(本票能给出的最确定的表述):**只要走「已关联设备」这条路,响应里根本取不到设备自己的坐标**(§1.3),所以「是否同一个值」在当前契约下**无法从任一 cockpit 端点观测到**。要回答它,必须先补齐 §1.4 的字段,或逐台调 `/equipments/{deviceCode}/archive` 与点位摘要做比对。
-- 前端纪律与此互相印证:`CONTEXT.md` 的 `Avoid_` 禁止「人工坐标偏移 / 把聚合点当作真实监测点」(票 09 正文 `:20` 已引),即**不许前端自己造设备位置**。
-
----
-
-## 4. 呈现原语(Cesium 1.145.0)
-
-### 4.1 版本与证据位置
-
-- 实装版本 **`1.145.0`**:`E:\City-Lifeline-Cockpit-TvT\node_modules\cesium\package.json` 的 `version` 字段(另有 `node_modules/cesium/package.json` 里 `@cesium/engine: ^26.3.0` 的运行时实现包)。
-- 随包 d.ts **只有一个**:`CES/` = `node_modules/cesium/Source/Cesium.d.ts`(51,152 行)。`node_modules/cesium/Build/Cesium/Cesium.d.ts` **不存在**(`Test-Path` = False),`package.json` 的 `types` 指向 `./Source/Cesium.d.ts`。
-- **注意**:随包 d.ts 是**被删减过的**,全文 grep `@performance` → **0 命中**。原版 JSDoc 的 `Performance:` 段落只在官方 ref-doc 网页上。故本节的「开销」结论同时引用三处:
-  1. 随包 d.ts(API 存在性与签名,行号可复现);
-  2. 随包实现源码 `ENG/` = `node_modules/@cesium/engine/Source/`(批渲染与拾取的**实现机制**,行号可复现);
-  3. 官方 ref-doc(`Performance:` 段落的**语义**,给 URL)。
-- 官方文档 URL(版本对应 1.145 源码行):
-  - `https://cesium.com/learn/cesiumjs/ref-doc/Billboard.html`
-  - `https://cesium.com/learn/cesiumjs/ref-doc/BillboardCollection.html`
-  - `https://cesium.com/learn/cesiumjs/ref-doc/PointPrimitiveCollection.html`
-  - `https://cesium.com/learn/cesiumjs/ref-doc/PointPrimitive.html`
-  - `https://cesium.com/learn/cesiumjs/ref-doc/Label.html` / `LabelCollection.html`
-  - `https://cesium.com/learn/cesiumjs/ref-doc/Scene.html#pick`
-
-### 4.2 三者的批渲染与单点开销(**机制**可证,**数值**无官方口径)
-
-| | `Entity` + `billboard`/`label` graphics | `BillboardCollection` | `PointPrimitiveCollection` |
-| --- | --- | --- | --- |
-| 声明位置 | `CES:21784 export class Entity {`(文档 `:21778-21783`「aggregate multiple forms of visualization into a single high-level object」) | `CES:27414 export class BillboardCollection {` | `CES:42804 export class PointPrimitiveCollection {` |
-| 元素类 | 不是元素:`Entity.billboard` `CES:21836`、`label` `:21864`、`point` `:21889`、`properties` `:21905`、`id` `:21801`(**注意 `Entity.id` 是 `string`**) | `Billboard` `CES:27064` | `PointPrimitive` `CES:42667` |
-| GPU 侧实际渲染 | **同一批 collection**:`BillboardVisualizer`(`CES:19715-19721`「maps `Entity#billboard` to a `Billboard`」)、`LabelVisualizer`(`CES:23375-23382`)、`PointVisualizer`(`CES:24089-24095`);`DataSourceDisplay.defaultVisualizersCallback()` `CES:21295` 默认挂上三者 | 整集合**一次 instanced draw**:`ENG/Scene/BillboardCollection.js:2089 command.count = 6;`、`:2090 command.instanceCount = billboardsLength;`;instanced ⇒ 恒 1 个 VA(`ENG/Renderer/VertexArrayFacade.js:344`) | `ENG/Scene/PointPrimitiveCollection.js:1154 command.primitiveType = PrimitiveType.POINTS;`、`:496` 注释 `// 1 vertex per pointPrimitive`、**不传 indexBuffer** ⇒ 1 个 VA ⇒ 一次 draw |
-| draw call 数(默认混合模式) | 同上(取决于它落到哪个 collection) | `OPAQUE_AND_TRANSLUCENT` 时 **2 个命令**:`ENG/Scene/BillboardCollection.js:2063 const totalLength = opaqueAndTranslucent ? vaLength * 2 : vaLength;` | 同理按 blend pass 分:`ENG/Scene/PointPrimitiveCollection.js:1155-1156`(`Pass.OPAQUE` / `Pass.TRANSLUCENT`) |
-| 官方 `Performance:` 口径 | 无 Entity 级条目 | 见下 | 见下 |
-| 每元素更新开销 | 每 Entity 一套 JS graphics 对象 + 每帧 Visualizer update(`BillboardVisualizer.update` d.ts `:19728`、`LabelVisualizer.update` `:23389`、`PointVisualizer.update` `:24102`);标签还有字形生成/缓存(`ENG/Scene/LabelCollection.js:96 createGlyphCanvas`) | 逐 billboard 写入 **CPU→GPU traffic**,且**与改了几个属性无关**;改动多时「清空重建」可能更省 | `add`/`remove` 期望常数时间,但会**整块重写顶点缓冲(O(n) + CPU→GPU 开销)**;大批量应先 `add` 完再 `update` |
-| 可调的性能杠杆 | — | `blendOption`(全不透明或全半透明可提升**最多 2x**)d.ts `:27472`;同标识符的图片**自动共享纹理** d.ts `:27390-27391` | `blendOption`(同 2x)d.ts `:42852`;`add` 的官方建议见下 |
-| 附带事实 | `DataSourceDisplay.defaultVisualizersCallback()` `CES:21295` 默认挂上三个 Visualizer;`EntityCluster` `CES:21984` 负责懒建集合(`clusterBillboards` `:22017` / `clusterLabels` `:22021` / `clusterPoints` `:22025`) | `BillboardCollection` 构造器需要 `scene` 才能贴地或对地球做深度测试(`CES:27406`) | — |
-
-**出处原文(官方 ref-doc,逐字要点):**
-
-- `Billboard` 页 `Performance:` —— "Reading a property, e.g., `Billboard#show`, is constant time. Assigning to a property is constant time but results in CPU to GPU traffic when `BillboardCollection#update` is called. **The per-billboard traffic is the same regardless of how many properties were updated.** If most billboards in a collection need to be updated, it may be more efficient to clear the collection with `BillboardCollection#removeAll` and add new billboards instead of modifying each one."(`https://cesium.com/learn/cesiumjs/ref-doc/Billboard.html`)
-- `PointPrimitiveCollection` 页 `Performance:` —— "For best performance, prefer **a few collections, each with many points**, to many collections with only a few points each. Organize collections so that points with the same update frequency are in the same collection."(`https://cesium.com/learn/cesiumjs/ref-doc/PointPrimitiveCollection.html`)
-- `PointPrimitiveCollection#add` 页 `Performance:` —— "Calling `add` is expected constant time. However, the collection's vertex buffer **is rewritten - an `O(n)` operation that also incurs CPU to GPU overhead**. For best performance, add as many pointPrimitives as possible before calling `update`."
-- `PointPrimitiveCollection#remove` 页 —— 同理 O(n) 重写;官方明确「只想临时隐藏点时,用 `PointPrimitive#show` 比 remove + re-add 更省」。
-- `LabelCollection` **自己没有绘制路径**,它建在两个 `BillboardCollection` 上:`ENG/Scene/LabelCollection.js:630`(`_backgroundBillboardCollection`)、`:643`(`_glyphBillboardCollection`)、`:274`(**每个字形一个 billboard**)、`:1049-1050`(两者各自 `update(frameState)`)。→ 文本标签的 GPU 成本 ≈ 字形数 × billboard,但仍落在 billboard 批里(`Label` 的 GPU 侧没有独立通道)。
-
-**诚实标注(本票不编数):** Cesium 官方文档与随包源码给出的都是**复杂度与机制**(1 次 draw / O(n) 重写 / 逐元素 traffic 恒定),**没有**「N 个点多少毫秒」这类数值,随包 d.ts 里连 `@performance` 段落都被删了。故本票**不给**量级数字;量的判断属于票 02(特效开销预算)与本图 08(原型)的走查。
-
-### 4.3 能否单独拾取(不与监测点标记抢)
-
-**结论:三者都能逐元素拾取,且拾取互不干扰;但「谁被拾到」由遮挡关系与拾取矩形决定,必须显式处理重叠。**
-
-- 每个元素都有自己的 `id`,这就是拾取时回给你的东西:
-  - `Billboard.id: any` `CES:27286`,option 文档 `CES:26994`「A user-defined object to return when the billboard is picked with `Scene#pick`」
-  - `Label.id: any` `CES:38568`(文档 `:38566`)
-  - `PointPrimitive.id: any` `CES:42755`(文档 `:42753`「the user-defined value returned when the point is picked」)
-  - `Entity.id: string` `CES:21801`(**类型不同**:Entity 路径下拾取回来的 `id` 是 Entity 本身,见下)
-- **一次 draw call 下仍能逐实例拾取**:pick color 是逐元素的 → `ENG/Scene/BillboardCollection.js:1096-1098`(batchTable 或顶点属性)、`ENG/Scene/PointPrimitiveCollection.js:556` + `:568 AttributeCompression.encodeRGB8(pickColor)` + `:1169 command.pickId = "v_pickColor"`。
-- 拾取在**离屏 pick framebuffer** 里做,结果进 `Set` 去重:`ENG/Scene/PickFramebuffer.js:56-98`(`:61 const objects = new Set();`)→ **互不干扰**。
-- API 签名与语义:
-  - `Scene.pick(windowPosition, width?, height?): any | undefined` `CES:44947`;文档 `CES:44928-44930`「contains **the first (top) primitive** in the scene at a particular window coordinate」;默认拾取矩形 `3×3`(`CES:44943-44944`)。
-  - `Scene.drillPick(windowPosition, limit?, width?, height?): any[]` `CES:45054`;文档 `CES:45042-45045`「**all** primitives at a particular window coordinate ... ordered by their visual order in the scene (**front to back**)」。
-  - `Scene.pickAsync` `CES:44986`;`Scene.pickPosition` `CES:45040`。
-- `pickedObject` 的形状固定为 `{ primitive, id, ... }`:`ENG/Renderer/Context.js:1679-1682` 的官方示例即 `context.createPickId({ primitive: this, id: this.id })`;渲染侧 `ENG/Scene/PickFramebuffer.js:78 const object = context.getObjectByPickColor(pickColor);`。
-- Entity 路径下 `id` 就是 Entity:`ENG/DataSources/BillboardVisualizer.js:127-128`(`billboard = cluster.getBillboard(entity); billboard.id = entity;`)、`ENG/DataSources/LabelVisualizer.js:138`、`ENG/DataSources/PointVisualizer.js:148`。
-
-**「抢不抢」的实际裁决点(仓库现状,这是票 06 要面对的既成事实):**
-
-`FE/services/cesiumEngine.js:404-431` 的 `pickPointMarkerAt()`:
-
-```
-417: export function pickPointMarkerAt(viewer, windowX, windowY) {
-418:     if (!viewer || !viewer.scene || !viewer.canvas || typeof viewer.scene.pick !== 'function') return null
-420:     const rect = viewer.canvas.getBoundingClientRect()
-421:     const picked = viewer.scene.pick(new Cartesian2(windowX - rect.left, windowY - rect.top))
-422:     const entity = picked && picked.id
-423:     const properties = entity && entity.properties
-424:     if (!properties || !properties.pointCode) return null
-```
-
-- 用的是 **`scene.pick`(单个,最上层)**,**没有传 width/height**(走默认 3×3),**没有用 `drillPick`**(整个 `src/` grep `drillPick` → 0 命中)。
-- 判据是 **实体属性里有 `pointCode`**(`:424`),注释 `:408-409` 明说「只认点位标记数据源上的实体 —— 拾到地形、影像、3D Tiles 或空白一律回 `null`」。
-- 因此:**一个只有 `deviceCode` 属性、没有 `pointCode` 的设备实体,在现有这条链路上会被判为 `null`(不抢)**;反过来说,**它也无法被现有链路拾到** —— 要支持设备拾取,就得扩充这条判据或另开一个入口(票 06 的范围)。
-- 重叠时的行为是确定的:`scene.pick` 只回最上层那一个(`:44928-44930`),**同像素下的另一个拾不到**;要区分就必须换 `drillPick`。这是「同一监测点下多台设备坐标重叠」时必须先裁决的事实(对应本图 `## Not yet specified` 第 1 条)。
-- 一个相关开关:`Scene#pickTranslucentDepth`(`ENG`/d.ts 中 `pickPosition` 的文档 `CES:45033`「Set `Scene#pickTranslucentDepth` to `true` to include the depth of translucent primitives; otherwise, this essentially picks through translucent primitives」)。该文档挂在 `pickPosition` 上,**不要外推到 `pick`**;但半透明特效与拾取的关系需要票 02/06 专门处理。
-
-**仓库现状补充:** `src/` grep `BillboardCollection|PointPrimitiveCollection|LabelCollection` → **0 命中**。点位目前**只用** Entity + `billboard` graphic(`FE/services/cesiumEngine.js:319-362` 的 `syncPointMarkers()`,`:335-357` 的 `source.entities.add({...})`;数据源常量 `:267 POINT_MARKER_SOURCE`,`pointMarkerSource()` `:286-301`)。DOM 手势层在 `FE/services/pointPick.js`(`:12 CLICK_MAX_DRAG_PX = 4`、`:20 isClickGesture`、`:35 attachPointPick`、`:52 onPick`),只做「位移 ≤ 4px 算点选」判定并输出窗口坐标,不 preventDefault、不抢 pointer capture。接线在 `FE/services/mapService.js:380`(`attachPointPick(record.host, ({x,y}) => pickPointAt(x,y))`)与 `:796-806`(`:799` 调 `pickPointMarkerAt`)。
-
-### 4.4 能否贴地(`heightReference`)
-
-**支持矩阵(随包 d.ts,行号可复现):**
-
-| 目标 | `heightReference` | 证据 |
-| --- | --- | --- |
-| `Entity.billboard` → `BillboardGraphics` | ✅ | option `CES:19517`、属性 `:19617`、option 文档 `:19494` |
-| `Entity.label` → `LabelGraphics` | ✅ | option `:23196`、属性 `:23314` |
-| `Entity.point` → `PointGraphics` | ✅ **但见下方陷阱** | option `:24003`、属性 `:24036` |
-| `BillboardCollection` 的 `Billboard` | ✅ | option `CES:27027`、属性 `:27078`、option 文档 `:27002` |
-| `LabelCollection` 的 `Label` | ✅ | option `:38343`、属性 `:38376` |
-| `PointPrimitiveCollection` 的 `PointPrimitive` | ❌ **没有** | `PointPrimitive` 全类 `CES:42667-42755`:`show`/`position`/`scaleByDistance`/`translucencyByDistance`/`pixelSize`/`color`/`outlineColor`/`outlineWidth`/`distanceDisplayCondition` `:42746`/`disableDepthTestDistance` `:42751`/`id` `:42755`/`splitDirection` —— **无 `heightReference`** |
-
-**`HeightReference` 枚举** `CES:36770`(文档 `:36767-36769`「Represents the position relative to the terrain.」):`NONE = 0` `:36774`、`CLAMP_TO_GROUND = 1` `:36778`、`RELATIVE_TO_GROUND = 2` `:36782`、`CLAMP_TO_TERRAIN = 3` `:36786`、`RELATIVE_TO_TERRAIN = 4` `:36790`、`CLAMP_TO_3D_TILE = 5` `:36794`。
-
-**实现侧交叉验证(`ENG/` 源码里 grep `heightReference` 的命中数):** `Scene/PointPrimitive.js` → **0**、`Scene/PointPrimitiveCollection.js` → **0**;而 `Scene/Billboard.js` → 19、`Scene/BillboardCollection.js` → 8、`Scene/Label.js` → 12、`Scene/LabelCollection.js` → 8。
-
-**关键陷阱(Entity 的 `point.heightReference` 是「借 billboard 冒充」):** `ENG/DataSources/PointVisualizer.js:117-130`
-
-```
-119:     if (heightReference !== HeightReference.NONE && !defined(billboard)) {
-120:       if (defined(pointPrimitive)) {
-121:         returnPrimitive(item, entity, cluster);
-122:         pointPrimitive = undefined;
-123:       }
-124:
-125:       billboard = cluster.getBillboard(entity);
-126:       billboard.id = entity;
-127:       billboard.image = undefined;
-```
-
-`:230 billboard.heightReference = heightReference;`;`:138-149` 在 `heightReference === NONE` 时才回落到真正的 `cluster.getPoint(entity)`。
-
-→ **想用 `PointPrimitiveCollection` 实现贴地是做不到的**(原语本身没有这个属性);Entity 的 `point` 一旦要贴地,引擎内部就把它换成一个**无图 billboard**。这个结论在随包实现源码与 `Build/CesiumUnminified/Cesium.js:197909-197927` 中一致。
-
-**构造器要求(贴地/深度测试的前提):** `BillboardCollection` 的 `options.scene` 文档 `CES:27406`「Must be passed in for billboards that use the height reference property or will be depth tested against the globe」;`LabelCollection` 同款 `CES:38647`。`PointPrimitiveCollection` 的构造器 `CES:42805-42810` **只有 `modelMatrix` / `debugShowBoundingVolume` / `blendOption` / `show`,没有 `scene`** —— 与「不支持贴地」自洽。Entity 路径下这个 `scene` 由 `ENG/DataSources/EntityCluster.js:661-665`(`new CollectionConstructor({ scene: this._scene })`)保证。
-
-**贴地相关的抗裁剪参数:** `BillboardCollection.coarseDepthTestDistance` `CES:27491` / `threePointDepthTestDistance` `:27503`;`LabelCollection.threePointDepthTestDistance` `CES:38737`;文档说明 `threePointDepthTestDistance` 管的是「billboard/label 的 `heightReference` 为 `CLAMP_TO_GROUND`/`CLAMP_TO_TERRAIN` 时,在相机距离内改用三点深度测试,保证任一点可见即整块可见」(`CES:27412-27413`、`:38653`)。`PointPrimitiveCollection` **没有**这两个参数。
-
-### 4.5 `disableDepthTestDistance` 与 `distanceDisplayCondition` 的可用性
-
-| 目标 | `distanceDisplayCondition` | `disableDepthTestDistance` |
-| --- | --- | --- |
-| `BillboardGraphics` | ✅ option `CES:19528` / 属性 `:19691` | ✅ option `:19529` / 属性 `:19696` |
-| `LabelGraphics` | ✅ option `:23203` / 属性 `:23355` | ✅ option `:23204` / 属性 `:23360` |
-| `PointGraphics` | ✅ option `:24009` / 属性 `:24065` | ✅ option `:24010` / 属性 `:24070` |
-| `Billboard` | ✅ option `:27038` / 属性 `:27275` | ✅ option `:27039` / 属性 `:27282` |
-| `Label` | ✅ option `:38350` / 属性 `:38558` | ✅ option `:38351` / 属性 `:38564` |
-| `PointPrimitive` | ✅ 属性 `CES:42746` | ✅ 属性 `CES:42751` |
-| 集合级 | — | `BillboardCollection` 的 `coarseDepthTestDistance` `:27491` / `threePointDepthTestDistance` `:27503` |
-
-`PointPrimitive` 这两项**可用但不在 d.ts 的 `add()` option 模板里**(`add(options?: any)` `CES:42883`),实现里确实生效:`ENG/Scene/PointPrimitive.js:49-53`(`disableDepthTestDistance` 校验)、`:60-103`(`distanceDisplayCondition` 校验/克隆)、`:398-440`(两个 accessor)。
-
-语义(`Billboard` 版,`CES:27277-27281`):「When set to `undefined` or `0`, the depth test is always applied. When set to `Number.POSITIVE_INFINITY`, the depth test is never applied.」`Label` `CES:38560-38563`、`PointPrimitive` `:42748-42751`、`BillboardGraphics` `:19693-19694` 同义。集合级 `coarseDepthTestDistance` 的文档 `CES:27482-27490` 说明它**只在 `disableDepthTestDistance` 允许深度测试时才生效**。
-
-**仓库现状(既成事实,设备点位要与它一致或显式分歧):** `FE/services/cesiumEngine.js:335-357` 的 billboard 同时写了
-
-```
-344:                 heightReference: HeightReference.CLAMP_TO_GROUND,
-346:                 disableDepthTestDistance: Number.POSITIVE_INFINITY,
-```
-
-(`:310-311` 注释说明动机:有 DEM,否则点位浮在山体上方或埋进去。)device 点位若要视觉一致,这两个取值就是现成的对照基准。
-
----
-
-## 5. 设备级资产清单
-
-### 5.1 glTF / GLB 等 3D 模型资产:**无设备级模型**
-
-全仓 glob `**/*.{glb,gltf,fbx,obj,b3dm,i3dm,pnts,cmpt,3dtiles,ktx2,dae,drc}` 命中 45 个文件,**全部在 `public/plugins/**`**(`dist/plugins/**` 是构建镜像,`node_modules/draco3d/bunny.drc` 是依赖自带)。`src/`、`tools/`、`preview/`、`pluginMaker/`、`docs/`、`.scratch/` **零命中**;**`src/assets/` 目录不存在**(`Test-Path` = False)。
-
-`public/` 下命中(路径 | 字节)——均为建筑/交通/星球/人体/道具:
-
-- `public/plugins/basic/htmls/model/model.gltf` | 550544 —— **扩展名骗人**:实为 GLB 二进制(头是 `glTF` magic + JSON chunk),含 `KHR_draco_mesh_compression`,generator `Khronos glTF Blender I/O v4.0.44`,节点名 `Bottom`;不是设备。
-- `public/plugins/basic/materials/raytracedDiamond/diamond.glb` | 9960
-- `public/plugins/digitalCity/model/{coneAnchorB 18132, spiral-middle 1460, tornado 4256}`、`digitalCity/volumetricFluidFire/demo-scene.glb` | 29448
-- `public/plugins/digitalPark/innovationHubAr/model/InnovationHubAR.glb` | 3331380;`digitalPark/model/{arctic_tooltip 178284, laboratoryBuild 200616, vr_sculpture_park 155416}`
-- `public/plugins/earthSample/model/lowpolyPlanet/{airplane.gltf 21598, cloud.gltf 11376, planet.gltf 727753}`
-- `public/plugins/eCommerce/model/{box 52140, pack 81068}`
-- `public/plugins/floor/models/topoBase/baseModel{A,B,C,D,E,F,G,H,L}.glb` | 4132 – 95928
-- `public/plugins/geokit/case-real-3/model/{chuan.glb 1806572(船), weixing.glb 360988(卫星)}`
-- `public/plugins/geokit/tiles/level_*_tile_*.b3dm` × 9 | 3333 – 190547
-- `public/plugins/hunyuan3D/model/icegl-bm.glb` | 305548
-- `public/plugins/industry4/model/lambo.glb` | 1230764(跑车)
-- `public/plugins/medical/model/brainStorm/brain.glb` | 408744
-- `public/plugins/UIdemo/model/arrowWhite2.5d.glb` | 155056
-- `public/plugins/visualArts/biineBee/models/bee.glb` | 1814280;`visualArts/model/{LeePerrySmith.glb 404976(人头), room-transformed.glb 428436}`
-- `public/plugins/water/model/staticWater.glb` | 58412
-
-**唯一「设备形状」的 glTF 只以远程 URL 存在于插件 demo 代码,仓库无本地副本:**
-
-- `src/plugins/zoneFreeScene/pages/freeRefiningIndustry.vue:90,100,102,104,106,110,112,114` —— `gasTank.glb` / `oilHeater.glb` / `tower.glb` / `oilTank.glb` / `machine.glb` / `tank2.glb` / `gtUnit.glb` / `flaringDevice.glb`,全部 `https://oss.icegl.cn/p/modelServer/models/2低像素炼油/...`
-- `src/plugins/zoneFreeScene/pages/freeShipSea.vue:67,69,71,73,75,77` —— `灯塔B.glb` / `货轮B.glb` / `游轮A.glb` / `风电机B.glb` / `风电机A.glb` / `浮标A.glb`(同域名)
-
-易误判项:`src/plugins/industry4/common/device.ts` + `components/device.vue` 名字里有 device,但读全文是 **UnrealBloom / LED 发光着色器 demo**,用的模型是 `lambo.glb`,不是设备资产。
-
-### 5.2 3D Tiles / city massing:与设备模型完全两条线
-
-仓库内 tileset json 只有 2 个:
-
-1. `public/plugins/geokit/tiles/tileset.json`(12913 B)—— 插件 demo 的倾斜摄影瓦片:`asset.version 1.0`、`gltfUpAxis: "Z"`、`geometricError 500.0`,配 9 个 `b3dm`。引用点:`src/plugins/geokit/pages/case-3dtiles.vue:27`、`src/plugins/simpleGIS/components/tilesCom.vue:44`、`src/plugins/simpleGIS/components/obliquePhoto.vue:41`。
-2. `.scratch/cockpit-shell-panel-layout/verify/fixtures/tiles/tileset.json`(739 B)—— 走查夹具,**只有包围盒、无 content**,自述见 `:3-7`。
-
-驾驶舱加载链(现状参考):`FE/domain/mapLayers.js:81-90`(`planTilesets()`)→ `FE/services/cesiumEngine.js:20-21`(import `Cesium3DTileset` / `Cesium3DTileStyle`)、`:204-223`(`applyTilesets()`,`:214 await Cesium3DTileset.fromUrl(...)`)、`:180-197`(`readTilesetDiagnostics()`)、`:235-255`(`applyViewModeLayers()` 用 `Cesium3DTileStyle` 调 alpha);入口注释 `FE/services/mapService.js:110` 与 `:896`。
-
-**票 34 的 city massing 资产本体不在仓库**,是 repo 外生成物:生成器 `tools/city-massing/build-town.mjs`(`:19-20` 产物 `<DATA_DIR>/{town.glb,tileset.json,meta.json}`;`:25-27` `GIS = 'D:/GIS/GIS'`、`DATA_DIR`;`:484` 写 `town.glb`;`:503-517` 写 `tileset.json`);回验 `tools/city-massing/verify-town.mjs`(`:29` 读 `town.glb`、`:149-150` 读 tileset `transform`);说明 `tools/city-massing/README.md:48-60`。运行时靠 `.env.local:11`(`{"prefix":"/gis/city","dir":"D:/GIS/GIS/cityMassing"}`)与 `public/cockpit/config.json:42-47` 的 `"tilesets":[{"url":"/gis/city/tileset.json"}]` 挂载(两者都被 `.gitignore` 排除)。票据 `.scratch/cockpit-shell-panel-layout/issues/34-lightweight-city-massing.md:23,46,73`。
-
-→ **仓库里 0 个 city massing 资产文件**,且它是「示意建筑体块」,与设备级模型无关。
-
-### 5.3 驾驶舱的 marker / legend 图标:**运行时以内联 SVG 生成,不是 canvas 绘制**
-
-- `FE/domain/icons.js:12-57` —— 内联 SVG 字符串图标集;渲染在 `FE/components/CockpitIcon.vue:8-18`,`:31 iconFor(props.name)`。
-- `FE/domain/pointStatus.js:42-59` —— `SHAPES` 内联 SVG 片段(12×12 viewBox,仅 `currentColor`);`:217-223 markerSvg()` 打成 `data:image/svg+xml;charset=utf-8,...`。
-- `FE/services/cesiumEngine.js:335-347` —— 作为 `billboard.image` 挂到 Cesium 实体,`:341-342 width: 18, height: 18`。
-- `FE/services/mapService.js:639-651` —— `toneColors()` 从 `.cockpit-shell` 的 `getComputedStyle` 读 `--lv1..--lv4/--neutral`(`:647 styles.getPropertyValue('--'+tone)`;`:631-637` 有同值兜底色);`:670-676` 调 `syncPointMarkers(..., (shape,color) => markerSvg(shape,color,18))`。
-
-**修正一处常见假设:** `FE/` 下 grep `OffscreenCanvas|drawImage|toDataURL|createElement('canvas')|devicePixelRatio` 在**绘制语义上 0 命中**(`canvas` 的命中全指 Cesium 自己的 `viewer.canvas`)。所以**没有离屏 canvas 光栅化**;是纯 SVG data-URL,尺寸由 `billboard.width/height = 18` 像素决定,与 `devicePixelRatio` 无关。
-
-### 5.4 静态图标文件(与设备无关)
-
-- `public/logo.png` | 33245、`src/images/icon.png` | 5586、`public/plugins/simpleGIS/image/icon.png` | 1440、`public/cockpit/aerial.webp` | 517078(`FE/components/MapCanvas.vue:12` 用作中央一张图的占位底图)。
-- **全仓唯一「地图点位图标」现成样例**:`public/plugins/geokit/case-real-3/icons/satellite.svg` | 1124、`ship.svg` | 600(24×24,圆底 + 白色 glyph),被 `src/plugins/geokit/pages/case-real-3.vue:51-53` 的 `<GeoIcon icon="…/satellite.svg" :size="16" :point="weiXingPosition" />` 使用。**注意这是 three.js / Tres 的 `@icegl/geokit` 组件(实现只在 `node_modules`),不是 Cesium 链路**,只可作视觉参照。
-- 无关 demo 资产:`public/plugins/industry4/icon/c1..c9.webp`(车漆皮肤按钮,`src/plugins/industry4/components/su7/UIcarSkin.vue:12,25`)、`industry4/preview/deviceLight*.png`(案例封面,`src/plugins/industry4/config.js:22-23`)、earthSample 地球贴图、`operationTool/preview/*.png` 中文预览图、各插件 `preview/*.png` 封面。
-- **`alarm` / `status` / `equip` 命名的设备状态图标:查不到。**(图片名匹配 `icon|marker|device|point|equip|alarm|status|legend` 与 `点|标|图例`,范围 `public/`、`public/plugins/`、`src/`。)
-- `FE/` 递归搜索 `*.png|*.svg|*.jpg|*.webp|*.gif|*.ico` → **0 个图片文件**。驾驶舱点位不依赖任何图片图标。
-
-### 5.5 `FE/domain/icons.js` 的形状清单(全 67 行)
-
-共 **6 个**图标,每项为 `{ viewBox, inner }`,`inner` 是**内联 SVG 标记字符串**,一律 `currentColor`(无任何色值),无 canvas、无外部图片文件、无 `devicePixelRatio` 逻辑:
-
-| 键名 | 行 | viewBox | 内容 |
-| --- | --- | --- | --- |
-| `brand` | `:14` | `0 0 32 32` | 同心环 + 十字轴线 |
-| `layers` | `:23` | `0 0 16 16` | 图层叠片 |
-| `points` | `:30` | `0 0 16 16` | 双圆环 + 实心点(**通用几何标记,非设备形状**) |
-| `event` | `:37` | `0 0 16 16` | 三角 + 感叹号 |
-| `resource` | `:44` | `0 0 16 16` | 库房 / 处置力量 |
-| `blank` | `:51` | `0 0 48 32` | 空框 + 折线(面板占位) |
-
-导出:`ICON_NAMES` `:59`、`isKnownIcon` `:61`、`iconFor` `:65`。尺寸由调用方给:`FE/components/CockpitIcon.vue:27` 的 `size` 默认 `'1em'`(`:11-12` 绑到 `width`/`height`)。文件头 `:4-7` 自述「先用内联 SVG 字符串而不是图片文件……正式稿由设计公司交付」。
-
-### 5.6 `FE/domain/pointStatus.js` 的 `SHAPES`(全 223 行)
-
-状态词表 `POINT_STATUS` `:27-34`(6 个,原样后端字符串):`ALARM` `:28`、`WARNING` `:29`、`WORK_ORDER` `:30`、`NORMAL` `:31`、`OFFLINE` `:32`、`EMPTY` `:33`。
-
-**形状注册表 `SHAPES` `:42-59`,8 个键(verbatim):**
-
-| 形状名 | 行 | 定义 |
-| --- | --- | --- |
-| `triangle` | `:44` | 实心三角 |
-| `diamond` | `:46` | 实心菱形 |
-| `square` | `:48` | 实心方块(`rx="1"`) |
-| `circle` | `:50` | 实心圆 |
-| `ring` | `:52` | 空心圆环(`stroke-width="2"`) |
-| `hollowSquare` | `:54` | 空心方块 |
-| `hollowDiamond` | `:56` | 空心菱形 |
-| `cross` | `:58` | 十字 |
-
-头部注释 `:37-40`:内联 SVG 片段、12×12 视框、只用 `currentColor`、**不含任何颜色值**;同一份片段被地图图例与地图标记共用。
-
-**状态 → 视觉映射 `POINT_STATUS_VISUALS` `:78-85`:**
-
-| 状态 | tone | shape | label | priority | 行 |
-| --- | --- | --- | --- | --- | --- |
-| `ALARM` | `lv1` | `triangle` | 报警 | 1 | `:79` |
-| `WARNING` | `lv2` | `diamond` | 预警 | 2 | `:80` |
-| `WORK_ORDER` | `lv3` | `square` | 工单处置 | 3 | `:81` |
-| `NORMAL` | `lv4` | `circle` | 正常 | 4 | `:82` |
-| `OFFLINE` | `neutral` | `ring` | 离线 | 5 | `:83` |
-| `EMPTY` | `neutral` | `hollowSquare` | 无设备 | 6 | `:84` |
-| (`UNKNOWN_VISUAL`) | `neutral` | `hollowDiamond` | 状态未发布 | 7 | `:88` |
-| (`UNASSOCIATED_VISUAL`) | `neutral` | `cross` | 无关联设备 | 8 | `:91` |
-
-其它:`DEFAULT_SHAPE = 'circle'` `:61`、`isKnownShape` `:63`、`shapeSvg` `:68`、`visualForStatus` `:94`、`pointVisual` `:105`、`planPointMarkers` `:121`、`summarizeMarkerStatuses` `:147`、`legendVisuals` `:157`、`pointLegend` `:172`、`legendMatchesMarkers` `:184`、`markerSvg` `:217`。`planPointMarkers()` 的 marker 对象带 `longitude` `:138` / `latitude` `:139`(`:101-102` 注释解释「这里不读坐标,能不能上图是 `domain/coordinate.js` 的事」)。
-
-**是否设备形状?——否。** 8 个全部是**通用几何标记**(实心/空心 的圆-环-方-菱形 + 三角 + 十字),**没有** box / camera / valve / pump / 设备箱 之类的设备 glyph。连「无设备」(`EMPTY`)用的也是几何形状 `hollowSquare`(`:84`)。
-
-### 5.7 `FE/styles/`
-
-目录里只有 1 个文件 `FE/styles/tokens.css`(2870 B,71 行)。点位 / 设备状态语义色(全部锚在 `.cockpit-shell` `:15`):
-
-| 自定义属性 | 行 | 值 |
-| --- | --- | --- |
-| `--lv1` | `:34` | `#ff4d5e` |
-| `--lv2` | `:35` | `#ffa63d` |
-| `--lv3` | `:36` | `#4fd8ff` |
-| `--lv4` | `:37` | `#35e39a` |
-| `--neutral` | `:38` | `#6d92ae` |
-
-`:31-33` 注释明确「语义色:事件四级与点位状态共用一套调色板(ADR 0006 / 0008)」。其它 token:`--u` `:17`、`--bg-0` `:20`、`--panel-top/-bot/-line/-line-hi` `:21-24`、`--inset` `:25`、`--cyan/--cyan-d` `:28-29`、`--text/--text-2/--muted` `:41-43`、`--fs-caption/--fs-body/--fs-title/--fs-heading` `:50-53`、`--space-1..3` `:56-58`、`--column-w` `:62`、`--z-map/--z-columns/--z-overlay` `:65-67`、`--shadow` `:70`。
-
-配套:事件四级色键 `FE/domain/statusTones.js:14`(`TONES`)、`:19-24`(`LEVEL_TAGS`);`FE/components/StatusTag.vue:86-90` 用 `var(--lv1..--neutral)` 落到 `--tag-color`。
-
-### 5.8 `tools/`
-
-递归只有 3 个文件:`tools/city-massing/build-town.mjs`(23899 B)、`tools/city-massing/verify-town.mjs`(9246 B)、`tools/city-massing/README.md`(4076 B)。
-
-- `build-town.mjs` = **城市体块**模型生成器(`:19-20` 用法与产物、`:25-27` 读本机 `D:/GIS/GIS` 的 OSM buildings/roads/waterways + `resa.json` + `terrain-grid.json`、`:387-390` 写 glTF JSON、`:484 writeGlb(...'town.glb'...)`、`:503-517` 写 `tileset.json`)。
-- `verify-town.mjs` = 回验脚本(不生成资产)。
-- **没有任何生成 marker 图片或下载模型的脚本**:`tools/` 下 grep `marker|icon|\.png|\.svg|\.jpg|download|fetch\(` → **0 命中**。
-
-### 5.9 设备级模型资产的一句话裁决
-
-> **No —— 仓库内不存在任何现成的设备级 glTF/GLB 模型资产。** 45 个 3D 文件的文件名与(小 JSON 及二进制头的)内容全部指向建筑、车辆、船只、卫星、星球、人体、道具与倾斜摄影瓦片;唯一「设备形状」的 glTF(`gasTank` / `oilTank` / `machine` / `tower` / `flaringDevice` / `风电机` / `灯塔` / `浮标`)只以 `https://oss.icegl.cn/...` **远程 URL** 写在 `src/plugins/zoneFreeScene/pages/*.vue` 的 demo 里,本地无副本。设备点位当前**完全依赖 SVG 形状**(`FE/domain/pointStatus.js:42-59` 的 8 个几何形状)+ `FE/styles/tokens.css:34-38` 的色键,烘焙成 `data:image/svg+xml` billboard 图片(`FE/domain/pointStatus.js:217-223`、`FE/services/cesiumEngine.js:335-347`)。驾驶舱内也**没有**任何 glTF 实体加载代码(`FE/` 内 `.glb|.gltf|GLTF|GLB|Model.` 仅 1 处注释命中 `FE/domain/mapLayers.js:73`)。
-
----
-
-## 6. 明确「查不到」的清单与查证范围
-
-| 查不到的东西 | 查过的地方与模式 |
-| --- | --- |
-| 后端任何「一次给出点位下全部设备经纬度 + 校验 + 状态」的端点 | `ADMIN/web/controller/cockpit/` 全部 20 个 `@GetMapping`;`docs/cockpit/README.md:27-61` 的端点清单;`docs/cockpit/*.html` 26 个 demo 页(重点 `monitoring-point-detail-demo.html`、`map-snapshot-demo.html`、`equipment-archive-demo.html`) |
-| 后端设备级相对位置 / 同井同杆 / 安装高度 / 朝向 | `pipe-network-service/` 全模块 grep `同井\|同杆\|杆塔\|relativePosition\|offsetX\|offsetY\|mountHeight\|installHeight\|poleCode\|wellCode` → 0 命中;`pipe-network-service/sql/` 全目录 grep `offset\|height\|azimuth\|heading\|elevation\|altitude\|relative` → 0 命中 |
-| 后端把「设备坐标 == 点位坐标」写成契约的地方 | `docs/cockpit/README.md` 全文;`CockpitMonitoringDistributionSnapshot.java:9-19` 与 `CockpitMapSnapshot.java` 的类注释;`CockpitCoordinateValidator.java` 全文(只做单点范围校验,不做两点相等校验) |
-| `equipment_point_rel` 之外的设备-点位关系表 | 全后端 grep `equipment_point_rel\|monitor_point` → 只命中 1 个 SQL、1 个实体、1 个 mapper、1 个 WS 监听器与 1 个测试 |
-| Cesium 随包 d.ts 里的 `@performance` / 「一次 draw call」文字 | `CES` 全文 51,152 行 grep `@performance` → 0 命中;grep `one draw call` / `single draw` / `batched` / `draw call` → 只命中 3D Tiles 与 Provider 的无关批处理 |
-| `node_modules/cesium/Build/Cesium/Cesium.d.ts` | `Test-Path` = False;全仓 glob `**/Cesium.d.ts` 只命中 `node_modules/cesium/Source/Cesium.d.ts` |
-| `PointPrimitive.heightReference` | `CES:42667-42755` 全类无该属性;`ENG/Scene/PointPrimitive.js` 与 `PointPrimitiveCollection.js` 各 grep `heightReference` → 0 命中;官方 `PointPrimitiveCollection` / `PointPrimitive` ref-doc 页无该项 |
-| `PointPrimitiveCollection` 的 `scene` / `coarseDepthTestDistance` / `threePointDepthTestDistance` | 构造器 `CES:42805-42810` 只有 4 个 option |
-| Cesium 官方给出的「N 个点耗时多少 ms」数值 | 随包 d.ts(`@performance` 已删)、`ENG/` 实现源码、官方 ref-doc 三处都只有复杂度与机制,**无实测数值** |
-| 仓库内设备级 glTF/GLB | 全仓 glob 各扩展名 + 逐名判定 + 小 JSON/GLB 头内容核查(见 §5.1) |
-| 仓库内 city massing 资产文件 | 全仓 glob `.glb/.gltf/.b3dm` + 文本 grep `cityMassing\|city-massing` → 只有工具/文档/票据/配置引用,产物在 repo 外 `D:/GIS/GIS/cityMassing` |
-| `src/assets/` 目录 | `Test-Path` = False |
-| `FE/` 下的图片资产 | 递归 `*.png,*.svg,*.jpg,*.webp,*.gif,*.ico` → 0 个图片文件 |
-| 设备/状态专用图标(alarm / status / equip 形状) | 图片名匹配 `icon\|marker\|device\|point\|equip\|alarm\|status\|legend` 与 `点\|标\|图例`,范围 `public/`、`public/plugins/`、`src/` → 只有插件 demo 图 |
-| 离屏 canvas 光栅化 marker | `FE/` grep `OffscreenCanvas\|drawImage\|toDataURL\|createElement('canvas')\|devicePixelRatio` → 0 命中 |
-| 前端对 `deviceCodes` 的任何消费 | 全前端 `src/` grep `deviceCodes` → 0 命中 |
-| 前端对 `BillboardCollection` / `PointPrimitiveCollection` / `LabelCollection` / `drillPick` 的任何使用 | 全前端 `src/` grep 上述四项 → 0 命中 |
-
-**已知的未验证项(诚实标注):** 后端的真实数据适配器未落地(`SYS/cockpit/point/EmptyCockpitMonitoringPointDetailAdapter.java:8-10` 是空实现),因此「设备坐标与点位坐标实际差多少」「同一点位下多台设备坐标是否真的重叠」这类**数据事实**在本工作区无法观测,只能从 schema 与契约推断(§3.3)。按 `AGENTS.md`,本次未运行任何构建/调试命令,也未做浏览器走查。

+ 0 - 249
.scratch/cockpit-point-locate-and-device-link/research/device-status-effect-mechanisms.md

@@ -1,249 +0,0 @@
-# Research: 设备状态特效在 Cesium 的可用手段、能力边界与开销
-
-**Ticket:** [02 — 调研:设备状态特效在 Cesium 的可用手段与开销预算](../issues/02-research-device-status-effect-mechanisms.md)
-**Date:** 2026-09-18
-**Scope:** 只查事实,不做实现。未改 `src/`,未装依赖,未跑 build/dev/serve(仓库规则 `AGENTS.md`)。
-**引擎版本(本地实测):** `cesium@1.145.0`(`node_modules/cesium/package.json:3`)。包布局与 06 笔记一致:`cesium` 包是空壳,真实源码在 `node_modules/@cesium/engine/Source/`(`node_modules/cesium/Source/Cesium.js:8` 起全部 `export { … } from '@cesium/engine'`)。**本笔记的「路径:行号」在没有特别注明时都指 `node_modules/@cesium/engine/Source/…`。**
-
-## 结论
-
-1. **最省的原语是 `BillboardCollection`**(`Entity.billboard` 底层就是它):整个 collection 是**一次 draw call**(官方博客原话「render the entire billboard collection with a single drawElements call」,[Billboard Instancing](https://cesium.com/blog/2015/10/28/billboard-instancing/)),**每个 billboard 可按 `scale`/`color` 逐帧单独改**,改哪个只重传哪个(`_billboardsToUpdate` 脏列表,`BillboardCollection.js:152-153, 655-657, 1466-1507`)。可拾取(可关)。→ 10/50/200 个脉冲点用「同一张扩散圈图片 + `scale`/`color` 动画」是**低风险、量级安全**的做法。
-2. **`Entity` + `CallbackProperty` 是所有路线里最贵的一条**:`isConstant=false` 的属性会让 `EllipseGeometryUpdater._isDynamic` 判真(`EllipseGeometryUpdater.js:222-239`),进而走 `DynamicGeometryBatch`(**每个实体一个 updater**,`DynamicGeometryBatch.js:14-22`),而 `DynamicGeometryUpdater.update()` **每帧先 `removeAndDestroy` 再 `new Primitive({asynchronous:false})` 重建**(`DynamicGeometryUpdater.js:76-85, 146-153`)。社区实测(2020 年、用户报告、非受控基准):25 个 CallbackProperty 圆柱 ≈8 FPS、200 个 ≈0 FPS([论坛帖](https://community.cesium.com/t/using-multiple-callbackproperty-kills-performance-drops-to-zero-fps-code-repaired/10608))。→ 200 个动画 `ellipse` **不可行**;「按需只给 1–3 个当前聚焦设备上动效」才成立。
-3. **「一次 draw call 画 N 个脉冲」成立,且有两条实证路径**:
-   - **自定义 `Primitive` + N 个 `GeometryInstance` + 同一个 `Appearance`**:几何实例先合并成一个几何,再建 batch table(`Primitive.js:538-543, 1160-1161`),draw command 数 = `_va.length`(**每个顶点数组一条**,`Primitive.js:1868`),逐实例数据(颜色/show/自定义属性)走 **batch table 纹理**在顶点着色器里按 `batchId` 取(`Primitive.js:887, 942-963`)。200 个实例 = 1 条命令。
-   - **`BufferPointCollection`(1.145 新增,`@experimental`)**:显式「N 个点 = 1 次 `DrawCommand`」,`count: collection.primitiveCount` 一次画完(`renderBufferPointCollection.js:278-294, 307`)。但它只支持**屏幕像素尺寸的点 + 颜色/描边**(`BufferPointMaterialOptions` 只有 `color/outlineColor/outlineWidth/size`,`Cesium.d.ts:28094-28105`),**没有时间 uniform、也没有自定义顶点属性入口**,做不了「设计感」的扩散圈。**且 `BufferPointCollection` / `BufferPrimitiveCollection` 是 `@experimental`,「not final and subject to change without Cesium's standard deprecation policy」(`BufferPointCollection.js:56-57`)**——本驾驶舱按 ADR 纪律不宜押注在它上面。
-4. **与雾气后处理共存:可行,且顺序有确定规则。** 同一个 `scene.postProcessStages` 可以挂任意多个自建 stage(`PostProcessStageCollection.add()`,无上限、无 sort 参数)。执行顺序 = **加入顺序**:AO → Bloom → 色调映射 → 按 `_stages` 数组顺序跑自建 stage(每个吃前一个的输出)→ FXAA(`PostProcessStageCollection.js:799-834`;过滤在 `:583-593`;无插入索引 API,`_index = stages.length` 在 `:466`)。**两者互不干扰的关键约束:每个 stage 拿到的 `depthTexture` 都是「场景渲染输出的那一张」**(`execute()` 的 `depthTexture` 参数一路原样传递,`:801-827`),所以雾气 stage 与特效 stage **读的是同一份深度**,谁先谁后都能读;但**颜色是链式的**,后跑的 stage 会把前一个的输出当输入。→ 顺序必须显式对齐:**先特效、后雾气**(特效属于「被雾遮蔽的远处内容」),否则特效会盖在雾上面。
-5. **`Context.depthTexture` 更正沿用 06 结论**:它是**布尔能力标志**(`Context.js:714-725`),不是纹理。设备特效若只想做「不穿模的点位特效」,**根本不必自己读深度**:用 `Primitive` / billboard 的正常深度测试即可。若确实要读深度(例如特效要按距离淡出),正确路径就是 `PostProcessStage` 默认提供的 `sampler2D depthTexture` uniform(`PostProcessStage.js:526-531`)+ `czm_readDepth(depthTexture, v_textureCoordinates)`,由 Cesium 自动注入 `LOG_DEPTH` 并解码(06 笔记 §2 第 4 条已核实)。
-6. **色与形的单一来源可维持,但引擎侧有一条硬边界**:`tokens.css` 是 CSS 文件,**WebGL 渲染路径读不到 CSS 变量**。所以凡是引擎侧要的颜色,都必须像 `markerSvg()` 一样由**服务层从计算样式读出、烘焙成具体色值**再传下去(现成实现:`services/mapService.js:639-651` 的 `toneColors()` → `:670-676`)。引擎侧要 `Cesium.Color` / 数字 RGBA 的 API 有:`billboard.image`(data-URI SVG,色值必须烘进图片)、`Billboard.color`、`Material` uniform(`Material.js:1255-1278` 的 uniform 类型判定,Color 对象而非 CSS 串)、`PerInstanceColorAppearance` 的 `ColorGeometryInstanceAttribute`、`BufferPointMaterial.color`、`PostProcessStage` 的 `uniforms`。形状仍只来自 `pointStatus.js` 的 `SHAPES`(`pointStatus.js:42-59`)。
-7. **拾取:特效是否吃点击,取决于「有没有 pickId」,而不是「透明不透明」**。选拾取通道时,只有带 `derivedCommands.picking`(由 `command.pickId` 派生)或 `derivedCommands.depth` 的命令才会画(`Scene.js:2379-2404`,即 `executeIdCommand`);**没有 pickId 的实体命令在拾取通道里被无条件跳过**。因此:`Primitive` + `allowPicking:false`(`Primitive.js:76, 2055-2059`)、`BufferPointCollection` 默认 `allowPicking=false`(`BufferPointCollection.js:159`)**天然不吃点击**;反过来说,**只要给特效挂了 pickId,它就会挡住背后监测点/设备的拾取**(`Scene.pick` 只回最靠近相机的一个,靠 `Scene.drillPick` 才能穿透,而 drillPick 是「拾一个藏一个再拾」的多次拾取,`Picking.js:895-941`)。
-
-## 1. 可用原语逐项核实
-
-判定口径统一为四问:**能否按实体定位 / 能否只影响一个点 / 能否被拾取 / 是否每帧重算**。
-
-### 1.1 `Entity` + `CallbackProperty` 驱动的动画 billboard / ellipse / cylinder
-
-| 原语 | 按实体定位 | 只影响一个点 | 可拾取 | 每帧重算 |
-| --- | --- | --- | --- | --- |
-| `Entity.billboard` | 是(`position`) | 是 | 是(canvas 内非透明像素,`Billboard.getPickId` `Billboard.js:1104-1114`) | 否(除非属性非常量) |
-| `Entity.ellipse`(常量属性) | 是 | 是 | 是 | 否 |
-| `Entity.ellipse`(`CallbackProperty` 属性) | 是 | 是 | 是 | **是,且是「销毁 + 重建 Primitive」** |
-| `Entity.cylinder`(同上) | 是 | 是 | 是 | **是** |
-
-依据:
-
-- `CallbackProperty` 的语义:`new CallbackProperty(callback, isConstant)`,`isConstant` 必须显式给(`CallbackProperty.js:12-19, 30-33, 72-86`);`getValue(time)` 惰性求值(`:59-63`)。**`isConstant=false` 意味着「每帧都要重新求值」**,也就是每帧都要造新值。
-- 常量属性路径:`GeometryVisualizer` 按「geometry 类型 + appearance + shadowMode」分组进 `StaticGeometryColorBatch` / `StaticGeometryPerMaterialBatch` / `StaticOutlineGeometryBatch`(`GeometryVisualizer.js:56-120`),**多个实体可以合批**。
-- 动态属性路径:`EllipseGeometryUpdater._isDynamic()` 只要求 `!ellipse.semiMajorAxis.isConstant` 等任一条件成立(`EllipseGeometryUpdater.js:222-239`),就走 `DynamicGeometryBatch`,而它是 **`this._dynamicUpdaters.set(updater.id, updater.createDynamicUpdater(...))`——每个实体一个 updater**(`DynamicGeometryBatch.js:14-22`)。
-- 动态路径的每帧代价:`DynamicGeometryUpdater.prototype.update` **先 `primitives.removeAndDestroy(this._primitive)`,再 `new Primitive({ geometryInstances: createFillGeometryInstance(time), asynchronous: false })` 重新 add**(`DynamicGeometryUpdater.js:76-85, 139-153`)。`asynchronous:false` 还意味着几何在**主线程**生成。
-- 社区实测(**用户报告的观测值,不是受控基准;2020 年、其本机硬件**):1 个 CallbackProperty 圆柱 ≈55 FPS → 25 个 ≈8 FPS → 200 个 ≈0 FPS([Using multiple CallbackProperty: kills performance](https://community.cesium.com/t/using-multiple-callbackproperty-kills-performance-drops-to-zero-fps-code-repaired/10608))。同帖引 Cesium 官方维护者在 [issue #11315](https://github.com/CesiumGS/cesium/issues/11315) 的说法:「The API expects non-constant callback properties to be updated every frame, hence request a new frame to render」——即非常量 CallbackProperty 会**持续打破 `requestRenderMode` 的按需渲染**。
-- 关键推论(对「按状态放大/收缩的扩散圈」很重要):`semiMajorAxis` 是**几何输入**,不是着色器 uniform。想做出「世界尺度上不断扩大的圈」,就必须动几何 → 只能走动态路径。若只做「整体透明度/大小的呼吸」,不要用 `ellipse`,用 **billboard 的 `scale`/`color`**(见 1.5)。
-
-**结论:`Entity` + `CallbackProperty` 适合「同时只有 1–3 个设备在播特效」的场景;绝不适合 200 个点常驻动画。**
-
-### 1.2 `CustomDataSource` 与 `Primitive` + `GeometryInstance` + `Appearance`
-
-- **`CustomDataSource` 只是「一串 Entity + 一个名字」的组织容器**,自己不产生任何渲染原语。仓库已有用例:点位标记数据源 `POINT_MARKER_SOURCE`(`src/modules/cockpit/services/cesiumEngine.js:267, 286-301`),并且该文件 `:272-283` 记录了 `DataSourceCollection.add()` 异步注册的坑(同 tick 先写后读会建出第二个空数据源)。→ **设备特效若走 Entity/DataSource 路线,必须沿用同一份「WeakMap 缓存实例」的写法**,否则会重演「图例有计数、地图 0 个标记」。
-- `Primitive` 的批量能力(**这是「一次 draw call 画 N 个」的正统路径**):
-  - 构造参数 `geometryInstances` 可以是数组(`Primitive.js:67`)。
-  - N 个几何实例的几何会**合并成一个几何**(`Primitive.js:1160-1161, 1195-1214` 把实例分片喂给 worker 后合并),再为它们建一张 **batch table**(`createBatchTable`,`Primitive.js:538-543, 628-663`)。
-  - draw command 数 = `primitive._va.length`(**每个顶点数组一条**,`Primitive.js:1868`)——合并后通常就是 1 条。
-  - 逐实例数据(`color` / `show` / `pickColor` / `distanceDisplayCondition` / 自定义属性)**不是顶点属性,而是 batch table 纹理**,在顶点着色器里按 `batchId` 查(`Primitive.js:817-963`:`czm_batchTable_show`、`czm_batchTable_color`、`czm_batchTable_pickColor`、`czm_batchTable_distanceDisplayCondition` 等)。
-  - 逐实例数据可以在运行时改:`primitive.getGeometryInstanceAttributes(id)` 返回带 getter/setter 的对象(`Primitive.js:2379-2420`);setter 会走 `createSetFunction` → `batchTable.setBatchedAttribute`(`Primitive.js:2277-2304`)。
-  - **自建 `Appearance` 是可行的**:`MaterialAppearance` 的 `vertexFormat` 由 `materialSupport` 决定(`MaterialAppearance.js:55, 94, 197-199`,`MaterialSupport.TEXTURED` / `ALL` 分别给 `POSITION_NORMAL_AND_ST` / `VertexFormat.ALL`,`:288-323`),`Appearance` 也接受自定义 `fragmentShaderSource`(`Appearance.js:125-153`)。→ 想「shader 里按 attribute 驱动」是可行的,但**逐实例的那个 attribute 只能走 batch table 纹理这条路**(`czm_batchTable_*`),不是真正的 instanced vertex attribute。
-  - `allowPicking` 是构造参数,默认 `true`(`Primitive.js:76, 243`),`false` 时**不创建 pickId 且不写 `command.pickId`**(`Primitive.js:650-653, 2055-2059`),文档原话「GPU memory is saved」。
-- **只影响一个点**:能。最省的做法是 **N=1 的 `GeometryInstance` 放在该设备的 ECEF 位置**(`Transforms.eastNorthUpToFixedFrame`,仓库已有用法 `cesiumEngine.js:452-455`)。
-
-### 1.3 `PostProcessStage` / `PostProcessStageCollection`
-
-- **本质是「全屏后处理」,不是「按实体定位」**:单个 stage 是一遍全屏 viewport-quad(`PostProcessStage.js:592` 的 `context.createViewportQuadCommand`),它无法知道「哪个像素属于哪个设备」。→ **不能只影响一个点**,除非你在 shader 里自己做「世界坐标 → 屏幕」的判定(要自己给 `uniforms` 传设备点位的屏幕坐标或世界坐标),或者用下面这条:
-  - **`stage.selected = [features]` + shader 里 `czm_selected()`**:这是 Cesium 官方给「只影响被选中的对象」的机制(`PostProcessStage.js:71-94` 的示例、`:355-395` 的 `selected`、`:548-567` 注入的 `czm_selected`)。但它只对**参与 id 通道的对象**(3D Tiles feature 等)有效,**普通 Entity/Primitive 不在其列**——所以它救不了「只影响某一个设备图标」。对「只影响一个点」的需求,**不要选后处理**。
-- **`uniforms` 支持每帧函数**:属性值可以是常量,也可以是函数,函数每帧执行前被调用(`PostProcessStage.js:237-245, 458-496`)。→ 「脉冲相位」这类每帧量可以这样喂。
-- **能力探测有现成实现**:`PostProcessStage.prototype._isSupported` 用正则检查片元着色器里有没有 `uniform sampler2D depthTexture`,有则要求 `context.depthTexture` 为真,否则整级不执行(`PostProcessStage.js:397-404`)。**注意它是文本正则匹配**(`/uniform\s+sampler2D\s+depthTexture/g`),声明写法不同会影响判定。
-- **可拾取**:后处理不参与拾取(它不是 primitive,`Scene.pick` 看不到它)。→ 「特效不该吃点击」这条需求,后处理天然满足;反问是**它也无法承载「点特效」**。
-- **每帧重算**:一遍全屏 pass,是「每帧都画」的固定成本(与特效点数量无关)。`enabled` 开关是廉价的(`PostProcessStage.js:178-182`)。
-
-### 1.4 `ParticleSystem`(**全局,不是按实体**)
-
-- **挂在 `Scene.primitives` 上**:官方教程与 API 的用法就是 `viewer.scene.primitives.add(particleSystem)`([Introduction to Particle Systems](https://cesium.com/learn/cesiumjs-learn/cesiumjs-particle-systems/)、[ParticleSystem API](https://cesium.com/learn/cesiumjs/ref-doc/ParticleSystem.html));源码里它内部**自建一个 `BillboardCollection`** 来画粒子(`ParticleSystem.js:710`),每次 `update` 都会 `this._billboardCollection.update(frameState)`(`ParticleSystem.js:807`)。→ 一个 `ParticleSystem` ≈ 一个 billboard collection 的开销;**N 个点 = N 个 ParticleSystem = N 份粒子模拟 + N 次 billboard collection 更新**。
-- **定位方式**:`modelMatrix`(把粒子系统从模型坐标变到世界坐标,`ParticleSystem.js:28, 97`)+ `emitterModelMatrix`(把发射器放到系统局部坐标里,`:29, 98-99, 213-223`)+ `emitter`(默认 `CircleEmitter(0.5)`,`:27, 89-93`)。**没有「position」参数,也没有 entity 关联**:它是场景级 primitive,要自己算 ECEF 矩阵。
-- **能否只影响一个点**:机制上「一个系统只在一个位置发射」,但**代价是一个系统一份模拟**;无法用一个 `ParticleSystem` 表达多个不同颜色的点位特效(`startColor/endColor` 是系统级,不能逐粒子按状态区分)。
-- **能否被拾取**:`ParticleSystem` 没有 `pickId` / `allowPicking` 相关成员(构造函数 `ParticleSystem.js:58-100` 与成员列表里都没有),它画出的粒子 billboard 也没有建立 pick 身份(`Particle.js` 里只有位置/速度/尺寸/颜色等)。→ 不参与拾取。**这一条对「特效不该吃点击」是有利的**,但换来的是「无法与设备点做拾取联动」。
-- **是否每帧重算**:是,粒子模拟(emit/update/回收)逐帧在 CPU+GPU 上跑,`emissionRate` 默认 5/秒、`particleLife` 默认 5 秒(见 API 文档默认值表)。→ 开销随 `emissionRate × particleLife` 的粒子数增长。
-
-### 1.5 `BillboardCollection`(**本票结论里的首选,独立列出**)
-
-- 一次 draw call 画整个 collection:官方博客「we can render the entire billboard collection with a single drawElements call」([Billboard Instancing](https://cesium.com/blog/2015/10/28/billboard-instancing/))。该文给的对照数字是 **10,000 个 billboard**:instancing 静态 266 FPS / 动画 138 FPS,batched 静态 262 FPS / 动画 120 FPS(2015 年、官方作者、其测试机)——**量级参考:一万级的静态 billboard 在同屏是轻松的,动画减半**。
-- 逐 billboard 改 size/color 只重传脏的那几个:`BillboardCollection.js:152-153`(`_billboardsToUpdate`)、`:655-657`(setter 里 push 脏索引)、`:1466-1507`(更新时只处理 `_billboardsToUpdateIndex` 个)。→ **每个特效点一套自己的相位是免费的**,不必重建整个 collection。
-- 但**每帧改 200 个 billboard 的属性 = 每帧 200 次 setter + 200 个顶点属性重传**,CPU 侧仍有线性成本(动画 138 vs 静态 266 FPS 就是这部分的代价)。
-- 可拾取:canvas 内非透明像素会命中(`Billboard.getPickId`,`Billboard.js:1104-1114`);`Entity.billboard` 的 `properties.pointCode` 正是仓库现有拾取判据(`cesiumEngine.js:417-431`)。**新增特效 billboard 时必须让它没有 `pointCode`,或者干脆不放拾取数据源里**,否则会污染 `pickPointMarkerAt()`。
-
-## 2. 批量做法与开销量级判断
-
-### 2.1 「一次 draw call 画 N 个脉冲」的路子
-
-| 路子 | 一次 draw call 能否画 N 个 | 每帧成本发生在哪 | 判定 |
-| --- | --- | --- | --- |
-| `BillboardCollection` + 每点 `scale`/`color` 动画 | **能**(一个 collection = 一次 drawElements) | CPU 侧改 N 个脏 billboard 的顶点属性(`BillboardCollection.js:1466-1507`) | **推荐**:换来的表现力足以做「呼吸/脉冲/扩散」;N=200 属安全区 |
-| 自定义 `Primitive`(N 个 `GeometryInstance` + 同一 `Appearance`) | **能**(command 数 = `_va.length`,通常 1) | 逐实例数据走 batch table 纹理(`Primitive.js:817-963`);改单个实例走 `getGeometryInstanceAttributes()` setter | 可行;**表现力最强**(自建 GLSL),但也是最需要自己写 GLSL 的一条 |
-| `BufferPointCollection`(1.145 新,`@experimental`) | **能**(显式 `count: collection.primitiveCount`,`renderBufferPointCollection.js:290`) | 只更新脏区间(`_dirtyCount`,`:109-164`) | **不建议押注**:`@experimental`(`BufferPointCollection.js:56-57`)且只能改 size/color,做不出设计感 |
-| `Entity` + `CallbackProperty` 椭圆/圆柱 | 否(每个实体一个 updater、每帧重建 Primitive) | 每帧几何在**主线程**重建(`DynamicGeometryUpdater.js:139-153`) | **只用于 1–3 个聚焦设备** |
-| `ParticleSystem` | 每个系统自己一次 billboard collection update | 粒子模拟 + 粒子数(`emissionRate × particleLife`) | 适合「单个重点设备的一处烟雾/火花」,不适合 N 点 |
-
-### 2.2 同屏 10 / 50 / 200 个特效点的量级判断
-
-**先说清性质:下面是「依据绘制结构 + 时间预算 + 可引用的实测」做出的推断,不是在本机实测的数据。本次调研按仓库规则没有跑任何 build/dev/浏览器测量。**
-
-- **单帧预算**:1920×1080 @ 60 FPS = **16.6 ms** 全部时间;给特效的合理预算按 10% 估 ≈ **1.7 ms**。
-- **10 个点**:任何一条路子都能过。最贵的 `CallbackProperty` 椭圆路线在这个量级也只剩「每帧 10 次几何重建」的噪声级成本(社区实测 25 个才掉到 8 FPS 那种量级的机器上)。
-- **50 个点**:`BillboardCollection` 与自定义 `Primitive` 都在安全区(对照:官方 10,000 个静态 billboard 266 FPS,即约 **0.0037 ms/个** 的整帧摊派,50 个 ≈0.19 ms);**`CallbackProperty` 椭圆路线开始危险**(社区实测同机 25 个 ≈8 FPS = **125 ms/帧**)。
-- **200 个点**:`BillboardCollection` 仍在安全区(官方实测 10,000 个动画 billboard 138 FPS ≈ **0.0007 ms/个** 摊派,200 个 ≈0.15 ms);自定义 `Primitive` 亦在同量级(1 条 draw command,成本主要是 batch table 纹理 200 个 texel);**`CallbackProperty` 椭圆路线不可行**(社区同机 200 个 ≈0 FPS)。
-- **需要留意的共同成本**:每个特效点若还要**每帧重建 JS 对象**(新的 `Color`/`Cartesian3`/图片 data-URI),GC 压力会盖过绘制成本。官方对 `BufferPrimitiveCollection` 明确写了这条纪律:「be careful to avoid allocating "N" instances of any related JavaScript object … using the flyweight pattern」(`Cesium.d.ts:28686-28691`)。同理,**禁止每帧重新生成 SVG data-URI 当图片**(`markerSvg()` 是建图片用的,不是每帧调用的)。
-- **与 requestRenderMode 的关系**:`CallbackProperty(isConstant=false)` 会持续请求新帧([issue #11315](https://github.com/CesiumGS/cesium/issues/11315) 引官方维护者原话),也就是说「常驻动画特效」会**让按需渲染失效**——这条对「低性能消耗」是硬约束,凡是常驻动效都要按 07 的门控来决定何时挂上。
-
-## 3. 与既有雾气后处理的共存(关键)
-
-### 3.1 同一 `PostProcessStageCollection` 里再挂一个设备特效级 —— 可行
-
-- 集合就是一个数组 + 名字表:`this._stages = []`(`PostProcessStageCollection.js:76`),`add(stage)` 只要求名字唯一并把 `_index` 设为当前长度(`:436-466`)。
-- **没有任何插入/排序/优先级 API**:全文件搜 `_index` 只有 `:420`(移除后重排)、`:466`(加入时赋值)、`:499/:502/:517`(移除)。→ **顺序 = 加入顺序**,唯一「插到中间」的办法是移除后按新顺序重新 add。
-- 每帧先过滤出活跃级:`stage.ready && stage.enabled && stage._isSupported(context)`(`:583-593`)。
-
-### 3.2 `execute` 的顺序语义(源码级)
-
-`PostProcessStageCollection.prototype.update` 收集活跃级(`:583-593`),`execute` 按固定骨架跑(`:768-835`):
-
-```
-initialTexture = colorTexture                        // 场景渲染结果
-if (ao)          { execute(ao, initialTexture, depthTexture, idTexture); initialTexture = ao.output }
-if (bloom)       { … }
-if (autoExposure){ … }
-if (tonemapping) { execute(…); initialTexture = tonemapping.output }
-// —— 自建 stage 按加入顺序,链式传色 ——
-execute(activeStages[0], initialTexture, depthTexture, idTexture)
-for i in 1..length-1:
-    execute(activeStages[i], getOutputTexture(activeStages[i-1]), depthTexture, idTexture)
-if (fxaa)        { execute(fxaa, lastTexture, depthTexture, idTexture) }
-```
-
-三条必须记住的语义:
-
-1. **自建 stage 永远排在 AO/Bloom/色调映射之后、FXAA 之前**(`:799-834`;类文档也这么写:`:25-30`)。→ 后处理级的位置不可能比内置级更早。
-2. **颜色是链式的**:第 i 个 stage 的 `colorTexture` 是第 i−1 个的输出(`:819-828`)。→ **特效级与雾气级的先后会直接改变观感**:谁后跑谁就在最上层。
-3. **深度不是链式的**:`depthTexture` 参数从 `execute` 入口一路原样传给每一级(`:801-827` 每处都传同一个 `depthTexture`)。→ **两级读到的深度完全一致**,谁先谁后都不影响读深度。
-
-### 3.3 顺序的应然取法(本票只给事实与约束)
-
-- **雾气是「大气/远处衰减」,特效是「近处设备上的发光」**。若雾气后跑,雾会把特效一起按距离衰减(物理上更对,远处设备特效变淡);若特效后跑,远处设备特效会「穿透」雾气浮在最上层(观感上像 UI 标注,但会破坏「范围外被雾遮住」的 Q11 诉求)。
-- **与本地图 07 的对齐点**:该图 07 的门控还要求「遮盖镜头旋转/侧视时露出的范围外场景、云层与地平线」(`.scratch/cockpit-primary-area-guardrails/issues/07-fog-implementation-spec.md:20-24`)。**设备特效必须排在雾气之前**才能被这份「遮盖」覆盖;否则侧视时会出现「雾把县城盖住了,但设备特效还亮在雾上面」。
-- **两级的开关要能在一次 `update` 里原子地对齐**:`enabled` 是逐级的独立开关(`PostProcessStage.js:178-182`),集合自己不做联动。→ 「07 里显式对齐顺序与开关」这句(`map.md:19`、`map.md:57`)在实现上就是:**同一处创建、同一处 add、按固定顺序 add、按开关一起 enable/disable**。
-
-### 3.4 关于「设备特效也要读深度」的正确路径
-
-- 06 已更正:`Context.depthTexture` 是**布尔能力标志**(`Context.js:714-725`),不是纹理(该更正沿用,[`research/three-fog-shader-reuse.md`](../../cockpit-primary-area-guardrails/research/three-fog-shader-reuse.md) 及其票面已核实)。
-- **正确路径 = `PostProcessStage` 默认提供的 `depthTexture` uniform**:`PostProcessStage.js:32-33` 的文档写明默认 uniform 就是 `sampler2D colorTexture` + `sampler2D depthTexture` + `vec2 v_textureCoordinates`;uniform 注入在 `:526-531`(`depthTexture`、`depthTextureDimensions`)与 `:588-592`(`ShaderSource`)。读深度用 `czm_readDepth(depthTexture, v_textureCoordinates)`(`Shaders/Builtin/Functions/readDepth.glsl:1-4`,`LOG_DEPTH` 下自动反解,见 06 笔记 §2 第 4 条)。
-- **但先问「需不需要」**:本票的场景(设备点位上的状态特效)几乎不需要自己读深度——`Primitive` / billboard 走正常深度测试就能「被建筑挡住」,`Entity.billboard` 现有的 `disableDepthTestDistance: POSITIVE_INFINITY`(`cesiumEngine.js:346`)是刻意「永不被挡」的选择。**只有「按距离淡出」这类需求才需要读深度**。
-- **若要读深度,注意两条边界**:① 能力探测是**文本正则**(`PostProcessStage.js:397-404`),片元着色器里必须写成 `uniform sampler2D depthTexture` 才能被识别;② `depthTexture` 的原始内容是 D24S8 打包纹理(DEPTH_STENCIL + UNSIGNED_INT_24_8,`Renderer/FramebufferManager.js:189-196`,06 已核实),必须经 `czm_readDepth` 解码,不能当普通颜色采样。
-
-## 4. 色与形的单一来源
-
-### 4.1 现状(仓库只读复核)
-
-- **语义色只在 `styles/tokens.css` 声明一份**:`--lv1: #ff4d5e; --lv2: #ffa63d; --lv3: #4fd8ff; --lv4: #35e39a; --neutral: #6d92ae;`(`src/modules/cockpit/styles/tokens.css:31-38`,挂在 `.cockpit-shell` 上而不是 `:root`,理由写在 `:9-13`)。同一文件里 `--lv3` 与主色 `--cyan` 同值(`:28, :36`)。
-- **状态 → 色键/形状/文字的唯一一张表**:`src/modules/cockpit/domain/pointStatus.js:78-85`(`POINT_STATUS_VISUALS`,六态各带 `tone`/`shape`/`label`/`priority`)、`:88`(`UNKNOWN_VISUAL`)、`:91`(`UNASSOCIATED_VISUAL`)、`:94-96`(`visualForStatus`)。形状片段只写 `currentColor`、**不含任何色值**(`:42-59`)。
-- **色键词表与「只描述颜色深浅、不说业务」的纪律**:`domain/statusTones.js:14`(`TONES = ['lv1'..'neutral']`)、`:32-38`(`TONE_DESCRIPTIONS`)。
-- **服务层是唯一读色值的地方**:`services/mapService.js:631-651`(`TONE_FALLBACK` + `toneColors()` 从 `.cockpit-shell` 的 `getComputedStyle` 读 `--lv1..--neutral`),`:670-676` 把它交给引擎(`colors[marker.tone] → markerSvg(shape, color, 18)`)。
-- **`markerSvg()` 为什么必须烘焙色值**:注释写得很直白——「`color` 属性让片段里的 `currentColor` 有值:**SVG 作为图片加载时读不到页面 CSS 变量**」(`pointStatus.js:218-222`);`mapService.js:626-629` 重复了同一句。
-- **引擎层不认识业务词**:`syncPointMarkers(viewer, markers, colorOf, imageOf)` 只收 `shape` 与 `color`(`cesiumEngine.js:303-319`)。→ 特效若新增「动画形态」,**应沿用同一模式:domain 出「形态键 + 色键」,服务层出「色值 + 素材」**,引擎层不新增第二张业务映射表。
-
-### 4.2 引擎侧**读不到 CSS 变量**、必须由服务层烘焙色值的具体 API
-
-CSS 自定义属性只在 DOM/CSS 世界解析,**WebGL 渲染路径(着色器 uniform、顶点属性、纹理)一律读不到**。逐项列出:
-
-| 引擎 API | 为什么读不到 CSS 变量 | 服务层该传什么 |
-| --- | --- | --- |
-| `BillboardCollection#add({ image })` / `Entity.billboard.image` | 图片是 data-URI/URL,**SVG 作为图片加载时不继承页面 CSS**(`pointStatus.js:218` 注释) | `markerSvg(shape, color)` 这样把色值写进 SVG 的 `color` 属性 |
-| `Billboard#color` / `Entity.billboard.color` | 要 `Cesium.Color`(数值 RGBA) | 由 `toneColors()` 的 `#rrggbb` 转 `Color.fromCssColorString()` |
-| `Material` 的 uniform | 要求 number / boolean / `Color` / `Cartesian2..4` / 纹理,字符串被当作图片或阶段输出名(`Material.js:1255-1278`) | `Color` 对象 |
-| `ColorGeometryInstanceAttribute`(`PerInstanceColorAppearance` 的逐实例颜色) | 数值 Uint8 RGBA | 同上 |
-| `BufferPointMaterial#color` / `#outlineColor` | `Color` 对象(`Cesium.d.ts:28094-28105`) | 同上 |
-| `PostProcessStage` 的 `uniforms` | 常量值或**每帧函数**(`PostProcessStage.js:237-245`),函数返回值是 GLSL 类型 | 由服务层闭包捕获烘焙好的 `Color` 再返回 |
-| 自建 GLSL 里的颜色常量 | 着色器源码是纯文本,不经过 CSS | 走 `uniforms`,不要硬编码 `#rrggbb` 进 shader |
-
-**结论:单一来源可以维持,但入口只有一个——「服务层调一次 `toneColors()`,把色值往下传」**。任何在引擎层或 shader 里写死色值的做法都会长出第二份定义。形状同理:`SHAPES` → `markerSvg()` → data-URI 是唯一通路;若要给特效做「扩散圈」素材,**素材本身应由 `shape` 派生或与 `shape` 一一对应**,不要在引擎里按状态名分支。
-
-## 5. 拾取与层级
-
-### 5.1 `Scene.pick` 与 `Scene.drillPick` 在多层叠加时的行为
-
-- `Scene.pick(windowPosition, width, height)` 走 `this._picking.pick(...)`(`Scene.js:4770`),**只返回最靠近相机的一个对象**。
-- `Scene.drillPick` 的实现是**反复拾取**:`drillPick(pickCallback, limit)` 先拾一次,把拾到的对象 `show = false` 藏起来,再拾下一次,直到拾满或拾不到,最后统一恢复 `show = true`(`Picking.js:895-941`)。→ **多层叠加时 drillPick 能全部拿到,但代价是 N 次 pick pass**;而且它靠改 `show` 实现,若对象本身 `show` 归别人管,会互相打架。
-- 拾取本身是**离屏 pass + 读回 1×1 像素**:`Scene.pick` 用的是 `_picking` 的专用 framebuffer(`Scene.js:3104-3106` 附近给 `getPickDepth` 喂深度),所以**拾取开销与「屏幕上画了多少东西」无关,而与「拾了几次」成正比**。→ 走查里 `pickPointMarkerAt()` 每次点击一次 `scene.pick`(`cesiumEngine.js:417-431`),是廉价的。
-
-### 5.2 特效会不会挡住监测点与设备的拾取
-
-**判据是「这条命令在拾取通道里有没有 pick 身份」,与「是不是半透明」无关**:
-
-- 拾取通道走 `performIdPass(...)`(`Scene.js:3147-3149`:CESIUM_3D_TILE / OPAQUE / TRANSLUCENT 三个 pass 都调它),每条命令进 `executeIdCommand`。
-- `executeIdCommand`(`Scene.js:2379-2404`)只做两件事:有 `derivedCommands.pickingMetadata` / `picking` 就画对应的 pick 命令,否则**若只有 `depth` 派生命令就只画深度**,**两者都没有就直接 `return`(什么都不画)**。
-- `derivedCommands.picking` 只在 `command.pickId` 有定义时才创建(`Scene.js:1810-1817`;`DerivedCommand.createPickDerivedCommand` 在 `DerivedCommand.js:315-334`)。
-- 所以:
-  - **特效命令没有 pickId** → 拾取通道里它**不画** → 既不吃点击,也不挡后面的对象。这正是 `Primitive` + `allowPicking:false`(`Primitive.js:2055-2059`)与 `BufferPointCollection` 默认 `allowPicking:false`(`BufferPointCollection.js:159`)的效果。
-  - **特效命令有 pickId** → 它会写拾取颜色 → `Scene.pick` 在它盖住的像素上返回**它**而不是后面的监测点。半透明的圈/粒子只要挂了 pickId 就会吃点击。
-- `Scene.pickTranslucentDepth` 默认 `false`(`Scene.js:539`),文档说明开了它才会把 `pickPosition` 的深度算上半透明对象(`Scene.js:4999-5030`)——**这是 `pickPosition` 的语义,不影响 `pick` 返回哪个对象**。
-
-### 5.3 「特效不该吃点击」的做法(按代价从低到高)
-
-1. **不给特效任何 pick 身份**(首选):`Primitive({ allowPicking: false, … })`(`Primitive.js:76`)、`BufferPointCollection({ allowPicking: false })`(默认就是 `false`)、`ParticleSystem`(本身没有拾取路径)、`PostProcessStage`(不是 primitive)。→ 零额外代码。
-2. **放进不参与拾取的 collection**:把特效 billboard 放进一个**独立的 `BillboardCollection`**(不是 `POINT_MARKER_SOURCE` 数据源),并且**不要给它 `pointCode` 属性**。仓库现有拾取口径正是「属性里有 `pointCode` 才算点位标记」(`cesiumEngine.js:405-431`),所以这条能立刻生效。
-3. **物理上让开**:把扩散圈做成**空心环**(中心透明),设备图标在环心就不会被盖住。注意 billboard 的拾取是「canvas 内非透明像素」,空心环天然只吃环上那一圈。
-4. **靠 pass 顺序兜底**:**不透明(OPAQUE pass)命令即使没有 pickId,也有 `depthOnly` 派生命令**(`Scene.js:2400-2403` 的 `else if (defined(depth))`),也就是说**一个不透明的特效面片会在拾取通道里写深度,挡住后面的设备点**。→ 特效面片**必须走半透明**(`Appearance.translucent = true` / `Material.isTranslucent()`,`Appearance.js:152-153`),否则它既没有 pickId 又会挡住拾取。这是最容易踩的一条。
-5. **`drillPick` 兜底(不推荐)**:若某天特效确实要可拾取(例如点特效弹菜单),用 `drillPick` 穿透取列表再筛——但它多次拾取、并且靠改 `show`(`Picking.js:923-934`),与「特效按状态独立开关」会互相干扰。
-
-## 查不到 / 未能核实的事项
-
-- **没有任何「在 Cesium 1.145 + 本仓库实际场景下」的实测帧率数据**。本票没有跑 build/dev/浏览器(`AGENTS.md` 禁止),所以 §2.2 的 10/50/200 量级判断**是推断**,依据已在正文逐条列出(绘制结构 + 官方 10,000 billboard 的 2015 年对照 + 社区 200 个 CallbackProperty 的用户实测)。要拿到真数,得在实现票里用 `scene.debugShowFramesPerSecond` 或 `FrameRateMonitor` 实测。
-- **「200 个 `CallbackProperty` 椭圆」在本仓库目标机型上的具体帧率**:查不到。社区那组数字是 2020 年、提问者本机、Sandcastle 环境,**只可作为量级参考**。
-- **`BufferPrimitiveCollection` 的正式文档页(cesium.com ref-doc)**:抓取 `ParticleSystem.html` 时的侧边栏里已出现 `BufferPointCollection` 等条目,但本票**没有抓到它们的官方参考页正文**,结论全部来自随包 `Cesium.d.ts:28040-28820` 与源码 `Renderer/…`、`Scene/BufferPointCollection.js`、`Scene/renderBufferPointCollection.js`。其 `@experimental` 标注即来自源码 JSDoc(`BufferPointCollection.js:25, 56-57`)。
-- **`ParticleSystem` 能否被 `Scene.pick` 拾取**:源码里没有任何 `pickId` / `allowPicking` 痕迹(构造参数表 `ParticleSystem.js:58-100`、成员表、内部 `BillboardCollection` 用法),但**没有找到官方明文写「不可拾取」**。判定为「机制上不参与拾取」,依据是源码缺失而非文档声明。
-- **雾气级与特效级「谁先谁后更好看」的产品裁定**:本票只给顺序语义与约束(§3.3),**不做产品选择**——那属于本地图 07 与 `.scratch/cockpit-primary-area-guardrails` 07 的对齐范围。
-- **`Entity.cylinder` 的 `_isDynamic` 具体判据**:本票只核了 `EllipseGeometryUpdater`(`EllipseGeometryUpdater.js:222-239`)。`CylinderGeometryUpdater` 未逐行读,但其 `length` 属性非常量时走同一套 `DynamicGeometryBatch` / `DynamicGeometryUpdater` 机制(`DynamicGeometryBatch.js:14-22`、`DynamicGeometryUpdater.js:68-85`),结论不变。
-
-## Sources
-
-**随包 CesiumJS 1.145.0(`node_modules/@cesium/engine/Source/`,行号见正文)**
-
-- `Scene/PostProcessStageCollection.js` —— stage 集合、`add`、`removeStages`、`_index`、`update` 的活跃级过滤、`execute` 的固定骨架与链式传色
-- `Scene/PostProcessStage.js` —— 默认 uniform(`colorTexture` / `depthTexture` / `v_textureCoordinates`)、`uniforms` 每帧函数、`enabled`、`selected` + `czm_selected`、`_isSupported` 的 depthTexture 正则
-- `Scene/BillboardCollection.js`、`Scene/Billboard.js` —— 脏列表更新、`getPickId`
-- `Scene/Primitive.js` —— `allowPicking`、batch table(`createBatchTable` / `czm_batchTable_*`)、`_va.length` 与 draw command 数、`getGeometryInstanceAttributes`
-- `Scene/BufferPrimitiveCollection.js`、`Scene/BufferPointCollection.js`、`Scene/renderBufferPointCollection.js`、`Shaders/BufferPointMaterialFS.js` —— 单 DrawCommand 画 N 点、`allowPicking=false` 默认、`@experimental`
-- `Scene/ParticleSystem.js` —— 内部自建 `BillboardCollection`、`modelMatrix` / `emitterModelMatrix`、无位置参数
-- `Scene/Appearance.js`、`Scene/MaterialAppearance.js` —— `vertexFormat` / `materialSupport` / `translucent`
-- `Scene/Material.js` —— uniform 类型判定(Color 而非 CSS 串)
-- `Scene/Scene.js` —— 拾取通道 `performIdPass`、`executeIdCommand`、`pickId` → `derivedCommands.picking`、`pickTranslucentDepth`
-- `Scene/Picking.js` —— `drillPick` 的「拾一个藏一个」实现
-- `Scene/DerivedCommand.js`、`Scene/DynamicGeometryBatch.js`、`Scene/DynamicGeometryUpdater.js`、`Scene/EllipseGeometryUpdater.js`、`DataSources/GeometryVisualizer.js` —— 动态几何的每帧重建链
-- `Scene/CallbackProperty.js` —— `isConstant` 语义
-- `Renderer/FramebufferManager.js` —— D24S8 深度纹理
-- `node_modules/cesium/package.json:3`(版本)、`node_modules/cesium/Source/Cesium.d.ts:28040-28820`(`BufferPointCollection` / `BufferPrimitiveMaterial` / `BufferPrimitiveCollectionOptions`)
-
-**仓库内文件**
-
-- `src/modules/cockpit/services/cesiumEngine.js`(点位标记数据源、`syncPointMarkers`、`pickPointMarkerAt`、`frameAt`)
-- `src/modules/cockpit/services/mapService.js`(`TONE_FALLBACK` / `toneColors()` / `syncPointMarkersForSummary`)
-- `src/modules/cockpit/domain/pointStatus.js`(`SHAPES` / `POINT_STATUS_VISUALS` / `markerSvg`)
-- `src/modules/cockpit/domain/statusTones.js`、`src/modules/cockpit/styles/tokens.css`
-- `.scratch/cockpit-primary-area-guardrails/research/three-fog-shader-reuse.md`、该图 `issues/06`、`issues/07`
-- `.scratch/cockpit-point-locate-and-device-link/map.md`(Notes 现状事实 1–8、跨图耦合)
-
-**官方文档 / 官方博客 / 社区**
-
-- [ParticleSystem - Cesium 官方文档](https://cesium.com/learn/cesiumjs/ref-doc/ParticleSystem.html)
-- [Introduction to Particle Systems - Cesium 官方教程](https://cesium.com/learn/cesiumjs-learn/cesiumjs-particle-systems/)
-- [Graphics Tech in Cesium - Billboard Instancing - Cesium 官方博客(2015-10-28)](https://cesium.com/blog/2015/10/28/billboard-instancing/)(10,000 billboard 的 instancing/batched、静态/动画 FPS 对照)
-- [PostProcessStageCollection - Cesium 官方文档](https://cesium.com/learn/cesiumjs/ref-doc/PostProcessStageCollection.html)(「AO/Bloom 先于所有 stage,FXAA 后于所有 stage」)
-- [PostProcessStage - Cesium 官方文档](https://cesium.com/learn/cesiumjs/ref-doc/PostProcessStage.html)
-- [Primitive - Cesium 官方文档](https://cesium.com/learn/ion-sdk/ref-doc/Primitive.html)
-- [Using multiple CallbackProperty: kills performance (drops to Zero FPS) - Cesium 社区(2020)](https://community.cesium.com/t/using-multiple-callbackproperty-kills-performance-drops-to-zero-fps-code-repaired/10608)(25 个 ≈8 FPS、200 个 ≈0 FPS 的用户实测)
-- [requestRenderMode property and CallbackProperty function conflicts · CesiumGS/cesium issue #11315](https://github.com/CesiumGS/cesium/issues/11315)(非恒定 CallbackProperty 会每帧请求新帧)

+ 0 - 70
.scratch/cockpit-point-locate-and-device-link/spec.md

@@ -1,70 +0,0 @@
-# 监测点 / 设备镜头定位视角契约 spec
-
-Status: ready-for-agent
-Labels: cockpit-locate-view
-Category: spec
-
-本规格固化决策地图「监测点定位、面板锚定与设备点位联动」的票 03(镜头「定位到监测点」的视角契约)的结论。上游决策记录:票面 `issues/03-locate-camera-view-contract.md`(`## Answer`);关联契约:ADR 0008(已原位修订)、ADR 0009(主场景区限位)、决策地图票 04(已同步)、`CONTEXT.md`(已补「定位 / 归位」词条)。
-
-## Problem Statement
-
-值班人员点击地图上的一个监测点后,镜头只把旋转中心挪过去、仍停在县域全景(range 12 km),看不清这个监测点及其周边,也看不到该点位下的设备点位 ——「点击后首先将镜头移动定位到该监测点位置,能够在三维场景下观察该监测点的状况」这条需求拿不到。同时,点选监测点与手动三键(轨道 / 滚轮 / 平移)的相机语义在既有契约(ADR 0008 / 决策地图票 04)里是同一套「只换中心」,需要为「定位」单独立一条语义,否则实现无从下手。
-
-## Solution
-
-从用户视角:点击地图上的监测点 → 镜头平滑飞向该点位的**定位视角**(中心对准点位地表、heading/pitch/range 一起换成点位级预设,缺省 `heading 0 / pitch −55 / range 500 m`),点位居中、周边街区级可辨、该点位下所有设备点位都可见;在监测面板里点一台设备 → 镜头飞向**设备级**定位视角(缺省 `range 150 m`)。取消选中或关闭面板后镜头**不回退**,用户自己滚轮 / 平移 / 归位离开;换一个点位直接从当前视角飞向下一个定位视角。「归位」永远是回「家」视角(县域级),定位不改变它。
-
-## User Stories
-
-1. 作为值班人员,我想点击地图上的监测点后镜头自动飞到该点位并看清它,以便立刻知道这个点在哪、周围是什么。
-2. 作为值班人员,我想点位级定位后该点位下的所有设备点位都出现在画面里,以便为接下来查看设备做准备。
-3. 作为值班人员,我想点位始终落在画面中心附近,以便我知道当前镜头对准的是哪一个点。
-4. 作为值班人员,我想定位后能看清点位周边一小片区域(街区级),以便判断它的空间上下文。
-5. 作为值班人员,我想在监测面板里点一台设备后镜头飞到该设备,以便近距离观察这台设备。
-6. 作为值班人员,我想设备级定位的视角比点位级更近,以便看清单台设备与其紧邻(特效也有空间表现)。
-7. 作为值班人员,我想定位不会跳到随机的朝向,以便画面稳定、可预期(heading 由预设给,缺省正北)。
-8. 作为值班人员,我想关闭详情面板或取消选中后镜头停留在定位视角,以便我继续观察而不是被弹回。
-9. 作为值班人员,我想连续点选不同点位时镜头平滑地从上一个点位飞到下一个,以便快速巡检多个点位。
-10. 作为值班人员,我想点「归位」任何时候都回到县城默认视角,以便在点乱之后一键回到全局。
-11. 作为值班人员,我想定位视角的数值可以由部署配置调整,以便不同部署(不同点位密度)能各自适配。
-12. 作为值班人员,我想定位不受主场景区限位的额外限制(定位 range 远小于限位上限),以便任何画布尺寸下行为一致。
-
-## Implementation Decisions
-
-1. **机制**:定位走「中心 + heading/pitch/range 一起换」的完整视角预设路径(现有视角预设通道,即归位所用的那条),**不是** `setFocus` 的只换中心。定位飞行复用既有阻尼 / 动画路径,不新增时长开关。
-2. **两档预设**:
-   - 点位级:缺省 `heading 0 / pitch −55 / range 500 m`;
-   - 设备级:缺省 `heading 0 / pitch −55 / range 150 m`;
-   - 数值由部署配置给(建议 `map.locate` 一节,含 `point` / `device` 两组),组件不硬编码,后续可在配置中修改。设备级数值供「面板选设备 → 定位到设备」的联动票引用,不另发明第二套。
-3. **中心**:定位中心=点位地表**实测地形**高程(沿用票 30 的地形采样口径),**不取椭球 0**。椭球 0 会把旋转中心埋进沅陵约 111.6 m 的地形里:贴地标记偏离画面中心(破坏「点位标记清晰落在画面中心」验收),且设备级 150 m 时相机离地仅剩约 11 m、120 m 档会入地。
-4. **与主场景区限位的合成**:定位**不设例外**,定位 hpr 必须落在限位内(`range ∈ [120, range_max]`、相机离地高度 `≥ 正下方地形 + 10 m`),被夹就如实呈现。`range_max` 按并行图收口为 `home.range = 12,000 m`(ADR 0009)—— 两档预设均远低于上限,任何画布比例下都不会被夹。
-5. **取消选中 / 关闭面板**:**完全不回退**(中心与视角都保留);换点位直接从当前视角飞向下一个定位视角,不先回退再定位。
-6. **与「归位」的关系**:定位不改 home;归位永远回 home;**不新增**「回到定位前 / 上一视角历史栈」。
-7. **非监测点对象与手动三键**:选中**非监测点对象**(未关联设备、事件等)或**手动三键操作**(右键轨道 / 滚轮 / 左键平移)时,维持既有「只换中心、保持当前 heading 与 range」的语义 —— 定位只作用于监测点(及其设备)。
-8. **ADR 0008 原位修订**:旋转中心一节改为「选中监测点 → 定位视角(中心 + hpr 一起换);非监测点对象与手动三键 → 只换中心;取消选中不立即回退中心或视角」。**不新开 ADR**。决策地图票 04 已同步同一句。
-9. **断言口径**:定位完成 = 相机状态 `settled`(经纬容差 1e-7° 已含 pivot 对齐)且 hpr 匹配对应档位预设,另断言相机离地高度 ≥ 点位地表高程 + 10 m(防入地由限位兜底,走查补一道)。走查读宿主 `dataset.camera` 或相机状态只读接口。
-10. **已知边界(不裁决)**:主场景区状态机只认「进入系统 / 归位」两条进区锁 —— 全球态下点选监测点得到定位视角但**不进区**,5 秒闲置会回引导页(既定行为)。若实现后体验不佳,另开票评估。
-
-## Testing Decisions
-
-- 好的测试只断言**外部可观察行为**,不碰实现细节:点选一个点位后,最终相机状态 = 点位中心 + 对应档位预设 hpr + `settled`;关闭面板后状态不变;点「归位」后回到 home。
-- 测试层级沿用仓库既有两条:**纯逻辑断言**(视角预设与夹取是纯函数,可脱离浏览器直接断言;参考既有相机契约的纯逻辑闸门)+**真机走查**(真实鼠标点标记、读宿主 dataset 断言;参考既有定位/点位详情的走查脚本,含「先等镜头停稳再断言」的既有坑位记录)。
-- 需要覆盖的模块:相机/视角预设域逻辑、地图服务的定位入口、组件侧的点选 → 定位接线。回归面:既有三键相机契约走查与「进入引导 / 归位」走查不得回退。
-- 数值校验口:点位级 500 m 的「该点位下所有设备点位可见」依赖设备点位图层(票 05/11)—— 数据落地时验证设备分布是否超出 ±200 m(= 500 m / −55° / 16:9 的屏幕竖直地面跨度一半),超了回来重议 500 m。
-
-## Out of Scope
-
-- 设备点位图层的渲染、触发与「关联设备不上图」裁决(票 05、11)。
-- 设备状态特效的视觉语言与实现(票 07、02 调研)。
-- 面板锚定跟随的形态(票 04)。
-- 面板选设备与三维设备点的对应反应(票 06)。
-- 后端「点位下设备的坐标与状态」契约补齐(票 09,HITL)。
-- 设备「模型」的口径与资产来源(票 10)。
-- 事件、管线、设施等其他对象的定位(事件定位归实现票 14/19,管线交互优先级归票 22)。
-- 竖屏、移动端、多屏拼接。
-
-## Further Notes
-
-- 上游引用:本规格是决策地图的产物,不是新决策源;细节以票 03 的 `## Answer` 与 ADR 0008 / ADR 0009 为准。
-- 数值都是**缺省**,部署配置可覆盖;写死任何一处都要回到本规格重审。
-- 交叉引用:并行图「主场景区限位、视距与过渡」的 `range_max = 12,000 m`(ADR 0009)是本规格限位合成的前提;若该图后续再改锚点,本规格第 4 条要同步复核。

+ 0 - 148
.scratch/cockpit-point-locate-and-device-link/verify/build-edges.ps1

@@ -1,148 +0,0 @@
-# Edge builder & validator for cockpit-point-locate-and-device-link
-# Written by AI agent. Runs in PowerShell (Windows sandbox safe).
-#
-# 本脚本是本 effort「关联到 map 的每个 ticket」的**原生边唯一来源**:
-#   1. 按下面的声明把 `Blocked by:` 字段行写进每张子票 —— 本仓库(本地 Markdown 后端)
-#      的原生依赖边就是这一行(面板按它渲染地图视图的依赖分层);
-#   2. 校验子票数量 == 声明数量 == map 的 `## 任务清单` 条目数;
-#   3. 校验 map 的任务清单**逐条引用**了每张子票(关联关系的落点);
-#   4. 校验每条 `Blocked by:` 引用都解析得到真实子票。
-# 任一校验对不上就非零退出,并把 expected / actual 都打印出来 —— 对不上就是失败,不要当成成功。
-
-param(
-    [string]$EffortDir = ""
-)
-
-if (-not $EffortDir) {
-    $EffortDir = Split-Path $PSScriptRoot -Parent
-}
-
-$TicketsDir = Join-Path $EffortDir "issues"
-$MapPath = Join-Path $EffortDir "map.md"
-
-Write-Host "Building native `Blocked by:` edges and validating ticket count..."
-Write-Host "  effort: $EffortDir"
-
-# Declare the expected tickets and their blocking edges (map 任务清单 的 01–14).
-$ExpectedTickets = [ordered]@{
-    "01-research-device-position-data-and-rendering-primitives.md" = "None"
-    "02-research-device-status-effect-mechanisms.md"               = "None"
-    "03-locate-camera-view-contract.md"                            = "None"
-    "04-panel-anchor-following-form.md"                            = "None"
-    "05-device-point-layer-trigger-and-lifecycle.md"               = "None"
-    "06-panel-to-scene-device-correspondence.md"                   = "05"
-    "07-device-status-effect-visual-language.md"                   = "02, 05"
-    "08-prototype-anchor-panel-and-device-effects.md"              = "04, 05, 07"
-    "09-task-backend-device-coordinate-contract.md"                = "01"
-    "10-device-model-asset-definition.md"                          = "01, 05"
-    "11-overlapping-device-points.md"                              = "05"
-    "12-panel-anchor-following-spec.md"                            = "04"
-    "13-mode-layer-visibility-matrix.md"                           = "05"
-    "14-associated-monitoring-device-layer-spec.md"                = "05"
-}
-
-$ExpectedTicketCount = $ExpectedTickets.Count
-$ExpectedTaskListCount = 14
-
-$Success = $true
-
-# ---- 1. build edges -------------------------------------------------------------------
-function Fix-Edge {
-    param([string]$TicketPath)
-    $TicketName = Split-Path $TicketPath -Leaf
-    if (-not $ExpectedTickets.Contains($TicketName)) {
-        Write-Host "  Unknown ticket (not declared): $TicketName"
-        return $false
-    }
-    $EdgeLine = "Blocked by: " + $ExpectedTickets[$TicketName]
-    $Content = Get-Content $TicketPath -Raw
-    if ($Content -match "(?m)^Blocked by: .*$") {
-        $Content = $Content -replace "(?m)^Blocked by: .*$", $EdgeLine
-    } else {
-        $Content = $EdgeLine + "`n`n" + $Content
-    }
-    $Content | Set-Content $TicketPath -Encoding UTF8 -NoNewline
-    Write-Host "  Fixed $TicketName : $EdgeLine"
-    return $true
-}
-
-foreach ($Ticket in Get-ChildItem $TicketsDir -Filter "*.md") {
-    if (-not (Fix-Edge $Ticket.FullName)) { $Success = $false }
-}
-
-# ---- 2. expected vs actual ticket count -----------------------------------------------
-$ActualTicketCount = (Get-ChildItem $TicketsDir -Filter "*.md").Count
-Write-Host "expected tickets: $ExpectedTicketCount / actual: $ActualTicketCount"
-if ($ActualTicketCount -ne $ExpectedTicketCount) {
-    Write-Host "  Ticket count mismatch."
-    $Success = $false
-}
-
-# ---- 3. map task list must reference every ticket, one line each -----------------------
-if (-not (Test-Path $MapPath)) {
-    Write-Host "  Missing map.md at $MapPath"
-    $Success = $false
-} else {
-    $MapContent = Get-Content $MapPath -Raw
-    $TaskSection = ""
-    if ($MapContent -match "(?ms)^## 任务清单\s*(.*?)^## ") {
-        $TaskSection = $Matches[1]
-    } else {
-        Write-Host "  map.md has no `## 任务清单` section"
-        $Success = $false
-    }
-
-    $ListedRefs = @()
-    if ($TaskSection) {
-        foreach ($Line in ($TaskSection -split "`n")) {
-            if ($Line -match '^\s*-\s*\[[ x]\]\s*\[\d{2}\s*:\s*[^\]]*\]\(issues/([^)]+)\)') {
-                $ListedRefs += $Matches[1].Trim()
-            }
-        }
-    }
-
-    Write-Host "expected map task-list items: $ExpectedTaskListCount / actual: $($ListedRefs.Count)"
-    if ($ListedRefs.Count -ne $ExpectedTaskListCount) {
-        Write-Host "  Task list item count mismatch."
-        $Success = $false
-    }
-
-    foreach ($TicketName in $ExpectedTickets.Keys) {
-        if ($ListedRefs -notcontains $TicketName) {
-            Write-Host "  Ticket not referenced from map task list: $TicketName"
-            $Success = $false
-        }
-    }
-    foreach ($Ref in $ListedRefs) {
-        if (-not $ExpectedTickets.Contains($Ref)) {
-            Write-Host "  Map task list references an undeclared ticket: $Ref"
-            $Success = $false
-        }
-    }
-}
-
-# ---- 4. every `Blocked by:` reference must resolve ------------------------------------
-foreach ($Ticket in Get-ChildItem $TicketsDir -Filter "*.md") {
-    $Content = Get-Content $Ticket.FullName -Raw
-    if ($Content -match "(?m)^Blocked by: (.*?)\s*$") {
-        $Edge = $Matches[1]
-        foreach ($Ref in ($Edge -split "," | ForEach-Object { $_.Trim() })) {
-            if ($Ref -eq "None" -or $Ref -eq "") { continue }
-            if (-not (Get-ChildItem $TicketsDir -Filter "$Ref-*.md")) {
-                Write-Host "  Invalid reference in $($Ticket.Name): $Ref"
-                $Success = $false
-            }
-        }
-    } else {
-        Write-Host "  Missing `Blocked by:` in $($Ticket.Name)"
-        $Success = $false
-    }
-}
-
-if ($Success) {
-    Write-Host "All edges built and validated successfully. Total tickets: $ActualTicketCount (expected $ExpectedTicketCount); map task-list items: $($ExpectedTaskListCount)."
-    exit 0
-} else {
-    Write-Host "Validation failed. Please fix above issues."
-    exit 1
-}

+ 0 - 154
.scratch/cockpit-point-locate-and-device-link/verify/t08-anchor-layout.mjs

@@ -1,154 +0,0 @@
-/**
- * 票 08 原型的**纯逻辑断言**(第一层证据;第二三层见 `## Answer`/票 07 的可断言口径)。
- *
- * 只测两个不碰 DOM / Cesium / Vue 的纯模块:
- *   · `prototypes/anchor-prototype-layout.js` —— 票 04 的锚定策略与阈值
- *   · `prototypes/anchor-prototype-effects.js` —— 票 07 的设备状态/特效/降级
- *
- * 跑法:`node .scratch/cockpit-point-locate-and-device-link/verify/t08-anchor-layout.mjs`
- * 它不断言画面(画面由维护者目视走查),也**不**证明「特效真在跑」——
- * 那是场景图跨帧快照与帧差的活,本脚本只锁映射与几何不变量。
- * 按 AGENTS.md,本会话未运行任何 build/dev。
- */
-import { solveAnchorLayout, solveFixedLayout, intersects } from '../../../src/modules/cockpit/prototypes/anchor-prototype-layout.js'
-import {
-    DEVICE_STATUS_VISUALS,
-    deviceLegend,
-    deviceVisual,
-    planDeviceEffects,
-    samplePointsNear,
-    spreadDevices,
-} from '../../../src/modules/cockpit/prototypes/anchor-prototype-effects.js'
-
-let failed = 0
-const ok = (name, condition) => {
-    if (!condition) failed += 1
-    console.log(`${condition ? 'PASS' : 'FAIL'} ${name}`)
-}
-
-const canvas = { left: 0, top: 0, right: 1920, bottom: 900 }
-const icon18 = (x, y) => ({ left: x - 9, top: y - 9, right: x + 9, bottom: y + 9 })
-
-// ---------------------------------------------------------------- 锚定求解
-let r = solveAnchorLayout({ anchor: { x: 960, y: 450 }, canvas, blocks: [], form: 'leader' })
-ok('画布中央的锚点:优先贴右侧', r.visible && r.side === 'right')
-ok('卡片与图标外缘至少留 12 CSS px', r.rect.left >= 960 + 9 + 12 - 0.01)
-ok('卡片不与图标相交', !intersects(r.rect, icon18(960, 450)))
-ok('卡片完全落在画布内(留边缘 12px)', r.rect.left >= 12 && r.rect.top >= 12 && r.rect.right <= 1908 && r.rect.bottom <= 888)
-
-r = solveAnchorLayout({ anchor: { x: 1900, y: 450 }, canvas, blocks: [], form: 'leader' })
-ok('贴右边缘:翻到左侧', r.visible && r.side === 'left')
-
-// 四向都排不满时不许硬塞:必须如实暂隐(票 04:不回固定位)
-// 用一个真正放不下的小画布:左右宽度不足可读下限、上下高度不足可读下限。
-const tight = { left: 0, top: 0, right: 600, bottom: 400 }
-r = solveAnchorLayout({ anchor: { x: 300, y: 200 }, canvas: tight, blocks: [], form: 'leader' })
-ok('四向都放不下:暂隐且不硬塞', !r.visible && r.reason === 'no-space')
-ok('暂隐带人话原因(顶栏要显示)', typeof r.hiddenText === 'string' && r.hiddenText.length > 0)
-
-r = solveAnchorLayout({ anchor: { x: -40, y: 450 }, canvas, blocks: [], form: 'leader' })
-ok('标记在画布外:判 icon-offscreen', !r.visible && r.reason === 'icon-offscreen')
-
-r = solveAnchorLayout({ anchor: null, canvas, blocks: [], form: 'leader' })
-ok('锚点缺失/投影无效:判 no-anchor', !r.visible && r.reason === 'no-anchor')
-
-r = solveAnchorLayout({ anchor: { x: 10, y: 10 }, canvas: null, blocks: [], form: 'leader' })
-ok('画布尺寸未知:判 canvas-missing', !r.visible && r.reason === 'canvas-missing')
-
-r = solveAnchorLayout({ anchor: { x: 100, y: 450 }, canvas, blocks: [{ left: 0, top: 0, right: 300, bottom: 900 }], form: 'leader' })
-ok('标记被业务界面遮住:判 icon-covered', !r.visible && r.reason === 'icon-covered')
-
-// 三形态:flush 无边线,leader 出线,arrow 出箭头
-for (const form of ['flush', 'leader', 'arrow']) {
-    r = solveAnchorLayout({ anchor: { x: 960, y: 450 }, canvas, blocks: [], form })
-    ok(`形态 ${form} 可见`, r.visible)
-    if (form === 'flush') ok('形态 flush:无连接件', r.connector === null)
-    if (form === 'leader') ok('形态 leader:细短线', r.connector && r.connector.kind === 'line')
-    if (form === 'arrow') ok('形态 arrow:角箭头', r.connector && r.connector.kind === 'arrow')
-}
-
-// 引线只接到图标外缘,不压图标
-r = solveAnchorLayout({ anchor: { x: 960, y: 450 }, canvas, blocks: [], form: 'leader' })
-ok('引线起点在图标外缘上(不压图标)', Math.abs(r.connector.from.x - (960 + 9)) < 0.01)
-
-// 阈值可调:把间距调大,卡片应随之远离
-const wide = solveAnchorLayout({ anchor: { x: 960, y: 450 }, canvas, blocks: [], form: 'leader', config: { gap: 40 } })
-ok('gap 可现场调参(40px 生效)', wide.rect.left >= 960 + 9 + 40 - 0.01)
-
-// 可读高度下限:把下限抬到超过可用高度,应判不放下
-const tall = solveAnchorLayout({ anchor: { x: 960, y: 450 }, canvas, blocks: [], form: 'leader', config: { minReadableHeight: 2000 } })
-ok('可读高度下限抬高后暂隐', !tall.visible && tall.reason === 'no-space')
-
-r = solveFixedLayout({ canvas })
-ok('固定位对照可见且不读锚点', r.visible && r.side === 'fixed')
-
-// ---------------------------------------------------------------- 设备状态与特效(票 07)
-ok('报警 = 扩散脉冲', deviceVisual('ALARM').effect === 'pulse')
-ok('预警 = 闪烁', deviceVisual('WARNING').effect === 'blink')
-ok('工单处置 = 旋转环', deviceVisual('WORK_ORDER').effect === 'spin')
-ok('正常 = 无特效', deviceVisual('NORMAL').effect === 'none' && deviceVisual('NORMAL').animated === false)
-ok('离线 = 无特效', deviceVisual('OFFLINE').effect === 'none' && deviceVisual('OFFLINE').animated === false)
-ok('状态未发布落 UNKNOWN,不猜 NORMAL', deviceVisual('MAINTENANCE').key === 'UNKNOWN')
-ok('设备词表不含 EMPTY(那是点位聚合态专有)', !('EMPTY' in DEVICE_STATUS_VISUALS))
-ok('图例 6 行(五态 + 状态未发布)', deviceLegend().length === 6)
-ok('设备状态表与点位表是两张表(键集不同)', !('EMPTY' in DEVICE_STATUS_VISUALS) && deviceLegend().some((row) => row.key === 'UNKNOWN'))
-
-// 样例数据确实覆盖齐五态
-const SAMPLE_POINTS = samplePointsNear({ lon: 110.39028164, lat: 28.4436787 })
-const allStatuses = new Set(SAMPLE_POINTS.flatMap((point) => point.devices.map((device) => device.status)))
-for (const status of ['ALARM', 'WARNING', 'WORK_ORDER', 'NORMAL', 'OFFLINE']) {
-    ok(`样例数据覆盖 ${status}`, allStatuses.has(status))
-}
-
-// 关键回归:样例点位必须落在**真实 home 视角看得见的地方**
-// (第一次走查「找不到设备及监测点位」的根因就是写死坐标离 home 24 km,超出 5.7 km 可视半宽)
-{
-    const home = { lon: 110.39028164, lat: 28.4436787, range: 12000, pitch: -55 }
-    const R = 6371000
-    const rad = (deg) => (deg * Math.PI) / 180
-    const distanceM = (a, b) => {
-        const dLat = rad(b.lat - a.lat)
-        const dLon = rad(b.lon - a.lon)
-        const h = Math.sin(dLat / 2) ** 2 + Math.cos(rad(a.lat)) * Math.cos(rad(b.lat)) * Math.sin(dLon / 2) ** 2
-        return 2 * R * Math.asin(Math.sqrt(h))
-    }
-    // 归位视角的地面可视半宽(保守估计)
-    const halfWidth = home.range * Math.sin(rad(Math.abs(home.pitch))) * Math.tan(rad(30))
-
-    for (const point of SAMPLE_POINTS) {
-        const away = distanceM(home, point)
-        ok(`样例点位 ${point.code} 在归位视野内(${(away / 1000).toFixed(2)} km < ${(halfWidth / 1000).toFixed(2)} km)`, away < halfWidth)
-    }
-    // 坐标必须是现算的:换一个 home,点位应随之平移(不再写死)
-    const shifted = samplePointsNear({ lon: 111.5, lat: 29.5 })
-    ok('样例坐标随 home 现算(换 home 会平移,不是写死值)', Math.abs(shifted[0].lon - 111.5) < 0.05 && Math.abs(shifted[0].lon - SAMPLE_POINTS[0].lon) > 0.5)
-    ok('三点互不重合', new Set(SAMPLE_POINTS.map((p) => `${p.lon.toFixed(6)},${p.lat.toFixed(6)}`)).size === 3)
-}
-
-// 并发降级:超限停动效,但**静止外观与动时一致**,所以仍看得出有异常
-const many = spreadDevices(SAMPLE_POINTS[0], { count: 20 })
-let p = planDeviceEffects(many, { animatedCap: 30 })
-ok('未超上限:不降级', p.degraded === false)
-ok('未超上限:异常设备都在播', p.animatedCount === p.abnormalCount && p.abnormalCount > 0)
-
-p = planDeviceEffects(many, { animatedCap: 3 })
-ok('超上限:判降级', p.degraded === true)
-ok('降级后:没有任何动效在播', p.animatedCount === 0)
-ok('降级后:异常仍被标记出来(不是「什么都没画」)', p.items.filter((item) => item.visual.animated).length === p.abnormalCount && p.abnormalCount > 0)
-ok('降级后:静止外观与动时同形同色(仍可辨认)', p.items.every((item) => item.visual.shape === deviceVisual(item.status).shape && item.visual.tone === deviceVisual(item.status).tone))
-
-// 门控与图层生命周期
-ok('门控关闭:一台都不播', planDeviceEffects(many, { gated: false }).animatedCount === 0)
-ok('图层收起(关闭详情):一台都不播', planDeviceEffects(many, { layerVisible: false }).animatedCount === 0)
-
-p = planDeviceEffects(many, { animatedCap: 30, selectedCode: many[0].code })
-ok('选中的设备被强化', p.items.find((item) => item.code === many[0].code).emphasis === 1)
-ok('强化只作用于一台', p.items.filter((item) => item.emphasis === 1).length === 1)
-ok('选中不改变它是否在播(选中 ≠ 只有它有特效)', p.items.find((item) => item.code === many[0].code).playing === deviceVisual(many[0].status).animated)
-
-// 铺点几何:不重叠
-const spread = spreadDevices(SAMPLE_POINTS[0], { count: 20 })
-ok('20 台设备铺开位置互不重合', new Set(spread.map((d) => `${d.lon.toFixed(6)},${d.lat.toFixed(6)}`)).size === 20)
-
-console.log(`\n${failed === 0 ? '全部通过' : `${failed} 项失败`}`)
-process.exit(failed === 0 ? 0 : 1)

+ 0 - 20
.scratch/cockpit-point-locate-and-device-link/verify/t08-find-devserver.mjs

@@ -1,20 +0,0 @@
-// 只读探针:找出本仓库(mountElementId = tvt-app)的 dev server 到底在哪个端口。
-// 判据:首页 HTML 里出现 tvt-app(本仓库)而不是 app(别的项目)。
-const PORTS = [5173, 5174, 8719, 8301, 9801, 3000, 8080, 8000, 4173, 7001]
-
-for (const port of PORTS) {
-    const base = `http://127.0.0.1:${port}`
-    try {
-        const controller = new AbortController()
-        const timer = setTimeout(() => controller.abort(), 4000)
-        const res = await fetch(base + '/', { signal: controller.signal })
-        clearTimeout(timer)
-        const html = await res.text()
-        const isThisRepo = html.includes('tvt-app')
-        const isOther = /id="app"/.test(html)
-        const title = (html.match(/<title>([^<]*)<\/title>/i) || [, ''])[1]
-        console.log(`${String(port).padEnd(6)} ${res.status} tvt-app=${isThisRepo} otherApp=${isOther} title="${title.slice(0, 40)}"`)
-    } catch (error) {
-        console.log(`${String(port).padEnd(6)} 不可达 (${error.name === 'AbortError' ? '超时' : error.message})`)
-    }
-}

+ 0 - 38
.scratch/cockpit-point-locate-and-device-link/verify/t08-probe-devserver.mjs

@@ -1,38 +0,0 @@
-// 只读探针:确认 5173 上跑的是不是本仓库,以及本仓库的路由模式。
-// 不发任何写请求,不启动/重启服务。
-const BASE = 'http://127.0.0.1:5173'
-
-async function probe(path, accept) {
-    try {
-        const res = await fetch(BASE + path, { headers: accept ? { accept } : {} })
-        const text = await res.text()
-        return { status: res.status, ct: res.headers.get('content-type') || '', len: text.length, text }
-    } catch (error) {
-        return { status: 'ERR', error: error.message }
-    }
-}
-
-const targets = [
-    ['/src/pages/cockpit/index.vue', 'application/javascript'],
-    ['/src/modules/cockpit/prototypes/anchor-prototype-layout.js', '*/*'],
-    ['/src/modules/cockpit/prototypes/FogPrototype.vue', '*/*'],
-    ['/src/common/cesiumStaticPlugin.js', '*/*'],
-    ['/@fs/E:/City-Lifeline-Cockpit-TvT/src/modules/cockpit/prototypes/AnchorPrototype.vue', '*/*'],
-]
-
-for (const [path, accept] of targets) {
-    const r = await probe(path, accept)
-    const looksHtml = /<!DOCTYPE html>/i.test(r.text || '')
-    const marker = path.includes('anchor-prototype-layout') ? r.text.includes('solveAnchorLayout')
-        : path.includes('AnchorPrototype') ? r.text.includes('AnchorPrototype')
-            : path.includes('cockpit/index.vue') ? r.text.includes('AnchorPrototype')
-                : null
-    console.log(`${r.status} ct=${r.ct} len=${r.len} html=${looksHtml} marker=${marker}  ${path}`)
-}
-
-// 这份 5173 的页面路由里有没有 cockpit?抓首页 HTML 里的路由线索
-const home = await probe('/', 'text/html')
-console.log('\n--- 首页 HTML 里的 title / 脚本 ---')
-console.log((home.text.match(/<title>[^<]*<\/title>/i) || ['(no title)'])[0])
-console.log('包含 tvt-app?', home.text.includes('tvt-app'))
-console.log('包含 /src/main?', /src\/main/.test(home.text))

+ 0 - 58
.scratch/cockpit-point-locate-and-device-link/verify/t08-sfc-static-check.mjs

@@ -1,58 +0,0 @@
-// 静态自检(只读):SFC 能否编译 + 模板用到的标识符是否都在 <script setup> 里存在。
-// 这不是 build/dev,只是用仓库已有的 @vue/compiler-sfc 做一次语法与绑定核对。
-import fs from 'node:fs'
-import { createRequire } from 'node:module'
-
-const require = createRequire(import.meta.url)
-const { parse, compileScript, compileTemplate } = require('@vue/compiler-sfc')
-
-let failed = 0
-const ok = (name, cond) => { if (!cond) failed += 1; console.log(`${cond ? 'PASS' : 'FAIL'} ${name}`) }
-
-for (const file of [
-    'src/modules/cockpit/prototypes/AnchorPrototype.vue',
-    'src/pages/cockpit/index.vue',
-]) {
-    const src = fs.readFileSync(file, 'utf8')
-    const { descriptor, errors } = parse(src, { filename: file })
-    ok(`${file} 解析`, errors.length === 0)
-    if (errors.length) { errors.forEach((e) => console.log('  -', e.message)); continue }
-
-    try {
-        const script = compileScript(descriptor, { id: 'proto' })
-        ok(`${file} script 编译`, true)
-        if (descriptor.template) {
-            const tpl = compileTemplate({ source: descriptor.template.content, filename: file, id: 'proto' })
-            ok(`${file} template 编译`, tpl.errors.length === 0)
-            tpl.errors.forEach((e) => console.log('  -', e.message || e))
-        }
-    } catch (error) {
-        ok(`${file} 编译`, false)
-        console.log('  -', error.message)
-    }
-}
-
-// 逐个核对 AnchorPrototype 模板里的顶层标识符是否真的有定义
-const file = 'src/modules/cockpit/prototypes/AnchorPrototype.vue'
-const src = fs.readFileSync(file, 'utf8')
-const { descriptor } = parse(src, { filename: file })
-const script = compileScript(descriptor, { id: 'proto' })
-const bindings = new Set(Object.keys(script.bindings || {}))
-const content = descriptor.scriptSetup.content
-
-const required = [
-    'ANCHOR_FORMS', 'deviceVisual', 'effectLabel', 'shapeChar', 'toneOf',
-    'variant', 'point', 'pointVisual', 'plan', 'layout', 'panelStyle', 'arrowStyle', 'stateText',
-    'deviceCount', 'pointIndex', 'barEl', 'cfg', 'statusOrder', 'selectedCode', 'selectDevice',
-    'closePanel', 'choose', 'cycle', 'rebuild', 'samplePoints', 'flyToPoints',
-]
-const missing = required.filter((name) => !bindings.has(name) && !new RegExp(`(function|const|let)\\s+${name}\\b`).test(content))
-ok('模板/脚本引用的标识符全部有定义', missing.length === 0)
-if (missing.length) console.log('  缺失:', missing.join(', '))
-
-// 关键纪律:不能残留未清理的调试语句
-ok('无 console.log 残留', !/console\.(log|debug)\(/.test(content))
-ok('无 debugger 残留', !/\bdebugger\b/.test(content))
-
-console.log(`\n${failed === 0 ? '静态自检全部通过' : `${failed} 项失败`}`)
-process.exit(failed === 0 ? 0 : 1)

+ 0 - 136
.scratch/cockpit-primary-area-guardrails/issues/01-primary-area-config-contract.md

@@ -1,136 +0,0 @@
-# 主场景区的配置契约
-
-Type: grilling
-Status: resolved
-Labels: wayfinder:grilling
-Blocked by: None
-
-## 进度:100%
-
-已收口(2026-09-18,维护者「确认,同意收口」)。第 1–3 轮 Q1–Q9 全部落 `## Answer`。下游:02(相机限位数值)与 07(视距雾气规格)随本票解除阻塞;实现票按地图约定接在 `.scratch/cockpit-shell-panel-layout/issues/` 的 30 之后。
-
-## Question
-
-以下保留本票启动时的问题与候选;当前有效结论以 `## Answer` 为准。
-
-「主场景区」(`map.primaryArea`)在配置里的**字段形状、坐标口径与兜底来源**是什么?
-
-甲方已裁定(Q1=C):用**新配置字段显式给出**,本部署取值=甲方框定的沅陵县城范围。但下面三件事还没定死:
-
-1. **字段形状**。`map.primaryArea.bounds`=`[west, south, east, north]`(度)?还是中心+半径、或 GeoJSON?仓库既有约定是四元数组(`map.overlays[].bounds`、`Rectangle.fromDegrees(bounds[0..3])`)。
-2. **坐标口径**。甲方给的 `[110.32874994141685, 28.491303899290685, 110.46577922666275, 28.3917230659234]` 是 `[W, N, E, S]`(纬度降序)。归一化到仓库约定后是 `[110.328749941417, 28.3917230659234, 110.465779226663, 28.4913038992907]`。要不要在配置里就写归一化后的顺序,还是允许写 `[W, N, E, S]` 由代码翻?
-3. **兜底来源**。甲方要求未配置时兜底=「影像 bounds ∩ DEM 有效范围」(下称 B)**收紧到 50%**。已核实的数字:DEM `layer.json`(`D:\GIS\GIS\yuanlingDEM`)`valid_bounds` = `[110.000, 28.000, 111.000, 29.000]`,与影像 bounds(`map.overlays[0].bounds`)求交后 **B ≈ 97.8 × 111.3 km**;按每边折半收紧 50% 后 ≈ **48.9 × 55.8 km**,仍是框定范围的 **≈18.5 倍面积**(含城区以外的山区森林)。另经 Cesium 1.145 运行时核对:**Cesium 不读 DEM 的 `valid_bounds`**(只读 `layer.json` 的 `available` 与 `metadataAvailability`)—— 也就是说「DEM 有效范围」在运行时**无法从 Cesium API 取得**;前端自行 fetch `map.terrain.url` 的 `layer.json` 则另说。那么兜底到底怎么算?
-
-候选:
-
-- (a) 兜底=硬编码一个保守常量(例如框定范围本身,或某个与沅陵无关的中性盒),不要试图在运行时推导 B。
-- (b) 兜底=只取影像 bounds(`map.overlays[0].bounds`),再按比例收紧 50%(可运行时取得,无需读 DEM)。
-- (c) 兜底=沿用 B∩收紧 50%,由部署侧在构建期写死进内置配置,不在运行时推导。
-- (d) 不设兜底:没配 `primaryArea` 就整段功能关闭(不锁、不雾),并在引擎浮标上如实报状态。
-
-## Answer
-
-### 已确认决策:第 1 轮 Q1–Q4
-
-维护者确认:`Q1=A; Q2=A; Q3=A; Q4=(i)`。下述编号对应本票访谈,不对应决策地图早期的 Q 编号。
-
-1. **字段形状(Q1=A)**:`map.primaryArea` 本期只设 `bounds`,取四元数组 `[west, south, east, north]`,单位为度。无需另存几何中点,不增加中心+半径、GeoJSON 或名称字段。此处的几何中点不改变 ADR 0008 中相机旋转中心的既有定义;相机契约合成仍由 03 裁决。
-2. **坐标顺序(Q2=A)**:部署配置必须写好 `[W, S, E, N]`。甲方提供的 `[W, N, E, S]` 在准备配置时转换;运行时代码不自动翻转。校验要求 `west < east`、`south < north`;错误记入配置问题并进入 Q3 确认的关闭路径。
-3. **兜底责任(Q3=A)**:采用“部署期计算候选值+运行时未配置则关闭”。当部署需要根据数据覆盖准备候选值时,由部署侧离线计算 `B = 影像 bounds ∩ DEM 有效范围`,收紧后写入同一个 `map.primaryArea.bounds` 字段。运行时直接消费配置,不为此额外读取 DEM 元数据、不自行计算 B、不内置沅陵地理常量。未配置时关闭本 effort 中依赖主场景区的限位与自建雾气,并由引擎浮标如实提示;顺序非法也进入关闭路径,并保留配置错误原因。
-4. **收紧尺度(Q4=(i))**:东西跨度、南北跨度各保留 B 的 50%;经纬度矩形的面积比例约为原来的 25%,不是“面积保留 50%”。收紧围绕哪个中心,仍待下一轮确认。
-
-Q3 替代决策地图早先“未配置时由内置兜底自动得到 B 收紧 50%”的描述。部署期生成候选值不等于前端构建期写死源码,也不要求为修改区域重建应用;结果进入既有部署配置。
-
-### 已确认决策:第 2 轮
-
-5. **数据越界条款(Q6=A)**:契约不含「配置几何合法但超出影像/DEM 覆盖」的校验。配置即权威,越界属部署责任,运行时按配置生效。本部署实测:甲方框定范围 ⊂ DEM `valid_bounds` ⊂ 影像 bounds,无实际越界。
-
-### 已确认决策:第 3 轮(Q5/Q7/Q8/Q9 收口)
-
-6. **Q5 收紧中心(最终口径)**:=「进入系统默认位置=归位按钮位置」=票 30 交付的 `map.home` 预设经纬度(相机路径由 `.scratch/cockpit-shell-panel-layout/issues/03` 相机控制契约锁定,该票已 resolved、维护者 2026-09-17 验收)。数值随部署配置 `map.home.lon/lat` 走;本机为 lon 110.39028164 / lat 28.4436787。维护者澄清:此处 T03 指**外壳与面板布局(执行索引)的 #3 相机控制契约**,不是本 effort 的 03 —— 引用目标已 resolved,无相互参照、无死锁,不需要加 `Blocked by` 边。
-7. **Q7=A(作用域)**:中心只作用于**部署期候选值公式**,bounds 仍写死进配置;运行时不做以中心定位或边界跟随。「跟随中心变化」=部署改 `map.home.lon/lat` 后重算候选值。本部署显式值不做重定位(其几何中点与 `map.home` 相差约 0.7 km,由 02/03 的限位口径自理)。
-8. **Q8=(a)(形状)**:沿用矩形(长宽不等);「方形」为笔误。不新增正方形口径。
-
-据此,部署期候选值公式补全为:**B = 影像 bounds ∩ DEM `layer.json` 的 `valid_bounds`;候选值 = B 围绕 `map.home` 经纬度、东西/南北各折半**。用本机数据算得示例 ≈ `[110.1402, 28.1936, 110.6404, 28.6938]`(约 48.9 × 55.7 km,仍完整包含甲方框定范围);部署时按实际数据重算,不内置该常量。
-
-### 本部署的显式值
-
-本部署仍采用甲方框定的沅陵县城范围,不用更大的 B 收紧结果替换它:
-
-```json
-{
-  "map": {
-    "primaryArea": {
-      "bounds": [110.328749941417, 28.3917230659234, 110.465779226663, 28.4913038992907]
-    }
-  }
-}
-```
-
-以上是已确认的契约示例,尚未修改运行配置或实现代码。
-
-### 目标、边界与取舍
-
-目标:提供一个明确的主场景区配置,供后续限位、雾气与过渡票共同引用。
-
-边界:本票确定区域配置与不可用时的处理;限位数值归 02,相机中心、home 预设及进出状态机合成归 03,雾气曲线归 07,过渡手段归 08。
-
-取舍:四元数组沿用仓库现有矩形约定;写入时归一化、运行时拒绝反向顺序,使配置错误可见。部署期计算把数据覆盖责任放在部署侧,减少运行时依赖;相应代价是配置遗漏后不会自动恢复区域保护,必须通过明确状态提示发现并修正。
-
-剩余风险:契约各条已全部确认;待维护者过目最终 Answer 全文并确认 resolved 收口。长期注意点:若本 effort 的 03 将来改动 home 的 lon/lat(当前冲突只涉及 range/pitch),需回本票更新 Q5 的中心口径。
-
-## Comments
-
-### 2026-09-18 第 1 轮:fact-check 与 frontier 发出
-
-事实核实(已改正题面):
-
-1. DEM `layer.json` 实测 `valid_bounds` = `[109.99967, 27.99977, 111.00011, 29.00022]`(≈ `[110, 28, 111, 29]`)。影像 bounds ∩ 它 ≈ **97.8 × 111.3 km**;原题面「B = 48.9 × 55.8 km」实为**每边折半收紧后**的数字,已按实测改正。
-2. Cesium 1.145 运行时(`Build/CesiumUnminified/Cesium.js`)只读 `layer.json` 的 `available` 与 `metadataAvailability`,`valid_bounds` 零引用 —— 「DEM 有效范围运行时从 Cesium API 拿不到」成立。原题面把该结论归给调研 05,实际 05 的产物未含此项,已改为直接溯源。
-3. 配置分层现状(`services/cockpitConfig.js`):内置默认 → 构建期环境变量 → 运行时文件(gitignored、部署侧维护)→ 宿主注入。内置默认层里 `terrain.url` 为空、`overlays` 为空 —— mock/无配置部署**没有影像也没有 DEM**,B 在该世界无从谈起。
-4. `normalizeConfig` 现状:`overlays[].bounds` 只校验「四个数字」,**不校验顺序**;主场景区若要防「纬度降序」抄错,需要新校验。
-
-第 1 轮四问已发维护者:Q1 字段形状 / Q2 坐标口径(含顺序校验)/ Q3 兜底机制 / Q4「收紧 50%」语义。结论待答复后写进 `## Answer`。
-
-### 2026-09-18 第 2 轮:Q1–Q4 落盘,Q5–Q6 发出
-
-维护者答复:`Q1=A; Q2=A; Q3=A; Q4=(i)`。已写入 `## Answer`,并同步更新 `map.md` 第 1 条裁定(原「内置兜底自动得到 B 收紧 50%」已被 Q3=A 取代)。
-
-补充事实(继承第 1 轮子代理简报并已复核):
-
-1. ADR 0008:旋转中心=选中对象地表点 / 屏幕中心地面拾取点,不是场景或城市原点;主场景区的几何中点只用于部署期收紧计算,不触碰旋转中心。
-2. `cockpitConfig.js` 单次加载、进程内缓存;主场景区沿用该生命周期,不新增热更新。
-3. 本部署数据覆盖关系(供 Q6 参照):甲方框定范围 ⊂ DEM `valid_bounds` ⊂ 影像 bounds,当前无实际越界。
-4. 若按「B 几何中点、每边折半」收紧(Q5 候选 a),候选值 ≈ `[110.250, 28.250, 110.750, 28.750]`,仍完整包含甲方框定范围。
-
-第 2 轮两问已发维护者:Q5 收紧中心、Q6 数据越界条款。结论待答复后并入 `## Answer`。
-
-### 2026-09-18 第 3 轮:Q5 记录待澄清、Q6 落盘、Q7–Q9 发出
-
-维护者答复:`Q5=以在地球视角点击进入系统后的默认位置与归位按钮进入的同位置(T03中完成)为中心点,方形边界区域跟随这个中心点变化;Q6=A`。
-
-核实事实:
-
-1. T03 头部:`Blocked by: 01,02`,open —— 若 01 的结论依赖 T03 将锁定的进入默认位置,01↔03 出现相互参照,需按本轮 Q9 选收口方式。
-2. 「进入系统」与「归位按钮」同走票 30 的 home 预设(`mapService.flyToHome` / `CockpitShell.vue` 归位按钮);本机 home 预设 lon 110.39028164 / lat 28.4436787 / pitch −55 / range 12000。
-3. T03 的冲突 3 已写明:新限位下 home 的 range 12000 超标 5.8×,可能裁定改小 home 预设或豁免归位 —— 即 home 的最终值由 T03 锁定。
-4. 本 effort 收口状态(回应维护者「请确认」):**未收口**。map.md `Status: open`、进度 38%;01 claimed、02/03/07/08 open、04/05/06 resolved。Q5 的 T03 耦合使 01 的 resolved 需先定 Q9 的收口方式。
-
-第 3 轮三问已发维护者:Q7 作用域 / Q8 形状 / Q9 依赖收口方式。
-
-### 2026-09-18 第 4 轮:Q7/Q8 落盘,Q9 经澄清消解
-
-维护者答复:`Q7=A; Q8=描述有误,选(a)沿用矩形; Q9=描述有误,T03 指外壳与面板布局(执行索引)的 #3 相机控制契约`。
-
-核实事实:
-
-1. `.scratch/cockpit-shell-panel-layout/issues/03-camera-control-contract.md`:`Status: resolved`,2026-09-17 维护者人工验收结票(三键契约、阻尼、旋转中心语义,来源 ADR 0008)。
-2. 「进入系统默认位置=归位位置」由同 effort 的票 30(进入引导与视角预设,已 resolved)交付:`map.home` 预设 lon 110.39028164 / lat 28.4436787。
-3. 因此第 3 轮事实 #1 的「01↔03 相互参照」判断不成立——引用目标在另一 effort 且已 resolved,无需 `Blocked by` 边、无死锁,Q9 消解。
-4. 候选值公式示例(本机数据):B 围绕 `map.home` 经纬度、每边折半 ≈ `[110.1402, 28.1936, 110.6404, 28.6938]`,完整包含甲方框定范围。
-
-下一动作:等维护者确认最终 Answer 全文后,本票 resolved + 写 map Decisions-so-far 索引。
-
-### 2026-09-18 收口
-
-维护者确认「确认,同意收口」。已执行:`Status: claimed` → `resolved`、进度 100%、`map.md` 的 Decisions-so-far 与任务清单同步、map 进度由 38% 更新为 50%。下游 02、07 随之解除阻塞。

+ 0 - 85
.scratch/cockpit-primary-area-guardrails/issues/02-camera-limit-numeric-contract.md

@@ -1,85 +0,0 @@
-# 相机限位的数值契约
-
-Type: grilling
-Status: resolved
-Labels: wayfinder:grilling
-Blocked by: 01
-
-## 进度:100%
-
-已收口(2026-09-18,维护者「确认」)。第 1–2 轮 Q1–Q4b 全部落 `## Answer`。下游 03(限位与既有相机契约的合成 + 进出状态机)随本票解除阻塞。
-
-## Question
-
-限位三个量(中心点、相机离地高度、range)的**具体数值与换算口径**是什么?
-
-已锁:Q2=C = 三量同夹;最低仍须能贴近看设备;`h_max` 由「最高视野下地面垂直覆盖 ≤ 主场景区的 15%」约束。
-
-**基准数(已在澄清中算出,勿重复推导)**:主场景区 E-W 13,413 m、N-S 11,008 m;**N-S 的 15% = 1,651 m**。
-
-还没定死的四件:
-
-1. **按哪个宽高比定 range 上限?** Cesium 的 `fov = 60°` 是**水平**视场角,`fovy = 2·atan(tan(30°)/aspect)`。同一 1,651 m 预算下(正俯视口径):aspect 1:1 → 1,430 m;4:3 → 1,906 m;16:9 → 2,542 m —— **相差近 2×**。是运行时按真实画布 aspect 反算,还是定一个保守基准写死?
-2. **pitch 要不要参与?** 侧视时地面覆盖被约 `1/sin|pitch|` 放大:同一 range 在 pitch −10° 时能看到的地面远多于 −55°。是把 range 上限做成**随 pitch 动态收紧**,还是取最保守 pitch 一次性定死?
-
-   **跨票分工边界(与 07 对齐,勿各自定死)**:甲方 Q11 已裁「侧视露出的范围外交给雾气遮盖」。据此,本票应倾向**限位只按正俯视基准、不随 pitch 收紧**,把侧视露出的活留给 07 的雾气;否则 02 若又按最保守 pitch 收紧,range 上限会被过度压缩(用户连俯视 15% 的县城都到不了),与 Q2 冲突。反过来说,若本票最终决定「随 pitch 收紧」,则 07 的雾气兜侧视职责可相应减轻——两条路只能二选一,答案里必须显式写出与 07 的这条边界。
-3. **「垂直覆盖」的度量口径**。是取屏幕竖直方向对应的**地面跨度**(上面按此算),还是取相机到目标的距离、或别的量?pitch 变化时这个量怎么定义?
-4. **h_min 是多少?** 甲方要求保留"贴近看设备"的能力(仓库存量 `RANGE_LIMITS.min = 120 m`,票 30 已把拉近到 120 m 时的黑屏修掉)。h_min 是「不低于地形/不钻进地下」,还是一个正的绝对下限?若是前者,它与票 30 的地形感知拾取如何衔接?
-
-另注:现有 home 预设 `pitch −55, range 12000` 在此预算下超标 **5.8×** —— 该冲突**不在本票裁决**,见 03。
-
-## Answer
-
-### 已确认决策:第 1 轮(Q1–Q3 锁定,Q4 语义确认)
-
-维护者答复:`Q1=A; Q2=选A(正俯视只提供一个参考基准值,其它倾斜角不改变该规则); Q3=(a)(度量只在正俯视定义); Q4=(ii)(h_min 是缩放下限,但确实还要「防入地」)`。编号为本票访谈编号。
-
-1. **range 上限换算(Q1=A)**:`range_max = 1651 / (2·tan(fovY/2))`,fovY 取 `engine.fovY()`(运行时真实垂直视场角,随画布 aspect 变)。resize 不主动回拉,clamp 只在输入时生效(现状)。
-2. **pitch 不参与(Q2=A)**:正俯视约束只提供一个**相机高度/弹簧臂长度的参考基准值**,其它镜头倾斜角不改变该规则;`range_max` 固定为正俯视导出值。与 07 的边界:**02 只保正俯视垂直覆盖 ≤ 15%,侧视露出的范围外由 07 雾遮盖**。
-3. **垂直覆盖度量(Q3=(a))**:屏幕竖直方向对应的地面跨度(正俯视 = `2·range·tan(fovY/2)`);该度量只在正俯视定义,pitch 变化时不扩展。
-4. **h_min 语义(Q4=(ii))**:h_min 即「贴近看设备的缩放下限」= `range_min = 120 m`(票 30 已锁,`RANGE_LIMITS.min`);此外维护者确要一条额外的**「防入地」**下限(相机不入地形),口径见第 2 轮。
-
-### 已确认决策:第 2 轮(Q4「防入地」落定)
-
-维护者答复:`Q4b=a,margin 暂定 10 m`。
-
-5. **「防入地」口径(Q4b=(a))**:地形感知下限 —— 相机离地高度 ≥ 其正下方地形 + margin(**暂定 10 m**,实现/走查可按观感微调)。在每次输入(滚轮/轨道)时算相机地面投影点、`globe.getHeight` 采样该处地形,不足则**夹 range**(不夹 pitch、不动 pivot);瓦片未加载时退回固定 10 m 绝对下限。「不足时夹 range」的合成规则随 03 实现。
-
-### 最终数值契约(汇总)
-
-- 中心点 ∈ `primaryArea.bounds`(01,矩形包含)。
-- `range ∈ [120, range_max]`,`range_max = 1651 / (2·tan(fovY/2))`(fovY 运行时实时、随 aspect 变;正俯视基准,pitch 不改变该规则)。
-- 相机离地高度 `h = range·sin|pitch|`:h_max 被 range_max 隐式罩住;h_min 由「range_min=120(缩放下限)+ 地形感知 ≥ 地形+10m(防入地)」双保险。
-- 垂直覆盖度量 = 屏幕竖直方向地面跨度,只在正俯视定义;侧视超界由 07 雾遮盖。
-
-### 边界(本票不裁决)
-
-pipeline 视图 range 400–6000 与 range_max 的合成优先级、home 预设超标 5.8×、到边界硬停/阻尼渐停 —— 均归 03;雾曲线归 07;E-W 水平覆盖在宽屏下超过 E-W 的 15% 属甲方「仅垂直视野」口径的自然结果,接受。
-
-## Comments
-
-### 2026-09-18 第 1 轮:fact-check 与 frontier 发出
-
-事实核实:
-
-1. `domain/camera.js`:`RANGE_LIMITS = { min: 120, max: 30000000 }` 是唯一全局常量;域层只有 spring-arm 的 `{heading, pitch, range}`,**没有「相机离地高度 h」这个量**。相机离地高度 ≈ `range · sin|pitch|`(pivot 已由票 30 地形感知,其 `height` 即 pivot 处地表高)。
-2. `cesiumEngine.fovY()` 返回 `camera.frustum.fovy`(Cesium 按水平 60° + 画布 aspect 实时算出),resize 即变。
-3. clamp 只在输入时生效(`zoomByWheel` / `setHpr` / `applyViewPreset`),**无逐帧主动回拉**。
-4. 视图模式:管线 pitch −18、range 400–6000;地面 pitch −85~−5。
-5. 甲方裁定 #2 原文「最低仍须能贴近看设备」—— 见第 1 轮 Q4 的语义澄清。
-
-第 1 轮四问已发维护者:Q1 宽高比 / Q2 pitch / Q3 度量口径 / Q4 h_min 语义。
-
-### 2026-09-18 第 2 轮:Q1–Q3 落盘,Q4「防入地」口径待定
-
-维护者答复:`Q1=A; Q2=正俯视约束只提供一个相机高度/弹簧臂长度的参考基准值,其它倾斜角不改变该规则(选A); Q3=(a),度量只在正俯视定义; Q4=(ii),h_min 是缩放下限,但确实还要「防入地」`。
-
-已写入 `## Answer` 第 1 轮。剩余:Q4 的「防入地」取地形感知还是绝对小值、margin 取多少 —— 第 2 轮发出。
-
-### 2026-09-18 第 3 轮:Q4b 落定
-
-维护者答复:`Q4b=a,并暂定 margin = 10 m`。已写入 `## Answer` 第 2 轮(地形感知下限:相机离地高度 ≥ 正下方地形 + 10 m 暂定;不足时夹 range,机制落 03)。剩余动作:等维护者确认最终 Answer 全文后,本票 resolved + 写 map Decisions-so-far 索引。
-
-### 2026-09-18 收口
-
-维护者确认。已执行:`Status: claimed` → `resolved`、进度 100%、`map.md` 的 Decisions-so-far 与任务清单同步、map 进度更新。下游 03 的 `Blocked by: 01,02` 两项齐备,随之解锁。

+ 0 - 86
.scratch/cockpit-primary-area-guardrails/issues/03-camera-contract-composition-and-entry-state.md

@@ -1,86 +0,0 @@
-# 限位与既有相机契约的合成 + 进出状态机
-
-Type: grilling
-Status: resolved
-Labels: wayfinder:grilling
-Blocked by: 01,02
-
-## 进度:100%
-
-已收口(2026-09-18 维护者确认)。Q1–Q5 与 R2-1…R2-6 全部裁定并落 `## Answer`;ADR 0009 已按 R2-6 创建、ADR 0008 已补指向行;map Decisions-so-far 已同步。**03 取代 02 的 `range_max` 锚点(2,542 m → 12,000 m)**,平行图 `cockpit-point-locate-and-device-link/issues/03` 已记跨图提示。
-
-## Question
-
-区域限位要落在**既有**相机契约的哪一层,进/出主场景区的**状态机**如何与视图模式、票 30 的 home 预设共存?
-
-现状(`domain/camera.js` + `domain/viewModes.js`):
-- `RANGE_LIMITS = { min: 120, max: 30000000 }` —— **全局常数**,上限覆盖全球视角。
-- `PITCH_LIMITS` 按**视图模式**分:`ground {min:−85,max:−5}` / `pipeline {min:−85,max:+5}`。
-- 管线视图把 range 压到 400–6,000 m、pitch 放宽到允许平视。
-- 票 30 的 home 预设 `pitch −55, range 12000`,已验收("12 km 是县域级")。
-- ADR 0008 锁死三键分工与阻尼,且"管线视图不重置旋转中心、不新增第二套鼠标绑定"。
-
-## Answer
-
-### 已确认决策:第 1 轮(Q1–Q5)
-
-维护者答复:`Q1=d; Q2=a(含闲置回初始态); Q3=a; Q4=c(软回弹,软区 5%); Q5=a+b`。编号为本票访谈编号。
-
-1. **限位锚点(Q1=d)**:**最远视野 = 归位默认视角** —— `range_max = home.range = 12,000 m`(pitch 沿用 home 的 −55,不单独收紧)。home 预设**不改**。02 的 `range_max = 1651 / (2·tan(fovY/2))`(≈2,542 m)与"15% 垂直覆盖预算"**被本票取代**,不再作为限位基准(该预算的数值不再约束相机;雾的"遮缝半径"另由 07 以本票口径重新对齐)。
-2. **进出状态机(Q2=a)**:显式模式锁 —— 点「进入系统」/「归位」进区并锁定;**只有「返回全球」能解除**。判定与相机位置无关,不随跨界自动切换(避免贴边抖动)。**全球态闲置 5 秒未操作 → 回到程序初始态**:地球自转恢复 + 屏幕中央闪烁「点击进入系统」。
-3. **与管线视图合成(Q3=a)**:取交集。区内管线视图 `range ∈ [400, min(6000, range_max)] = [400, 6,000]` —— 因 `range_max = 12,000 > 6,000`,**区内管线视图与现状完全一致,无额外收紧**;pitch 上下限沿用视图模式(区域不收紧 pitch,02 已定)。
-4. **边界行为(Q4=c 软回弹)**:拖拽时 `target.pivot` 允许越出 `primaryArea.bounds` 最多一个**软区(框短边的 5%,≈550 m,可微调)**;越界部分在**松手时按既有阻尼弹回**框内(不是拖拽中持续回弹)。`shown` 始终由阻尼跟随 `target`。断言口径:`shown.pivot` 最终回到框内;越界只出现在拖拽过程。
-5. **返回全球语义(Q5=a+b)**:解除限位 + 阻尼飞到**程序初始态的全景**(heading 0 / pitch −60 / range 20,000,000,即引导页开始时的星球视角)+ 之后完全自由;**全球态闲置 5 秒 → 重新回到引导页**(星球自转 + 「点击进入系统」),**不重置一级模式/筛选**;顶栏按钮在区外自动变回「归位/进入」。
-
-### 已确认决策:第 2 轮(R2-1…R2-6)
-
-维护者答复:`R2-1=推荐; R2-2=推荐(软区 5%); R2-3=推荐; R2-4=推荐; R2-5=推荐; R2-6=推荐`。
-
-6. **`range_max` 最终数值(R2-1)**:`range_max = 12,000 m`,精确等于 `home.range` —— 归位即最远视野,再往外滚轮即夹。`isAtHome` 的 2% 相对容差(240 m)足够识别"已归位"。
-7. **软回弹参数(R2-2)**:软区 = 框短边(N-S 11,008 m)的 **5% ≈ 550 m**,可微调;松手回弹,不持续回弹。
-8. **返回全球目标(R2-3)**:程序初始态全景(heading 0 / pitch −60 / range 20,000,000)。
-9. **状态与计时器落点(R2-4)**:`areaState: 'locked' | 'global'` 进 **`filterStore`**(与 `viewMode` 同层——全局视图条件,切一级模式不重置);`mapService` 负责状态转移(进入/返回/归位);`cameraControl` 按状态换 limits(新增 `setRangeLimits` 一类,与既有 `setPitchLimits` 对称);**5 秒闲置计时器与引导页显隐归 `CockpitShell`**(组件内或 `uiStore`),只在 `global` 态计时、任何交互即重置、**不重置** `mode`/筛选。`map.intro.enabled=false`(其它票走查自隔离)时按 `global` 自由视角启动、不弹引导页。
-10. **顶栏按钮(R2-5)**:区内显示「归位」+「返回全球」两个 chip(归位沿用现有);区外只显示一个「归位/进入」chip。
-11. **ADR 归属(R2-6)**:新建 **ADR 0009「主场景区的相机限位与进出状态机」**,记录第 1–2 轮结论;ADR 0008 补一行指向 ADR 0009。
-
-### 防入地(继承 02,机制落本票)
-
-- 相机离地高度 `h = range·sin|pitch|`;每次输入(滚轮/轨道)时取相机正下方地形(`globe.getHeight`),若 `h < 地形 + 10 m` 则**夹 range**(不夹 pitch、不动 pivot);瓦片未加载时退回固定 10 m 绝对下限。
-- 与区域限位的合成:输入时 `range` 同时被 `[120, range_max]`、管线视图 `[400, 6000]`、防入地下限三处夹取,最终生效 = 三者交集(防入地是下限侧,与 120/400 取 max;range_max 是上限侧)。
-
-### 边界(本票不裁决)
-
-- 雾的曲线、配色、门控 —— 归 07(以本票的 `areaState` 与 `range_max` 为输入契约)。
-- 低精度→高精度过渡手段 —— 归 08(以本票的进出状态机为输入契约)。
-- 中心点 `height` 口径、`setFocus`(选中只换中心)—— 既有票 03/04/30 已锁,本票不重复。
-- 平行图 `cockpit-point-locate-and-device-link` 的镜头定位 —— 其上限受 `range_max = 12,000` 约束;其票 03 的 fact-check 引用 02 的 `1651/(2·tan(fovY/2))` 公式,**需以本票取代**(落盘时在该图相关票面记一行)。
-
-## Comments
-
-### 2026-09-18 第 1 轮:fact-check 与 frontier 发出
-
-事实核实(只读复核,均在本仓或同 effort 票面内):
-
-1. `domain/camera.js`:`RANGE_LIMITS = { min: 120, max: 30000000 }` 是唯一全局常量;域层只有 `{heading, pitch, range}`,无「相机离地高度 h」这个量(`h ≈ range·sin|pitch|`,pivot 已由票 30 地形感知)。
-2. clamp 只在输入时生效(`zoomByWheel` / `setHpr` / `applyViewPreset`),无逐帧主动回拉 —— 因此若进区后 range 仍为 12000,第一次滚轮内滚会从 12000 一次夹到 ~2542(4.7× 突跳),这是 Q1 冲突的具体危害。
-3. 管线视图 `PIPELINE_CAMERA`:pitch −18、range 压到 35% 并夹 [400, 6,000];`viewHprFor` 只动 pitch 与 range,heading 与中心原样带走。
-4. 进入/归位同走 `mapService.goHome()` → `cameraControl.applyViewPreset()`;引导 `CockpitIntro` `@enter="enterSystem"` → 停自转 + `goHome`。无「返回全球」现有按钮。
-5. `filterStore` 已持 `viewMode`(ground/pipeline)作"全局视图条件"先例;`uiStore` 只管 mode/面板。
-6. 平行图 `cockpit-point-locate-and-device-link` 的 03(镜头定位)claimed,fact-check 引用本 effort 02 的 `1651/(2·tan(fovY/2))`;其 Notes 明确"镜头定位必须与 .scratch/cockpit-primary-area-guardrails 的限位数值契约(02 resolved)与其进出状态机(03 open)合成"。
-7. `CONTEXT.md` 已收录「主场景区」词条(未提交)。
-
-第 1 轮五问已发维护者:Q1 home 预设冲突 / Q2 进出判定与状态机 / Q3 管线合成 / Q4 边界行为 / Q5 返回全球语义。
-
-### 2026-09-18 第 2 轮:Q1–Q5 落盘,R2-1…R2-6 发出
-
-维护者答复:`Q1=d(以归位默认视角为参考); Q2=a(且全球态闲置 5 秒回初始态); Q3=a; Q4=c(软回弹); Q5=a+b(先飞出,闲置 5 秒回引导页,不重置模式/筛选,顶栏区外变回归位/进入)`。已写入 `## Answer` 第 1 轮。
-
-Q1=d 连锁:`range_max` 锚点从 02 的 `1651/(2·tan(fovY/2))`(≈2,542 m)改为 **home.range = 12,000 m**;02 的"15% 垂直覆盖预算"不再作为限位基准;Q3 的管线合成冲突因 `min(6000, 12000)=6000` 自动消解。
-
-第 2 轮六问已发:R2-1 数值 / R2-2 软区参数 / R2-3 返回全球目标 / R2-4 状态落点 / R2-5 顶栏形态 / R2-6 ADR。
-
-### 2026-09-18 第 3 轮:R2 全部落盘
-
-维护者答复:`R2-1=推荐; R2-2=推荐(软区 5%); R2-3=推荐; R2-4=推荐; R2-5=推荐; R2-6=推荐`。已写入 `## Answer` 第 2 轮;软区定为框短边 5%(≈550 m)。
-
-剩余动作:等维护者确认最终 Answer 全文与 ADR 0009 内容后收口 resolved;同步 map Decisions-so-far;向平行图 `cockpit-point-locate-and-device-link` 相关票面记"以 03 为准,02 的 range_max 锚点已取代";随后提醒 `/to-spec`。

+ 0 - 35
.scratch/cockpit-primary-area-guardrails/issues/04-research-cesium-ground-fog-mechanisms.md

@@ -1,35 +0,0 @@
-# 调研 —— Cesium 地面视距雾的机制与限制
-
-Type: research
-Status: resolved
-Labels: wayfinder:research
-Blocked by: None
-
-## 进度:100%
-
-已关闭。产物:[`research/cesium-ground-fog-mechanisms.md`](../research/cesium-ground-fog-mechanisms.md)。只做只读调研;未改任何文件、未跑 build/dev。
-
-## Question
-
-在县城尺度(相机离地数百米到数公里)下,Cesium 1.145 有哪些**能真正限制视距 / 形成地面雾霾**的机制?各自的能力边界与代价是什么?特别是:
-
-- `scene.fog` 默认状态是什么?在地面尺度到底能不能用?
-- 有没有现成的雾/霾后处理级?
-- 自建深度后处理雾的可行性、需要的 API 与风险?
-- 仓库里那条 Three.js 增强管线(`useThreeGeospatial`)能不能承载雾?它现在是活的吗?
-
-## Answer
-
-**结论:内置 `scene.fog` 当不了稳定视距限制;引擎无现成雾后处理级;自建 depth 后处理雾是唯一可控解;Three.js 管线在驾驶舱里是休眠的。**
-
-1. **默认就是开着的**。`VIEWER_OPTIONS`(`services/cesiumEngine.js`)全是控件开关,不碰雾/大气/天空盒/post-process,所以 `Scene.fog` 停在 Cesium 默认:`enabled = true`、`density = 6e-4`、`heightScalar = 1e-3`、`heightFalloff = 0.59`、`maxHeight = 8e5`、`visualDensityScalar = 0.15`。
-
-2. **内置雾在地面尺度"生效但太弱",且结构上不可控**。`Fog.update` 每帧把密度算成 `density·heightScalar·(h/maxHeight)^−0.59`,再乘 `1 − |dot(camera.direction, up)|`。实测推得的 50% 雾距离:相机 200 m 高 → 约 20 km;500 m → 34 km;1 km → 51 km;3 km → 97 km;10 km → 197 km。驾驶舱 home 视角(`pitch −55, range 12000` ≈ 9.8 km 高,方向因子仅 0.181)在 50 km 处只有约 **1.3%** 雾,肉眼不可见。三个硬伤:**正俯视时 `1−|dot| = 0`,雾完全消失**;密度**绑相机高度**,半径随高度漂移;密度按高度反比,同参数下 200 m 高度比 10 km 高度浓约 10 倍,一个 `density` 值服务不了两个量级。
-
-3. **没有现成的雾/霾后处理级**。`PostProcessStageLibrary` 的 17 个工厂里没有雾(blur、depthOfField、edgeDetection、silhouette、bloom、ambientOcclusion、FXAA、4 种 tonemapping、autoExposure、blackAndWhite、brightness、nightVision、depthView、lensFlare)。但 `PostProcessStage` 的 shader **默认就带 `colorTexture` 与 `depthTexture`** 两个 uniform,`Context.depthTexture` 运行时确认存在 —— 自建 depth 雾可行,代价是场景先渲到纹理 + 多一遍全屏 pass(`usePostProcess`),需要 `WEBGL_depth_texture`(WebGL2 具备),且 `Context.depthTexture` **不在随包 `Cesium.d.ts` 里**(半公开 API,升级有回归风险)。
-
-4. **`globe.showGroundAtmosphere` 不解决问题**。它默认开(WGS84),但由 `lightingFadeIn/FadeOutDistance` 围栏,管的是几十到几百公里的**高空大气透视**,量级不对。
-
-5. **Three.js 那条管线在驾驶舱里是休眠的**。`lib/clouds` 确实有 Haze(`hazeDensityScale = 3e-5`、`hazeExponent = 1e-3`),但 `src/modules` **零引用**;它只在 `/plugins/useThreeGeospatial/<page>` 作为独立 `<TresCanvas>` 场景可达,自带 `EffectComposer/CloudsEffect/AerialPerspectiveEffect`,**不是叠在 Cesium 画布上**的层。
-
-6. **仓库现状**:`src/modules/cockpit` 里**零** `camera.changed` / `preRender` / `percentageChanged` / 影像层 alpha/show/LOD 门控;唯一的逐帧宿主循环是票 30 的进入引导自转(`mapService` 的 `requestAnimationFrame`)。`Camera.percentageChanged` 默认 `0.5`,做高度驱动的淡入需要调小它或直接轮询 `preRender`。

+ 0 - 39
.scratch/cockpit-primary-area-guardrails/issues/05-research-overlay-rectangle-pop-mechanisms.md

@@ -1,39 +0,0 @@
-# 调研 —— 精细矩形跳变的成因与可用手段
-
-Type: research
-Status: resolved
-Labels: wayfinder:research
-Blocked by: None
-
-## 进度:100%
-
-已关闭。产物:[`research/overlay-rectangle-pop-mechanisms.md`](../research/overlay-rectangle-pop-mechanisms.md)。只做只读调研;未改任何文件、未跑 build/dev。
-
-## Question
-
-从全球飞到县城视角的途中,「全球粗底图 → 本地精细影像矩形」为什么会突兀地跳出一块颜色明显不同的方形区域?引擎里已有哪些可用来消除或掩盖它的手段,各自的边界是什么?
-
-## Answer
-
-**结论:矩形边缘是 GLSL 硬切、引擎零羽化零交叉淡入、且精细层并非"近了才加载";跳变有**两类**,一类是矩形整体出现,一类是矩形内部逐级换挡。**
-
-1. **图层构成**:全局底图=Cesium 自带离线 Natural Earth II(TMS),且**只有 0–2 级**(`tilemapresource.xml` 的 `TileSet order 0/1/2`)→ 最高约 **19.5 km/像素**,整个沅陵在底图上只有约 6×6 个像素。精细层=`/gis/pic3` 的 TMS,挂在 provider 的 `rectangle` 内,画在底图之上,层序 `[NaturalEarthII, 精细层]`,初始色 `#04121f`。
-
-2. **精细层是全不透明的,且 `minimumLevel: 0` 是空操作**。`ImageryLayer.alpha` 默认 1.0;`UrlTemplateImageryProvider` 的 `_minimumLevel` 默认就是 0,所以配置里那句 `"minimumLevel": 0` **等于没写** —— 精细层从自身金字塔最粗级别起就有资格被请求,**不存在"靠近了才开始加载"**。
-
-3. **边缘是硬切,没有羽化**。`GlobeFS.glsl` 用 `step()` 生成 0/1 遮罩(`textureAlpha *= alphaMultiplier.x * alphaMultiplier.y`),无羽化宽度、无混合距离、无抖动。矩形外由底图层兜着(`isBaseLayer()` 的 texel 拉伸),**两层之间没有任何内置交叉淡入** —— 整个引擎里没有 imagery fade 逻辑可复用,任何渐显都得手写。
-
-4. **跳变有两类,不要只处理一类**:
-   - (a) 矩形**首批瓦片到位时整体出现**:`GlobeSurfaceTileProvider` 对未就绪或无影像的层直接 `continue`,所以该层是"一帧内从无到全不透明"。
-   - (b) 矩形**内部持续逐级换挡**:影像级别**被地形级别牵着走**(`ImageryLayer` 用 `terrainProvider.getLevelMaximumGeometricError(tile.level)` 推 `imageryLevel`),每次地形 LOD 细化都连带提升影像级别、无过渡替换。
-
-5. **可用手段与其边界**:
-   - `ImageryLayer.alpha` —— 可写,但**只是整层一个 uniform**(能整体淡入淡出,**不能羽化边缘**);且越过 0.0 会翻转 shader 变体(有重编译/切换成本)。JSDoc 里那个按瓦片的函数形式**在随包引擎里没有实现**。
-   - `ImageryLayer.show` —— 二元开关;隐藏底图会把精细层**提升为 base layer**(于是它的 texel 被拉伸到全球),是个陷阱。
-   - `ImageryLayer.rectangle` —— **只读**,不能动画。
-   - `minimumTerrainLevel` / `maximumTerrainLevel` —— 可按地形级别做**硬门**("太远就不显示"最省事的近似闸门),但是硬开关、不是渐变。
-   - `brightness/contrast/gamma/saturation/hue` —— 只能**对齐颜色**,抹不掉分辨率台阶。
-   - 逐帧驱动点:`Scene.preRender` / `postRender`;`Camera.changed`(默认 `percentageChanged = 0.5`,偏粗,需调小);`globe.tileLoadProgressEvent`(唯一的加载进度信号)。
-   - **注意**:仓库用 `new ImageryLayer(provider)` 建精细层,而 `ImageryLayer.readyEvent` **只在 `fromProviderAsync` 工厂里触发** —— 所以精细层**没有现成的"就绪"信号**。
-
-6. **历史**:票 21 与票 30 都**没有**尝试或推迟过颜色对齐、边缘羽化、交叉淡入或 LOD 过渡 —— 这是全新工作面。

+ 0 - 46
.scratch/cockpit-primary-area-guardrails/issues/06-research-three-fog-shader-reuse.md

@@ -1,46 +0,0 @@
-# 调研 —— Three.js 雾 shader 可复用面与 Cesium 深度互通可行性
-
-Type: research
-Status: resolved
-Labels: wayfinder:research
-Blocked by: None
-
-## 进度:100%
-
-已关闭(resolved)。甲方 Q12 已裁定:**优先采用简单、能落地的方法,极难完成的就此作罢** —— 「Cesium 深度喂给 Three」备选**废止**,雾气锁定**单一 Cesium 渲染器**的自建 `PostProcessStage`(路线 B),数学从 Three haze 侧搬。
-
-产物:[`research/three-fog-shader-reuse.md`](../research/three-fog-shader-reuse.md)。只读调研:未改 `src/`、未装依赖、未跑 build/dev。
-
-下一步:本图 frontier 只剩 01(主场景区的配置契约);07 已只欠 01。
-
-## Question
-
-甲方裁定(Q9=B、Q12=B):雾气走**自建 Cesium depth 后处理级(路线 B)**,但要**参考 Three.js 增强管线的 shader 源码写法**,以求更好观感、更低开销;同时**另立一张调研票**评估「把 Cesium 深度喂给 Three」的可行性。
-
-本票回答两件事:
-
-1. **可移植的数学与写法**。`src/plugins/useThreeGeospatial/lib/clouds`(`CloudsEffect.ts` / `CloudsMaterial.ts` / README 的 `haze`、`hazeDensityScale = 3e-5`、`hazeExponent = 1e-3`、`AerialPerspectiveEffect`)里,哪些是**与渲染器无关的纯数学/曲线**(可搬进 Cesium 的 `PostProcessStage` GLSL/Cesium 方言),哪些**耦合 Three 的渲染器状态**(必须重写)?具体给出:雾/霾的密度函数、颜色混合公式、相位/散射项是否与太阳方向耦合、以及它对"距离"的定义(视空间深度 vs 世界距离 vs 大气路径长度)。
-
-2. **深度互通的真实可行性**(作为备选,不改架构)。要让 Three 的雾正确遮蔽 **Cesium 渲染的**地形/影像/3D Tiles,需要把 Cesium 的深度缓冲喂给 Three。评估:`Context.depthTexture` 能否作为 Three 的 depth 输入?两者相机模型(Cesium `lookAtTransform` 的 `HeadingPitchRange` vs Three 的透视相机)与深度非线性约定如何对齐?需要多少跨渲染器同步代码、每帧成本多少、失败面有哪些?
-
-**约束(不得越界)**:ADR 0004 锁死「Cesium 为主、Three.js 为增强,**不接管 Cesium 场景**」;ADR 0007 锁死交付以 `src/modules/cockpit` 为准、**大型插件不作为驾驶舱主体依赖**。本票**只做评估**,结论若指向"引入第二套渲染器",必须明确标注它需要**推翻/修订 ADR 0004**,并交回甲方裁定 —— 不得直接建议实施。
-
-**做法**:只读调研 + 网络检索 Cesium/Three 官方文档与社区实践。**不要**引入依赖、不要改 `src/`、不要跑 build/dev(`AGENTS.md`)。产物落 `research/three-fog-shader-reuse.md`,并在票面记一条指向它的链接。
-
-## Answer
-
-**甲方裁定(Q12 定案)**:优先采用**简单、能落地**的方法,极难完成的就此作罢。据此**废止**「把 Cesium 深度喂给 Three」这条备选:它只有两条路 —— 把 Cesium 私有 context 交给 Three(上游自 2013 年 open、明确不支持),或每帧 `readPixels` 回读(管线停顿、量化、动态 near/far 归一化);社区同 canvas 方案实测俯视遮挡错乱,正中驾驶舱近俯视主视角;采用它还须先推翻 ADR 0004。**定案:维持单一 Cesium 渲染器,雾气走自建 `PostProcessStage`(路线 B),数学从 Three haze 侧搬。** 07、08 只在本边界内选简单手段,不再评估第二渲染器路线。以下为裁定前的调研记录。
-
-**结论:Q1 的雾/霾数学(密度曲线、闭式光深积分、混合公式、相位函数)是与渲染器无关的纯数学,可整体搬进 Cesium 自建 `PostProcessStage`(路线 B 无障碍);Q2「把 Cesium 深度喂给 Three」评级=极难,且与 ADR 0004 冲突,须交回甲方裁定,本票不建议实施。** 全文与证据:[`research/three-fog-shader-reuse.md`](../research/three-fog-shader-reuse.md)。
-
-1. **密度与「距离」口径**:密度=`modulation·hazeDensityScale·exp(−cameraHeight·hazeExponent)`(默认 `3e-5` / `1e-3`,`clouds.frag:666`),光深是沿视线的**闭式解析积分**(iq fog 公式,`clouds.frag:671-680`)。「距离」=射线-椭球求交 + 场景深度缓冲(RGBA 打包 → reverseLogDepth → 余弦修正)钳出的**世界射线路径长**(`clouds.frag:806-814`),不是视空间深度。注意 `max(cosθ, 1e-5)` 钳制(`clouds.frag:673`):一切俯视/平视视角下公式退化为 **OD ≈ d₀·L 的线性距离雾**——恰好适配 07 的各向同性约束。
-
-2. **颜色混合**:`C_out = C_in·(1−a) + inscatter`(`clouds.frag:988`)。inscatter 含双 HG 相位(`cosTheta = dot(sunDirection, rayDirection)`)+ 预计算 LUT 辐照 + 阴影长度——**太阳耦合全在颜色项**;距离/透射率项与太阳无关,07 用常量雾色即可完全剥离太阳,三张大气 LUT 不搬。
-
-3. **可搬性判定**:纯数学项(密度、积分、混合、HG 相位)原样可搬;结构同构需改写(深度采样换 `czm_readDepth`、uniform 换成 Cesium 自动 uniform `czm_inverseProjection` / `czm_eyeHeight` / `czm_ellipsoidRadii`、GLSL1 方言、两套 log 深度编码互转);不可搬(EffectComposer/MRT/temporal upscale、transmittance/scattering/irradiance LUT——纯雾不需要)。详见笔记 §1.5 判定表与 §2。
-
-4. **默认参数不可用**:默认参数(coverage 调制 0.5)下 50% 起雾 ≈ **56 km**(h=200 m),比 07 的 1,651 m 预算弱 ≈ **30×**;07 须把密度抬约 30 倍或换更陡曲线族。
-
-5. **Q2 前提修正**:`Context.depthTexture` 是**布尔能力标志**(是否支持 WEBGL_depth_texture,`Context.js:714-725`),不是纹理对象——票面与 04 笔记的表述以本票为准;真正的场景深度是 **D24S8 打包纹理**(DEPTH_STENCIL + UNSIGNED_INT_24_8,`FramebufferManager.js:189-196`),格式与 Three `DepthStencilFormat` 相容,但 **WebGL 纹理不能跨 context 共享**。
-
-6. **Q2 评级:极难**。零拷贝路线=把 Cesium 私有 context 交给 Three(上游 [issue #648](https://github.com/CesiumGS/cesium/issues/648) 自 2013 年 open,明确不支持);否则每帧 `readPixels` 回读(管线停顿、24bit 量化、Cesium 动态 near/far 需手动归一化);社区同 canvas 方案实测**俯视/仰视遮挡错乱**,正中驾驶舱近俯视主视角。**该路线=引入第二套渲染器接管合成,与 ADR 0004 冲突,须交回甲方裁定**。

+ 0 - 166
.scratch/cockpit-primary-area-guardrails/issues/07-fog-implementation-spec.md

@@ -1,166 +0,0 @@
-# 视距雾气的实现规格
-
-Type: grilling
-Status: resolved
-Labels: wayfinder:grilling
-Blocked by: 01,04,06
-
-## 进度:100%
-
-维护者在第 4 轮答复“全部按推荐来”:确认按决策范围收口、雾失效时业务可用优先、B 参数采用原型默认值。本票作为决策票 resolved;不等于进入过渡已修复、完整视觉验收通过或性能达标。首次人工反馈认可 B 的颜色/距离,仍保留“进入遮挡过晚”的真实失败记录。
-
-下一步:已解除[低精度 → 高精度过渡的解法](08-coarse-to-fine-transition-solution.md)的阻塞,由其细化并验证完整影像交接、取消/返回与加载失败恢复;后续实施保留 P95 帧耗时增幅 ≤15%、管线可读性和进出无突闪的硬验收。可执行 /to-spec 单独固化本票结论,不覆盖已有相机规格。按 AGENTS.md 本轮未运行 build/debug 或浏览器验证。
-
-## Question
-
-在单一 Cesium 渲染器内,用自建深度后处理雾保护主场景区观感:归位视角保持可读,倾斜观察时远景与地平线瑕疵被柔和遮盖,进入途中可提前渐显,返回全球后恢复清晰。
-
-边界:相机限位与进出动作沿用已收口的「限位与既有相机契约的合成 + 进出状态机」;完整影像交接方案归「低精度 → 高精度过渡的解法」。本票确定雾的观感、参数、生命周期与后处理合成约束,不引入第二渲染器。
-
-实施与下游原型仍须验证(不作为本决策票未决项):
-
-1. 完整路径下的归位近景、浅俯角远景及进入/返回交接;B 默认参数已确认,原 60/20 km 高度带的进入演示存在失败记录,不能沿用为交接保证。
-2. 半透明管线与设备特效的实际深度表现,以及已确认降级路径能否保持业务可读性。
-3. 性能对比是否满足第 3 轮确认的 P95 帧耗时增幅不超过 15% 目标;当前没有性能实测结果。
-
-## Answer
-
-### 最终决策(本节优先于下方讨论历史)
-
-维护者第 4 轮确认“全部按推荐来”,对应 R4-1=A、R4-2=A、R4-3=默认。决策如下:
-
-1. **采用 B:仅地物距离雾**。单一 Cesium PostProcessStage,使用世界射线距离;仅处理有效几何深度,无有效深度背景保持原颜色,不采用 C 的天空染色或太阳方向暖色。默认参数确认采用 **18,000 m 起雾、42,000 m 完全遮蔽、#bdcdd6**,中间平滑增浓。默认参数由维护者确认,不表述为自动采集的运行快照。
-2. **生命周期**:可提前准备,主场景区稳定态启用;纯全球自由/引导态关闭。退出采用 **0.8 秒淡出**的已确认时长,但撤除时机须配合影像反向交接,不能先撤保护再收精细层。复用 stage 对象、离开页面销毁;禁用仍可能发生引擎内部资源开销,须实测。
-3. **保护准备/撤除的接口语义**:雾提供是否支持、是否可执行、当前强度、配置参数及失败/降级原因;支持提前准备、进入/稳定态启用、退出淡出、取消过期请求与销毁。具体函数名由实施决定。ready 不等于边缘已遮住,strength 也不是整幅画面的保护证明;不得提供未经验证的全局 protected=true 承诺。
-4. **与影像的边界**:已确认全球/引导先隐藏精细叠加影像,雾先形成有效保护后再渐显精细影像;返回反向交接。原型的 **60/20 km 高度带不能单独作为可揭示影像的充分条件**,人工反馈已证明当前进入演示太晚。精细层渐显时长、影像准备度、飞行路径及边缘风险判定由下游过渡票细化并原型验收,本票不反向等待它而锁死依赖。
-5. **无深度或雾运行失败**:业务可用优先,关闭自建雾并如实提示,仍提供可用的近景和业务功能,不让影像永远等待不可能出现的雾就绪信号。具体无雾交接办法归下游过渡票;降级时不得宣称保证完全遮缝。主场景区配置不可用时沿用配置票的关闭路径。
-6. **业务可读性与特效**:管线透明深度错位时允许减弱或暂关雾,保留限位并提示;设备三维特效随雾衰减,不开穿雾例外,面板状态信息不受影响。后处理特效在雾之前合成,开关独立;先后顺序不保证透明对象具有自身深度。
-7. **性能验收**:同机、同分辨率、同视角及相同加载条件下,开启自建雾的 P95 帧耗时增幅必须 **≤15%**,并单独检查首次启用及进出卡顿。超预算先优化,仍不达标则降低质量或关闭并提示。当前无性能达标证据,这一要求转为实施交付硬验收,不随决策票关闭而豁免。
-8. **收口口径**:B 稳定态方案、默认参数、保护准备/撤除语义、异常降级及性能目标已明确,故本决策票收口。完整交接观感由下游原型验证,生产运行质量另行实施验收。原型保留为证据,不直接视为正式功能。
-
-**后续实际场景反馈(以 08 的最终 Answer 为准)**:维护者已基本验收[低精度 → 高精度过渡的解法](08-coarse-to-fine-transition-solution.md#answer)的原型方向,同时要求进出雾气变化稍快、雾色偏冷白灰且不得发绿、返回全球时镜头高度与雾气模糊同步变化并保持精细影像持续受保护。上述反馈未指定新秒数或色号;本票的 `#bdcdd6` 与退出 0.8 秒仅保留为早期原型默认参数,不作为正式实施的最终观感值。07 的历史决策仍保留,生产运行与性能仍须独立验收。
-
-### 第 1 轮已确认
-
-维护者原答复:
-
-- Q1 = (a) + 世界射线长。
-- Q2 = 暂定 C,具体观感需要做出一版效果之后再确认。
-- Q3 = B。
-- Q4 = A,不要压成夜空。
-- Q5 = A。
-- Q6 = 整级禁用 + 如实报。
-
-据此记录:
-
-1. **距离口径**:使用世界空间沿视线的相机到片元距离(米)。起雾约 15–20 km、充分遮蔽约 40–45 km 作为首版调参区间;具体单值与曲线待第 2 轮确定。该范围不是已验证的遮缝保证。
-2. **配色暂定**:基础色配合轻微太阳方向调整。维护者要求先看一版效果再确认,当前不锁最终色值。
-3. **提前渐显**:主场景区锁定时生效,并在进入途中按高度带提前渐显。不能仅用 locked 布尔值排除进入阶段。
-4. **天空参与**:天空/地平线参与雾效;明确不得把天空压成夜空。此前把 #04121f 直接作为全天空最终色的建议不作为结论。
-5. **按需启用**:接受 Q5=A 的节省开销方向。它必须与 Q3=B 合并为“进入途中及锁定阶段可启用,纯全球自由态与引导态关闭”;退出渐变细节待第 2 轮。enable/disable 不等于每次创建/销毁,生命周期策略待定。
-6. **能力降级**:无可用深度支持时整级禁用,并如实提示;不默认用全屏常量色遮罩冒充距离雾。
-
-### 第 2 轮已确认:首版参数与进出观感
-
-维护者答复:`R2-1=采用推荐;R2-2=A;R2-3=采用推荐;R2-4=B`。
-
-1. **距离带初值**:世界射线距离 18 km 开始起雾,42 km 达到完全遮蔽;中间平滑增浓,避免明显雾墙。作为首版可调值,不据此宣称全部视角遮缝已通过。具体曲线实现应保留两端语义;单纯指数衰减在有限距离不自动达到完全不透明,不能直接等同于上述端点。
-2. **天空与配色方向**:选择“地平线浓、向头顶渐淡”,高处天空保留原亮度与层次。基础色首版走低饱和浅蓝灰,太阳方向仅作轻微暖冷变化,不把 #04121f 直接当作天空终色。最终色值与视觉效果仍须第一版原型过目确认。
-3. **进入阶段门控初值**:仅在明确的进入主场景区飞行中,相机椭球高度降到 60 km 开始渐显,降到 20 km 达到完整雾效强度。该高度带控制整级强度,与 18/42 km 的片元距离带是两个独立量。落定后持续启用,不因拉近设备自动消失;自由全球浏览到相同高度不自动起雾。
-4. **退出策略初值**:返回全球时用 0.8 秒淡出,淡出结束后禁用;再次进入复用 stage 对象,离开页面时销毁。不在每次进出时主动 remove/destroy。引擎可能在禁用期间释放内部资源,复用对象不等于保证无 shader 重建或无 framebuffer 开销,性能须实测。
-
-上述为本轮已确认的首版方案;中途取消、连续进出等过渡竞态需在实现设计中保持单一当前目标,不能让过期淡出回调关闭新的进入效果。此处不新增业务模式。
-
-### 第 3 轮已确认:业务优先级、特效合成与性能预算
-
-维护者答复:`R3-1=A;R3-2=A;R3-3=采用推荐,预算可以提高到15%`。
-
-1. **透明管线可读性优先**:若管线视图原型出现透明颜色与深度不匹配、近处管线被错误雾化等问题,允许该视图减弱或暂时关闭自建雾;保留相机限位并如实提示降级。接受此时远景遮蔽减弱的代价,不以完整雾效牺牲管线检查的可读性。这是条件降级,不预先认定所有管线视图都应关雾。
-2. **设备特效随雾衰减**:三维设备特效随远景一起变淡、不开穿雾例外;面板内的报警文字与状态信息保持清晰。若特效使用后处理级,颜色合成按“先特效、后雾”;普通三维特效先在场景中渲染,再接受后处理。雾与特效独立控制开关,不把共用集合解释为必须同时启停。透明特效能否按正确深度衰减仍须原型验证。
-3. **性能目标改为 15%**:在同一设备、分辨率、视角与相同加载条件下,对比关闭与开启雾效,目标为 `(开启时 P95 帧耗时 / 关闭时 P95 帧耗时 - 1) ≤ 15%`。记录基线与开启值,不把帧耗时增幅混写成 FPS 降幅;低帧率基线本身不因此合格。
-4. **单独检查切换卡顿**:首次启用、进入/返回切换的明显卡顿另行检查,不能仅以稳定段 P95 合格替代。尚未执行任何性能测量,不承诺已经达标。
-5. **超预算处理**:先优化;仍不达标时降低效果质量或关闭自建雾并如实提示,不静默牺牲交互流畅度。降低质量后的近景可读性、天空观感与远景遮蔽仍需维护者验收,不能以达成性能数字代替视觉确认。
-
-### 首次人工原型反馈:采用 B,进入时序未通过
-
-维护者原话:「方案使用B:仅地物距离雾,颜色和距离基本没有问题,但是进入演示时雾出现的太晚,完全没来得及遮挡矩形边缘,最终执行时需要解决这一点。」
-
-1. **方案选择已定**:采用 B,仅对有有效几何深度的地物做世界射线距离雾;无有效几何深度的背景不由本级重绘。C 的地平线背景渐变与小幅太阳方向暖色均不纳入当前采用方案。这取代前轮“暂定 C、天空参与”的实现方向;不等于宣布所有视角的天空问题已消除。
-2. **颜色与距离反馈**:维护者认可实际看到的 B 颜色与距离“基本没有问题”,因此本次不调整这些参数。源码初值是 18/42 km 与 #bdcdd6;未获取原型运行时快照,不能把源码初值冒充为维护者现场调参后的确切值,最终数值固化时应记录实际参数。
-3. **已知失败**:进入过程中雾出现太晚,矩形边缘先暴露。现有 60/20 km 高度带不能当成已验收的进入遮挡契约;性能、半透明管线与修正后的进入过程均未通过本次反馈得到验收。
-4. **源码能确定的时序**:演示先将相机放到全景,200 ms 后同时设置 entering 与调用 goHome;entering 的强度在高度 ≥60 km 时仍为 0,降到 ≤20 km 才到 1。源码不包含精细影像可见性的交接控制,也不等待遮挡就绪后再开始飞行。首次 shader 就绪耗时与矩形首次可见时刻未经测量,不能断言仅调高一个高度阈值就能解决。
-5. **正式交付的必要结果**:进入全过程中,保护必须先于矩形边缘暴露成立。不得把已知瑕疵作为“静态 B 效果好”的可接受例外,也不得因只调整高度参数而宣称修复。
-6. **交接方向已确认**:维护者随后答复“采用上述推荐方案”。保留 B 稳定态参数;全球自转/引导阶段隐藏精细叠加影像、保留粗略地球底图;点击进入后提前准备雾;雾已具备遮挡能力且接近主要区域时,才渐显精细影像。返回全球采用反向交接,在失去保护前收起精细层,避免退出途中再次暴露矩形边缘。
-7. **就绪与遮挡不可混为一谈**:stage ready 只能说明处理级已可执行,不证明当前强度、视角与片元距离已经遮住边缘;同样,影像 provider 已创建不证明目标视野内瓦片已可显示。交接须以实际可见结果为判据,不仅等待固定计时器或换一个高度阈值。
-8. **范围与待定边界**:本次确认的是精细叠加影像的可见性交接,不自动扩展为隐藏 DEM、3D Tiles、设备或业务面板;隐藏是否同时停请求尚未裁定。具体渐显区间、目标瓦片准备度、无深度/影像失败降级与取消/快速反向切换由后续过渡设计确定,不能以临时关闭粗底图造成空白来代替验证。完整交接细节由[低精度 → 高精度过渡的解法](08-coarse-to-fine-transition-solution.md)持有,本票提供雾的保护准备与撤除要求;不新增反向阻塞。
-
-### 依据纠正与失效约束
-
-- 「限位与既有相机契约的合成 + 进出状态机」已把上限改为 home.range(本部署 12,000 m),旧的 1,651 m 垂直覆盖预算不再作为本票参数约束。
-- **撤销原题面的“起雾/全不透明距离必须 ≤1,651 m”**:地面跨度和相机到片元的射线长度是不同量;“起雾更远反而吞掉近景”的方向推理也不成立。它并非仅因限位改变才失效,而是原推导本身有误。不能把该不等式简单改成 ≤12,000 m。
-- 先前用 1/sin(|pitch|) 外推浅俯角可见跨度,只是局部近似。视锥跨地平线时不能据此宣称有限的 89 km 地面跨度或保证所有方向遮缝;真实地形、椭球、相机位置和视锥均会影响结果。
-- Three haze “50% 衰减约 56 km”来自旧报告的特定高度与参数假设,不代表在约 10 km 高度的归位视角可直接复用;50% 衰减也不等于完全遮蔽。旧“密度提高约 30 倍”和上轮“现在默认参数几乎正好”都不作为调参结论。
-- `Context.depthTexture` 是布尔能力标志,不是要传入的纹理。深度采样走 PostProcessStage 提供的 depthTexture uniform。
-- 天空无有效几何深度,不能把它不加区分地当成普通地表远片元。维护者已选择 B:本级对无有效几何深度的背景保留输入颜色,不沿用 C 的天空染色分支。
-
-## Comments
-
-### 第 1 轮答复记录与第 2 轮准备
-
-已按维护者逐条答复登记 confirmed / provisional 的区别。Q2 的色调必须经第一版效果确认,不能提前标为通过。
-
-第 2 轮将询问:首版起雾/遮蔽参数与曲线、天空亮度及地平线过渡、进入高度带与退出渐变。后处理深度、天空与半透明对象的技术边界另做只读核查,事实不转嫁给维护者猜测。
-
-本票与「低精度 → 高精度过渡的解法」存在前后依赖:本票的配色验收应可用独立雾效果完成,不必等待整套影像过渡原型,否则可能互相等待。未执行新的原型任务,也未修改该票依赖。
-
-### 只读技术复核:天空、透明对象、生命周期与排序
-
-依据当前安装的 `@cesium/engine` 26.3.0 源码。以下为事实与限制,不是新的维护者产品裁定;未做 GPU、浏览器、构建或测试实测。
-
-1. **无几何深度不是“真实无限距离”**。场景深度清为 1.0(`node_modules/@cesium/engine/Source/Scene/Scene.js:223`),天空盒和大气不写深度(`Scene/CubeMapPanorama.js:266`、`Scene/SkyAtmosphere.js:291`)。shader 应先识别无有效几何深度,再走背景分支;该标记也可能对应没有写自身深度的透明内容,不能把它当作可靠的天空对象分类。
-2. **只反解一次对数深度**。`Shaders/Builtin/Functions/readDepth.glsl:1` 返回反解后的投影深度,不是米制距离;`windowToEyeCoordinates.glsl:67` 的 vec2+depth 重载内部自己解 log,不能先 reverseLogDepth 再重复解。反投影还需齐次除法;沿射线距离应取重建位置的长度,不是直接取负 z。无几何/远平面应先分支,避免远端数值误差。
-3. **禁用与移除不同**。`PostProcessStage.js:876` 在禁用后的 update 释放部分资源并返回,`:952` 避免本级 draw;对象仍可再次 enabled,但不能保证保留全部已编译资源。`PostProcessStageCollection.js:473` 的 remove 会 destroy,对象不可复用。集合仍含禁用 stage 时,`Scene.js:4023` 仍可能准备/清理后处理 framebuffer;所以不得写“禁用即完全零 GPU 成本”,也不得保证“复用对象就完全没有重建开销”。具体差异待测。
-4. **半透明颜色不保证有匹配深度**。透明 globe 路径 `GlobeTranslucencyState.js:571` 的 depthMask=false;默认 BillboardCollection 的透明 pass 也不写深度(`BillboardCollection.js:1826`),但 TRANSLUCENT-only 存在不同处理,不能一概而论。OIT 派生命令也可强制 depthMask=false(`OIT.js:447`)。后处理颜色可能含透明地表/特效,深度却来自背后的地物或清屏值。管线视图与透明特效须列为原型验收场景,不能承诺全对象自动获得正确距离雾。
-5. **排序不决定开关绑定**。自建 stage 的颜色按加入顺序串联、深度沿用同一张场景纹理(`PostProcessStageCollection.js:819`);各 stage 的 enabled/support 独立筛选(`:587`)。“先特效、后雾”可以让已有特效颜色被雾衰减,但不生成特效自身深度,也不意味着两级必须同开同关。是否让业务特效免雾或共用门控仍是产品裁定,不把平行图研究建议当作已确认结论。
-
-此前第 2 轮待答,技术复核本身不增加决策完成数;现维护者已答复 R2-1 至 R2-4,结果见 Answer 第 2 轮。
-
-### 第 3 轮 frontier
-
-本轮询问:R3-1 管线视图出现透明深度不匹配时是否优先可读性;R3-2 三维设备特效是否随雾衰减且独立开关;R3-3 同条件 P95 帧耗时对比与超预算处理。原建议预算为 10%。
-
-维护者已答复 `R3-1=A;R3-2=A;R3-3=采用推荐,预算可以提高到15%`。答案已录入 Answer 第 3 轮,最终预算为 15%,取代提问中的 10%。目前不再重复询问已确定的产品取舍;下一阶段是原型观感与性能验证,未 close。
-
-### Wayfinder 续推:独立雾原型已准备
-
-按最解阻原则继续本会话已认领的「视距雾气的实现规格」:其全部依赖 resolved,而「低精度 → 高精度过渡的解法」仍阻塞在本票。未新增重复票、未越过其依赖、未关闭任何票。
-
-原型资产与运行/撤除方法见[雾原型验收入口](../prototypes/fog-review.md)。现有 `/cockpit` 仅在 DEV 且 route query `fogPrototype=1` 时懒加载原型;三个变体为关闭自建雾、仅几何距离雾、几何距离雾+地平线背景。未改地图服务、相机控制、配置文件或并行工作文件,仅在薄页面入口添加可撤除挂载。
-
-只读复核修复了 hash 路由参数位置、采样历史递归嵌套及内置视觉雾污染对照三项问题。保留透明深度、无几何背景、禁用 stage 残余开销的限制说明;原型采样只是渲染事件帧间隔,不代表 GPU 时间或自动性能通过。
-
-原型首次准备时曾列出天空染色验收;该方向已由后续人工反馈选择 B 取代。当前 B 颜色/距离基本可用,进入演示的遮挡时序未通过;管线场景与性能未提供完整证据。尚无帧耗时实测,没有无原型基线,不能宣称达成 15%。原型有效结论产生后才存档 throwaway 分支,当前未提交/切分支以免带入其他工作区改动。
-
-### Wayfinder 第 4 轮:解除规划与实施验收混用
-
-本会话继续持有本票。已核对地图 Destination 为“锁定可实现契约”、Notes 为“只产出决策,不实现功能”。六张前置/相邻票已 resolved,本票已 claimed,下游「低精度 → 高精度过渡的解法」依赖本票;未认领的新 frontier 为空。
-
-发现问题:本票提供雾契约,下游负责完整交接原型;若本票反过来以完整交接验收作为关闭前提,将形成流程上的互相等待,虽无显式依赖环也不能推进。性能达标是尚未完成的实施验收,不应冒充已有证据。
-
-本轮待维护者裁定(尚无答案):
-
-- R4-1:是否将本票的收口限定为 B 稳定态方案、保护准备/撤除接口、异常降级与性能目标均明确;完整交接观感归下游原型,15% 目标保留为实施交付硬验收,不因决策票收口而视为通过。若要求本票先完成全套运行验收,则须明确调整下游阻塞关系并授权所需验证,当前不自行调整。
-- R4-2:雾无深度支持或运行失效时,是业务可用优先(关闭雾并提示,仍提供可用近景/业务;不得宣称已满足遮缝),还是无保护便不交接精细层、保留粗底图等待恢复/返回。建议业务可用优先,具体无雾过渡手段留给下游,不令图层永远等待不可能成立的雾就绪信号。
-- R4-3:维护者观看 B 时是否保持原型默认 18/42 km、#bdcdd6,还是曾调过;以实际看过的参数为依据,不拿源码默认冒充实测快照。
-
-术语约束:fog stage ready 仅是可执行状态,strength 是当前渐变强度,二者均不是“矩形边缘已被遮住”的全画面证明。雾提供状态/参数,影像过渡根据其路径与边缘暴露风险作交接判断;不新增未经验证的 protected=true 全局承诺。
-
-### Resolution:第 4 轮确认,按决策范围收口
-
-维护者答复“全部按推荐来”,确认 R4-1=A(决策收口)、R4-2=A(业务可用优先降级)、R4-3=默认(18/42 km、#bdcdd6)。最终结论已归并到 Answer 首节,Status 从 claimed 转 resolved、进度 100%。
-
-原型正向进入“遮挡过晚”的缺陷并未修复,性能与透明管线仍无完整运行验收;这些不被抹去或宣布通过。完整过渡交接归下游原型,运行质量和 ≤15% 性能目标归正式实施硬验收。地图只新增本票的简要索引,下一 frontier 为「低精度 → 高精度过渡的解法」。本轮未开始或关闭其他决策票,也未改生产/原型运行代码。
-
-### /to-spec:雾气规格单独发布
-
-依维护者要求,已把本票最终决策固化到[主场景区地物距离雾规格](../spec-fog.md)(`Status: ready-for-agent`、`Label: ready-for-agent`)。它与已有相机限位 [spec.md](../spec.md) 并列,不覆盖相机规格。完整交接与运行性能仍按规格所列留待后续决策与实施验收。

+ 0 - 112
.scratch/cockpit-primary-area-guardrails/issues/08-coarse-to-fine-transition-solution.md

@@ -1,112 +0,0 @@
-# 低精度 → 高精度过渡的解法
-
-Type: prototype
-Status: resolved
-Labels: wayfinder:prototype
-Blocked by: 01,05,07
-
-## 进度:100%
-
-维护者已在实际场景观看影像交接原型,反馈“基本上没问题,可以算原型基本验收完毕”。本票按方案决策与原型观感范围 resolved:选用先隐藏精细层、雾先保护、再渐显影像,返回反向交接的方案;不等于正式功能完成、全路径零跳变或性能已达标。
-
-下一步:正式实施按 Answer 的三条反馈调快进出雾气变化、校成冷白灰而非绿色,并使返回全球的镜头高度变化与雾气模糊同步;保留影像先收起、保护不断档的顺序。实施时还须核查目标瓦片、矩形内部 LOD 换级、异常降级和 P95 帧耗时增幅 ≤15%。三项反馈未给精确数值,交由实施调校与维护者复核,不把原型参数当成最终数值。依项目规则本轮未运行 build/dev 或浏览器调试,目视结论来自维护者反馈。
-
-## Question
-
-按甲方判据(Q5=B)——「从全球飞到县城视角的**全过程**不出现肉眼可辨的方形色块跳变」,选出一套手段组合并给出可实施的判定。
-
-调研 05 已查明两条硬约束:① 矩形边缘是 GLSL `step()` **硬切**,引擎**零羽化、零交叉淡入**,任何渐显都得手写;② 跳变有**两类**——(a) 矩形首批瓦片到位时**整体出现**,(b) 矩形**内部**被地形 LOD 牵着**逐级换挡**。甲方已确认 (b) 类也纳入判据,并允许「边界羽化 / 渐变遮罩」这类**碰 shader** 的手段。
-
-候选手段(按成本从低到高):
-
-- **雾气遮蔽**(07 的 depth 雾)——零额外机制,但只在有雾的距离带内有效;
-- **`minimumTerrainLevel` 硬门**——"太远就不显示精细层",最省的近似闸门,但是硬开关;
-- **按高度驱动 `ImageryLayer.alpha` 整层渐显**——能消除 (a) 类的"整体出现",但**羽化不了边缘**,也治不了 (b) 类(整层 alpha 无法只淡"新到的那一级");
-- **边界羽化 / 渐变遮罩**(碰 shader)——唯一能真正软化矩形边缘的手段;
-- **隐藏精细层至接近才渐显**——甲方原提案,甲方自评"效果不一定好";
-- **预烘焙中精度影像层**——把台阶从 1 级变 2 级,成本最高,作为兜底。
-
-要裁决的:
-
-1. **选哪几样组合**才能同时满足 (a)(b) 两类、且不把开销推高(甲方 Q9 明确在意性能)?
-2. 若选边界羽化:走哪个挂点 —— 自建 provider 包装、自定义 `Globe` 材质、还是复用 07 那条 depth 后处理级(把"距离"换成"到矩形的距离")?与 ADR 0004「不接管 Cesium 场景」是否相容?
-3. 用**原型**(廉价、粗糙、可反应)验证观感,而不是先写正式实现;原型只回答"看起来行不行"。
-
-**建议做法**:先出一版最小原型(改 alpha 曲线 + 一个边界羽化的最小 shader 挂点,不做美化),交给甲方目视判断;原型结论落票面 `## Answer`,**不**作为正式实现进仓。
-
-## Answer
-
-### 最终决策:实际场景原型基本验收
-
-维护者在真实驾驶舱场景目视后反馈:“基本上没问题,可以算原型基本验收完毕”。本票采用 **B:地物距离雾先形成保护,再渐显精细影像;返回采用反向交接**。全球/引导态保留粗底图、隐藏精细层;进入可以在精细层不可见时预取;目标瓦片未准备好不揭示影像。雾失效时业务可用优先,提示降级并渐显精细层,但不承诺完全遮边。取消、返回及再次进入只认最新目标,过期渐显或撤雾不得生效。原型 A(现状直显)与 C(接近后渐显)保留作对照,不作为本票选定方案。
-
-正式实施必须处理维护者指出的三项问题:
-
-1. **进出雾变换稍快**:进入时清晰到模糊、返回时模糊到清晰的变化速度均比原型稍快。反馈没有指定秒数或高度阈值;以实际飞行观感调校,不能未经复核自行锁成某个时长。
-2. **冷白灰雾色**:视觉应偏冷白灰,不得发绿。此前 B 的 `#bdcdd6` 是旧原型已确认默认初值,不等于这次实际场景的最终色;实施时以本次色相反馈校准并请维护者复核最终值,18/42 km 距离端点未在本次反馈中变更。
-3. **返回同步**:返回全球时,镜头高度上升与雾气模糊减弱须同步作用;不能先让影像收完、画面停顿,再让镜头飞走,也不能镜头远离后才突然撤雾。与此同时保持“影像在失去保护前收起”:需在镜头起飞阶段继续保护,按画面实际风险协调雾强度与影像 alpha,避免重现矩形边缘。
-
-本次仅通过**方案决策/原型观感**,不宣称生产实现已满足从全球到县城全程无可辨方块。真实保护成立不等于 stage ready、雾强度或相机距边界单一指标;`globe.tilesLoaded` 也不证明目标视野逐像素就绪。矩形内部随地形 LOD 逐级换挡不能靠整层 alpha 修复,若正式实现中肉眼可辨,须另做局部过渡或在不接管 Cesium 场景的边界内评估 shader 羽化,预烘焙中精度层仅作更高成本兜底。目标瓦片失败和雾失效的可用性、透明对象可读性、首次启用卡顿及同条件 P95 帧耗时增幅 ≤15% 仍是实施验收项,均未由本票原型证明。
-
-### 已确认的方案方向(上游原型反馈,历史)
-
-维护者在选择 B(仅地物距离雾)、指出进入遮挡过晚后,确认“采用上述推荐方案”。采用[视距雾气的实现规格](07-fog-implementation-spec.md#answer)记录的“精细影像先隐藏 → 雾先形成保护 → 精细影像渐显”,返回全球采用反向交接。本节是当时的方向记录;本票后续的原型观感验收见 Answer 首节,不代表正式功能已实施。
-
-### 第 1 轮已确认
-
-维护者答复 Q1=A、Q2=A、Q3=A、Q4=A:
-
-1. 本轮先做单文件逻辑原型,不挂真实地球。
-2. 雾不可用时关闭并提示,精细层仍渐显,但不承诺遮住矩形边。
-3. 取消进入或返回全球时立刻改成收起,不把正在变亮的过程放完;过期操作无效。
-4. 隐藏时可以预取瓦片。看不见不等于停止加载。DEM、3D Tiles 和业务对象仍不在这次范围内。
-
-### 原型里的可观察规则
-
-逻辑见 `prototypes/prototype-coarse-to-fine-transition.html` 的 `Transition.reduce`。页面只负责按钮。
-
-- 雾可执行、雾强度和保护成立是三件事。只有保护成立后,才允许精细层变亮。
-- 目标视野还不能显示时也不变亮,避免先空白再跳出方块。
-- 返回时先收起精细层,收到看不见再撤雾。精细层还在时撤雾,记成边缘可能露出。
-- 雾失败且精细层已经可见时,不突然藏起来,同时把遮缝承诺改成降级。
-- 整层透明度不能只淡矩形内部新到的那一级。这条走查只把问题摊开,没有选中修复手段。
-
-### 原型阶段未确认(历史,最终口径见本票 Answer 首节)
-
-- 四档变亮/变暗只是按钮粒度,不是秒数,也还没和相机运动绑在一起。
-- 瓦片失败时保持隐藏、继续用粗底图。
-- 「保护成立」在原型里由人标记。真实画面上怎样才算挡住边缘,还没验收。
-- 正向和反向都还没有肉眼验收,也没有性能证据。
-
-### 实际场景原型入口与限制
-
-维护者要求在实际场景观察,因此在原有单文件逻辑原型之外,新增 `src/modules/cockpit/prototypes/TransitionPrototype.vue` 与 `transition-prototype-scene.js`,并通过 `src/pages/cockpit/index.vue` 的 DEV query 显式接入现有 `/cockpit`。普通应用按仓库已有 `yarn dev` 启动,打开其实际地址的 `/#/cockpit?transitionPrototype=1&variant=B`;`variant=A` 和 `variant=C` 为对照,同一路由底栏左右箭头或键盘左右键切换。不要同时启用其它原型 query。
-
-B 复用已有仅地物距离雾 stage,进入时渐入雾,图层 alpha=0 时保留挂载供加载,满足粗判才用 900 ms 渐显。当前原型的返回演示先把精细层收至 0,再撤雾并让镜头返回全球;维护者已指出此时高度与雾气模糊应同步,故**正式实现不能照搬这一等待顺序**,须以 Answer 首节的同步要求和连续保护为准。A 保留现状作为方块对照,C 只在接近时渐显。卸载还原图层原 alpha/show 与内置雾。相机距影像矩形边缘 38 km、20 km 高度门控与 `globe.tilesLoaded` 只是原型初值和代理信号,不是已验收的保护/瓦片就绪契约。本 agent 未运行开发服务器、构建或浏览器走查;维护者已提供目视反馈,原型方案基本通过,生产运行质量仍待验收。
-
-## Comments
-
-### 上游雾原型人工反馈:进入遮挡出现过晚
-
-维护者选择[视距雾气的实现规格](07-fog-implementation-spec.md)的 B(仅地物距离雾),反馈颜色与距离基本没有问题,但进入演示的雾出现过晚,未能及时遮挡矩形边缘。B 不包含天空/地平线背景着色,也不包含 C 的太阳方向微调。
-
-本票后续必须验证“矩形边缘首次可能可见”与“保护已成立”之间的先后关系。当前雾原型只有 60/20 km 高度门控,未控制精细影像层交接;不能直接把这套进入参数当成达标手段。
-
-初次登记时为候选;维护者随后确认“采用上述推荐方案”,已写入 Answer 的方向记录。保持现有依赖,不创建反向阻塞,也不将方案获选当作进入过渡已修复。本轮已 claimed,只为开始第 1 轮澄清。
-
-### 第 1 轮澄清:原型分支与交接条件
-
-方向已锁,不重开。下列四项会改变原型形状,未答复前不写原型:
-
-- Q1:本轮先验证状态时序,还是直接在驾驶舱里目视。
-- Q2:无雾时是否仍渐显精细层。
-- Q3:取消进入、快速返回时是否立即收起精细层。
-- Q4:隐藏时是否继续请求瓦片。
-
-维护者答复 Q1=A、Q2=A、Q3=A、Q4=A。逻辑原型已放到 `prototypes/prototype-coarse-to-fine-transition.html`。脚本核对了八条状态走查:保护未成立时变亮被拒绝;先收影像再撤雾不记边缘露出;先撤雾、雾失败后变亮、无雾变亮会记成可能露出。该记录只描述第一阶段;实际场景原型随后已获维护者基本目视认可,见 Answer 首节。本轮未跑 build/dev。
-
-### 实际场景原型反馈与决策验收
-
-维护者反馈:“基本上没问题,可以算原型基本验收完毕”,同时要求后续具体执行注意:进入和返回时雾气变换速度再快一点,雾为偏冷白灰而非绿色,返回全球时高度和雾气模糊同步应用。将反馈记录为本票决策与原型观感验收,不误记成正式功能或性能验收。执行细则与保留的技术风险已归并到 Answer 首节;本票 resolved。
-
-可丢弃原型源文件目前仍在共享工作区,`/cockpit` 入口还含其它尚未独立归档的原型接线;本轮未切分支或提交,以免把并行改动混入证据。后续整理时将本票原型与其入口补丁单独保存在 throwaway 分支并留下分支指针,正式实现只吸收获确认的交接决策,不把原型代码直接当生产实现。

+ 0 - 97
.scratch/cockpit-primary-area-guardrails/map.md

@@ -1,97 +0,0 @@
-# 主场景区限位、视距与过渡(决策地图)
-
-Status: resolved
-Labels: wayfinder:map
-
-本 effort 只产出决策,不实现功能(Q6=(a))。实现票在本图收口后接进 `.scratch/cockpit-shell-panel-layout/issues/` 的 30 之后。
-
-## Destination
-
-把「进入沅陵县城主场景区之后」的三件事锁成可实现契约,让 agent 不必再发明语义:
-
-1. **区域限位**:弹簧臂/相机按主场景区限位 —— 中心点约束 + 相机离地高度上下限 + range 上限(Q2=C)。
-2. **视距雾气**:机制、曲线、门控与配色(Q4 / Q9 / Q11)。
-3. **低精度 → 高精度过渡**:从全球飞到县城视角的全过程不出现肉眼可辨的方形色块跳变(Q5=B)。
-
-目的地达成的判据:每张子票都有明确答案,且答案之间的数值互相自洽 —— 限位数值、雾气门控、过渡曲线引用**同一个**主场景区定义与**同一套**视距基准。
-
-## Notes
-
-- Domain:城市生命线 Web 驾驶舱 `/cockpit`。词汇以 `CONTEXT.md` 为准。
-- 每次会话先读:`CONTEXT.md`、`docs/adr/0001`–`0008`、`src/modules/cockpit/domain/camera.js`、`domain/viewPresets.js`、`domain/viewModes.js`、`domain/mapLayers.js`、`services/cesiumEngine.js`、`services/cameraControl.js`、`public/cockpit/config.json`,以及 `.scratch/cockpit-shell-panel-layout/issues/21`、`30`。
-- 引擎现状:`cesium@1.145.0`;唯一 `import 'cesium'` 在 `services/cesiumEngine.js`;目前**零**自定义 shader、**零** post-process 级、**零** fog/atmosphere 配置 —— 但 `scene.fog` 默认 `enabled: true`(默认密度在县城尺度几乎不可见)。
-- 本图只产出决策,不实现功能。实现票去向:`.scratch/cockpit-shell-panel-layout/issues/` 30 之后(Q6=(a)+(c))。
-- 技能:grilling、domain-modeling、research、prototype。
-- 决策票状态词是 wayfinder 自己的 `open`/`claimed`/`resolved`,**不套 triage 五标签**。
-- 新增 ADR 0009 记录本图结论(Q7)。
-- 验收=可计算部分走纯逻辑断言,观感部分由维护者目视走查(Q8)。按 `AGENTS.md`,改代码后不自动跑 build/dev。
-- 阻塞关系以 `Blocked by:` 字段行为准。
-
-### 已锁定的甲方裁定(2026-09-18 澄清)
-
-1. 主场景区用**新配置字段**显式给出(Q1=C)。本部署取甲方框定的沅陵县城范围 `[110.328749941417, 28.3917230659234, 110.465779226663, 28.4913038992907]`(`[west, south, east, north]`,13.4 km × 11.0 km,147.6 km²)。**01 已 resolved(2026-09-18)**:兜底改为「部署期准备候选值+运行时未配置则关闭」;候选值公式=影像 bounds ∩ DEM `valid_bounds` 的 B,围绕 `map.home` 经纬度(=进入系统默认位置=归位位置)每边折半。原“内置兜底自动得到 B 收紧 50%”不再适用,详见 [01 的 Answer](issues/01-primary-area-config-contract.md#answer)。
-2. 限位为**三量同夹**:中心点、相机离地高度、range(Q2=C)。最低仍须能贴近看设备。**02 已 resolved(2026-09-18):数值口径见 Decisions so far。**
-3. 进区后**锁定**,另给显式「返回全球」(Q3=C)。
-4. 雾气定位=**氛围 + 遮缝**,不承担"限制用户能看多远"的职责 —— 限距由第 1 条的限位承担(Q4)。
-5. 过渡判据=从全球飞到县城视角的**全过程**无肉眼可辨的方形色块跳变;允许边界羽化/渐变遮罩这类**碰 shader** 的手段(Q5=B)。
-6. 本图只产决策;实现票接在 `cockpit-shell-panel-layout` 的 30 之后(Q6=(a)+(c))。
-7. 新增 ADR 0009(Q7)。
-8. 验收=纯逻辑断言 + 维护者目视走查(Q8)。
-9. 雾气走**自建 Cesium depth 后处理级(路线 B)**,参考 Three.js 增强管线的 shader 写法以提升观感、降低开销(Q9=B)。
-10. 早期「垂直视野 ≤15%」口径已由[限位与既有相机契约的合成 + 进出状态机](issues/03-camera-contract-composition-and-entry-state.md)取代:最远视野跟随 home.range(本部署 12,000 m)。不得再用 1,651 m 地面跨度直接约束雾的相机到片元距离。
-11. 早期要求包含侧视远景及天空处理;维护者经原型已选[视距雾气的实现规格](issues/07-fog-implementation-spec.md#answer)的 B,仅处理有效几何深度地物,不采用天空背景染色。无雾降级时业务可用优先并提示,不承诺完全遮缝。
-12. 另立调研票评估「把 Cesium 深度喂给 Three」的可行性(Q12=B)。
-
-### 早期计算记录(历史,不作为现行限位或雾参数)
-
-- 主场景区:E-W 13,413 m,N-S 11,008 m;**N-S 的 15% = 1,651 m**(这是"最高视野下地面垂直覆盖"的预算)。
-- Cesium 默认 `fov = 60°` 是**水平**视场角,`fovy = 2·atan(tan(30°)/aspect)`。同一预算下 range 上限随画布宽高比浮动约 2×:aspect 1:1 → 1,430 m;4:3 → 1,906 m;16:9 → 2,542 m(均为正俯视口径)。**02 已定:运行时按真实 `fovY` 反算 `range_max = 1651 / (2·tan(fovY/2))`,不写死基准。**
-- 现有 home 预设 `pitch −55, range 12000` 的垂直覆盖约 **9.5 km ≈ 主场景区 N-S 的 86%**,是上述预算的 **5.8×** —— 与票 30 的"12 km 是县域级"直接冲突,03 必须处置。
-
-## 任务清单
-
-- [x] [01: 主场景区的配置契约](issues/01-primary-area-config-contract.md) — 已 resolved:字段形状 / 坐标口径 / 兜底来源。
-- [x] [02: 相机限位的数值契约](issues/02-camera-limit-numeric-contract.md) — 已 resolved:h_min / h_max / range 上限与 15% 预算的换算口径。
-- [x] [03: 限位与既有相机契约的合成 + 进出状态机](issues/03-camera-contract-composition-and-entry-state.md) — 已 resolved:限位锚点 / 进出状态机 / 管线合成 / 软回弹 / ADR 0009。
-- [x] [04: 调研 —— Cesium 地面视距雾的机制与限制](issues/04-research-cesium-ground-fog-mechanisms.md) — 已 resolved,产物 `research/cesium-ground-fog-mechanisms.md`。
-- [x] [05: 调研 —— 精细矩形跳变的成因与可用手段](issues/05-research-overlay-rectangle-pop-mechanisms.md) — 已 resolved,产物 `research/overlay-rectangle-pop-mechanisms.md`。
-- [x] [06: 调研 —— Three.js 雾 shader 可复用面与 Cesium 深度互通可行性](issues/06-research-three-fog-shader-reuse.md) — 已 resolved,产物 `research/three-fog-shader-reuse.md`;甲方裁定废止深度互通备选。
-- [x] [07: 视距雾气的实现规格](issues/07-fog-implementation-spec.md) — 已 resolved:采用 B 仅地物距离雾,默认 18/42 km、#bdcdd6;进入时序要求“精细影像先隐藏 → 雾先保护 → 影像渐显”,失败时业务可用优先;实施验收保留 ≤15% P95 与透明管线/进出交接检查。
-- [x] [08: 低精度 → 高精度过渡的解法](issues/08-coarse-to-fine-transition-solution.md) — 已 resolved:维护者在实际场景基本验收 B 过渡方向,三项后续要求已落 Answer;生产零跳变与性能仍待实施验收。
-
-## Decisions so far
-
-<!-- 本地图已关闭的决策票索引;一条决策只活在票里。 -->
-
-- [01: 主场景区的配置契约](issues/01-primary-area-config-contract.md) — `map.primaryArea` 只设 `bounds`(`[W,S,E,N]` 度,与 `map.overlays[].bounds` 同口径);配置必须写归一化顺序,运行时校验 `west<east`/`south<north`、**不自动翻转**,非法即记配置问题并进入关闭路径;兜底=**部署期**计算候选值(B = 影像 bounds ∩ DEM `valid_bounds`,围绕 `map.home` 经纬度=进入系统默认位置=归位位置、每边折半)写入同一字段,**运行时未配置就关闭**依赖主场景区的限位与自建雾并如实报浮标 —— 原「内置兜底自动得 B 收紧 50%」作废;契约不含数据越界校验(配置即权威);本部署显式值仍为甲方框定县城范围。
-- [02: 相机限位的数值契约](issues/02-camera-limit-numeric-contract.md) — 中心点 ∈ `primaryArea.bounds`(01);`range ∈ [120, range_max]`,`range_max = 1651 / (2·tan(fovY/2))`(取运行时真实 fovY,随画布 aspect 变:1:1→1,430 / 4:3→1,906 / 16:9→2,542 m);**正俯视只提供参考基准值,pitch 不参与收紧**;「垂直覆盖」度量=屏幕竖直方向地面跨度,**只在正俯视定义**;**02 只保正俯视 ≤15%,侧视露出的范围外由 07 雾遮盖**;相机离地高度 `h = range·sin|pitch|`(h_max 被 range_max 隐式罩住);**防入地=地形感知下限**(`h ≥ 正下方地形 + 10 m`,margin 暂定可微调,不足时夹 range,机制落 03)。**⚠️ 其 `range_max` 锚点(2,542 m)已被 03 取代(12,000 m = home.range),限位数值以 03 / ADR 0009 为准。**
-- [03: 限位与既有相机契约的合成 + 进出状态机](issues/03-camera-contract-composition-and-entry-state.md) — `range_max = home.range = 12,000 m`(**最远视野 = 归位默认视角**,取代 02 的 2,542 m 与"15% 垂直覆盖预算");显式模式锁(进入系统/归位进、返回全球出,判定与位置无关);全球态闲置 5 秒回初始态(自转 + 闪烁「点击进入系统」),不重置模式/筛选;区内管线视图 `range ∈ [400, min(6000, range_max)] = [400, 6,000]`(因 range_max>6000,与现状一致,冲突消解);边界软回弹(`target.pivot` 越出 ≤ 框短边 5% ≈ 550 m,松手阻尼弹回);返回全球目标=初始全景(h0/p−60/r 2×10⁷);`areaState: 'locked'|'global'` 进 `filterStore`(与 `viewMode` 同层),`mapService` 转移、`cameraControl` 换 limits(`setRangeLimits` 与 `setPitchLimits` 对称)、`CockpitShell` 管 5 秒计时与引导页显隐;`intro.enabled=false` 按 global 启动;防入地继承 02(三处夹取交集);ADR 0009 已建、ADR 0008 已补指向。
-- [04: 调研 —— Cesium 地面视距雾的机制与限制](issues/04-research-cesium-ground-fog-mechanisms.md) — 内置 `scene.fog` 的密度由**相机高度**反算并乘 `1−|dot(朝向,上)|`,正俯视归零、半径随高度漂移,当不了稳定视距限制;引擎无现成雾/霾后处理级,但 `PostProcessStage` 默认就带 `depthTexture`,自建 depth 雾可行(`Context.depthTexture` 运行时存在但不在随包 d.ts 里;**06 更正:它实为布尔能力标志,非纹理对象**)。
-- [05: 调研 —— 精细矩形跳变的成因与可用手段](issues/05-research-overlay-rectangle-pop-mechanisms.md) — 矩形边缘是 GLSL `step()` **硬切**,引擎零羽化、零交叉淡入;配置里的 `minimumLevel: 0` 是**空操作**(等于 provider 默认),精细层并非"近了才加载";跳变有**两类**(首批瓦片整体出现 + 地形 LOD 牵着影像逐级换挡);`ImageryLayer.alpha` 只是整层 uniform,`ImageryLayer.rectangle` 只读。
-- [06: 调研 —— Three.js 雾 shader 可复用面与 Cesium 深度互通可行性](issues/06-research-three-fog-shader-reuse.md) — Three haze 的**密度曲线与闭式光深积分是纯数学**,可搬进 Cesium `PostProcessStage`;太阳耦合全在颜色项,常量雾色即可剥离;默认密度的距离估算仅适用于原研究的特定高度假设;旧 1,651 m 预算及倍率推导已失效,现行参数以雾规格最终决策为准。「Cesium 深度喂给 Three」评级**极难**(`Context.depthTexture` 仅是布尔能力标志、纹理不可跨 context、社区实测俯视遮挡错乱、且须推翻 ADR 0004)—— 甲方裁定**废止**该备选,雾锁定单一 Cesium 渲染器走路线 B。
-
-- [视距雾气的实现规格](issues/07-fog-implementation-spec.md#answer) — 采用 B 地物距离雾及原型默认参数;提前准备/撤除与影像交接协调,异常时业务可用优先;按决策范围收口,进入遮挡失败记录、透明场景检查和 ≤15% P95 验收仍归下游原型/实施,不表示运行验收通过。
-- [08: 低精度 → 高精度过渡的解法](issues/08-coarse-to-fine-transition-solution.md#answer) — 维护者在实际场景基本验收「藏精细影像 → 雾保护 → 渐显影像;返回反向交接」。正式实施需让进出雾变换稍快、采用冷白灰且不发绿、返回时镜头高度与雾气模糊同步变化并保持影像保护。原型决策通过不等于生产全程零跳变或 ≤15% P95 已达标。
-
-## Not yet specified
-
-<!-- 天空/地平线已成为「视距雾气的实现规格」中的明确问题并有独立原型,不再重复列为迷雾;按 aspect 推导上限的旧问题已被 home.range 决策取代。 -->
-- 正式交付若矩形内部 LOD 换级仍肉眼可辨,再评估局部过渡、shader 羽化或**预烘焙中精度影像层**(成本高)作为兜底;本次原型观感通过不代表已经修复内部换级。
-- 运维自检「限位与雾气已生效」的操作口径 —— **已由实现票 [36: 主场景区配置](../cockpit-shell-panel-layout/issues/36-primary-area-config.md) 落地**:`docs/cockpit-deployment.md` 新增 2.9 节(顺序是硬要求 + 三种浮标读数对照表 + 矩形怎么定),
-  运行期读数为引擎浮标 `主场景区:…` 与宿主 `data-map-primary-area`(`-reason`)。雾气本身的可视验收仍归实施票 38。
-
-## Out of scope
-
-- 让 Three.js 接管 Cesium 场景或承担全场大气 —— ADR 0004 / ADR 0007 已锁死;Q12 已由 06 调研后**裁定废止**(极难,且须推翻 ADR 0004),不再作为备选。
-- 重做地球自转与进入引导动画本身(票 30 已交付并验收;本图只处理进入途中的过渡瑕疵)。
-- 甲方本地 3D Tiles 模型数据补齐(属 `.scratch/cockpit-shell-panel-layout/issues/21`)。
-- 后处理通用框架化或其它后处理效果(本图只做视距雾一个)。
-- 改 `mode` / `panelId` / 面板语义与布局。
-
-## 进度:100%
-
-8/8 张决策票已 resolved。[08:低精度 → 高精度过渡的解法](issues/08-coarse-to-fine-transition-solution.md#answer)经维护者在真实场景基本验收,方案方向和三条后续实施约束已落票;雾气决策另见[07](issues/07-fog-implementation-spec.md#answer)。本图按「锁定可实现的决策」范围收口,不代表原型直接入生产、全程零方块跳变或性能合格。
-
-下一步:按地图约定在 `.scratch/cockpit-shell-panel-layout/issues/` 的 30 之后承接正式实现。进出雾速度稍快、冷白灰不发绿、返回高度与雾气同步且边缘持续受保护均须在真实画面复核;另检查目标瓦片、内部 LOD 换级、异常降级、管线可读性及 P95 帧耗时增幅 ≤15%。雾气与相机规格继续分别以[spec-fog.md](spec-fog.md)和[spec.md](spec.md)为准,新的实施约束以 08 的最终 Answer 优先。
-
-历史 1,651 m 预算及旧雾密度推导不可作现行验收标准;本轮依 AGENTS.md 未运行 build/dev 或浏览器调试。

+ 0 - 55
.scratch/cockpit-primary-area-guardrails/prototypes/fog-review.md

@@ -1,55 +0,0 @@
-# 视距雾气:可丢弃原型与人工验收入口
-
-Status: awaiting-human-review
-
-## 回答的问题
-
-「18/42 km 世界射线距离雾 + 浅蓝灰地平线 + 进入时 60/20 km 高度带 + 退出 0.8 秒淡出」能否保留归位视角可读性、遮住浅俯角远景,并避免把天空压成夜空?
-
-本原型属于「视距雾气的实现规格」的决策证据,不是生产实现,不承诺全视角遮缝或性能已达标。视觉分支以实际驾驶舱地图为背景,三种方案区别在合成方式,不另建 GIS 数据与渲染器。
-
-## 首次人工反馈
-
-维护者已选 B(仅地物距离雾),认可颜色和距离基本没有问题。进入演示未通过:雾出现太晚,未能及时遮挡矩形边缘;正式交付必须解决。C 不作为当前选择,但保留其代码供历史对照。
-
-维护者在第 4 轮答复“全部按推荐来”,确认 B 按原型默认 18/42 km 与 #bdcdd6 固化;此为人工确认,不是自动采集快照。雾规格已按决策范围收口,进入演示失败仍待下游完整交接修正;未提供性能数据,不能判定 15% 预算通过。继续复现时使用 `variant=B`,本次未修改原型运行代码。
-
-## 位置与打开方式
-
-- 入口:`src/pages/cockpit/index.vue`,只增加 DEV + route query 显式选择的动态加载。
-- 控制台:`src/modules/cockpit/prototypes/FogPrototype.vue`。
-- 后处理:`src/modules/cockpit/prototypes/fog-prototype-stage.js`。
-- 普通应用使用仓库现有 `yarn dev`(Fes),不使用 pre.dev;本会话未执行启动命令。
-- 在该命令输出的现有应用地址打开 `/#/cockpit?fogPrototype=1&variant=B`。项目使用 hash 路由,query 在 `#/cockpit` 后。不预设本机端口,也不另起替代服务器。
-- 先点击原页面「点击进入系统」,再点击原型底栏「归位观察」。初始原型 phase=global(无自建雾),原型不会偷改真实进入按钮。
-- 没有 `fogPrototype=1` 或非 DEV 时不挂载原型;删除该 query 即卸载、恢复内置视觉雾;不修改运行配置文件,不保存参数。
-
-## 三种方案
-
-- A:关闭自建雾的基线。默认同时关闭内置雾的视觉贡献,但保留其剔除开关与既有天空大气;并非关闭所有大气效果。
-- B:只处理有效几何深度的世界射线距离雾,背景保持原样。用于观察地平线断层是否明显。
-- C:B + 无有效深度背景的地平线渐变 + 小幅太阳方向暖色。默认首版色 `#bdcdd6` 仅为可调起点,不是已验收终色;该背景分支也可能影响无自身深度的透明内容,必须检查。
-
-用底栏按钮或左右箭头切换,输入框聚焦时不劫持箭头。切换更新 route query。可调起雾/遮蔽距离与基础色;参数仅驻内存。
-
-## 操作与验收清单
-
-1. **归位观察**:切 A/C 对比,主体街区仍应可读,近处建筑不应蒙成一片。分别看 B/C 的远地物与天空衔接。
-2. **浅俯角 −10°**:观察地平线、山体与影像边缘,检查天空不是夜空、没有雾墙/色带。手动旋转到其他方向,结论不能只基于一个方向。
-3. **进入演示**:原型立即回到全景,稍后调用现有 goHome;高度从 60 km 到 20 km 驱动雾强度。此按钮演示门控,不实现正式进入状态机或完整影像交接。
-4. **退出演示**:现有相机向全景过渡,雾 0.8 秒淡出后禁用。连续进入/退出不应被旧计时器反向关闭。
-5. **管线视图(原型局部)**:调用现有地图服务的管线显示,不改正式 filterStore 选择;检查透明地表/近处管线是否被错误浓雾覆盖。可切 A 暂关自建雾对照,按已确认“可读性优先”决定是否需要正式降级。
-6. **性能参考**:固定视角、尺寸与内置视觉雾开关,等加载稳定并预热,按 A→C→A 收集三段各 10 秒帧间隔。控制台显示配置、画布尺寸、tilesLoaded 和各段 P95,最多保存六段。不要在采样中移动相机/改参数/切视图/切标签页。
-
-性能读数是渲染事件间隔,不是 GPU 时间。A 保留禁用 stage,因此这组对照不能单独测清“完全不装 stage”的基线额外开销;帧同步也可能掩盖 GPU 增量。目标仍是确认的 P95 帧耗时增幅 ≤15%,正式验收还需无原型基线、同条件重复测量与首次启用/切换卡顿检查。本原型不自动显示通过结论。
-
-## 已执行与未执行
-
-- 已执行:源文件读回、接线 diff 检查、独立只读复核。
-- 已修复:hash query 入口读错位置;采样结果递归嵌套历史;内置视觉雾混入 A/B/C 对照。
-- 未执行:构建、dev、shader 编译、浏览器截图、GPU/帧耗时采样;遵守 AGENTS.md 的显式验证授权要求。
-- 待维护者:实际打开原型,记录偏好、可读性缺陷和性能结果;不能凭代码存在关闭 HITL 决策票。
-
-## 清理与保全
-
-结论确认前保留为未提交的可丢弃源文件,工作区有大量并行改动,不自动切分支或提交。原型被验收后,将这两个原型文件与入口接线的独立补丁保存在 throwaway 分支,生产实现另行整理;移除入口动态挂载与整个 prototypes 雾目录即可撤除。不把未验证原型直接当正式交付。

+ 0 - 476
.scratch/cockpit-primary-area-guardrails/prototypes/prototype-coarse-to-fine-transition.html

@@ -1,476 +0,0 @@
-<!DOCTYPE html>
-<html lang="zh-CN">
-<head>
-<meta charset="utf-8" />
-<meta name="viewport" content="width=device-width, initial-scale=1" />
-<title>原型 · 低精度到高精度的交接</title>
-<style>
-  :root { color-scheme: light; --ink: #1c2430; --muted: #5c6775; --line: #ddd4c6; --paper: #f6f3ec; --card: #fffdf8; --accent: #1f6f8b; --bad: #8c3a2f; --ok: #2d6a4f; }
-  * { box-sizing: border-box; }
-  body { margin: 0; font: 16px/1.5 "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; color: var(--ink); background: var(--paper); }
-  main { max-width: 980px; margin: 0 auto; padding: 32px 20px 80px; }
-  h1 { font-size: 28px; line-height: 1.25; margin: 0 0 8px; }
-  h2 { font-size: 18px; margin: 28px 0 10px; }
-  p { margin: 0 0 12px; }
-  .eyebrow { color: var(--accent); font-weight: 700; letter-spacing: 0.04em; }
-  .question, .assume, .panel, .walk { background: var(--card); border: 1px solid var(--line); border-radius: 14px; padding: 16px 18px; }
-  .assume { border-color: #e2c48a; background: #fff8ea; }
-  .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); gap: 10px; }
-  .field { border: 1px solid var(--line); border-radius: 10px; padding: 10px 12px; background: #fff; }
-  .field b { display: block; font-size: 13px; color: var(--muted); font-weight: 600; }
-  .field.changed { border-color: var(--accent); box-shadow: inset 3px 0 0 var(--accent); }
-  .groups { display: grid; gap: 14px; }
-  .group { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
-  .group span { min-width: 4.5em; color: var(--muted); font-size: 14px; }
-  button { font: inherit; border: 1px solid #b9c7cf; background: #fff; color: var(--ink); border-radius: 999px; padding: 7px 12px; cursor: pointer; }
-  button:hover { border-color: var(--accent); }
-  button:disabled { opacity: 0.45; cursor: default; }
-  button.primary { background: var(--accent); color: white; border-color: var(--accent); }
-  .tabs { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }
-  .tabs button[aria-selected="true"] { background: var(--ink); color: white; border-color: var(--ink); }
-  .steps { display: grid; gap: 8px; margin-top: 12px; }
-  .note { color: var(--muted); }
-  .bad { color: var(--bad); }
-  .ok { color: var(--ok); }
-  code { font-family: ui-monospace, Consolas, monospace; font-size: 0.92em; }
-</style>
-</head>
-<body>
-<main>
-  <p class="eyebrow">可丢弃原型 · 双击即可打开 · 不写入正式地图</p>
-  <h1>低精度到高精度,边缘什么时候可以出现</h1>
-  <p>用按钮把「全球 → 进入县城 → 锁定 → 返回 / 取消」走一遍,看精细影像会不会早于遮挡出现。</p>
-
-  <section class="question">
-    <p><strong>要回答的问题:</strong>在已确认的「先藏精细影像、雾先挡住、再渐显;返回时反过来」下,取消、雾失败、瓦片早晚和过期操作,会不会让矩形边缘先露出来。</p>
-    <p class="note">这是逻辑原型,不是地球画面。它不证明肉眼看不到方块,也不证明帧耗时。保护是否成立由你显式标记,不能从「雾强度已经拉满」推出来。</p>
-  </section>
-
-  <h2>还没经你确认的假设</h2>
-  <section class="assume">
-    <p>下面这些只为让按钮能跑,点出来不对就改,不要当成验收通过:</p>
-    <p>渐显和收起各分四下,只是按钮粒度,不是时长。亮起前除了保护成立,还要等「目标视野可显示」;瓦片失败时保持隐藏、继续用粗底图。返回收到看不见之后才回到全球。矩形内部换级不会被整层透明度修掉。</p>
-  </section>
-
-  <h2>当前状态</h2>
-  <section id="state" class="panel"></section>
-  <p id="delta" class="note"></p>
-  <p id="refusal"></p>
-
-  <h2>自由试</h2>
-  <div id="free" class="groups"></div>
-
-  <h2>对照走查</h2>
-  <section class="walk">
-    <div id="tabs" class="tabs"></div>
-    <p id="blurb"></p>
-    <div id="steps" class="steps"></div>
-  </section>
-</main>
-<script>
-const Transition = (() => {
-  const STEP = 0.25
-
-  function createState() {
-    return {
-      phase: "global",
-      intent: "idle",
-      generation: 0,
-      fogSupported: true,
-      fogReady: false,
-      fogStrength: 0,
-      fogFailure: null,
-      protection: false,
-      alpha: 0,
-      reveal: "hidden",
-      prefetch: "idle",
-      promise: "none",
-      edgeExposed: false,
-      internalSwap: false,
-      notice: "全球和引导阶段只看粗底图,精细影像藏着。",
-      refusal: null
-    }
-  }
-
-  function next(state, patch) {
-    return Object.assign({}, state, patch, { refusal: null })
-  }
-
-  function refuse(state, reason) {
-    return Object.assign({}, state, { refusal: reason })
-  }
-
-  function fogExpected(state) {
-    return state.fogSupported && !state.fogFailure
-  }
-
-  function beginPrefetch(state) {
-    if (state.prefetch === "ready" || state.prefetch === "fetching") return state.prefetch
-    return "fetching"
-  }
-
-  function syncReveal(state) {
-    if (state.intent === "hide") {
-      return state.alpha > 0 ? "concealing" : "hidden"
-    }
-    if (state.intent !== "show") return state.alpha > 0 ? "shown" : "hidden"
-    if (state.alpha >= 1) return "shown"
-    if (state.alpha > 0) return "revealing"
-    return "hidden"
-  }
-
-  function promiseFor(state) {
-    if (state.alpha <= 0) return "none"
-    if (state.protection) return "covered"
-    return "degraded"
-  }
-
-  function enter(state, phase) {
-    const showing = state.alpha > 0
-    return next(state, {
-      phase: phase,
-      intent: "show",
-      generation: state.generation + 1,
-      prefetch: beginPrefetch(state),
-      reveal: syncReveal({ intent: "show", alpha: state.alpha }),
-      promise: promiseFor(state),
-      notice: showing
-        ? "最新目标改成进入。过期的收起作废,精细层停在当前透明度。"
-        : "已进入交接。精细层仍不可见,并开始预取瓦片。"
-    })
-  }
-
-  function leave(state, label) {
-    return next(state, {
-      phase: "returning",
-      intent: "hide",
-      generation: state.generation + 1,
-      reveal: state.alpha > 0 ? "concealing" : "hidden",
-      notice: state.alpha > 0
-        ? label + "立刻开始收起精细层,不把正在变亮的过程放完。"
-        : label + "时精细层本来就看不见。"
-    })
-  }
-
-  function reduce(state, action) {
-    switch (action.type) {
-      case "prepareFog":
-        if (!state.fogSupported || state.fogFailure) return refuse(state, "雾当前不可用,准备不会变成保护。")
-        return next(state, { fogReady: true, notice: "雾处理级可执行。强度仍是 0,边缘还没被挡住。" })
-      case "raiseFog":
-        if (!state.fogReady || !fogExpected(state)) return refuse(state, "雾还不能执行,不能把强度拉满。")
-        return next(state, { fogStrength: 1, notice: "雾强度已满。这仍不等于矩形边缘已经被挡住。" })
-      case "markProtection":
-        if (!(state.fogReady && state.fogStrength >= 1 && fogExpected(state))) {
-          return refuse(state, "保护不能由准备完成或强度单独成立。")
-        }
-        return next(state, { protection: true, promise: state.alpha > 0 ? "covered" : state.promise, notice: "已标记保护成立。精细层不会因此自动亮起。" })
-      case "loseProtection":
-        return next(state, {
-          protection: false,
-          promise: state.alpha > 0 ? "degraded" : "none",
-          edgeExposed: state.edgeExposed || (state.alpha > 0 && fogExpected(state)),
-          notice: state.alpha > 0 ? "保护消失时精细层还看得到。" : "保护已取消,精细层当时看不见。"
-        })
-      case "fogFailed":
-        return next(state, {
-          fogSupported: false,
-          fogReady: false,
-          fogStrength: 0,
-          fogFailure: "failed",
-          protection: false,
-          promise: state.alpha > 0 ? "degraded" : "none",
-          edgeExposed: state.edgeExposed || state.alpha > 0,
-          notice: state.alpha > 0
-            ? "雾已关闭并提示。精细层不突然藏起来,但不再承诺挡住边缘。"
-            : "雾已关闭并提示。精细层当时看不见,业务不无限等待。"
-        })
-      case "fogRecovered":
-        return next(state, {
-          fogSupported: true,
-          fogFailure: null,
-          notice: "雾恢复为可准备。不会自动标成已经挡住边缘。"
-        })
-      case "enter":
-        if (state.phase === "locked" && state.intent === "show") return refuse(state, "已经锁定在主场景,不用再次进入。")
-        return enter(state, "entering")
-      case "arrive":
-        if (state.intent !== "show") return refuse(state, "当前目标不是进入,不能锁定主场景。")
-        return next(state, { phase: "locked", notice: "相机已到主场景。精细层是否可见仍看保护和瓦片,不看锁定本身。" })
-      case "cancel":
-        if (state.phase !== "entering") return refuse(state, "只有正在进入时可以取消。已经锁定请用返回全球。")
-        return leave(state, "取消进入")
-      case "returnGlobal":
-        if (state.phase === "global" && state.alpha === 0) return refuse(state, "已经在全球,精细层也看不见。")
-        return leave(state, "返回全球")
-      case "tilesReady":
-        if (state.prefetch === "failed" && state.intent !== "show") return refuse(state, "这次加载已失败。要重试请先再次进入。")
-        return next(state, { prefetch: "ready", notice: "目标视野现在可显示。是否亮起仍要看当前目标和保护。" })
-      case "tilesFailed":
-        if (state.alpha > 0) {
-          return next(state, { prefetch: "failed", internalSwap: true, notice: "新的一级没到,已经显示的精细层保持原样。整层透明度修不了内部换挡。" })
-        }
-        return next(state, { prefetch: "failed", reveal: "hidden", notice: "瓦片失败。精细层继续隐藏,画面留在粗底图。" })
-      case "revealStep": {
-        if (state.intent !== "show") return refuse(state, "当前目标不是亮起精细层,过期的变亮无效。")
-        if (state.prefetch !== "ready") return refuse(state, "目标视野还不能显示,先不亮,避免空白后突然跳出方块。")
-        if (!state.protection && fogExpected(state)) return refuse(state, "雾仍被指望遮住边缘,但保护还没成立,不能亮起精细层。")
-        if (state.alpha >= 1) return refuse(state, "精细层已经完全可见。")
-        const alpha = Math.min(1, Math.round((state.alpha + STEP) * 100) / 100)
-        const exposed = state.edgeExposed || !state.protection
-        return next(state, {
-          alpha: alpha,
-          reveal: alpha >= 1 ? "shown" : "revealing",
-          promise: state.protection ? "covered" : "degraded",
-          edgeExposed: exposed,
-          notice: state.protection ? "在保护成立后渐显一档。" : "无雾降级:精细层仍渐显,但不承诺挡住矩形边。"
-        })
-      }
-      case "concealStep": {
-        if (state.intent !== "hide") return refuse(state, "当前目标不是收起,过期的变暗无效。")
-        if (state.alpha <= 0) return refuse(state, "精细层已经看不见。")
-        const alpha = Math.max(0, Math.round((state.alpha - STEP) * 100) / 100)
-        const done = alpha === 0
-        return next(state, {
-          alpha: alpha,
-          reveal: done ? "hidden" : "concealing",
-          promise: done ? "none" : promiseFor(Object.assign({}, state, { alpha: alpha })),
-          phase: done ? "global" : "returning",
-          intent: done ? "idle" : "hide",
-          notice: done ? "精细层已收起,可以再撤雾。现在回到全球。" : "精细层再收起一档。雾先留着。"
-        })
-      }
-      case "dropFog":
-        if (state.alpha > 0) {
-          return next(state, {
-            fogStrength: 0,
-            protection: false,
-            promise: "degraded",
-            edgeExposed: true,
-            notice: "精细层还在时雾被撤掉,矩形边缘可能露出。这不是合格的返回顺序。"
-          })
-        }
-        return next(state, { fogStrength: 0, protection: false, fogReady: state.fogReady, notice: "精细层已经看不见,撤雾不会露出矩形边缘。" })
-      case "levelSwap":
-        return next(state, {
-          internalSwap: true,
-          notice: state.alpha > 0 ? "矩形内部换了一级。整层透明度不能只淡新到的那一级。" : "换级发生时精细层不可见,画面上还没有跳变。"
-        })
-      default:
-        return refuse(state, "没有这个操作。")
-    }
-  }
-
-  return { createState: createState, reduce: reduce }
-})()
-
-const PHASE = { global: "全球", entering: "正在进入", locked: "已锁定主场景", returning: "正在返回" }
-const REVEAL = { hidden: "隐藏", revealing: "正在亮起", shown: "已显示", concealing: "正在收起" }
-const PREFETCH = { idle: "未请求", fetching: "预取中,仍不可见", ready: "目标视野可显示", failed: "加载失败" }
-const PROMISE = { none: "无", covered: "有保护", degraded: "已降级,不承诺遮边" }
-const FIELDS = [
-  ["phase", "阶段", (s) => PHASE[s.phase]],
-  ["intent", "当前目标", (s) => ({ idle: "闲置", show: "亮起精细层", hide: "收起精细层" }[s.intent])],
-  ["generation", "只认最新操作", (s) => "第 " + s.generation + " 次"],
-  ["fogSupported", "雾是否可用", (s) => s.fogSupported ? "可用" : "已关闭"],
-  ["fogReady", "雾是否可执行", (s) => s.fogReady ? "可执行" : "还不能执行"],
-  ["fogStrength", "雾强度", (s) => Math.round(s.fogStrength * 100) + "%"],
-  ["protection", "保护是否成立", (s) => s.protection ? "成立" : "未成立"],
-  ["alpha", "精细层透明度", (s) => Math.round(s.alpha * 100) + "%"],
-  ["reveal", "精细层显示", (s) => REVEAL[s.reveal]],
-  ["prefetch", "瓦片", (s) => PREFETCH[s.prefetch]],
-  ["promise", "遮缝承诺", (s) => PROMISE[s.promise]],
-  ["edgeExposed", "边缘是否可能已露出", (s) => s.edgeExposed ? "是" : "否"],
-  ["internalSwap", "内部换级", (s) => s.internalSwap ? "发生过,整层透明度治不了" : "还没有"]
-]
-
-const FREE = [
-  ["雾", [
-    ["准备雾", { type: "prepareFog" }],
-    ["把雾强度拉满", { type: "raiseFog" }],
-    ["标记保护已成立", { type: "markProtection" }],
-    ["标记保护消失", { type: "loseProtection" }],
-    ["雾运行失败", { type: "fogFailed" }],
-    ["雾恢复可准备", { type: "fogRecovered" }],
-    ["撤掉雾", { type: "dropFog" }]
-  ]],
-  ["进出", [
-    ["点击进入系统", { type: "enter" }],
-    ["飞到并锁定", { type: "arrive" }],
-    ["取消进入", { type: "cancel" }],
-    ["返回全球", { type: "returnGlobal" }]
-  ]],
-  ["影像", [
-    ["目标视野可显示", { type: "tilesReady" }],
-    ["瓦片加载失败", { type: "tilesFailed" }],
-    ["精细层再亮一档", { type: "revealStep" }],
-    ["精细层再暗一档", { type: "concealStep" }],
-    ["矩形内部换了一级", { type: "levelSwap" }]
-  ]]
-]
-
-const SCENARIOS = [
-  { id: "forward", title: "正向进入", blurb: "看精细层会不会在保护成立前亮起。强度拉满之后仍应不能亮。", steps: [
-    ["点击进入系统", { type: "enter" }], ["准备雾", { type: "prepareFog" }], ["把雾强度拉满", { type: "raiseFog" }],
-    ["目标视野可显示", { type: "tilesReady" }], ["精细层再亮一档", { type: "revealStep" }], ["标记保护已成立", { type: "markProtection" }],
-    ["精细层再亮一档", { type: "revealStep" }], ["精细层再亮一档", { type: "revealStep" }], ["精细层再亮一档", { type: "revealStep" }],
-    ["精细层再亮一档", { type: "revealStep" }], ["飞到并锁定", { type: "arrive" }]
-  ]},
-  { id: "strength", title: "只有强度", blurb: "复现「雾看起来满了,但还没挡住边缘」。这一下变亮必须被拒绝。", steps: [
-    ["点击进入系统", { type: "enter" }], ["准备雾", { type: "prepareFog" }], ["把雾强度拉满", { type: "raiseFog" }],
-    ["目标视野可显示", { type: "tilesReady" }], ["精细层再亮一档", { type: "revealStep" }]
-  ]},
-  { id: "back", title: "返回先收影像", blurb: "先把精细层收到看不见,再撤雾。走完后边缘应仍是「否」。", steps: [
-    ["点击进入系统", { type: "enter" }], ["准备雾", { type: "prepareFog" }], ["把雾强度拉满", { type: "raiseFog" }],
-    ["标记保护已成立", { type: "markProtection" }], ["目标视野可显示", { type: "tilesReady" }],
-    ["精细层再亮一档", { type: "revealStep" }], ["精细层再亮一档", { type: "revealStep" }], ["精细层再亮一档", { type: "revealStep" }], ["精细层再亮一档", { type: "revealStep" }],
-    ["返回全球", { type: "returnGlobal" }],
-    ["精细层再暗一档", { type: "concealStep" }], ["精细层再暗一档", { type: "concealStep" }], ["精细层再暗一档", { type: "concealStep" }], ["精细层再暗一档", { type: "concealStep" }],
-    ["撤掉雾", { type: "dropFog" }]
-  ]},
-  { id: "earlyFog", title: "返回时先撤雾", blurb: "不合格顺序:精细层还在就撤雾。边缘应变成「是」。", steps: [
-    ["点击进入系统", { type: "enter" }], ["准备雾", { type: "prepareFog" }], ["把雾强度拉满", { type: "raiseFog" }],
-    ["标记保护已成立", { type: "markProtection" }], ["目标视野可显示", { type: "tilesReady" }],
-    ["精细层再亮一档", { type: "revealStep" }], ["精细层再亮一档", { type: "revealStep" }], ["精细层再亮一档", { type: "revealStep" }], ["精细层再亮一档", { type: "revealStep" }],
-    ["返回全球", { type: "returnGlobal" }], ["撤掉雾", { type: "dropFog" }]
-  ]},
-  { id: "cancel", title: "取消正在变亮", blurb: "亮到一半改取消。下一档变亮应失效,接着只应能收起。", steps: [
-    ["点击进入系统", { type: "enter" }], ["准备雾", { type: "prepareFog" }], ["把雾强度拉满", { type: "raiseFog" }],
-    ["标记保护已成立", { type: "markProtection" }], ["目标视野可显示", { type: "tilesReady" }],
-    ["精细层再亮一档", { type: "revealStep" }], ["精细层再亮一档", { type: "revealStep" }],
-    ["取消进入", { type: "cancel" }], ["精细层再亮一档", { type: "revealStep" }], ["精细层再暗一档", { type: "concealStep" }]
-  ]},
-  { id: "reenter", title: "返回途中再进入", blurb: "收起还没完成就再次进入。过期的变暗应失效,精细层停在当前透明度。", steps: [
-    ["点击进入系统", { type: "enter" }], ["准备雾", { type: "prepareFog" }], ["把雾强度拉满", { type: "raiseFog" }],
-    ["标记保护已成立", { type: "markProtection" }], ["目标视野可显示", { type: "tilesReady" }],
-    ["精细层再亮一档", { type: "revealStep" }], ["精细层再亮一档", { type: "revealStep" }], ["精细层再亮一档", { type: "revealStep" }], ["精细层再亮一档", { type: "revealStep" }],
-    ["返回全球", { type: "returnGlobal" }], ["精细层再暗一档", { type: "concealStep" }], ["精细层再暗一档", { type: "concealStep" }],
-    ["点击进入系统", { type: "enter" }], ["精细层再暗一档", { type: "concealStep" }], ["精细层再亮一档", { type: "revealStep" }]
-  ]},
-  { id: "nofog", title: "雾失败仍要能看", blurb: "业务可用优先:仍渐显,但承诺改成不保证遮边,并记下边缘可能露出。", steps: [
-    ["点击进入系统", { type: "enter" }], ["雾运行失败", { type: "fogFailed" }], ["目标视野可显示", { type: "tilesReady" }],
-    ["精细层再亮一档", { type: "revealStep" }]
-  ]},
-  { id: "tiles", title: "瓦片失败", blurb: "保护已经成立,但目标视野没有影像。应继续隐藏,不要亮出空白再跳变。", steps: [
-    ["点击进入系统", { type: "enter" }], ["准备雾", { type: "prepareFog" }], ["把雾强度拉满", { type: "raiseFog" }],
-    ["标记保护已成立", { type: "markProtection" }], ["瓦片加载失败", { type: "tilesFailed" }], ["精细层再亮一档", { type: "revealStep" }]
-  ]},
-  { id: "swap", title: "内部换级", blurb: "精细层完全可见后,地形又换了一级影像。确认整层透明度没有把它淡掉。", steps: [
-    ["点击进入系统", { type: "enter" }], ["准备雾", { type: "prepareFog" }], ["把雾强度拉满", { type: "raiseFog" }],
-    ["标记保护已成立", { type: "markProtection" }], ["目标视野可显示", { type: "tilesReady" }],
-    ["精细层再亮一档", { type: "revealStep" }], ["精细层再亮一档", { type: "revealStep" }], ["精细层再亮一档", { type: "revealStep" }], ["精细层再亮一档", { type: "revealStep" }],
-    ["矩形内部换了一级", { type: "levelSwap" }]
-  ]}
-]
-
-let state = Transition.createState()
-let previous = null
-let activeScenario = null
-let stepIndex = 0
-
-function dispatch(action) {
-  previous = state
-  state = Transition.reduce(state, action)
-  render()
-}
-
-function render() {
-  const root = document.querySelector("#state")
-  root.replaceChildren()
-  const grid = document.createElement("div")
-  grid.className = "grid"
-  FIELDS.forEach(([key, label, format]) => {
-    const card = document.createElement("div")
-    card.className = "field" + (previous && previous[key] !== state[key] ? " changed" : "")
-    const name = document.createElement("b")
-    name.textContent = label
-    card.append(name, document.createTextNode(format(state)))
-    grid.append(card)
-  })
-  const notice = document.createElement("p")
-  notice.textContent = state.notice
-  root.append(grid, notice)
-
-  const changed = previous ? FIELDS.filter(([key]) => previous[key] !== state[key]).map(([, label]) => label) : []
-  document.querySelector("#delta").textContent = changed.length ? "这一下变了:" + changed.join("、") : "还没有操作。"
-  const refusal = document.querySelector("#refusal")
-  refusal.textContent = state.refusal ? "这一下被拒绝:" + state.refusal : ""
-  refusal.className = state.refusal ? "bad" : ""
-
-  renderWalk()
-}
-
-function renderFree() {
-  const root = document.querySelector("#free")
-  FREE.forEach(([title, actions]) => {
-    const row = document.createElement("div")
-    row.className = "group"
-    const label = document.createElement("span")
-    label.textContent = title
-    row.append(label)
-    actions.forEach(([text, action]) => {
-      const button = document.createElement("button")
-      button.type = "button"
-      button.textContent = text
-      button.addEventListener("click", () => { activeScenario = null; dispatch(action) })
-      row.append(button)
-    })
-    root.append(row)
-  })
-  const reset = document.createElement("button")
-  reset.type = "button"
-  reset.textContent = "回到初始全球"
-  reset.addEventListener("click", () => { activeScenario = null; previous = state; state = Transition.createState(); render() })
-  root.append(reset)
-}
-
-function renderWalk() {
-  const tabs = document.querySelector("#tabs")
-  tabs.replaceChildren()
-  SCENARIOS.forEach((scenario) => {
-    const button = document.createElement("button")
-    button.type = "button"
-    button.textContent = scenario.title
-    button.setAttribute("aria-selected", String(activeScenario === scenario.id))
-    button.addEventListener("click", () => startScenario(scenario.id))
-    tabs.append(button)
-  })
-  const scenario = SCENARIOS.find((item) => item.id === activeScenario)
-  document.querySelector("#blurb").textContent = scenario ? scenario.blurb : "选一个走查会先回到全球初始,再按顺序点。"
-  const steps = document.querySelector("#steps")
-  steps.replaceChildren()
-  if (!scenario) return
-  scenario.steps.forEach(([label], index) => {
-    const button = document.createElement("button")
-    button.type = "button"
-    button.textContent = (index + 1) + ". " + label
-    button.disabled = index !== stepIndex
-    if (index === stepIndex) button.className = "primary"
-    button.addEventListener("click", () => {
-      dispatch(scenario.steps[index][1])
-      stepIndex += 1
-      render()
-    })
-    steps.append(button)
-  })
-  if (stepIndex >= scenario.steps.length) {
-    const done = document.createElement("p")
-    done.className = "ok"
-    done.textContent = "这一条走完了。拒绝也是结果:看上面有没有「被拒绝」,以及边缘是否可能已露出。"
-    steps.append(done)
-  }
-}
-
-function startScenario(id) {
-  activeScenario = id
-  stepIndex = 0
-  previous = null
-  state = Transition.createState()
-  render()
-}
-
-renderFree()
-render()
-</script>
-</body>
-</html>

+ 0 - 45
.scratch/cockpit-primary-area-guardrails/research/cesium-ground-fog-mechanisms.md

@@ -1,45 +0,0 @@
-# Research: Cesium ground-level view-distance fog mechanisms
-
-**Ticket:** [04 — Cesium 地面视距雾的机制与限制](../issues/04-research-cesium-ground-fog-mechanisms.md)
-**Date:** 2026-09-18
-**Scope:** decision facts only. No production cockpit code was modified; no build/dev/serve was run (repo rule).
-
-## Bottom line
-
-The built-in `scene.fog` **cannot** act as a stable view-distance limit at county scale. The engine ships **no** built-in fog/haze post-process stage. A hand-rolled `PostProcessStage` depth fog is the only controllable option. The repo's Three.js `useThreeGeospatial` pipeline is dormant in the cockpit.
-
-## 1. Built-in fog is on, but structurally unsuitable
-
-`services/cesiumEngine.js` `VIEWER_OPTIONS` are widget-chrome flags only, so `Scene.fog` sits at Cesium defaults: `enabled = true`, `density = 6e-4`, `heightScalar = 1e-3`, `heightFalloff = 0.59`, `maxHeight = 8e5`, `visualDensityScalar = 0.15`.
-
-`Fog.update` recomputes density **per frame from camera height**:
-
-```
-density = this.density * heightScalar * (h / maxHeight)^-heightFalloff
-density *= 1 - |dot(camera.direction, up)|
-```
-
-Two structural problems:
-
-- **Nadir kills it**: `1 - |dot(dir, up)|` → 0 when looking straight down, exactly the cockpit's dominant view (home pitch −55° gives a ×0.181 factor).
-- **Radius drifts with height**: density is an inverse function of camera altitude, so the same setting cannot hold a stable visibility radius across zoom levels.
-
-Derived 50%-fog distances (4-arg `czm_fog`, `visualDensityScalar = 0.15`, nadir): 200 m → ~20 km; 500 m → ~34 km; 1 km → ~51 km; 3 km → ~97 km; 10 km → ~197 km. The cockpit home view (≈9.8 km altitude) sees ~1.3% fog at 50 km — invisible.
-
-## 2. No built-in fog post-process stage
-
-`PostProcessStageLibrary.create*Stage` ships 17 factories (blur, depthOfField, edgeDetection, silhouette, bloom, ambientOcclusion, FXAA, 4 tonemapping, autoExposure, blackAndWhite, brightness, nightVision, depthView, lensFlare). **None is fog/haze.**
-
-But `PostProcessStage` shaders receive `colorTexture` and `depthTexture` uniforms by default, and `Context.depthTexture` exists at runtime (verified in `Build/CesiumUnminified/Cesium.js`; it is **absent** from the bundled `Cesium.d.ts`). A custom depth-fog stage is feasible: one depth-attached scene target + one full-screen pass + a line of GLSL (`czm_readDepth`/`czm_unpackDepth`). Requires `WEBGL_depth_texture` (fine on WebGL2).
-
-## 3. `showGroundAtmosphere` is the wrong scale
-
-`Globe.showGroundAtmosphere` defaults true (WGS84) but is distance-fenced by `lightingFadeIn/FadeOutDistance` — high-altitude aerial perspective, tens to hundreds of km. Not a ground-level limit.
-
-## 4. The Three.js pipeline is dormant
-
-`src/plugins/useThreeGeospatial/lib/clouds` documents `haze` / `hazeDensityScale = 3e-5` / `hazeExponent = 1e-3` and implements `CloudsEffect`/`AerialPerspectiveEffect`. But `src/modules` has **zero** references to it; it mounts only as a standalone `<TresCanvas>` page under `/plugins/useThreeGeospatial/<page>`, not as a layer over the Cesium canvas.
-
-## 5. Repo has no per-frame camera/height listeners
-
-No `camera.changed` / `preRender` / `postRender` / `percentageChanged` usage in `src/modules/cockpit`. The only host-side `requestAnimationFrame` loop is the intro globe spin (ticket 30). `Camera.percentageChanged` defaults to `0.5` (coarse); a height-driven fade must lower it or poll `scene.preRender`.

+ 0 - 45
.scratch/cockpit-primary-area-guardrails/research/overlay-rectangle-pop-mechanisms.md

@@ -1,45 +0,0 @@
-# Research: global-basemap vs bounded local overlay "rectangular pop-in"
-
-**Ticket:** [05 — 精细矩形跳变的成因与可用手段](../issues/05-research-overlay-rectangle-pop-mechanisms.md)
-**Date:** 2026-09-18
-**Scope:** decision facts only. No production cockpit code was modified; no build/dev/serve was run (repo rule).
-
-## Bottom line
-
-The fine-overlay rectangle edge is a GLSL **hard clip** with zero feathering and zero cross-fade anywhere in the engine. `minimumLevel: 0` in the config is a **no-op** (equal to the provider default), so the fine layer is not "deferred until close". There are **two distinct pops** to handle, and nothing built-in to reuse — any fade must be hand-rolled.
-
-## 1. Layer stack
-
-- Global basemap: Natural Earth II TMS (`Assets/Textures/NaturalEarthII`), **levels 0–2 only** → max ~19.5 km/texel. The county is ~6×6 basemap texels.
-- Fine overlay: `/gis/pic3` TMS, provider `rectangle = [109.962, 27.963, 111.046, 29.061]`, `alpha = 1.0`, `minimumLevel = 0`, `maximumLevel = 19`.
-- Composite order: `[NaturalEarthII, 精细层]`, base color `#04121f`.
-
-## 2. Hard edge, no feathering, no cross-fade
-
-`GlobeFS.glsl` clips with `step()` — `textureAlpha *= alphaMultiplier.x * alphaMultiplier.y` — a binary 0/1 mask. No feather width, no blend, no dither. Outside its rectangle the overlay contributes nothing and the basemap shows through (the base layer stretches its edge texels globally).
-
-Two layers blend by plain alpha-over (`sourceAlpha = alpha * textureAlpha`); `alpha` here is the **whole-layer** `ImageryLayer.alpha` uniform, not a per-tile fade. `TileImagery`/`Imagery` carry no alpha at all.
-
-## 3. `minimumLevel: 0` is a no-op
-
-`UrlTemplateImageryProvider._minimumLevel` defaults to 0, so the config value changes nothing. The overlay is eligible from its coarsest pyramid level immediately — it is **not** deferred until the camera is close.
-
-## 4. Two pops
-
-- **(a) First-tile appearance**: a layer with no ready imagery is skipped entirely (`if (!defined(imagery) || imagery.imageryLayer.alpha === 0.0) continue;`), so the rectangle switches on **in one frame** at full alpha.
-- **(b) Terrain-LOD-driven level swaps**: imagery level is slaved to terrain level (`targetGeometricError = terrainProvider.getLevelMaximumGeometricError(tile.level)`), so every terrain refinement also bumps the imagery level with no transition — ongoing pops inside the rectangle.
-
-## 5. Available knobs and their limits
-
-- `ImageryLayer.alpha` — writable, **whole-layer uniform only** (can fade the whole patch, cannot feather the edge). The JSDoc `function(frameState, layer, x, y, level)` form is **not implemented** in the installed engine.
-- `ImageryLayer.show` — binary; hiding the basemap promotes the overlay to base layer (global texel stretch) — a trap.
-- `ImageryLayer.rectangle` — **read-only getter**; not animatable.
-- `minimumTerrainLevel` / `maximumTerrainLevel` — hard on/off gate by terrain LOD; a cheap "don't show when far" proxy, but not a fade.
-- `brightness/contrast/gamma/saturation/hue` — color-matching only; cannot remove the resolution step.
-- `readyEvent` — raised **only** by `fromProviderAsync`, but the repo builds overlays with `new ImageryLayer(provider)`, so there is **no provider-ready signal** for the overlay.
-- `Globe.tileLoadProgressEvent` — the only built-in load-progress signal.
-- `Scene.preRender` / `postRender` — the host hook for a per-frame fade.
-
-## 6. History
-
-Tickets 21 and 30 did **not** attempt or defer any color matching, edge feathering, cross-fade, or LOD transition — this is a fresh work surface.

+ 0 - 189
.scratch/cockpit-primary-area-guardrails/research/three-fog-shader-reuse.md

@@ -1,189 +0,0 @@
-# Research: Three.js fog shader reuse surface & Cesium↔Three depth interop feasibility
-
-**Ticket:** [06 — Three.js 雾 shader 可复用面与 Cesium 深度互通可行性](../issues/06-research-three-fog-shader-reuse.md)
-**Date:** 2026-09-18
-**Scope:** decision facts only. No production cockpit code was modified; no build/dev/serve was run (repo rule). Engine versions verified locally: `cesium@1.145.0`(`node_modules/cesium/package.json:3`)、`three@0.180.0`(`node_modules/three/package.json:3`)。npm 包布局:`cesium` 包是空壳,真实渲染器源码在 `node_modules/@cesium/engine/Source/`(`node_modules/cesium/Source/Cesium.js:8` 起全部 `export { … } from '@cesium/engine'`)。
-
-## 结论
-
-1. **可搬的数学(问题 1)**:`@takram/three-clouds` 的 haze 是一段**与渲染器无关的闭式光学厚度积分**(密度沿高度指数衰减 + 沿视线解析积分,源自 iq 的 fog 公式)。密度曲线、积分公式、双 HG 相位函数、射线-椭球求交都是纯数学,可原样搬进 Cesium `PostProcessStage`;`PostProcessStage` 自带 `colorTexture`/`depthTexture`/`v_textureCoordinates`,且 Cesium 自动注入 `czm_inverseProjection`、`czm_eyeHeight`、`czm_ellipsoidRadii` 等自动 uniform,几乎一一对应 Three 侧手动供给的矩阵 uniform。**必须重写的**是:深度采样约定(`.r`+log 解码 → `czm_readDepth`)、EffectComposer 多目标渲染管线、阴影长度/Beer shadow map、以及全部 LUT 纹理(transmittance/scattering/irradiance)——后两者是体积云/大气散射专用的,纯雾不需要。**关键数值事实**:默认 `hazeDensityScale=3e-5`、`hazeExponent=1e-3` 在县城尺度(相机高度 200 m 量级)等效于每米消光 ≈1.2×10⁻⁵,50% 起雾距离 ≈ 56 km——比 07 票的 1,651 m 预算弱约 30 倍,直接搬默认值不可用,必须重调参。
-2. **深度互通(问题 2)评级:极难**。核心事实链:`Context.depthTexture` 是**布尔能力探测**(是否支持 WEBGL_depth_texture),不是纹理对象,且不在随包 `Cesium.d.ts`;真正的场景深度纹理是打包 **DEPTH_STENCIL / UNSIGNED_INT_24_8** 的 D24S8 纹理(`FramebufferManager` 创建)。Two 个 WebGL context 之间**不能直接共享纹理对象**,所以只有两条路:共用 Cesium 的私有 GL context(上游明确不支持,[issue #648](https://github.com/CesiumGS/cesium/issues/648) 自 2013 年开票至今 open)或每帧 `readPixels` 回读(管线停顿 + 24 bit 量化 + 动态近远平面归一化)。社区实测还报告了**俯视/仰视时遮挡错乱**([same-canvas 线程](https://community.cesium.com/t/integrate-cesiumjs-with-other-webgl-renderers-into-same-canvas/32080)),而驾驶舱主视角正是近俯视。**该路径 = 驾驶舱引入第二套渲染器接管合成,与 ADR 0004「Cesium 为主、Three 为增强、不接管 Cesium 场景」冲突**,按票面约束,若甲方坚持此路线须先推翻/修订 ADR 0004 并交回甲方裁定;本报告不提出实施建议。
-
-## 1. 问题 1 —— 雾/霾数学解剖
-
-来源对象:`src/plugins/useThreeGeospatial/lib/clouds`(即 npm 包 `@takram/three-clouds@0.2.1`,`lib/clouds/package.json:2`)+ `lib/atmosphere`(`@takram/three-atmosphere@0.11.1`)。雾霾主实现是 `clouds.frag` 的 `approximateHaze()`(`src/plugins/useThreeGeospatial/lib/clouds/src/shaders/clouds.frag:653-690`),开关是 `CloudsMaterial` 的 `@define('HAZE') haze`(`lib/clouds/src/CloudsMaterial.ts:438-439`),README 明确标注它「inexpensive and recommended to keep enabled」(`lib/clouds/README.md:589-595`)。
-
-### 1.1 密度函数:指数密度 × 闭式积分,两个参数
-
-密度是**相机高度处的指数函数**,两个参数各管一件事:
-
-```glsl
-float modulation = remapClamped(coverage, 0.2, 0.4);            // clouds.frag:662
-float density = modulation * hazeDensityScale * exp(-cameraHeight * hazeExponent); // clouds.frag:666
-if (density < 1e-7) { return vec4(0.0); }  // 高空看太空时防伪影          // clouds.frag:667-669
-```
-
-- `hazeDensityScale = 3e-5`:整体密度倍率,越大越浓(默认值在 `CloudsMaterial.ts:251`、README 定义在 `README.md:938-944`)。
-- `hazeExponent = 1e-3`:密度随**海拔**的指数衰减率,越小越贴地、越大越往高空铺(默认值在 `CloudsMaterial.ts:252`、README 定义在 `README.md:946-952`)。
-- `cameraHeight` 是相机相对椭球的**大地高**(不是视空间坐标):`CloudsMaterial.copyCameraSettings()` 里 `geodeticScratch.setFromECEF(cameraPositionECEF).height`(`CloudsMaterial.ts:363-370`)。
-- `coverage`(云覆盖率,默认 0.3,`uniforms.ts:54`)经 `remapClamped(coverage, 0.2, 0.4)` 映射成 0..1 的调制系数——纯雾场景等于常数倍率(`math.glsl:78-80` 给出 `remapClamped` 的 `saturate((x-min1)/(max1-min1))` 定义)。
-
-沿视线的光学厚度是**解析积分**(注释引 [iq 的 fog 文章](https://iquilezles.org/articles/fog/),`clouds.frag:671-672`):
-
-```glsl
-float angle = max(dot(normalize(rayOrigin), rayDirection), 1e-5);   // clouds.frag:673
-float exponent = angle * hazeExponent;                              // clouds.frag:674
-float expTerm = 1.0 - exp(-maxRayDistance * exponent);              // clouds.frag:676
-float linearTerm = density / hazeExponent / angle;                  // clouds.frag:678
-float opticalDepth = expTerm * linearTerm;                          // clouds.frag:679
-```
-
-即 OD = (d₀/β/cosθ)·(1 − exp(−L·β·cosθ)),其中 d₀ 为相机高度密度、β = hazeExponent、θ 为「地心向外方向」与视线的夹角。**注意 `max(…, 1e-5)` 这个钳制**:视线朝下(θ>90°,cosθ<0,即一切俯视/平视视角)时公式被钳到 1e-5 分支,`expTerm ≈ L·1e-8`、`linearTerm ≈ d₀·1e8`,两者相乘退化为 **OD ≈ d₀·L —— 线性距离雾**(每米消光 = `modulation·hazeDensityScale·exp(−h·hazeExponent)`)。指数增长分支只在仰视(cosθ>0)时启用。县城尺度下 d₀≈1.2×10⁻⁵ m⁻¹(h=200 m、coverage=0.3),50% 起雾 ≈ 56 km;即**默认参数在县域尺度约等于无雾**,07 要的「1.6 km 内足够陡」需要把密度抬约 30 倍(h=10 m 时 ≈29×,h=200 m 时 ≈35×)。
-
-### 1.2 颜色混合公式:透射率乘法 + 内散射加法
-
-`approximateHaze` 返回 `vec4(inscatter, saturate(1-exp(-opticalDepth)))`(`clouds.frag:689`),合成在两步完成:
-
-- 云 pass 内:`color = color * (1.0 - haze.a) + haze;`(`clouds.frag:988`)
-- 与场景合成(`cloudsEffect.frag:7-9`):`outputColor.rgb = inputColor.rgb * (1.0 - clouds.a) + clouds.rgb;`
-
-合并即标准雾公式 **C_out = C_in·exp(−OD) + inscatter**。内散射色不是常量,而是:
-
-```glsl
-vec3 irradiance = sunIrradiance * phaseFunction(cosTheta);          // clouds.frag:684
-irradiance += skyIrradiance * RECIPROCAL_PI4 * skyIrradianceScale;  // clouds.frag:685
-vec3 inscatter = albedo * irradiance * saturate(1.0 - exp(-effectiveOpticalDepth)); // clouds.frag:686
-```
-
-`sunIrradiance`/`skyIrradiance` 由 `GetSunAndSkyIrradiance`(`clouds.vert:34-53`)从**预计算 LUT** 求出——太阳辐照 = 透射率 LUT 查询(`functions.glsl:410-419`、`GetTransmittanceToSun` 在 `functions.glsl:139-148`),天空辐照 = irradiance LUT 查询(`functions.glsl:218-221`)。LUT 尺寸与格式:transmittance 2D 256×64、scattering 3D 32×128×32×8、irradiance 2D 64×16(`lib/atmosphere/src/constants.ts:1-13`),以 EXR/半浮点或 `.bin` 加载(`PrecomputedTexturesLoader.ts:42-51, 97-135`)。大气参数默认值在 `AtmosphereParameters.ts:45-58`(Rayleigh `(0.005802, 0.013558, 0.0331)`、Mie `0.003996`、g=0.8、bottomRadius 6360 km/topRadius 6420 km)。
-
-**两条配色路线的可搬性差别**:
-- `AerialPerspectiveEffect` 路线(`aerialPerspectiveEffect.frag:249-340`):对每个片元用 `GetSkyRadianceToPoint`(`functions.glsl:329-408`)查 transmittance + 3D scattering LUT 得到**光谱透射率与内散射**——完全太阳/大气耦合,LUT 不搬进来就没法用。
-- 纯 haze 的**距离项与太阳无关**:`opticalDepth`/`expTerm`/`linearTerm` 只含 d₀、β、cosθ、L 四个量,把 `inscatter` 换成一个常量雾色(或 `czm_sunDirectionEC` 的简易相位着色),公式照常成立。这正是可搬给 07 的最小形态。
-
-### 1.3 太阳耦合:强,但都集中在「颜色」项而非「距离」项
-
-- 相位项:`cosTheta = dot(sunDirection, rayDirection)`(`clouds.frag:828`),双 lobe Henyey-Greenstein(`clouds.frag:291-336`),宏常量 `SCATTER_ANISOTROPY_1=0.7`、`_2=-0.2`、`_MIX=0.5`(`CloudsMaterial.ts:459-466`)。
-- 辐照项:上面 1.2 的 LUT 查询,强依赖太阳方向(`mu_s = dot(camera, sun_direction)/r`,`functions.glsl:350`)。
-- 阴影项:`shadowExpTerm` 用 `shadowLength`(来自 SHADOW_LENGTH pass 的 Beer 阴影长度,`clouds.frag:677`、`680`)削减雾的内散射——无云/无光轴时不参与。
-- 结论:**不喂 LUT、不做太阳时,haze 的距离-透射率部分 100% 与太阳无关**;只有雾色(内散射)依赖太阳。若 07 用固定雾色(票面问的 `#04121f` 或灰白霾),搬进 `PostProcessStage` 后不需要任何太阳纹理,最多用 Cesium 现成的 `czm_sunDirectionEC`/`czm_sunDirectionWC`(`AutomaticUniforms.js:1255-1286`)做个可选的方向性微调。
-
-### 1.4 距离口径:世界空间射线长度(米),不是视空间深度
-
-雾的「距离」是**沿视线的世界距离**,来源是射线-椭球求交,不是深度缓冲:
-
-- 射线近端 = `cameraNear`(把 near 平面距离当射线起点,`clouds.frag:982`)。
-- 射线远端 = `getHazeRayNearFar`(`clouds.frag:786-804`):`raySphereIntersections()` 对 `bottomRadius`(地表球)与 `bottomRadius+vec4(0,minHeight,maxHeight,shadowTopHeight)`(云层球壳)求交;命中地面取第一交点、否则取云顶第二交点。
-- 再用场景深度把远端**钳到真实遮挡面**:`getRayDistanceToScene`(`clouds.frag:806-814`)读深度纹理 → `reverseLogDepth` 反解 log 深度 → `perspectiveDepthToViewZ`(three 的 packing chunk,`node_modules/three/src/renderers/shaders/ShaderChunk/packing.glsl.js:96`)得视空间 z → `-viewZ / dot(rayDirection, vCameraDirection)` 换算成**沿射线的距离**。
-
-射线方向本身是**每像素反投影到 ECEF**:`clouds.vert:58-64` 用 `inverseProjectionMatrix`、`inverseViewMatrix`、`inverseEllipsoidMatrix` 把全屏四边形顶点反解成 ECEF 射线。到 Cesium 的对应物:`czm_inverseProjection`(`AutomaticUniforms.js:524-532`)+ `czm_viewRotation`(`AutomaticUniforms.js:352-362`)+ 椭球参数 `czm_ellipsoidRadii`(`AutomaticUniforms.js:1903-1914`),相机高度直接用 `czm_eyeHeight`(`AutomaticUniforms.js:1003`)——无需手工上传任何矩阵。
-
-### 1.5 可搬性判定表
-
-| 项 | 判定 | 依据 |
-| --- | --- | --- |
-| 密度曲线 `d₀·exp(−h·β)` | **纯数学可搬** | `clouds.frag:666`;参数仅两个标量 |
-| 闭式 OD 积分(iq 公式 + cosθ 钳制) | **纯数学可搬** | `clouds.frag:671-680`;只依赖标量 |
-| 混合公式 `C·(1−a)+inscatter` | **纯数学可搬** | `clouds.frag:988`、`cloudsEffect.frag:7-9`;`PostProcessStage` 一次全屏 pass 内直接写 `out_FragColor` |
-| 距离口径(世界射线长) | **结构同构需改写** | 深度→距离换算与射线-椭球求交要换 Cesium 方言(`czm_readDepth`、`czm_inverseProjection`、`czm_ellipsoidRadii`),公式本体不变 |
-| 太阳耦合(相位+辐照) | **部分可搬/部分不可搬** | HG 相位函数纯数学可搬(`clouds.frag:291-336`);LUT 辐照(transmittance/scattering/irradiance 三张纹理)不可搬,须换常量雾色或 Cesium 大气 uniform |
-| uniform 供给 | **结构同构需改写** | Three 侧手工上传 `viewMatrix`/`inverseProjectionMatrix`/`inverseViewMatrix`/`cameraNear`/`cameraFar`/`cameraHeight`(`CloudsMaterial.ts:194-201, 319-358`)→ Cesium 自动 uniform 直接对应(见 §2) |
-| EffectComposer / renderTarget 管线 | **强耦合 Three 不可搬** | MRT(`layout(location=0..2)` 输出颜色/速度/阴影长度,`clouds.frag:80-84`)、temporal upscale、reprojection(`CloudsMaterial.ts:322-354`)全部丢弃 |
-| 深度纹理采样 | **结构同构需改写** | Three:`DEPTH_PACKING==3201` 时 `unpackRGBAToDepth` 否则 `.r`(`clouds.frag:86-92`,3201=`RGBADepthPacking`、3200=`BasicDepthPacking`,`node_modules/three/src/constants.js:1199-1207`);Cesium:`czm_readDepth(depthTexture, uv)` = `.r` + log 解码(`readDepth.glsl:1-4`、`reverseLogDepth.glsl:1-11`) |
-| GLSL 方言 | **结构同构需改写** | 见 §2 |
-| 坐标系 | **结构同构需改写(低风险)** | 雾公式本身用 ECEF 相对坐标(`clouds.vert:58-64` 已把 Three 世界坐标转 ECEF),与 Cesium 的 ECEF 世界/眼坐标天然同族;Three Y-up 只是外层包装 |
-| 对数深度 | **结构同构需改写** | Three `USE_LOGDEPTHBUF` 编码(`logdepthbuf_fragment.glsl.js:2-8`:`gl_FragDepth = log2(vFragDepth)*logDepthBufFC*0.5`)与 Cesium 的 log 深度编码/解码(`reverseLogDepth.glsl`)是两套公式;Cesium 侧 `PostProcessStage` 会自动拿到 `LOG_DEPTH` define 并已替你解码(§2) |
-
-## 2. 问题 1 附 —— 渲染器耦合面清单(搬进 PostProcessStage 必须重写的每一项)
-
-1. **GLSL 版本与方言**:Three 侧用 `GLSL3`(`CloudsMaterial.ts:159`),`layout(location=N) in/out` 显式声明(`clouds.vert:23`、`clouds.frag:80-84`),WebGL2 前缀里 `#define texture2D texture`(`node_modules/three/src/renderers/webgl/WebGLProgram.js:864-878`)。Cesium 侧写「GLSL1 风格 + czm_* 内建」即可,`ShaderSource` 在 WebGL2 下自动补 `#version 300 es`(`ShaderSource.js:296-301`),WebGL1 下自动降级(`demodernizeShader`);输出变量必须叫 `out_FragColor`,`v_textureCoordinates` 由用户自己声明([PostProcessStage 官方文档](https://cesium.com/learn/cesiumjs/ref-doc/PostProcessStage.html) 示例)。
-2. **float 精度**:Three 侧 shader 自带 `precision highp float`(`clouds.frag:1`);Cesium 会**剥掉用户 precision 声明**(`ShaderSource.js:206-210`)并注入 `GL_FRAGMENT_PRECISION_HIGH` 分支的 highp/mediump 兜底(`ShaderSource.js:229-243`)——不写反而更省事。
-3. **uniform 供给**:Three 管线手动逐帧拷贝 `viewMatrix`/`inverseViewMatrix`/`inverseProjectionMatrix`/`cameraNear`/`cameraFar`(`CloudsMaterial.ts:319-358`);postprocessing 库的 `EffectMaterial` 额外内建 `cameraNear`(默认 0.3)/`cameraFar`(默认 1000)/`resolution`/`texelSize`([EffectMaterial.js 源码](https://cdn.jsdelivr.net/gh/pmndrs/postprocessing@v6.37.8/src/materials/EffectMaterial.js))。Cesium 对应物全自动:`czm_projection`/`czm_inverseProjection`(`AutomaticUniforms.js:499-509, 524-532`)、`czm_viewRotation`(`:352-362`)、`czm_viewport`(`:64`)、`czm_eyeHeight`(`:1003`)、`czm_entireFrustum`(`:1137`)、`czm_currentFrustum`(`:1152-1160`)、`czm_sunDirectionEC/WC`(`:1264-1286`)、`czm_ellipsoidRadii/InverseRadii`(`:1903-1914`);用户自定义 uniform 走 `stage.uniforms`(常量或每帧函数,`PostProcessStage.js:406-466`)。
-4. **深度纹理**:`PostProcessStage` 默认即获得 `sampler2D colorTexture` + `sampler2D depthTexture` + `vec2 v_textureCoordinates`(`PostProcessStage.js:32-33`、uniform 注入在 `:520-530`),深度是场景渲染输出的 D24S8 深度-模板纹理(`FramebufferManager.js:189-196` 创建,`Scene.js:4170-4173` 传入后处理)。stage 声明了 `depthTexture` 但设备不支持时整级自动禁用(`PostProcessStage.js:397-404`)——即 07 想要的能力探测已有现成实现。读深度用 `czm_readDepth(depthTexture, v_textureCoordinates)`,它在 `LOG_DEPTH` 下自动做 log 反解(`readDepth.glsl:1-4`、`reverseLogDepth.glsl:1-11`);`LOG_DEPTH` define 由场景状态自动注入(`PostProcessStage.js:876-887, 588-591`),`Scene.logarithmicDepthBuffer` 在 1.145 **默认 true**(`Scene.js:195` + `Scene.defaultLogDepthBuffer = true` 在 `Scene.js:798`,仅要求 `context.fragmentDepth`)。
-5. **坐标系与高度**:公式用的「上」是地心向外(`normalize(rayOrigin)`,`clouds.frag:673`),相机高度是椭球大地高(`CloudsMaterial.ts:363-370`)。Cesium 侧 `czm_eyeHeight` 正是「相机相对椭球的高度」(`AutomaticUniforms.js:1003` 及其文档),语义完全一致;无需理会 Three Y-up / Cesium Z-up 的差异——那是 Three 局部场景的包装,雾公式早已通过 `inverseEllipsoidMatrix` 转到 ECEF(`clouds.vert:58-64`)。
-6. **管线与多 pass**:丢弃全部多目标渲染(MRT 三输出)、temporal upscale(16 帧 Bayer 交错,`cloudsResolve.frag:190-197`)、reprojection 速度(`clouds.frag:936-942`)、BSM 级联阴影、STBN 抖动。雾只需要「一遍全屏 pass + 深度附件」;深度附件在启用 post-process 时由引擎白送(`Scene.js:4160-4175`)。
-
-## 3. 问题 2 —— Cesium 深度 → Three 的可行性
-
-### 3.1 现状与类型:`Context.depthTexture` 是布尔,真正的深度纹理是 D24S8
-
-- `Context.depthTexture` 是 **boolean 能力标志**(是否支持 WEBGL_depth_texture):实现见 `Context.js:148-151`(构造期查扩展)、`:714-725`(getter `return this._depthTexture || this._webgl2`)。**它不是纹理对象**——票面「`Context.depthTexture` 能否作为 Three 的深度输入」的前提本身需要修正。
-- 该属性**不在随包类型声明**:全文件 `grep depthTexture` 仅命中 `Cesium.d.ts:43388`、`:43429` 两处 PostProcessStage 的 JSDoc 注释,`Context` 类无此成员(引用 04 结论并给出本文件证据)。
-- 真正的深度纹理来自 `FramebufferManager`:**`PixelFormat.DEPTH_STENCIL` + `PixelDatatype.UNSIGNED_INT_24_8`**(`FramebufferManager.js:189-196`),即 24 bit 深度 + 8 bit 模板打包;另有一个纯深度 `DEPTH_COMPONENT/UNSIGNED_INT` 变体供「仅深度」framebuffer(`FramebufferManager.js:217-226`)。主场景走 `getDepthStencilTexture()`(`Scene.js:4170-4173`)。默认 MSAA=4(`Scene.js:118, 253`),多采样时深度渲染进 renderbuffer、再解析进纹理。
-- Three 期望的格式:`DepthTexture` 默认 `UnsignedIntType + DepthFormat`,也支持 `DepthStencilFormat`(`node_modules/three/src/textures/DepthTexture.js:28-38`);WebGL2 下内部格式映射 `DEPTH24_STENCIL8`/`DEPTH_COMPONENT24`/`DEPTH_COMPONENT32F`/`DEPTH_COMPONENT16`(`WebGLTextures.js:232-270`)。**格式上相容**(D24S8 正是 Three `DepthStencilFormat+UnsignedInt248Type` 的映射目标),采样 `.x` 即深度。
-- **但纹理对象不能跨 context**:WebGL 没有跨 context 导入纹理的 API。Cesium 在它自己的 canvas 上独占一个 GL context,Three 0.180 的 `WebGLRenderer` 是 **WebGL2-only**(`WebGLRenderer.js:58-61`「WebGL 1 is not supported since r163」)且**接受外部 `context` 参数**(`WebGLRenderer.js:73-74, 97-111`)——理论上唯一零拷贝路线是把 Cesium 的私有 context(`scene.context._gl`,非公开 API)交给 Three,而 Cesium 官方明确不支持与其他引擎共享 context([issue #648](https://github.com/CesiumGS/cesium/issues/648):自 2013 年 open,「Cesium 需要在 `Scene.render` 调用之外不持有 context」,至今未做)。另一条路是 `readPixels` 回读 + 重上传,见 §3.4。
-
-### 3.2 相机与投影对齐:每帧要同步什么
-
-Cesium 侧全部有公开 API:`camera.lookAtTransform` 的 HeadingPitchRange 语义(heading 从 y 轴向 x 轴、正 pitch 在参考平面下方,`Camera.js:2393-2396`;实现 `offsetFromHeadingPitchRange` 在 `Camera.js:2360-2389`)、`camera.viewMatrix`/`inverseViewMatrix`(`Camera.js:891-907`)、`frustum.near/far/fovy/aspectRatio`。Three 侧 `PerspectiveCamera` 由 fov/aspect/near/far 构造;`Matrix4.elements` 列主序(`node_modules/three/src/math/Matrix4.js:78`),与 Cesium 列主序一致。
-
-每帧同步面清单(双 canvas 官方方案,[Cesium 官方博客「Integrating Cesium with Three.js」](https://cesium.com/blog/2017/10/23/integrating-cesium-with-threejs/)):
-
-1. 相机位置:`positionWC`(ECEF)→ Three 世界坐标(官方博客方案里直接复制进 Three 的局部 ECEF 场景,需做 Y-up/Z-up 轴交换,博客原文注明「Three.js renders z-up and Cesium renders y-up」);或转成东北天局部系(仓库已有 `Transforms.eastNorthUpToFixedFrame` 实践,`src/modules/cockpit/services/cesiumEngine.js:236-238`)。
-2. 相机朝向:ECEF 方向/上方向 → Three `camera.quaternion`(轴交换 + 用 ENU 帧矩阵变换);社区 Babylon 移植版给出了同款换算([same-canvas 线程第 5 楼](https://community.cesium.com/t/integrate-cesiumjs-with-other-webgl-renderers-into-same-canvas/32080))。
-3. 投影:`frustum.fovy`(仓库已有 `fovY()` 读法,`cesiumEngine.js:313-315`)、`aspect`、`near`。**far 是坑**:Cesium 每帧动态重算 near/far(社区实测「相机一动深度值就抖」,需用 `uniformState.currentFrustum` 归一化,[how-to-get-depth-texture-properly 线程](https://community.cesium.com/t/how-to-get-depth-texture-properly/32282)第 4 楼)——Three 侧固定 near/far 的逆投影会对不上。
-4. 渲染时序:`useDefaultRenderLoop=false` + 每帧 `viewer.render()` 后接 `three.renderer.render()`(官方博客模式);resize 时序同步 `viewer.resize()` 与 `renderer.setSize`。
-
-代码量估计:矩阵/参数同步函数约 60–100 行(含 ENU 轴交换与 quaternion 换算);但**同步相机只是热身**,深度的语义对齐才是主体(§3.3-3.5)。
-
-### 3.3 深度非线性口径与精度
-
-- Cesium(默认 log depth,`Scene.js:195, 798`):存的是 `log2(depthFromNear+1)/czm_log2FarDepthFromNearPlusOne` 编码值;后处理里 `czm_readDepth` 经 `czm_reverseLogDepth` 反解回透视 [0,1] 深度(`reverseLogDepth.glsl:1-11`)。若直接拿原始纹理值喂 Three,必须**自己重新线性化**,且近远平面取 `czm_currentFrustum`(动态)。
-- Three:透视深度或自己的 log 编码 `gl_FragDepth = log2(vFragDepth)*logDepthBufFC*0.5`(`logdepthbuf_fragment.glsl.js:2-8`)——与 Cesium 的编码**不是同一公式**,双向转换都要写。
-- 精度(县城尺度 near 1 m / far 100 km,参照 `map.md:47-49` 基准):24 bit 定点 log 深度在 1 m–100 km 跨 5 个数量级时量化步长尚可(log 分布把精度摊匀),**真正的精度杀手是回读路径**:`readPixels` 只能整帧回读,D24S8 的深度要么以 `DEPTH_COMPONENT→UNSIGNED_INT` 回读(WebGL2 允许的组合受 [规范 §4.3.2](https://registry.khronos.org/webgl/specs/latest/2.0/#4.3.2) 限制,深度-模板附件不是所有组合都合法),要么按 Cesium 现成的 `GlobeDepth` copyDepth 机制先把深度**打进 RGBA 色纹**(`GlobeDepth.js:47` 的 `_copyDepthFramebuffer`、`:177-193` 的 copy 命令,用 `czm_packDepth` 的 4×8bit 编码,`packDepth.glsl:10-18`)再以 `UNSIGNED_BYTE` 回读——多一遍全屏 pass + 上传,且 8bit 分量编码在 100 km 量程上有可见带化风险。z-fighting 风险在县城尺度低,量化/带化风险中。
-
-### 3.4 每帧成本
-
-- `readPixels` 是**阻塞管线停顿**操作,无论读多少都强制 GPU→CPU 同步([Stack Overflow 实测 10 ms 级](https://stackoverflow.com/questions/28282935/working-around-webgl-readpixels-being-slow)、[Khronos 论坛](https://community.khronos.org/t/readpixels-performance-and-pipeline-stall/107644))。
-- 1920×1080 深度一帧:UNSIGNED_INT 回读 ≈ 8.3 MB、RGBA 打包回读同样 8.3 MB,随后 CPU→GPU 重上传到另一个 context——每帧两次全带宽拷贝 + 一次停顿。
-- 加上 copyDepth 打包 pass、双渲染器各自的帧循环、动态近远平面归一化、devicePixelRatio/resize 双份同步;官方博客方案的额外 `resolutionScale` 之类近似会进一步破坏深度对齐。
-
-### 3.5 失败面
-
-1. **深度格式**:D24S8 打包格式、log 深度编码与 Three 期望的透视/自有 log 编码三处都不直接对齐,全部要转(§3.3)。
-2. **上下文/画布隔离**:双 context 无纹理互通 API;单 context 共用靠 Cesium 私有 `_gl`,上游不支持([issue #648](https://github.com/CesiumGS/cesium/issues/648) 自 2013 年 open);同 canvas 混渲会互相踩 GL 状态,社区要手动 `gl.frontFace(gl.CCW)` 复位([same-canvas 线程第 2 楼](https://community.cesium.com/t/integrate-cesiumjs-with-other-webgl-renderers-into-same-canvas/32080)),Cesium 无官方状态复位 API。
-3. **Cesium 内部多 pass 不一致**:深度纹理内容随 `useGlobeDepthFramebuffer`/OIT/MSAA/是否有 3D Tiles 而变——社区实测同一取法「加了 OSM Buildings 深度才平滑、否则抖」([how-to-get-depth-texture-properly 线程](https://community.cesium.com/t/how-to-get-depth-texture-properly/32282)第 1/4 楼)。
-4. **俯视遮挡错乱**:把 Cesium 深度写回 canvas 深度缓冲再让外部引擎继续画的方案,实测**只在平视正确,俯视/仰视遮挡错误**([same-canvas 线程第 9 楼](https://community.cesium.com/t/integrate-cesiumjs-with-other-webgl-renderers-into-same-canvas/32080))——驾驶舱主视角(home pitch −55°、近俯视,`map.md:49`)正中此失败面。
-5. **半公开 API 回归**:`uniformState.globeDepthTexture`/`scene.context._gl` 均不在 d.ts、无兼容承诺(社区全靠它取纹理,[线程](https://community.cesium.com/t/how-to-get-depth-texture-properly/32282))。
-6. **移动端 WebGL1**:Three 0.180 只支持 WebGL2(`WebGLRenderer.js:58-61`),该组合在 WebGL1 设备直接不可用;Cesium 的 depthTexture 在 WebGL1 依赖扩展(`Context.js:148-151`)。
-
-### 3.6 评级与最小可行草图(只评估,不实施)
-
-**评级:极难**。格式与语义对齐每一条都能做,但**上下文隔离**这一条把整条路线逼到「私有 API 单 context 拼装」或「每帧读回」两条悬崖边上,且社区实测在俯视下失败——与本驾驶舱主视角正交冲突。
-
-若被迫实施的最小可行草图(一段话,**不是实施建议**):单 canvas 单 context(把 Cesium 的私有 WebGL2 context 交给 `THREE.WebGLRenderer({canvas, context})`),Cesium 侧 `useDefaultRenderLoop=false`;每帧 `scene.render()` 后,在 Cesium 的 `postRender` 钩子用 `GlobeDepth` 的 copyDepth 机制把 log 深度打包进 RGBA 色纹,`readPixels`(UNSIGNED_BYTE)回读并按 `uniformState.currentFrustum` 归一化、反解 log 深度后以 `DataTexture` 上传给 Three 的雾 shader,Three 相机按 §3.2 清单逐帧同步;全程用 `context.depthTexture` 与 WebGL2 探测包裹,拿不到就退化。该草图未经任何实现验证,且**明确与 ADR 0004 冲突**:它把深度合成从 Cesium 手里拿出来、让驾驶舱场景由两套渲染器共同决定遮挡(等于让第二套渲染器接管合成),超出「Three 只做重点管线/局部构件增强」的边界;同时让大型插件能力进入驾驶舱主渲染循环,与 ADR 0007「大型插件不作为驾驶舱主体依赖」相抵。**按票面约束(`issues/06:22`),此路线须先推翻/修订 ADR 0004 并交回甲方裁定,不得直接建议实施。**
-
-## 4. 对 07(雾实现规格)的输入
-
-1. **曲线可复用,参数必须重调**:闭式公式 `OD = (d₀/β/cosθ)·(1−exp(−L·β·cosθ))`(`clouds.frag:673-679`)直接搬;但在俯视/平视钳制分支退化为 `OD ≈ d₀·L` 的**线性距离雾**,默认参数(3e-5 / 1e-3,coverage 调制 0.5)50% 起雾 ≈ 56 km(h=200 m)——07 的 1,651 m 预算要求密度抬 ≈30×,或换更陡的曲线族。这个「俯视自动退化为各向同性线性雾」的性质恰好适配 07 的自洽约束(`issues/07:18` 要求各向同性且俯视看得到 15% 县城)。
-2. **距离口径**:Three 版用世界射线长;Cesium `PostProcessStage` 版可用 `czm_readDepth` 取视空间深度再乘 `czm_inverseProjection` 反解,或按 §1.4 的射线-椭球求交复刻——两者 07 二选一,都与「相机→片元距离」语义一致。
-3. **配色**:LUT 路线(transmittance/scattering/irradiance 三张纹理)不搬;雾色用常量(`#04121f` vs 灰白霾是 07 的产品取舍,本票只确认数学上常量雾色即成立)。可选:用 `czm_sunDirectionEC` 做简易相位着色。
-4. **门控**:`PostProcessStage` 有现成的能力探测(`PostProcessStage.js:397-404`)与 `enabled` 开关(`PostProcessStage.js:182`),可支撑 07 的按需挂载/高度带门控;深度附件在启用后处理时由引擎白送(`Scene.js:4160-4175`),开销 = 一遍全屏 pass + 打包深度已有的附件。
-5. **风险**:`Context.depthTexture` 不在 d.ts(半公开 API),07 须做能力探测 + 优雅降级(拿不到深度时退化为无雾或常量混色)——已有现成 `_isSupported` 行为可参考。
-
-## Sources
-
-- `src/plugins/useThreeGeospatial/lib/clouds/src/shaders/clouds.frag`、`clouds.vert`、`cloudsEffect.frag`、`cloudsResolve.frag`、`parameters.glsl`、`types.glsl`、`clouds.glsl`(仓库内,行号见正文)
-- `src/plugins/useThreeGeospatial/lib/clouds/src/CloudsMaterial.ts`、`CloudsEffect.ts`、`PassBase.ts`、`uniforms.ts`、`constants.ts`、`qualityPresets.ts`
-- `src/plugins/useThreeGeospatial/lib/clouds/README.md`(haze 参数文档)
-- `src/plugins/useThreeGeospatial/lib/atmosphere/src/AerialPerspectiveEffect.ts`、`AtmosphereParameters.ts`、`PrecomputedTexturesLoader.ts`、`constants.ts`、`shaders/functions.glsl`、`shaders/aerialPerspectiveEffect.frag`
-- `src/plugins/useThreeGeospatial/lib/effects/src/DepthEffect.ts`、`src/shaders/depthEffect.frag`
-- `node_modules/@takram/three-geospatial/src/shaders/depth.glsl`、`math.glsl`、`transform.glsl`、`packing.glsl`
-- `node_modules/@cesium/engine/Source/Renderer/Context.js`、`FramebufferManager.js`、`ShaderSource.js`、`AutomaticUniforms.js`、`UniformState.js`
-- `node_modules/@cesium/engine/Source/Scene/PostProcessStage.js`、`PostProcessStageCollection.js`、`Scene.js`、`Camera.js`、`GlobeDepth.js`、`Core/PerspectiveFrustum.js`
-- `node_modules/@cesium/engine/Source/Shaders/Builtin/Functions/readDepth.glsl`、`reverseLogDepth.glsl`、`packDepth.glsl`、`unpackDepth.js`、`windowToEyeCoordinates.glsl`
-- `node_modules/cesium/Source/Cesium.d.ts`(depthTexture 仅出现在注释:43388、43429)
-- `node_modules/cesium/package.json`、`node_modules/three/package.json`
-- `node_modules/three/src/textures/DepthTexture.js`、`src/renderers/WebGLRenderer.js`、`src/renderers/webgl/WebGLTextures.js`、`WebGLProgram.js`、`WebGLCapabilities.js`、`src/renderers/shaders/ShaderChunk/logdepthbuf_fragment.glsl.js`、`packing.glsl.js`、`src/math/Matrix4.js`、`src/constants.js`
-- [PostProcessStage - Cesium 官方文档](https://cesium.com/learn/cesiumjs/ref-doc/PostProcessStage.html)
-- [Scene.defaultLogDepthBuffer - Cesium 官方文档](https://cesium.com/learn/cesiumjs/ref-doc/Scene.html)
-- [Integrate with other WebGL engines · CesiumGS/cesium issue #648](https://github.com/CesiumGS/cesium/issues/648)(2013 年开票,仍 open)
-- [Integrating Cesium with Three.js - Cesium 官方博客](https://cesium.com/blog/2017/10/23/integrating-cesium-with-threejs/)
-- [How to get depth texture properly? - Cesium 社区](https://community.cesium.com/t/how-to-get-depth-texture-properly/32282)
-- [Integrate CesiumJS with other WebGL renderers into same canvas - Cesium 社区](https://community.cesium.com/t/integrate-cesiumjs-with-other-webgl-renderers-into-same-canvas/32080)
-- [three.js DepthTexture 文档](https://threejs.org/docs/#api/en/textures/DepthTexture.html)、[WebGLRenderTarget.depthTexture 文档](https://threejs.org/docs/#api/en/renderers/WebGLRenderTarget.depthTexture)
-- [postprocessing EffectMaterial 源码(v6.37.8)](https://cdn.jsdelivr.net/gh/pmndrs/postprocessing@v6.37.8/src/materials/EffectMaterial.js)
-- [Working around WebGL readPixels being slow - Stack Overflow](https://stackoverflow.com/questions/28282935/working-around-webgl-readpixels-being-slow)
-- [ReadPixels performance and pipeline stall - Khronos 论坛](https://community.khronos.org/t/readpixels-performance-and-pipeline-stall/107644)
-- [WebGL2 规范 §4.3.2(readPixels 允许的格式组合)](https://registry.khronos.org/webgl/specs/latest/2.0/#4.3.2)
-- [Inigo Quilez - Fog(haze 闭式积分的来源)](https://iquilezles.org/articles/fog/)

+ 0 - 74
.scratch/cockpit-primary-area-guardrails/spec-fog.md

@@ -1,74 +0,0 @@
-# 主场景区地物距离雾规格
-
-Status: ready-for-agent
-Label: ready-for-agent
-
-本规格仅固化已收口的[视距雾气的实现规格](issues/07-fog-implementation-spec.md)决策。原有 [主场景区相机限位与进出状态机规格](spec.md)保持独立;本规格不替代其进出状态、相机限位和归位契约。
-
-## Problem Statement
-
-值班人员进入沅陵县城主场景区后,拉近与旋转镜头需要保留清晰的地物,同时避免远方粗底图、影像边缘等瑕疵破坏沉浸感。此前仅靠 Cesium 默认雾无法稳定获得所需效果。维护者观看独立原型后认可 B 方案的地物距离雾颜色和距离,但进入演示中雾形成得太晚,矩形影像边缘先暴露。需要让可实施的雾气契约与进入/返回时的影像交接相容,并如实处理雾不可用、透明地表和性能风险。
-
-## Solution
-
-在现有 Cesium 地图上增加一层**仅作用于有有效几何深度地物**的距离雾。维护者选定的首版默认参数为相机到片元的世界射线距离 **18 km 开始起雾、42 km 达到完全遮蔽、雾色 `#bdcdd6`**,中间平滑增浓;无有效深度的天空背景保留原色。该雾可以提前准备、进入主场景区时启用、返回全球时平滑退出;它提供能力、就绪、强度和故障信息供影像过渡使用,但不自行断言整幅画面已被遮住。
-
-全球自转和引导阶段先隐藏精细叠加影像,雾先具备有效保护,再渐显精细层;返回时反向交接。具体交接时机、影像准备度、失败恢复及全程无矩形跳变的判定由[低精度 → 高精度过渡的解法](issues/08-coarse-to-fine-transition-solution.md#answer)持有:维护者已基本验收实际场景原型方向,并要求正式实施将进出雾气变化稍调快、雾色校为不发绿的冷白灰、返回时让镜头高度与雾气模糊同步变化且影像始终受保护。本规格固化雾气端必须提供的能力,不把原型观感认可写成正式运行达标。雾无法运行时保留业务可用性并清楚提示降级。
-
-## User Stories
-
-1. 作为值班人员,我希望在主场景区观察近处街区时地物依然清晰,以便辨认监测点及周围环境。
-2. 作为值班人员,我希望远处地物平滑融入雾色,以便旋转镜头时看不到突兀的远景边缘。
-3. 作为值班人员,我希望雾色和当前认可的 B 原型接近,以便县城画面保持已确认的视觉基调。
-4. 作为值班人员,我希望天空保留本来的颜色,以便它不会被整片压成夜空或一块平涂背景。
-5. 作为值班人员,我希望从全球进入主场景区时精细影像不要早于遮挡保护出现,以便飞入过程不露出明显矩形。
-6. 作为值班人员,我希望返回全球途中精细影像在保护撤除前收起,以便镜头拉远时不再次露出矩形边缘。
-7. 作为值班人员,我希望在自由全球视角没有自建县城雾,以便继续观看完整地球。
-8. 作为值班人员,我希望连续进入、取消和返回时仅最新操作生效,以便旧淡出操作不会突然关掉新进入的雾。
-9. 作为值班人员,我希望在管线视图检查近处管线时保持可读,以便透明深度错位时可减弱或暂关雾。
-10. 作为值班人员,我希望远处三维设备特效随雾一起减弱,以便特效不会像贴在雾面之上。
-11. 作为值班人员,我希望面板中的报警文字和状态始终清晰,以便远景雾不会遮住业务判断依据。
-12. 作为值班人员,我希望雾无法运行时仍能查看可用近景和业务内容,以便单个视觉效果失效不会锁住工作流程。
-13. 作为维护者,我希望无深度能力、雾创建失败或因可读性禁用时界面如实提示,以便部署异常不被误判成遮缝已生效。
-14. 作为维护者,我希望复用已创建的雾处理级并在离开地图时释放,以便减少反复创建造成的切换开销。
-15. 作为维护者,我希望雾支持按条件启停与参数调整,以便完成现场视觉调校及性能降级。
-16. 作为测试人员,我希望对同一视角和加载状态比较开启与关闭雾的帧耗时,以便判断是否满足性能预算。
-17. 作为验收人员,我希望单独检查首次启用、进入、返回、归位、浅俯角和管线视图,以便稳定段数字不会掩盖切换卡顿或错误雾化。
-18. 作为验收人员,我希望明确知道“雾已就绪”和“矩形边缘确已遮住”的区别,以便不把引擎状态误当作视觉验收结果。
-
-## Implementation Decisions
-
-- 在**单一 Cesium 渲染器**中增加一层自建深度后处理雾;采用经维护者选择的 B 方案,不引入第二套 Three.js 渲染器,也不复用原型 C 的天空染色或太阳方向暖色。
-- 对有效几何深度重建相机到片元的世界空间射线距离,按 18,000 m 起雾、42,000 m 完全遮蔽的默认端点平滑增浓。`#bdcdd6` 为早期经维护者确认的原型默认初值;后续实际场景反馈进一步要求**白灰偏冷、不得发绿**,正式色值须在真实场景调校和复核,不把初值当最终色。距离端点未在这次反馈中变更。
-- 无有效几何深度的背景保留原色;无深度值不能被直接当作真实无限距离,也不能当作可靠的天空对象分类。透明地表与特效可能已写颜色但未写自身深度,需把这一点纳入处理与验收。
-- 雾支持提前准备、启用、调节当前强度、退出时淡出、取消过期操作、禁用及销毁。对外提供**是否支持、是否可执行、当前强度、当前参数、失败/降级原因**等可观测信息;不以 ready 或强度单项宣称整幅视野已有保护,也不提供未经验证的全局“已遮缝”布尔承诺。
-- 延续主场景区的进出状态:稳定进入时自建雾生效,自由全球及引导态关闭。进入途中的早期高度渐变可以作为雾强度输入,但原型的 **60/20 km 高度带已在人工演示中表现为遮挡过晚**,不得作为揭示精细影像的充分条件。实际先后交接由过渡规格决定。
-- 返回全球时原型采用 **0.8 秒淡出**,只是历史原型参数,不作为本次最终速度;维护者在真实场景反馈**进入和返回雾气变化都应稍快**,未指定新时长。返回时相机高度上升与雾气模糊减弱要同步,而精细影像必须在失去保护前安全收起;不允许等影像收完后画面停顿才起飞,也不允许提前露边。反向快速切换必须取消过期操作。优先复用处理级对象,离开地图时销毁;禁用不保证完全无 GPU 开销,也不保证下次启用毫无资源重建。
-- 精细影像在全球/引导阶段先隐藏、保留粗底图;进入时先准备雾再渐显精细层,返回反向交接。这是确定的交接方向。具体交接阈值、瓦片准备度、是否继续请求隐藏层瓦片、失败与取消路径由下游过渡决策确定,不在雾气模块里复制一套影像状态机。
-- 无深度支持或雾运行失效时整级禁用并如实提示;**业务可用优先**,继续提供可用近景与业务操作,不让精细影像永久等待不可能成立的雾就绪。降级时不能宣称完全遮缝;无雾时影像怎样交接由过渡规格决定。主场景区配置缺失时沿用既有配置失效口径。
-- 管线视图出现透明对象错误雾化时,允许减弱或暂关自建雾,同时保留相机限位并提示。正常情况下不预先关闭所有管线视图雾效。
-- 三维设备特效随远景雾衰减,不开穿雾例外;面板业务文字始终清楚。若特效采用后处理,按颜色串联先特效后雾,并让两者**独立启停**。顺序不保证透明特效有自身深度。
-- 同机、同分辨率、同视角和相同加载条件下,正式交付的 **P95 帧耗时增幅 ≤15%**;首次启用与进出卡顿另行验收。超预算先优化,仍不达标时降低效果质量或关闭并提示;性能合格不能替代视觉和业务可读性验收。
-
-## Testing Decisions
-
-- 好的测试检查**外部可见行为**:近景可读、远景平滑衰减、无深度背景不被整屏染色、能力失败有提示、进出时序有可观测状态,以及管线与设备特效在降级前后仍符合业务目标。不要通过内部变量名或“stage ready=true”代替视觉验收。
-- **主要验证 seam**:使用现有驾驶舱地图服务层的可观测状态与真实一张图做集成走查;雾处理级提供支持/强度/失败信息,过渡逻辑只消费这些信息。既有相机状态和地图服务读数可作为同一场景下的对照,避免另造第二套地图测试入口。
-- **计算型补充 seam**:用仓库既有纯逻辑相机和视图模式断言形式,验证 18/42 km 衰减端点、进入/退出强度推进、降级选择及过期操作取消;这些断言只能证明策略计算正确,不能证明 shader 观感或遮缝已达标。
-- **人工视觉验收**:对照已选 B 的原型检查归位视角、浅俯角多个方向、地平线、影像边缘、半透明管线、设备特效,以及进入与返回全过程。进入演示曾被维护者判为“雾太晚、矩形边缘先出现”;修正后必须重看,不能沿用前次通过结论。完整影像交接的验收归过渡决策与其实施票。
-- **性能验收**:在固定设备、分辨率、视角、图层及相同加载状态下,记录**无原型基线**和开启雾后的重复采样 P95 帧耗时,验证增幅不超过 15%,并单独检查首次启用和切换卡顿。原型里的渲染事件间隔仅供参考,不是 GPU 时间,也不能单独证明预算达标;低帧率基线本身也不因相对增幅合格而合格。
-- 本仓已有相机契约、视图模式的纯逻辑验证,以及进入引导的走查记录,可复用其模式。依照项目规则,只有维护者明确要求时才运行 build/dev、浏览器走查或截图。
-
-## Out of Scope
-
-- 主场景区的坐标、相机 range 上限、软回弹及“返回全球”状态机:已由现有相机规格与 ADR 定义,本规格只消费其结果。
-- 精细影像渐显的具体时长、瓦片就绪判定、影像层级替换与边缘羽化算法、正反向交接原型:由「低精度 → 高精度过渡的解法」继续裁决。
-- 第二套 Three.js 渲染器、跨引擎深度互通、大气散射 LUT 或通用后处理框架。
-- 更改业务面板、报警状态、监测点与设备的数据契约。
-- 把开发态可丢弃雾原型直接当成生产代码或性能合格证据。
-
-## Further Notes
-
-- 早期独立雾原型选 B,维护者曾反馈**颜色和距离基本没有问题**,并确认其默认 18/42 km 与 `#bdcdd6`;当时进入演示明确失败:矩形边缘先于雾的有效遮挡出现。之后的[实际场景影像交接原型](issues/08-coarse-to-fine-transition-solution.md#answer)已获维护者基本认可,同时补充冷白灰不发绿、雾变换稍快、返回高度与雾效同步的要求。前次失败记录不删除;决策通过不等于正式实现已修复该缺陷。
-- 若雾不支持或管线视图因透明深度错位被迫降级,应提示远景保护减弱;具体无雾过渡路径由下一决策票明确。本票已按决策范围收口,不能让下游过渡票等待雾的生产性能数据再认领。
-- 本规格与[主场景区相机限位与进出状态机规格](spec.md)是并列文件;后者未被覆盖或改写。实现票仍依地图约定在 Cockpit 外壳执行索引中建立。

+ 0 - 68
.scratch/cockpit-primary-area-guardrails/spec.md

@@ -1,68 +0,0 @@
-# 主场景区的相机限位与进出状态机 spec
-
-Status: ready-for-agent
-
-## Problem Statement
-
-驾驶舱进入主场景区(沅陵县城框定范围)后,相机没有区域限位:用户可以把弹簧臂拉远到全球尺度,看到高精度精细数据以外的低精度区域与底图接缝("露馅")。同时,进入主场景区时相机从全球粗略底图飞到精细影像矩形,过渡突兀。本 spec 固化第 1 项(相机限位与进出状态机)的实现契约;雾与过渡分别由同 effort 的 07、08 另行固化为规格(待 resolved)。
-
-决策来源:`.scratch/cockpit-primary-area-guardrails/issues/03`(已 resolved,维护者 Q1–Q5 + R2-1…R2-6 全部确认),决策记录在 `docs/adr/0009-primary-area-camera-limits-and-entry-state.md`。
-
-## Solution
-
-进入主场景区后,相机被显式模式锁限定:**最远视野 = 归位默认视角**(`range_max = 12,000 m`),中心点被约束在 `map.primaryArea.bounds` 内(边界软回弹),相机不钻入地形;只有「返回全球」能解除限位,回到自由全球视角;全球态闲置 5 秒未操作回到程序初始态(星球自转 + 屏幕中央闪烁「点击进入系统」),不重置一级模式与筛选。与既有三键相机契约(ADR 0008)、地面/管线视图(取交集)合成,全部落在服务层。
-
-## User Stories
-
-1. 作为值班人员,我希望进入主场景区后滚轮不能把镜头拉远到超出归位默认视角,以便看不到高精度数据以外的区域而"露馅"。
-2. 作为值班人员,我希望点「进入系统」或「归位」后进入主场景区并自动锁定限位,以便无需手动设置即可获得保护。
-3. 作为值班人员,我希望「归位」把我带回沅陵县城默认视角(12 km 县域级),以便快速回到工作位置。
-4. 作为值班人员,我希望点击「返回全球」后限位解除、镜头阻尼飞到自由全球全景,以便查看整体态势或离开主场景区。
-5. 作为值班人员,我希望在自由全球视角闲置 5 秒后回到程序初始态(星球自转 + 「点击进入系统」闪烁),以便长时间离开后回来仍是熟悉入口。
-6. 作为值班人员,我希望回到引导页时不重置当前一级模式与行业/区域筛选,以便回来后续接之前的工作上下文。
-7. 作为值班人员,我希望在区内平移到边界时有软回弹手感(短暂越界后松手弹回),以便知道"到头了"而不是被硬生生挡住。
-8. 作为值班人员,我希望镜头贴近地面或设备时不会钻进地形(防入地),以便始终能看到目标。
-9. 作为值班人员,我希望在区内切换地面/管线视图时两组约束取交集、行为与现状一致,以便管线巡查不被区域限位破坏。
-10. 作为值班人员,我希望在区外顶栏按钮显示为「归位/进入」,以便一步回到主场景区。
-11. 作为维护者,我希望未配置 `map.primaryArea` 时区域限位与自建雾自动关闭并如实提示,以便不误导部署。
-12. 作为维护者,我希望限位的数值(`range_max`、软区、防入地 margin)可配置或可微调,以便按现场观感校准。
-13. 作为测试人员,我希望限位与状态机的纯逻辑可直接断言(脱离浏览器),以便快速回归。
-14. 作为验收人员,我希望通过既有走查脚本验证"进入→锁定→返回→闲置回引导"全过程,以便确认无跳变与露馅。
-
-## Implementation Decisions
-
-- **`range_max = home.range = 12,000 m`**:最远视野 = 归位默认视角(`map.home` pitch −55 / range 12000,home 预设不改)。取代 `issues/02` 的 `range_max = 1651/(2·tan(fovY/2))`(≈2,542 m)与"15% 垂直覆盖预算"锚点;`isAtHome` 的 2% 相对容差(240 m)足够识别"已归位"。
-- **中心点 ∈ `primaryArea.bounds`(软回弹)**:拖拽时 `target.pivot` 允许越出最多框短边的 5%(≈550 m,可微调);越界部分在**松手时按既有阻尼弹回**框内(非拖拽中持续回弹);`shown.pivot` 最终回到框内,越界只出现在拖拽过程。
-- **显式模式锁**:`areaState: 'locked' | 'global'`;点「进入系统」/「归位」进区并锁定;**只有「返回全球」能解除**;判定与相机位置无关,不随跨界自动切换(避免贴边抖动)。
-- **返回全球**:解除限位 + 阻尼飞到程序初始态全景(heading 0 / pitch −60 / range 20,000,000,与 `INITIAL_HPR` 一致),之后完全自由。
-- **全球态闲置 5 秒**:回到程序初始态(星球自转恢复 + 屏幕中央闪烁「点击进入系统」);**不重置** `mode`/筛选;任何交互即重置计时。`map.intro.enabled=false`(走查自隔离)时按 `global` 自由视角启动、不弹引导页。
-- **与视图模式合成(取交集)**:区内管线视图 `range ∈ [400, min(6000, range_max)] = [400, 6,000]`(因 `range_max=12,000 > 6,000`,与现状一致,无额外收紧);pitch 上下限沿用视图模式(区域不收紧 pitch)。
-- **防入地(继承 02)**:相机离地高度 `h = range·sin|pitch|`;每次输入(滚轮/轨道)时取相机正下方地形(`globe.getHeight`),若 `h < 地形 + 10 m` 则**夹 range**(不夹 pitch、不动 pivot);瓦片未加载时退回固定 10 m 绝对下限。输入时 range 同时受 `[120, range_max]`、管线视图 `[400, 6000]`、防入地下限三处夹取,最终生效 = 三者交集。
-- **状态归属**:`areaState` 进 `filterStore`(全局视图条件,与 `viewMode` 同层,切一级模式不重置);`mapService` 负责状态转移(进入/返回/归位);`cameraControl` 按状态换 limits(新增 `setRangeLimits` 一类,与既有 `setPitchLimits` 对称);5 秒闲置计时器与引导页显隐归 `CockpitShell`(组件内或 `uiStore`)。
-- **顶栏按钮**:区内显示「归位」+「返回全球」两个 chip(归位沿用现有);区外只显示一个「归位/进入」chip。
-- 三键分工与阻尼(ADR 0008)不变;限位只在输入时生效(现状 clamp 行为),无逐帧主动回拉。
-
-## Testing Decisions
-
-- **什么是好测试**:只断言外部行为 —— 限位生效后的可达 range 区间、状态机转移(进入/返回/闲置回引导)、软回弹的最终落点、防入地夹取、`isAtHome` 识别;不断言内部变量名或组件结构。
-- **逻辑 seam(主)**:`domain/camera.js` 及同层新增纯函数(限位解析 / 状态机转移 / 软区夹取)—— 与既有 `verify/t03-camera-contract.mjs`(相机契约)、`t21-view-modes.mjs`(视图模式计划)同一模式:脱浏览器断言。
-- **组件 seam**:`CockpitShell` 是唯一公共组件 seam —— 走查脚本断言顶栏 chip 的显隐/文案(`data-*`)、引导页显隐、`dataset.camera` 状态读数。
-- **集成 seam**:`mapService.getCameraState()` + `filterStore.areaState` 为只读观测出口;走查断言"进入→锁定→滚轮拉远被夹→返回→闲置回引导"全过程,沿用 `t30-intro-walkthrough.mjs` 的做法。
-- **先例**:`verify/t03-camera-contract.mjs`、`t30-intro-walkthrough.mjs`、`t30-view-presets.mjs`。按 `AGENTS.md`,改代码后不自动跑 build/dev,验证由维护者明确要求时执行。
-
-## Out of Scope
-
-- 视距雾气(曲线/配色/门控)—— 同 effort 07,`areaState` 与 `range_max` 是其输入契约。
-- 低精度→高精度过渡手段 —— 同 effort 08。
-- 镜头定位到监测点/设备的视角契约 —— 平行图 `cockpit-point-locate-and-device-link` 03(其 range 上限受 `range_max = 12,000` 约束)。
-- 重做进入引导动画本身(票 30 已交付并验收)。
-- 让 Three.js 接管场景(ADR 0004 / 0007 锁死;06 已裁定废止深度互通备选)。
-- 主场景区的配置字段形状与兜底(01 已锁)、数值口径(02 已锁,锚点被本 spec 取代)。
-- 改 `mode` / `panelId` / 面板语义与布局。
-
-## Further Notes
-
-- **联动风险(已确认归属,未裁决,留给平行图 03)**:主场景区只覆盖县城(13.4×11 km),而监测点散布全县(影像 105×122 km)。若区外监测点被选中/定位,中心点 ∈ `primaryArea.bounds` 的限位会挡住它 —— 该裁决归 `cockpit-point-locate-and-device-link/issues/03`(claimed);若裁定允许区外定位,则需在本契约开例外或收紧范围定义。
-- **边缘口径(2026-09-18 维护者确认)**:`intro.enabled=false` 时"全球态闲置 5 秒回引导页"**不发生**(无引导页可回,保持 global 自由视角)。
-- **软区与防入地 margin 均标注"可微调"**:实现与走查按观感校准,数值改动只涉及 domain 层常量,不改变契约形状。
-- 本 spec 与 `docs/adr/0009-primary-area-camera-limits-and-entry-state.md` 一致;实现票按 Q6=(a)+(c) 接在 `.scratch/cockpit-shell-panel-layout/issues/` 30 之后(31+)。

+ 0 - 108
.scratch/cockpit-primary-area-guardrails/verify/build-edges.ps1

@@ -1,108 +0,0 @@
-# Edge builder & validator for cockpit-primary-area-guardrails
-# Written by AI agent. Runs in PowerShell (Windows sandbox safe).
-
-param(
-    [string]$EffortDir = ""
-)
-
-if (-not $EffortDir) {
-    $EffortDir = Split-Path $PSScriptRoot -Parent
-}
-
-$TicketsDir = Join-Path $EffortDir "issues"
-$MapPath = Join-Path $EffortDir "map.md"
-
-Write-Host "Building native `Blocked by:` edges and validating ticket count..."
-
-# Declare expected tickets and their blocking edges
-$ExpectedTickets = @{
-    "01-primary-area-config-contract.md" = "None"
-    "02-camera-limit-numeric-contract.md" = "01"
-    "03-camera-contract-composition-and-entry-state.md" = "01,02"
-    "04-research-cesium-ground-fog-mechanisms.md" = "None"
-    "05-research-overlay-rectangle-pop-mechanisms.md" = "None"
-    "06-research-three-fog-shader-reuse.md" = "None"
-    "07-fog-implementation-spec.md" = "01,04,06"
-    "08-coarse-to-fine-transition-solution.md" = "01,05,07"
-}
-
-$ExpectedMapTaskCount = 8
-
-# Function to fix one ticket
-function Fix-Edge {
-    param([string]$TicketPath)
-    $TicketName = Split-Path $TicketPath -Leaf
-    if (-not $ExpectedTickets.ContainsKey($TicketName)) {
-        Write-Host "Unknown ticket: $TicketName"
-        return $false
-    }
-    $ExpectedEdge = $ExpectedTickets[$TicketName]
-    $EdgeLine = "Blocked by: $ExpectedEdge"
-    $Content = Get-Content $TicketPath -Raw
-    if ($Content -match "(?m)^Blocked by: .*$") {
-        $Content = $Content -replace "(?m)^Blocked by: .*$", $EdgeLine
-    } else {
-        $Content = $EdgeLine + "`n`n" + $Content
-    }
-    $Content | Set-Content $TicketPath -Encoding UTF8 -NoNewline
-    Write-Host "  Fixed $TicketName : $EdgeLine"
-    return $true
-}
-
-# Build edges
-$Success = $true
-foreach ($Ticket in Get-ChildItem $TicketsDir -Filter "*.md") {
-    $Fixed = Fix-Edge $Ticket.FullName
-    if (-not $Fixed) { $Success = $false }
-}
-
-# Validate count
-$ActualTicketCount = (Get-ChildItem $TicketsDir -Filter "*.md").Count
-if ($ActualTicketCount -ne $ExpectedMapTaskCount) {
-    Write-Host "Ticket count mismatch: expected $ExpectedMapTaskCount, actual $ActualTicketCount"
-    $Success = $false
-}
-
-# Validate each `Blocked by:`
-foreach ($Ticket in Get-ChildItem $TicketsDir -Filter "*.md") {
-    $Content = Get-Content $Ticket.FullName -Raw
-    if ($Content -match "(?m)^Blocked by: (.*?)$") {
-        $Edge = $Matches[1]
-        $Refs = $Edge -split "," | ForEach-Object { $_.Trim() }
-        foreach ($Ref in $Refs) {
-            if ($Ref -ne "None") {
-                $Referenced = Get-ChildItem $TicketsDir -Filter "$Ref*.md"
-                if (-not $Referenced) {
-                    Write-Host "  Invalid reference in $($Ticket.Name): $Ref"
-                    $Success = $false
-                }
-            }
-        }
-    } else {
-        Write-Host "  Missing `Blocked by:` in $($Ticket.Name)"
-        $Success = $false
-    }
-}
-
-# Validate resolved research tickets (04 and 05 are resolved; 06 is open)
-$ResolvedResearch = @("04-research-cesium-ground-fog-mechanisms.md", "05-research-overlay-rectangle-pop-mechanisms.md")
-foreach ($Name in $ResolvedResearch) {
-    $Ticket = Join-Path $TicketsDir $Name
-    if (Test-Path $Ticket) {
-        $Content = Get-Content $Ticket -Raw
-        if ($Content -notmatch "(?m)^Status: resolved$") {
-            Write-Host "  Research ticket $Name not resolved"
-            $Success = $false
-        }
-    } else {
-        Write-Host "  Missing research ticket $Name"
-        $Success = $false
-    }
-}
-
-if ($Success) {
-    Write-Host "All edges built and validated successfully. Total tickets: $ActualTicketCount"
-} else {
-    Write-Host "Validation failed. Please fix above issues."
-    exit 1
-}

+ 0 - 79
.scratch/cockpit-real-data-wayfinding/handoff/baselines/map-monitoring.md

@@ -1,79 +0,0 @@
-# 基线卡片 01:地图摘要 + 运行监测
-
-> 按决策票 [05 真实联调基线与收口证据门槛](../../issues/05-integration-baseline-and-evidence.md) 的格式产出。本卡片为**第一张样板卡**,用 2026-10-08 的实测结果填写,用于验证格式可填、并把缺口如实钉在台账里。
-
-## 一、基线
-
-| 项 | 值 |
-| --- | --- |
-| 记录时间 | 2026-10-08 10:05(本地时钟) |
-| 记录角色 | 前端执行代理(只读核实与探测) |
-| 联调环境 baseUrl | `http://192.168.110.96:8301`(**实测可达**) |
-| 后端仓库 / 分支 / 提交 | `http://192.168.110.30:3000/shaoy/pipe-ner-server` / `master` / `5b53a6d` |
-| 后端推送状态 | **未核实**;工作区有 **1 项未提交改动**(`CockpitFreshnessSemanticsControllerTest.java`) |
-| 前端仓库 / 分支 / 提交 | `http://192.168.110.30:3000/ChenJZ/City-Lifeline-Cockpit` / `master` / `f6288e0` |
-| 前端推送状态 | **未核实**;工作区有 **55 项未提交改动**(含本图 5 张票与地图、票 25 等) |
-| 访问控制 | 驾驶舱只读 REST 与 WS 免鉴权(ADR 0005 修订);实测无凭据 HTTP 200 |
-| 只读数据源 | 本地联调实例的只读快照;`readOnly: true` |
-| 目标部署 | `192.168.110.30:8301` **移出本阶段**(不参与状态判定) |
-
-## 二、收口条件五行
-
-| # | 条件 | 状态 | 证据 | 未完成项 |
-| --- | --- | --- | --- | --- |
-| ① | 后端提交 / 编译 / 契约测试 | **未完成** | 后端 `git status` 有 1 项未提交;本阶段未运行 Maven | 提交改动并跑编译/契约测试 |
-| ② | 状态筛选与 `MANHOLE` 跨端 | **未完成** | 实测 `GET /map-snapshot?pointStatus=ALARM` 与不传返回**完全相同**(`total=13`、`monitoringPoints=2`)→ 后端忽略 `pointStatus` | 按规格 07 实现多值 `pointStatus`(含 `UNKNOWN`/`UNASSOCIATED`) |
-| ③ | `createdAt` 数据时间 | **未完成** | 三端点响应字段枚举均无 `createdAt`;`distribution.pointStatusDistribution` 仅 6 项(六态) | 按规格 08 补三端点字段(含信封级 + 逐对象级) |
-| ④ | real 生命周期 | **未完成** | 前端 real 分支启动逻辑尚未接线(规格 09 待实施);工作区有未提交改动 | 按规格 09 接线并验证 |
-| ⑤ | 真实环境 / 访问控制 / 数据快照 + 走查 | **部分完成** | 本地环境可达、免鉴权实测 200(`foundation`/`map-snapshot`/三监测端点全部 200);走查未执行 | 执行前端 real 走查(AGENTS 规则下需维护者授权) |
-
-## 三、实测记录(可复制命令与结果)
-
-命令:
-
-```powershell
-$base='http://192.168.110.96:8301/api/cockpit/v1'
-Invoke-WebRequest "$base/foundation" -UseBasicParsing
-Invoke-WebRequest "$base/map-snapshot?page=1&pageSize=2" -UseBasicParsing
-Invoke-WebRequest "$base/map-snapshot?page=1&pageSize=2&pointStatus=ALARM" -UseBasicParsing
-Invoke-WebRequest "$base/monitoring/overview" -UseBasicParsing
-Invoke-WebRequest "$base/monitoring/distribution?page=1&pageSize=2" -UseBasicParsing
-Invoke-WebRequest "$base/monitoring/situation?page=1&pageSize=2" -UseBasicParsing
-```
-
-结果:
-
-| 端点 | HTTP | 关键实测值 |
-| --- | --- | --- |
-| `/foundation` | 200 | `mapFrame.state=READY`、`renderable=true`、`freshness.status=FRESH`、`readOnly=true` |
-| `/map-snapshot`(默认) | 200 | `total=13`、本页 `monitoringPoints=2`、`unassociatedEquipment=0`、`snapshotAt` 与 `coordinateValidation` 齐备 |
-| `/map-snapshot?pointStatus=ALARM` | 200 | **与不传完全一致(`total=13`)** → `pointStatus` 被忽略 |
-| `/monitoring/overview` | 200 | `counts.monitoringPointCount=0`、`counts.equipmentCount=7`;无 `createdAt` |
-| `/monitoring/distribution` | 200 | `pagination.total=0`;`pointStatusDistribution` 6 项;无 `createdAt` |
-| `/monitoring/situation` | 200 | `pagination.total=7`;`abnormalEquipment` 有数据;无 `createdAt` |
-
-**数据稀疏事实(如实记录,不当作缺陷)**:`/monitoring/overview` 的监测点数为 0 而设备数为 7;`/monitoring/distribution` 分页总数为 0 而 `/monitoring/situation` 异常设备为 7。各端点口径不同,联调时不得互相冒充。
-
-## 四、真人验收
-
-**未执行。** 本卡片不宣称任何人工验收结论。后续按决策票 01 的口径,由真人团队用真实设备报警走完整流程;届时在本节记录场景、参与角色、时间、结论与可见结果(截图/录屏路径)。
-
-## 五、未完成项与补齐条件
-
-1. **提交门槛**:后端 1 项、前端 55 项未提交改动 → 补齐条件:本地联调通过后提交并推送到 Gogs。
-2. **规格 07(状态筛选)**:`pointStatus` 被忽略(实测) → 补齐条件:后端实现多值入参并在 `mapFilter` 回显;前端接多选状态栏。
-3. **规格 08(数据时间)**:三端点无 `createdAt` → 补齐条件:按规格补信封级 + 逐对象级字段。
-4. **规格 09(real 生命周期)**:real 分支未接线 → 补齐条件:按规格实施并走查。
-5. **走查**:未执行 → 补齐条件:维护者授权后执行前端 real 走查。
-
-## 六、状态判定
-
-**当前状态:① 接口 200(已达成);② 契约已接(未达成)。**
-
-依据:免鉴权只读接口实测全部 200 且 `readOnly: true`,满足①;但规格 07/08 的新契约字段均不在响应中(`pointStatus` 被忽略、无 `createdAt`),因此不能声称契约已接,更不能进入③「真实数据联调完成」。
-
-## 七、关联
-
-- 本图决策票:[05 真实联调基线与收口证据门槛](../../issues/05-integration-baseline-and-evidence.md)
-- 规格子票:[07 地图摘要状态筛选](../../issues/07-map-status-filter-spec.md)、[08 监测数据时间](../../issues/08-monitoring-created-at-spec.md)、[09 real 生命周期](../../issues/09-real-lifecycle-spec.md)
-- 上位台账:[票 25 后端契约缺口待办](../../../cockpit-shell-panel-layout/issues/25-backend-contract-gaps.md)、Gogs #63

+ 0 - 141
.scratch/cockpit-real-data-wayfinding/handoff/baselines/pipe-network-type-namespace.md

@@ -1,141 +0,0 @@
-# 基线卡片 05:管网编码 0/1 映射与设备边界设施类型命名空间
-
-> 按决策票 [05 真实联调基线与收口证据门槛](../../issues/05-integration-baseline-and-evidence.md) 的固定骨架产出,对应执行票 [20 管网编码 0/1 映射与设备边界设施类型命名空间](../../issues/20-20-0-1.md)。**状态判定:③ 真实数据联调完成**(2026-10-12 部署后复验七项全通过;④ 模式整体收口仍待前端界面走查)。人工核验步骤见[人工核验步骤说明](../manual-verify-pipe-type-namespace.md)。
-
-## 一、基线
-
-| 项 | 值 |
-| --- | --- |
-| 记录时间 | 2026-10-10 实施 / 2026-10-12 复验 |
-| 记录角色 | 后端执行代理(诊断 + 实施 + 编译 + 契约测试 + 变异检验 + 部署复验) |
-| 联调环境 baseUrl | `http://192.168.110.96:8301`(= 本机,IDE 内运行的 Spring Boot 进程) |
-| 后端仓库 / 分支 / 提交 | `http://192.168.110.30:3000/shaoy/pipe-ner-server` / `master` / **`740d896`**(已推送 `94971b5..740d896`) |
-| 运行实例 | PID 25880,启动于 2026-10-12 08:39:37(**已加载新构建**) |
-| 并行会话说明 | 基点期间并行会话推入 4 个提交(`4fa26a4/a151183/927fad2/94971b5`),经核实**未触碰 cockpit 包**,无冲突 |
-| 数据源 | 真实库只读探针(凭据经环境变量注入,探针放 `%TEMP%` 用完即删) |
-| 验收口径 | 四族管网计数覆盖真实分布;概览不再整体排除;设备边界类型名与行业正确 |
-
-## 二、收口条件五行
-
-| # | 条件 | 状态 | 证据 | 未完成项 |
-| --- | --- | --- | --- | --- |
-| ① | 单一权威来源(覆盖 `0/1/2` 与历史串) | **已完成** | 新增 `CockpitPipeNetworkType`;`CockpitPipeNetworkTypeTest` 7 例 | 无 |
-| ② | 四适配器映射与行业归属修正、不再静默 `continue` | **已完成** | base-archives / management / operating / overview 均改为单源;未映射值归哨兵并交由既有 `excluded` 计数承接 | 无 |
-| ③ | 设备边界设施类型不借用设备类型字典 | **已完成** | 设施改用管网命名空间码;`BusinessCockpitDeviceBoundaryAdapterTest` 3 例 + `CockpitDeviceBoundaryPipeIndustryTest` 4 例(贯通适配器→服务→响应) | 无 |
-| ④ | 契约测试 + 变异检验 | **已完成** | 全量 **439/439**;变异检验两处均确认测试有牙齿(见 §四) | 无 |
-| ⑤ | 真实环境复验 | **已完成** | 2026-10-12 实例 PID 25880 复验七项全通过:各端点管网条数均为 537/996/3735(合计 **5268**,与真实库精确吻合);概览 `unknownTypeCount` 5268→0;设备边界无管网被标成「…设备」 | 界面层走查见[人工核验步骤说明](../manual-verify-pipe-type-namespace.md) |
-
-## 三、本票交付内容
-
-### 新增单一权威来源
-
-`cockpit/foundation/CockpitPipeNetworkType`(仿票 12 的 `CockpitDistrict` 模式)持有**管网命名空间**的类型码 / 中文名 / 行业:
-
-| `network_type` | 行业 | 类型码 | 展示名 |
-| --- | --- | --- | --- |
-| `0` / `gas` / `燃气` / `燃气管网` | `GAS` | `GAS_PIPE` | 燃气管线 |
-| `1` / `drainage` / `SEWER` / `排水` / `排水管网` | `DRAINAGE` | `DRAINAGE_PIPE` | 排水管线 |
-| `2` / `water` / `WATER_SUPPLY` / `供水` / `供水管网` | `WATER_SUPPLY` | `WATER_PIPE` | 供水管线 |
-| 其它 / 空 / 未知 | `null` | `PIPE_UNCLASSIFIED` | 未分类管网 |
-
-维护者裁定:**删除**旧映射 `"3"`→燃气(真实库无此值;它与 `equipment_type.id=3`「燃气设备」同源混淆)。
-
-### 三处缺陷的修复
-
-1. **4272 条管网整体丢失**(现象 1):三处各自复制的映射逻辑(`pipeType` / `pipeIndustry`×2)都漏 `0`/`1`,返回 null 后被调用方 `continue` 静默丢弃。统一到单源后全部解析。
-2. **命名空间碰撞**(现象 2):设备边界把原始 `network_type` 透传给 `equipment_type` 字典查询,两套编码数值域重叠而语义不同 —— 3735 条**排水**设施被标成「供水设备 / WATER_SUPPLY」、996 条**供水**设施被标成「排水设备 / DRAINAGE」。现设施使用管网命名空间码(`objectKind=FACILITY`),与设备类型命名空间彻底隔离。适配器原类注释「network_type 是自由文本、与 equipment_type.id 无对应关系」的前提为假,已改正。
-3. **概览整体排除**(现象 3):设施 `SourceType` 此前 `industry` 恒传 null,5268 条管网被服务层计入 `excluded.unknownTypeCount`。现由单源给出行业。
-
-### 不静默丢弃(契约零变更)
-
-无法映射的取值归入**非空**哨兵码 `PIPE_UNCLASSIFIED`,由各端点**既有**的 excluded 计数如实承接:
-
-| 端点 | 承接字段 | 承接位置 |
-| --- | --- | --- |
-| base-archives | `excluded.unknownTypeCount` | 服务层 `type==null` 分支 |
-| management | `excluded.unknownIndustryCount` | 服务层 `isBlank(industry)` 分支 |
-| operating | `excluded.unknownIndustryCount` | 同上 |
-| overview | `excluded.unknownTypeCount` | 服务层「类型解析出但行业非正式」分支 |
-| device-boundary | `unknownTypeCount` | 哨兵码不在字典中 |
-
-四个服务的承接逻辑**本就存在**,是适配器提前 `continue` 让它们恒为 0;修复后自然生效,**响应契约无任何变更**。另外加固了 `metricCode`:行业为空时用 `unknown_` 前缀,避免空指针。
-
-## 四、实测记录
-
-### 4.1 编译与测试
-
-```powershell
-cd E:\pipe-ner-server
-mvn -o -pl pipe-network-service/zksy-admin -am test
-```
-
-结果:全量 8 模块 **Tests run: 439, Failures: 0, Errors: 0, Skipped: 0**(`BUILD SUCCESS`);票 20 前为 422,新增 17 例。
-
-新增覆盖:`CockpitPipeNetworkTypeTest`(7)、`BusinessCockpitDeviceBoundaryAdapterTest`(3)、`CockpitDeviceBoundaryPipeIndustryTest`(4,贯通适配器→服务→响应)、base-archives/operating/overview 各 1。
-
-另有 4 处既有测试**锁定的是旧 bug**(测试名直接写着 `SkipsUnclassifiableRows`、断言类型码为原始自由文本),已改为强断言新契约(未映射值必须保留、权威码必须全解析),而非简单改数字。
-
-### 4.2 变异检验(确认测试有牙齿)
-
-| 变异 | 结果 |
-| --- | --- |
-| 还原命名空间隔离(透传原始 `network_type`) | **7 例转红**,精确复现票面症状:`排水管线 but was 供水设备`、`供水管线 but was 排水设备`、`[0,1,2]` 原始透传 |
-| 还原概览 `industry=null` | **1 例转红**:`正式管网类型必须带行业` |
-
-两次变异均在还原后复跑全量确认 **439/439 BUILD SUCCESS**。
-
-### 4.3 部署后真实环境复验(2026-10-12,实例 PID 25880 @ 08:39:37 加载 `740d896`)
-
-```powershell
-$base='http://192.168.110.96:8301/api/cockpit/v1'
-# 各端点管网计数与排除统计
-'infrastructure/base-archives','overview/infrastructure','device-boundary',
-'infrastructure/management-information','infrastructure/operating-information' |
-  ForEach-Object { (Invoke-WebRequest "$base/$_" -UseBasicParsing -TimeoutSec 60).Content | ConvertFrom-Json }
-```
-
-| 端点 | 实测 | 判定 |
-| --- | --- | --- |
-| `base-archives` | 排水管线 **3735** / 供水管线 **996** / 燃气管线 **537**,合计 **5268**;`unknownTypeCount=0` | **通过** |
-| `base-archives` 总数 | `facilityCount=5278`(5268 管网+3 水厂+5 泵站+2 排水工程设施) | **通过** |
-| `overview/infrastructure` | `unknownTypeCount` **5268 → 0**;GAS **537** / WATER_SUPPLY **1004** / DRAINAGE **3735** | **通过** |
-| `device-boundary` | 设施类型仅燃气/供水/排水管线三类,**无「供水设备/排水设备/燃气设备」冒充**;gas **537** / waterSupply **996** / drainage **3735**;`unknownTypeCount=0` | **通过** |
-| `management-information` | `unknownIndustryCount=0`;未分区桶 DRAINAGE **3735** / GAS **537** / WATER_SUPPLY **998** | **通过** |
-| `operating-information` | `unknownIndustryCount=0`;管网长度指标 **537** / **996** / **3735** 条 | **通过** |
-
-**跨端点自洽**:五个端点的管网条数一致 = 537+996+3735 = **5268**,与真实库 `network_type` 分布精确吻合。
-
-## 五、真人验收
-
-**维护者粗验(2026-10-12)**:重启后人工过了一遍界面,**未发现问题**。逐项精确核验见 §4.3(自动化,接口层),界面层步骤见[人工核验步骤说明](../manual-verify-pipe-type-namespace.md)。
-
-票 18 的完整真人走查尚未逐项复跑;本卡片只覆盖后端契约切片,**不宣称**人工验收结论。
-
-## 六、未完成项与补齐条件
-
-1. ~~部署后复验~~ —— **已完成**(2026-10-12,§4.3 七项全通过)。
-2. **票 18 界面走查复跑**:按[人工核验步骤说明](../manual-verify-pipe-type-namespace.md) 逐项确认,重点是「设备边界无管网被标成…设备」与设施总数从 1001 回归 5278。
-3. **前端消费侧**:若前端按 `typeCode` 展示设施类型名(`GAS_PIPE` 等),契约未变(base-archives 早已用这三个码),无需改动;仅 `device-boundary` 的设施类型码由原始 `0/1/2` 变为 `GAS_PIPE/…`,若前端有硬编码需同步(已回写票 18)。
-
-## 七、状态判定
-
-**当前状态:③ 真实数据联调完成(2026-10-12 达成)。**(④ 模式整体收口仍待票 18 界面走查。)
-
-依据:编译通过 + 439/439 全量测试 + 变异检验确认测试有牙齿,且**部署后真实环境复验七项全通过**——五个端点的管网条数一致为 5268(与真实库精确吻合)、概览 `unknownTypeCount` 由 5268 归零、设备边界不再出现「排水设施被标成供水设备」的命名空间错位。
-
-**不进入 ④ 的原因**:④ 要求「该模式全部面板 + 地图 + 推送在真人验收下通过」——本卡片只覆盖后端契约切片,票 18 的界面走查尚未逐项复跑。
-
-## 八、收尾审查补齐(2026-10-12)
-
-初始审查发现 P2 缺跨端点同源回归与 P3 基础档案展示字典重复,已由 `29d1ae2` 补齐,并在 `0a6b108` 整合远端后推送。补齐后独立只读复核无新增阻塞,详情见[审查报告](../review-pipe-type-namespace.md)。
-
-准确测试口径:同步远端前 system 187 + admin 441 = 628 项全部通过;同步远端最新报警描述改动后,全量在 system 的 AlarmLifecycleServiceTest 出现 1 项顺序断言失败(报警相关源码、测试与远端一致,本次管网补齐未改),不能称当前整合树全量通过;整合后管网专项 29/29 通过。此前 439 是 admin 模块小计。
-
-现场证据针对已部署主修复 `740d896`;补齐仅消除一致显示字典的重复并新增测试,不改变当前管网名称/行业/排序,未将后续报警合并标成已经实机部署。人工界面步骤已修正不同对象域总数、窨井计数、后端端口和指标单位,见[人工核验说明](../manual-verify-pipe-type-namespace.md)。
-
-## 九、关联
-
-- 本图执行票:[20 管网编码 0/1 映射与设备边界设施类型命名空间](../../issues/20-20-0-1.md)
-- 发现于:[18 前端真实接口联调与阶段收口](../../issues/18-frontend-real-integration-gate.md)(真实数据口径核对 + 真人走查第 4 项)
-- 上位台账:[票 25 后端契约缺口待办](../../../cockpit-shell-panel-layout/issues/25-backend-contract-gaps.md)(需回写)、Gogs #63
-- 同型先例:票 12 的 `CockpitDistrict`(片区口径单源)

+ 0 - 79
.scratch/cockpit-real-data-wayfinding/handoff/baselines/warning-settings-lifecycle.md

@@ -1,79 +0,0 @@
-# 基线卡片 06:预警设置端点预警自身生命周期统计
-
-> 按决策票 [05 真实联调基线与收口证据门槛](../../issues/05-integration-baseline-and-evidence.md) 的固定骨架产出,对应执行票 [41 预警设置端点改为预警自身生命周期统计](../../issues/41-warning-settings-endpoint-rework.md)。**状态判定:③ 真实数据联调完成**(2026-10-14 第二实例实测通过,见 §4;共享实例部署窗口与前端走查不在本票范围)。
-
-## 一、基线
-
-| 项 | 值 |
-| --- | --- |
-| 记录时间 | 2026-10-14(本地时钟) |
-| 记录角色 | 后端执行代理(实施 + 编译 + 契约测试 + 第二实例复验 + 推送) |
-| 联调环境 baseUrl | `http://192.168.110.96:8301`(共享实例)+ 复验用第二实例 `http://127.0.0.1:8399`(本票构建,已停机) |
-| 后端仓库 / 分支 / 提交 | `http://192.168.110.30:3000/shaoy/pipe-ner-server` / `master` / **`b047a60d`**(本票 `fix(cockpit): 预警设置端点改为预警自身生命周期统计 (#41)`,变基含上游 `05c23774`) |
-| 复验方式 | 不动共享实例:`t41` 本地 profile(同库同 Redis,端口 8399、nacos 注册关闭、推送任务 delay 1h)起第二实例实测;对照共享实例区分上游修复与本票新契约 |
-| 后端推送状态 | **已推送**;远端 `master=b047a60d`,`05c23774..b047a60d` |
-| 前端仓库 | 本票**不涉及**前端(消费侧另立票,见票面「前端后续」) |
-| 访问控制 | 驾驶舱只读 REST 免鉴权(ADR 0005);两实例实测无凭据 HTTP 200 |
-| 只读数据源 | 联调库只读快照;`readOnly: true` |
-
-## 二、收口条件五行
-
-| # | 条件 | 状态 | 证据 | 未完成项 |
-| --- | --- | --- | --- | --- |
-| ① | 后端提交 / 编译 / 契约测试 | **已完成** | 编译通过;针对性 24/24;全量 `Cockpit*Test,BusinessCockpit*Test` **498/498**(变基后复跑) | 无 |
-| ② | 新字段形状(状态/等级/趋势/分布) | **已完成** | §4.2 实测:六态/1–4/四行业/三片区+未分区全铺,和恒等于 total | 无 |
-| ③ | 趋势窗口跟随 `range` | **已完成** | §4.2 实测:`LAST_7_DAYS` 只留窗口内日桶(窗外桶被裁) | 无 |
-| ④ | 空态 | **已完成** | 契约测试 `emptyFamilyUsesExplicitEmptyState`:`state=EMPTY`、处置率 `NO_WARNINGS`、分布仍全铺计数 0 | 无 |
-| ⑤ | 真实环境复验「不再 500」 | **已完成(第二实例)** | §4.1 实测 `code=200`;共享实例 8301 的 500 已由上游 `94bb7e4d`(删 `dept_id`)先行消除,与本票改动叠加后新契约生效 | 共享实例滚动重启窗口归票 17;前端走查归前端消费票 |
-
-## 三、契约变更摘要
-
-| 维度 | 预警族(`warning`) | 报警族(`alarm`) |
-| --- | --- | --- |
-| 状态分布 | `statusDistribution`:`WarningWorkflow` 六态(DRAFT/PENDING/RECEIVED/PROCESSING/HANDLED/CLOSED)全铺 | `statusDistribution`:`alarm_status` 归一 `UNHANDLED/HANDLED`(新增) |
-| 工单口径字段 | **`null`**(`handled/pending/cancelledCount/unlinkedEventCount/invalidWorkOrderStateCount/workOrderStates`) | **原样保留**(含 `workOrderStates` 0–7、未回联/脏码/已取消分列) |
-| `handlingEfficiency` | 已解除 ÷ 预警总数(预警自身口径;分母 0 → `NO_WARNINGS`) | 状态 5 ÷ 状态 0–6 工单数(工单口径,决策票 06) |
-| 等级分布 | `levelDistribution` 1–4 全铺(`warning_level` 字符串解析) | `levelDistribution` 1–4 全铺(`alarm_level`) |
-| 行业 / 片区 | 沿 `equipment_base` + `equipment_type` 树上溯;未知/未分区显式入桶 | 同左 |
-| 趋势 | 按发布时间分桶;`handled`=桶内已解除 | 按更新时间分桶;`handled`=关联工单已结案 |
-
-分布读数取整个快照(不随 `range` 收缩),故 `sum(分布桶) == total` 可被审计复算;只有趋势桶跟随窗口。
-
-## 四、实测记录
-
-### 4.1 端点 200 复验(第二实例,真实联调库)
-
-```powershell
-$r = Invoke-WebRequest 'http://127.0.0.1:8399/api/cockpit/v1/overview/warning-settings?mode=WARNING&range=LAST_7_DAYS' -UseBasicParsing
-# HTTP 200, code=200(改造前同库同端点为 HTTP 200 + body code=500:字段 "dept_id" 不存在)
-```
-
-### 4.2 预警族自洽性(真实数据复算)
-
-| 分布 | 实测计数 | 和 |
-| --- | --- | --- |
-| 状态 | DRAFT 2 + PENDING 4 + RECEIVED 0 + PROCESSING 4 + HANDLED 2 + CLOSED 19 | **31 = total** ✓ |
-| 等级 | 1级 13 + 2级 5 + 3级 1 + 4级 12 | **31** ✓ |
-| 行业 | 供水 2 + 排水 24 + 窨井 1 + UNKNOWN 4(燃气 0) | **31** ✓ |
-| 片区 | 城南 24 + 城北 1 + 太常 2 + 未分区 4 | **31** ✓ |
-| 处置率 | 19/31 = `0.6129`(已解除÷总数) | ✓ |
-| 趋势 | 2026-10-09/10/14 三桶(`LAST_7_DAYS` 窗内) | ✓ |
-
-报警族:`total=66`,`handled=64`,`statusDistribution` 2+64=66 ✓,`workOrderStates` 0–7 齐全,处置率 `1/5=0.2000`(工单口径)。
-
-### 4.3 对照证据(共享实例 vs 第二实例,同时刻)
-
-| 实例 | 构建 | HTTP/body code | 契约 |
-| --- | --- | --- | --- |
-| `8301` 共享 | 上游 `05c23774`(已被他人重启) | 200/200 | **旧契约**:`warning.handled=0`、`workOrderStates` 在、无 `statusDistribution`、处置率 0.2000 |
-| `8399` 第二 | 本票 `b047a60d` | 200/200 | **新契约**:见 §4.2 |
-
-> 判读:8301 的 500→200 恢复来自上游 `94bb7e4d` 删 `dept_id`(诊断期实测 `dispatch_user` → 复测 `dept_id`,两形态都是「实体声明了库中已删列」);本票在其上把预警族口径换为自身生命周期,8399 实测生效。
-
-## 五、已知边界与后续
-
-- 共享实例重启加载 `b047a60d` 前,8301 仍回旧契约(预警族工单字段为旧口径);滚动部署归票 17。
-- 前端消费侧(`toOverviewWarningSettings` / `alertSettingsPanel` / `contractMocks.js`)尚未读新分布块,
-  预警半边会显示全 0 —— 按票面验收另立前端票,不阻塞本票收口。
-- 预警族 `handlingEfficiency` 语义已从「工单处置率」改为「已解除率」;消费方必须连 `state/reason` 一起读,
-  分母 0 显示「不适用」而非 0%。

+ 0 - 164
.scratch/cockpit-real-data-wayfinding/handoff/baselines/work-order-rate.md

@@ -1,164 +0,0 @@
-# 基线卡片 02:工单状态与处置率读模型
-
-> 按决策票 [05 真实联调基线与收口证据门槛](../../issues/05-integration-baseline-and-evidence.md) 的固定骨架产出,对应执行票 [11 工单状态与处置率读模型对齐](../../issues/11-backend-work-order-rate.md)。**状态判定:③ 真实数据联调完成**(2026-10-08 部署后复验通过,见 §4.3;④ 模式整体收口仍待真人验收)。
-
-## 一、基线
-
-| 项 | 值 |
-| --- | --- |
-| 记录时间 | 2026-10-08(本地时钟) |
-| 记录角色 | 后端执行代理(实施 + 编译 + 契约测试 + 部署后复验) |
-| 联调环境 baseUrl | `http://192.168.110.96:8301`(**实测可达**) |
-| 后端仓库 / 分支 / 提交 | `http://192.168.110.30:3000/shaoy/pipe-ner-server` / `master` / **`89ca989`**(含本票口径修订 `3de679c` 与上游 `7df9a87`) |
-| 运行实例 | PID 26956,启动于 2026-10-08 18:39:11(重启后加载新字节码) |
-| 后端推送状态 | **已推送**;远端 `master=89ca989` 与本地一致,工作树干净 |
-| 前端仓库 / 分支 / 提交 | 本票**不涉及**前端(前端消费与 mock 归执行票 14–16) |
-| 访问控制 | 驾驶舱只读 REST 免鉴权(ADR 0005);实测无凭据 HTTP 200 |
-| 只读数据源 | 本地联调实例的只读快照;`readOnly: true` |
-| 目标部署 | `192.168.110.30:8301` **移出本阶段**(不参与状态判定) |
-
-## 二、收口条件五行
-
-| # | 条件 | 状态 | 证据 | 未完成项 |
-| --- | --- | --- | --- | --- |
-| ① | 后端提交 / 编译 / 契约测试 | **已完成** | 编译通过;`-Dtest=Cockpit*Test` **299/299 通过**(提交后复跑一致);提交 `3de679c` + `9380c1b` 已推送 `origin/master` | 无 |
-| ② | 状态筛选与 `MANHOLE` 跨端 | 不在本票范围 | — | 归执行票 12 |
-| ③ | `createdAt` 数据时间 | 不在本票范围 | — | 归规格 08 / 执行票 12 |
-| ④ | real 生命周期 | 不在本票范围 | — | 归规格 09 / 执行票 14 |
-| ⑤ | 真实环境 / 访问控制 / 数据快照 + 走查 | **已完成(后端契约层)** | 部署后复验通过(§4.3):新 `basis` 生效、`sum(0–6)==denominator`、分母≠事件数;免鉴权 200 | 前端 real 走查归执行票 14–16;真人验收归决策票 01 |
-
-## 三、本票交付内容(口径修订)
-
-| 项 | 修订前 | 修订后 |
-| --- | --- | --- |
-| 分子 | 状态 5 | 状态 5(不变) |
-| 分母 | 事件总数 | **状态 0–6 工单数之和**(状态 7 取消排除) |
-| 范围 | 事件范围 | **当前行业/时间范围内全部工单**(按 `work_order.create_time`),不要求关联报警/事件 |
-| `basis` | `CLOSED_WORK_ORDERS_OVER_TOTAL_EVENTS` | **`CLOSED_WORK_ORDERS_OVER_ELIGIBLE_WORK_ORDERS`** |
-| 分母 0 的 `reason` | `NO_EVENTS` | **`NO_ELIGIBLE_WORK_ORDERS`**(`state=EMPTY` 不变) |
-
-同口径覆盖三处(用户裁定「三处同族分母一并修订」):`events/handling-efficiency`、`events/history-analysis`(汇总)、`overview/warning-settings`(两 family 共用一份全范围工单读数)。`workOrderStateCounts` / `workOrderStates` 同步改为工单口径,使 `sum(状态 0–6) == denominator` 可被审计复算。历史分析小时桶与预警设置日趋势**仍是事件桶**(口径不同,未改)。
-
-## 四、实测记录(可复制命令与结果)
-
-### 4.1 联调环境探针(旧口径诊断 + 部署后复验共用)
-
-> **注意**:下方原命令把时间窗**写死**为 `2026-09-08 ~ 2026-10-08`,该窗口早已过期,复跑会得到空数据。**部署后请改用 §4.3 的相对窗口命令**(`AddDays(-31)`,永不过期)并按其自洽性判据判定。此处保留原命令与当时结果,是因为它记录的是**诊断时点的实测证据**,不应回改。
-
-```powershell
-$base='http://192.168.110.96:8301/api/cockpit/v1'
-$a = Invoke-WebRequest "$base/events/handling-efficiency?eventFamily=ALARM&startTime=2026-09-08T00:00:00%2B08:00&endTime=2026-10-08T00:00:00%2B08:00" -UseBasicParsing
-($a.Content | ConvertFrom-Json).data.summary
-```
-
-结果:
-
-| 端点 | 关键实测值 |
-| --- | --- |
-| `/events/handling-efficiency?eventFamily=ALARM` | `totalEvents=6`、`handledCount=1`、`denominator=6`(**=`totalEvents`,事件数**)、`basis=CLOSED_WORK_ORDERS_OVER_TOTAL_EVENTS`;`workOrderStateCounts` 为 `0=1, 5=1`(**旧口径按事件回联累加**,不能据此反推新分母——见 §4.3 勘误) |
-| `/events/handling-efficiency?eventFamily=WARNING`(近 31 天) | `totalEvents=4`、`denominator=4`(事件数)、`handledCount=0` |
-| `/events/handling-efficiency?eventFamily=WARNING`(默认窗口) | `totalEvents=0`、`denominator=0`、`state=EMPTY`、`reason=NO_EVENTS` |
-
-**口径错误已被真实数据复现**:旧实例 `denominator=6` 等于 `totalEvents`,而按新口径分母应为**范围内全部状态 0–6 工单数**(此窗口内当次 `workOrderStateCounts` 仅给出**事件回联**的 `0=1, 5=1`,不等于新口径分母;见 §4.3 勘误)。旧口径把「事件数」当分母即错,无需依赖具体数值即可判定。
-
-### 4.2 修订后:本地编译与定向契约测试
-
-```powershell
-cd E:\pipe-ner-server
-mvn -o -pl pipe-network-service/zksy-admin -am compile
-mvn -o -pl pipe-network-service/zksy-admin -am test "-Dtest=Cockpit*Test" "-DfailIfNoTests=false" "-Dsurefire.failIfNoSpecifiedTests=false"
-```
-
-结果:编译通过;`Tests run: 299, Failures: 0, Errors: 0, Skipped: 0`(`BUILD SUCCESS`)。本票新增/改写的针对性覆盖:
-
-- **0–7 字典**:`dictionaryMatchesTheRuntimeWorkOrderStatusEnumCodeForCode` 逐码逐名比对运行时 `WorkOrderStatusEnum`,并锁 8 态。
-- **分母含状态 6**:`rateDenominatorIncludesDelayingStateSixAndExcludesCancelledSeven`(5/6/7/7 → 分母 2、分子 1、`0.5000`)。
-- **状态 7 排除**:同上 + `cancelledWorkOrdersAreExcludedFromTheRateDenominator`(全为 7 → 分母 0 → `EMPTY`)。
-- **分母 0**:`emptyWindowReportsEmptyEfficiencyAndDurationStates`(`NO_ELIGIBLE_WORK_ORDERS`)。
-- **分子 5**:`summaryNumeratorAndDenominatorMatchTheReportedStateDistribution`(自洽复算)。
-- **分母与事件解耦**:`rateIsAvailableFromWorkOrdersEvenWhenNoEventsFallInTheWindow`(窗口内无事件仍算出处置率)。
-- **两 family 共用**:`bothFamiliesShareTheSameWorkOrderScopedRate`(overview service 层)。
-
-### 4.3 联调环境复验(**已执行,通过 — 2026-10-08**)
-
-**部署依据**:`origin/master = 89ca989`(含本票口径修订 `3de679c` 与上游 `7df9a87`);运行进程 PID 26956,启动于 18:39:11(重启后加载新字节码)。复验票据:[17 部署后真实环境复验](../../issues/17-postdeploy-live-verification.md)。
-
-**复验结果(新口径已生效)**:
-
-| 请求 | basis | num/den | value | state | 自洽性 |
-| --- | --- | --- | --- | --- | --- |
-| `events/handling-efficiency?eventFamily=ALARM`(近 31 天) | `CLOSED_WORK_ORDERS_OVER_ELIGIBLE_WORK_ORDERS` | 2/3 | `0.6667` | AVAILABLE | `sum(0..6)=3`==分母,`totalEvents=6`≠分母 ✓ |
-| 同上 `?eventFamily=WARNING` | 同上 | 2/3 | `0.6667` | AVAILABLE | 工单范围与 family 无关(符合决策 06)✓ |
-| `events/history-analysis?eventFamily=WARNING` | (无 basis 字段) | 汇总 2/3 | `0.6667` | AVAILABLE | 行级仍 `reason=NO_EVENTS`(事件桶未变)✓ |
-| `overview/warning-settings` | (无 basis 字段) | warning 2/3、alarm 2/3 | `0.6667` | AVAILABLE | 两 family 同源;事件计数各算(10 / 6)✓ |
-
-`stateCounts` 实测为 `1=1, 5=2`(分母 3 = 状态 1 的 1 张 + 状态 5 的 2 张;状态 7 无工单)。
-
-**对照(部署前同请求,旧口径)**:`basis=CLOSED_WORK_ORDERS_OVER_TOTAL_EVENTS`、`num/den=1/6`、`value=0.1667` —— 分母恰等于 `totalEvents`,即旧口径把事件数当工单分母。修正确已生效。
-
-**数据稀疏事实(如实记录,不当作缺陷)**:`work_order` 表仅 3 行(状态 1×1、5×2),故分母为 3。样本极小,本节只验证**口径与自洽性**,不宣称统计意义;真人验收仍按决策票 01 执行。
-
-> **勘误留存(2026-10-08)**:本卡片早先版本写「重新部署后应期望 ALARM 组 `denominator=2`、`value=0.5000`」,**该预期错误**。那个 2 由**旧响应**的 `stateCounts`(`0=1, 5=1`)反推;但旧 `stateCounts` 只统计**已关联报警**的工单(未关联者 `continue` 跳过)且按事件累加,而新口径分母取自 `scopeWorkOrders`(范围内**全部**工单,含未关联报警者)。两者基数不同,不可互推 —— 实测分母为 3(全部工单),并非 2。判据应为**自洽性**(`sum(0–6) == denominator` 且分母 ≠ 事件数),而非固定数值。
-
-**复验用命令(可复制,窗口相对当前时间、永不过期)**:
-
-```powershell
-$base='http://192.168.110.96:8301/api/cockpit/v1'
-$end=(Get-Date).ToString('yyyy-MM-ddTHH:mm:sszzz'); $start=(Get-Date).AddDays(-31).ToString('yyyy-MM-ddTHH:mm:sszzz')
-$r = Invoke-RestMethod "$base/events/handling-efficiency?eventFamily=ALARM&startTime=$([uri]::EscapeDataString($start))&endTime=$([uri]::EscapeDataString($end))"
-$s = $r.data.summary
-"basis  = $($s.handlingEfficiency.basis)"          # 期望 CLOSED_WORK_ORDERS_OVER_ELIGIBLE_WORK_ORDERS
-"num/den= $($s.handlingEfficiency.numerator)/$($s.handlingEfficiency.denominator)"
-"events = $($s.totalEvents)"                        # 分母必须 ≠ 事件数
-($r.data.workOrderStateCounts | Where-Object { $_.stateCode -ge 0 -and $_.stateCode -le 6 } |
-   Measure-Object -Property count -Sum).Sum         # 期望 == denominator
-```
-
-### 4.4 部署窗口内的真实环境故障(已解决,如实登记)
-
-复验前的重启窗口内曾出现 **5 个只读端点 500**(`events/*` 与 `overview/warning-settings`)。根因是**数据库表结构重构与 Java 层不匹配**:`app_user.equipment_warning_type` 已重构为关联表(`equipment_type_id` + `warning_type_id`),元数据移到新表 `app_user.warning_type`;`app_user.alarm_data.warning_type` 列已删除。
-
-该重构由上游 `7df9a87「优化报警管理」` 完整修复(新增 `EquipmentWarningType` 实体、`AlarmData` 去 `warning_type`、`WarningThresholdServiceImpl` 与 4 个 XML mapper 一并改到新结构)。本会话曾基于落后基点做局部修复,发现上游已有完整解后已废弃该分支并跟随 master。**教训:复验前必须先 `git fetch` 确认远端基线。**
-
-
-| 判据 | 期望 |
-| --- | --- |
-| `basis` | `CLOSED_WORK_ORDERS_OVER_ELIGIBLE_WORK_ORDERS` |
-| `sum(workOrderStateCounts[0..6])` | **== `denominator`**(自洽,可复算) |
-| `denominator` | **≠** `totalEvents`(不再是事件数) |
-| 分母 0 时 | `state=EMPTY` + `reason=NO_ELIGIBLE_WORK_ORDERS` |
-
-不得以「分母等于某个具体数字」作为通过条件 —— 分母随真实工单数据变化。
-
-## 五、真人验收
-
-**未执行。** 本卡片不宣称任何人工验收结论。真实设备报警闭环仍按决策票 01 由真人团队执行。
-
-## 六、未完成项与补齐条件
-
-1. ~~**部署后复验**~~:**已完成**(§4.3,2026-10-08):新 `basis` 生效、`sum(0–6)==denominator`、分母≠事件数。由 [票 17](../../issues/17-postdeploy-live-verification.md) 承载并已回写。
-2. **前端消费与 mock 对齐**:前端 `contractMocks.js` 的 `denominator = total`(事件数)与 `reason: 'NO_EVENTS'`,以及 `handlingRateHint()` 文案「…没有可参与计算的事件…」仍为旧口径 → 补齐条件:归执行票 14–16(规格 10 已把该文案改动划在前端域)。
-3. **真人验收**:未执行 → 补齐条件:按决策票 01 由真人团队用真实设备报警走完整流程。
-
-## 七、状态判定
-
-**当前状态:③ 真实数据联调完成(2026-10-08 达成)。**(④ 模式整体收口仍待真人验收。)
-
-依据:编译通过 + 299/299 定向契约测试通过(提交后复跑一致),且**部署后真实环境复验通过**(§4.3)—— `events/handling-efficiency` 回新 `basis=CLOSED_WORK_ORDERS_OVER_ELIGIBLE_WORK_ORDERS`、`num/den=2/3`、`sum(状态0–6)=3`==分母、`totalEvents=6`≠分母;`history-analysis` 汇总同步为新口径而小时桶仍事件桶;`overview/warning-settings` 两 family 共用同一份工单范围读数。三项判定均符合 [票 17](../../issues/17-postdeploy-live-verification.md) 的事前期望。
-
-**不进入 ④ 的原因**:④ 要求「该模式全部面板 + 地图 + 推送在真人验收下通过」—— 本卡片只覆盖「工单状态与处置率」这一后端切片,前端 real 生命周期接线(执行票 14–16)与真人设备报警闭环(决策票 01)尚未发生。
-
-**提交事实**:`3de679c`(本票口径修订,14 文件)+ `9380c1b`(**非本票**:修复 `HEAD` 中 `CockpitFreshnessSemanticsControllerTest.java` 未闭合语句导致 `zksy-admin` 测试模块无法编译,与本票口径无关,单独成提交便于审计)→ `git push origin master`,`5b53a6d..9380c1b`;其后上游 `7df9a87`(报警管理重构,已完整修复 warning 表结构漂移所致的 500)与合并提交 `89ca989` 构成当前复验基线。
-
-## 部署窗口内的故障记录(已解决)
-
-见 §4.4:重启窗口曾出现 5 个只读端点 500,根因是数据库表结构重构(`equipment_warning_type` 改关联表、`alarm_data.warning_type` 删列)与 Java 层不匹配,由上游 `7df9a87` 完整修复。本会话基于落后基点做过的局部修复已废弃(分支未推送、无损失)。
-
-## 八、关联
-
-- 本图执行票:[11 工单状态与处置率读模型对齐](../../issues/11-backend-work-order-rate.md)
-- 复验承载:[17 部署后真实环境复验](../../issues/17-postdeploy-live-verification.md)
-- 上游决策:[01 报警—工单闭环读模型与 Gogs 票拆分边界](../../issues/01-backend-slice-ticket-boundary.md)、[05 真实联调基线与收口证据门槛](../../issues/05-integration-baseline-and-evidence.md)、[06 异常统计、工单处置率与管理字段词汇](../../issues/06-cross-mode-business-semantics.md)
-- 规格:[10 业务语义规格](../../issues/10-business-semantics-spec.md)
-- 下游:执行票 [12 报警/工单读模型与 district](../../issues/12-backend-read-model-district.md)、前端票 14–16
-- 上位台账:[票 25 后端契约缺口待办](../../../cockpit-shell-panel-layout/issues/25-backend-contract-gaps.md)、Gogs #63

+ 0 - 143
.scratch/cockpit-real-data-wayfinding/handoff/baselines/work-order-read-model-district.md

@@ -1,143 +0,0 @@
-# 基线卡片 03:报警/工单读模型、设备–监测点回联 + district 片区契约
-
-> 按决策票 [05 真实联调基线与收口证据门槛](../../issues/05-integration-baseline-and-evidence.md) 的固定骨架产出,对应执行票 [12 后端读模型 + district 片区契约](../../issues/12-backend-read-model-district.md)。**状态判定:③ 真实数据联调完成**(2026-10-08/09 部署后复验通过,见 §四;④ 模式整体收口仍待真人验收)。
-
-## 一、基线
-
-| 项 | 值 |
-| --- | --- |
-| 记录时间 | 2026-10-08/09(本地时钟) |
-| 记录角色 | 后端执行代理(实施 + 编译 + 契约测试 + 部署后复验) |
-| 联调环境 baseUrl | `http://192.168.110.96:8301`(= 本机,IDE 内运行的 Spring Boot 进程) |
-| 后端仓库 / 分支 / 提交 | `http://192.168.110.30:3000/shaoy/pipe-ner-server` / `master` / **`818a6d7`**(合并 `ee2e1b6` + `18db910`) |
-| 运行实例 | PID 15244,启动于 2026-10-09(已加载新构建) |
-| 后端推送状态 | **已推送**;远端 `master=818a6d7` 与本地一致,工作树干净 |
-| 前端仓库 / 分支 / 提交 | 本票**不涉及**前端;但契约变更连带影响前端(见 §六) |
-| 访问控制 | 驾驶舱只读 REST 免鉴权(ADR 0005);实测无凭据 HTTP 200 |
-| 只读数据源 | 本地联调实例的只读快照;`readOnly: true` |
-| 目标部署 | `192.168.110.30:8301` **移出本阶段**(不参与状态判定) |
-
-## 二、收口条件五行
-
-| # | 条件 | 状态 | 证据 | 未完成项 |
-| --- | --- | --- | --- | --- |
-| ① | 后端提交 / 编译 / 契约测试 | **已完成** | 编译通过;组合测试 **372/372 通过**;`818a6d7` 已推送 `origin/master` | 无 |
-| ② | 状态筛选与 `MANHOLE` 跨端 | 不在本票范围 | — | 归规格 07 / 执行票 14 |
-| ③ | `createdAt` 数据时间 | 不在本票范围 | — | 归规格 08 / 执行票 14 |
-| ④ | real 生命周期 | 不在本票范围 | — | 归规格 09 / 执行票 14 |
-| ⑤ | 真实环境 / 访问控制 / 数据快照 + 走查 | **已完成(后端契约层)** | 部署后复验 8 项全通过(§四);免鉴权 200 | 前端 real 走查归执行票 14–16;真人验收归决策票 01 |
-
-## 三、本票交付内容
-
-### 范围 2 —— district 片区契约(六族统一)
-
-| 项 | 修订前 | 修订后 |
-| --- | --- | --- |
-| 权威来源 | 无(`region` 自由串、适配器写死 `null`) | `equipment_base.district`(0/1/2);**点位经其上挂设备归纳**,不一致/无设备→未分区 |
-| 响应形态 | `region`(String,恒「未分区」) | `district`(Integer,未分区 `null`)+ `districtName`(「未分区」) |
-| 筛选入参 | `region`(自由文本、无校验) | `district=0\|1\|2\|UNASSIGNED`;**脏值 400** |
-| 分布字段 | `regionDistribution` | `districtDistribution` |
-| `mapFilter` | `region` | `district` + `districtName` + `districtFilter`(`NONE`/`ASSIGNED`/`UNASSIGNED`) |
-
-覆盖六族:地图摘要、运行监测(distribution + situation)、基础设施(base-archives + management-information)、事件多维分析。新增单源 `cockpit/foundation/CockpitDistrict.java`(归一/归纳/入参解析/匹配 + 常量)。
-
-**点位与设施无片区来源这一事实**:`monitor_point` 无 district 列、`PipeNetworkBase` 无该字段、`equipment_point_rel` 实测 **0 行** → 点位实际全部落「未分区」,**如实呈现、不编造**(维护者裁定:不加数据库列)。
-
-### 范围 3 —— 管理信息删字段
-
-删除 `organizations`、`ownershipDistribution`、`totals.unmaintainedCount`、`totals.organizationCount`、内部类 `Organization`/`Ownership`、`selectedFilters.organizationCode`、`mapFilter.organizationCode(s)`;改为 `districtIndustry[]`(片区 × 行业的设施/设备计数),符合决策票 06「管理信息面板改片区×行业视图」。
-
-### 范围 1 —— 报警/工单读模型
-
-- 设备维度 `/equipments/{code}/work-orders` 返回该设备**已有全部工单**;每张工单新增**显式**关联标记 `associatedWithCurrentEvent`,响应新增顶层 `unassociatedWorkOrderCount` —— 满足「未关联当前报警的工单明确提示」,调用方不必从 `eventAssociation == null` 反推。
-- 报警维度只回实际关联的工单(`alarm_data.work_order_id = work_order.order_id`)。
-- 多工单:保留全部明细,不引入「当前工单」规则(维护者裁定)。
-
-## 四、实测记录(可复制命令与结果)
-
-### 4.1 本地编译与组合契约测试
-
-```powershell
-cd E:\pipe-ner-server
-mvn -o -pl pipe-network-service/zksy-admin -am compile
-mvn -o -pl pipe-network-service/zksy-admin -am test `
-  "-Dtest=Cockpit*Test,Business*AdapterTest" "-DfailIfNoTests=false" "-Dsurefire.failIfNoSpecifiedTests=false"
-```
-
-结果:编译通过;`Tests run: 372, Failures: 0, Errors: 0, Skipped: 0`(`BUILD SUCCESS`)。
-(注:早期只跑 `Cockpit*Test` 得 311,**漏掉了 `Business*AdapterTest` 一族**;正确组合为 372。)
-
-本票新增/改写的针对性覆盖:
-
-- **district 归一**:`unknownDistrictIsRejectedWithBadRequest`(`99`→400)、脏值 `城区B`→400、`7`→未分区(适配器层)
-- **筛选语义**:`districtFilterMatchesAssignedDistrict`(`0`→`ASSIGNED`)、`districtFilterMatchesNormalizedUnassignedDistrict`(`UNASSIGNED`→只回未分区)、`unassignedDistrictFilterSelectsOnlyUnassignedAndEchoesFilterFlag`
-- **点位归纳**:多设备片区不一致→未分区;无设备→未分区
-- **删字段**:管理信息响应不再含 `organizations`/`ownershipDistribution`/`unmaintained*`,改断言 `districtIndustry[]`
-- **工单关联标记**:`deviceOrdersFlagUnassociatedWorkOrdersExplicitly`、`eventOrdersAreAllMarkedAssociated`
-- **报警定位**:`alarmEventResolvesByAlarmIdAndReturnsItsLinkedOrders`、`alarmEventFallsBackToAlarmNoWhenIdMisses`(已做变异检验确认有牙)
-
-### 4.2 部署后真实环境复验(2026-10-08/09,实例 PID 15244)
-
-```powershell
-$base='http://192.168.110.96:8301/api/cockpit/v1'
-# 六族端点健康
-'map-snapshot?page=1&pageSize=2','monitoring/distribution?page=1&pageSize=2',
-'monitoring/situation?page=1&pageSize=2','infrastructure/base-archives',
-'infrastructure/management-information','events/multidimensional-analysis?eventFamily=ALARM',
-'equipments/21119740/work-orders' | ForEach-Object { Invoke-WebRequest "$base/$_" -UseBasicParsing }
-# 筛选与脏值
-0,1,2,'UNASSIGNED' | ForEach-Object { Invoke-WebRequest "$base/map-snapshot?district=$_" -UseBasicParsing }
-```
-
-结果(8 项全通过):
-
-| 复验项 | 实测 |
-| --- | --- |
-| 六族端点健康 | 7/7 HTTP 200 |
-| `district` 字段 | `mapFilter` 含 `district`/`districtName`/`districtFilter`;`region` 已消失 |
-| 筛选生效 | `0`→4、`1`→2、`2`→1、`UNASSIGNED`→6(合计 7+6=13 == 全量) |
-| 脏值 400 | `9` / `-1` / `城区B` 全部 400 |
-| 旧 `region` 被忽略 | `region=城南` → 200 且 `districtFilter=NONE` |
-| 管理信息删字段 | 顶层无 `organizations`/`ownershipDistribution`;`districtIndustry[]` 7 条(含「未分区」桶) |
-| 点位归纳 | `equipment_point_rel` 0 行 → 点位全部「未分区」 |
-| 工单关联标记 | 设备维度逐张 `associated=true`;报警维度按真实 UUID 回 `READY`+1 张 |
-
-### 4.3 一处测量错误与撤回(如实登记)
-
-复验中曾依据「`alarm_data.id` 全部是全零占位值」提交 `905f593`(改按 `alarm_no` 解析)。**该观测错误** —— 是控制台把 32 位十六进制串统一渲染成 `0` 的**显示假象**(`md5(id)` 同样被渲染)。
-
-字符直方图 + base64 复核证明 6 行 `id` 是**互不相同的真实 UUID**(非零字符 30/29/32/29/32/30;`count(DISTINCT id)=6`;base64 解出 `fca8fd6b…`/`8b5c81fc…`,与端点回显一致),且 `events/ALARM/{真UUID}/work-orders` 回 `READY`+1 张。故以 `18db910` 撤回,恢复「先按主键 `getById`、查不到才回退 `alarm_no`」。
-
-**教训**:控制台对长十六进制串的渲染不可信;判「相同/为零」须用 `count(DISTINCT)`、直方图、base64 等不可被渲染扭曲的方式。
-
-## 五、真人验收
-
-**未执行。** 本卡片不宣称任何人工验收结论。真实设备报警闭环仍按决策票 01 由真人团队执行。
-
-## 六、未完成项与补齐条件
-
-1. ~~提交与推送~~ —— **已完成**(`818a6d7` → `origin/master`)。
-2. ~~部署后复验~~ —— **已完成**(§4.2,8 项全通过)。
-3. **前端消费侧(必须同步,已通知 [执行票 15](../../issues/15-frontend-time-dimension-panel.md))**:
-   - `domain/filterOptions.js` 的 `regionQuery`/`hasRegionMapping`/`REGION_MAP_GAP` 与配置 `map.filters.regionMap` 已失效(后端不再接受 `region`)→ 改为下发 `district` 编码。
-   - `domain/infrastructurePanels.js` 仍读 `organizations`/`ownershipDistribution`/`totals.unmaintainedCount`(约 213–232、430–446 行)与 `MANAGEMENT_REGION_GAP` → 改为消费 `districtIndustry[]`。
-   - 管理信息 freshness reason 由 `NO_ORGANIZATIONS` 改名 `NO_RELATIONS`(前端未硬编码该字面量,已核实)。
-4. **文档**:`docs/cockpit/README.md` 已同步(含新增「片区(district)契约」章节);两个 demo html(`map-snapshot-demo.html`、`monitoring-distribution-demo.html`)仍是 `region:'城区A'` fixtures,未改(离线样例,不阻塞契约)。
-5. ~~既有测试失败(非本票引入,需另开票)~~ —— **已修复**(2026-10-09,提交 `4ef2159` 推送 `origin/master`):`WaterSupplyDeviceServiceImplTest`(2) 与 `AlarmClearServiceTest`(1) 为过期断言(根因上游 `7df9a87` 改实现未同步测试),已更新为当前契约(「设备报警」/「监测报警」);`AlarmReviewAttachmentServiceTest`(2) 按维护者裁定整类删除(断言的内容嗅探/伪造扩展名拦截为生产从未实现的策略,且测试环境必然 NPE)。修复后全量 8 模块 **414/414** 通过。本票对这 5 个失败相关文件 **0 处**生产改动(已逐文件核实)。
-
-## 七、状态判定
-
-**当前状态:③ 真实数据联调完成(2026-10-08/09 达成)。**(④ 模式整体收口仍待真人验收与前端接线。)
-
-依据:编译 + 372/372 组合契约测试通过,且**部署后真实环境复验 8 项全通过** —— `district` 系列字段生效、筛选与脏值 400 语义正确、旧 `region` 被忽略、管理信息三字段已删并改 `districtIndustry[]`、点位片区如实归未分区、工单显式关联标记可用。
-
-**不进入 ④ 的原因**:④ 要求「该模式全部面板 + 地图 + 推送在真人验收下通过」—— 本卡片只覆盖后端契约切片,前端 real 生命周期接线(执行票 14–16)与真人设备报警闭环(决策票 01)尚未发生。
-
-## 八、关联
-
-- 本图执行票:[12 后端读模型 + district 片区契约](../../issues/12-backend-read-model-district.md)
-- 复验承载:[17 部署后真实环境复验(后端切片 11–13)](../../issues/17-postdeploy-live-verification.md)
-- 上游决策:[01 报警—工单闭环读模型与 Gogs 票拆分边界](../../issues/01-backend-slice-ticket-boundary.md)、[05 真实联调基线与收口证据门槛](../../issues/05-integration-baseline-and-evidence.md)、[06 异常统计、工单处置率与管理字段词汇](../../issues/06-cross-mode-business-semantics.md)
-- 规格:[10 业务语义规格](../../issues/10-business-semantics-spec.md)
-- 下游:执行票 [13 报警/工单实时同步](../../issues/13-backend-realtime-sync.md)
-- 上位台账:[票 25 后端契约缺口待办](../../../cockpit-shell-panel-layout/issues/25-backend-contract-gaps.md)、Gogs #63

+ 0 - 118
.scratch/cockpit-real-data-wayfinding/handoff/baselines/work-order-realtime-sync.md

@@ -1,118 +0,0 @@
-# 基线卡片 04:报警/工单实时同步(工单状态增量帧 + 设备帧 eventId 透传)
-
-> 按决策票 [05 真实联调基线与收口证据门槛](../../issues/05-integration-baseline-and-evidence.md) 的固定骨架产出,对应执行票 [13 报警/工单实时同步](../../issues/13-backend-realtime-sync.md)。**状态判定:③ 真实数据联调完成**(2026-10-09 部署后复验 6 项全通过,含真人触发状态变化的端到端帧捕获;④ 模式整体收口仍待真人验收与前端接线)。
-
-## 一、基线
-
-| 项 | 值 |
-| --- | --- |
-| 记录时间 | 2026-10-09(本地时钟) |
-| 记录角色 | 后端执行代理(实施 + 编译 + 契约测试 + 部署后复验) |
-| 联调环境 baseUrl | `http://192.168.110.96:8301`(= 本机,IDE 内运行的 Spring Boot 进程) |
-| 后端仓库 / 分支 / 提交 | `http://192.168.110.30:3000/shaoy/pipe-ner-server` / `master` / **`d8a6b03`**(已推送 `4ef2159..d8a6b03`) |
-| 运行实例 | PID 20240,启动于 2026-10-09 13:40:58(**已加载新构建**) |
-| 访问控制 | 驾驶舱只读 REST/WS 免鉴权(ADR 0005);本票不改鉴权范围 |
-| 只读边界 | 未触碰工单创建/派单/接单/处置/验收/延期/取消逻辑(决策 01 非目标) |
-
-## 二、收口条件五行
-
-| # | 条件 | 状态 | 证据 | 未完成项 |
-| --- | --- | --- | --- | --- |
-| ① | 后端提交 / 编译 / 契约测试 | **已完成** | 编译通过;针对性契约测试 **21/21**;全量 8 模块 **422/422**;`d8a6b03` 已推送 | 无 |
-| ② | REST 快照复用 | **已完成(无新端点)** | 票 11/12 交付的读模型即快照源:`/events/current`、`/equipments/{code}/work-orders`、`/events/{source}/{code}/work-orders`、`/work-orders/{code}/timeline` | 无 |
-| ③ | WS 增量帧 | **已完成(部署后实测)** | 真人操作 284 工单(3→4)→ **40 秒内 `cockpitWorkOrderStateDelta` 帧到达**,字段全对;无变化/不改状态的操作均正确不推送 | 设备帧 `eventId` 线上携带值待外部发布事件(契约已锁定) |
-| ④ | 诚实降级 | **已完成(契约层 + 实测)** | 帧带 `scanIntervalMs` 标注延迟上限(实测延迟 40s < 60s 上限);无订阅不查库;断线重订、失效会话清理有测试 | 前端消费侧呈现归票 16 |
-| ⑤ | 真实设备报警闭环 | **不归本票** | 按决策 01 由真人团队执行 | 阶段总收口(票 18 上位阶段) |
-
-## 三、本票交付内容
-
-### 新增:`cockpitWorkOrderStateDelta` 工单状态增量帧
-
-| 项 | 决策 | 依据 |
-| --- | --- | --- |
-| 产生机制 | 定时扫描 `work_order.order_status` **逐单比对**(默认 60s,`websocket.cockpit-work-order-delta.scan-interval-ms` 可配) | 状态流转不写 `update_time`(`transition()` 只 set orderStatus、无 FieldFill),靠时间过滤会漏掉全部流转——诊断实测核实 |
-| 推送对象 | 驾驶舱全量订阅会话(免鉴权 scope=all),**不做按人可见性过滤** | 与管理端 `workOrderReminderPush`(需鉴权+按人过滤)明确区分:内容是**状态增量**,不是提醒清单 |
-| 首扫语义 | **只建基线不推送** | REST 才是初始快照(与 `cockpitSubscribe` 既有语义一致);WS 只收后续变化 |
-| 变化判定 | orderStatus 与上次扫描不同,或工单新出现 | 新工单也是状态变化(0-待派单入场) |
-| 帧形状 | `type/scanIntervalMs/serverUpdatedAt/data{items[{workOrderCode,deviceCode,stateCode,stateName}],total}` | 对外键 `workOrderCode`(order_no),**不泄漏数据库主键 orderId**(与 REST 读模型同口径) |
-| 诚实边界 | 帧不带工单 `updateTime`(流转不更新该列,携带会误导);变化时刻=帧级 `serverUpdatedAt`(扫描时间);脏状态码原样上报、`stateName` 显式 null | 不给消费方看起来可信实则不更新的时间;不编造名称 |
-| 状态字典 | `stateCode/stateName` 出自 `CockpitWorkOrderStateDictionary`(0–7 单源,票 11) | 与 REST 读模型完全一致 |
-| 降级与重连 | 无全量订阅会话时**不查库**;发送失败的失效会话被清出订阅集合;错过的增量由消费方 REST 重取兜底 | 先看会话再扫表(同 `WorkOrderWebSocketPushTask` 纪律);断线后重订即可 |
-
-### 修改:`cockpitDeviceStatusDelta` 补 `eventId` 透传
-
-Redis 消息(`device:status:change`)携带 `eventId` 时透传进驾驶舱帧,缺省**显式 null**(WriteMapNullValue 序列化,不静默丢)——前端收到 `status=ALARM` 增量后可精确重取该报警详情,而非盲刷列表。是否携带由发布方(zk-api-service)决定,后端不臆造。
-
-### 明确的语义隔离(决策 01 红线)
-
-`cockpitDeviceStatusDelta`(设备在线/报警状态)与 `cockpitWorkOrderStateDelta`(工单状态)**帧类型不同、语义不同**,文档明言不得互为冒充;REST 快照与 WS 增量的分工(快照=REST、增量=WS)在两帧上一致。
-
-## 四、实测记录(可复制命令与结果)
-
-```powershell
-cd E:\pipe-ner-server
-mvn -o -pl pipe-network-service/zksy-admin -am test "-Dtest=CockpitWorkOrderDeltaPushTaskTest,DeviceStatusCockpitWebSocketHandlerTest" "-DfailIfNoTests=false"
-mvn -o -pl pipe-network-service/zksy-admin -am test   # 全量
-```
-
-结果:针对性 **21/21**(新增 8 例);全量 8 模块 **422/422**(基线 414 + 8)。
-
-新增契约覆盖(`CockpitWorkOrderDeltaPushTaskTest` 7 例 + 监听器 1 例):
-
-- `firstScanEstablishesBaselineWithoutPush` —— REST 才是快照,首扫零推送
-- `stateChangePushesDeltaWithDictionaryStateNames` —— 变化推送 + 字典名称 + `scanIntervalMs` + 不泄漏 orderId
-- `newlyAppearingOrderIsPushedAsDelta` —— 新工单入场即增量;无设备关联时 `deviceCode` 显式 null
-- `unchangedOrdersProduceNoSecondPush` —— 无变化不推
-- `unknownStateCodeKeepsRawCodeWithExplicitNullName` —— 脏码原样、名称 null
-- `noCockpitSessionMeansNoDatabaseScan` —— 无订阅不查库(降级纪律)
-- `staleAllScopeSessionIsPrunedWhenSendFails` —— 失效会话清理(重连语义)
-- `cockpitDeltaCarriesEventIdWhenPresentAndExplicitNullWhenAbsent` —— eventId 透传与显式 null
-
-### 4.2 部署后真实环境复验(2026-10-09,实例 PID 20240 @ 13:41:14 加载 `d8a6b03`)
-
-```powershell
-# WS 直订(System.Net.WebSockets.ClientWebSocket,PowerShell)
-$ws.ConnectAsync([Uri]'ws://192.168.110.96:8301/ws/deviceStatus', …)
-→ {"type":"cockpitSubscribe","scope":"all"}
-← {"type":"cockpitSubscribed",…}
-# 保持订阅监听帧;同时用只读 JDBC 探针快照 work_order.order_status 对照
-```
-
-结果(6 项全通过):
-
-| 复验项 | 实测 |
-| --- | --- |
-| 新构建加载 | PID 20240 启动 13:41:14 > 提交;日志无调度任务异常 |
-| WS 直订回执 | `cockpitSubscribed`(13:49:38 与 13:52:23 两次) |
-| 无变化不推送 | 订阅 75 秒零增量帧;复查库确认 7 张工单零变化(13:48:56 vs 13:50:53) |
-| 不改状态的操作不触发 | 13:55:47 真人「处置」284(仅 update_time 变、状态码仍 3)→ **无帧**(检测由 orderStatus 驱动的正面证明) |
-| **真人状态变化 → 端到端帧** | 13:57:54 提交验收(3→4)→ **13:58:34 帧到达**(延迟 40s < scanIntervalMs=60000):<br>`{"type":"cockpitWorkOrderStateDelta","scanIntervalMs":60000,"serverUpdatedAt":"2026-10-09T13:58:34+08:00","data":{"total":1,"items":[{"workOrderCode":"WO202610091014453765","stateCode":4,"stateName":"待验收","deviceCode":"TEST-CBYL5432168"}]}}`<br>帧内无 `orderId`/`updateTime`;items 只含变化那一张 |
-| 设备帧 `eventId` | 契约与显式 null 已由测试锁定;线上携带值待外部发布事件(窗口内无设备状态发布) |
-
-## 五、真人验收
-
-**闭环演示未执行**(归决策 01 的真人团队阶段总收口)。但本票复验包含一次**真人真实操作**:维护者于 13:57:54 在管理端对工单 284 提交验收(状态 3→4),驾驶舱侧 40 秒内收到增量帧——这是真实业务路径的端到端证据,非模拟。
-
-## 六、未完成项与补齐条件
-
-1. ~~部署后复验~~ —— **已完成**(2026-10-09,6 项全通过,见 §4.2;记录同步至 [票 17](../../issues/17-postdeploy-live-verification.md))。
-2. **前端消费侧**(归 [票 16](../../issues/16-frontend-alarm-order-consumption.md)):消费 `cockpitWorkOrderStateDelta`(按 `workOrderCode` 合并、`scanIntervalMs` 标注延迟);`cockpitDeviceStatusDelta.eventId` 用于精确重取报警。前端对未知帧静默忽略,**不升级前端也不会崩**。
-3. `eventId` 是否真的由 zk-api-service 发布**未证实**——透传是防御性的;复验窗口内无设备状态发布事件,线上携带值待外部发布时观察(契约与显式 null 已由测试锁定)。
-4. **决策 01 真人闭环演示**(报警产生→关联生成工单→推进→驾驶舱呈现全链路):归阶段总收口,本票已交付其驾驶舱侧全部依赖。
-
-## 七、状态判定
-
-**当前状态:③ 真实数据联调完成(2026-10-09 达成)。**(④ 模式整体收口仍待真人验收与前端接线。)
-
-依据:编译 + 422/422 全量测试 + 契约测试通过,且**部署后真实环境复验 6 项全通过**——含端到端正面证据(真人操作 3→4 后 40 秒帧到达、字段全对)与两项负向证据(无变化不推送、不改状态码的操作不触发,证明增量由 `orderStatus` 驱动)。
-
-**不进入 ④ 的原因**:④ 要求模式整体收口(真人验收 + 前端接线 + 阶段联调)——本卡片只覆盖后端切片 3;前端 real 生命周期接线(票 14–16)与真人设备报警闭环(决策 01)尚未发生。
-
-## 八、关联
-
-- 本图执行票:[13 报警/工单实时同步](../../issues/13-backend-realtime-sync.md)
-- 复验承载:[17 部署后真实环境复验(后端切片 11–13)](../../issues/17-postdeploy-live-verification.md)
-- 上游决策:[01 报警—工单闭环读模型与 Gogs 票拆分边界](../../issues/01-backend-slice-ticket-boundary.md)、[04 real 生命周期与 mock 同构启动契约](../../issues/04-real-lifecycle-contract.md)
-- 规格:[09 real 生命周期规格](../../issues/09-real-lifecycle-spec.md)
-- 下游:前端执行票 [16 报警–工单闭环消费](../../issues/16-frontend-alarm-order-consumption.md)
-- 上位台账:[票 25 后端契约缺口待办](../../../cockpit-shell-panel-layout/issues/25-backend-contract-gaps.md)、Gogs #63

+ 0 - 23
.scratch/cockpit-real-data-wayfinding/handoff/geometry-read-model-review.md

@@ -1,23 +0,0 @@
-# 几何读模型:最终审查
-
-审查范围:后端 `git diff 60a0e9a...33ca0b6`,5 个新增文件、631 行(实现 260 行、契约测试 371 行)。规格:[后端执行票 —— 驾驶舱设施点位与管线几何读模型](../issues/30-untitled.md),契约:[实施与验收记录](geometry-read-model.md)。
-
-## Standards
-
-首次审查发现 1 项实际阻断:事务代理开启/提交异常会绕过服务方法内 catch,可能进入既有全局处理器返回 HTTP 200 且暴露底层错误。已在本控制器局部 `@ExceptionHandler(RuntimeException.class)` 统一脱敏为 HTTP 500 + SERVER_ERROR,并添加事务异常回归;服务端保留日志线索。最终复核:0 项未解决的可证实代码漏洞。
-
-Lombok 初始化 final type 不进入 AllArgsConstructor,Feature 三参调用正确;Clock/事务/Jackson依赖与 Boot 2 API 一致;行业映射复用已有单源,标准 list 路径沿用逻辑删除。
-
-## Spec
-
-首次审查发现合法非空 Point/LineString 缺实际 JSON 序列化回归。已补实际 Service → MVC 用例,断言坐标层级、顺序、networkId、pointType 和 UNVERIFIED。最终复核:0 项未解决的可证实代码漏洞。
-
-三源原始坐标、构件类别及关系保留;非法对象 geometry=null、合法但未校核仍不可渲染;未知管网不丢;coverage 区分 EMPTY/UNAVAILABLE 并可从 features 复算。不生成默认点或拓扑。
-
-## 验收证据限制
-
-针对性测试已通过 22/22:Service 14、Controller 6、AnonymousAccessSecurityConfig 2。Standalone MVC 不代表真实安全链验收,后者仍须在实例加载新代码后执行无 Authorization 的只读 GET。
-
-全量回归首次被并行未提交的设施统计测试构造器差异阻断,未改动其工作;改在仅含已提交基线与本票提交的隔离副本运行,结果记录于实施与验收记录。
-
-初次两轴以两独立后台代理并行进行;最终修复复核由独立后台代理只读完成,其再次并行委派受深度上限拒绝,在该代理内分别复核两轴,不伪称新增并行审查。所有代理已结束。

+ 0 - 57
.scratch/cockpit-real-data-wayfinding/handoff/geometry-read-model.md

@@ -1,57 +0,0 @@
-# 驾驶舱设施点位与管线几何读模型:实施与验收记录
-
-本记录承接[后端执行票 —— 驾驶舱设施点位与管线几何读模型](../issues/30-untitled.md)。最终两轴审查见[几何读模型最终审查](geometry-read-model-review.md)。
-
-## 基线与范围
-
-- 后端仓库:`E:\pipe-ner-server`,分支 `master`,本轮起始 `HEAD=7fb7c2c`;初查工作区干净。
-- 前端已有大量并行工作;本票只改本票记录与后端新增文件,不收取其它改动。
-- 端点:`GET /api/cockpit/v1/infrastructure/geometry`。沿用现有 `AjaxResult` 信封,`data` 是 GeoJSON `FeatureCollection`;GET 继承既有 `/api/cockpit/v1/**` 免鉴权规则,无写入口、无新增鉴权例外。
-- 从 `PipeNetworkBaseService.list()` / `GasPipePointService.list()` / `ManholeBaseService.list()` 读取;实体的 MyBatis-Plus `@TableLogic` 排除删除数据。事务标记只读,三源任一异常整体返回 HTTP 500 + `SERVER_ERROR`,不伪装为部分成功或空集合。
-
-## 响应契约
-
-- 顶层:`type=FeatureCollection`、`generatedAt`、`features[]`、`coverage[]`。
-- `generatedAt` 只代表响应生成时间,不冒充可重读的一致性快照 token。
-- `Feature.id`:`来源表:来源主键`,跨表不碰撞。
-- `geometry`:真实两端点 `LineString` 或真实 `Point`;坐标顺序始终 `[longitude, latitude]`。任何端点缺失、越界或为 `(0,0)`,该对象 `geometry=null`,对象保留。
-- `properties`:`source/sourceId/code/name/industry/typeCode/networkType/pointType/networkId/sourceCoordinates/coordinateValidation/coordinateValidations`。
-- 管段与燃气构件 `networkId` 原样关联,窨井关联原样保留;没有关系时显式 null,不通过距离或共享坐标生成关系。
-- `pointType` 原样透传,不把设施构件类型与监测点类型/设备类型编码混用,不填充不存在的构件。
-- `coordinateValidation` 与 map-snapshot 同形:`state/reason/renderable/crs`。先按已有 validator 检测结构错误;数值合法之后仍因无校核来源返回 `UNVERIFIED/UNVERIFIED_COORDINATE/renderable=false`。**范围合法不能升格为已校核**。
-- `coordinateValidations` 按源顺序提供逐坐标校验(点一个,管段起终点两个),避免终点非法被起点未校核掩盖。`sourceCoordinates` 保留原始值(含 null),供无地图位置清单解释。
-- 未分类管网仍保留,`industry=UNKNOWN/typeCode=PIPE_UNCLASSIFIED` 并透传 `networkType`,不静默归入其它行业。
-
-## 覆盖语义
-
-每个 coverage 条目包含 `industry/source/geometryType/state/reason/recordCount/geometryCount/renderableCount`;数量来自本次响应,未硬编码现场规模。
-
-| 行业 | LineString | Point |
-| --- | --- | --- |
-| GAS | pipe_network_base:真实两端点,TWO_ENDPOINTS_ONLY | gas_pipe_point:全部实际构件,pointType/networkId 原样 |
-| WATER_SUPPLY | pipe_network_base:真实两端点,TWO_ENDPOINTS_ONLY | UNAVAILABLE / NO_COMPONENT_SOURCE |
-| DRAINAGE | pipe_network_base:真实两端点,TWO_ENDPOINTS_ONLY | UNAVAILABLE / NO_TYPED_COMPONENT_SOURCE |
-| MANHOLE | UNAVAILABLE / NO_PIPELINE_SOURCE | manhole_base:真实窨井点,NO_COMPONENT_TYPE |
-| UNKNOWN | 未分类管网原样保留,UNKNOWN_NETWORK_TYPE | 无独立来源 |
-
-`AVAILABLE` 表示已读取到源记录,`EMPTY` 表示支持的数据源本次没有记录,`UNAVAILABLE` 表示本端点没有该几何来源。`geometryCount` 是具有结构合法几何的对象数,**不等于可上图数**;可上图只能看 `renderableCount` / 对象的 `coordinateValidation.renderable`。
-
-## 对抗式自查计划
-
-- 三行业类型映射和设施命名空间;未知类型不丢弃。
-- 真实两端点/Point、坐标顺序、跨表 ID、不生成拓扑。
-- 所有原始燃气构件类型(包括缺失和未知)保留,networkId 不臆造。
-- 缺经纬度、正负越界、零坐标;特别检查“起点未校核 + 终点非法”整线不可渲染且有具体错误。
-- 三类来源都不能凭坐标合法升级为 VALID;边界值不误判越界。
-- 空源与无来源覆盖状态区分;数量与实际 features 自洽。
-- 任何源失败/返回 null:HTTP 500,无私有错误泄漏、无部分成功。
-- JSON 信封、geometry=null、只读 GET/POST 405;真实安全链仍须实测,无头的 standalone MockMvc 不作为免鉴权证据。
-
-## 验收状态
-
-- 提交 `33ca0b6`(feat(cockpit): add read-only facility geometry model,5 文件 +631 行)已推送 `origin/master`(`60a0e9a..33ca0b6`,快进)。
-- 针对性测试 **22/22**:几何 Service 14、Controller 6(含真实 Service→MVC 非空 Point/LineString 序列化与事务异常脱敏 500 回归)、免鉴权配置 2。
-- 全量 `*Cockpit*Test` **463/463**:在仅含 `33ca0b6` 的隔离副本(`implementation/t30/validation-checkout`)通过;主工作树因另一并行会话未提交的设施统计构造器改动无法编译测试,未动其工作。
-- 实施中独立两轴审查发现并修复:事务代理异常绕过稳定失败响应(控制器局部 RuntimeException handler 统一脱敏 500)、合法非空几何缺实际 JSON 序列化回归(已补)。最终复核 Standards 0 / Spec 0,见[几何读模型最终审查](geometry-read-model-review.md)。
-- MockMvc 为 standalone,不作为真实安全链证据;真实免鉴权 GET 验收(无 Authorization 只读请求)在 `192.168.110.96:8301` 实例加载 `33ca0b6` 后执行:探针 `implementation/t30/live-acceptance.py`(当前仍 404,实例未重启,结果落 `live-acceptance.json`)。
-- 2026-10-12 本轮用户明确授权 Maven 测试(含编译)与由用户在 IDE 重启实例;未运行浏览器调试。

+ 0 - 121
.scratch/cockpit-real-data-wayfinding/handoff/manual-verify-pipe-type-namespace.md

@@ -1,121 +0,0 @@
-# 人工核验步骤:管网编码与设施类型命名空间
-
-适用范围:[管网编码 0/1 映射与设备边界设施类型命名空间](../issues/20-20-0-1.md) 的后端修复 `740d896`。本说明对应 2026-10-12 数据快照;后续业务数据增长时,先用当时的数据重新核定计数。详见[基线卡片 05](baselines/pipe-network-type-namespace.md)。
-
-## 核验前准备(约 2 分钟)
-
-1. 打开你部署的前端地址下的 `/cockpit`。`8301` 是后端接口端口,不是前端页面地址。
-2. 确认页面使用 `real` 数据源,接口实际请求到 `http://192.168.110.96:8301/api/cockpit/v1/`。按 F12 打开开发者工具 → Network(网络)→ Fetch/XHR,刷新页面,检查 Request URL。
-3. 清空行业、片区、设施类型等筛选,回到全量范围。记录核验时间、前后端版本和请求参数。不要用 mock 页面或仍带筛选的数字与下表比较。
-4. 核验只涉及浏览页面和 GET 请求,无需创建、修改或删除业务数据。
-
-## 本次核验基准
-
-| 管网原始编码 | 行业 | 设施类型码 | 中文名 | 条数 |
-| --- | --- | --- | --- | ---: |
-| `0` | GAS(燃气) | GAS_PIPE | 燃气管线 | 537 |
-| `1` | DRAINAGE(排水) | DRAINAGE_PIPE | 排水管线 | 3735 |
-| `2` | WATER_SUPPLY(供水) | WATER_PIPE | 供水管线 | 996 |
-| 合计 | | | | **5268** |
-
-这是管网条数,不能与包含其它设施的总数直接比较。旧界面显示 `1001` 是历史异常现象,不作为当前各端点总数的统一期望。
-
-## 步骤 1:基础档案(页面 + 响应,约 2 分钟)
-
-切换到基础设施模式,查看基础档案的设施总数与类型分布。在 Network 中找到 `infrastructure/base-archives`,打开 Response(响应):
-
-- `data.facilityTypeDistribution` 的 `code/name/count` 应包含:GAS_PIPE/燃气管线/537、DRAINAGE_PIPE/排水管线/3735、WATER_PIPE/供水管线/996。
-- 三类管网相加应为 **5268**;`data.totals.facilityCount` 应为 **5278**。
-- `data.excluded.unknownTypeCount` 应为 **0**。注意:修复前适配器直接丢弃部分管网,这个计数也可能为 0,因此**必须同时核对三类条数**。
-- 若界面展示与同一次请求的响应不同,记录为前端消费问题;不要将其误判为后端映射未修复。
-
-## 步骤 2:总体概览(约 2 分钟)
-
-切换总体概览,查看基础设施分区,并检查 `overview/infrastructure` 的响应:
-
-| 响应字段 | 期望 |
-| --- | ---: |
-| `data.totals.facilityCount` | **5276** |
-| `data.industries` 中 GAS 的 `facilityCount` | 537 |
-| 同上 WATER_SUPPLY | 1004 |
-| 同上 DRAINAGE | 3735 |
-| 同上 MANHOLE | 0 |
-| `data.excluded.unknownTypeCount` | **0**(修复前为 5268) |
-
-总体概览的 5276 = 5268 管网 + 3 水厂 + 5 供水泵站。它不包含基础档案里的 2 处排水工程设施,所以比基础档案少 2 是预期行为。
-
-## 步骤 3:设施命名空间(接口检查,约 2 分钟)
-
-设备边界不一定有独立可见面板,可直接在浏览器打开下面的只读接口,或使用下方 PowerShell 命令:
-
-`http://192.168.110.96:8301/api/cockpit/v1/device-boundary`
-
-核对 `data.facilities[]` 中的设施:
-
-- `facilityType.code/name` 只能使用管网语义:GAS_PIPE/燃气管线、DRAINAGE_PIPE/排水管线、WATER_PIPE/供水管线。
-- `industry` 分别为 GAS、DRAINAGE、WATER_SUPPLY,`formalScope` 应为 true。
-- **管网设施不得被标为“供水设备”“排水设备”“燃气设备”**。这些名称出现在 `data.devices[]` 的监测设备类型里可以正常,不要将设备清单与设施清单混淆。
-- `data.statistics.facilityCount=5268`,`unknownTypeCount=0`,行业设施数分别为 537/3735/996。
-
-关键记忆:`network_type=1` 是排水管网,而 `equipment_type.id=1` 是供水设备,两者不能共用数字编码去查类型。
-
-## 步骤 4:管理信息与运行信息(约 2 分钟)
-
-基础设施模式 → 管理信息,检查 `infrastructure/management-information`:
-
-- `data.totals.facilityCount=5273`、`equipmentCount=7`。
-- `data.districtIndustry` 的未分区行:GAS 537、DRAINAGE 3735、WATER_SUPPLY 998(996 管网 + 2 水源地)、MANHOLE 3(窨井)。
-- `data.excluded.unknownIndustryCount=0`。管网没有权威片区字段,因此归未分区是正确行为。
-
-检查 `infrastructure/operating-information`:
-
-- 在 `data.industries[].metrics[]` 中,仅统计 `metricCode` 以 `_pipe_network_length` 结尾的条目;应为 GAS 537、WATER_SUPPLY 996、DRAINAGE 3735,合计 5268。
-- `data.excluded.unknownIndustryCount=0`。指标条数是管道数量,不是长度值;长度单位以响应的 `unit` 为准(此指标为 m),不要把它当 km。
-- 页面可能聚合指标,未必展示 5268 行明细;此项以响应统计为准。
-
-## 可选:用 PowerShell 一次输出核心核对项
-
-以下命令只发 GET 请求,可在 Windows PowerShell 中复制执行:
-
-```powershell
-$base = 'http://192.168.110.96:8301/api/cockpit/v1'
-function Get-CockpitData($path) {
-    $r = Invoke-RestMethod "$base/$path" -TimeoutSec 60
-    if ($r.code -ne 200 -or $null -eq $r.data) {
-        throw "接口失败:$path,业务 code=$($r.code)"
-    }
-    return $r.data
-}
-$a = Get-CockpitData 'infrastructure/base-archives'
-$a.facilityTypeDistribution | Where-Object {
-    $_.code -in @('GAS_PIPE', 'WATER_PIPE', 'DRAINAGE_PIPE')
-} | Format-Table code,name,count
-"基础档案总数=$($a.totals.facilityCount),未知类型=$($a.excluded.unknownTypeCount)"
-
-$o = Get-CockpitData 'overview/infrastructure'
-"概览总数=$($o.totals.facilityCount),未知类型=$($o.excluded.unknownTypeCount)"
-$o.industries | Select-Object code,facilityCount | Format-Table
-
-$b = Get-CockpitData 'device-boundary'
-$b.facilities | Group-Object {
-    "$($_.facilityType.code)|$($_.facilityType.name)|$($_.industry)"
-} | Select-Object Name,Count | Format-Table
-"设备边界设施数=$($b.statistics.facilityCount),未知类型=$($b.statistics.unknownTypeCount)"
-```
-
-## 判定与记录
-
-三类管网合计一致、名称与行业正确、未知计数为 0,且页面与其实际请求响应一致,才能判定本次管网映射核验通过。固定数字仅对应本次快照;数据新增后应重新建立基准,不将正常变化判成回归。
-
-| 记录项 | 填写 |
-| --- | --- |
-| 时间、页面地址、版本 | |
-| 行业/片区/类型筛选是否清空 | |
-| 基础档案:三类管网数、总数 | |
-| 概览:行业设施数、未知类型数 | |
-| 设备边界:设施名称与行业是否正确 | |
-| 管理信息:未分区计数;运行信息:管网指标条数 | |
-| 页面与响应是否一致;异常截图或响应 | |
-| 操作人、结论(通过/未通过) | |
-
-完整界面与模式阶段验收继续归[前端真实接口联调与阶段收口](../issues/18-frontend-real-integration-gate.md);本说明不代表该阶段已全部收口。

+ 0 - 50
.scratch/cockpit-real-data-wayfinding/handoff/review-pipe-type-namespace.md

@@ -1,50 +0,0 @@
-# 管网编码与设施命名空间:收尾审查报告
-
-审查日期:2026-10-12。初始范围为后端提交 `740d896`,固定比较 `git diff 94971b5...740d896`;规格为[管网编码 0/1 映射与设备边界设施类型命名空间](../issues/20-20-0-1.md)及维护者已确认的两项裁定。规范与规格两个轴独立审查,随后对发现项补齐并回归。
-
-## Standards(规范轴)
-
-未发现适用文档规范违规或值得阻塞的代码结构问题。数字编码 `0/1/2` 与现行业务 GIS 规则一致,设施使用 `GAS_PIPE/WATER_PIPE/DRAINAGE_PIPE`,与监测设备类型主键隔离。无法映射的管网上行到既有未知项计数,而不是在适配器静默丢失。
-
-## Spec(规格轴)
-
-原提交存在两项规格完整性缺口,目前已补齐:
-
-| 优先级 | 原问题 | 补齐 |
-| --- | --- | --- |
-| P2 | 票面要求跨端点管网总量一致的契约测试,原测试主要逐适配器验证,无法锁住五端点的共同口径 | 新增 `CockpitPipeNetworkCrossEndpointContractTest`,同一份管网表数据经过五套真实适配器与服务,只 mock 数据源;逐行业精确比较基础档案、管理信息、运行信息、概览和设备边界的管网计数,并验证 `3/99/null` 未知项的五处排除计数 |
-| P3 | 基础档案的三类管网名称和行业仍独立硬编码,类型单源部分完成 | 改为从 `CockpitPipeNetworkType` 直接取得类型码、中文名及行业,保留原有列表顺序与当前值 |
-
-这两项没有证实当前线上映射错误;它们是自动回归与单源传播的交付缺口。补齐未修改数据库、前端代码或业务写流程。
-
-## 验证与实际部署边界
-
-- 补充两个跨端点用例均通过。
-- 全量 reactor `BUILD SUCCESS`:`zksy-system` 187 项、`zksy-admin` 441 项,合计 **628 项**,0 失败、0 错误、0 跳过。
-- 更正此前计数表述:“439/439 全量”是 `zksy-admin` 模块小计,并非所有模块合计;本次分别记录两个模块,避免口径混用。
-- 真实运行实例 PID 25880 已验证主修复 `740d896` 的行为。补齐只将一致的显示字典定义改为共享读取、增加测试,当前部署的主修复现场证据与补齐源码回归分别登记,不凭进程启动时间推定后续补齐已经部署。
-- 本次未做自动浏览器走查或截图;界面只采用维护者“粗验未发现问题”的反馈,不宣称完整人工验收通过。
-
-## 现场复验摘要
-
-| 对象域 | 设施总数 | 管网计数核对 |
-| --- | ---: | --- |
-| 基础档案 | 5278 | 燃气 537/供水 996/排水 3735 |
-| 总体概览 | 5276 | 同上;排除未知类型 0 |
-| 设备边界 | 5268 | 同上;名称正确,没有管网被标为“供水设备/排水设备” |
-| 管理信息 | 5273 | 含 5268 管网 + 3 窨井 + 2 水源地;管网归未分区 |
-| 运行信息 | 不按设施总数比较 | 三行业管网长度指标条数 537/996/3735 |
-
-不同端点对象域不同,设施总数无需相等;共同的管网集合为 **5268**。运行信息的管网长度单位为 m,不能将原始 value 当作 km。
-
-## 远端并行更新后的最终验证
-
-审查补齐提交 `29d1ae2`,整合远端报警描述更新后的合并提交 `0a6b108` 均已推送。规范和规格补齐再经只读复核,P2/P3 闭环,无新增阻塞。
-
-- 同步远端前:`zksy-system` 187 + `zksy-admin` 441,合计 628 项全部通过。
-- 同步远端报警描述提交 `86b0c8d` 后:全量在 `zksy-system` 的 `AlarmLifecycleServiceTest.insertWritesLogBeforeCommittingWithoutTrigger` 出现 1 个 `VerificationInOrderFailure`(190 项、1 失败),停在该模块,不能宣称当前整合树全量通过。报警日志断言涉及的源码与测试与远端完全一致,本次管网补齐没有改动它们;本轮只记录,不扩展到报警领域修复。
-- 整合后的管网专项回归:29 项全部通过(包含五端点同源的正常/未知值两组测试)。
-- 并行会话的工单读适配器与其测试改动留在工作树,未暂存、未代提交。
-- 已部署主修复为 `740d896`;审查补齐为 `29d1ae2`,并未声称当前运行实例加载了后续合并的所有报警改动。单源补齐保持现有管网名称、行业和排列不变。
-
-规范轴未报告问题;规格轴初始发现 P2、P3 两项并完成补齐。人工界面核验按[人工核验步骤](manual-verify-pipe-type-namespace.md)继续,整体阶段仍归[前端真实接口联调与阶段收口](../issues/18-frontend-real-integration-gate.md)。

+ 0 - 41
.scratch/cockpit-real-data-wayfinding/handoff/t18-current-preflight.md

@@ -1,41 +0,0 @@
-# 前端真实接口联调:当前预检与验收启动条件
-
-## 当前结论(2026-10-13)
-
-**尚未达到完整真人验收就绪条件。** 已完成用户明确要求的当前版本核对、真实只读接口预检、WS订阅预检及浏览器进入系统检查;没有代执行任何外部业务操作。地图渲染停止、监测数据时间契约缺失及交付基线未锁定,均需先处理,不能用真人签字替代。
-
-所属工单:[前端真实接口联调与阶段收口](../issues/18-frontend-real-integration-gate.md);后续真人步骤与结果模板:[真人接手清单](t18-human-takeover.md);此前仅历史资料核对:[证据就绪性报告](t18-evidence-readiness.md)。三份文档的证据范围不同,本报告记录实际新预检。
-
-## 实测与限制
-
-| 项目 | 本轮结果 | 证据 / 限制 |
-| --- | --- | --- |
-| 实际前端入口 | `http://127.0.0.1:8000/#/cockpit`,现有Vite dev服务;进入系统后配置显示real | 当前路由为hash;不含hash的 `/cockpit` 首次返回首页。未启动替代服务 |
-| 真实后端 | `http://192.168.110.96:8301/api/cockpit/v1`,18个端点均 HTTP200、业务code200、readOnly=true | [新预检响应与版本记录](../verify/artifacts/t18/takeover-2026-10-13T08-44-40-563Z/preflight.json);检查只证明读取可达,不等于面板、地图与边界全部通过 |
-| 监测数据时间 | 三个监测端点均缺自有createdAt属性,21项检查中18过/3失败 | 已交[监测三端点createdAt契约修复](../issues/35-createdat.md);缺字段不是显式null,不用响应时间冒充 |
-| 真实正向工单样本 | 设备21119740、工单WO202610121103507623;stateCode=0/待派单,关联当前ALARM为true,事件e68a942d92c48ac709fca8cee3160f3b | 新预检candidate及两端原始响应;未派单、未接单、未处理、未结案,未作业务通过结论 |
-| WS订阅 | 16:36:38–16:37:08(+08:00)收到cockpitSubscribed,scope=all,errors=[] | [30秒预检窗口](../verify/artifacts/t18/ws-takeover-preflight-20261013-163637.json);deltaFrames=0,无真人动作,不判断增量通过或故障。窗口已结束,目前没有长时间捕获运行 |
-| 浏览器real呈现 | 总体概览可见设施5283、设备9、在线3/9、当前预警29、当前报警66;状态栏可见EMPTY6/UNASSOCIATED9 | 仅当前界面读数;不是固定长期期望。无四模式全部面板新通过结论 |
-| 地图 | 黑屏并报Fragment shader failed to compile / varying Illegal use of reserved word / Rendering has stopped | [现场截图](../verify/artifacts/t18/takeover-browser-20261013/rendering-stopped.jpg),交[调色shader兼容修复](../issues/36-untitled.md);地图、定位、稳定性均不能据本次算通过 |
-
-首次预检中三个事件聚合端点遗漏必需的eventFamily,故返回400;已按真实适配器补参并重跑。首次记录另存,作为探针自身错误历史;它不是后端新缺陷。本表仅使用修正入参后的18请求/21检查结果。
-
-浏览器控制台的Cesium生成shader含 `#version 300 es` 和 `varying vec2 v_textureCoordinates;`,gradeSaturation等uniform与工作树调色stage对应;调用栈经过PostProcessStage.execute。本轮CSS视口1600×1000,会话曾设置ipad-mini仿真,故不把这次观察泛化为所有浏览器/GPU的结论;修复应在实际桌面GPU环境重新验收。浏览器调试capture启动因工具参数校验失败,未取得完整网络调试录制;保留的是实际页面、截图、控制台读取及独立GET/WS证据,不声称已有完整HAR或data-*断言。
-
-## 版本核对
-
-- 前端master HEAD=`5a83ab9b836156a4bb9468d1868e4d8040238571`;实际 `git ls-remote` 的origin/master=`a6fbbea9dc32e83ee61dcb5a6646bf5875399636`。本地显示ahead12;驾驶舱36个已跟踪源码文件有工作树差异,另有未跟踪源码/素材与并行工作。抖动修复相关源码及验证材料仍未形成干净已交付基线。未替并行工作提交、推送或回退。
-- 后端master HEAD=`a96091ce081411e8ceedcf73fe38274d0c63fae8`;实际远端master=`3e448568cd7db6c9a710479283553eab16e67817`,缓存origin/master仍为989a707e。本地“ahead1”只相对缓存,不能据此断言实际远端缺修复;本轮未fetch、未推断祖先关系。后端已跟踪工作树只见IDE编码配置差异。
-- 前端是含未提交改动的dev实例;后端当前进程产物与纯净提交的绑定尚未建立。本轮API/界面证据属于观察窗口,不等于正式已提交交付版本。
-
-## 验收启动条件与下一步
-
-1. 对应前端执行票恢复地图渲染;对应后端执行票补齐三端点createdAt契约。两项为可代理的技术工作,不交给用户手工修代码。
-2. 交付负责人协调并行修改,完成限定提交/推送,代理核对实际运行产物及前后端版本。新版本追加基线卡片,保留旧记录。
-3. 代理在相同稳定运行基线重做受影响预检,补齐四模式、筛选/分页、边界/失败态和浏览器断言。旧mock证据不替代真实通过。
-4. 上述准备完成后,代理通知用户可开始,并立即开启新的WS捕获窗口;确认订阅回执后才通知业务人员推进工单,避免用户动作落在捕获窗口外。
-5. 真人按接手清单做页面业务走查、90秒静置与刷新检查、真实报警—工单各阶段流转及团队验收;代理同步对照程序证据。先用上述样本做状态推进检查可以,但新一轮完整链路若需要从报警生成/工单创建开始,须业务人员选定可真实执行的新样本,不能把旧创建记录冒充当前窗口内动作。
-
-现阶段请保持候选工单待派单,不安排最终验收签字。发现的问题已回执行票并接入上位阻塞关系;上位工单保持claimed/ready-for-human/open,本次不关闭、不升级基线状态,不向地图Decisions so far写完成记录。
-
-本轮只运行用户已要求的GET、WS及浏览器预检,未运行项目build、未启动/重启前后端服务;未修改功能代码或真实业务数据。

+ 0 - 34
.scratch/cockpit-real-data-wayfinding/handoff/t18-evidence-readiness.md

@@ -1,34 +0,0 @@
-# T18 证据就绪性:本次只读事实核对
-
-## 结论与核对边界
-
-**指定资料不足以确认最新前后端提交均已推送且与当前运行实例绑定,也不足以形成新一轮 T18 阶段验收。** 本次仅阅读五张基线卡片、票 05/17/33 和本图 `verify/t18-*.mjs`、`verify/artifacts/t18/` 中已有材料;以下接口结果、测试数、PID、推送记录和真人操作均为**历史来源记载**,不是本次重测。没有查询远端、当前工作树或运行进程,没有构建、调试、浏览器、接口探针、服务器或任何测试;唯一写入为本报告。不推断当前接口或当前运行版本,不作真人业务结论。阶段门依据:[票 05](../issues/05-integration-baseline-and-evidence.md#L19-L49)。
-
-## 版本、推送与运行基线
-
-| 来源 | 历史记载 | 本次可确认的限制 |
-| --- | --- | --- |
-| [卡片 01](baselines/map-monitoring.md#L9-L28) | 2026-10-08:后端 `master/5b53a6d`、前端 `master/f6288e0`;推送均未核实,分别有 1/55 项未提交 | 样板旧卡只到①,不是新验收的交付锚点 |
-| [卡片 02](baselines/work-order-rate.md#L9-L18)、[03](baselines/work-order-read-model-district.md#L9-L18)、[04](baselines/work-order-realtime-sync.md#L9-L15)、[票 17](../issues/17-postdeploy-live-verification.md#L19-L32) | 2026-10-08/09:后端先后 `89ca989`/PID26956、`818a6d7`/PID15244、`d8a6b03`/PID20240,记载已推送及当时复验;这些切片历史判定为③ | 不覆盖后来提交,也不提供新前端基线;票 17 resolved 仅表示切片 11–13 的滚动复验完成 |
-| [T18 旧矩阵](../verify/artifacts/t18/real-integration-gate-report.md#L3-L9) | 前端 `master/b8917ed` 已推送;后端远端 `a151183`、本地 `d8a6b03`,运行来源未可验证绑定 | 旧矩阵自身已登记版本绑定缺口;不能沿用为最新运行基线 |
-| [卡片 05](baselines/pipe-network-type-namespace.md#L9-L16)、[后续补记](baselines/pipe-network-type-namespace.md#L128-L134) | 2026-10-12:主修复 `740d896` 已推送,当时 PID25880 加载;随后补齐 `29d1ae2` 并以 `0a6b108` 整合远端后推送 | 卡片表头运行证据仍针对 `740d896`;补记明确不把后续报警合并标成实机部署。整合后管网专项 29/29,但整合树全量有一项报警顺序断言失败,不能沿用旧 439 宣称最新全量通过 |
-| [票 33](../issues/33-33-jitter-fix.md#L47-L53) | 2026-10-13 已关闭,记载历史回归及维护者目视终判;修复仍未提交,明确“关闭≠已推送” | 在本次指定资料中,未见覆盖这批修复的前端提交/推送/运行绑定证据;不据此断言当前工作树仍未提交 |
-
-票 05 要求新提交追加基线、旧卡注明被取代,并登记前后端提交及推送。现有卡片主要按不同后端切片记录,不能用编号 05 代替整套前后端最新基线;T18 较新 JSON 的顶层只记时间、base、只读范围及结果,未登记前后端提交或实例来源。[版本漂移规则](../issues/05-integration-baseline-and-evidence.md#L19-L24)、[较新 JSON](../verify/artifacts/t18/live-audit-results.json#L1-L5)、[JSON 生成字段](../verify/t18-real-audit.mjs#L92-L96)
-
-## 最重要的阶段门缺口
-
-1. **交付与运行绑定缺口**:尚需一份涵盖前后端最新提交、推送状态、运行来源、记录时间和数据窗口的统一登记;尤其不能把 `0a6b108` 的推送当作已运行,也不能把票 33 关闭当作修复已入库。[卡片 05 补记](baselines/pipe-network-type-namespace.md#L130-L134)、[票 33](../issues/33-33-jitter-fix.md#L50-L51)、[票 05 固定骨架](../issues/05-integration-baseline-and-evidence.md#L43-L49)
-2. **较新审计尚有三项监测时间检查未通过**:2026-10-12T15:19:59.139Z 的 JSON 记载 141 次请求、140 项检查通过、3 项检查失败;失败均为三监测端点 `createdAt`,记录 `code=200`、`createdAt=null`。脚本在 `code=200` 时仍失败,指向信封 `data` 缺少自有 `createdAt` 属性;记录中的 `null` 是脚本的兜底表达,不证明响应实际带有 `createdAt:null`。该脚本也没有逐对象时间或时间语义验证。该历史轮尚不能证明票 05 要求的数据时间门槛已补齐,本次不推断当前接口。[结果](../verify/artifacts/t18/live-audit-results.json#L5751-L5807)、[检查实现](../verify/t18-real-audit.mjs#L78-L81)、[卡片 01 时间条件](baselines/map-monitoring.md#L25-L28)
-3. **契约读取通过不能覆盖全部可见行为**:最新指标 current/history 检查均留 `EMPTY`,脚本只判业务 `code=200`,缺非空指标展示证据;状态筛选检查主要判回显,坐标检查判“不渲染未经校核对象”,不是地图实际呈现验收。业务错误映射项虽 passed,但当次 `rawCode=200`,脚本短路通过,未在该轮证明业务 500 的失败映射。[指标检查](../verify/artifacts/t18/live-audit-results.json#L5075-L5091)、[脚本判据](../verify/t18-real-audit.mjs#L35-L37)、[状态/指标判据](../verify/t18-real-audit.mjs#L53-L70)、[错误映射项](../verify/artifacts/t18/live-audit-results.json#L5793-L5799)、[短路判据](../verify/t18-real-audit.mjs#L89-L91)
-4. **推送与真人整体收口证据不足**:T18 四份 WS 产物只含 `cockpitSubscribed`,没有设备/工单增量;需与同窗操作记录或数据变化对照才能解释。票 17 的 2026-10-09 真人 3→4、40 秒收到工单帧是历史后端切片正面证据,不能替代新前端消费及完整报警—工单—地图—面板闭环;设备帧真实 `eventId` 携带值在该切片仍未实测。当前资料不能据此作新的真人业务结论。[WS 180 秒](../verify/artifacts/t18/ws-capture.json)、[WS 75 秒](../verify/artifacts/t18/ws-observation.json)、[WS 420 秒](../verify/artifacts/t18/ws-order-advance-1512.json)、[WS 900 秒](../verify/artifacts/t18/ws-order-advance-2.json)、[票 17 边界](../issues/17-postdeploy-live-verification.md#L25-L36)、[旧矩阵真人证据项](../verify/artifacts/t18/real-integration-gate-report.md#L36-L44)、[④门槛](../issues/05-integration-baseline-and-evidence.md#L34-L49)
-
-## 旧计数、旧窗口为何不能直接验收
-
-- **旧矩阵已落后于较新产物**:矩阵的 123 请求/92 通过/33 失败与 2026-10-10 前修复 JSON 一致;2026-10-12 JSON 已是 141 请求/140 通过/3 失败。请求数与检查数独立计数,不能相加比较为“成功请求率”;旧 33 失败不能全部转述为当前缺陷,较新 3 失败也不是本次在线结果。[旧结果](../verify/artifacts/t18/live-audit-results-2026-10-10T0225Z-pre-fix.json#L4745-L4750)、[旧矩阵](../verify/artifacts/t18/real-integration-gate-report.md#L7-L9)、[较新结果](../verify/artifacts/t18/live-audit-results.json#L5802-L5807)、[计数实现](../verify/t18-real-audit.mjs#L92-L94)
-- **数据集与对象域不同**:旧地图总数 13,较新适配器总数 15;旧设施总数 1006、卡片 05 复验 5278、较新适配器 5283 是不同时点记录。它们不能作为新一轮固定预期,也不能把设施、设备、监测点、管网四个域互相替代。[旧信封](../verify/artifacts/t18/initial-live-endpoints.json#L3-L16)、[旧设施](../verify/artifacts/t18/live-audit-results-2026-10-10T0225Z-pre-fix.json#L4720-L4734)、[卡片 05 计数](baselines/pipe-network-type-namespace.md#L99-L106)、[较新计数](../verify/artifacts/t18/live-audit-results.json#L5775-L5790)
-- **时间窗口必须显式登记**:票 17 仍保留 `2026-09-08 ~ 2026-10-08` 的历史命令,卡片 02 明确它不应沿用,另给相对 31 天窗口;T18 脚本的 history 窗口仍固定为 `2026-10-03 ~ 2026-10-10`,较新 JSON 也实际记录这一窗口。相对窗口只避免日期写死,不能让旧执行结果自动变新,也不保证有样本。[票 17 旧命令](../issues/17-postdeploy-live-verification.md#L104-L112)、[卡片 02 提醒](baselines/work-order-rate.md#L44-L46)、[相对窗口](baselines/work-order-rate.md#L103-L115)、[T18 固定窗口](../verify/t18-real-audit.mjs#L67-L70)、[较新历史请求](../verify/artifacts/t18/live-audit-results.json#L2141)
-- **工单口径必须复算,不锁旧数值**:旧 `0=1,5=1` 是事件回联计数,不能反推新口径全部状态 0–6 工单分母;历史新值 2/3 也不应成为恒定期望。应以同一行业/时间范围内工单基数、状态分布与分母一致及新 `basis` 核对。资料中的“分母≠事件数”是当次区分旧实现的样本证据;不同对象域在新样本中数值可能碰巧相同,不能单凭相等判失败。[历史勘误与范围](baselines/work-order-rate.md#L35-L40)、[勘误与禁止固定计数](../issues/17-postdeploy-live-verification.md#L123-L127)
-- **性能证据也依赖环境**:票 33 将 v6 的 16.7ms 与无 GPU/v8 的约 200ms 底噪明确区分;旧绝对阈值与同环境 tick 增量证据不可混用,更不能推导新机器已达标。[票 33 历史对照及限制](../issues/33-33-jitter-fix.md#L42-L46)
-
-本次报告只登记证据就绪性;由于用户限制和项目规则,已跳过 build/debug 及全部执行性验证。后续若开展新验收,应以新登记、同窗证据和独立人工记录补齐上述门槛;本报告不改变任何 issue、map、基线或源代码状态。[门槛与证据分列规则](../issues/05-integration-baseline-and-evidence.md#L39-L49)

+ 0 - 83
.scratch/cockpit-real-data-wayfinding/handoff/t18-human-takeover.md

@@ -1,83 +0,0 @@
-# 前端真实接口联调与阶段收口:真人接手清单
-
-所属工单:[前端真实接口联调与阶段收口](../issues/18-frontend-real-integration-gate.md)。本清单提供真人必须亲自完成的步骤;首次接手准备后,用户已明确要求同步取证。当前实测与版本记录见[当前预检报告](t18-current-preflight.md)。
-
-**当前尚未就绪:请先保持候选工单状态,不开始最终验收。** 实际页面入口为 `http://127.0.0.1:8000/#/cockpit`;18个只读接口预检通过,WS订阅通过,但地图因调色shader编译失败停止渲染,监测三个端点仍缺createdAt,交付运行基线尚未锁定。先由对应执行票修复与协调版本;代理复验通过、重新开启WS窗口并明确通知后,再执行下表。
-
-## 为什么需要真人
-
-判定类别:**需人工测试**,附带外部业务系统的操作权限和最终业务判断。驾驶舱只读;真实报警及工单的创建、派单、接单、处理、提交验收、结案由业务人员在外部业务系统执行。代理不能代替业务人员作真实处置,也不能替真人团队给出验收结论。技术取证、版本核对、证据整理和基线回写由代理承担。
-
-工单已认领,15 项已登记前置依赖均已关闭。关闭执行票不等于阶段已通过;本票保持 `ready-for-human` / `claimed`,目前没有新的完整真人验收结论。
-
-## 开始真人操作前:由代理或对应执行票处理
-
-这些是技术准备,不是交给用户的实施步骤:
-
-- 核定前后端仓库、分支、精确提交、Gogs 推送状态、运行产物和页面实际地址;锁定 `DATA_SOURCE.REAL`、后端 `http://192.168.110.96:8301`、REST `/api/cockpit/v1/**`、WS `/ws/deviceStatus` 与只读快照。源码 HEAD 不能单独证明服务运行的是该版本。
-- 核对[刷新引发抖动的修复交付](../issues/33-33-jitter-fix.md)是否已提交推送。地图和该票仍有“修复留在工作树”的记录;完成版本核对前,只能称其历史走查通过,不能当作已交付基线。
-- 复核监测三端点 `createdAt` 缺口。最新已读历史审计为 140/143,余 3 项未通过;空值的合法性与字段缺失须分开核对。若真实缺口仍存在,回到对应规格/执行票补齐,不能用验收签字绕过。
-- 用户已在当前对话明确要求真实只读接口、WS和浏览器取证。本轮已做GET/订阅/页面进入预检,结果和限制见[当前预检报告](t18-current-preflight.md);未运行build,未启动/重启服务,未做真人业务操作。复验和长时间同步取证继续由代理承担,不要求真人手工分析JSON、运行脚本或维护基线。
-- 同一轮取证须记录时间范围、快照与筛选参数。现有基线卡片按历史版本保留;新版本追加卡片并注明替代关系,不覆盖历史证据、不提前升级状态。
-
-## 仅需真人执行的实施步骤
-
-技术准备期间可以进行预走查;其结果须标注“预走查”,满足完整版本与证据门槛后才能作为最终验收。
-
-| 顺序 | 真人操作 | 每步检查点与验证方法 | 留给代理的结果 |
-| --- | --- | --- | --- |
-| 1 | 在代理确认的 real 页面,依次切换总体概览、基础设施、运行监测、预警处置,检查全部 16 个面板 | 数字、名称与业务认识一致;真实空态能理解;没有 `undefined`、明显空白或错误被显示成正常数据。发现不符,记录模式、面板名、可见现象和时刻;代理核对同次请求 | 每个模式通过/未通过/待验及异常现象 |
-| 2 | 走查一张图,切换行业(含窨井)、片区(含未分区)、状态多选,查看监测点、未关联设备、设施与管线,打开有位置和无位置对象的详情 | 筛选与对象身份符合业务语义;无设备点位仍在统计/台账;无位置对象不伪造位置且有原因。管网条数与设施总数不同,不混比。代理核对八桶、分页 token、计数和同次响应 | 可读性、定位、图层及筛选的真人结论 |
-| 3 | 在实际使用的有 GPU 电脑保持画面静置至少 90 秒,再做一次手动刷新、筛选和模式切换 | 目视覆盖至少两个 30s freshness tick,确认没有周期性抽搐、标记闪失、选择对象串位或面板失联。若有问题,记发生时刻与当时操作;代理做刷新窗口与性能证据对照 | 电脑/浏览器环境、观察时长、是否再现抖动 |
-| 4 | 在代理已确认取证窗口开启后,由业务人员选定一个真实报警,在外部系统创建并关联工单;按实际流程派单、接单、处理、提交验收,最终由有验收权限的人结案 | 不跳过业务必经环节。记录设备编码、报警单号、工单编号及每次动作时刻;每个阶段在驾驶舱检查同一报警、工单状态和时间线。代理同步核对 REST 与 `cockpitWorkOrderStateDelta`,真人不用解析帧 | 同一条真实链路的动作记录、页面可见结果 |
-| 5 | 由真人验收团队对本轮完整结果给出结论 | 逐模式与完整闭环分别标“通过/未通过/待验”,说明原因和可见结果。无样本、无结案权限或尚未走完的步骤一律待验;最终业务结论不能由代理代签 | 参与角色、时间、场景、结论及阻断项 |
-
-外部系统如需分角色操作,由具有相应权限的业务人员继续;不要求当前用户代行全部角色。记录角色即可,不要求实名。
-
-## 样本与当前口径
-
-- 历史正向样本:设备 `21119740`、报警 `BJ202610121103101791`、工单 `WO202610121103507623`;[关联一致性最终验收](../issues/21-21-vs.md)记录三端关联 true,状态为当时的 0/待派单。本轮须先由代理只读核对其当前状态和关联,再决定能否继续用于真实闭环。
-- 历史负向样本 `WO202610101511303516` 在后续验收时为未关联 false/null,不能沿用早期 true 记录,把它当作当前正向闭环。
-- 设施统一口径以[设施总数一致性修复](../issues/31-5278-5276-5273.md)为准:当时三端设施总数为 5283,管网集合为 5268。这些数字只属于历史快照;新一轮按同范围、同时间数据核对一致性。旧[管网人工核验说明](manual-verify-pipe-type-namespace.md)的 5278/5276/5273 分域期望已经被新裁定取代。
-- 坐标按[自动准入裁定](../issues/34-coordinate-auto-admission.md)验证:合法且在主场景区内才能上图;非法或区外保留清单及原因。取消人工校核前置后,不再要求用户寻找不存在的坐标校核入口。旧“全量 UNVERIFIED”是历史结果。
-- **状态序变化属预期(2026-10-23,票 71 收口)**:后端单设备状态序已改为「未转工单的报警 > 工单处置 > 预警」并部署复验通过(10/10,脚本 `../verify/artifacts/t71/verify-after-deploy.mjs`)。走查预期读数:未关联设备业务轴 预警 **3→1**、工单处置 **3→5**;`41010500001320000002`、`TEST-CBYL5432168` 两台由「预警」变「工单处置」(特效 blink → spin,属两态动效切换);`TEST-TCLLJ002215` 仍预警;`21119740` 点位仍报警。另一处必须解释、**不能当缺陷**:同一台设备可能「地图/设备卡=工单处置、运行监测异常设备清单=预警/离线」——这是票 71 裁定 Q3=A 的结果(situation 按设备事实判、不跟随业务序,也没有 `WORK_ORDER` 桶)。
-
-## 完成实施后的自查:代理逐项对照,真人确认业务结果
-
-| 验收与回归项 | 必须保留的证据 / 判定 |
-| --- | --- |
-| real 生命周期 | 地图、16 面板、WS、freshness 自动初始化;实际请求指向锁定后端;失败不回退 mock |
-| 地图筛选与快照 | 状态多选 OR;八桶全局计数与饼图同源;逐页携带同一个 `snapshotAt`;行业/片区/状态组合与同次响应一致 |
-| 业务词汇与时间 | MANHOLE 显“窨井”;未分区不伪造归属;`createdAt` 与 `sampledAt` 分开;监测三端点缺口真正消除或如实记未达标 |
-| 合法空态 | 无设备点位仍计数;无关联工单明确提示;分母 0 显示不适用;空列表不输出 `undefined` 或伪造数字 |
-| 坐标边界 | 缺失/非法/区外对象不上图但保留记录与原因;不补默认坐标;坐标准入不改变对象是否计入统计 |
-| 分区失败与恢复 | 真实失败只影响对应分区,不能显示为空态或正常数据;恢复后回到真实数据。若本轮没有自然故障,测试方安排受控验证;代理辅助取证,不要求用户制造业务故障 |
-| 报警—工单一致性 | 同一真实链路在设备、ALARM、时间线的关联与状态一致;创建到结案的动作与真人记录对应;处置率为状态 5 / 状态 0–6,状态 7 排除 |
-| WS 正反向 | 状态实际变化时核对对应工单增量帧和页面更新;记录实际 `scanIntervalMs` 和延迟。观察覆盖至少两个扫描周期;无帧时先核对是否真的发生状态码变化,不能仅凭“处置”动作或 `update_time` 判断故障 |
-| 状态序与观感(票 71) | 两台设备显示「工单处置」而非「预警」、业务轴 预警 1 / 工单 5;同一设备在地图与运行监测异常清单状态不同属裁定结果(Q3),不记缺陷;动效由闪烁变旋转属预期 |
-| 交互回归 | 刷新/模式切换/筛选后详情、锚点、选择对象仍一致,未复现周期性抽搐;既有 mock 回归单独记录,不算真实验收证据 |
-| 基线与最终结论 | 前后端精确版本及推送/运行证明、请求与 WS 证据、freshness、浏览器 `data-*` 断言、真人结论齐备;缺任一关键项保持未收口 |
-
-未找到真实边界样本的项标“待验”,不能从正常路径通过推断边界通过。发现功能缺陷回对应执行票修复,本票不承接代码修改。
-
-## 真人结果记录模板
-
-- 页面与设备环境:
-- 观察时间范围 / 参与角色:
-- 总体概览:通过 / 未通过 / 待验;现象:
-- 基础设施:通过 / 未通过 / 待验;现象:
-- 运行监测:通过 / 未通过 / 待验;现象:
-- 预警处置:通过 / 未通过 / 待验;现象:
-- 地图、筛选、无地图位置、刷新稳定性:
-- 设备编码 / 报警单号 / 工单编号:
-- 动作记录(动作、时刻、前后状态、页面结果):
-- 已结案:是 / 否;若否,原因与接手角色:
-- 最终业务结论:通过 / 未通过 / 待验;剩余阻断:
-
-## 不在本票范围内
-
-前后端功能开发或修复、驾驶舱写操作、替真人执行外部工单处置、目标环境 `192.168.110.30:8301` 部署、管线 glTF 素材制作和并行白模素材工作。它们不能夹入本次联调交付,但实际改变运行版本或界面时应登记基线,复验受影响项。
-
-## 收尾纪律
-
-代理收到真人结果后对照上表整理程序证据,追加基线卡片;全部条件满足才写“真实数据联调完成”,全部首阶段模式和闭环均获真人通过才写“模式整体收口”。届时在工单发 resolution comment、关闭工单、调用 `deck_issue_report` 完成上报,并在地图 Decisions so far 追加工单名称链接与一句结论。当前只完成接手准备,保持开放,不记完成决策。

+ 0 - 85
.scratch/cockpit-real-data-wayfinding/handoff/work-order-association-verification.md

@@ -1,85 +0,0 @@
-# 工单关联标记口径统一:验收通过
-
-对应 [后端执行票 —— 工单关联标记口径统一(设备维度 vs 时间线)](../issues/21-21-vs.md)。**本票验收通过**:编译、Cockpit 系列契约测试、真实三端点正向关联、旧工单负向对照均已完成。用户已明确要求再次验证并提供新真实样本;本轮未修改真实业务数据,未启动或重启服务。
-
-## 最终基线
-
-- 修复提交:[`894402a`](http://192.168.110.30:3000/shaoy/pipe-ner-server/commit/894402a6d495be28265014b316742fac355b311a),已推送且仍是当前 `origin/master` 的祖先。
-- 本轮隔离测试提交:`7fb7c2ccb62a78ef52c69eb399b0a6fc0264e112`;本票适配器与关联测试相对修复提交没有变化。独立工作树为 `E:\pipe-ner-server-verify-workorder-final`,未夹带主工作树中的并行未提交改动。
-- 联调环境:`http://192.168.110.96:8301/api/cockpit/v1`。实例 PID **36420**,启动于 `2026-10-12T11:09:02+08:00`。
-- 主工作树编译产物与隔离测试产物的本票适配器 SHA-256 完全相同:`DB89DE226BD28BD8ED7A6E967FA780CACE1EFB7A6AD02913C61D0F6ED8DF77EC`。产物写入早于本次实例启动,真实时间线已显示修复后的正向关联行为。此证据限于本票适配器,不声明整个运行实例对应一个纯净提交。
-- 结构化最终证据见 [最终验收记录](../verify/t21-work-order-association-final.json)。
-
-## 编译与契约测试
-
-用户授权后,在上述隔离工作树执行:
-
-```powershell
-mvn -o -pl pipe-network-service/zksy-admin -am '-Dtest=Cockpit*Test,BusinessCockpit*Test' '-Dsurefire.failIfNoSpecifiedTests=false' test
-```
-
-结果:**BUILD SUCCESS**,完成于 `2026-10-12T11:10:00+08:00`。58 份 Surefire XML 汇总 **428 个测试,Failures / Errors / Skipped 均为 0**;admin 模块 426,另 2 个在其它模块。
-
-本票关联契约 **7/7**、业务适配器 **3/3**、工单控制器 **9/9**。关联契约经真实适配器贯穿三个 HTTP 入口,覆盖单源关联一致、报警单号别名与查询参数、未关联 false/null、同设备工单隔离、未知工单、重复关联正反序失败,以及真实 DAO/MyBatis 异常转换。
-
-维护者裁定的一对一及重复关联失败,由契约测试验证;未为实测制造真实重复关联或数据库故障。
-
-## 真实正向验收
-
-维护者本轮提供:设备 `21119740`,工单 `WO202610121103507623`,报警单号 `BJ202610121103101791`。该样本替代已无关联的首次正向样本。
-
-`2026-10-12T11:11:17+08:00` 三端点均 HTTP 200、业务 `code=200`,目标工单唯一命中;三个入口工单状态均 **0 / 待派单**,关联标记均 **true**,完整关联对象完全一致:
-
-```json
-{
-  "eventSource": "ALARM",
-  "eventCode": "e68a942d92c48ac709fca8cee3160f3b",
-  "associatedAt": "2026-10-12T11:03:51+08:00"
-}
-```
-
-| 入口 | 目标工单 | 标记 | 关联对象比较 |
-| --- | --- | --- | --- |
-| 设备工单 | 唯一命中 | true | 三字段一致 |
-| ALARM 工单(报警单号查询) | 唯一命中 | true | 三字段一致 |
-| 工单时间线 | 唯一命中 | true | 三字段一致 |
-
-报警单号解析为其真实 ID;额外用该 ID 查询 ALARM 工单,关联字段与单号查询一致(`11:13:35` 核验)。设备共有 6 张工单,其中 5 张未关联;返回 `unassociatedWorkOrderCount=5` 与逐条布尔值重算一致,报警端点未关联计数为 0。
-
-可复制的只读复验命令:
-
-```powershell
-$base = 'http://192.168.110.96:8301/api/cockpit/v1'
-$deviceCode = '21119740'
-$orderCode = 'WO202610121103507623'
-$alarmCode = 'BJ202610121103101791'
-$device = Invoke-RestMethod "$base/equipments/$deviceCode/work-orders"
-$alarm = Invoke-RestMethod "$base/events/ALARM/$alarmCode/work-orders"
-$timeline = Invoke-RestMethod "$base/work-orders/$orderCode/timeline"
-$deviceOrder = @($device.data.workOrders | Where-Object workOrderCode -EQ $orderCode)
-$alarmOrder = @($alarm.data.workOrders | Where-Object workOrderCode -EQ $orderCode)
-if ($deviceOrder.Count -ne 1 -or $alarmOrder.Count -ne 1) { throw '目标工单缺失或重复' }
-$expected = $deviceOrder[0].eventAssociation
-foreach ($order in @($deviceOrder[0], $alarmOrder[0], $timeline.data.workOrder)) {
-    if ($order.workOrderCode -ne $orderCode -or $order.deviceCode -ne $deviceCode -or
-        $order.associatedWithCurrentEvent -ne $true -or $null -eq $order.eventAssociation -or
-        $order.eventAssociation.eventSource -ne 'ALARM' -or
-        $order.eventAssociation.eventCode -cne $expected.eventCode -or
-        $order.eventAssociation.associatedAt -cne $expected.associatedAt) {
-        throw '三端点关联标记或关联信息不一致'
-    }
-}
-if ($alarm.data.eventCode -cne $expected.eventCode) { throw '报警单号解析标识不一致' }
-```
-
-## 历史样本与负向对照
-
-首次在精确修复提交上验证:`2026-10-12 10:38`,**417/417** 测试通过,记录见 [首次验收记录](../verify/t21-work-order-association-results.json)。当时原报警查询为空,设备与时间线的原工单均 false/null,故未关票,未把一致 false 当作正向通过。
-
-维护者随后指出原工单 `WO202610101511303516` 应为待接单;`11:06` 与 `11:13` 复验确认两处均 **1 / 待接单**,关联仍为 false/null。工单流程状态与是否关联报警是两个独立事实;原工单作为负向对照通过。
-
-请求新样本期间出现一次连接拒绝,随后检测到现有实例恢复监听并完成全部只读验收。连接拒绝未被当作数据不一致,也未以旧响应冒充本轮证据。
-
-## 结论
-
-本票的实施、契约测试、正向真实三端点一致性、负向未关联显式提示及提交推送要求均满足;无需再补本票验收项。上位前端阶段的其它工单流程或界面验收仍由所属阶段票承担。

+ 0 - 26
.scratch/cockpit-real-data-wayfinding/implementation/t29/apply-patch.py

@@ -1,26 +0,0 @@
-from pathlib import Path
-import hashlib
-import json
-import subprocess
-
-root = Path(__file__).resolve().parent
-target = Path('E:/pipe-ner-server').resolve()
-assert str(target).lower().replace('\\', '/') == 'e:/pipe-ner-server'
-manifest = json.loads((root / 'manifest.json').read_text(encoding='utf-8'))
-for entry in manifest:
-    path = target / entry['path']
-    digest = hashlib.sha256(path.read_bytes()).hexdigest() if path.exists() else None
-    if digest != entry['beforeSha256']:
-        raise SystemExit('Concurrent modification; patch not applied: ' + entry['path'])
-    staged = root / entry['path']
-    if hashlib.sha256(staged.read_bytes()).hexdigest() != entry['afterSha256']:
-        raise SystemExit('Staged file changed; rebuild manifest: ' + entry['path'])
-patch = str(root / 'point-status-eight-buckets.patch')
-# Inherit stdio; no named-pipe output capture under Windows sandbox.
-subprocess.run(['git', 'apply', '--check', '--whitespace=error', patch], cwd=target, check=True)
-subprocess.run(['git', 'apply', '--whitespace=error', patch], cwd=target, check=True)
-for entry in manifest:
-    final = (target / entry['path']).read_bytes().replace(b'\r\n', b'\n')
-    expected = (root / entry['path']).read_bytes().replace(b'\r\n', b'\n')
-    assert final == expected, entry['path']
-print('Applied and verified ' + str(len(manifest)) + ' backend files; no build/debug/test commands executed.')

+ 0 - 77
.scratch/cockpit-real-data-wayfinding/implementation/t29/baseline/pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitMapSnapshotController.java

@@ -1,77 +0,0 @@
-package com.zksy.web.controller.cockpit;
-
-import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
-import com.zksy.cockpit.foundation.CockpitErrorPayload;
-import com.zksy.cockpit.foundation.CockpitReadException;
-import com.zksy.cockpit.foundation.CockpitSnapshotExpiredException;
-import com.zksy.cockpit.map.CockpitMapSnapshotResponse;
-import com.zksy.cockpit.map.CockpitMapSnapshotService;
-import com.zksy.common.core.domain.AjaxResult;
-import com.zksy.common.utils.StringUtils;
-import com.zksy.common.utils.uuid.IdUtils;
-import org.springframework.http.HttpStatus;
-import org.springframework.http.ResponseEntity;
-import org.springframework.web.bind.annotation.ExceptionHandler;
-import org.springframework.web.bind.annotation.GetMapping;
-import org.springframework.web.bind.annotation.RequestHeader;
-import org.springframework.web.bind.annotation.RequestParam;
-import org.springframework.web.bind.annotation.RestController;
-
-import javax.servlet.http.HttpServletRequest;
-
-@RestController
-public class CockpitMapSnapshotController {
-    private static final String REQUEST_ID_HEADER = "X-Request-Id";
-
-    private final CockpitMapSnapshotService mapSnapshotService;
-
-    public CockpitMapSnapshotController(CockpitMapSnapshotService mapSnapshotService) {
-        this.mapSnapshotService = mapSnapshotService;
-    }
-
-    @GetMapping("/api/cockpit/v1/map-snapshot")
-    public ResponseEntity<AjaxResult> mapSnapshot(
-            @RequestParam(value = "page", defaultValue = "1") Integer page,
-            @RequestParam(value = "pageSize", defaultValue = "500") Integer pageSize,
-            @RequestParam(value = "snapshotAt", required = false) String snapshotAt,
-            @RequestParam(value = "industry", required = false) String industry,
-            @RequestParam(value = "district", required = false) String district,
-            @RequestParam(value = "deviceType", required = false) String deviceType,
-            @RequestHeader(value = REQUEST_ID_HEADER, required = false) String requestId) {
-        CockpitMapSnapshotResponse response = mapSnapshotService.load(
-                resolveRequestId(requestId), page, pageSize, snapshotAt, industry, district, deviceType);
-        return ResponseEntity.ok(AjaxResult.success(response));
-    }
-
-    @ExceptionHandler(CockpitInvalidRequestException.class)
-    public ResponseEntity<AjaxResult> handleInvalidRequest(
-            HttpServletRequest request,
-            CockpitInvalidRequestException exception) {
-        return badRequest(mapSnapshotService.invalidRequest(
-                resolveRequestId(request.getHeader(REQUEST_ID_HEADER)),
-                exception.getMessage()));
-    }
-
-    @ExceptionHandler(CockpitSnapshotExpiredException.class)
-    public ResponseEntity<AjaxResult> handleSnapshotExpired(HttpServletRequest request) {
-        return badRequest(mapSnapshotService.snapshotExpired(
-                resolveRequestId(request.getHeader(REQUEST_ID_HEADER))));
-    }
-
-    @ExceptionHandler(CockpitReadException.class)
-    public ResponseEntity<AjaxResult> handleReadFailure(HttpServletRequest request) {
-        CockpitErrorPayload payload = mapSnapshotService.serverError(
-                resolveRequestId(request.getHeader(REQUEST_ID_HEADER)));
-        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
-                .body(new AjaxResult(HttpStatus.INTERNAL_SERVER_ERROR.value(), payload.getMessage(), payload));
-    }
-
-    private ResponseEntity<AjaxResult> badRequest(CockpitErrorPayload payload) {
-        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
-                .body(new AjaxResult(HttpStatus.BAD_REQUEST.value(), payload.getMessage(), payload));
-    }
-
-    private String resolveRequestId(String requestId) {
-        return StringUtils.isNotEmpty(requestId) ? requestId : IdUtils.fastSimpleUUID();
-    }
-}

+ 0 - 84
.scratch/cockpit-real-data-wayfinding/implementation/t29/baseline/pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitMonitoringDistributionController.java

@@ -1,84 +0,0 @@
-package com.zksy.web.controller.cockpit;
-
-import com.zksy.cockpit.foundation.CockpitErrorPayload;
-import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
-import com.zksy.cockpit.foundation.CockpitReadException;
-import com.zksy.cockpit.foundation.CockpitSnapshotExpiredException;
-import com.zksy.cockpit.monitoring.CockpitMonitoringDistributionService;
-import com.zksy.common.core.domain.AjaxResult;
-import com.zksy.common.utils.StringUtils;
-import com.zksy.common.utils.uuid.IdUtils;
-import org.springframework.http.HttpStatus;
-import org.springframework.http.ResponseEntity;
-import org.springframework.web.bind.annotation.ExceptionHandler;
-import org.springframework.web.bind.annotation.GetMapping;
-import org.springframework.web.bind.annotation.RequestHeader;
-import org.springframework.web.bind.annotation.RequestParam;
-import org.springframework.web.bind.annotation.RestController;
-
-import javax.servlet.http.HttpServletRequest;
-
-@RestController
-public class CockpitMonitoringDistributionController {
-    private static final String REQUEST_ID_HEADER = "X-Request-Id";
-
-    private final CockpitMonitoringDistributionService distributionService;
-
-    public CockpitMonitoringDistributionController(
-            CockpitMonitoringDistributionService distributionService) {
-        this.distributionService = distributionService;
-    }
-
-    @GetMapping("/api/cockpit/v1/monitoring/distribution")
-    public ResponseEntity<AjaxResult> monitoringDistribution(
-            @RequestParam(value = "industry", required = false) String industry,
-            @RequestParam(value = "deviceType", required = false) String deviceType,
-            @RequestParam(value = "pointStatus", required = false) String pointStatus,
-            // 范围维度自执行票 12 起为片区;旧自由串 region 不再是正式契约,签名里已去掉
-            @RequestParam(value = "district", required = false) String district,
-            @RequestParam(value = "page", defaultValue = "1") Integer page,
-            @RequestParam(value = "pageSize", defaultValue = "500") Integer pageSize,
-            @RequestParam(value = "snapshotAt", required = false) String snapshotAt,
-            @RequestHeader(value = REQUEST_ID_HEADER, required = false) String requestId) {
-        return ResponseEntity.ok(AjaxResult.success(
-                distributionService.load(
-                        resolveRequestId(requestId),
-                        industry,
-                        deviceType,
-                        pointStatus,
-                        district,
-                        page,
-                        pageSize,
-                        snapshotAt)));
-    }
-
-    @ExceptionHandler(CockpitInvalidRequestException.class)
-    public ResponseEntity<AjaxResult> handleInvalidRequest(
-            HttpServletRequest request,
-            CockpitInvalidRequestException exception) {
-        return error(HttpStatus.BAD_REQUEST, distributionService.invalidRequest(
-                resolveRequestId(request.getHeader(REQUEST_ID_HEADER)),
-                exception.getMessage()));
-    }
-
-    @ExceptionHandler(CockpitSnapshotExpiredException.class)
-    public ResponseEntity<AjaxResult> handleSnapshotExpired(HttpServletRequest request) {
-        return error(HttpStatus.BAD_REQUEST, distributionService.snapshotExpired(
-                resolveRequestId(request.getHeader(REQUEST_ID_HEADER))));
-    }
-
-    @ExceptionHandler(CockpitReadException.class)
-    public ResponseEntity<AjaxResult> handleReadFailure(HttpServletRequest request) {
-        return error(HttpStatus.INTERNAL_SERVER_ERROR, distributionService.serverError(
-                resolveRequestId(request.getHeader(REQUEST_ID_HEADER))));
-    }
-
-    private ResponseEntity<AjaxResult> error(HttpStatus status, CockpitErrorPayload payload) {
-        return ResponseEntity.status(status)
-                .body(new AjaxResult(status.value(), payload.getMessage(), payload));
-    }
-
-    private String resolveRequestId(String requestId) {
-        return StringUtils.isNotEmpty(requestId) ? requestId : IdUtils.fastSimpleUUID();
-    }
-}

+ 0 - 166
.scratch/cockpit-real-data-wayfinding/implementation/t29/baseline/pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitDeviceFactResolverTest.java

@@ -1,166 +0,0 @@
-package com.zksy.web.controller.cockpit;
-
-import com.baomidou.mybatisplus.core.conditions.Wrapper;
-import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
-import org.mockito.ArgumentCaptor;
-import com.zksy.base.domain.EquipmentBase;
-import com.zksy.base.domain.EquipmentStatus;
-import com.zksy.base.service.EquipmentStatusService;
-import com.zksy.base.warning.domain.EarlyWarning;
-import com.zksy.base.warning.service.EarlyWarningService;
-import com.zksy.cockpit.point.BusinessCockpitDeviceFactResolver;
-import com.zksy.cockpit.point.CockpitMonitoringPointDeviceSnapshot;
-import com.zksy.cockpit.point.CockpitMonitoringPointStatus;
-import com.zksy.cockpit.point.CockpitMonitoringPointStatusResolver;
-import com.zksy.ops.domain.WorkOrder;
-import com.zksy.ops.service.WorkOrderService;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-
-import java.math.BigDecimal;
-import java.time.LocalDateTime;
-import java.util.Collections;
-import java.util.List;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertFalse;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
-
-/**
- * 设备事实解析器的契约测试:固化「现网状态/预警/工单 → 设备事实」的口径,
- * 覆盖现网脏值(0-based 工单状态、缺状态记录、预警状态大小写)。
- */
-class BusinessCockpitDeviceFactResolverTest {
-
-    private EquipmentStatusService equipmentStatusService;
-    private EarlyWarningService earlyWarningService;
-    private WorkOrderService workOrderService;
-    private BusinessCockpitDeviceFactResolver resolver;
-
-    @BeforeEach
-    void setUp() {
-        equipmentStatusService = mock(EquipmentStatusService.class);
-        earlyWarningService = mock(EarlyWarningService.class);
-        workOrderService = mock(WorkOrderService.class);
-        resolver = new BusinessCockpitDeviceFactResolver(
-                equipmentStatusService, earlyWarningService, workOrderService);
-    }
-
-    @Test
-    void alarmStatusAndOpenWarningAndOpenWorkOrderAllResolve() {
-        when(equipmentStatusService.getByDeviceCode("DEV-1")).thenReturn(status(1, 1, 1));
-        when(earlyWarningService.list(any(Wrapper.class))).thenReturn(Collections.singletonList(warning("PENDING")));
-        when(workOrderService.list(any(Wrapper.class))).thenReturn(Collections.singletonList(order(3)));
-
-        CockpitMonitoringPointDeviceSnapshot fact = resolver.resolve(device("DEV-1"), "压力计");
-
-        assertTrue(fact.isOnline(), "online_status=1 应在线");
-        assertTrue(fact.isAlarm(), "alarm_status=1 应报警");
-        assertTrue(fact.isWarning(), "PENDING 预警应视为预警中");
-        assertTrue(fact.isWorkOrderInProgress(), "现网 0-based 的 3=处理中 应视为在途");
-        ArgumentCaptor<QueryWrapper> orderQuery = ArgumentCaptor.forClass(QueryWrapper.class);
-        verify(workOrderService).list(orderQuery.capture());
-        assertTrue(orderQuery.getValue().getSqlSegment().contains("device_id"), "工单必须按设备主键关联");
-        assertTrue(orderQuery.getValue().getParamNameValuePairs().containsValue("ID-DEV-1"));
-        assertEquals("压力计", fact.getDeviceTypeName());
-        assertEquals(new BigDecimal("110.4025"), fact.getLongitude());
-        assertEquals(new BigDecimal("28.4500"), fact.getLatitude());
-        assertEquals("UNVERIFIED", fact.getCoordinateValidation().getState());
-        assertEquals("UNVERIFIED_COORDINATE", fact.getCoordinateValidation().getReason());
-        assertFalse(fact.getCoordinateValidation().isRenderable());
-        assertEquals(CockpitMonitoringPointStatus.ALARM,
-                CockpitMonitoringPointStatusResolver.deviceStatus(fact));
-    }
-
-    @Test
-    void offlineStatusResolvesToOffline() {
-        when(equipmentStatusService.getByDeviceCode("DEV-2")).thenReturn(status(1, 0, 0));
-        when(earlyWarningService.list(any(Wrapper.class))).thenReturn(Collections.emptyList());
-        when(workOrderService.list(any(Wrapper.class))).thenReturn(Collections.emptyList());
-
-        CockpitMonitoringPointDeviceSnapshot fact = resolver.resolve(device("DEV-2"), null);
-
-        assertFalse(fact.isOnline());
-        assertEquals(CockpitMonitoringPointStatus.OFFLINE,
-                CockpitMonitoringPointStatusResolver.deviceStatus(fact));
-    }
-
-    @Test
-    void missingStatusIsReportedOfflineWithExplicitReason() {
-        when(equipmentStatusService.getByDeviceCode("DEV-3")).thenReturn(null);
-        when(earlyWarningService.list(any(Wrapper.class))).thenReturn(Collections.emptyList());
-        when(workOrderService.list(any(Wrapper.class))).thenReturn(Collections.emptyList());
-
-        CockpitMonitoringPointDeviceSnapshot fact = resolver.resolve(device("DEV-3"), null);
-
-        assertFalse(fact.isOnline(), "缺状态记录按离线");
-        assertTrue(fact.getStatusReasons().stream().anyMatch(reason -> reason.contains("无状态记录")));
-        assertEquals(CockpitMonitoringPointStatus.OFFLINE,
-                CockpitMonitoringPointStatusResolver.deviceStatus(fact));
-    }
-
-    @Test
-    void closedOrCancelledWorkOrderIsNotInProgress() {
-        when(equipmentStatusService.getByDeviceCode("DEV-4")).thenReturn(status(1, 0, 1));
-        when(earlyWarningService.list(any(Wrapper.class))).thenReturn(Collections.emptyList());
-        when(workOrderService.list(any(Wrapper.class))).thenReturn(Collections.singletonList(order(5)));
-
-        CockpitMonitoringPointDeviceSnapshot fact = resolver.resolve(device("DEV-4"), null);
-
-        assertFalse(fact.isWorkOrderInProgress(), "0-based 的 5=已结案 不在途");
-        assertEquals(CockpitMonitoringPointStatus.NORMAL,
-                CockpitMonitoringPointStatusResolver.deviceStatus(fact));
-    }
-
-    @Test
-    void warningStatusIsCaseInsensitiveAndClosedWarningIsNotWarning() {
-        when(equipmentStatusService.getByDeviceCode("DEV-5")).thenReturn(status(1, 0, 1));
-        when(earlyWarningService.list(any(Wrapper.class))).thenReturn(Collections.singletonList(warning("processing")));
-        when(workOrderService.list(any(Wrapper.class))).thenReturn(Collections.emptyList());
-
-        assertTrue(resolver.resolve(device("DEV-5"), null).isWarning(), "小写 processing 也视为预警中");
-
-        when(earlyWarningService.list(any(Wrapper.class))).thenReturn(Collections.singletonList(warning("released")));
-        assertTrue(resolver.resolve(device("DEV-5"), null).isWarning(), "RELEASED 已发布预警仍为当前预警");
-
-        when(earlyWarningService.list(any(Wrapper.class))).thenReturn(Collections.singletonList(warning("CLOSED")));
-        assertFalse(resolver.resolve(device("DEV-5"), null).isWarning(), "CLOSED 预警不算预警中");
-    }
-
-    // ---- fixtures ----
-
-    private static EquipmentBase device(String code) {
-        EquipmentBase device = new EquipmentBase();
-        device.setEquipmentId("ID-" + code);
-        device.setEquipmentCode(code);
-        device.setEquipmentName(code);
-        device.setLongitude(new BigDecimal("110.4025"));
-        device.setLatitude(new BigDecimal("28.4500"));
-        return device;
-    }
-
-    private static EquipmentStatus status(int current, int alarm, int online) {
-        EquipmentStatus status = new EquipmentStatus();
-        status.setCurrentStatus(current);
-        status.setAlarmStatus(alarm);
-        status.setOnlineStatus(online);
-        status.setStatusUpdateTime(LocalDateTime.parse("2026-09-02T09:59:50"));
-        return status;
-    }
-
-    private static EarlyWarning warning(String status) {
-        EarlyWarning warning = new EarlyWarning();
-        warning.setStatus(status);
-        return warning;
-    }
-
-    private static WorkOrder order(int status) {
-        WorkOrder order = new WorkOrder();
-        order.setOrderStatus(status);
-        return order;
-    }
-}

+ 0 - 602
.scratch/cockpit-real-data-wayfinding/implementation/t29/baseline/pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMonitoringDistributionControllerTest.java

@@ -1,602 +0,0 @@
-package com.zksy.web.controller.cockpit;
-
-import com.zksy.cockpit.foundation.CockpitReadException;
-import com.zksy.cockpit.foundation.CockpitSnapshotExpiredException;
-import com.zksy.cockpit.monitoring.CockpitMonitoringDistributionAdapter;
-import com.zksy.cockpit.monitoring.CockpitMonitoringDistributionDeviceStatusDelta;
-import com.zksy.cockpit.monitoring.CockpitMonitoringDistributionService;
-import com.zksy.cockpit.monitoring.CockpitMonitoringDistributionSnapshot;
-import com.zksy.cockpit.monitoring.CockpitMonitoringDistributionResponse;
-import com.zksy.cockpit.point.CockpitMonitoringPointDeviceSnapshot;
-import com.zksy.cockpit.point.CockpitMonitoringPointStatus;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-import org.springframework.test.web.servlet.MockMvc;
-import org.springframework.test.web.servlet.setup.MockMvcBuilders;
-import org.springframework.web.bind.annotation.DeleteMapping;
-import org.springframework.web.bind.annotation.GetMapping;
-import org.springframework.web.bind.annotation.PatchMapping;
-import org.springframework.web.bind.annotation.PostMapping;
-import org.springframework.web.bind.annotation.PutMapping;
-
-import java.lang.reflect.Method;
-import java.math.BigDecimal;
-import java.time.Clock;
-import java.time.Instant;
-import java.time.ZoneOffset;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.List;
-
-import static org.hamcrest.Matchers.hasSize;
-import static org.hamcrest.Matchers.nullValue;
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.verifyNoInteractions;
-import static org.mockito.Mockito.when;
-import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
-import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
-import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
-import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
-
-class CockpitMonitoringDistributionControllerTest {
-
-    private static final String ENDPOINT = "/api/cockpit/v1/monitoring/distribution";
-    private static final String SNAPSHOT_AT = "2026-09-02T10:00:00+08:00";
-
-    private CockpitMonitoringDistributionAdapter adapter;
-    private MockMvc mockMvc;
-    private CockpitMonitoringDistributionService service;
-
-    @BeforeEach
-    void setUp() {
-        adapter = mock(CockpitMonitoringDistributionAdapter.class);
-        service = new CockpitMonitoringDistributionService(
-                adapter,
-                Clock.fixed(Instant.parse("2026-09-02T02:00:00Z"), ZoneOffset.UTC));
-        mockMvc = MockMvcBuilders
-                .standaloneSetup(new CockpitMonitoringDistributionController(service))
-                .build();
-        when(adapter.load(null)).thenReturn(fixture());
-        when(adapter.load(SNAPSHOT_AT)).thenReturn(fixture());
-    }
-
-    @Test
-    void distributionExposesEnvelopeSixStateDistributionDistrictIndustryTypeAndMapFilter() throws Exception {
-        mockMvc.perform(get(ENDPOINT)
-                        .param("snapshotAt", SNAPSHOT_AT)
-                        .header("X-Request-Id", "monitoring-distribution-001"))
-                .andExpect(status().isOk())
-                .andExpect(jsonPath("$.code").value(200))
-                .andExpect(jsonPath("$.data.requestId").value("monitoring-distribution-001"))
-                .andExpect(jsonPath("$.data.apiVersion").value("v1"))
-                .andExpect(jsonPath("$.data.readOnly").value(true))
-                .andExpect(jsonPath("$.data.displayName").value("监测分布"))
-                .andExpect(jsonPath("$.data.timezone").value("Asia/Shanghai"))
-                .andExpect(jsonPath("$.data.pagination.page").value(1))
-                .andExpect(jsonPath("$.data.pagination.pageSize").value(500))
-                .andExpect(jsonPath("$.data.pagination.total").value(9))
-                .andExpect(jsonPath("$.data.pagination.totalPages").value(1))
-                .andExpect(jsonPath("$.data.pagination.hasMore").value(false))
-                .andExpect(jsonPath("$.data.monitoringPoints", hasSize(5)))
-                .andExpect(jsonPath("$.data.monitoringPoints[0].pointCode").value("P-DR-1"))
-                .andExpect(jsonPath("$.data.monitoringPoints[0].pointStatus").value("WARNING"))
-                .andExpect(jsonPath("$.data.monitoringPoints[1].pointCode").value("P-GAS-1"))
-                .andExpect(jsonPath("$.data.monitoringPoints[1].pointStatus").value("ALARM"))
-                .andExpect(jsonPath("$.data.monitoringPoints[1].statusReasons[0]").value("GAS-002 浓度超限"))
-                .andExpect(jsonPath("$.data.monitoringPoints[2].pointCode").value("P-GAS-2"))
-                .andExpect(jsonPath("$.data.monitoringPoints[2].pointStatus").value("OFFLINE"))
-                .andExpect(jsonPath("$.data.monitoringPoints[3].pointCode").value("P-WS-1"))
-                .andExpect(jsonPath("$.data.monitoringPoints[3].pointStatus").value("WORK_ORDER"))
-                .andExpect(jsonPath("$.data.monitoringPoints[4].pointCode").value("P-WS-2"))
-                .andExpect(jsonPath("$.data.monitoringPoints[4].pointStatus").value("EMPTY"))
-                .andExpect(jsonPath("$.data.unassociatedEquipment", hasSize(4)))
-                .andExpect(jsonPath("$.data.unassociatedEquipment[0].deviceCode").value("EQ-FREE-001"))
-                .andExpect(jsonPath("$.data.unassociatedEquipment[0].pointStatus").value("NORMAL"))
-                .andExpect(jsonPath("$.data.unassociatedEquipment[1].deviceCode").value("EQ-FREE-002"))
-                .andExpect(jsonPath("$.data.unassociatedEquipment[1].pointStatus").value("WARNING"))
-                .andExpect(jsonPath("$.data.unassociatedEquipment[2].deviceCode").value("EQ-FREE-003"))
-                .andExpect(jsonPath("$.data.unassociatedEquipment[2].pointStatus").value("OFFLINE"))
-                .andExpect(jsonPath("$.data.pointStatusDistribution", hasSize(6)))
-                .andExpect(jsonPath("$.data.pointStatusDistribution[0].status").value("ALARM"))
-                .andExpect(jsonPath("$.data.pointStatusDistribution[0].count").value(1))
-                .andExpect(jsonPath("$.data.pointStatusDistribution[1].status").value("WARNING"))
-                .andExpect(jsonPath("$.data.pointStatusDistribution[1].count").value(2))
-                .andExpect(jsonPath("$.data.pointStatusDistribution[2].status").value("WORK_ORDER"))
-                .andExpect(jsonPath("$.data.pointStatusDistribution[2].count").value(1))
-                .andExpect(jsonPath("$.data.pointStatusDistribution[3].status").value("NORMAL"))
-                .andExpect(jsonPath("$.data.pointStatusDistribution[3].count").value(2))
-                .andExpect(jsonPath("$.data.pointStatusDistribution[4].status").value("OFFLINE"))
-                .andExpect(jsonPath("$.data.pointStatusDistribution[4].count").value(2))
-                .andExpect(jsonPath("$.data.pointStatusDistribution[5].status").value("EMPTY"))
-                .andExpect(jsonPath("$.data.pointStatusDistribution[5].count").value(1))
-                .andExpect(jsonPath("$.data.districtDistribution", hasSize(3)))
-                .andExpect(jsonPath("$.data.districtDistribution[0].district").value(0))
-                .andExpect(jsonPath("$.data.districtDistribution[0].districtName").value("城南"))
-                .andExpect(jsonPath("$.data.districtDistribution[0].pointCount").value(2))
-                .andExpect(jsonPath("$.data.districtDistribution[0].deviceCount").value(5))
-                .andExpect(jsonPath("$.data.districtDistribution[1].district").value(1))
-                .andExpect(jsonPath("$.data.districtDistribution[1].districtName").value("城北"))
-                .andExpect(jsonPath("$.data.districtDistribution[1].pointCount").value(2))
-                .andExpect(jsonPath("$.data.districtDistribution[1].deviceCount").value(2))
-                .andExpect(jsonPath("$.data.districtDistribution[2].district").value(nullValue()))
-                .andExpect(jsonPath("$.data.districtDistribution[2].districtName").value("未分区"))
-                .andExpect(jsonPath("$.data.districtDistribution[2].pointCount").value(1))
-                .andExpect(jsonPath("$.data.districtDistribution[2].deviceCount").value(2))
-                .andExpect(jsonPath("$.data.industryDistribution", hasSize(3)))
-                .andExpect(jsonPath("$.data.industryDistribution[0].industry").value("GAS"))
-                .andExpect(jsonPath("$.data.industryDistribution[0].pointCount").value(2))
-                .andExpect(jsonPath("$.data.industryDistribution[0].deviceCount").value(5))
-                .andExpect(jsonPath("$.data.industryDistribution[1].industry").value("WATER_SUPPLY"))
-                .andExpect(jsonPath("$.data.industryDistribution[1].deviceCount").value(2))
-                .andExpect(jsonPath("$.data.industryDistribution[2].industry").value("DRAINAGE"))
-                .andExpect(jsonPath("$.data.industryDistribution[2].deviceCount").value(2))
-                .andExpect(jsonPath("$.data.deviceTypeDistribution", hasSize(3)))
-                .andExpect(jsonPath("$.data.deviceTypeDistribution[0].deviceType.code").value("GAS_SENSOR"))
-                .andExpect(jsonPath("$.data.deviceTypeDistribution[0].deviceCount").value(5))
-                .andExpect(jsonPath("$.data.deviceTypeDistribution[1].deviceType.code").value("SEWAGE_ENV"))
-                .andExpect(jsonPath("$.data.deviceTypeDistribution[2].deviceType.code").value("WATER_SUPPLY_SENSOR"))
-                .andExpect(jsonPath("$.data.mapFilter.industry").value(nullValue()))
-                .andExpect(jsonPath("$.data.mapFilter.deviceType").value(nullValue()))
-                .andExpect(jsonPath("$.data.mapFilter.pointStatus").value(nullValue()))
-                .andExpect(jsonPath("$.data.mapFilter.district").value(nullValue()))
-                .andExpect(jsonPath("$.data.mapFilter.districtName").value(nullValue()))
-                .andExpect(jsonPath("$.data.mapFilter.districtFilter").value("NONE"))
-                .andExpect(jsonPath("$.data.mapFilter.pointCodes", hasSize(5)))
-                .andExpect(jsonPath("$.data.mapFilter.deviceCodes", hasSize(9)))
-                .andExpect(jsonPath("$.data.excluded.unknownTypeCount").value(1))
-                .andExpect(jsonPath("$.data.excluded.typeKindMismatchCount").value(1))
-                .andExpect(jsonPath("$.data.excluded.nonFormalIndustryCount").value(1))
-                .andExpect(jsonPath("$.data.excluded.invalidCoordinateCount").value(0))
-                .andExpect(jsonPath("$.data.freshness.status").value("CURRENT"))
-                .andExpect(jsonPath("$.data.freshness.reason").value("READY"));
-    }
-
-    @Test
-    void associatedDevicesRenderAsOnePointAndPaginationMatchesStatusTotals() throws Exception {
-        mockMvc.perform(get(ENDPOINT))
-                .andExpect(status().isOk())
-                .andExpect(jsonPath("$.data.pagination.total").value(9))
-                .andExpect(jsonPath("$.data.pointStatusDistribution[0].count").value(1))
-                .andExpect(jsonPath("$.data.monitoringPoints", hasSize(5)))
-                .andExpect(jsonPath("$.data.unassociatedEquipment", hasSize(4)));
-    }
-
-    @Test
-    void industryFilterNarrowsDistributionAndMapFilter() throws Exception {
-        mockMvc.perform(get(ENDPOINT).param("industry", "GAS"))
-                .andExpect(status().isOk())
-                .andExpect(jsonPath("$.data.pagination.total").value(4))
-                .andExpect(jsonPath("$.data.monitoringPoints", hasSize(2)))
-                .andExpect(jsonPath("$.data.unassociatedEquipment", hasSize(2)))
-                .andExpect(jsonPath("$.data.mapFilter.industry").value("GAS"))
-                .andExpect(jsonPath("$.data.industryDistribution", hasSize(1)))
-                .andExpect(jsonPath("$.data.industryDistribution[0].industry").value("GAS"))
-                .andExpect(jsonPath("$.data.pointStatusDistribution[0].count").value(1))
-                .andExpect(jsonPath("$.data.pointStatusDistribution[3].count").value(2))
-                .andExpect(jsonPath("$.data.pointStatusDistribution[4].count").value(1));
-    }
-
-    @Test
-    void deviceTypeFilterNarrowsDistribution() throws Exception {
-        mockMvc.perform(get(ENDPOINT).param("deviceType", "GAS_SENSOR"))
-                .andExpect(status().isOk())
-                .andExpect(jsonPath("$.data.pagination.total").value(4))
-                .andExpect(jsonPath("$.data.mapFilter.deviceType").value("GAS_SENSOR"))
-                .andExpect(jsonPath("$.data.deviceTypeDistribution", hasSize(1)))
-                .andExpect(jsonPath("$.data.deviceTypeDistribution[0].deviceType.code").value("GAS_SENSOR"));
-    }
-
-    @Test
-    void pointStatusFilterNarrowsMarkersAndResetRestoresScope() throws Exception {
-        mockMvc.perform(get(ENDPOINT).param("pointStatus", "WARNING"))
-                .andExpect(status().isOk())
-                .andExpect(jsonPath("$.data.pagination.total").value(2))
-                .andExpect(jsonPath("$.data.mapFilter.pointStatus").value("WARNING"))
-                .andExpect(jsonPath("$.data.pointStatusDistribution[0].count").value(0))
-                .andExpect(jsonPath("$.data.pointStatusDistribution[1].count").value(2))
-                .andExpect(jsonPath("$.data.pointStatusDistribution[4].count").value(0));
-
-        mockMvc.perform(get(ENDPOINT))
-                .andExpect(status().isOk())
-                .andExpect(jsonPath("$.data.pagination.total").value(9))
-                .andExpect(jsonPath("$.data.mapFilter.pointStatus").value(nullValue()));
-    }
-
-    @Test
-    void districtFilterNarrowsDistributionAndMapFilter() throws Exception {
-        mockMvc.perform(get(ENDPOINT).param("district", "1"))
-                .andExpect(status().isOk())
-                .andExpect(jsonPath("$.data.pagination.total").value(3))
-                .andExpect(jsonPath("$.data.mapFilter.district").value(1))
-                .andExpect(jsonPath("$.data.mapFilter.districtName").value("城北"))
-                .andExpect(jsonPath("$.data.mapFilter.districtFilter").value("ASSIGNED"))
-                .andExpect(jsonPath("$.data.districtDistribution", hasSize(1)))
-                .andExpect(jsonPath("$.data.districtDistribution[0].district").value(1))
-                .andExpect(jsonPath("$.data.districtDistribution[0].districtName").value("城北"))
-                .andExpect(jsonPath("$.data.districtDistribution[0].pointCount").value(2))
-                .andExpect(jsonPath("$.data.districtDistribution[0].deviceCount").value(2))
-                .andExpect(jsonPath("$.data.industryDistribution", hasSize(2)));
-    }
-
-    @Test
-    void unassignedDistrictFilterSelectsOnlyUnassignedAndEchoesFilterFlag() throws Exception {
-        mockMvc.perform(get(ENDPOINT).param("district", "UNASSIGNED"))
-                .andExpect(status().isOk())
-                .andExpect(jsonPath("$.data.pagination.total").value(2))
-                .andExpect(jsonPath("$.data.mapFilter.district").value(nullValue()))
-                .andExpect(jsonPath("$.data.mapFilter.districtName").value(nullValue()))
-                .andExpect(jsonPath("$.data.mapFilter.districtFilter").value("UNASSIGNED"))
-                .andExpect(jsonPath("$.data.districtDistribution", hasSize(1)))
-                .andExpect(jsonPath("$.data.districtDistribution[0].district").value(nullValue()))
-                .andExpect(jsonPath("$.data.districtDistribution[0].districtName").value("未分区"))
-                .andExpect(jsonPath("$.data.districtDistribution[0].pointCount").value(1))
-                .andExpect(jsonPath("$.data.districtDistribution[0].deviceCount").value(2));
-    }
-
-    @Test
-    void invalidDistrictReturnsTypedBadRequestBeforeReadAdapterIsCalled() throws Exception {
-        mockMvc.perform(get(ENDPOINT).param("district", "城区B"))
-                .andExpect(status().isBadRequest())
-                .andExpect(jsonPath("$.code").value(400))
-                .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"))
-                .andExpect(jsonPath("$.data.message").value(
-                        "district must be 0, 1, 2 or UNASSIGNED"));
-
-        verifyNoInteractions(adapter);
-    }
-
-    @Test
-    void unknownDeviceTypeReturnsExplicitEmptyDistribution() throws Exception {
-        mockMvc.perform(get(ENDPOINT).param("deviceType", "NOT_A_TYPE"))
-                .andExpect(status().isOk())
-                .andExpect(jsonPath("$.data.pagination.total").value(0))
-                .andExpect(jsonPath("$.data.monitoringPoints", hasSize(0)))
-                .andExpect(jsonPath("$.data.pointStatusDistribution[0].count").value(0))
-                .andExpect(jsonPath("$.data.mapFilter.deviceType").value("NOT_A_TYPE"))
-                .andExpect(jsonPath("$.data.excluded.invalidCoordinateCount").value(0))
-                .andExpect(jsonPath("$.data.freshness.status").value("EMPTY"))
-                .andExpect(jsonPath("$.data.freshness.reason").value("NO_RESOLVED_DEVICES"));
-    }
-
-    @Test
-    void invalidIndustryReturnsTypedBadRequestBeforeReadAdapterIsCalled() throws Exception {
-        mockMvc.perform(get(ENDPOINT).param("industry", "WHATEVER"))
-                .andExpect(status().isBadRequest())
-                .andExpect(jsonPath("$.code").value(400))
-                .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"))
-                .andExpect(jsonPath("$.data.message").value(
-                        "industry must be GAS, WATER_SUPPLY, DRAINAGE or MANHOLE"));
-
-        verifyNoInteractions(adapter);
-    }
-
-    @Test
-    void invalidPointStatusReturnsTypedBadRequestBeforeReadAdapterIsCalled() throws Exception {
-        mockMvc.perform(get(ENDPOINT).param("pointStatus", "UNKNOWN_STATE"))
-                .andExpect(status().isBadRequest())
-                .andExpect(jsonPath("$.code").value(400))
-                .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"))
-                .andExpect(jsonPath("$.data.message").value(
-                        "pointStatus must be ALARM, WARNING, WORK_ORDER, NORMAL, OFFLINE or EMPTY"));
-
-        verifyNoInteractions(adapter);
-    }
-
-    @Test
-    void invalidPaginationReturnsTypedBadRequestBeforeReadAdapterIsCalled() throws Exception {
-        mockMvc.perform(get(ENDPOINT)
-                        .param("page", "0")
-                        .param("pageSize", "1001"))
-                .andExpect(status().isBadRequest())
-                .andExpect(jsonPath("$.code").value(400))
-                .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"))
-                .andExpect(jsonPath("$.data.message").value("监测分布分页参数不合法"));
-
-        verifyNoInteractions(adapter);
-    }
-
-    @Test
-    void invalidSnapshotTimestampReturnsTypedBadRequestBeforeReadAdapterIsCalled() throws Exception {
-        mockMvc.perform(get(ENDPOINT).param("snapshotAt", "not-a-timestamp"))
-                .andExpect(status().isBadRequest())
-                .andExpect(jsonPath("$.code").value(400))
-                .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"))
-                .andExpect(jsonPath("$.data.message").value("监测分布分页参数不合法"));
-
-        verifyNoInteractions(adapter);
-    }
-
-    @Test
-    void expiredSnapshotReturnsTypedBadRequestAndDoesNotPretendToBeEmpty() throws Exception {
-        when(adapter.load("2026-09-02T09:00:00+08:00"))
-                .thenThrow(new CockpitSnapshotExpiredException("snapshot expired"));
-
-        mockMvc.perform(get(ENDPOINT).param("snapshotAt", "2026-09-02T09:00:00+08:00"))
-                .andExpect(status().isBadRequest())
-                .andExpect(jsonPath("$.code").value(400))
-                .andExpect(jsonPath("$.data.category").value("SNAPSHOT_EXPIRED"))
-                .andExpect(jsonPath("$.data.message").value("地图快照已失效,请重新加载"));
-    }
-
-    @Test
-    void serverFailureReturnsTypedServerError() throws Exception {
-        when(adapter.load(null)).thenThrow(new CockpitReadException("upstream unavailable"));
-
-        mockMvc.perform(get(ENDPOINT).header("X-Request-Id", "monitoring-distribution-500"))
-                .andExpect(status().isInternalServerError())
-                .andExpect(jsonPath("$.code").value(500))
-                .andExpect(jsonPath("$.data.category").value("SERVER_ERROR"))
-                .andExpect(jsonPath("$.data.message").value("驾驶舱监测分布数据暂不可用"))
-                .andExpect(jsonPath("$.data.requestId").value("monitoring-distribution-500"));
-    }
-
-    @Test
-    void mutatingRequestIsRejectedBeforeReadAdapterIsCalled() throws Exception {
-        mockMvc.perform(post(ENDPOINT))
-                .andExpect(status().isMethodNotAllowed());
-
-        verifyNoInteractions(adapter);
-    }
-
-    @Test
-    void controllerOnlyExposesAuthenticatedReadOnlyOperations() {
-        assertTrue(Arrays.stream(CockpitMonitoringDistributionController.class.getDeclaredMethods())
-                .filter(this::hasRequestMappingAnnotation)
-                .allMatch(CockpitMonitoringDistributionControllerTest::isReadOnlyMappedMethod));
-    }
-
-    @Test
-    void webSocketDeltaRecomputesStatusCountsAndIgnoresOldOrUnknownDeltas() {
-        CockpitMonitoringDistributionResponse initial = service.load(
-                "delta-baseline", null, null, null, null, 1, 100, SNAPSHOT_AT);
-        assertEquals(2, statusCount(initial, CockpitMonitoringPointStatus.OFFLINE));
-        assertEquals(2, statusCount(initial, CockpitMonitoringPointStatus.NORMAL));
-
-        CockpitMonitoringDistributionResponse applied = service.applyDeviceStatusDelta(
-                "delta-1", null, null, null, null, 1, 100, SNAPSHOT_AT,
-                new CockpitMonitoringDistributionDeviceStatusDelta(
-                        "GAS-003", null, "ONLINE", "2026-09-02T09:59:00+08:00",
-                        "2026-09-02T10:05:00+08:00"));
-
-        assertEquals(1, statusCount(applied, CockpitMonitoringPointStatus.OFFLINE));
-        assertEquals(3, statusCount(applied, CockpitMonitoringPointStatus.NORMAL));
-
-        CockpitMonitoringDistributionResponse ignoredOld = service.applyDeviceStatusDelta(
-                "delta-2", null, null, null, null, 1, 100, SNAPSHOT_AT,
-                new CockpitMonitoringDistributionDeviceStatusDelta(
-                        "GAS-003", null, "OFFLINE", "2026-09-02T09:59:00+08:00",
-                        "2026-09-02T10:04:00+08:00"));
-
-        assertEquals(1, statusCount(ignoredOld, CockpitMonitoringPointStatus.OFFLINE));
-        assertEquals(3, statusCount(ignoredOld, CockpitMonitoringPointStatus.NORMAL));
-
-        CockpitMonitoringDistributionResponse ignoredUnknown = service.applyDeviceStatusDelta(
-                "delta-3", null, null, null, null, 1, 100, SNAPSHOT_AT,
-                new CockpitMonitoringDistributionDeviceStatusDelta(
-                        "NO-SUCH-DEVICE", null, "ONLINE", "2026-09-02T09:59:00+08:00",
-                        "2026-09-02T10:06:00+08:00"));
-
-        assertEquals(1, statusCount(ignoredUnknown, CockpitMonitoringPointStatus.OFFLINE));
-        assertEquals(3, statusCount(ignoredUnknown, CockpitMonitoringPointStatus.NORMAL));
-    }
-
-    @Test
-    void sixEmptyUntypedPointsRemainInLedgerAndStatusTotalsWithoutVerifiedCoordinates() throws Exception {
-        List<CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint> points = new java.util.ArrayList<>();
-        for (String code : Arrays.asList("GS001", "GS002", "PS001", "PS002", "RQ001", "RQ002")) {
-            points.add(new CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint(
-                    code, code, null, null, new BigDecimal("110.399876"), new BigDecimal("28.458287"),
-                    false, Collections.emptyList()));
-        }
-        CockpitMonitoringDistributionSnapshot snapshot = new CockpitMonitoringDistributionSnapshot(
-                SNAPSHOT_AT, SNAPSHOT_AT, Collections.emptyList(), points, Collections.emptyList());
-        when(adapter.load(null)).thenReturn(snapshot);
-        when(adapter.load(SNAPSHOT_AT)).thenReturn(snapshot);
-
-        mockMvc.perform(get(ENDPOINT).param("pointStatus", "EMPTY").param("pageSize", "2"))
-                .andExpect(status().isOk())
-                .andExpect(jsonPath("$.data.pagination.total").value(6))
-                .andExpect(jsonPath("$.data.pagination.totalPages").value(3))
-                .andExpect(jsonPath("$.data.pagination.hasMore").value(true))
-                .andExpect(jsonPath("$.data.monitoringPoints", hasSize(2)))
-                .andExpect(jsonPath("$.data.monitoringPoints[0].pointCode").value("GS001"))
-                .andExpect(jsonPath("$.data.monitoringPoints[0].pointStatus").value("EMPTY"))
-                .andExpect(jsonPath("$.data.monitoringPoints[0].industry").value(nullValue()))
-                .andExpect(jsonPath("$.data.monitoringPoints[0].deviceType.code").value(nullValue()))
-                .andExpect(jsonPath("$.data.monitoringPoints[0].coordinateValidation.renderable").value(false))
-                .andExpect(jsonPath("$.data.pointStatusDistribution[5].count").value(6))
-                .andExpect(jsonPath("$.data.districtDistribution[0].districtName").value("未分区"))
-                .andExpect(jsonPath("$.data.districtDistribution[0].pointCount").value(6))
-                .andExpect(jsonPath("$.data.industryDistribution[0].industry").value(nullValue()))
-                .andExpect(jsonPath("$.data.industryDistribution[0].pointCount").value(6))
-                .andExpect(jsonPath("$.data.deviceTypeDistribution[0].deviceType.code").value(nullValue()))
-                .andExpect(jsonPath("$.data.mapFilter.pointCodes", hasSize(6)))
-                .andExpect(jsonPath("$.data.mapFilter.deviceCodes", hasSize(0)))
-                .andExpect(jsonPath("$.data.excluded.unknownTypeCount").value(0))
-                .andExpect(jsonPath("$.data.excluded.invalidCoordinateCount").value(0))
-                .andExpect(jsonPath("$.data.freshness.status").value("CURRENT"))
-                .andExpect(jsonPath("$.data.freshness.reason").value("READY"));
-        mockMvc.perform(get(ENDPOINT).param("page", "3").param("pageSize", "2").param("snapshotAt", SNAPSHOT_AT))
-                .andExpect(status().isOk())
-                .andExpect(jsonPath("$.data.pagination.total").value(6))
-                .andExpect(jsonPath("$.data.pagination.hasMore").value(false))
-                .andExpect(jsonPath("$.data.monitoringPoints[0].pointCode").value("RQ001"));
-        mockMvc.perform(get(ENDPOINT).param("district", "UNASSIGNED"))
-                .andExpect(status().isOk()).andExpect(jsonPath("$.data.pagination.total").value(6));
-        for (String[] filter : Arrays.asList(new String[]{"industry", "GAS"},
-                new String[]{"deviceType", "GAS_SENSOR"}, new String[]{"district", "0"},
-                new String[]{"pointStatus", "NORMAL"})) {
-            mockMvc.perform(get(ENDPOINT).param(filter[0], filter[1]))
-                    .andExpect(status().isOk()).andExpect(jsonPath("$.data.pagination.total").value(0));
-        }
-    }
-
-    @Test
-    void coordinatesDoNotAffectStatisticsAndAssociatedDevicesNeverBecomeStandaloneMarkers() {
-        CockpitMonitoringPointDeviceSnapshot bound = device(
-                "BOUND", "关联设备", true, true, false, false, Collections.emptyList());
-        CockpitMonitoringDistributionSnapshot snapshot = new CockpitMonitoringDistributionSnapshot(
-                SNAPSHOT_AT, SNAPSHOT_AT, fixture().getTypes(),
-                Collections.singletonList(new CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint(
-                        "P", "点位", 0, "GAS_SENSOR", null, null, false, Collections.singletonList(bound))),
-                Arrays.asList(smartDevice("BOUND", "关联设备", 1, "GAS_SENSOR", "110.4", "28.4", false,
-                                true, true, false, false, Collections.emptyList()),
-                        smartDevice("FREE", "独立设备", 1, "GAS_SENSOR", "181", "28.4", true,
-                                true, false, false, false, Collections.emptyList())));
-        when(adapter.load(null)).thenReturn(snapshot);
-        CockpitMonitoringDistributionResponse all = service.load("all", null, null, null, null, 1, 100, null);
-        assertEquals(2, all.getPagination().getTotal());
-        assertEquals(1, all.getUnassociatedEquipment().size());
-        assertEquals("FREE", all.getUnassociatedEquipment().get(0).getDeviceCode());
-        assertTrue(!all.getMonitoringPoints().get(0).getCoordinateValidation().isRenderable());
-        assertTrue(!all.getUnassociatedEquipment().get(0).getCoordinateValidation().isRenderable());
-        assertEquals(0, all.getExcluded().getInvalidCoordinateCount());
-        assertEquals("READY", all.getFreshness().getReason());
-        assertEquals(1, statusCount(all, CockpitMonitoringPointStatus.ALARM));
-        assertEquals(1, statusCount(all, CockpitMonitoringPointStatus.NORMAL));
-
-        CockpitMonitoringDistributionResponse normal = service.load("normal", null, null, "NORMAL", null, 1, 100, null);
-        assertEquals(Collections.singletonList("FREE"), normal.getMapFilter().getDeviceCodes());
-        assertTrue(normal.getMapFilter().getPointCodes().isEmpty());
-        CockpitMonitoringDistributionResponse district = service.load("district", null, null, null, "1", 1, 100, null);
-        assertEquals(1, district.getPagination().getTotal(), "被筛掉的点位不能让关联设备变成独立设备");
-        assertEquals(Collections.singletonList("FREE"), district.getMapFilter().getDeviceCodes());
-    }
-
-    @Test
-    void exclusionsCountEachRejectedObjectOnceRegardlessOfAssociatedDeviceCount() {
-        CockpitMonitoringPointDeviceSnapshot d = device("D", "设备", true, false, false, false, Collections.emptyList());
-        CockpitMonitoringDistributionSnapshot snapshot = new CockpitMonitoringDistributionSnapshot(
-                SNAPSHOT_AT, SNAPSHOT_AT, fixture().getTypes(), Arrays.asList(
-                        point("EMPTY", "合法空点", null, null, "110.4", "28.4"),
-                        point("UNKNOWN", "未知类型", 0, "LEGACY", "110.4", "28.4", d, d, d),
-                        point("MISMATCH", "错种类", 0, "WATER_PLANT", "110.4", "28.4", d, d),
-                        point("NONFORMAL", "无行业", 0, "SMART_MANHOLE", "110.4", "28.4", d)), Arrays.asList(
-                        smartDevice("U", "未知", 0, "LEGACY", "110.4", "28.4", true, true, false, false, false, Collections.emptyList()),
-                        smartDevice("M", "错种类", 0, "WATER_PLANT", "110.4", "28.4", true, true, false, false, false, Collections.emptyList()),
-                        smartDevice("N", "无行业", 0, "SMART_MANHOLE", "110.4", "28.4", true, true, false, false, false, Collections.emptyList())));
-        when(adapter.load(null)).thenReturn(snapshot);
-        CockpitMonitoringDistributionResponse response = service.load("reconcile", null, null, null, null, 1, 100, null);
-        assertEquals(1, response.getPagination().getTotal());
-        assertEquals(1, statusCount(response, CockpitMonitoringPointStatus.EMPTY));
-        assertEquals(2, response.getExcluded().getUnknownTypeCount());
-        assertEquals(2, response.getExcluded().getTypeKindMismatchCount());
-        assertEquals(2, response.getExcluded().getNonFormalIndustryCount());
-        int excluded = response.getExcluded().getUnknownTypeCount() + response.getExcluded().getTypeKindMismatchCount()
-                + response.getExcluded().getNonFormalIndustryCount() + response.getExcluded().getInvalidCoordinateCount();
-        assertEquals(7, response.getPagination().getTotal() + excluded);
-    }
-
-    private int statusCount(CockpitMonitoringDistributionResponse response,
-            CockpitMonitoringPointStatus status) {
-        return response.getPointStatusDistribution()
-                .get(status.ordinal())
-                .getCount();
-    }
-
-    private boolean hasRequestMappingAnnotation(Method method) {
-        return method.isAnnotationPresent(org.springframework.web.bind.annotation.RequestMapping.class)
-                || method.isAnnotationPresent(GetMapping.class)
-                || method.isAnnotationPresent(PostMapping.class)
-                || method.isAnnotationPresent(PutMapping.class)
-                || method.isAnnotationPresent(PatchMapping.class)
-                || method.isAnnotationPresent(DeleteMapping.class);
-    }
-
-    private static boolean isReadOnlyMappedMethod(Method method) {
-        return method.isAnnotationPresent(GetMapping.class)
-                && !method.isAnnotationPresent(PostMapping.class)
-                && !method.isAnnotationPresent(PutMapping.class)
-                && !method.isAnnotationPresent(PatchMapping.class)
-                && !method.isAnnotationPresent(DeleteMapping.class);
-    }
-
-    private static CockpitMonitoringDistributionSnapshot fixture() {
-        return new CockpitMonitoringDistributionSnapshot(
-                SNAPSHOT_AT,
-                "2026-09-02T09:59:50+08:00",
-                Arrays.asList(
-                        new CockpitMonitoringDistributionSnapshot.SourceType(
-                                "GAS_SENSOR", "燃气浓度计", "GAS", "SMART_DEVICE"),
-                        new CockpitMonitoringDistributionSnapshot.SourceType(
-                                "WATER_SUPPLY_SENSOR", "供水压力计", "WATER_SUPPLY", "SMART_DEVICE"),
-                        new CockpitMonitoringDistributionSnapshot.SourceType(
-                                "SEWAGE_ENV", "污水环境监测", "DRAINAGE", "SMART_DEVICE"),
-                        new CockpitMonitoringDistributionSnapshot.SourceType(
-                                "WATER_PLANT", "水厂", "WATER_SUPPLY", "FACILITY"),
-                        new CockpitMonitoringDistributionSnapshot.SourceType(
-                                "SMART_MANHOLE", "智能井盖", null, "SMART_DEVICE")),
-                Arrays.asList(
-                        point("P-WS-1", "供水监测点-1", 1, "WATER_SUPPLY_SENSOR",
-                                "110.150", "28.460", device("WS-001", "供水压力计-001",
-                                        true, false, false, true, Collections.emptyList())),
-                        point("P-WS-2", "供水监测点-2", 1, "WATER_SUPPLY_SENSOR",
-                                "110.151", "28.461"),
-                        point("P-GAS-1", "燃气监测点-1", 0, "GAS_SENSOR",
-                                "110.130", "28.440",
-                                device("GAS-001", "燃气浓度计-001",
-                                        true, false, false, false, Collections.emptyList()),
-                                device("GAS-002", "燃气浓度计-002",
-                                        true, true, false, false, Collections.singletonList("浓度超限"))),
-                        point("P-GAS-2", "燃气监测点-2", 0, "GAS_SENSOR",
-                                "110.131", "28.441", device("GAS-003", "燃气浓度计-003",
-                                        false, false, false, false, Collections.emptyList())),
-                        // 快照里点位片区为 null(如实未分区)→ 与脏值设备一起落入未分区桶
-                        point("P-DR-1", "污水监测点-1", null, "SEWAGE_ENV",
-                                "110.170", "28.480", device("DRAIN-001", "污水环境监测仪-001",
-                                        true, false, true, false, Collections.singletonList("浓度预警"))),
-                        point("P-MISMATCH", "水厂设施点", 0, "WATER_PLANT",
-                                "110.120", "28.430", device("MISMATCH-001", "水厂设备",
-                                        true, false, false, false, Collections.emptyList())),
-                        point("P-UNKNOWN", "未知类型点", 0, "LEGACY",
-                                "110.121", "28.431", device("UNKNOWN-001", "旧设备",
-                                        true, false, false, false, Collections.emptyList()))),
-                Arrays.asList(
-                        // 脏值片区(7)不得冒充合法片区,归一后并入未分区
-                        smartDevice("EQ-FREE-003", "压力井盖外设备", 7, "WATER_SUPPLY_SENSOR",
-                                "110.171", "28.481", true,
-                                false, false, false, false, Arrays.asList("离线")),
-                        smartDevice("EQ-FREE-001", "井盖外燃气计", 0, "GAS_SENSOR",
-                                "110.132", "28.442", true,
-                                true, false, false, false, Collections.emptyList()),
-                        smartDevice("EQ-FREE-002", "污水环境监测-独立", 1, "SEWAGE_ENV",
-                                "110.152", "28.462", true,
-                                true, false, true, false, Collections.singletonList("设备预警")),
-                        smartDevice("EQ-FREE-004", "智能井盖独立", 0, "SMART_MANHOLE",
-                                "110.133", "28.443", true,
-                                true, false, false, false, Collections.emptyList()),
-                        smartDevice("EQ-FREE-005", "越界坐标设备", 0, "GAS_SENSOR",
-                                "181", "28.444", true,
-                                true, false, false, false, Collections.emptyList())));
-    }
-
-    private static CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint point(
-            String code, String name, Integer district, String typeCode,
-            String longitude, String latitude, CockpitMonitoringPointDeviceSnapshot... devices) {
-        return new CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint(
-                code, name, district, typeCode,
-                new BigDecimal(longitude), new BigDecimal(latitude), true,
-                Arrays.asList(devices));
-    }
-
-    private static CockpitMonitoringPointDeviceSnapshot device(
-            String code, String name, boolean online, boolean alarm, boolean warning,
-            boolean workOrderInProgress, List<String> statusReasons) {
-        return new CockpitMonitoringPointDeviceSnapshot(
-                code, name, name, online, alarm, warning, workOrderInProgress,
-                statusReasons, SNAPSHOT_AT);
-    }
-
-    private static CockpitMonitoringDistributionSnapshot.SourceSmartDevice smartDevice(
-            String code, String name, Integer district, String typeCode,
-            String longitude, String latitude, boolean verified,
-            boolean online, boolean alarm, boolean warning, boolean workOrderInProgress,
-            List<String> statusReasons) {
-        return new CockpitMonitoringDistributionSnapshot.SourceSmartDevice(
-                code, name, district, typeCode,
-                new BigDecimal(longitude), new BigDecimal(latitude), verified,
-                online, alarm, warning, workOrderInProgress, statusReasons, SNAPSHOT_AT);
-    }
-}

+ 0 - 0
.scratch/cockpit-real-data-wayfinding/implementation/t29/baseline/pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitPointStatusContractTest.java


+ 0 - 0
.scratch/cockpit-real-data-wayfinding/implementation/t29/baseline/pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitPointStatusSnapshotSerializationTest.java


+ 0 - 296
.scratch/cockpit-real-data-wayfinding/implementation/t29/baseline/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/BusinessCockpitMapSnapshotAdapter.java

@@ -1,296 +0,0 @@
-package com.zksy.cockpit.map;
-
-import com.zksy.base.domain.EquipmentBase;
-import com.zksy.base.domain.EquipmentType;
-import com.zksy.base.domain.MonitorPoint;
-import com.zksy.base.service.EquipmentBaseService;
-import com.zksy.base.service.EquipmentTypeService;
-import com.zksy.base.service.MonitorPointService;
-import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
-import com.zksy.cockpit.foundation.CockpitReadException;
-import com.zksy.cockpit.foundation.CockpitSnapshotExpiredException;
-import com.zksy.cockpit.foundation.CockpitSnapshotTime;
-import com.zksy.cockpit.foundation.CockpitTypeIndustry;
-import com.zksy.cockpit.point.BusinessCockpitDeviceFactResolver;
-import com.zksy.cockpit.foundation.CockpitDistrict;
-import com.zksy.cockpit.point.CockpitMonitoringPointDeviceSnapshot;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.context.annotation.Primary;
-import org.springframework.data.redis.core.RedisTemplate;
-import org.springframework.stereotype.Component;
-
-import java.time.Clock;
-import java.time.DateTimeException;
-import java.time.Duration;
-import java.time.OffsetDateTime;
-import java.time.ZoneId;
-import java.time.format.DateTimeFormatter;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.LinkedHashMap;
-import java.util.LinkedHashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Objects;
-import java.util.Set;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.ConcurrentHashMap;
-import java.util.concurrent.ConcurrentMap;
-import java.util.stream.Collectors;
-
-/**
- * 地图快照的真实数据适配器(BT-5 纵向样板)。
- *
- * <p>组合既有表与服务(不新增表、不加投影):
- * <ul>
- *   <li>点位:{@code monitor_point}(MyBatis-Plus 逻辑删除自动过滤 {@code del_flag});</li>
- *   <li>点位→设备:{@code equipment_point_rel}(经 {@link EquipmentBaseService#getEquipmentByPoint});</li>
- *   <li>设备状态:{@code equipment_status}(在线/报警/当前状态);</li>
- *   <li>预警:{@code early_warning}(按 {@code device_code},未关闭即视为预警中);</li>
- *   <li>在途工单:{@code work_order}(注意现网状态是 **0-based**:0 待派单 … 5 已结案、7 已取消);</li>
- *   <li>类型字典:{@code equipment_type}(行业由 {@code parent_id} 根推导:1 供水、2 排水、3 燃气、4 窨井)。</li>
- * </ul>
- *
- * <p>无 {@code snapshotAt} 的首屏请求固定首次读取结果;显式 token 只查询已签发的版本,
- * 缺失或过期时拒绝,不用当前数据冒充旧快照。地图与详情端点通过共享快照存储复用该版本,
- * 进程本地缓存只用于减少 Redis 访问,不负责定义跨实例一致性。
- *
- * <p><b>如实接线,不臆造</b>:现网没有「坐标是否已校核」字段,故一律按未校核(
- * {@code coordinateVerified=false})上报,由冻结字典裁决为不可渲染;不为了「图上有东西」而伪造校验状态。
- * 现网 {@code monitor_point} 也没有行业/类型列,点位的 {@code typeCode} 只能在其关联设备类型唯一时推导。
- */
-@Component
-@Primary
-public class BusinessCockpitMapSnapshotAdapter implements CockpitMapSnapshotAdapter {
-
-    private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
-    private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
-    private static final Duration SNAPSHOT_RETENTION = Duration.ofMinutes(10);
-    private static final int MAX_CACHED_SNAPSHOTS = 256;
-
-    private final MonitorPointService monitorPointService;
-    private final EquipmentBaseService equipmentBaseService;
-    private final EquipmentTypeService equipmentTypeService;
-    private final BusinessCockpitDeviceFactResolver deviceFactResolver;
-    private final Clock clock;
-    private final RedisTemplate<Object, Object> redisTemplate;
-    private final ConcurrentMap<String, CockpitMapSnapshot> snapshots = new ConcurrentHashMap<>();
-
-    @Autowired
-    public BusinessCockpitMapSnapshotAdapter(
-            MonitorPointService monitorPointService,
-            EquipmentBaseService equipmentBaseService,
-            EquipmentTypeService equipmentTypeService,
-            BusinessCockpitDeviceFactResolver deviceFactResolver,
-            Clock clock,
-            RedisTemplate<Object, Object> redisTemplate) {
-        this.monitorPointService = monitorPointService;
-        this.equipmentBaseService = equipmentBaseService;
-        this.equipmentTypeService = equipmentTypeService;
-        this.deviceFactResolver = deviceFactResolver;
-        this.clock = clock;
-        this.redisTemplate = redisTemplate;
-    }
-
-    public BusinessCockpitMapSnapshotAdapter(
-            MonitorPointService monitorPointService,
-            EquipmentBaseService equipmentBaseService,
-            EquipmentTypeService equipmentTypeService,
-            BusinessCockpitDeviceFactResolver deviceFactResolver,
-            Clock clock) {
-        this(monitorPointService, equipmentBaseService, equipmentTypeService, deviceFactResolver, clock, null);
-    }
-
-    @Override
-    public CockpitMapSnapshot load(String snapshotAt) {
-        String resolvedSnapshotAt = snapshotAt == null ? businessTime() : snapshotAt;
-        OffsetDateTime requestedAt = parseSnapshotAt(resolvedSnapshotAt);
-        OffsetDateTime now = OffsetDateTime.now(clock);
-        validateSnapshotWindow(requestedAt, now, "snapshot timestamp outside retention window");
-
-        snapshots.keySet().removeIf(cachedAt ->
-                !OffsetDateTime.parse(cachedAt).isAfter(now.minus(SNAPSHOT_RETENTION)));
-        CockpitMapSnapshot cached = snapshots.get(resolvedSnapshotAt);
-        if (cached != null) {
-            validateSnapshotWindow(requestedAt, OffsetDateTime.now(clock), "snapshot timestamp expired during load");
-            return cached;
-        }
-
-        String cacheKey = "cockpit:map-snapshot:" + resolvedSnapshotAt;
-        if (redisTemplate != null) {
-            try {
-                CockpitMapSnapshot shared = (CockpitMapSnapshot) redisTemplate.opsForValue().get(cacheKey);
-                if (shared != null) {
-                    cacheLocally(resolvedSnapshotAt, shared);
-                    validateSnapshotWindow(requestedAt, OffsetDateTime.now(clock),
-                            "snapshot timestamp expired during load");
-                    return shared;
-                }
-            } catch (CockpitSnapshotExpiredException exception) {
-                throw exception;
-            } catch (RuntimeException exception) {
-                throw new CockpitReadException("snapshot cache unavailable");
-            }
-        }
-        if (snapshotAt != null) {
-            throw new CockpitSnapshotExpiredException("snapshot not found");
-        }
-        if (redisTemplate == null && snapshots.size() >= MAX_CACHED_SNAPSHOTS) {
-            throw new CockpitReadException("snapshot cache capacity reached");
-        }
-
-        CockpitMapSnapshot built = buildSnapshot(resolvedSnapshotAt);
-        OffsetDateTime writeTime = OffsetDateTime.now(clock);
-        validateSnapshotWindow(requestedAt, writeTime, "snapshot timestamp expired during load");
-        if (redisTemplate != null) {
-            try {
-                Duration remaining = Duration.between(writeTime, requestedAt.plus(SNAPSHOT_RETENTION));
-                long ttlSeconds = Math.max(1,
-                        remaining.getSeconds() + (remaining.getNano() > 0 ? 1 : 0));
-                Boolean stored = redisTemplate.opsForValue()
-                        .setIfAbsent(cacheKey, built, ttlSeconds, TimeUnit.SECONDS);
-                if (!Boolean.TRUE.equals(stored)) {
-                    CockpitMapSnapshot shared = (CockpitMapSnapshot) redisTemplate.opsForValue().get(cacheKey);
-                    if (shared == null) {
-                        throw new CockpitReadException("snapshot cache unavailable");
-                    }
-                    validateSnapshotWindow(requestedAt, OffsetDateTime.now(clock),
-                            "snapshot timestamp expired during load");
-                    built = shared;
-                }
-            } catch (CockpitSnapshotExpiredException | CockpitReadException exception) {
-                throw exception;
-            } catch (RuntimeException exception) {
-                throw new CockpitReadException("snapshot cache unavailable");
-            }
-        }
-        cacheLocally(resolvedSnapshotAt, built);
-        CockpitMapSnapshot result = snapshots.getOrDefault(resolvedSnapshotAt, built);
-        validateSnapshotWindow(requestedAt, OffsetDateTime.now(clock),
-                "snapshot timestamp expired during load");
-        return result;
-    }
-
-    private static OffsetDateTime parseSnapshotAt(String value) {
-        try {
-            return CockpitSnapshotTime.parseBusinessOffset(value);
-        } catch (DateTimeException exception) {
-            throw new CockpitInvalidRequestException("snapshot timestamp must use ISO-8601 +08:00");
-        }
-    }
-
-    private void cacheLocally(String snapshotAt, CockpitMapSnapshot snapshot) {
-        synchronized (snapshots) {
-            if (snapshots.containsKey(snapshotAt) || snapshots.size() < MAX_CACHED_SNAPSHOTS) {
-                snapshots.putIfAbsent(snapshotAt, snapshot);
-            }
-        }
-    }
-    private static void validateSnapshotWindow(
-            OffsetDateTime requestedAt, OffsetDateTime now, String message) {
-        if (!requestedAt.isAfter(now.minus(SNAPSHOT_RETENTION))
-                || !requestedAt.isBefore(now.plus(SNAPSHOT_RETENTION))) {
-            throw new CockpitSnapshotExpiredException(message);
-        }
-    }
-
-    private CockpitMapSnapshot buildSnapshot(String resolvedSnapshotAt) {
-
-        Map<Integer, EquipmentType> types = equipmentTypes();
-        List<MonitorPoint> points = safeList(monitorPointService.list());
-
-        Map<String, EquipmentBase> deviceIndex = new LinkedHashMap<>();
-        for (EquipmentBase device : safeList(equipmentBaseService.list())) {
-            if (device != null && !isBlank(device.getEquipmentCode())) {
-                deviceIndex.put(device.getEquipmentCode().trim(), device);
-            }
-        }
-
-        List<CockpitMapSnapshot.SourceMonitoringPoint> sourcePoints = new ArrayList<>();
-        for (MonitorPoint point : points) {
-            if (point == null || isBlank(point.getPointCode())) {
-                continue;
-            }
-            List<EquipmentBase> boundDevices = safeList(
-                    equipmentBaseService.getEquipmentByPoint(point.getPointId()));
-            List<CockpitMonitoringPointDeviceSnapshot> devices = new ArrayList<>();
-            Set<String> deviceTypes = new LinkedHashSet<>();
-            List<Integer> boundDistricts = new ArrayList<>();
-            for (EquipmentBase device : boundDevices) {
-                if (device == null || isBlank(device.getEquipmentCode())) {
-                    continue;
-                }
-                EquipmentType boundType = device.getEquipmentTypeId() == null
-                        ? null
-                        : types.get(device.getEquipmentTypeId());
-                String typeName = boundType == null ? null : boundType.getTypeName();
-                devices.add(deviceFactResolver.resolve(device, typeName));
-                if (boundType != null && boundType.getId() != null) {
-                    deviceTypes.add(String.valueOf(boundType.getId()));
-                }
-                boundDistricts.add(device.getDistrict());
-            }
-            sourcePoints.add(new CockpitMapSnapshot.SourceMonitoringPoint(
-                    point.getPointCode().trim(),
-                    point.getPointName(),
-                    // 点位无自有片区列,按「经其上挂设备归纳」取得;不一致/无设备 → 未分区。
-                    CockpitDistrict.aggregate(boundDistricts),
-                    deviceTypes.size() == 1 ? deviceTypes.iterator().next() : null,
-                    point.getLongitude(),
-                    point.getLatitude(),
-                    false,
-                    devices));
-        }
-
-        // 智能设备清单按「全量」上报:哪些算「未关联」由 CockpitMapSnapshotService 统一判定,避免双份规则。
-        List<CockpitMapSnapshot.SourceSmartDevice> smartDevices = new ArrayList<>();
-        for (EquipmentBase device : deviceIndex.values()) {
-            EquipmentType type = device.getEquipmentTypeId() == null
-                    ? null
-                    : types.get(device.getEquipmentTypeId());
-            smartDevices.add(new CockpitMapSnapshot.SourceSmartDevice(
-                    device.getEquipmentCode().trim(),
-                    device.getEquipmentName(),
-                    // 设备自身片区(equipment_base.district)即权威来源。
-                    CockpitDistrict.codeOf(device.getDistrict()),
-                    type == null || type.getId() == null ? null : String.valueOf(type.getId()),
-                    device.getLongitude(),
-                    device.getLatitude(),
-                    false));
-        }
-
-        List<CockpitMapSnapshot.SourceType> sourceTypes = types.values().stream()
-                .filter(type -> type.getId() != null)
-                .map(type -> new CockpitMapSnapshot.SourceType(
-                        String.valueOf(type.getId()),
-                        type.getTypeName(),
-                        CockpitTypeIndustry.industryOf(type, types),
-                        "SMART_DEVICE"))
-                .collect(Collectors.toList());
-
-        return new CockpitMapSnapshot(resolvedSnapshotAt, sourceTypes, sourcePoints, smartDevices);
-    }
-
-    private Map<Integer, EquipmentType> equipmentTypes() {
-        Map<Integer, EquipmentType> result = new HashMap<>();
-        for (EquipmentType type : safeList(equipmentTypeService.list())) {
-            if (type != null && type.getId() != null) {
-                result.put(type.getId(), type);
-            }
-        }
-        return result;
-    }
-
-    private static <T> List<T> safeList(List<T> value) {
-        return value == null ? Collections.emptyList() : value.stream().filter(Objects::nonNull).collect(Collectors.toList());
-    }
-
-    private static boolean isBlank(String value) {
-        return value == null || value.trim().isEmpty();
-    }
-
-    private String businessTime() {
-        return TIME_FORMATTER.format(clock.instant().atZone(BUSINESS_ZONE));
-    }
-}

+ 0 - 128
.scratch/cockpit-real-data-wayfinding/implementation/t29/baseline/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMapSnapshot.java

@@ -1,128 +0,0 @@
-package com.zksy.cockpit.map;
-
-import com.zksy.cockpit.point.CockpitMonitoringPointDeviceSnapshot;
-
-import java.math.BigDecimal;
-import java.util.Collections;
-import java.util.List;
-
-public class CockpitMapSnapshot {
-    private final String snapshotAt;
-    private final List<SourceType> types;
-    private final List<SourceMonitoringPoint> monitoringPoints;
-    private final List<SourceSmartDevice> smartDevices;
-
-    public CockpitMapSnapshot(String snapshotAt, List<SourceType> types,
-            List<SourceMonitoringPoint> monitoringPoints, List<SourceSmartDevice> smartDevices) {
-        this.snapshotAt = snapshotAt;
-        this.types = types;
-        this.monitoringPoints = monitoringPoints;
-        this.smartDevices = smartDevices;
-    }
-
-    public static CockpitMapSnapshot empty(String snapshotAt) {
-        return new CockpitMapSnapshot(
-                snapshotAt, Collections.emptyList(), Collections.emptyList(), Collections.emptyList());
-    }
-
-    public String getSnapshotAt() {
-        return snapshotAt;
-    }
-
-    public List<SourceType> getTypes() {
-        return types;
-    }
-
-    public List<SourceMonitoringPoint> getMonitoringPoints() {
-        return monitoringPoints;
-    }
-
-    public List<SourceSmartDevice> getSmartDevices() {
-        return smartDevices;
-    }
-
-    /**
-     * 类型字典:行业由类型推导,口径与 `/monitoring/distribution` 一致。
-     * 地图快照对不可解析的类型**不做排除**,只在请求了 industry 过滤时按推导结果筛选。
-     */
-    public static class SourceType {
-        private final String code;
-        private final String name;
-        private final String industry;
-        private final String objectKind;
-
-        public SourceType(String code, String name, String industry, String objectKind) {
-            this.code = code;
-            this.name = name;
-            this.industry = industry;
-            this.objectKind = objectKind;
-        }
-
-        public String getCode() { return code; }
-        public String getName() { return name; }
-        public String getIndustry() { return industry; }
-        public String getObjectKind() { return objectKind; }
-    }
-
-    public static class SourceMonitoringPoint {
-        private final String pointCode;
-        private final String pointName;
-        private final Integer district;
-        private final String typeCode;
-        private final BigDecimal longitude;
-        private final BigDecimal latitude;
-        private final boolean coordinateVerified;
-        private final List<CockpitMonitoringPointDeviceSnapshot> devices;
-
-        public SourceMonitoringPoint(String pointCode, String pointName, Integer district, String typeCode,
-                BigDecimal longitude, BigDecimal latitude,
-                boolean coordinateVerified, List<CockpitMonitoringPointDeviceSnapshot> devices) {
-            this.pointCode = pointCode;
-            this.pointName = pointName;
-            this.district = district;
-            this.typeCode = typeCode;
-            this.longitude = longitude;
-            this.latitude = latitude;
-            this.coordinateVerified = coordinateVerified;
-            this.devices = devices;
-        }
-
-        public String getPointCode() { return pointCode; }
-        public String getPointName() { return pointName; }
-        public Integer getDistrict() { return district; }
-        public String getTypeCode() { return typeCode; }
-        public BigDecimal getLongitude() { return longitude; }
-        public BigDecimal getLatitude() { return latitude; }
-        public boolean isCoordinateVerified() { return coordinateVerified; }
-        public List<CockpitMonitoringPointDeviceSnapshot> getDevices() { return devices; }
-    }
-
-    public static class SourceSmartDevice {
-        private final String deviceCode;
-        private final String deviceName;
-        private final Integer district;
-        private final String typeCode;
-        private final BigDecimal longitude;
-        private final BigDecimal latitude;
-        private final boolean coordinateVerified;
-
-        public SourceSmartDevice(String deviceCode, String deviceName, Integer district, String typeCode,
-                BigDecimal longitude, BigDecimal latitude, boolean coordinateVerified) {
-            this.deviceCode = deviceCode;
-            this.deviceName = deviceName;
-            this.district = district;
-            this.typeCode = typeCode;
-            this.longitude = longitude;
-            this.latitude = latitude;
-            this.coordinateVerified = coordinateVerified;
-        }
-
-        public String getDeviceCode() { return deviceCode; }
-        public String getDeviceName() { return deviceName; }
-        public Integer getDistrict() { return district; }
-        public String getTypeCode() { return typeCode; }
-        public BigDecimal getLongitude() { return longitude; }
-        public BigDecimal getLatitude() { return latitude; }
-        public boolean isCoordinateVerified() { return coordinateVerified; }
-    }
-}

+ 0 - 95
.scratch/cockpit-real-data-wayfinding/implementation/t29/baseline/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMapSnapshotResponse.java

@@ -1,95 +0,0 @@
-package com.zksy.cockpit.map;
-
-import java.util.List;
-
-public class CockpitMapSnapshotResponse {
-    private final String requestId;
-    private final String apiVersion;
-    private final boolean readOnly;
-    private final int page;
-    private final int pageSize;
-    private final String snapshotAt;
-    private final int total;
-    private final String batchUpdatedAt;
-    private final boolean complete;
-    private final List<CockpitMonitoringPointSummary> monitoringPoints;
-    private final List<CockpitUnassociatedEquipmentSummary> unassociatedEquipment;
-    private final CockpitCoordinateValidationSummary coordinateValidation;
-    private final MapFilter mapFilter;
-
-    public CockpitMapSnapshotResponse(String requestId, String apiVersion, boolean readOnly, int page, int pageSize,
-            String snapshotAt, int total, String batchUpdatedAt, boolean complete,
-            List<CockpitMonitoringPointSummary> monitoringPoints,
-            List<CockpitUnassociatedEquipmentSummary> unassociatedEquipment,
-            CockpitCoordinateValidationSummary coordinateValidation,
-            MapFilter mapFilter) {
-        this.requestId = requestId;
-        this.apiVersion = apiVersion;
-        this.readOnly = readOnly;
-        this.page = page;
-        this.pageSize = pageSize;
-        this.snapshotAt = snapshotAt;
-        this.total = total;
-        this.batchUpdatedAt = batchUpdatedAt;
-        this.complete = complete;
-        this.monitoringPoints = monitoringPoints;
-        this.unassociatedEquipment = unassociatedEquipment;
-        this.coordinateValidation = coordinateValidation;
-        this.mapFilter = mapFilter;
-    }
-
-    public String getRequestId() { return requestId; }
-    public String getApiVersion() { return apiVersion; }
-    public boolean isReadOnly() { return readOnly; }
-    public int getPage() { return page; }
-    public int getPageSize() { return pageSize; }
-    public String getSnapshotAt() { return snapshotAt; }
-    public int getTotal() { return total; }
-    public String getBatchUpdatedAt() { return batchUpdatedAt; }
-    public boolean isComplete() { return complete; }
-    public List<CockpitMonitoringPointSummary> getMonitoringPoints() { return monitoringPoints; }
-    public List<CockpitUnassociatedEquipmentSummary> getUnassociatedEquipment() { return unassociatedEquipment; }
-    public CockpitCoordinateValidationSummary getCoordinateValidation() { return coordinateValidation; }
-    public MapFilter getMapFilter() { return mapFilter; }
-
-    /**
-     * 生效范围回显:请求的过滤条件 + 过滤后**全量**(分页前)的点位/设备编码。
-     * 口径与 `/monitoring/distribution` 的 `mapFilter` 一致。
-     *
-     * <p>范围维度自执行票 12 起为**片区**(`district`):旧自由串 `region` 不再作为正式契约。
-     * 「未筛」与「只看未分区」的编码都是 `null`,靠 {@link #districtFilter} 区分
-     * (`NONE` / `UNASSIGNED` / `ASSIGNED`),不靠猜。
-     */
-    public static class MapFilter {
-        private final String industry;
-        private final Integer district;
-        private final String districtName;
-        private final String districtFilter;
-        private final String deviceType;
-        private final List<String> pointCodes;
-        private final List<String> deviceCodes;
-
-        public MapFilter(String industry, Integer district, String districtFilter, String deviceType,
-                List<String> pointCodes, List<String> deviceCodes) {
-            this.industry = industry;
-            this.district = district;
-            this.districtFilter = districtFilter;
-            this.districtName = district == null ? null
-                    : com.zksy.cockpit.foundation.CockpitDistrict.nameOf(district);
-            this.deviceType = deviceType;
-            this.pointCodes = pointCodes;
-            this.deviceCodes = deviceCodes;
-        }
-
-        public String getIndustry() { return industry; }
-        /** 生效片区编码;未筛或「只看未分区」时为 {@code null}(用 {@link #getDistrictFilter()} 区分)。 */
-        public Integer getDistrict() { return district; }
-        /** 生效片区展示名;未筛与未分区都为 {@code null 或「未分区」},详见 {@link #getDistrictFilter()}。 */
-        public String getDistrictName() { return districtName; }
-        /** `NONE`(未筛)/ `ASSIGNED`(按片区)/ `UNASSIGNED`(只看未分区)。 */
-        public String getDistrictFilter() { return districtFilter; }
-        public String getDeviceType() { return deviceType; }
-        public List<String> getPointCodes() { return pointCodes; }
-        public List<String> getDeviceCodes() { return deviceCodes; }
-    }
-}

+ 0 - 407
.scratch/cockpit-real-data-wayfinding/implementation/t29/baseline/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMapSnapshotService.java

@@ -1,407 +0,0 @@
-package com.zksy.cockpit.map;
-
-import com.zksy.cockpit.foundation.CockpitCoordinateValidation;
-import com.zksy.cockpit.foundation.CockpitCoordinateValidator;
-import com.zksy.cockpit.foundation.CockpitDistrict;
-import com.zksy.cockpit.foundation.CockpitErrorCategory;
-import com.zksy.cockpit.foundation.CockpitErrorPayload;
-import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
-import com.zksy.cockpit.foundation.CockpitSnapshotExpiredException;
-import com.zksy.cockpit.foundation.CockpitSnapshotTime;
-import com.zksy.cockpit.point.CockpitMonitoringPointDeviceSnapshot;
-import com.zksy.cockpit.point.CockpitMonitoringPointStatus;
-import com.zksy.cockpit.point.CockpitMonitoringPointStatusResolver;
-import org.springframework.stereotype.Service;
-
-import java.math.BigDecimal;
-import java.time.Clock;
-import java.time.DateTimeException;
-import java.time.ZoneId;
-import java.time.format.DateTimeFormatter;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.Comparator;
-import java.util.LinkedHashMap;
-import java.util.LinkedHashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Objects;
-import java.util.Set;
-import java.util.stream.Collectors;
-
-@Service
-public class CockpitMapSnapshotService {
-    private static final int DEFAULT_PAGE_SIZE = 500;
-    private static final int MAX_PAGE_SIZE = 1000;
-    private static final String WGS84 = "WGS84";
-    private static final String SMART_DEVICE = "SMART_DEVICE";
-    private static final List<String> INDUSTRIES = Arrays.asList("GAS", "WATER_SUPPLY", "DRAINAGE", "MANHOLE");
-
-    private final CockpitMapSnapshotAdapter adapter;
-    private final Clock clock;
-
-    public CockpitMapSnapshotService(CockpitMapSnapshotAdapter adapter, Clock clock) {
-        this.adapter = adapter;
-        this.clock = clock;
-    }
-
-    /**
-     * 地图分页快照。全局筛选(industry / district / deviceType)生效时服务端完成过滤,
-     * `mapFilter` 回显实际生效范围;分页与 `snapshotAt` 语义不因过滤而改变。
-     *
-     * <p>范围维度自执行票 12 起为**片区**(`district` 0/1/2 + `UNASSIGNED`);
-     * 旧自由串 `region` 不再是正式契约,入参被忽略。
-     */
-    public CockpitMapSnapshotResponse load(String requestId, Integer page, Integer pageSize, String snapshotAt,
-            String industry, String district, String deviceType) {
-        int safePage = page == null ? 1 : page;
-        int safePageSize = pageSize == null ? DEFAULT_PAGE_SIZE : pageSize;
-        validatePagination(safePage, safePageSize, snapshotAt);
-        validateSnapshotAt(snapshotAt);
-        String selectedIndustry = resolveIndustry(industry);
-        CockpitDistrict.Query selectedDistrict = CockpitDistrict.parseQuery(district);
-        if (selectedDistrict.invalid()) {
-            throw new CockpitInvalidRequestException(
-                    "district must be 0, 1, 2 or UNASSIGNED");
-        }
-        String selectedDeviceType = isBlank(deviceType) ? null : deviceType.trim();
-
-        CockpitMapSnapshot snapshot = adapter.load(snapshotAt);
-        if (snapshot == null) {
-            if (snapshotAt != null) {
-                throw new CockpitSnapshotExpiredException("snapshot not found");
-            }
-            snapshot = CockpitMapSnapshot.empty(resolveSnapshotAt(null));
-        }
-        if (snapshot.getSnapshotAt() == null) {
-            throw new CockpitSnapshotExpiredException("snapshot timestamp missing");
-        }
-        if (snapshotAt != null && !snapshotAt.equals(snapshot.getSnapshotAt())) {
-            throw new CockpitSnapshotExpiredException("snapshot timestamp changed");
-        }
-
-        Map<String, CockpitMapSnapshot.SourceType> types = types(snapshot);
-        List<CockpitMonitoringPointSummary> points = monitoringPoints(
-                snapshot, types, selectedIndustry, selectedDistrict, selectedDeviceType);
-        List<CockpitUnassociatedEquipmentSummary> equipment = unassociatedEquipment(
-                snapshot, types, selectedIndustry, selectedDistrict, selectedDeviceType);
-        int total = points.size() + equipment.size();
-        int fromIndex = Math.min((safePage - 1) * safePageSize, total);
-        int toIndex = Math.min(fromIndex + safePageSize, total);
-        List<CockpitMonitoringPointSummary> pagePoints = points.subList(
-                clamp(fromIndex, 0, points.size()),
-                clamp(toIndex, 0, points.size()));
-        List<CockpitUnassociatedEquipmentSummary> pageEquipment = equipment.subList(
-                clamp(fromIndex - points.size(), 0, equipment.size()),
-                clamp(toIndex - points.size(), 0, equipment.size()));
-
-        return new CockpitMapSnapshotResponse(
-                requestId,
-                "v1",
-                true,
-                safePage,
-                safePageSize,
-                snapshot.getSnapshotAt(),
-                total,
-                snapshot.getSnapshotAt(),
-                toIndex >= total,
-                pagePoints,
-                pageEquipment,
-                coordinateValidation(points, equipment),
-                mapFilter(selectedIndustry, selectedDistrict, selectedDeviceType, points, equipment));
-    }
-
-    public CockpitErrorPayload invalidRequest(String requestId, String message) {
-        return new CockpitErrorPayload(
-                CockpitErrorCategory.INVALID_REQUEST,
-                message,
-                requestId,
-                businessTime());
-    }
-
-    public CockpitErrorPayload snapshotExpired(String requestId) {
-        return new CockpitErrorPayload(
-                CockpitErrorCategory.SNAPSHOT_EXPIRED,
-                "地图快照已失效,请重新加载",
-                requestId,
-                businessTime());
-    }
-
-    public CockpitErrorPayload serverError(String requestId) {
-        return new CockpitErrorPayload(
-                CockpitErrorCategory.SERVER_ERROR,
-                "驾驶舱地图快照数据暂不可用",
-                requestId,
-                businessTime());
-    }
-
-    private static void validatePagination(int page, int pageSize, String snapshotAt) {
-        if (page < 1 || pageSize < 1 || pageSize > MAX_PAGE_SIZE) {
-            throw new CockpitInvalidRequestException("地图快照分页参数不合法");
-        }
-        if (page > 1 && isBlank(snapshotAt)) {
-            throw new CockpitSnapshotExpiredException("snapshot token required for later pages");
-        }
-    }
-
-    private static void validateSnapshotAt(String snapshotAt) {
-        if (snapshotAt == null) {
-            return;
-        }
-        try {
-            CockpitSnapshotTime.parseBusinessOffset(snapshotAt);
-        } catch (DateTimeException exception) {
-            throw new CockpitInvalidRequestException("地图快照分页参数不合法");
-        }
-    }
-
-    private static String resolveIndustry(String industry) {
-        if (isBlank(industry)) {
-            return null;
-        }
-        String resolved = industry.trim();
-        if (!INDUSTRIES.contains(resolved)) {
-            throw new CockpitInvalidRequestException("industry must be GAS, WATER_SUPPLY, DRAINAGE or MANHOLE");
-        }
-        return resolved;
-    }
-
-    private static boolean isBlank(String value) {
-        return value == null || value.trim().isEmpty();
-    }
-
-    private String resolveSnapshotAt(String snapshotAt) {
-        return snapshotAt == null ? businessTime() : snapshotAt;
-    }
-
-    private static Map<String, CockpitMapSnapshot.SourceType> types(CockpitMapSnapshot snapshot) {
-        Map<String, CockpitMapSnapshot.SourceType> result = new LinkedHashMap<>();
-        if (snapshot.getTypes() == null) {
-            return result;
-        }
-        for (CockpitMapSnapshot.SourceType type : snapshot.getTypes()) {
-            if (type == null || isBlank(type.getCode())) {
-                continue;
-            }
-            result.put(type.getCode().trim(), type);
-        }
-        return result;
-    }
-
-    private static CockpitMapSnapshot.SourceType resolveType(
-            Map<String, CockpitMapSnapshot.SourceType> types, String typeCode) {
-        return isBlank(typeCode) ? null : types.get(typeCode.trim());
-    }
-
-    /**
-     * 行业推导口径同 `/monitoring/distribution`:类型可解析、`objectKind = SMART_DEVICE`
-     * 且行业属于正式行业;不满足时该对象的 `industry` 为 `null`(默认不排除对象)。
-     */
-    private static String industryOf(CockpitMapSnapshot.SourceType type) {
-        if (type == null || !SMART_DEVICE.equals(type.getObjectKind())) {
-            return null;
-        }
-        if (type.getIndustry() == null || !INDUSTRIES.contains(type.getIndustry())) {
-            return null;
-        }
-        return type.getIndustry();
-    }
-
-    private static String typeNameOf(CockpitMapSnapshot.SourceType type) {
-        return type == null ? null : type.getName();
-    }
-
-    private static String typeCodeOf(String typeCode) {
-        return isBlank(typeCode) ? null : typeCode.trim();
-    }
-
-    private static boolean matchesFilters(String industry, CockpitDistrict.Query district, String deviceType,
-            String typeCode, Integer rawDistrict, Map<String, CockpitMapSnapshot.SourceType> types) {
-        CockpitMapSnapshot.SourceType type = resolveType(types, typeCode);
-        if (industry != null && !industry.equals(industryOf(type))) {
-            return false;
-        }
-        if (deviceType != null && (type == null || !deviceType.equals(typeCodeOf(type.getCode())))) {
-            return false;
-        }
-        return CockpitDistrict.matches(district, rawDistrict);
-    }
-
-    private static List<CockpitMonitoringPointSummary> monitoringPoints(
-            CockpitMapSnapshot snapshot,
-            Map<String, CockpitMapSnapshot.SourceType> types,
-            String industry, CockpitDistrict.Query district, String deviceType) {
-        if (snapshot.getMonitoringPoints() == null) {
-            return Collections.emptyList();
-        }
-        return snapshot.getMonitoringPoints().stream()
-                .filter(Objects::nonNull)
-                .filter(source -> matchesFilters(
-                        industry, district, deviceType, source.getTypeCode(), source.getDistrict(), types))
-                .map(source -> monitoringPoint(source, types))
-                .sorted(Comparator.comparing(CockpitMonitoringPointSummary::getPointCode,
-                        Comparator.nullsLast(String::compareTo)))
-                .collect(Collectors.toList());
-    }
-
-    private static CockpitMonitoringPointSummary monitoringPoint(
-            CockpitMapSnapshot.SourceMonitoringPoint source,
-            Map<String, CockpitMapSnapshot.SourceType> types) {
-        List<CockpitMonitoringPointDeviceSnapshot> devices = source.getDevices() == null
-                ? Collections.emptyList()
-                : source.getDevices();
-        CockpitMonitoringPointStatus pointStatus = CockpitMonitoringPointStatusResolver.resolve(devices);
-        List<String> deviceCodes = devices.stream()
-                .filter(Objects::nonNull)
-                .map(CockpitMonitoringPointDeviceSnapshot::getDeviceCode)
-                .filter(Objects::nonNull)
-                .map(String::trim)
-                .filter(code -> !code.isEmpty())
-                .distinct()
-                .collect(Collectors.toList());
-        CockpitMapSnapshot.SourceType type = resolveType(types, source.getTypeCode());
-        return new CockpitMonitoringPointSummary(
-                source.getPointCode(),
-                source.getPointName(),
-                pointStatus,
-                CockpitMonitoringPointStatusResolver.summaryReasons(pointStatus, devices),
-                deviceCodes,
-                source.getDistrict(),
-                industryOf(type),
-                typeCodeOf(source.getTypeCode()),
-                typeNameOf(type),
-                source.getLongitude(),
-                source.getLatitude(),
-                CockpitCoordinateValidator.validate(
-                        source.getLongitude(), source.getLatitude(), source.isCoordinateVerified()));
-    }
-
-    private static List<CockpitUnassociatedEquipmentSummary> unassociatedEquipment(
-            CockpitMapSnapshot snapshot,
-            Map<String, CockpitMapSnapshot.SourceType> types,
-            String industry, CockpitDistrict.Query district, String deviceType) {
-        if (snapshot.getSmartDevices() == null) {
-            return Collections.emptyList();
-        }
-        Set<String> associatedDeviceCodes = associatedDeviceCodes(snapshot);
-        return snapshot.getSmartDevices().stream()
-                .filter(Objects::nonNull)
-                .filter(device -> device.getDeviceCode() == null
-                        || !associatedDeviceCodes.contains(device.getDeviceCode()))
-                .filter(source -> matchesFilters(
-                        industry, district, deviceType, source.getTypeCode(), source.getDistrict(), types))
-                .map(source -> unassociatedEquipment(source, types))
-                .sorted(Comparator.comparing(CockpitUnassociatedEquipmentSummary::getDeviceCode,
-                        Comparator.nullsLast(String::compareTo)))
-                .collect(Collectors.toList());
-    }
-
-    private static Set<String> associatedDeviceCodes(CockpitMapSnapshot snapshot) {
-        if (snapshot.getMonitoringPoints() == null) {
-            return Collections.emptySet();
-        }
-        return snapshot.getMonitoringPoints().stream()
-                .map(CockpitMapSnapshot.SourceMonitoringPoint::getDevices)
-                .filter(Objects::nonNull)
-                .flatMap(List::stream)
-                .map(CockpitMonitoringPointDeviceSnapshot::getDeviceCode)
-                .filter(Objects::nonNull)
-                .collect(Collectors.toSet());
-    }
-
-    private static CockpitUnassociatedEquipmentSummary unassociatedEquipment(
-            CockpitMapSnapshot.SourceSmartDevice source,
-            Map<String, CockpitMapSnapshot.SourceType> types) {
-        CockpitMapSnapshot.SourceType type = resolveType(types, source.getTypeCode());
-        return new CockpitUnassociatedEquipmentSummary(
-                source.getDeviceCode(),
-                source.getDeviceName(),
-                source.getDistrict(),
-                industryOf(type),
-                typeCodeOf(source.getTypeCode()),
-                typeNameOf(type),
-                source.getLongitude(),
-                source.getLatitude(),
-                CockpitCoordinateValidator.validate(
-                        source.getLongitude(), source.getLatitude(), source.isCoordinateVerified()));
-    }
-
-    private static CockpitMapSnapshotResponse.MapFilter mapFilter(
-            String industry, CockpitDistrict.Query district, String deviceType,
-            List<CockpitMonitoringPointSummary> points,
-            List<CockpitUnassociatedEquipmentSummary> equipment) {
-        Set<String> pointCodes = new LinkedHashSet<>();
-        Set<String> deviceCodes = new LinkedHashSet<>();
-        for (CockpitMonitoringPointSummary point : points) {
-            if (!isBlank(point.getPointCode())) {
-                pointCodes.add(point.getPointCode().trim());
-            }
-            if (point.getDeviceCodes() != null) {
-                for (String code : point.getDeviceCodes()) {
-                    if (!isBlank(code)) {
-                        deviceCodes.add(code.trim());
-                    }
-                }
-            }
-        }
-        for (CockpitUnassociatedEquipmentSummary device : equipment) {
-            if (!isBlank(device.getDeviceCode())) {
-                deviceCodes.add(device.getDeviceCode().trim());
-            }
-        }
-        return new CockpitMapSnapshotResponse.MapFilter(
-                industry,
-                district.active() ? district.code() : null,
-                !district.active() ? "NONE" : (district.unassignedOnly() ? "UNASSIGNED" : "ASSIGNED"),
-                deviceType, sorted(pointCodes), sorted(deviceCodes));
-    }
-
-    private static List<String> sorted(Set<String> values) {
-        List<String> result = new ArrayList<>(values);
-        Collections.sort(result, Comparator.nullsLast(String::compareTo));
-        return result;
-    }
-
-    private static int clamp(int value, int minimum, int maximum) {
-        return Math.max(minimum, Math.min(value, maximum));
-    }
-
-    private static CockpitCoordinateValidationSummary coordinateValidation(
-            List<CockpitMonitoringPointSummary> points, List<CockpitUnassociatedEquipmentSummary> equipment) {
-        int renderableCount = 0;
-        int invalidCount = 0;
-        int unverifiedCount = 0;
-        for (CockpitMonitoringPointSummary point : points) {
-            CockpitCoordinateValidation validation = point.getCoordinateValidation();
-            if (validation.isRenderable()) {
-                renderableCount++;
-            } else if ("INVALID".equals(validation.getState())) {
-                invalidCount++;
-            } else {
-                unverifiedCount++;
-            }
-        }
-        for (CockpitUnassociatedEquipmentSummary device : equipment) {
-            CockpitCoordinateValidation validation = device.getCoordinateValidation();
-            if (validation.isRenderable()) {
-                renderableCount++;
-            } else if ("INVALID".equals(validation.getState())) {
-                invalidCount++;
-            } else {
-                unverifiedCount++;
-            }
-        }
-        return new CockpitCoordinateValidationSummary(
-                WGS84,
-                points.size() + equipment.size(),
-                renderableCount,
-                invalidCount,
-                unverifiedCount);
-    }
-
-    private String businessTime() {
-        return DateTimeFormatter.ISO_OFFSET_DATE_TIME
-                .format(clock.instant().atZone(ZoneId.of("Asia/Shanghai")));
-    }
-}

+ 0 - 45
.scratch/cockpit-real-data-wayfinding/implementation/t29/baseline/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitUnassociatedEquipmentSummary.java

@@ -1,45 +0,0 @@
-package com.zksy.cockpit.map;
-
-import com.zksy.cockpit.foundation.CockpitCoordinateValidation;
-import com.zksy.cockpit.foundation.CockpitDistrict;
-
-import java.math.BigDecimal;
-
-public class CockpitUnassociatedEquipmentSummary {
-    private final String deviceCode;
-    private final String deviceName;
-    private final Integer district;
-    private final String industry;
-    private final String typeCode;
-    private final String typeName;
-    private final BigDecimal longitude;
-    private final BigDecimal latitude;
-    private final CockpitCoordinateValidation coordinateValidation;
-
-    public CockpitUnassociatedEquipmentSummary(String deviceCode, String deviceName,
-            Integer district, String industry, String typeCode, String typeName,
-            BigDecimal longitude, BigDecimal latitude, CockpitCoordinateValidation coordinateValidation) {
-        this.deviceCode = deviceCode;
-        this.deviceName = deviceName;
-        this.district = CockpitDistrict.codeOf(district);
-        this.industry = industry;
-        this.typeCode = typeCode;
-        this.typeName = typeName;
-        this.longitude = longitude;
-        this.latitude = latitude;
-        this.coordinateValidation = coordinateValidation;
-    }
-
-    public String getDeviceCode() { return deviceCode; }
-    public String getDeviceName() { return deviceName; }
-    /** 片区编码(0-城南 1-城北 2-太常);未分区为 {@code null}。 */
-    public Integer getDistrict() { return district; }
-    /** 片区展示名;未分区如实回「未分区」。 */
-    public String getDistrictName() { return CockpitDistrict.nameOf(district); }
-    public String getIndustry() { return industry; }
-    public String getTypeCode() { return typeCode; }
-    public String getTypeName() { return typeName; }
-    public BigDecimal getLongitude() { return longitude; }
-    public BigDecimal getLatitude() { return latitude; }
-    public CockpitCoordinateValidation getCoordinateValidation() { return coordinateValidation; }
-}

+ 0 - 181
.scratch/cockpit-real-data-wayfinding/implementation/t29/baseline/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/BusinessCockpitMonitoringDistributionAdapter.java

@@ -1,181 +0,0 @@
-package com.zksy.cockpit.monitoring;
-
-import com.zksy.base.domain.EquipmentBase;
-import com.zksy.base.domain.EquipmentType;
-import com.zksy.base.domain.MonitorPoint;
-import com.zksy.base.service.EquipmentBaseService;
-import com.zksy.base.service.EquipmentTypeService;
-import com.zksy.base.service.MonitorPointService;
-import com.zksy.cockpit.foundation.CockpitDistrict;
-import com.zksy.cockpit.foundation.CockpitTypeIndustry;
-import com.zksy.cockpit.point.BusinessCockpitDeviceFactResolver;
-import com.zksy.cockpit.point.CockpitMonitoringPointDeviceSnapshot;
-import org.springframework.context.annotation.Primary;
-import org.springframework.stereotype.Component;
-
-import java.time.Clock;
-import java.time.ZoneId;
-import java.time.format.DateTimeFormatter;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.LinkedHashMap;
-import java.util.LinkedHashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Objects;
-import java.util.Set;
-import java.util.stream.Collectors;
-
-/**
- * 监测分布的真实数据适配器(BT-5 第 2 片)。
- *
- * <p>与地图快照适配器共享同一套构图({@link BusinessCockpitDeviceFactResolver} +
- * {@link CockpitTypeIndustry}),只换快照形状:分布的未关联智能设备额外携带实时事实
- * (在线/报警/预警/在途工单 + 原因 + 状态时间),点位的设备仍经 {@code CockpitMonitoringPointDeviceSnapshot} 描述。
- *
- * <p>如实接线,不臆造:现网无坐标校核字段 → 一律 {@code coordinateVerified=false};
- * {@code monitor_point} 无行业列 → 点位 {@code typeCode} 仅在其关联设备类型唯一时推导。
- * 片区(district)的权威来源是 {@code equipment_base.district}:点位按「经其上挂设备归纳」
- * 取得(不一致/无设备 → 未分区),设备直接用自身片区。
- */
-@Component
-@Primary
-public class BusinessCockpitMonitoringDistributionAdapter implements CockpitMonitoringDistributionAdapter {
-
-    private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
-    private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
-
-    private final MonitorPointService monitorPointService;
-    private final EquipmentBaseService equipmentBaseService;
-    private final EquipmentTypeService equipmentTypeService;
-    private final BusinessCockpitDeviceFactResolver deviceFactResolver;
-    private final Clock clock;
-
-    public BusinessCockpitMonitoringDistributionAdapter(
-            MonitorPointService monitorPointService,
-            EquipmentBaseService equipmentBaseService,
-            EquipmentTypeService equipmentTypeService,
-            BusinessCockpitDeviceFactResolver deviceFactResolver,
-            Clock clock) {
-        this.monitorPointService = monitorPointService;
-        this.equipmentBaseService = equipmentBaseService;
-        this.equipmentTypeService = equipmentTypeService;
-        this.deviceFactResolver = deviceFactResolver;
-        this.clock = clock;
-    }
-
-    @Override
-    public CockpitMonitoringDistributionSnapshot load(String snapshotAt) {
-        String resolvedSnapshotAt = snapshotAt == null ? businessTime() : snapshotAt;
-
-        Map<Integer, EquipmentType> types = equipmentTypes();
-        List<MonitorPoint> points = safeList(monitorPointService.list());
-
-        Map<String, EquipmentBase> deviceIndex = new LinkedHashMap<>();
-        for (EquipmentBase device : safeList(equipmentBaseService.list())) {
-            if (device != null && !isBlank(device.getEquipmentCode())) {
-                deviceIndex.put(device.getEquipmentCode().trim(), device);
-            }
-        }
-
-        List<CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint> sourcePoints = new ArrayList<>();
-        for (MonitorPoint point : points) {
-            if (point == null || isBlank(point.getPointCode())) {
-                continue;
-            }
-            List<EquipmentBase> boundDevices = safeList(
-                    equipmentBaseService.getEquipmentByPoint(point.getPointId()));
-            List<CockpitMonitoringPointDeviceSnapshot> devices = new ArrayList<>();
-            Set<String> deviceTypes = new LinkedHashSet<>();
-            List<Integer> boundDistricts = new ArrayList<>();
-            for (EquipmentBase device : boundDevices) {
-                if (device == null || isBlank(device.getEquipmentCode())) {
-                    continue;
-                }
-                EquipmentType boundType = device.getEquipmentTypeId() == null
-                        ? null
-                        : types.get(device.getEquipmentTypeId());
-                String typeName = boundType == null ? null : boundType.getTypeName();
-                devices.add(deviceFactResolver.resolve(device, typeName));
-                if (boundType != null && boundType.getId() != null) {
-                    deviceTypes.add(String.valueOf(boundType.getId()));
-                }
-                boundDistricts.add(device.getDistrict());
-            }
-            sourcePoints.add(new CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint(
-                    point.getPointCode().trim(),
-                    point.getPointName(),
-                    // 点位无自有片区列,按「经其上挂设备归纳」取得;不一致/无设备 → 未分区。
-                    CockpitDistrict.aggregate(boundDistricts),
-                    deviceTypes.size() == 1 ? deviceTypes.iterator().next() : null,
-                    point.getLongitude(),
-                    point.getLatitude(),
-                    false,
-                    devices));
-        }
-
-        List<CockpitMonitoringDistributionSnapshot.SourceSmartDevice> smartDevices = new ArrayList<>();
-        for (EquipmentBase device : deviceIndex.values()) {
-            EquipmentType type = device.getEquipmentTypeId() == null
-                    ? null
-                    : types.get(device.getEquipmentTypeId());
-            String typeCode = type == null || type.getId() == null ? null : String.valueOf(type.getId());
-            String typeName = type == null ? null : type.getTypeName();
-            CockpitMonitoringPointDeviceSnapshot fact = deviceFactResolver.resolve(device, typeName);
-            smartDevices.add(new CockpitMonitoringDistributionSnapshot.SourceSmartDevice(
-                    device.getEquipmentCode().trim(),
-                    device.getEquipmentName(),
-                    // 设备自身片区(equipment_base.district)即权威来源。
-                    CockpitDistrict.codeOf(device.getDistrict()),
-                    typeCode,
-                    device.getLongitude(),
-                    device.getLatitude(),
-                    false,
-                    fact.isOnline(),
-                    fact.isAlarm(),
-                    fact.isWarning(),
-                    fact.isWorkOrderInProgress(),
-                    fact.getStatusReasons(),
-                    fact.getStatusTime()));
-        }
-
-        List<CockpitMonitoringDistributionSnapshot.SourceType> sourceTypes = types.values().stream()
-                .filter(type -> type.getId() != null)
-                .map(type -> new CockpitMonitoringDistributionSnapshot.SourceType(
-                        String.valueOf(type.getId()),
-                        type.getTypeName(),
-                        CockpitTypeIndustry.industryOf(type, types),
-                        "SMART_DEVICE"))
-                .collect(Collectors.toList());
-
-        return new CockpitMonitoringDistributionSnapshot(
-                resolvedSnapshotAt,
-                null,
-                sourceTypes,
-                sourcePoints,
-                smartDevices);
-    }
-
-    private Map<Integer, EquipmentType> equipmentTypes() {
-        Map<Integer, EquipmentType> result = new HashMap<>();
-        for (EquipmentType type : safeList(equipmentTypeService.list())) {
-            if (type != null && type.getId() != null) {
-                result.put(type.getId(), type);
-            }
-        }
-        return result;
-    }
-
-    private static <T> List<T> safeList(List<T> value) {
-        return value == null ? Collections.emptyList() : value.stream().filter(Objects::nonNull).collect(Collectors.toList());
-    }
-
-    private static boolean isBlank(String value) {
-        return value == null || value.trim().isEmpty();
-    }
-
-    private String businessTime() {
-        return TIME_FORMATTER.format(clock.instant().atZone(BUSINESS_ZONE));
-    }
-}

+ 0 - 402
.scratch/cockpit-real-data-wayfinding/implementation/t29/baseline/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringDistributionResponse.java

@@ -1,402 +0,0 @@
-package com.zksy.cockpit.monitoring;
-
-import com.zksy.cockpit.foundation.CockpitCoordinateValidation;
-import com.zksy.cockpit.foundation.CockpitDistrict;
-import com.zksy.cockpit.point.CockpitMonitoringPointStatus;
-
-import java.math.BigDecimal;
-import java.util.List;
-
-public class CockpitMonitoringDistributionResponse {
-    private final String requestId;
-    private final String apiVersion;
-    private final boolean readOnly;
-    private final String displayName;
-    private final String timezone;
-    private final Range range;
-    private final String updatedAt;
-    private final Freshness freshness;
-    private final List<DistrictDistribution> districtDistribution;
-    private final List<IndustryDistribution> industryDistribution;
-    private final List<DeviceTypeDistribution> deviceTypeDistribution;
-    private final List<PointStatusCount> pointStatusDistribution;
-    private final MapFilter mapFilter;
-    private final Pagination pagination;
-    private final List<MonitoringPointMarker> monitoringPoints;
-    private final List<UnassociatedEquipmentMarker> unassociatedEquipment;
-    private final Excluded excluded;
-
-    public CockpitMonitoringDistributionResponse(
-            String requestId,
-            String apiVersion,
-            boolean readOnly,
-            String displayName,
-            String timezone,
-            Range range,
-            String updatedAt,
-            Freshness freshness,
-            List<DistrictDistribution> districtDistribution,
-            List<IndustryDistribution> industryDistribution,
-            List<DeviceTypeDistribution> deviceTypeDistribution,
-            List<PointStatusCount> pointStatusDistribution,
-            MapFilter mapFilter,
-            Pagination pagination,
-            List<MonitoringPointMarker> monitoringPoints,
-            List<UnassociatedEquipmentMarker> unassociatedEquipment,
-            Excluded excluded) {
-        this.requestId = requestId;
-        this.apiVersion = apiVersion;
-        this.readOnly = readOnly;
-        this.displayName = displayName;
-        this.timezone = timezone;
-        this.range = range;
-        this.updatedAt = updatedAt;
-        this.freshness = freshness;
-        this.districtDistribution = districtDistribution;
-        this.industryDistribution = industryDistribution;
-        this.deviceTypeDistribution = deviceTypeDistribution;
-        this.pointStatusDistribution = pointStatusDistribution;
-        this.mapFilter = mapFilter;
-        this.pagination = pagination;
-        this.monitoringPoints = monitoringPoints;
-        this.unassociatedEquipment = unassociatedEquipment;
-        this.excluded = excluded;
-    }
-
-    public String getRequestId() { return requestId; }
-    public String getApiVersion() { return apiVersion; }
-    public boolean isReadOnly() { return readOnly; }
-    public String getDisplayName() { return displayName; }
-    public String getTimezone() { return timezone; }
-    public Range getRange() { return range; }
-    public String getUpdatedAt() { return updatedAt; }
-    public Freshness getFreshness() { return freshness; }
-    public List<DistrictDistribution> getDistrictDistribution() { return districtDistribution; }
-    public List<IndustryDistribution> getIndustryDistribution() { return industryDistribution; }
-    public List<DeviceTypeDistribution> getDeviceTypeDistribution() { return deviceTypeDistribution; }
-    public List<PointStatusCount> getPointStatusDistribution() { return pointStatusDistribution; }
-    public MapFilter getMapFilter() { return mapFilter; }
-    public Pagination getPagination() { return pagination; }
-    public List<MonitoringPointMarker> getMonitoringPoints() { return monitoringPoints; }
-    public List<UnassociatedEquipmentMarker> getUnassociatedEquipment() { return unassociatedEquipment; }
-    public Excluded getExcluded() { return excluded; }
-
-    public static class Range {
-        private final String type;
-
-        public Range(String type) {
-            this.type = type;
-        }
-
-        public String getType() {
-            return type;
-        }
-    }
-
-    public static class Freshness {
-        private final String status;
-        private final String reason;
-        private final String updatedAt;
-        private final String serverUpdatedAt;
-
-        public Freshness(String status, String reason, String updatedAt, String serverUpdatedAt) {
-            this.status = status;
-            this.reason = reason;
-            this.updatedAt = updatedAt;
-            this.serverUpdatedAt = serverUpdatedAt;
-        }
-
-        public String getStatus() { return status; }
-        public String getReason() { return reason; }
-        public String getUpdatedAt() { return updatedAt; }
-        public String getServerUpdatedAt() { return serverUpdatedAt; }
-    }
-
-    public static class StatusCounts {
-        private final int alarm;
-        private final int warning;
-        private final int workOrder;
-        private final int normal;
-        private final int offline;
-        private final int empty;
-
-        public StatusCounts(int alarm, int warning, int workOrder, int normal, int offline, int empty) {
-            this.alarm = alarm;
-            this.warning = warning;
-            this.workOrder = workOrder;
-            this.normal = normal;
-            this.offline = offline;
-            this.empty = empty;
-        }
-
-        public int getAlarm() { return alarm; }
-        public int getWarning() { return warning; }
-        public int getWorkOrder() { return workOrder; }
-        public int getNormal() { return normal; }
-        public int getOffline() { return offline; }
-        public int getEmpty() { return empty; }
-    }
-
-    /**
-     * 片区分布行。片区的权威来源是 {@code equipment_base.district}(0-城南 1-城北 2-太常),
-     * 点位按「经其上挂设备归纳」取得;未分区(无来源/不一致/脏值)回 {@code district=null} +
-     * {@code districtName="未分区"},不猜、不按行业冒充。
-     */
-    public static class DistrictDistribution {
-        private final Integer district;
-        private final String districtName;
-        private final int pointCount;
-        private final int deviceCount;
-        private final StatusCounts statusCounts;
-
-        public DistrictDistribution(Integer district, int pointCount, int deviceCount, StatusCounts statusCounts) {
-            this.district = CockpitDistrict.codeOf(district);
-            this.districtName = CockpitDistrict.nameOf(this.district);
-            this.pointCount = pointCount;
-            this.deviceCount = deviceCount;
-            this.statusCounts = statusCounts;
-        }
-
-        /** 片区编码;未分区为 {@code null},名称见 {@link #getDistrictName()}。 */
-        public Integer getDistrict() { return district; }
-        public String getDistrictName() { return districtName; }
-        public int getPointCount() { return pointCount; }
-        public int getDeviceCount() { return deviceCount; }
-        public StatusCounts getStatusCounts() { return statusCounts; }
-    }
-
-    public static class IndustryDistribution {
-        private final String industry;
-        private final int pointCount;
-        private final int deviceCount;
-        private final StatusCounts statusCounts;
-
-        public IndustryDistribution(String industry, int pointCount, int deviceCount, StatusCounts statusCounts) {
-            this.industry = industry;
-            this.pointCount = pointCount;
-            this.deviceCount = deviceCount;
-            this.statusCounts = statusCounts;
-        }
-
-        public String getIndustry() { return industry; }
-        public int getPointCount() { return pointCount; }
-        public int getDeviceCount() { return deviceCount; }
-        public StatusCounts getStatusCounts() { return statusCounts; }
-    }
-
-    public static class DeviceTypeDistribution {
-        private final DeviceType deviceType;
-        private final String industry;
-        private final int pointCount;
-        private final int deviceCount;
-        private final StatusCounts statusCounts;
-
-        public DeviceTypeDistribution(DeviceType deviceType, String industry, int pointCount, int deviceCount,
-                StatusCounts statusCounts) {
-            this.deviceType = deviceType;
-            this.industry = industry;
-            this.pointCount = pointCount;
-            this.deviceCount = deviceCount;
-            this.statusCounts = statusCounts;
-        }
-
-        public DeviceType getDeviceType() { return deviceType; }
-        public String getIndustry() { return industry; }
-        public int getPointCount() { return pointCount; }
-        public int getDeviceCount() { return deviceCount; }
-        public StatusCounts getStatusCounts() { return statusCounts; }
-    }
-
-    public static class DeviceType {
-        private final String code;
-        private final String name;
-
-        public DeviceType(String code, String name) {
-            this.code = code;
-            this.name = name;
-        }
-
-        public String getCode() { return code; }
-        public String getName() { return name; }
-    }
-
-    public static class PointStatusCount {
-        private final CockpitMonitoringPointStatus status;
-        private final int count;
-
-        public PointStatusCount(CockpitMonitoringPointStatus status, int count) {
-            this.status = status;
-            this.count = count;
-        }
-
-        public CockpitMonitoringPointStatus getStatus() { return status; }
-        public int getCount() { return count; }
-    }
-
-    /**
-     * 生效范围回显。片区维度自执行票 12 起为 {@code district}:
-     * 「未筛」与「只看未分区」的编码都是 {@code null},靠 {@code districtFilter}
-     * ({@code NONE} / {@code ASSIGNED} / {@code UNASSIGNED})区分,不靠猜。
-     */
-    public static class MapFilter {
-        private final String industry;
-        private final String deviceType;
-        private final String pointStatus;
-        private final Integer district;
-        private final String districtName;
-        private final String districtFilter;
-        private final List<String> pointCodes;
-        private final List<String> deviceCodes;
-
-        public MapFilter(String industry, String deviceType, String pointStatus,
-                Integer district, String districtFilter,
-                List<String> pointCodes, List<String> deviceCodes) {
-            this.industry = industry;
-            this.deviceType = deviceType;
-            this.pointStatus = pointStatus;
-            this.district = district;
-            this.districtName = district == null ? null : CockpitDistrict.nameOf(district);
-            this.districtFilter = districtFilter;
-            this.pointCodes = pointCodes;
-            this.deviceCodes = deviceCodes;
-        }
-
-        public String getIndustry() { return industry; }
-        public String getDeviceType() { return deviceType; }
-        public String getPointStatus() { return pointStatus; }
-        /** 生效片区编码;未筛或「只看未分区」时为 {@code null}(用 {@link #getDistrictFilter()} 区分)。 */
-        public Integer getDistrict() { return district; }
-        /** 生效片区展示名;未筛为 {@code null},只看未分区为「未分区」。 */
-        public String getDistrictName() { return districtName; }
-        /** `NONE`(未筛)/ `ASSIGNED`(按片区)/ `UNASSIGNED`(只看未分区)。 */
-        public String getDistrictFilter() { return districtFilter; }
-        public List<String> getPointCodes() { return pointCodes; }
-        public List<String> getDeviceCodes() { return deviceCodes; }
-    }
-
-    public static class Pagination {
-        private final int page;
-        private final int pageSize;
-        private final int total;
-        private final int totalPages;
-        private final boolean hasMore;
-
-        public Pagination(int page, int pageSize, int total, int totalPages, boolean hasMore) {
-            this.page = page;
-            this.pageSize = pageSize;
-            this.total = total;
-            this.totalPages = totalPages;
-            this.hasMore = hasMore;
-        }
-
-        public int getPage() { return page; }
-        public int getPageSize() { return pageSize; }
-        public int getTotal() { return total; }
-        public int getTotalPages() { return totalPages; }
-        public boolean isHasMore() { return hasMore; }
-    }
-
-    public static class MonitoringPointMarker {
-        private final String pointCode;
-        private final String pointName;
-        private final Integer district;
-        private final String districtName;
-        private final String industry;
-        private final DeviceType deviceType;
-        private final CockpitMonitoringPointStatus pointStatus;
-        private final List<String> statusReasons;
-        private final BigDecimal longitude;
-        private final BigDecimal latitude;
-        private final CockpitCoordinateValidation coordinateValidation;
-
-        public MonitoringPointMarker(String pointCode, String pointName, Integer district, String industry,
-                DeviceType deviceType, CockpitMonitoringPointStatus pointStatus, List<String> statusReasons,
-                BigDecimal longitude, BigDecimal latitude, CockpitCoordinateValidation coordinateValidation) {
-            this.pointCode = pointCode;
-            this.pointName = pointName;
-            this.district = CockpitDistrict.codeOf(district);
-            this.districtName = CockpitDistrict.nameOf(this.district);
-            this.industry = industry;
-            this.deviceType = deviceType;
-            this.pointStatus = pointStatus;
-            this.statusReasons = statusReasons;
-            this.longitude = longitude;
-            this.latitude = latitude;
-            this.coordinateValidation = coordinateValidation;
-        }
-
-        public String getPointCode() { return pointCode; }
-        public String getPointName() { return pointName; }
-        public Integer getDistrict() { return district; }
-        public String getDistrictName() { return districtName; }
-        public String getIndustry() { return industry; }
-        public DeviceType getDeviceType() { return deviceType; }
-        public CockpitMonitoringPointStatus getPointStatus() { return pointStatus; }
-        public List<String> getStatusReasons() { return statusReasons; }
-        public BigDecimal getLongitude() { return longitude; }
-        public BigDecimal getLatitude() { return latitude; }
-        public CockpitCoordinateValidation getCoordinateValidation() { return coordinateValidation; }
-    }
-
-    public static class UnassociatedEquipmentMarker {
-        private final String deviceCode;
-        private final String deviceName;
-        private final Integer district;
-        private final String districtName;
-        private final String industry;
-        private final DeviceType deviceType;
-        private final CockpitMonitoringPointStatus pointStatus;
-        private final List<String> statusReasons;
-        private final BigDecimal longitude;
-        private final BigDecimal latitude;
-        private final CockpitCoordinateValidation coordinateValidation;
-
-        public UnassociatedEquipmentMarker(String deviceCode, String deviceName, Integer district, String industry,
-                DeviceType deviceType, CockpitMonitoringPointStatus pointStatus, List<String> statusReasons,
-                BigDecimal longitude, BigDecimal latitude, CockpitCoordinateValidation coordinateValidation) {
-            this.deviceCode = deviceCode;
-            this.deviceName = deviceName;
-            this.district = CockpitDistrict.codeOf(district);
-            this.districtName = CockpitDistrict.nameOf(this.district);
-            this.industry = industry;
-            this.deviceType = deviceType;
-            this.pointStatus = pointStatus;
-            this.statusReasons = statusReasons;
-            this.longitude = longitude;
-            this.latitude = latitude;
-            this.coordinateValidation = coordinateValidation;
-        }
-
-        public String getDeviceCode() { return deviceCode; }
-        public String getDeviceName() { return deviceName; }
-        public Integer getDistrict() { return district; }
-        public String getDistrictName() { return districtName; }
-        public String getIndustry() { return industry; }
-        public DeviceType getDeviceType() { return deviceType; }
-        public CockpitMonitoringPointStatus getPointStatus() { return pointStatus; }
-        public List<String> getStatusReasons() { return statusReasons; }
-        public BigDecimal getLongitude() { return longitude; }
-        public BigDecimal getLatitude() { return latitude; }
-        public CockpitCoordinateValidation getCoordinateValidation() { return coordinateValidation; }
-    }
-
-    public static class Excluded {
-        private final int unknownTypeCount;
-        private final int typeKindMismatchCount;
-        private final int nonFormalIndustryCount;
-        private final int invalidCoordinateCount;
-
-        public Excluded(int unknownTypeCount, int typeKindMismatchCount, int nonFormalIndustryCount,
-                int invalidCoordinateCount) {
-            this.unknownTypeCount = unknownTypeCount;
-            this.typeKindMismatchCount = typeKindMismatchCount;
-            this.nonFormalIndustryCount = nonFormalIndustryCount;
-            this.invalidCoordinateCount = invalidCoordinateCount;
-        }
-
-        public int getUnknownTypeCount() { return unknownTypeCount; }
-        public int getTypeKindMismatchCount() { return typeKindMismatchCount; }
-        public int getNonFormalIndustryCount() { return nonFormalIndustryCount; }
-        public int getInvalidCoordinateCount() { return invalidCoordinateCount; }
-    }
-}

+ 0 - 844
.scratch/cockpit-real-data-wayfinding/implementation/t29/baseline/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringDistributionService.java

@@ -1,844 +0,0 @@
-package com.zksy.cockpit.monitoring;
-
-import com.zksy.cockpit.foundation.CockpitCoordinateValidation;
-import com.zksy.cockpit.foundation.CockpitCoordinateValidator;
-import com.zksy.cockpit.foundation.CockpitDistrict;
-import com.zksy.cockpit.foundation.CockpitErrorCategory;
-import com.zksy.cockpit.foundation.CockpitErrorPayload;
-import com.zksy.cockpit.foundation.CockpitFreshnessStatus;
-import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
-import com.zksy.cockpit.foundation.CockpitSnapshotExpiredException;
-import com.zksy.cockpit.point.CockpitMonitoringPointDeviceSnapshot;
-import com.zksy.cockpit.point.CockpitMonitoringPointStatus;
-import com.zksy.cockpit.point.CockpitMonitoringPointStatusResolver;
-import org.springframework.stereotype.Service;
-
-import java.math.BigDecimal;
-import java.time.Clock;
-import java.time.OffsetDateTime;
-import java.time.ZoneId;
-import java.time.format.DateTimeFormatter;
-import java.time.format.DateTimeParseException;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.Comparator;
-import java.util.LinkedHashMap;
-import java.util.LinkedHashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-
-/**
- * Read-only aggregation for 运行监测 / 监测分布.
- *
- * <p>Statistics and pagination use selected business objects, including empty points
- * and objects without renderable coordinates. coordinateValidation controls map
- * presentation only; excluded counts rejected objects, not their associated devices.
- */
-@Service
-public class CockpitMonitoringDistributionService {
-    private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
-    private static final List<String> INDUSTRIES = Arrays.asList("GAS", "WATER_SUPPLY", "DRAINAGE", "MANHOLE");
-    private static final String SMART_DEVICE = "SMART_DEVICE";
-    private static final int DEFAULT_PAGE_SIZE = 500;
-    private static final int MAX_PAGE_SIZE = 1000;
-    /** 未分区在片区分桶里的哨兵 key:不用 null 做 key,且自然排序后落在所有合法片区之后。 */
-    private static final Integer UNASSIGNED_BUCKET = Integer.MAX_VALUE;
-    private static final String UNKNOWN_TYPE = "UNKNOWN_TYPE";
-    private static final String TYPE_KIND_MISMATCH = "TYPE_KIND_MISMATCH";
-    private static final String NON_FORMAL_INDUSTRY = "NON_FORMAL_INDUSTRY";
-
-    private final CockpitMonitoringDistributionAdapter adapter;
-    private final Clock clock;
-    private RuntimeState state;
-
-    public CockpitMonitoringDistributionService(
-            CockpitMonitoringDistributionAdapter adapter,
-            Clock clock) {
-        this.adapter = adapter;
-        this.clock = clock;
-    }
-
-    /**
-     * 监测分布。范围维度自执行票 12 起为**片区**(`district` 0/1/2 + `UNASSIGNED`):
-     * 不是 {@code 0/1/2/UNASSIGNED} 的入参一律 400 —— 脏值不得参与筛选(决策票 06)。
-     */
-    public synchronized CockpitMonitoringDistributionResponse load(
-            String requestId,
-            String industry,
-            String deviceType,
-            String pointStatus,
-            String district,
-            Integer page,
-            Integer pageSize,
-            String snapshotAt) {
-        String selectedIndustry = resolveIndustry(industry);
-        CockpitMonitoringPointStatus selectedStatus = resolvePointStatus(pointStatus);
-        CockpitDistrict.Query selectedDistrict = resolveDistrict(district);
-        int safePage = page == null ? 1 : page;
-        int safePageSize = pageSize == null ? DEFAULT_PAGE_SIZE : pageSize;
-        validatePagination(safePage, safePageSize, snapshotAt);
-        validateSnapshotAt(snapshotAt);
-        refreshIfNeeded(snapshotAt);
-        return build(
-                requestId,
-                selectedIndustry,
-                deviceType,
-                selectedStatus,
-                selectedDistrict,
-                safePage,
-                safePageSize);
-    }
-
-    public synchronized CockpitMonitoringDistributionResponse applyDeviceStatusDelta(
-            String requestId,
-            String industry,
-            String deviceType,
-            String pointStatus,
-            String district,
-            Integer page,
-            Integer pageSize,
-            String snapshotAt,
-            CockpitMonitoringDistributionDeviceStatusDelta delta) {
-        String selectedIndustry = resolveIndustry(industry);
-        CockpitMonitoringPointStatus selectedStatus = resolvePointStatus(pointStatus);
-        CockpitDistrict.Query selectedDistrict = resolveDistrict(district);
-        int safePage = page == null ? 1 : page;
-        int safePageSize = pageSize == null ? DEFAULT_PAGE_SIZE : pageSize;
-        validatePagination(safePage, safePageSize, snapshotAt);
-        validateSnapshotAt(snapshotAt);
-        refreshIfNeeded(snapshotAt);
-        if (delta == null || isBlank(delta.getDeviceCode())) {
-            throw new CockpitInvalidRequestException("device code is required");
-        }
-        OffsetDateTime serverUpdatedAt = parseTime(delta.getServerUpdatedAt(), "serverUpdatedAt");
-        DeviceRuntime previous = state.devices.get(delta.getDeviceCode().trim());
-        if (previous == null) {
-            return build(
-                    requestId,
-                    selectedIndustry,
-                    deviceType,
-                    selectedStatus,
-                    selectedDistrict,
-                    safePage,
-                    safePageSize);
-        }
-        if (previous.serverUpdatedAt != null
-                && !serverUpdatedAt.isAfter(previous.serverUpdatedAt)) {
-            return build(
-                    requestId,
-                    selectedIndustry,
-                    deviceType,
-                    selectedStatus,
-                    selectedDistrict,
-                    safePage,
-                    safePageSize);
-        }
-        state.devices.put(
-                previous.deviceCode,
-                new DeviceRuntime(
-                        previous.deviceCode,
-                        normalizeState(delta.getMonitoringState()),
-                        previous.pointCode,
-                        serverUpdatedAt));
-        return build(
-                requestId,
-                selectedIndustry,
-                deviceType,
-                selectedStatus,
-                selectedDistrict,
-                safePage,
-                safePageSize);
-    }
-
-    public CockpitErrorPayload invalidRequest(String requestId, String message) {
-        return new CockpitErrorPayload(
-                CockpitErrorCategory.INVALID_REQUEST,
-                message,
-                requestId,
-                businessTime());
-    }
-
-    public CockpitErrorPayload snapshotExpired(String requestId) {
-        return new CockpitErrorPayload(
-                CockpitErrorCategory.SNAPSHOT_EXPIRED,
-                "地图快照已失效,请重新加载",
-                requestId,
-                businessTime());
-    }
-
-    public CockpitErrorPayload serverError(String requestId) {
-        return new CockpitErrorPayload(
-                CockpitErrorCategory.SERVER_ERROR,
-                "驾驶舱监测分布数据暂不可用",
-                requestId,
-                businessTime());
-    }
-
-    private synchronized void refreshIfNeeded(String snapshotAt) {
-        if (state != null) {
-            return;
-        }
-        CockpitMonitoringDistributionSnapshot loaded = adapter.load(snapshotAt);
-        CockpitMonitoringDistributionSnapshot snapshot = loaded == null
-                ? CockpitMonitoringDistributionSnapshot.empty(resolveSnapshotAt(snapshotAt))
-                : loaded;
-        if (snapshot.getSnapshotAt() == null) {
-            throw new CockpitSnapshotExpiredException("snapshot timestamp missing");
-        }
-        if (snapshotAt != null && !snapshotAt.equals(snapshot.getSnapshotAt())) {
-            throw new CockpitSnapshotExpiredException("snapshot timestamp changed");
-        }
-        Map<String, DeviceRuntime> devices = new LinkedHashMap<>();
-        if (snapshot.getMonitoringPoints() != null) {
-            for (CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint point : snapshot.getMonitoringPoints()) {
-                if (point == null || point.getDevices() == null) {
-                    continue;
-                }
-                for (CockpitMonitoringPointDeviceSnapshot device : point.getDevices()) {
-                    if (device == null || isBlank(device.getDeviceCode())) {
-                        continue;
-                    }
-                    devices.putIfAbsent(
-                            device.getDeviceCode().trim(),
-                            new DeviceRuntime(
-                                    device.getDeviceCode().trim(),
-                                    device.isOnline() ? "ONLINE" : "OFFLINE",
-                                    point.getPointCode(),
-                                    null));
-                }
-            }
-        }
-        if (snapshot.getSmartDevices() != null) {
-            for (CockpitMonitoringDistributionSnapshot.SourceSmartDevice device : snapshot.getSmartDevices()) {
-                if (device == null || isBlank(device.getDeviceCode())) {
-                    continue;
-                }
-                devices.putIfAbsent(
-                        device.getDeviceCode().trim(),
-                        new DeviceRuntime(
-                                device.getDeviceCode().trim(),
-                                device.isOnline() ? "ONLINE" : "OFFLINE",
-                                null,
-                                null));
-            }
-        }
-        state = new RuntimeState(snapshot, devices);
-    }
-
-    private CockpitMonitoringDistributionResponse build(
-            String requestId,
-            String selectedIndustry,
-            String deviceType,
-            CockpitMonitoringPointStatus selectedStatus,
-            CockpitDistrict.Query selectedDistrict,
-            int page,
-            int pageSize) {
-        CockpitMonitoringDistributionSnapshot snapshot = state.snapshot;
-        Map<String, CockpitMonitoringDistributionSnapshot.SourceType> types = types(snapshot);
-
-        int unknownTypeCount = 0;
-        int typeKindMismatchCount = 0;
-        int nonFormalIndustryCount = 0;
-        // Compatibility field: coordinates no longer exclude business objects.
-        int invalidCoordinateCount = 0;
-
-        List<Marker> pointMarkers = new ArrayList<>();
-        List<Marker> deviceMarkers = new ArrayList<>();
-        Set<String> pointCodes = new LinkedHashSet<>();
-        Set<String> deviceCodes = new LinkedHashSet<>();
-        // Association is a source fact, independent of all display filters.
-        Set<String> associatedDeviceCodes = new LinkedHashSet<>();
-        if (snapshot.getMonitoringPoints() != null) {
-            for (CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint point : snapshot.getMonitoringPoints()) {
-                if (point != null && !isBlank(point.getPointCode())) {
-                    associatedDeviceCodes.addAll(codes(safeDevices(point.getDevices())));
-                }
-            }
-        }
-
-        if (snapshot.getMonitoringPoints() != null) {
-            for (CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint point : snapshot.getMonitoringPoints()) {
-                if (point == null || isBlank(point.getPointCode())) {
-                    continue;
-                }
-                CockpitMonitoringDistributionSnapshot.SourceType type = resolveType(types, point.getTypeCode());
-                String mismatch = classify(type);
-                List<CockpitMonitoringPointDeviceSnapshot> devices = safeDevices(point.getDevices());
-                boolean emptyPointWithUnknownType = UNKNOWN_TYPE.equals(mismatch) && devices.isEmpty();
-                if (mismatch != null && !emptyPointWithUnknownType) {
-                    if (UNKNOWN_TYPE.equals(mismatch)) {
-                        unknownTypeCount++;
-                    } else if (TYPE_KIND_MISMATCH.equals(mismatch)) {
-                        typeKindMismatchCount++;
-                    } else {
-                        nonFormalIndustryCount++;
-                    }
-                    continue;
-                }
-                String markerIndustry = type == null ? null : type.getIndustry();
-                String markerTypeCode = type == null ? null : type.getCode();
-                if (selectedIndustry != null && !selectedIndustry.equals(markerIndustry)) {
-                    continue;
-                }
-                if (!isBlank(deviceType) && !deviceType.trim().equals(markerTypeCode)) {
-                    continue;
-                }
-                CockpitCoordinateValidation validation = CockpitCoordinateValidator.validate(
-                        point.getLongitude(), point.getLatitude(), point.isCoordinateVerified());
-                // 点位片区按「经其上挂设备归纳」取得(适配器已归纳,这里只做归一与筛选)
-                Integer markerDistrict = CockpitDistrict.codeOf(point.getDistrict());
-                if (!CockpitDistrict.matches(selectedDistrict, markerDistrict)) {
-                    continue;
-                }
-                List<CockpitMonitoringPointDeviceSnapshot> effective = new ArrayList<>();
-                for (CockpitMonitoringPointDeviceSnapshot device : devices) {
-                    if (device == null || isBlank(device.getDeviceCode())) {
-                        continue;
-                    }
-                    effective.add(effectiveDevice(device));
-                }
-                CockpitMonitoringPointStatus status = CockpitMonitoringPointStatusResolver.resolve(effective);
-                if (selectedStatus != null && selectedStatus != status) {
-                    continue;
-                }
-                pointCodes.add(point.getPointCode().trim());
-                deviceCodes.addAll(codes(effective));
-                pointMarkers.add(new Marker(
-                        point.getPointCode().trim(),
-                        point.getPointName(),
-                        markerDistrict,
-                        markerIndustry,
-                        markerTypeCode,
-                        type == null ? "未知" : type.getName(),
-                        status,
-                        CockpitMonitoringPointStatusResolver.summaryReasons(status, effective),
-                        point.getLongitude(),
-                        point.getLatitude(),
-                        validation,
-                        true,
-                        codes(effective)));
-            }
-        }
-
-        if (snapshot.getSmartDevices() != null) {
-            for (CockpitMonitoringDistributionSnapshot.SourceSmartDevice device : snapshot.getSmartDevices()) {
-                if (device == null || isBlank(device.getDeviceCode())
-                        || associatedDeviceCodes.contains(device.getDeviceCode().trim())) {
-                    continue;
-                }
-                CockpitMonitoringDistributionSnapshot.SourceType type = resolveType(types, device.getTypeCode());
-                String mismatch = classify(type);
-                if (mismatch != null) {
-                    if (UNKNOWN_TYPE.equals(mismatch)) {
-                        unknownTypeCount++;
-                    } else if (TYPE_KIND_MISMATCH.equals(mismatch)) {
-                        typeKindMismatchCount++;
-                    } else {
-                        nonFormalIndustryCount++;
-                    }
-                    continue;
-                }
-                if (selectedIndustry != null && !selectedIndustry.equals(type.getIndustry())) {
-                    continue;
-                }
-                if (!isBlank(deviceType) && !deviceType.trim().equals(type.getCode())) {
-                    continue;
-                }
-                CockpitCoordinateValidation validation = CockpitCoordinateValidator.validate(
-                        device.getLongitude(), device.getLatitude(), device.isCoordinateVerified());
-                // 设备自身片区(equipment_base.district)即权威来源
-                Integer markerDistrict = CockpitDistrict.codeOf(device.getDistrict());
-                if (!CockpitDistrict.matches(selectedDistrict, markerDistrict)) {
-                    continue;
-                }
-                CockpitMonitoringPointDeviceSnapshot fact = new CockpitMonitoringPointDeviceSnapshot(
-                        device.getDeviceCode().trim(),
-                        device.getDeviceName(),
-                        type.getName(),
-                        device.getLongitude(),
-                         device.getLatitude(),
-                         validation,
-                         device.isOnline(),
-                        device.isAlarm(),
-                        device.isWarning(),
-                        device.isWorkOrderInProgress(),
-                        device.getStatusReasons(),
-                        device.getStatusTime());
-                CockpitMonitoringPointDeviceSnapshot applied = effectiveDevice(fact);
-                CockpitMonitoringPointStatus status = CockpitMonitoringPointStatusResolver.deviceStatus(applied);
-                if (selectedStatus != null && selectedStatus != status) {
-                    continue;
-                }
-                deviceCodes.add(applied.getDeviceCode().trim());
-                deviceMarkers.add(new Marker(
-                        applied.getDeviceCode().trim(),
-                        applied.getDeviceName(),
-                        markerDistrict,
-                        type.getIndustry(),
-                        type.getCode(),
-                        type.getName(),
-                        status,
-                        CockpitMonitoringPointStatusResolver.summaryReasons(
-                                status, Collections.singletonList(applied)),
-                        device.getLongitude(),
-                        device.getLatitude(),
-                        validation,
-                        false,
-                        Collections.emptyList()));
-            }
-        }
-
-        pointMarkers.sort(Comparator.comparing(Marker::getCode, Comparator.nullsLast(String::compareTo)));
-        deviceMarkers.sort(Comparator.comparing(Marker::getCode, Comparator.nullsLast(String::compareTo)));
-
-        // 片区分桶按**归一后的编码**归并:未分区(null)自成一桶,不与任何合法片区混同
-        Map<Integer, Accumulator> byDistrict = new LinkedHashMap<>();
-        Map<String, Accumulator> byIndustry = new LinkedHashMap<>();
-        Map<String, Accumulator> byType = new LinkedHashMap<>();
-        int[] statusCounts = new int[CockpitMonitoringPointStatus.values().length];
-        for (Marker marker : pointMarkers) {
-            accumulate(byDistrict, marker.district, marker);
-            accumulate(byIndustry, marker.industry, marker);
-            accumulate(byType, marker.typeCode, marker);
-            statusCounts[marker.status.ordinal()]++;
-        }
-        for (Marker marker : deviceMarkers) {
-            accumulate(byDistrict, marker.district, marker);
-            accumulate(byIndustry, marker.industry, marker);
-            accumulate(byType, marker.typeCode, marker);
-            statusCounts[marker.status.ordinal()]++;
-        }
-
-        List<CockpitMonitoringDistributionResponse.DistrictDistribution> districtRows =
-                districtRows(byDistrict);
-        List<CockpitMonitoringDistributionResponse.IndustryDistribution> industryRows =
-                industryRows(byIndustry);
-        List<CockpitMonitoringDistributionResponse.DeviceTypeDistribution> typeRows =
-                typeRows(byType);
-        List<CockpitMonitoringDistributionResponse.PointStatusCount> statusRows =
-                new ArrayList<>();
-        for (CockpitMonitoringPointStatus status : CockpitMonitoringPointStatus.values()) {
-            statusRows.add(new CockpitMonitoringDistributionResponse.PointStatusCount(
-                    status, statusCounts[status.ordinal()]));
-        }
-
-        int total = pointMarkers.size() + deviceMarkers.size();
-        int totalPages = total == 0 ? 0 : (total + pageSize - 1) / pageSize;
-        int fromIndex = Math.min((page - 1) * pageSize, total);
-        int toIndex = Math.min(fromIndex + pageSize, total);
-        int pointFrom = clamp(fromIndex, 0, pointMarkers.size());
-        int pointTo = clamp(toIndex, 0, pointMarkers.size());
-        int deviceFrom = clamp(fromIndex - pointMarkers.size(), 0, deviceMarkers.size());
-        int deviceTo = clamp(toIndex - pointMarkers.size(), 0, deviceMarkers.size());
-
-        List<CockpitMonitoringDistributionResponse.MonitoringPointMarker> pagePoints =
-                new ArrayList<>();
-        for (Marker marker : pointMarkers.subList(pointFrom, pointTo)) {
-            pagePoints.add(marker.toMonitoringPointMarker());
-        }
-        List<CockpitMonitoringDistributionResponse.UnassociatedEquipmentMarker> pageDevices =
-                new ArrayList<>();
-        for (Marker marker : deviceMarkers.subList(deviceFrom, deviceTo)) {
-            pageDevices.add(marker.toUnassociatedEquipmentMarker());
-        }
-
-        boolean hasResolved = total > 0;
-        String updatedAt = isBlank(snapshot.getUpdatedAt()) ? businessTime() : snapshot.getUpdatedAt();
-
-        return new CockpitMonitoringDistributionResponse(
-                requestId,
-                "v1",
-                true,
-                "监测分布",
-                "Asia/Shanghai",
-                new CockpitMonitoringDistributionResponse.Range("CURRENT_BASELINE"),
-                updatedAt,
-                new CockpitMonitoringDistributionResponse.Freshness(
-                        hasResolved ? CockpitFreshnessStatus.CURRENT.name()
-                                : CockpitFreshnessStatus.EMPTY.name(),
-                        hasResolved ? "READY" : "NO_RESOLVED_DEVICES",
-                        updatedAt,
-                        businessTime()),
-                districtRows,
-                industryRows,
-                typeRows,
-                statusRows,
-                new CockpitMonitoringDistributionResponse.MapFilter(
-                        selectedIndustry,
-                        isBlank(deviceType) ? null : deviceType.trim(),
-                        selectedStatus == null ? null : selectedStatus.name(),
-                        // 未筛与「只看未分区」的编码都是 null,靠 districtFilter 区分(口径同 map-snapshot)
-                        selectedDistrict.code(),
-                        !selectedDistrict.active() ? "NONE"
-                                : (selectedDistrict.unassignedOnly() ? "UNASSIGNED" : "ASSIGNED"),
-                        sorted(pointCodes),
-                        sorted(deviceCodes)),
-                new CockpitMonitoringDistributionResponse.Pagination(
-                        page, pageSize, total, totalPages, toIndex < total),
-                pagePoints,
-                pageDevices,
-                new CockpitMonitoringDistributionResponse.Excluded(
-                        unknownTypeCount,
-                        typeKindMismatchCount,
-                        nonFormalIndustryCount,
-                        invalidCoordinateCount));
-    }
-
-    private CockpitMonitoringPointDeviceSnapshot effectiveDevice(
-            CockpitMonitoringPointDeviceSnapshot source) {
-        DeviceRuntime runtime = state.devices.get(source.getDeviceCode().trim());
-        boolean online = runtime == null ? source.isOnline() : "ONLINE".equals(runtime.monitoringState);
-        return new CockpitMonitoringPointDeviceSnapshot(
-                source.getDeviceCode(),
-                source.getDeviceName(),
-                source.getDeviceTypeName(),
-                 source.getLongitude(),
-                 source.getLatitude(),
-                 source.getCoordinateValidation(),
-                online,
-                source.isAlarm(),
-                source.isWarning(),
-                source.isWorkOrderInProgress(),
-                source.getStatusReasons(),
-                source.getStatusTime());
-    }
-
-    /**
-     * 片区分桶:未分区用哨兵 key,避免把 null 当 Map key;出口再还原为响应契约的 {@code null}。
-     */
-    private static void accumulate(Map<Integer, Accumulator> buckets, Integer key, Marker marker) {
-        Accumulator accumulator = buckets.computeIfAbsent(
-                key == null ? UNASSIGNED_BUCKET : key, ignore -> new Accumulator());
-        accumulator.add(marker);
-    }
-
-    private static void accumulate(Map<String, Accumulator> buckets, String key, Marker marker) {
-        Accumulator accumulator = buckets.computeIfAbsent(key, ignore -> new Accumulator());
-        accumulator.add(marker);
-    }
-
-    /**
-     * 片区行顺序固定为 城南(0) → 城北(1) → 太常(2) → 未分区,保证响应可预期;
-     * 未分区是归一的必然结果,排最后(口径同基础设施基础档案的 districtDistribution)。
-     */
-    private static List<CockpitMonitoringDistributionResponse.DistrictDistribution> districtRows(
-            Map<Integer, Accumulator> buckets) {
-        List<Integer> keys = new ArrayList<>(buckets.keySet());
-        Collections.sort(keys);
-        List<CockpitMonitoringDistributionResponse.DistrictDistribution> rows = new ArrayList<>();
-        for (Integer key : keys) {
-            Accumulator accumulator = buckets.get(key);
-            // 哨兵 key 在出口还原为 null:响应层只认「未分区=编码 null + 名称未分区」
-            rows.add(new CockpitMonitoringDistributionResponse.DistrictDistribution(
-                    UNASSIGNED_BUCKET.equals(key) ? null : key,
-                    accumulator.pointCount,
-                    accumulator.deviceCount,
-                    accumulator.statusCounts()));
-        }
-        return rows;
-    }
-
-    private static List<CockpitMonitoringDistributionResponse.IndustryDistribution> industryRows(
-            Map<String, Accumulator> buckets) {
-        List<String> keys = new ArrayList<>(buckets.keySet());
-        Collections.sort(keys, Comparator
-                .comparingInt(CockpitMonitoringDistributionService::industryIndex)
-                .thenComparing(Comparator.nullsLast(String::compareTo)));
-        List<CockpitMonitoringDistributionResponse.IndustryDistribution> rows = new ArrayList<>();
-        for (String key : keys) {
-            Accumulator accumulator = buckets.get(key);
-            rows.add(new CockpitMonitoringDistributionResponse.IndustryDistribution(
-                    key, accumulator.pointCount, accumulator.deviceCount, accumulator.statusCounts()));
-        }
-        return rows;
-    }
-
-    private static List<CockpitMonitoringDistributionResponse.DeviceTypeDistribution> typeRows(
-            Map<String, Accumulator> buckets) {
-        List<String> keys = new ArrayList<>(buckets.keySet());
-        Collections.sort(keys, Comparator
-                .comparingInt((String key) -> buckets.get(key).deviceCount)
-                .reversed()
-                .thenComparing(Comparator.nullsLast(String::compareTo)));
-        List<CockpitMonitoringDistributionResponse.DeviceTypeDistribution> rows = new ArrayList<>();
-        for (String key : keys) {
-            Accumulator accumulator = buckets.get(key);
-            rows.add(new CockpitMonitoringDistributionResponse.DeviceTypeDistribution(
-                    new CockpitMonitoringDistributionResponse.DeviceType(
-                            key, accumulator.typeName),
-                    accumulator.industry,
-                    accumulator.pointCount,
-                    accumulator.deviceCount,
-                    accumulator.statusCounts()));
-        }
-        return rows;
-    }
-
-    private static int industryIndex(String industry) {
-        int index = INDUSTRIES.indexOf(industry);
-        return index < 0 ? Integer.MAX_VALUE : index;
-    }
-
-    private static Map<String, CockpitMonitoringDistributionSnapshot.SourceType> types(
-            CockpitMonitoringDistributionSnapshot snapshot) {
-        Map<String, CockpitMonitoringDistributionSnapshot.SourceType> result = new LinkedHashMap<>();
-        if (snapshot.getTypes() != null) {
-            for (CockpitMonitoringDistributionSnapshot.SourceType type : snapshot.getTypes()) {
-                if (type != null && !isBlank(type.getCode())) {
-                    result.putIfAbsent(type.getCode().trim(), type);
-                }
-            }
-        }
-        return result;
-    }
-
-    private static CockpitMonitoringDistributionSnapshot.SourceType resolveType(
-            Map<String, CockpitMonitoringDistributionSnapshot.SourceType> types,
-            String typeCode) {
-        return isBlank(typeCode) ? null : types.get(typeCode.trim());
-    }
-
-    private String classify(CockpitMonitoringDistributionSnapshot.SourceType type) {
-        if (type == null) {
-            return UNKNOWN_TYPE;
-        }
-        if (!SMART_DEVICE.equals(type.getObjectKind())) {
-            return TYPE_KIND_MISMATCH;
-        }
-        if (type.getIndustry() == null || !INDUSTRIES.contains(type.getIndustry())) {
-            return NON_FORMAL_INDUSTRY;
-        }
-        return null;
-    }
-
-    private static String resolveIndustry(String industry) {
-        if (isBlank(industry)) {
-            return null;
-        }
-        String resolved = industry.trim();
-        if (!INDUSTRIES.contains(resolved)) {
-            throw new CockpitInvalidRequestException("industry must be GAS, WATER_SUPPLY, DRAINAGE or MANHOLE");
-        }
-        return resolved;
-    }
-
-    /**
-     * 片区入参解析:不传=不筛,{@code 0/1/2} 按片区,{@code UNASSIGNED} 只看未分区;
-     * 其它值一律 400 —— 脏值不得静默变成「不筛」而返回全量。
-     */
-    private static CockpitDistrict.Query resolveDistrict(String district) {
-        CockpitDistrict.Query query = CockpitDistrict.parseQuery(district);
-        if (query.invalid()) {
-            throw new CockpitInvalidRequestException(
-                    "district must be 0, 1, 2 or UNASSIGNED");
-        }
-        return query;
-    }
-
-    private static CockpitMonitoringPointStatus resolvePointStatus(String pointStatus) {
-        if (isBlank(pointStatus)) {
-            return null;
-        }
-        String resolved = pointStatus.trim();
-        for (CockpitMonitoringPointStatus status : CockpitMonitoringPointStatus.values()) {
-            if (status.name().equals(resolved)) {
-                return status;
-            }
-        }
-        throw new CockpitInvalidRequestException(
-                "pointStatus must be ALARM, WARNING, WORK_ORDER, NORMAL, OFFLINE or EMPTY");
-    }
-
-    private static void validatePagination(int page, int pageSize, String snapshotAt) {
-        if (page < 1 || pageSize < 1 || pageSize > MAX_PAGE_SIZE
-                || (page > 1 && isBlank(snapshotAt))) {
-            throw new CockpitInvalidRequestException("监测分布分页参数不合法");
-        }
-    }
-
-    private static void validateSnapshotAt(String snapshotAt) {
-        if (isBlank(snapshotAt)) {
-            return;
-        }
-        try {
-            OffsetDateTime.parse(snapshotAt);
-        } catch (DateTimeParseException exception) {
-            throw new CockpitInvalidRequestException("监测分布分页参数不合法");
-        }
-    }
-
-    private static OffsetDateTime parseTime(String value, String fieldName) {
-        if (isBlank(value)) {
-            throw new CockpitInvalidRequestException(fieldName + " is required");
-        }
-        try {
-            return OffsetDateTime.parse(value);
-        } catch (DateTimeParseException exception) {
-            throw new CockpitInvalidRequestException("invalid " + fieldName);
-        }
-    }
-
-    private static String normalizeState(String value) {
-        if ("ONLINE".equals(value) || "OFFLINE".equals(value) || "STALE".equals(value)) {
-            return value;
-        }
-        return "UNKNOWN";
-    }
-
-    private String resolveSnapshotAt(String snapshotAt) {
-        return snapshotAt == null ? businessTime() : snapshotAt;
-    }
-
-    private String businessTime() {
-        return DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(
-                OffsetDateTime.now(clock).atZoneSameInstant(BUSINESS_ZONE).toOffsetDateTime());
-    }
-
-    private static List<String> codes(List<CockpitMonitoringPointDeviceSnapshot> devices) {
-        List<String> result = new ArrayList<>();
-        for (CockpitMonitoringPointDeviceSnapshot device : devices) {
-            if (device != null && !isBlank(device.getDeviceCode())) {
-                result.add(device.getDeviceCode().trim());
-            }
-        }
-        return result;
-    }
-
-    private static List<String> sorted(Set<String> values) {
-        List<String> result = new ArrayList<>(values);
-        Collections.sort(result, Comparator.nullsLast(String::compareTo));
-        return result;
-    }
-
-    private static int clamp(int value, int minimum, int maximum) {
-        return Math.max(minimum, Math.min(value, maximum));
-    }
-
-    private static List<CockpitMonitoringPointDeviceSnapshot> safeDevices(
-            List<CockpitMonitoringPointDeviceSnapshot> devices) {
-        return devices == null ? Collections.emptyList() : devices;
-    }
-
-    private static boolean isBlank(String value) {
-        return value == null || value.trim().isEmpty();
-    }
-
-    private static class RuntimeState {
-        private final CockpitMonitoringDistributionSnapshot snapshot;
-        private final Map<String, DeviceRuntime> devices;
-
-        private RuntimeState(
-                CockpitMonitoringDistributionSnapshot snapshot,
-                Map<String, DeviceRuntime> devices) {
-            this.snapshot = snapshot;
-            this.devices = devices;
-        }
-    }
-
-    private static class DeviceRuntime {
-        private final String deviceCode;
-        private final String monitoringState;
-        private final String pointCode;
-        private final OffsetDateTime serverUpdatedAt;
-
-        private DeviceRuntime(
-                String deviceCode,
-                String monitoringState,
-                String pointCode,
-                OffsetDateTime serverUpdatedAt) {
-            this.deviceCode = deviceCode;
-            this.monitoringState = monitoringState;
-            this.pointCode = pointCode;
-            this.serverUpdatedAt = serverUpdatedAt;
-        }
-    }
-
-    private static class Marker {
-        private final String code;
-        private final String name;
-        private final Integer district;
-        private final String industry;
-        private final String typeCode;
-        private final String typeName;
-        private final CockpitMonitoringPointStatus status;
-        private final List<String> statusReasons;
-        private final BigDecimal longitude;
-        private final BigDecimal latitude;
-        private final CockpitCoordinateValidation coordinateValidation;
-        private final boolean point;
-        private final List<String> deviceCodes;
-
-        private Marker(String code, String name, Integer district, String industry, String typeCode, String typeName,
-                CockpitMonitoringPointStatus status, List<String> statusReasons, BigDecimal longitude,
-                BigDecimal latitude, CockpitCoordinateValidation coordinateValidation, boolean point,
-                List<String> deviceCodes) {
-            this.code = code;
-            this.name = name;
-            this.district = district;
-            this.industry = industry;
-            this.typeCode = typeCode;
-            this.typeName = typeName;
-            this.status = status;
-            this.statusReasons = statusReasons;
-            this.longitude = longitude;
-            this.latitude = latitude;
-            this.coordinateValidation = coordinateValidation;
-            this.point = point;
-            this.deviceCodes = deviceCodes;
-        }
-
-        private String getCode() {
-            return code;
-        }
-
-        private CockpitMonitoringDistributionResponse.MonitoringPointMarker toMonitoringPointMarker() {
-            return new CockpitMonitoringDistributionResponse.MonitoringPointMarker(
-                    code, name, district, industry,
-                    new CockpitMonitoringDistributionResponse.DeviceType(typeCode, typeName),
-                    status, statusReasons, longitude, latitude, coordinateValidation);
-        }
-
-        private CockpitMonitoringDistributionResponse.UnassociatedEquipmentMarker toUnassociatedEquipmentMarker() {
-            return new CockpitMonitoringDistributionResponse.UnassociatedEquipmentMarker(
-                    code, name, district, industry,
-                    new CockpitMonitoringDistributionResponse.DeviceType(typeCode, typeName),
-                    status, statusReasons, longitude, latitude, coordinateValidation);
-        }
-    }
-
-    private static class Accumulator {
-        private final int[] statusCounts = new int[CockpitMonitoringPointStatus.values().length];
-        private int pointCount;
-        private int deviceCount;
-        private String typeName;
-        private String industry;
-
-        private void add(Marker marker) {
-            if (marker.point) {
-                pointCount++;
-                deviceCount += marker.deviceCodes.size();
-            } else {
-                deviceCount++;
-            }
-            statusCounts[marker.status.ordinal()]++;
-            if (typeName == null) {
-                typeName = marker.typeName;
-            }
-            if (industry == null) {
-                industry = marker.industry;
-            }
-        }
-
-        private CockpitMonitoringDistributionResponse.StatusCounts statusCounts() {
-            return new CockpitMonitoringDistributionResponse.StatusCounts(
-                    statusCounts[CockpitMonitoringPointStatus.ALARM.ordinal()],
-                    statusCounts[CockpitMonitoringPointStatus.WARNING.ordinal()],
-                    statusCounts[CockpitMonitoringPointStatus.WORK_ORDER.ordinal()],
-                    statusCounts[CockpitMonitoringPointStatus.NORMAL.ordinal()],
-                    statusCounts[CockpitMonitoringPointStatus.OFFLINE.ordinal()],
-                    statusCounts[CockpitMonitoringPointStatus.EMPTY.ordinal()]);
-        }
-    }
-}

+ 0 - 259
.scratch/cockpit-real-data-wayfinding/implementation/t29/baseline/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringDistributionSnapshot.java

@@ -1,259 +0,0 @@
-package com.zksy.cockpit.monitoring;
-
-import com.zksy.cockpit.point.CockpitMonitoringPointDeviceSnapshot;
-
-import java.math.BigDecimal;
-import java.util.Collections;
-import java.util.List;
-
-/**
- * Read snapshot for 运行监测 / 监测分布.
- *
- * <p>Contract notes:
- * <ul>
- *   <li>Associated devices are described through their monitoring point; the point's
- *       typeCode is the shared smart-device type of its devices.</li>
- *   <li>Unassociated smart devices are described standalone and render as their own
- *       map marker when their coordinates are valid and verified.</li>
- * </ul>
- */
-public class CockpitMonitoringDistributionSnapshot {
-    private final String snapshotAt;
-    private final String updatedAt;
-    private final List<SourceType> types;
-    private final List<SourceMonitoringPoint> monitoringPoints;
-    private final List<SourceSmartDevice> smartDevices;
-
-    public CockpitMonitoringDistributionSnapshot(
-            String snapshotAt,
-            String updatedAt,
-            List<SourceType> types,
-            List<SourceMonitoringPoint> monitoringPoints,
-            List<SourceSmartDevice> smartDevices) {
-        this.snapshotAt = snapshotAt;
-        this.updatedAt = updatedAt;
-        this.types = types;
-        this.monitoringPoints = monitoringPoints;
-        this.smartDevices = smartDevices;
-    }
-
-    public static CockpitMonitoringDistributionSnapshot empty(String snapshotAt) {
-        return new CockpitMonitoringDistributionSnapshot(
-                snapshotAt,
-                null,
-                Collections.emptyList(),
-                Collections.emptyList(),
-                Collections.emptyList());
-    }
-
-    public String getSnapshotAt() {
-        return snapshotAt;
-    }
-
-    public String getUpdatedAt() {
-        return updatedAt;
-    }
-
-    public List<SourceType> getTypes() {
-        return types;
-    }
-
-    public List<SourceMonitoringPoint> getMonitoringPoints() {
-        return monitoringPoints;
-    }
-
-    public List<SourceSmartDevice> getSmartDevices() {
-        return smartDevices;
-    }
-
-    public static class SourceType {
-        private final String code;
-        private final String name;
-        private final String industry;
-        private final String objectKind;
-
-        public SourceType(String code, String name, String industry, String objectKind) {
-            this.code = code;
-            this.name = name;
-            this.industry = industry;
-            this.objectKind = objectKind;
-        }
-
-        public String getCode() {
-            return code;
-        }
-
-        public String getName() {
-            return name;
-        }
-
-        public String getIndustry() {
-            return industry;
-        }
-
-        public String getObjectKind() {
-            return objectKind;
-        }
-    }
-
-    public static class SourceMonitoringPoint {
-        private final String pointCode;
-        private final String pointName;
-        private final Integer district;
-        private final String typeCode;
-        private final BigDecimal longitude;
-        private final BigDecimal latitude;
-        private final boolean coordinateVerified;
-        private final List<CockpitMonitoringPointDeviceSnapshot> devices;
-
-        public SourceMonitoringPoint(
-                String pointCode,
-                String pointName,
-                Integer district,
-                String typeCode,
-                BigDecimal longitude,
-                BigDecimal latitude,
-                boolean coordinateVerified,
-                List<CockpitMonitoringPointDeviceSnapshot> devices) {
-            this.pointCode = pointCode;
-            this.pointName = pointName;
-            this.district = district;
-            this.typeCode = typeCode;
-            this.longitude = longitude;
-            this.latitude = latitude;
-            this.coordinateVerified = coordinateVerified;
-            this.devices = devices;
-        }
-
-        public String getPointCode() {
-            return pointCode;
-        }
-
-        public String getPointName() {
-            return pointName;
-        }
-
-        /** 点位无自有片区列,按「经其上挂设备归纳」取得;未分区为 {@code null}。 */
-        public Integer getDistrict() {
-            return district;
-        }
-
-        public String getTypeCode() {
-            return typeCode;
-        }
-
-        public BigDecimal getLongitude() {
-            return longitude;
-        }
-
-        public BigDecimal getLatitude() {
-            return latitude;
-        }
-
-        public boolean isCoordinateVerified() {
-            return coordinateVerified;
-        }
-
-        public List<CockpitMonitoringPointDeviceSnapshot> getDevices() {
-            return devices;
-        }
-    }
-
-    public static class SourceSmartDevice {
-        private final String deviceCode;
-        private final String deviceName;
-        private final Integer district;
-        private final String typeCode;
-        private final BigDecimal longitude;
-        private final BigDecimal latitude;
-        private final boolean coordinateVerified;
-        private final boolean online;
-        private final boolean alarm;
-        private final boolean warning;
-        private final boolean workOrderInProgress;
-        private final List<String> statusReasons;
-        private final String statusTime;
-
-        public SourceSmartDevice(
-                String deviceCode,
-                String deviceName,
-                Integer district,
-                String typeCode,
-                BigDecimal longitude,
-                BigDecimal latitude,
-                boolean coordinateVerified,
-                boolean online,
-                boolean alarm,
-                boolean warning,
-                boolean workOrderInProgress,
-                List<String> statusReasons,
-                String statusTime) {
-            this.deviceCode = deviceCode;
-            this.deviceName = deviceName;
-            this.district = district;
-            this.typeCode = typeCode;
-            this.longitude = longitude;
-            this.latitude = latitude;
-            this.coordinateVerified = coordinateVerified;
-            this.online = online;
-            this.alarm = alarm;
-            this.warning = warning;
-            this.workOrderInProgress = workOrderInProgress;
-            this.statusReasons = statusReasons;
-            this.statusTime = statusTime;
-        }
-
-        public String getDeviceCode() {
-            return deviceCode;
-        }
-
-        public String getDeviceName() {
-            return deviceName;
-        }
-
-        /** 设备自身片区(equipment_base.district)即权威来源;未分区为 {@code null}。 */
-        public Integer getDistrict() {
-            return district;
-        }
-
-        public String getTypeCode() {
-            return typeCode;
-        }
-
-        public BigDecimal getLongitude() {
-            return longitude;
-        }
-
-        public BigDecimal getLatitude() {
-            return latitude;
-        }
-
-        public boolean isCoordinateVerified() {
-            return coordinateVerified;
-        }
-
-        public boolean isOnline() {
-            return online;
-        }
-
-        public boolean isAlarm() {
-            return alarm;
-        }
-
-        public boolean isWarning() {
-            return warning;
-        }
-
-        public boolean isWorkOrderInProgress() {
-            return workOrderInProgress;
-        }
-
-        public List<String> getStatusReasons() {
-            return statusReasons;
-        }
-
-        public String getStatusTime() {
-            return statusTime;
-        }
-    }
-}

+ 0 - 135
.scratch/cockpit-real-data-wayfinding/implementation/t29/baseline/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/point/BusinessCockpitDeviceFactResolver.java

@@ -1,135 +0,0 @@
-package com.zksy.cockpit.point;
-
-import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
-import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
-import com.zksy.base.domain.EquipmentBase;
-import com.zksy.base.domain.EquipmentStatus;
-import com.zksy.base.service.EquipmentStatusService;
-import com.zksy.base.warning.domain.EarlyWarning;
-import com.zksy.base.warning.service.EarlyWarningService;
-import com.zksy.ops.domain.WorkOrder;
-import com.zksy.ops.service.WorkOrderService;
-import org.springframework.stereotype.Component;
-
-import java.time.ZoneId;
-import java.time.format.DateTimeFormatter;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.LinkedHashSet;
-import java.util.List;
-import java.util.Set;
-
-/**
- * 从现网既有表组合出「单台设备的实时事实」(在线/报警/预警/在途工单),供各只读适配器复用。
- *
- * <p>只读、不新增表、不加投影;任何一项数据缺失都按保守口径降级(缺状态=离线),
- * 不为了「看起来正常」而伪造在线/无警状态。
- */
-@Component
-public class BusinessCockpitDeviceFactResolver {
-
-    private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
-    private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
-
-    /** 未关闭的预警状态:现网取值含 PENDING/PROCESSING 等。 */
-    private static final Set<String> OPEN_WARNING_STATUS = new LinkedHashSet<>(
-            Arrays.asList("PENDING", "PROCESSING", "HANDLING", "RELEASED"));
-
-    private final EquipmentStatusService equipmentStatusService;
-    private final EarlyWarningService earlyWarningService;
-    private final WorkOrderService workOrderService;
-
-    public BusinessCockpitDeviceFactResolver(
-            EquipmentStatusService equipmentStatusService,
-            EarlyWarningService earlyWarningService,
-            WorkOrderService workOrderService) {
-        this.equipmentStatusService = equipmentStatusService;
-        this.earlyWarningService = earlyWarningService;
-        this.workOrderService = workOrderService;
-    }
-
-    public CockpitMonitoringPointDeviceSnapshot resolve(EquipmentBase device, String deviceTypeName) {
-        String deviceCode = device.getEquipmentCode().trim();
-        EquipmentStatus status = null;
-        try {
-            status = equipmentStatusService.getByDeviceCode(deviceCode);
-        } catch (Exception ignored) {
-            // 状态缺失按离线处理,不阻断整张图
-        }
-        boolean online = status != null && Integer.valueOf(1).equals(status.getOnlineStatus());
-        boolean alarm = status != null && Integer.valueOf(1).equals(status.getAlarmStatus());
-
-        List<String> reasons = new ArrayList<>();
-        if (alarm) {
-            reasons.add(deviceCode + " 报警");
-        }
-        if (!online && status != null) {
-            reasons.add(deviceCode + " 离线");
-        }
-        if (status == null) {
-            reasons.add(deviceCode + " 无状态记录");
-        }
-
-        return new CockpitMonitoringPointDeviceSnapshot(
-                deviceCode,
-                device.getEquipmentName(),
-                deviceTypeName,
-                 device.getLongitude(),
-                 device.getLatitude(),
-                 false,
-                online,
-                alarm,
-                hasOpenWarning(deviceCode),
-                hasOpenWorkOrder(device.getEquipmentId()),
-                reasons,
-                status == null || status.getStatusUpdateTime() == null
-                        ? null
-                        : TIME_FORMATTER.format(status.getStatusUpdateTime()
-                                .atZone(BUSINESS_ZONE).toOffsetDateTime()));
-    }
-
-    private boolean hasOpenWarning(String deviceCode) {
-        try {
-            List<EarlyWarning> warnings = safeList(earlyWarningService.list(
-                    new LambdaQueryWrapper<EarlyWarning>().eq(EarlyWarning::getDeviceCode, deviceCode)));
-            return warnings.stream()
-                    .anyMatch(warning -> warning != null
-                            && warning.getStatus() != null
-                            && OPEN_WARNING_STATUS.contains(warning.getStatus().trim().toUpperCase()));
-        } catch (Exception ignored) {
-            return false;
-        }
-    }
-
-    /**
-     * 在途工单:现网 {@code work_order.order_status} 是 0-based
-     * (0 待派单、1 待接单、2 已接单、3 处理中、4 待验收、5 已结案、6 延期中、7 已取消),
-     * 与 cockpit 冻结的 1-based 七态**不同值域**;此处只判「是否在途」,不冒充冻结字典。
-     */
-    private boolean hasOpenWorkOrder(String deviceId) {
-        if (deviceId == null || deviceId.trim().isEmpty()) {
-            return false;
-        }
-        try {
-            List<WorkOrder> orders = safeList(workOrderService.list(
-                    new QueryWrapper<WorkOrder>().eq("device_id", deviceId)));
-            return orders.stream()
-                    .anyMatch(order -> order != null && isOpenOrderStatus(order.getOrderStatus()));
-        } catch (Exception ignored) {
-            return false;
-        }
-    }
-
-    private static boolean isOpenOrderStatus(Integer orderStatus) {
-        if (orderStatus == null) {
-            return false;
-        }
-        // 0-based:0 待派单、1 待接单、2 已接单、3 处理中、4 待验收、6 延期中 均为在途;5 已结案、7 已取消为终态。
-        return (orderStatus >= 0 && orderStatus <= 4) || orderStatus == 6;
-    }
-
-    private static <T> List<T> safeList(List<T> value) {
-        return value == null ? Collections.emptyList() : value;
-    }
-}

+ 0 - 69
.scratch/cockpit-real-data-wayfinding/implementation/t29/baseline/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/point/CockpitMonitoringPointDeviceSnapshot.java

@@ -1,69 +0,0 @@
-package com.zksy.cockpit.point;
-
-import com.zksy.cockpit.foundation.CockpitCoordinateValidation;
-import com.zksy.cockpit.foundation.CockpitCoordinateValidator;
-
-import java.math.BigDecimal;
-import java.util.List;
-
-public class CockpitMonitoringPointDeviceSnapshot {
-    private final String deviceCode;
-    private final String deviceName;
-    private final String deviceTypeName;
-    private final BigDecimal longitude;
-    private final BigDecimal latitude;
-    private final CockpitCoordinateValidation coordinateValidation;
-    private final boolean online;
-    private final boolean alarm;
-    private final boolean warning;
-    private final boolean workOrderInProgress;
-    private final List<String> statusReasons;
-    private final String statusTime;
-
-    public CockpitMonitoringPointDeviceSnapshot(String deviceCode, String deviceName, String deviceTypeName,
-            boolean online, boolean alarm, boolean warning, boolean workOrderInProgress,
-            List<String> statusReasons, String statusTime) {
-        this(deviceCode, deviceName, deviceTypeName, null, null, false,
-                online, alarm, warning, workOrderInProgress, statusReasons, statusTime);
-    }
-
-    public CockpitMonitoringPointDeviceSnapshot(String deviceCode, String deviceName, String deviceTypeName,
-            BigDecimal longitude, BigDecimal latitude, boolean coordinateVerified,
-            boolean online, boolean alarm, boolean warning, boolean workOrderInProgress,
-            List<String> statusReasons, String statusTime) {
-        this(deviceCode, deviceName, deviceTypeName, longitude, latitude,
-                CockpitCoordinateValidator.validate(longitude, latitude, coordinateVerified),
-                online, alarm, warning, workOrderInProgress, statusReasons, statusTime);
-    }
-
-    public CockpitMonitoringPointDeviceSnapshot(String deviceCode, String deviceName, String deviceTypeName,
-            BigDecimal longitude, BigDecimal latitude, CockpitCoordinateValidation coordinateValidation,
-            boolean online, boolean alarm, boolean warning, boolean workOrderInProgress,
-            List<String> statusReasons, String statusTime) {
-        this.deviceCode = deviceCode;
-        this.deviceName = deviceName;
-        this.deviceTypeName = deviceTypeName;
-        this.longitude = longitude;
-        this.latitude = latitude;
-        this.coordinateValidation = coordinateValidation;
-        this.online = online;
-        this.alarm = alarm;
-        this.warning = warning;
-        this.workOrderInProgress = workOrderInProgress;
-        this.statusReasons = statusReasons;
-        this.statusTime = statusTime;
-    }
-
-    public String getDeviceCode() { return deviceCode; }
-    public String getDeviceName() { return deviceName; }
-    public String getDeviceTypeName() { return deviceTypeName; }
-    public BigDecimal getLongitude() { return longitude; }
-    public BigDecimal getLatitude() { return latitude; }
-    public CockpitCoordinateValidation getCoordinateValidation() { return coordinateValidation; }
-    public boolean isOnline() { return online; }
-    public boolean isAlarm() { return alarm; }
-    public boolean isWarning() { return warning; }
-    public boolean isWorkOrderInProgress() { return workOrderInProgress; }
-    public List<String> getStatusReasons() { return statusReasons; }
-    public String getStatusTime() { return statusTime; }
-}

+ 0 - 0
.scratch/cockpit-real-data-wayfinding/implementation/t29/baseline/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/point/CockpitPointStatusQuery.java


+ 0 - 29
.scratch/cockpit-real-data-wayfinding/implementation/t29/browser-verification.md

@@ -1,29 +0,0 @@
-# 真实浏览器八桶验收
-
-时间:2026-10-12,约 10:39–10:54(本机时间)。入口 `http://localhost:8001/#/cockpit`,本机 real 配置指向 `http://192.168.110.96:8301/api/cockpit/v1`。
-
-使用 browser-skill 的 Agent Window 与真实后端,无请求 mock 或数据改写。会话 `rwyj`,标签 `1644488067`;先以平板视口读页面,后调整到 1900×1000。依次进入系统、运行监测,使用按钮原生键盘 Space 操作状态栏。
-
-| 验收 | 实测 |
-| --- | --- |
-| 全局八桶已发布 | 报警/预警/工单处置/正常/离线=0;无设备=6;状态未发布=0;无关联设备=7 |
-| 初始地图对象 | 加载完成,已加载13;无地图位置13;坐标未校验13 |
-| 饼图真实计数 | SVG image「点位状态分布(全局未筛)」有「无设备6」「无关联设备7」两段,八行图例与顶部完全相同 |
-| 报警筛选 | 已加载1;分布口径显示「生效状态 报警」「监测点总数1」 |
-| 多选报警+预警 | 已加载2;分布口径显示「生效状态 报警、预警」「监测点总数2」 |
-| 筛选时全局桶不变 | 两种筛选后状态栏/饼图仍为 EMPTY=6、UNASSOCIATED=7,其余0 |
-| 选中反馈 | 报警按钮HTML含 class is-selected、data-status-selected=yes、aria-pressed=true;混选页面回显报警、预警 |
-| 坐标真实边界 | 13→1→2 都显示相应未校验数,实际可上图始终0,未把坐标校验状态改成已校验 |
-
-操作后取自工具的报警按钮关键HTML:
-
-```html
-<button type="button" class="map-holo__legend-row is-selected"
- data-legend-key="ALARM" data-status-selected="yes" data-status-count="0"
- title="取消「报警」筛选(计数 0 为全局未筛口径,不随筛选变化)"
- aria-pressed="true">报警 0</button>
-```
-
-同轮[真实GET探针](real-probe-new-runtime.json)45/45通过,含CSV、重复参数、非法值400、类别混选与分页token。先前[旧版本探针](real-probe-old-runtime.json)保留为历史记录;之后运行环境已更新,新探针与本次真实浏览器作为收口证据。
-
-限制:本次浏览器动态流量capture未成功建立,故不宣称浏览器请求总数或0网络错误;HTTP契约证据来自真实GET探针。UNKNOWN真实点位样本未出现(既有解析器真实发布六态),UNKNOWN设备/旧缓存行为由已通过契约测试覆盖。未修改前端源码。前端面板仍有既有标签把总序列13写作「监测点总数」,这不改变本票八桶及筛选验收结果,消费侧另票负责。

+ 0 - 21
.scratch/cockpit-real-data-wayfinding/implementation/t29/commit-backend.py

@@ -1,21 +0,0 @@
-from pathlib import Path
-import json
-import subprocess
-
-root = Path(__file__).resolve().parent
-target = Path('E:/pipe-ner-server').resolve()
-manifest = json.loads((root/'manifest.json').read_text(encoding='utf-8'))
-paths = [row['path'] for row in manifest]
-for rel in paths:
-    actual = (target/rel).read_bytes().replace(b'\r\n',b'\n')
-    expected = (root/rel).read_bytes().replace(b'\r\n',b'\n')
-    if actual != expected:
-        raise SystemExit('Concurrent changes: do not stage or commit ' + rel)
-subprocess.run(['git','diff','--check'],cwd=target,check=True)
-subprocess.run(['git','diff','--cached','--quiet'],cwd=target,check=True)
-subprocess.run(['git','add','--']+paths,cwd=target,check=True)
-# Only this ticket's 18 selected files; shared distribution prerequisite is included and documented.
-subprocess.run(['git','diff','--cached','--stat'],cwd=target,check=True)
-subprocess.run(['git','commit','-m','fix(cockpit): align map and monitoring pointStatus eight-bucket contract',
-                '-m','Support CSV/repeated status filters, stable global counts and explicit device status publication. Preserve empty-point distribution prerequisites. Add cross-endpoint, cancelled-order and Redis compatibility coverage. Verified 75 focused tests, 429 cockpit tests, 45 real GET assertions and browser filtering 13 -> 1 -> 2.'],cwd=target,check=True)
-subprocess.run(['git','log','-1','--format=%H %s'],cwd=target,check=True)

+ 0 - 254
.scratch/cockpit-real-data-wayfinding/implementation/t29/edit-staged.py

@@ -1,254 +0,0 @@
-from pathlib import Path
-root = Path(__file__).parent / 'pipe-network-service'
-system = root / 'zksy-system/src/main/java/com/zksy/cockpit'
-admin = root / 'zksy-admin/src/main/java/com/zksy/web/controller/cockpit'
-
-def change(path, old, new, count=1):
-    text = path.read_text(encoding='utf-8')
-    assert text.count(old) == count, (path.name, old[:90], text.count(old), count)
-    path.write_text(text.replace(old, new), encoding='utf-8', newline='\n')
-
-p = system / 'point/CockpitMonitoringPointDeviceSnapshot.java'
-change(p, '    private final String statusTime;', '    private final String statusTime;\n    private final boolean statusPublished;')
-change(p, '        this.deviceCode = deviceCode;', '''        this(deviceCode, deviceName, deviceTypeName, longitude, latitude, coordinateValidation,
-                online, alarm, warning, workOrderInProgress, statusReasons, statusTime, true);
-    }
-
-    public CockpitMonitoringPointDeviceSnapshot(String deviceCode, String deviceName, String deviceTypeName,
-            BigDecimal longitude, BigDecimal latitude, CockpitCoordinateValidation coordinateValidation,
-            boolean online, boolean alarm, boolean warning, boolean workOrderInProgress,
-            List<String> statusReasons, String statusTime, boolean statusPublished) {
-        this.deviceCode = deviceCode;''')
-change(p, '        this.statusTime = statusTime;', '        this.statusTime = statusTime;\n        this.statusPublished = statusPublished;')
-change(p, '    public String getStatusTime() { return statusTime; }', '''    public String getStatusTime() { return statusTime; }
-    public boolean isStatusPublished() { return statusPublished; }
-    /** 未发布状态与明确离线分离;点位六态聚合仍沿用既有解析器。 */
-    public String getPublishedDeviceStatus() {
-        return statusPublished ? CockpitMonitoringPointStatusResolver.deviceStatus(this).name() : null;
-    }''')
-p = system / 'point/BusinessCockpitDeviceFactResolver.java'
-change(p, 'import com.zksy.base.domain.EquipmentStatus;', 'import com.zksy.base.domain.EquipmentStatus;\nimport com.zksy.cockpit.foundation.CockpitCoordinateValidator;')
-change(p, '                 false,', '                 CockpitCoordinateValidator.validate(device.getLongitude(), device.getLatitude(), false),')
-change(p, '.atZone(BUSINESS_ZONE).toOffsetDateTime()));', '.atZone(BUSINESS_ZONE).toOffsetDateTime()),\n                status != null);')
-
-p = system / 'map/CockpitMapSnapshot.java'
-change(p, '''        private final boolean coordinateVerified;
-
-        public SourceSmartDevice(String deviceCode, String deviceName, Integer district, String typeCode,
-                BigDecimal longitude, BigDecimal latitude, boolean coordinateVerified) {''', '''        private final boolean coordinateVerified;
-        private final String deviceStatus;
-
-        public SourceSmartDevice(String deviceCode, String deviceName, Integer district, String typeCode,
-                BigDecimal longitude, BigDecimal latitude, boolean coordinateVerified) {
-            this(deviceCode, deviceName, district, typeCode, longitude, latitude, coordinateVerified, null);
-        }
-
-        public SourceSmartDevice(String deviceCode, String deviceName, Integer district, String typeCode,
-                BigDecimal longitude, BigDecimal latitude, boolean coordinateVerified, String deviceStatus) {''')
-change(p, '''            this.coordinateVerified = coordinateVerified;
-        }
-
-        public String getDeviceCode()''', '''            this.coordinateVerified = coordinateVerified;
-            this.deviceStatus = deviceStatus;
-        }
-
-        public String getDeviceStatus() { return deviceStatus; }
-        public String getDeviceCode()''')
-p = system / 'map/BusinessCockpitMapSnapshotAdapter.java'
-change(p, '''                    device.getLatitude(),
-                    false));''', '''                    device.getLatitude(),
-                    false,
-                    deviceFactResolver.resolve(device, type == null ? null : type.getTypeName())
-                            .getPublishedDeviceStatus()));''')
-p = system / 'map/CockpitUnassociatedEquipmentSummary.java'
-change(p, '    private final CockpitCoordinateValidation coordinateValidation;', '    private final CockpitCoordinateValidation coordinateValidation;\n    private final String deviceStatus;')
-change(p, 'BigDecimal longitude, BigDecimal latitude, CockpitCoordinateValidation coordinateValidation) {', 'BigDecimal longitude, BigDecimal latitude, CockpitCoordinateValidation coordinateValidation,\n            String deviceStatus) {')
-change(p, '        this.coordinateValidation = coordinateValidation;', '        this.coordinateValidation = coordinateValidation;\n        this.deviceStatus = deviceStatus;')
-change(p, '    public String getDeviceCode()', '    public String getDeviceStatus() { return deviceStatus; }\n    public String getDeviceCode()')
-
-p = system / 'map/CockpitMapSnapshotResponse.java'
-change(p, 'import java.util.List;', 'import java.util.List;\nimport com.zksy.cockpit.point.CockpitPointStatusQuery;')
-change(p, '    private final MapFilter mapFilter;', '    private final MapFilter mapFilter;\n    private final List<CockpitPointStatusQuery.Count> pointStatusDistribution;')
-change(p, '            MapFilter mapFilter) {', '            MapFilter mapFilter, List<CockpitPointStatusQuery.Count> pointStatusDistribution) {')
-change(p, '        this.mapFilter = mapFilter;', '        this.mapFilter = mapFilter;\n        this.pointStatusDistribution = pointStatusDistribution;')
-change(p, '    public MapFilter getMapFilter()', '    public List<CockpitPointStatusQuery.Count> getPointStatusDistribution() { return pointStatusDistribution; }\n    public MapFilter getMapFilter()')
-change(p, '        private final String deviceType;', '        private final String deviceType;\n        private final String pointStatus;')
-change(p, 'public MapFilter(String industry, Integer district, String districtFilter, String deviceType,', 'public MapFilter(String industry, Integer district, String districtFilter, String deviceType, String pointStatus,')
-change(p, '            this.deviceType = deviceType;', '            this.deviceType = deviceType;\n            this.pointStatus = pointStatus;')
-change(p, '        public String getDeviceType()', '        public String getPointStatus() { return pointStatus; }\n        public String getDeviceType()')
-p = system / 'map/CockpitMapSnapshotService.java'
-change(p, 'import com.zksy.cockpit.point.CockpitMonitoringPointStatusResolver;', 'import com.zksy.cockpit.point.CockpitMonitoringPointStatusResolver;\nimport com.zksy.cockpit.point.CockpitPointStatusQuery;')
-change(p, '''            String industry, String district, String deviceType) {
-        int safePage''', '''            String industry, String district, String deviceType) {
-        return load(requestId, page, pageSize, snapshotAt, industry, district, deviceType, null);
-    }
-
-    public CockpitMapSnapshotResponse load(String requestId, Integer page, Integer pageSize, String snapshotAt,
-            String industry, String district, String deviceType, String pointStatus) {
-        CockpitPointStatusQuery statusQuery = CockpitPointStatusQuery.parse(pointStatus);
-        int safePage''')
-change(p, '''        int total = points.size() + equipment.size();''', '''        CockpitPointStatusQuery.Distribution global = new CockpitPointStatusQuery.Distribution();
-        monitoringPoints(snapshot, types, null, CockpitDistrict.parseQuery(null), null)
-                .forEach(point -> global.addPoint(point.getPointStatus()));
-        unassociatedEquipment(snapshot, types, null, CockpitDistrict.parseQuery(null), null)
-                .forEach(device -> global.addUnassociatedDevice());
-        points = points.stream().filter(point -> statusQuery.matchesPoint(point.getPointStatus()))
-                .collect(Collectors.toList());
-        equipment = equipment.stream().filter(device -> statusQuery.matchesDevice(device.getDeviceStatus()))
-                .collect(Collectors.toList());
-        int total = points.size() + equipment.size();''')
-change(p, 'mapFilter(selectedIndustry, selectedDistrict, selectedDeviceType, points, equipment));', 'mapFilter(selectedIndustry, selectedDistrict, selectedDeviceType, statusQuery.echo(), points, equipment),\n                global.rows());')
-change(p, 'source.getLongitude(), source.getLatitude(), source.isCoordinateVerified()));\n    }\n\n    private static CockpitMapSnapshotResponse.MapFilter', 'source.getLongitude(), source.getLatitude(), source.isCoordinateVerified()),\n                CockpitPointStatusQuery.deviceBucket(source.getDeviceStatus()));\n    }\n\n    private static CockpitMapSnapshotResponse.MapFilter')
-change(p, '''            String industry, CockpitDistrict.Query district, String deviceType,
-            List<CockpitMonitoringPointSummary> points,''', '''            String industry, CockpitDistrict.Query district, String deviceType, String pointStatus,
-            List<CockpitMonitoringPointSummary> points,''')
-change(p, 'deviceType, sorted(pointCodes), sorted(deviceCodes));', 'deviceType, pointStatus, sorted(pointCodes), sorted(deviceCodes));')
-# Null placeholder records must not crash association discovery.
-change(p, '''return snapshot.getMonitoringPoints().stream()
-                .map(CockpitMapSnapshot.SourceMonitoringPoint::getDevices)''', '''return snapshot.getMonitoringPoints().stream()
-                .filter(Objects::nonNull)
-                .map(CockpitMapSnapshot.SourceMonitoringPoint::getDevices)''')
-change(p, '''                .flatMap(List::stream)
-                .map(CockpitMonitoringPointDeviceSnapshot::getDeviceCode)''', '''                .flatMap(List::stream)
-                .filter(Objects::nonNull)
-                .map(CockpitMonitoringPointDeviceSnapshot::getDeviceCode)''')
-
-p = admin / 'CockpitMapSnapshotController.java'
-change(p, '''            @RequestParam(value = "deviceType", required = false) String deviceType,
-            @RequestHeader''', '''            @RequestParam(value = "deviceType", required = false) String deviceType,
-            @RequestParam(value = "pointStatus", required = false) String[] pointStatus,
-            @RequestHeader''')
-change(p, 'resolveRequestId(requestId), page, pageSize, snapshotAt, industry, district, deviceType);', 'resolveRequestId(requestId), page, pageSize, snapshotAt, industry, district, deviceType,\n                pointStatus == null ? null : String.join(",", pointStatus));')
-p = admin / 'CockpitMonitoringDistributionController.java'
-change(p, '@RequestParam(value = "pointStatus", required = false) String pointStatus,', '@RequestParam(value = "pointStatus", required = false) String[] pointStatus,')
-change(p, '                        pointStatus,', '                        pointStatus == null ? null : String.join(",", pointStatus),')
-
-p = system / 'monitoring/CockpitMonitoringDistributionSnapshot.java'
-change(p, '        private final String statusTime;', '        private final String statusTime;\n        private final boolean statusPublished;')
-change(p, '            this.deviceCode = deviceCode;', '''            this(deviceCode, deviceName, district, typeCode, longitude, latitude, coordinateVerified,
-                    online, alarm, warning, workOrderInProgress, statusReasons, statusTime, true);
-        }
-
-        public SourceSmartDevice(String deviceCode, String deviceName, Integer district, String typeCode,
-                BigDecimal longitude, BigDecimal latitude, boolean coordinateVerified,
-                boolean online, boolean alarm, boolean warning, boolean workOrderInProgress,
-                List<String> statusReasons, String statusTime, boolean statusPublished) {
-            this.deviceCode = deviceCode;''')
-change(p, '            this.statusTime = statusTime;', '            this.statusTime = statusTime;\n            this.statusPublished = statusPublished;')
-change(p, '        public String getStatusTime() {', '        public boolean isStatusPublished() { return statusPublished; }\n\n        public String getStatusTime() {')
-p = system / 'monitoring/BusinessCockpitMonitoringDistributionAdapter.java'
-change(p, '                    fact.getStatusTime()));', '                    fact.getStatusTime(),\n                    fact.isStatusPublished()));')
-p = system / 'monitoring/CockpitMonitoringDistributionResponse.java'
-change(p, 'import com.zksy.cockpit.point.CockpitMonitoringPointStatus;', 'import com.zksy.cockpit.point.CockpitMonitoringPointStatus;\nimport com.zksy.cockpit.point.CockpitPointStatusQuery;')
-change(p, 'List<PointStatusCount>', 'List<CockpitPointStatusQuery.Count>', 3)
-start = p.read_text(encoding='utf-8').index('    public static class PointStatusCount {')
-text = p.read_text(encoding='utf-8')
-end = text.index('    /**', start)
-p.write_text(text[:start] + text[end:], encoding='utf-8', newline='\n')
-print('Updated status facts, DTOs, bindings and map service.')
-
-p = system / 'monitoring/CockpitMonitoringDistributionService.java'
-change(p, 'import com.zksy.cockpit.point.CockpitMonitoringPointStatusResolver;', 'import com.zksy.cockpit.point.CockpitMonitoringPointStatusResolver;\nimport com.zksy.cockpit.point.CockpitPointStatusQuery;')
-change(p, ''' * <p>All distribution statistics use the map rendering scope: monitoring points with
- * valid verified coordinates render once regardless of how many devices they carry;
- * unassociated smart devices render as their own marker only when coordinates are
- * valid and verified. Non-renderable objects are counted in excluded.invalidCoordinateCount.''', ''' * <p>业务对象参与计数、台账与筛选,坐标校验仅决定能否上图。
- * 八桶分布取全局未筛全集(六态/UNKNOWN 数点位,UNASSOCIATED 数未关联设备)。''')
-change(p, 'CockpitMonitoringPointStatus selectedStatus = resolvePointStatus(pointStatus);', 'CockpitPointStatusQuery selectedStatus = CockpitPointStatusQuery.parse(pointStatus);', 2)
-change(p, '            CockpitMonitoringPointStatus selectedStatus,', '            CockpitPointStatusQuery selectedStatus,')
-change(p, '        Set<String> deviceCodes = new LinkedHashSet<>();', '''        Set<String> deviceCodes = new LinkedHashSet<>();
-        Set<String> associatedCodes = new LinkedHashSet<>();
-        CockpitPointStatusQuery.Distribution global = new CockpitPointStatusQuery.Distribution();''')
-start = p.read_text(encoding='utf-8').index('                String mismatch = classify(type);')
-text = p.read_text(encoding='utf-8')
-end = text.index('                pointCodes.add(point.getPointCode().trim());', start)
-p.write_text(text[:start] + '''                List<CockpitMonitoringPointDeviceSnapshot> effective = new ArrayList<>();
-                for (CockpitMonitoringPointDeviceSnapshot device : safeDevices(point.getDevices())) {
-                    if (device == null || isBlank(device.getDeviceCode())) continue;
-                    associatedCodes.add(device.getDeviceCode().trim());
-                    effective.add(effectiveDevice(device));
-                }
-                CockpitMonitoringPointStatus status = CockpitMonitoringPointStatusResolver.resolve(effective);
-                global.addPoint(status);
-                String markerIndustry = industryOf(type);
-                Integer markerDistrict = CockpitDistrict.codeOf(point.getDistrict());
-                if (!matchesFilters(selectedIndustry, deviceType, selectedDistrict,
-                        markerIndustry, point.getTypeCode(), markerDistrict, type)
-                        || !selectedStatus.matchesPoint(status)) continue;
-                CockpitCoordinateValidation validation = CockpitCoordinateValidator.validate(
-                        point.getLongitude(), point.getLatitude(), point.isCoordinateVerified());
-                if (!validation.isRenderable()) invalidCoordinateCount++;
-                deviceCodes.addAll(codes(effective));
-''' + text[end:], encoding='utf-8', newline='\n')
-change(p, '''                        type.getIndustry(),
-                        type.getCode(),
-                        type.getName(),''', '''                        markerIndustry,
-                        markerTypeCode,
-                        type == null ? null : type.getName(),''', 2)
-start = p.read_text(encoding='utf-8').index('                CockpitMonitoringDistributionSnapshot.SourceType type = resolveType(types, device.getTypeCode());')
-text = p.read_text(encoding='utf-8')
-end = text.index('                CockpitMonitoringPointDeviceSnapshot fact =', start)
-p.write_text(text[:start] + '''                if (associatedCodes.contains(device.getDeviceCode().trim())) continue;
-                global.addUnassociatedDevice();
-                CockpitMonitoringDistributionSnapshot.SourceType type = resolveType(types, device.getTypeCode());
-                String markerIndustry = industryOf(type);
-                String markerTypeCode = device.getTypeCode();
-                Integer markerDistrict = CockpitDistrict.codeOf(device.getDistrict());
-                if (!matchesFilters(selectedIndustry, deviceType, selectedDistrict,
-                        markerIndustry, device.getTypeCode(), markerDistrict, type)) continue;
-                CockpitCoordinateValidation validation = CockpitCoordinateValidator.validate(
-                        device.getLongitude(), device.getLatitude(), device.isCoordinateVerified());
-''' + text[end:], encoding='utf-8', newline='\n')
-change(p, '                        type.getName(),\n                        device.getLongitude(),', '                        type == null ? null : type.getName(),\n                        device.getLongitude(),')
-change(p, '                        device.getStatusTime());', '                        device.getStatusTime(), device.isStatusPublished());')
-change(p, '''                CockpitMonitoringPointStatus status = CockpitMonitoringPointStatusResolver.deviceStatus(applied);
-                if (selectedStatus != null && selectedStatus != status) {
-                    continue;
-                }''', '''                String publishedStatus = publishedStatus(applied);
-                CockpitMonitoringPointStatus status = "UNKNOWN".equals(publishedStatus)
-                        ? null : CockpitMonitoringPointStatus.valueOf(publishedStatus);
-                if (!selectedStatus.matchesDevice(publishedStatus)) continue;
-                if (!validation.isRenderable()) invalidCoordinateCount++;''')
-change(p, '                String markerIndustry = industryOf(type);\n                Integer markerDistrict = CockpitDistrict.codeOf(point.getDistrict());', '                String markerIndustry = industryOf(type);\n                String markerTypeCode = point.getTypeCode();\n                Integer markerDistrict = CockpitDistrict.codeOf(point.getDistrict());')
-change(p, '''                        CockpitMonitoringPointStatusResolver.summaryReasons(
-                                status, Collections.singletonList(applied)),''', '''                        status == null ? applied.getStatusReasons()
-                                : CockpitMonitoringPointStatusResolver.summaryReasons(
-                                        status, Collections.singletonList(applied)),''')
-change(p, '        int[] statusCounts = new int[CockpitMonitoringPointStatus.values().length];\n', '')
-change(p, '            accumulate(byType, marker.typeCode, marker);\n            statusCounts[marker.status.ordinal()]++;', '            accumulate(byType, marker.typeCode, marker);', 2)
-text = p.read_text(encoding='utf-8')
-start = text.index('        List<CockpitMonitoringDistributionResponse.PointStatusCount> statusRows =')
-end = text.index('        int total =', start)
-p.write_text(text[:start] + '        List<CockpitPointStatusQuery.Count> statusRows = global.rows();\n\n' + text[end:], encoding='utf-8', newline='\n')
-change(p, '        boolean hasResolved = !deviceCodes.isEmpty();', '        boolean hasResolved = total > 0;')
-change(p, 'hasResolved ? "READY" : "NO_RESOLVED_DEVICES",', 'hasResolved ? "READY" : "NO_OBJECTS",')
-change(p, 'selectedStatus == null ? null : selectedStatus.name(),', 'selectedStatus.echo(),')
-# Preserve publication fact when applying a runtime delta. A missing/unknown state remains UNKNOWN.
-change(p, '                source.getStatusTime());', '''                source.getStatusTime(),
-                runtime == null || runtime.serverUpdatedAt == null
-                        ? source.isStatusPublished()
-                        : "ONLINE".equals(runtime.monitoringState) || "OFFLINE".equals(runtime.monitoringState));''')
-text = p.read_text(encoding='utf-8')
-start = text.index('    private static CockpitMonitoringPointStatus resolvePointStatus(')
-end = text.index('    private static void validatePagination', start)
-p.write_text(text[:start] + '''    private String industryOf(CockpitMonitoringDistributionSnapshot.SourceType type) {
-        return classify(type) == null ? type.getIndustry() : null;
-    }
-
-    private static boolean matchesFilters(String industry, String deviceType, CockpitDistrict.Query district,
-            String actualIndustry, String typeCode, Integer actualDistrict,
-            CockpitMonitoringDistributionSnapshot.SourceType type) {
-        return (industry == null || industry.equals(actualIndustry))
-                && (isBlank(deviceType) || (type != null && deviceType.trim().equals(typeCode)))
-                && CockpitDistrict.matches(district, actualDistrict);
-    }
-
-    private static String publishedStatus(CockpitMonitoringPointDeviceSnapshot device) {
-        return CockpitPointStatusQuery.deviceBucket(device.getPublishedDeviceStatus());
-    }
-
-''' + text[end:], encoding='utf-8', newline='\n')
-change(p, '            Collections.sort(keys);', '            Collections.sort(keys);', 0) if False else None
-change(p, '        Collections.sort(keys, Comparator\n                .comparingInt((String key) -> buckets.get(key).deviceCount)', '        Collections.sort(keys, Comparator\n                .comparingInt((String key) -> buckets.get(key).deviceCount)')
-change(p, '            statusCounts[marker.status.ordinal()]++;', '            if (marker.status != null) statusCounts[marker.status.ordinal()]++;')
-print('Updated monitoring global distribution and filtering.')

+ 0 - 128
.scratch/cockpit-real-data-wayfinding/implementation/t29/manifest.json

@@ -1,128 +0,0 @@
-[
-  {
-    "path": "pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitMapSnapshotController.java",
-    "beforeSha256": "040c757c74a6ac93e302acdb7fd2b2186d679446d1884587be6c5cf0ba6e864b",
-    "afterSha256": "94831a8475e7e6d96101b62bd19483cc2e053df4d0516e1f5c702e4ae60449ef",
-    "added": 3,
-    "removed": 1
-  },
-  {
-    "path": "pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitMonitoringDistributionController.java",
-    "beforeSha256": "4bca6471a97c6ed13548c6f51204e6c2d37aa80c88a3ddcb7bd04b644116e81b",
-    "afterSha256": "454979086df9111bc5aad08c3169dc94ad234571466021982fa66c527ca0bd8c",
-    "added": 2,
-    "removed": 2
-  },
-  {
-    "path": "pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitDeviceFactResolverTest.java",
-    "beforeSha256": "d9a10280aae56b38bccca6a917af1a05f766f5b8bf2c3224c423b2a010d84703",
-    "afterSha256": "255c4c562a7657084c98c6389dda0ccc45e98c803a5a35ac896b86051e0953b1",
-    "added": 21,
-    "removed": 0
-  },
-  {
-    "path": "pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMonitoringDistributionControllerTest.java",
-    "beforeSha256": "36e305d6fc1f0c0b9802d53f0ba29661b57f9c30bb5f99c155e0f85434d05493",
-    "afterSha256": "de1f4424fb332d87cc8e28143d975b9a7600ad05801c6a85702a10f8f8739d7f",
-    "added": 14,
-    "removed": 14
-  },
-  {
-    "path": "pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitPointStatusContractTest.java",
-    "beforeSha256": null,
-    "afterSha256": "5bd371d0d329daa56facf268b3fd7c7bb053d7ee39f76e06d6cd2c214b0c0b40",
-    "added": 191,
-    "removed": 0
-  },
-  {
-    "path": "pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitPointStatusSnapshotSerializationTest.java",
-    "beforeSha256": null,
-    "afterSha256": "19a067551a0bd38d0efdcf853b556ef59e277b8137566b0dae1e31ff8de665ce",
-    "added": 46,
-    "removed": 0
-  },
-  {
-    "path": "pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/BusinessCockpitMapSnapshotAdapter.java",
-    "beforeSha256": "b426359f945944288a3a8cfe46facebe9f7ef41d08e0c3d6c3e6ec32d65c129f",
-    "afterSha256": "b1d2ebd520499118665e38ac988c8795f47c9f4f4cee3e9657b85c4f1cdafe65",
-    "added": 3,
-    "removed": 1
-  },
-  {
-    "path": "pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMapSnapshot.java",
-    "beforeSha256": "4458a63364c5b2e5ae78d78e5272bce367c1db9377a8afeca9cb8a863ab5273d",
-    "afterSha256": "abb49c929dbf61221ea60c11e6e6b2419fc05529c5c5ba9e9cb7823f03dd01e4",
-    "added": 8,
-    "removed": 0
-  },
-  {
-    "path": "pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMapSnapshotResponse.java",
-    "beforeSha256": "ddf522f5292737b3b3fc2d514c65f967cdb48b447208dbf2052a516e602c6fa5",
-    "afterSha256": "158ede3890969cf532077aa2e5f4d6acf7e1df1e4e0d5bea66b13bf2a939bfc5",
-    "added": 9,
-    "removed": 2
-  },
-  {
-    "path": "pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMapSnapshotService.java",
-    "beforeSha256": "8f6da5ffdbc576024c24b2541758772ce3d1f30d71c5c88810039f3915c78b5f",
-    "afterSha256": "fd04cced758d9358be9823415b3855ad7b50ded2df468d2b99d575ebef2d9ebb",
-    "added": 31,
-    "removed": 7
-  },
-  {
-    "path": "pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitUnassociatedEquipmentSummary.java",
-    "beforeSha256": "a33c48aad50f2ece8035dd765e25fbd21fae2ed0d1691aa1fd8f59991929911a",
-    "afterSha256": "ba08ecda4984fc1a160bbe6ccab20d8af7a884086bbd30e5092329f71932fec8",
-    "added": 5,
-    "removed": 1
-  },
-  {
-    "path": "pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/BusinessCockpitMonitoringDistributionAdapter.java",
-    "beforeSha256": "2735033e15156ecbae3352e95e2638124831fdfed0f6f64ccd6b9e84e175ed30",
-    "afterSha256": "1788604ec748d9e714703e2871f2cc0e2c3ef0c30173e164bc866e58a27d10c6",
-    "added": 2,
-    "removed": 1
-  },
-  {
-    "path": "pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringDistributionResponse.java",
-    "beforeSha256": "ede2805d2110251e98025ab56e4eff6a9b9b6e270d96e087f0c70c7314588714",
-    "afterSha256": "0d783683ced0d73e8fd02e1138fad74778a47410ca2abe705ea8ca222cf9ac2d",
-    "added": 7,
-    "removed": 16
-  },
-  {
-    "path": "pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringDistributionService.java",
-    "beforeSha256": "1958985661d75028ae0d06445d59172a23aefa89c575c00ccc2835f77222ebc2",
-    "afterSha256": "bd00e77216d68e4772355ba0f5f4034a7b60dbac0f1c1425a649796bcf6ba881",
-    "added": 72,
-    "removed": 106
-  },
-  {
-    "path": "pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringDistributionSnapshot.java",
-    "beforeSha256": "1c71089f693a6a60c3f1bf577e652cb08278a730e6bd7e8a64646904dbaa17a4",
-    "afterSha256": "da9edac88b7dd6b41394a1625bef8c0f8c68977cc5bf76c141c7d167812d5cdf",
-    "added": 12,
-    "removed": 0
-  },
-  {
-    "path": "pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/point/BusinessCockpitDeviceFactResolver.java",
-    "beforeSha256": "1bb7cd317260f6ae2ab8c530e7314915e28576a69dc324fe149205b081fc47cf",
-    "afterSha256": "c8d4b5bd593c86312099c40bdb117912b8760ae5d636bc88a96d52ea3926c142",
-    "added": 4,
-    "removed": 2
-  },
-  {
-    "path": "pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/point/CockpitMonitoringPointDeviceSnapshot.java",
-    "beforeSha256": "73fc6d2d8d50e641e147d964e926460762bdc2cb008579e85b66cc46a15db2e6",
-    "afterSha256": "51cc709475b57e15da157b9a9a45b9a17350920b209a51b4d4176d58573f0d8d",
-    "added": 15,
-    "removed": 0
-  },
-  {
-    "path": "pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/point/CockpitPointStatusQuery.java",
-    "beforeSha256": null,
-    "afterSha256": "1c71b16330f1a1c673aa91ecaa437ef4056388bdb8ab6e2b1c0cb1693688e682",
-    "added": 96,
-    "removed": 0
-  }
-]

+ 0 - 46
.scratch/cockpit-real-data-wayfinding/implementation/t29/merge-tests.py

@@ -1,46 +0,0 @@
-from pathlib import Path
-root = Path(__file__).parent
-rel = Path('pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMonitoringDistributionControllerTest.java')
-s = (Path('E:/pipe-ner-server') / rel).read_text(encoding='utf-8')
-s = s.replace('distributionExposesEnvelopeSixStateDistributionDistrictIndustryTypeAndMapFilter', 'distributionExposesEnvelopeGlobalEightStateDistributionAndMapFilter')
-s = s.replace('pointStatusDistribution", hasSize(6)', 'pointStatusDistribution", hasSize(8)')
-s = s.replace('pointStatus must be ALARM, WARNING, WORK_ORDER, NORMAL, OFFLINE or EMPTY', 'pointStatus must be ALARM, WARNING, WORK_ORDER, NORMAL, OFFLINE, EMPTY, UNKNOWN or UNASSOCIATED')
-s = s.replace('pointStatusDistribution[0].count").value(0)', 'pointStatusDistribution[0].count").value(1)')
-s = s.replace('pointStatusDistribution[1].count").value(2)', 'pointStatusDistribution[1].count").value(1)')
-s = s.replace('pointStatusDistribution[4].count").value(2)', 'pointStatusDistribution[4].count").value(1)')
-s = s.replace('pointStatusDistribution[4].count").value(0)', 'pointStatusDistribution[4].count").value(1)')
-s = s.replace('assertEquals(2, statusCount(initial, CockpitMonitoringPointStatus.OFFLINE))', 'assertEquals(1, statusCount(initial, CockpitMonitoringPointStatus.OFFLINE))')
-for target in ('applied', 'ignoredOld', 'ignoredUnknown'):
-    s = s.replace(f'assertEquals(1, statusCount({target}, CockpitMonitoringPointStatus.OFFLINE))', f'assertEquals(0, statusCount({target}, CockpitMonitoringPointStatus.OFFLINE))')
-s = s.replace('assertEquals(1, statusCount(all, CockpitMonitoringPointStatus.NORMAL))', 'assertEquals(0, statusCount(all, CockpitMonitoringPointStatus.NORMAL))')
-(root / rel).write_text(s, encoding='utf-8', newline='\n')
-# Add publication and explicit status-7 regression assertions to the actual fact resolver seam.
-rel = Path('pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitDeviceFactResolverTest.java')
-s = (Path('E:/pipe-ner-server') / rel).read_text(encoding='utf-8')
-s = s.replace('import static org.junit.jupiter.api.Assertions.assertFalse;', 'import static org.junit.jupiter.api.Assertions.assertFalse;\nimport static org.junit.jupiter.api.Assertions.assertNull;')
-anchor = '    // ---- fixtures ----'
-addition = '''    @Test
-    void publishedOfflineAndMissingStatusAreDistinctAndDoNotDependOnTimestamp() {
-        EquipmentStatus offline = status(1, 0, 0);
-        offline.setStatusUpdateTime(null);
-        when(equipmentStatusService.getByDeviceCode("DEV-PUBLISHED")).thenReturn(offline);
-        assertEquals("OFFLINE", resolver.resolve(device("DEV-PUBLISHED"), null).getPublishedDeviceStatus());
-        assertNull(resolver.resolve(device("DEV-MISSING"), null).getPublishedDeviceStatus());
-    }
-
-    @Test
-    void cancelledStatusSevenNeverPublishesWorkOrderState() {
-        when(equipmentStatusService.getByDeviceCode("DEV-CANCELLED")).thenReturn(status(1, 0, 1));
-        when(workOrderService.list(any(Wrapper.class))).thenReturn(Collections.singletonList(order(7)));
-        CockpitMonitoringPointDeviceSnapshot fact = resolver.resolve(device("DEV-CANCELLED"), null);
-        assertFalse(fact.isWorkOrderInProgress());
-        assertEquals("NORMAL", fact.getPublishedDeviceStatus());
-        assertEquals(CockpitMonitoringPointStatus.NORMAL,
-                CockpitMonitoringPointStatusResolver.resolve(Collections.singletonList(fact)));
-    }
-
-'''
-assert anchor in s
-s = s.replace(anchor, addition + anchor)
-(root / rel).write_text(s, encoding='utf-8', newline='\n')
-print('Preserved concurrent distribution tests and added publication/status-7 regressions.')

+ 0 - 79
.scratch/cockpit-real-data-wayfinding/implementation/t29/pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitMapSnapshotController.java

@@ -1,79 +0,0 @@
-package com.zksy.web.controller.cockpit;
-
-import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
-import com.zksy.cockpit.foundation.CockpitErrorPayload;
-import com.zksy.cockpit.foundation.CockpitReadException;
-import com.zksy.cockpit.foundation.CockpitSnapshotExpiredException;
-import com.zksy.cockpit.map.CockpitMapSnapshotResponse;
-import com.zksy.cockpit.map.CockpitMapSnapshotService;
-import com.zksy.common.core.domain.AjaxResult;
-import com.zksy.common.utils.StringUtils;
-import com.zksy.common.utils.uuid.IdUtils;
-import org.springframework.http.HttpStatus;
-import org.springframework.http.ResponseEntity;
-import org.springframework.web.bind.annotation.ExceptionHandler;
-import org.springframework.web.bind.annotation.GetMapping;
-import org.springframework.web.bind.annotation.RequestHeader;
-import org.springframework.web.bind.annotation.RequestParam;
-import org.springframework.web.bind.annotation.RestController;
-
-import javax.servlet.http.HttpServletRequest;
-
-@RestController
-public class CockpitMapSnapshotController {
-    private static final String REQUEST_ID_HEADER = "X-Request-Id";
-
-    private final CockpitMapSnapshotService mapSnapshotService;
-
-    public CockpitMapSnapshotController(CockpitMapSnapshotService mapSnapshotService) {
-        this.mapSnapshotService = mapSnapshotService;
-    }
-
-    @GetMapping("/api/cockpit/v1/map-snapshot")
-    public ResponseEntity<AjaxResult> mapSnapshot(
-            @RequestParam(value = "page", defaultValue = "1") Integer page,
-            @RequestParam(value = "pageSize", defaultValue = "500") Integer pageSize,
-            @RequestParam(value = "snapshotAt", required = false) String snapshotAt,
-            @RequestParam(value = "industry", required = false) String industry,
-            @RequestParam(value = "district", required = false) String district,
-            @RequestParam(value = "deviceType", required = false) String deviceType,
-            @RequestParam(value = "pointStatus", required = false) String[] pointStatus,
-            @RequestHeader(value = REQUEST_ID_HEADER, required = false) String requestId) {
-        CockpitMapSnapshotResponse response = mapSnapshotService.load(
-                resolveRequestId(requestId), page, pageSize, snapshotAt, industry, district, deviceType,
-                pointStatus == null ? null : String.join(",", pointStatus));
-        return ResponseEntity.ok(AjaxResult.success(response));
-    }
-
-    @ExceptionHandler(CockpitInvalidRequestException.class)
-    public ResponseEntity<AjaxResult> handleInvalidRequest(
-            HttpServletRequest request,
-            CockpitInvalidRequestException exception) {
-        return badRequest(mapSnapshotService.invalidRequest(
-                resolveRequestId(request.getHeader(REQUEST_ID_HEADER)),
-                exception.getMessage()));
-    }
-
-    @ExceptionHandler(CockpitSnapshotExpiredException.class)
-    public ResponseEntity<AjaxResult> handleSnapshotExpired(HttpServletRequest request) {
-        return badRequest(mapSnapshotService.snapshotExpired(
-                resolveRequestId(request.getHeader(REQUEST_ID_HEADER))));
-    }
-
-    @ExceptionHandler(CockpitReadException.class)
-    public ResponseEntity<AjaxResult> handleReadFailure(HttpServletRequest request) {
-        CockpitErrorPayload payload = mapSnapshotService.serverError(
-                resolveRequestId(request.getHeader(REQUEST_ID_HEADER)));
-        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
-                .body(new AjaxResult(HttpStatus.INTERNAL_SERVER_ERROR.value(), payload.getMessage(), payload));
-    }
-
-    private ResponseEntity<AjaxResult> badRequest(CockpitErrorPayload payload) {
-        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
-                .body(new AjaxResult(HttpStatus.BAD_REQUEST.value(), payload.getMessage(), payload));
-    }
-
-    private String resolveRequestId(String requestId) {
-        return StringUtils.isNotEmpty(requestId) ? requestId : IdUtils.fastSimpleUUID();
-    }
-}

+ 0 - 84
.scratch/cockpit-real-data-wayfinding/implementation/t29/pipe-network-service/zksy-admin/src/main/java/com/zksy/web/controller/cockpit/CockpitMonitoringDistributionController.java

@@ -1,84 +0,0 @@
-package com.zksy.web.controller.cockpit;
-
-import com.zksy.cockpit.foundation.CockpitErrorPayload;
-import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
-import com.zksy.cockpit.foundation.CockpitReadException;
-import com.zksy.cockpit.foundation.CockpitSnapshotExpiredException;
-import com.zksy.cockpit.monitoring.CockpitMonitoringDistributionService;
-import com.zksy.common.core.domain.AjaxResult;
-import com.zksy.common.utils.StringUtils;
-import com.zksy.common.utils.uuid.IdUtils;
-import org.springframework.http.HttpStatus;
-import org.springframework.http.ResponseEntity;
-import org.springframework.web.bind.annotation.ExceptionHandler;
-import org.springframework.web.bind.annotation.GetMapping;
-import org.springframework.web.bind.annotation.RequestHeader;
-import org.springframework.web.bind.annotation.RequestParam;
-import org.springframework.web.bind.annotation.RestController;
-
-import javax.servlet.http.HttpServletRequest;
-
-@RestController
-public class CockpitMonitoringDistributionController {
-    private static final String REQUEST_ID_HEADER = "X-Request-Id";
-
-    private final CockpitMonitoringDistributionService distributionService;
-
-    public CockpitMonitoringDistributionController(
-            CockpitMonitoringDistributionService distributionService) {
-        this.distributionService = distributionService;
-    }
-
-    @GetMapping("/api/cockpit/v1/monitoring/distribution")
-    public ResponseEntity<AjaxResult> monitoringDistribution(
-            @RequestParam(value = "industry", required = false) String industry,
-            @RequestParam(value = "deviceType", required = false) String deviceType,
-            @RequestParam(value = "pointStatus", required = false) String[] pointStatus,
-            // 范围维度自执行票 12 起为片区;旧自由串 region 不再是正式契约,签名里已去掉
-            @RequestParam(value = "district", required = false) String district,
-            @RequestParam(value = "page", defaultValue = "1") Integer page,
-            @RequestParam(value = "pageSize", defaultValue = "500") Integer pageSize,
-            @RequestParam(value = "snapshotAt", required = false) String snapshotAt,
-            @RequestHeader(value = REQUEST_ID_HEADER, required = false) String requestId) {
-        return ResponseEntity.ok(AjaxResult.success(
-                distributionService.load(
-                        resolveRequestId(requestId),
-                        industry,
-                        deviceType,
-                        pointStatus == null ? null : String.join(",", pointStatus),
-                        district,
-                        page,
-                        pageSize,
-                        snapshotAt)));
-    }
-
-    @ExceptionHandler(CockpitInvalidRequestException.class)
-    public ResponseEntity<AjaxResult> handleInvalidRequest(
-            HttpServletRequest request,
-            CockpitInvalidRequestException exception) {
-        return error(HttpStatus.BAD_REQUEST, distributionService.invalidRequest(
-                resolveRequestId(request.getHeader(REQUEST_ID_HEADER)),
-                exception.getMessage()));
-    }
-
-    @ExceptionHandler(CockpitSnapshotExpiredException.class)
-    public ResponseEntity<AjaxResult> handleSnapshotExpired(HttpServletRequest request) {
-        return error(HttpStatus.BAD_REQUEST, distributionService.snapshotExpired(
-                resolveRequestId(request.getHeader(REQUEST_ID_HEADER))));
-    }
-
-    @ExceptionHandler(CockpitReadException.class)
-    public ResponseEntity<AjaxResult> handleReadFailure(HttpServletRequest request) {
-        return error(HttpStatus.INTERNAL_SERVER_ERROR, distributionService.serverError(
-                resolveRequestId(request.getHeader(REQUEST_ID_HEADER))));
-    }
-
-    private ResponseEntity<AjaxResult> error(HttpStatus status, CockpitErrorPayload payload) {
-        return ResponseEntity.status(status)
-                .body(new AjaxResult(status.value(), payload.getMessage(), payload));
-    }
-
-    private String resolveRequestId(String requestId) {
-        return StringUtils.isNotEmpty(requestId) ? requestId : IdUtils.fastSimpleUUID();
-    }
-}

+ 0 - 187
.scratch/cockpit-real-data-wayfinding/implementation/t29/pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/BusinessCockpitDeviceFactResolverTest.java

@@ -1,187 +0,0 @@
-package com.zksy.web.controller.cockpit;
-
-import com.baomidou.mybatisplus.core.conditions.Wrapper;
-import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
-import org.mockito.ArgumentCaptor;
-import com.zksy.base.domain.EquipmentBase;
-import com.zksy.base.domain.EquipmentStatus;
-import com.zksy.base.service.EquipmentStatusService;
-import com.zksy.base.warning.domain.EarlyWarning;
-import com.zksy.base.warning.service.EarlyWarningService;
-import com.zksy.cockpit.point.BusinessCockpitDeviceFactResolver;
-import com.zksy.cockpit.point.CockpitMonitoringPointDeviceSnapshot;
-import com.zksy.cockpit.point.CockpitMonitoringPointStatus;
-import com.zksy.cockpit.point.CockpitMonitoringPointStatusResolver;
-import com.zksy.ops.domain.WorkOrder;
-import com.zksy.ops.service.WorkOrderService;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-
-import java.math.BigDecimal;
-import java.time.LocalDateTime;
-import java.util.Collections;
-import java.util.List;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertFalse;
-import static org.junit.jupiter.api.Assertions.assertNull;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
-
-/**
- * 设备事实解析器的契约测试:固化「现网状态/预警/工单 → 设备事实」的口径,
- * 覆盖现网脏值(0-based 工单状态、缺状态记录、预警状态大小写)。
- */
-class BusinessCockpitDeviceFactResolverTest {
-
-    private EquipmentStatusService equipmentStatusService;
-    private EarlyWarningService earlyWarningService;
-    private WorkOrderService workOrderService;
-    private BusinessCockpitDeviceFactResolver resolver;
-
-    @BeforeEach
-    void setUp() {
-        equipmentStatusService = mock(EquipmentStatusService.class);
-        earlyWarningService = mock(EarlyWarningService.class);
-        workOrderService = mock(WorkOrderService.class);
-        resolver = new BusinessCockpitDeviceFactResolver(
-                equipmentStatusService, earlyWarningService, workOrderService);
-    }
-
-    @Test
-    void alarmStatusAndOpenWarningAndOpenWorkOrderAllResolve() {
-        when(equipmentStatusService.getByDeviceCode("DEV-1")).thenReturn(status(1, 1, 1));
-        when(earlyWarningService.list(any(Wrapper.class))).thenReturn(Collections.singletonList(warning("PENDING")));
-        when(workOrderService.list(any(Wrapper.class))).thenReturn(Collections.singletonList(order(3)));
-
-        CockpitMonitoringPointDeviceSnapshot fact = resolver.resolve(device("DEV-1"), "压力计");
-
-        assertTrue(fact.isOnline(), "online_status=1 应在线");
-        assertTrue(fact.isAlarm(), "alarm_status=1 应报警");
-        assertTrue(fact.isWarning(), "PENDING 预警应视为预警中");
-        assertTrue(fact.isWorkOrderInProgress(), "现网 0-based 的 3=处理中 应视为在途");
-        ArgumentCaptor<QueryWrapper> orderQuery = ArgumentCaptor.forClass(QueryWrapper.class);
-        verify(workOrderService).list(orderQuery.capture());
-        assertTrue(orderQuery.getValue().getSqlSegment().contains("device_id"), "工单必须按设备主键关联");
-        assertTrue(orderQuery.getValue().getParamNameValuePairs().containsValue("ID-DEV-1"));
-        assertEquals("压力计", fact.getDeviceTypeName());
-        assertEquals(new BigDecimal("110.4025"), fact.getLongitude());
-        assertEquals(new BigDecimal("28.4500"), fact.getLatitude());
-        assertEquals("UNVERIFIED", fact.getCoordinateValidation().getState());
-        assertEquals("UNVERIFIED_COORDINATE", fact.getCoordinateValidation().getReason());
-        assertFalse(fact.getCoordinateValidation().isRenderable());
-        assertEquals(CockpitMonitoringPointStatus.ALARM,
-                CockpitMonitoringPointStatusResolver.deviceStatus(fact));
-    }
-
-    @Test
-    void offlineStatusResolvesToOffline() {
-        when(equipmentStatusService.getByDeviceCode("DEV-2")).thenReturn(status(1, 0, 0));
-        when(earlyWarningService.list(any(Wrapper.class))).thenReturn(Collections.emptyList());
-        when(workOrderService.list(any(Wrapper.class))).thenReturn(Collections.emptyList());
-
-        CockpitMonitoringPointDeviceSnapshot fact = resolver.resolve(device("DEV-2"), null);
-
-        assertFalse(fact.isOnline());
-        assertEquals(CockpitMonitoringPointStatus.OFFLINE,
-                CockpitMonitoringPointStatusResolver.deviceStatus(fact));
-    }
-
-    @Test
-    void missingStatusIsReportedOfflineWithExplicitReason() {
-        when(equipmentStatusService.getByDeviceCode("DEV-3")).thenReturn(null);
-        when(earlyWarningService.list(any(Wrapper.class))).thenReturn(Collections.emptyList());
-        when(workOrderService.list(any(Wrapper.class))).thenReturn(Collections.emptyList());
-
-        CockpitMonitoringPointDeviceSnapshot fact = resolver.resolve(device("DEV-3"), null);
-
-        assertFalse(fact.isOnline(), "缺状态记录按离线");
-        assertTrue(fact.getStatusReasons().stream().anyMatch(reason -> reason.contains("无状态记录")));
-        assertEquals(CockpitMonitoringPointStatus.OFFLINE,
-                CockpitMonitoringPointStatusResolver.deviceStatus(fact));
-    }
-
-    @Test
-    void closedOrCancelledWorkOrderIsNotInProgress() {
-        when(equipmentStatusService.getByDeviceCode("DEV-4")).thenReturn(status(1, 0, 1));
-        when(earlyWarningService.list(any(Wrapper.class))).thenReturn(Collections.emptyList());
-        when(workOrderService.list(any(Wrapper.class))).thenReturn(Collections.singletonList(order(5)));
-
-        CockpitMonitoringPointDeviceSnapshot fact = resolver.resolve(device("DEV-4"), null);
-
-        assertFalse(fact.isWorkOrderInProgress(), "0-based 的 5=已结案 不在途");
-        assertEquals(CockpitMonitoringPointStatus.NORMAL,
-                CockpitMonitoringPointStatusResolver.deviceStatus(fact));
-    }
-
-    @Test
-    void warningStatusIsCaseInsensitiveAndClosedWarningIsNotWarning() {
-        when(equipmentStatusService.getByDeviceCode("DEV-5")).thenReturn(status(1, 0, 1));
-        when(earlyWarningService.list(any(Wrapper.class))).thenReturn(Collections.singletonList(warning("processing")));
-        when(workOrderService.list(any(Wrapper.class))).thenReturn(Collections.emptyList());
-
-        assertTrue(resolver.resolve(device("DEV-5"), null).isWarning(), "小写 processing 也视为预警中");
-
-        when(earlyWarningService.list(any(Wrapper.class))).thenReturn(Collections.singletonList(warning("released")));
-        assertTrue(resolver.resolve(device("DEV-5"), null).isWarning(), "RELEASED 已发布预警仍为当前预警");
-
-        when(earlyWarningService.list(any(Wrapper.class))).thenReturn(Collections.singletonList(warning("CLOSED")));
-        assertFalse(resolver.resolve(device("DEV-5"), null).isWarning(), "CLOSED 预警不算预警中");
-    }
-
-    @Test
-    void publishedOfflineAndMissingStatusAreDistinctAndDoNotDependOnTimestamp() {
-        EquipmentStatus offline = status(1, 0, 0);
-        offline.setStatusUpdateTime(null);
-        when(equipmentStatusService.getByDeviceCode("DEV-PUBLISHED")).thenReturn(offline);
-        assertEquals("OFFLINE", resolver.resolve(device("DEV-PUBLISHED"), null).getPublishedDeviceStatus());
-        assertNull(resolver.resolve(device("DEV-MISSING"), null).getPublishedDeviceStatus());
-    }
-
-    @Test
-    void cancelledStatusSevenNeverPublishesWorkOrderState() {
-        when(equipmentStatusService.getByDeviceCode("DEV-CANCELLED")).thenReturn(status(1, 0, 1));
-        when(workOrderService.list(any(Wrapper.class))).thenReturn(Collections.singletonList(order(7)));
-        CockpitMonitoringPointDeviceSnapshot fact = resolver.resolve(device("DEV-CANCELLED"), null);
-        assertFalse(fact.isWorkOrderInProgress());
-        assertEquals("NORMAL", fact.getPublishedDeviceStatus());
-        assertEquals(CockpitMonitoringPointStatus.NORMAL,
-                CockpitMonitoringPointStatusResolver.resolve(Collections.singletonList(fact)));
-    }
-
-    // ---- fixtures ----
-
-    private static EquipmentBase device(String code) {
-        EquipmentBase device = new EquipmentBase();
-        device.setEquipmentId("ID-" + code);
-        device.setEquipmentCode(code);
-        device.setEquipmentName(code);
-        device.setLongitude(new BigDecimal("110.4025"));
-        device.setLatitude(new BigDecimal("28.4500"));
-        return device;
-    }
-
-    private static EquipmentStatus status(int current, int alarm, int online) {
-        EquipmentStatus status = new EquipmentStatus();
-        status.setCurrentStatus(current);
-        status.setAlarmStatus(alarm);
-        status.setOnlineStatus(online);
-        status.setStatusUpdateTime(LocalDateTime.parse("2026-09-02T09:59:50"));
-        return status;
-    }
-
-    private static EarlyWarning warning(String status) {
-        EarlyWarning warning = new EarlyWarning();
-        warning.setStatus(status);
-        return warning;
-    }
-
-    private static WorkOrder order(int status) {
-        WorkOrder order = new WorkOrder();
-        order.setOrderStatus(status);
-        return order;
-    }
-}

+ 0 - 602
.scratch/cockpit-real-data-wayfinding/implementation/t29/pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitMonitoringDistributionControllerTest.java

@@ -1,602 +0,0 @@
-package com.zksy.web.controller.cockpit;
-
-import com.zksy.cockpit.foundation.CockpitReadException;
-import com.zksy.cockpit.foundation.CockpitSnapshotExpiredException;
-import com.zksy.cockpit.monitoring.CockpitMonitoringDistributionAdapter;
-import com.zksy.cockpit.monitoring.CockpitMonitoringDistributionDeviceStatusDelta;
-import com.zksy.cockpit.monitoring.CockpitMonitoringDistributionService;
-import com.zksy.cockpit.monitoring.CockpitMonitoringDistributionSnapshot;
-import com.zksy.cockpit.monitoring.CockpitMonitoringDistributionResponse;
-import com.zksy.cockpit.point.CockpitMonitoringPointDeviceSnapshot;
-import com.zksy.cockpit.point.CockpitMonitoringPointStatus;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-import org.springframework.test.web.servlet.MockMvc;
-import org.springframework.test.web.servlet.setup.MockMvcBuilders;
-import org.springframework.web.bind.annotation.DeleteMapping;
-import org.springframework.web.bind.annotation.GetMapping;
-import org.springframework.web.bind.annotation.PatchMapping;
-import org.springframework.web.bind.annotation.PostMapping;
-import org.springframework.web.bind.annotation.PutMapping;
-
-import java.lang.reflect.Method;
-import java.math.BigDecimal;
-import java.time.Clock;
-import java.time.Instant;
-import java.time.ZoneOffset;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.List;
-
-import static org.hamcrest.Matchers.hasSize;
-import static org.hamcrest.Matchers.nullValue;
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertTrue;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.verifyNoInteractions;
-import static org.mockito.Mockito.when;
-import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
-import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
-import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
-import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
-
-class CockpitMonitoringDistributionControllerTest {
-
-    private static final String ENDPOINT = "/api/cockpit/v1/monitoring/distribution";
-    private static final String SNAPSHOT_AT = "2026-09-02T10:00:00+08:00";
-
-    private CockpitMonitoringDistributionAdapter adapter;
-    private MockMvc mockMvc;
-    private CockpitMonitoringDistributionService service;
-
-    @BeforeEach
-    void setUp() {
-        adapter = mock(CockpitMonitoringDistributionAdapter.class);
-        service = new CockpitMonitoringDistributionService(
-                adapter,
-                Clock.fixed(Instant.parse("2026-09-02T02:00:00Z"), ZoneOffset.UTC));
-        mockMvc = MockMvcBuilders
-                .standaloneSetup(new CockpitMonitoringDistributionController(service))
-                .build();
-        when(adapter.load(null)).thenReturn(fixture());
-        when(adapter.load(SNAPSHOT_AT)).thenReturn(fixture());
-    }
-
-    @Test
-    void distributionExposesEnvelopeGlobalEightStateDistributionAndMapFilter() throws Exception {
-        mockMvc.perform(get(ENDPOINT)
-                        .param("snapshotAt", SNAPSHOT_AT)
-                        .header("X-Request-Id", "monitoring-distribution-001"))
-                .andExpect(status().isOk())
-                .andExpect(jsonPath("$.code").value(200))
-                .andExpect(jsonPath("$.data.requestId").value("monitoring-distribution-001"))
-                .andExpect(jsonPath("$.data.apiVersion").value("v1"))
-                .andExpect(jsonPath("$.data.readOnly").value(true))
-                .andExpect(jsonPath("$.data.displayName").value("监测分布"))
-                .andExpect(jsonPath("$.data.timezone").value("Asia/Shanghai"))
-                .andExpect(jsonPath("$.data.pagination.page").value(1))
-                .andExpect(jsonPath("$.data.pagination.pageSize").value(500))
-                .andExpect(jsonPath("$.data.pagination.total").value(9))
-                .andExpect(jsonPath("$.data.pagination.totalPages").value(1))
-                .andExpect(jsonPath("$.data.pagination.hasMore").value(false))
-                .andExpect(jsonPath("$.data.monitoringPoints", hasSize(5)))
-                .andExpect(jsonPath("$.data.monitoringPoints[0].pointCode").value("P-DR-1"))
-                .andExpect(jsonPath("$.data.monitoringPoints[0].pointStatus").value("WARNING"))
-                .andExpect(jsonPath("$.data.monitoringPoints[1].pointCode").value("P-GAS-1"))
-                .andExpect(jsonPath("$.data.monitoringPoints[1].pointStatus").value("ALARM"))
-                .andExpect(jsonPath("$.data.monitoringPoints[1].statusReasons[0]").value("GAS-002 浓度超限"))
-                .andExpect(jsonPath("$.data.monitoringPoints[2].pointCode").value("P-GAS-2"))
-                .andExpect(jsonPath("$.data.monitoringPoints[2].pointStatus").value("OFFLINE"))
-                .andExpect(jsonPath("$.data.monitoringPoints[3].pointCode").value("P-WS-1"))
-                .andExpect(jsonPath("$.data.monitoringPoints[3].pointStatus").value("WORK_ORDER"))
-                .andExpect(jsonPath("$.data.monitoringPoints[4].pointCode").value("P-WS-2"))
-                .andExpect(jsonPath("$.data.monitoringPoints[4].pointStatus").value("EMPTY"))
-                .andExpect(jsonPath("$.data.unassociatedEquipment", hasSize(4)))
-                .andExpect(jsonPath("$.data.unassociatedEquipment[0].deviceCode").value("EQ-FREE-001"))
-                .andExpect(jsonPath("$.data.unassociatedEquipment[0].pointStatus").value("NORMAL"))
-                .andExpect(jsonPath("$.data.unassociatedEquipment[1].deviceCode").value("EQ-FREE-002"))
-                .andExpect(jsonPath("$.data.unassociatedEquipment[1].pointStatus").value("WARNING"))
-                .andExpect(jsonPath("$.data.unassociatedEquipment[2].deviceCode").value("EQ-FREE-003"))
-                .andExpect(jsonPath("$.data.unassociatedEquipment[2].pointStatus").value("OFFLINE"))
-                .andExpect(jsonPath("$.data.pointStatusDistribution", hasSize(8)))
-                .andExpect(jsonPath("$.data.pointStatusDistribution[0].status").value("ALARM"))
-                .andExpect(jsonPath("$.data.pointStatusDistribution[0].count").value(1))
-                .andExpect(jsonPath("$.data.pointStatusDistribution[1].status").value("WARNING"))
-                .andExpect(jsonPath("$.data.pointStatusDistribution[1].count").value(1))
-                .andExpect(jsonPath("$.data.pointStatusDistribution[2].status").value("WORK_ORDER"))
-                .andExpect(jsonPath("$.data.pointStatusDistribution[2].count").value(1))
-                .andExpect(jsonPath("$.data.pointStatusDistribution[3].status").value("NORMAL"))
-                .andExpect(jsonPath("$.data.pointStatusDistribution[3].count").value(2))
-                .andExpect(jsonPath("$.data.pointStatusDistribution[4].status").value("OFFLINE"))
-                .andExpect(jsonPath("$.data.pointStatusDistribution[4].count").value(1))
-                .andExpect(jsonPath("$.data.pointStatusDistribution[5].status").value("EMPTY"))
-                .andExpect(jsonPath("$.data.pointStatusDistribution[5].count").value(1))
-                .andExpect(jsonPath("$.data.districtDistribution", hasSize(3)))
-                .andExpect(jsonPath("$.data.districtDistribution[0].district").value(0))
-                .andExpect(jsonPath("$.data.districtDistribution[0].districtName").value("城南"))
-                .andExpect(jsonPath("$.data.districtDistribution[0].pointCount").value(2))
-                .andExpect(jsonPath("$.data.districtDistribution[0].deviceCount").value(5))
-                .andExpect(jsonPath("$.data.districtDistribution[1].district").value(1))
-                .andExpect(jsonPath("$.data.districtDistribution[1].districtName").value("城北"))
-                .andExpect(jsonPath("$.data.districtDistribution[1].pointCount").value(2))
-                .andExpect(jsonPath("$.data.districtDistribution[1].deviceCount").value(2))
-                .andExpect(jsonPath("$.data.districtDistribution[2].district").value(nullValue()))
-                .andExpect(jsonPath("$.data.districtDistribution[2].districtName").value("未分区"))
-                .andExpect(jsonPath("$.data.districtDistribution[2].pointCount").value(1))
-                .andExpect(jsonPath("$.data.districtDistribution[2].deviceCount").value(2))
-                .andExpect(jsonPath("$.data.industryDistribution", hasSize(3)))
-                .andExpect(jsonPath("$.data.industryDistribution[0].industry").value("GAS"))
-                .andExpect(jsonPath("$.data.industryDistribution[0].pointCount").value(2))
-                .andExpect(jsonPath("$.data.industryDistribution[0].deviceCount").value(5))
-                .andExpect(jsonPath("$.data.industryDistribution[1].industry").value("WATER_SUPPLY"))
-                .andExpect(jsonPath("$.data.industryDistribution[1].deviceCount").value(2))
-                .andExpect(jsonPath("$.data.industryDistribution[2].industry").value("DRAINAGE"))
-                .andExpect(jsonPath("$.data.industryDistribution[2].deviceCount").value(2))
-                .andExpect(jsonPath("$.data.deviceTypeDistribution", hasSize(3)))
-                .andExpect(jsonPath("$.data.deviceTypeDistribution[0].deviceType.code").value("GAS_SENSOR"))
-                .andExpect(jsonPath("$.data.deviceTypeDistribution[0].deviceCount").value(5))
-                .andExpect(jsonPath("$.data.deviceTypeDistribution[1].deviceType.code").value("SEWAGE_ENV"))
-                .andExpect(jsonPath("$.data.deviceTypeDistribution[2].deviceType.code").value("WATER_SUPPLY_SENSOR"))
-                .andExpect(jsonPath("$.data.mapFilter.industry").value(nullValue()))
-                .andExpect(jsonPath("$.data.mapFilter.deviceType").value(nullValue()))
-                .andExpect(jsonPath("$.data.mapFilter.pointStatus").value(nullValue()))
-                .andExpect(jsonPath("$.data.mapFilter.district").value(nullValue()))
-                .andExpect(jsonPath("$.data.mapFilter.districtName").value(nullValue()))
-                .andExpect(jsonPath("$.data.mapFilter.districtFilter").value("NONE"))
-                .andExpect(jsonPath("$.data.mapFilter.pointCodes", hasSize(5)))
-                .andExpect(jsonPath("$.data.mapFilter.deviceCodes", hasSize(9)))
-                .andExpect(jsonPath("$.data.excluded.unknownTypeCount").value(1))
-                .andExpect(jsonPath("$.data.excluded.typeKindMismatchCount").value(1))
-                .andExpect(jsonPath("$.data.excluded.nonFormalIndustryCount").value(1))
-                .andExpect(jsonPath("$.data.excluded.invalidCoordinateCount").value(0))
-                .andExpect(jsonPath("$.data.freshness.status").value("CURRENT"))
-                .andExpect(jsonPath("$.data.freshness.reason").value("READY"));
-    }
-
-    @Test
-    void associatedDevicesRenderAsOnePointAndPaginationMatchesStatusTotals() throws Exception {
-        mockMvc.perform(get(ENDPOINT))
-                .andExpect(status().isOk())
-                .andExpect(jsonPath("$.data.pagination.total").value(9))
-                .andExpect(jsonPath("$.data.pointStatusDistribution[0].count").value(1))
-                .andExpect(jsonPath("$.data.monitoringPoints", hasSize(5)))
-                .andExpect(jsonPath("$.data.unassociatedEquipment", hasSize(4)));
-    }
-
-    @Test
-    void industryFilterNarrowsDistributionAndMapFilter() throws Exception {
-        mockMvc.perform(get(ENDPOINT).param("industry", "GAS"))
-                .andExpect(status().isOk())
-                .andExpect(jsonPath("$.data.pagination.total").value(4))
-                .andExpect(jsonPath("$.data.monitoringPoints", hasSize(2)))
-                .andExpect(jsonPath("$.data.unassociatedEquipment", hasSize(2)))
-                .andExpect(jsonPath("$.data.mapFilter.industry").value("GAS"))
-                .andExpect(jsonPath("$.data.industryDistribution", hasSize(1)))
-                .andExpect(jsonPath("$.data.industryDistribution[0].industry").value("GAS"))
-                .andExpect(jsonPath("$.data.pointStatusDistribution[0].count").value(1))
-                .andExpect(jsonPath("$.data.pointStatusDistribution[3].count").value(2))
-                .andExpect(jsonPath("$.data.pointStatusDistribution[4].count").value(1));
-    }
-
-    @Test
-    void deviceTypeFilterNarrowsDistribution() throws Exception {
-        mockMvc.perform(get(ENDPOINT).param("deviceType", "GAS_SENSOR"))
-                .andExpect(status().isOk())
-                .andExpect(jsonPath("$.data.pagination.total").value(4))
-                .andExpect(jsonPath("$.data.mapFilter.deviceType").value("GAS_SENSOR"))
-                .andExpect(jsonPath("$.data.deviceTypeDistribution", hasSize(1)))
-                .andExpect(jsonPath("$.data.deviceTypeDistribution[0].deviceType.code").value("GAS_SENSOR"));
-    }
-
-    @Test
-    void pointStatusFilterNarrowsMarkersAndResetRestoresScope() throws Exception {
-        mockMvc.perform(get(ENDPOINT).param("pointStatus", "WARNING"))
-                .andExpect(status().isOk())
-                .andExpect(jsonPath("$.data.pagination.total").value(2))
-                .andExpect(jsonPath("$.data.mapFilter.pointStatus").value("WARNING"))
-                .andExpect(jsonPath("$.data.pointStatusDistribution[0].count").value(1))
-                .andExpect(jsonPath("$.data.pointStatusDistribution[1].count").value(1))
-                .andExpect(jsonPath("$.data.pointStatusDistribution[4].count").value(1));
-
-        mockMvc.perform(get(ENDPOINT))
-                .andExpect(status().isOk())
-                .andExpect(jsonPath("$.data.pagination.total").value(9))
-                .andExpect(jsonPath("$.data.mapFilter.pointStatus").value(nullValue()));
-    }
-
-    @Test
-    void districtFilterNarrowsDistributionAndMapFilter() throws Exception {
-        mockMvc.perform(get(ENDPOINT).param("district", "1"))
-                .andExpect(status().isOk())
-                .andExpect(jsonPath("$.data.pagination.total").value(3))
-                .andExpect(jsonPath("$.data.mapFilter.district").value(1))
-                .andExpect(jsonPath("$.data.mapFilter.districtName").value("城北"))
-                .andExpect(jsonPath("$.data.mapFilter.districtFilter").value("ASSIGNED"))
-                .andExpect(jsonPath("$.data.districtDistribution", hasSize(1)))
-                .andExpect(jsonPath("$.data.districtDistribution[0].district").value(1))
-                .andExpect(jsonPath("$.data.districtDistribution[0].districtName").value("城北"))
-                .andExpect(jsonPath("$.data.districtDistribution[0].pointCount").value(2))
-                .andExpect(jsonPath("$.data.districtDistribution[0].deviceCount").value(2))
-                .andExpect(jsonPath("$.data.industryDistribution", hasSize(2)));
-    }
-
-    @Test
-    void unassignedDistrictFilterSelectsOnlyUnassignedAndEchoesFilterFlag() throws Exception {
-        mockMvc.perform(get(ENDPOINT).param("district", "UNASSIGNED"))
-                .andExpect(status().isOk())
-                .andExpect(jsonPath("$.data.pagination.total").value(2))
-                .andExpect(jsonPath("$.data.mapFilter.district").value(nullValue()))
-                .andExpect(jsonPath("$.data.mapFilter.districtName").value(nullValue()))
-                .andExpect(jsonPath("$.data.mapFilter.districtFilter").value("UNASSIGNED"))
-                .andExpect(jsonPath("$.data.districtDistribution", hasSize(1)))
-                .andExpect(jsonPath("$.data.districtDistribution[0].district").value(nullValue()))
-                .andExpect(jsonPath("$.data.districtDistribution[0].districtName").value("未分区"))
-                .andExpect(jsonPath("$.data.districtDistribution[0].pointCount").value(1))
-                .andExpect(jsonPath("$.data.districtDistribution[0].deviceCount").value(2));
-    }
-
-    @Test
-    void invalidDistrictReturnsTypedBadRequestBeforeReadAdapterIsCalled() throws Exception {
-        mockMvc.perform(get(ENDPOINT).param("district", "城区B"))
-                .andExpect(status().isBadRequest())
-                .andExpect(jsonPath("$.code").value(400))
-                .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"))
-                .andExpect(jsonPath("$.data.message").value(
-                        "district must be 0, 1, 2 or UNASSIGNED"));
-
-        verifyNoInteractions(adapter);
-    }
-
-    @Test
-    void unknownDeviceTypeReturnsExplicitEmptyDistribution() throws Exception {
-        mockMvc.perform(get(ENDPOINT).param("deviceType", "NOT_A_TYPE"))
-                .andExpect(status().isOk())
-                .andExpect(jsonPath("$.data.pagination.total").value(0))
-                .andExpect(jsonPath("$.data.monitoringPoints", hasSize(0)))
-                .andExpect(jsonPath("$.data.pointStatusDistribution[0].count").value(1))
-                .andExpect(jsonPath("$.data.mapFilter.deviceType").value("NOT_A_TYPE"))
-                .andExpect(jsonPath("$.data.excluded.invalidCoordinateCount").value(0))
-                .andExpect(jsonPath("$.data.freshness.status").value("EMPTY"))
-                .andExpect(jsonPath("$.data.freshness.reason").value("NO_RESOLVED_DEVICES"));
-    }
-
-    @Test
-    void invalidIndustryReturnsTypedBadRequestBeforeReadAdapterIsCalled() throws Exception {
-        mockMvc.perform(get(ENDPOINT).param("industry", "WHATEVER"))
-                .andExpect(status().isBadRequest())
-                .andExpect(jsonPath("$.code").value(400))
-                .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"))
-                .andExpect(jsonPath("$.data.message").value(
-                        "industry must be GAS, WATER_SUPPLY, DRAINAGE or MANHOLE"));
-
-        verifyNoInteractions(adapter);
-    }
-
-    @Test
-    void invalidPointStatusReturnsTypedBadRequestBeforeReadAdapterIsCalled() throws Exception {
-        mockMvc.perform(get(ENDPOINT).param("pointStatus", "UNKNOWN_STATE"))
-                .andExpect(status().isBadRequest())
-                .andExpect(jsonPath("$.code").value(400))
-                .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"))
-                .andExpect(jsonPath("$.data.message").value(
-                        "pointStatus must be ALARM, WARNING, WORK_ORDER, NORMAL, OFFLINE, EMPTY, UNKNOWN or UNASSOCIATED"));
-
-        verifyNoInteractions(adapter);
-    }
-
-    @Test
-    void invalidPaginationReturnsTypedBadRequestBeforeReadAdapterIsCalled() throws Exception {
-        mockMvc.perform(get(ENDPOINT)
-                        .param("page", "0")
-                        .param("pageSize", "1001"))
-                .andExpect(status().isBadRequest())
-                .andExpect(jsonPath("$.code").value(400))
-                .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"))
-                .andExpect(jsonPath("$.data.message").value("监测分布分页参数不合法"));
-
-        verifyNoInteractions(adapter);
-    }
-
-    @Test
-    void invalidSnapshotTimestampReturnsTypedBadRequestBeforeReadAdapterIsCalled() throws Exception {
-        mockMvc.perform(get(ENDPOINT).param("snapshotAt", "not-a-timestamp"))
-                .andExpect(status().isBadRequest())
-                .andExpect(jsonPath("$.code").value(400))
-                .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"))
-                .andExpect(jsonPath("$.data.message").value("监测分布分页参数不合法"));
-
-        verifyNoInteractions(adapter);
-    }
-
-    @Test
-    void expiredSnapshotReturnsTypedBadRequestAndDoesNotPretendToBeEmpty() throws Exception {
-        when(adapter.load("2026-09-02T09:00:00+08:00"))
-                .thenThrow(new CockpitSnapshotExpiredException("snapshot expired"));
-
-        mockMvc.perform(get(ENDPOINT).param("snapshotAt", "2026-09-02T09:00:00+08:00"))
-                .andExpect(status().isBadRequest())
-                .andExpect(jsonPath("$.code").value(400))
-                .andExpect(jsonPath("$.data.category").value("SNAPSHOT_EXPIRED"))
-                .andExpect(jsonPath("$.data.message").value("地图快照已失效,请重新加载"));
-    }
-
-    @Test
-    void serverFailureReturnsTypedServerError() throws Exception {
-        when(adapter.load(null)).thenThrow(new CockpitReadException("upstream unavailable"));
-
-        mockMvc.perform(get(ENDPOINT).header("X-Request-Id", "monitoring-distribution-500"))
-                .andExpect(status().isInternalServerError())
-                .andExpect(jsonPath("$.code").value(500))
-                .andExpect(jsonPath("$.data.category").value("SERVER_ERROR"))
-                .andExpect(jsonPath("$.data.message").value("驾驶舱监测分布数据暂不可用"))
-                .andExpect(jsonPath("$.data.requestId").value("monitoring-distribution-500"));
-    }
-
-    @Test
-    void mutatingRequestIsRejectedBeforeReadAdapterIsCalled() throws Exception {
-        mockMvc.perform(post(ENDPOINT))
-                .andExpect(status().isMethodNotAllowed());
-
-        verifyNoInteractions(adapter);
-    }
-
-    @Test
-    void controllerOnlyExposesAuthenticatedReadOnlyOperations() {
-        assertTrue(Arrays.stream(CockpitMonitoringDistributionController.class.getDeclaredMethods())
-                .filter(this::hasRequestMappingAnnotation)
-                .allMatch(CockpitMonitoringDistributionControllerTest::isReadOnlyMappedMethod));
-    }
-
-    @Test
-    void webSocketDeltaRecomputesStatusCountsAndIgnoresOldOrUnknownDeltas() {
-        CockpitMonitoringDistributionResponse initial = service.load(
-                "delta-baseline", null, null, null, null, 1, 100, SNAPSHOT_AT);
-        assertEquals(1, statusCount(initial, CockpitMonitoringPointStatus.OFFLINE));
-        assertEquals(2, statusCount(initial, CockpitMonitoringPointStatus.NORMAL));
-
-        CockpitMonitoringDistributionResponse applied = service.applyDeviceStatusDelta(
-                "delta-1", null, null, null, null, 1, 100, SNAPSHOT_AT,
-                new CockpitMonitoringDistributionDeviceStatusDelta(
-                        "GAS-003", null, "ONLINE", "2026-09-02T09:59:00+08:00",
-                        "2026-09-02T10:05:00+08:00"));
-
-        assertEquals(0, statusCount(applied, CockpitMonitoringPointStatus.OFFLINE));
-        assertEquals(3, statusCount(applied, CockpitMonitoringPointStatus.NORMAL));
-
-        CockpitMonitoringDistributionResponse ignoredOld = service.applyDeviceStatusDelta(
-                "delta-2", null, null, null, null, 1, 100, SNAPSHOT_AT,
-                new CockpitMonitoringDistributionDeviceStatusDelta(
-                        "GAS-003", null, "OFFLINE", "2026-09-02T09:59:00+08:00",
-                        "2026-09-02T10:04:00+08:00"));
-
-        assertEquals(0, statusCount(ignoredOld, CockpitMonitoringPointStatus.OFFLINE));
-        assertEquals(3, statusCount(ignoredOld, CockpitMonitoringPointStatus.NORMAL));
-
-        CockpitMonitoringDistributionResponse ignoredUnknown = service.applyDeviceStatusDelta(
-                "delta-3", null, null, null, null, 1, 100, SNAPSHOT_AT,
-                new CockpitMonitoringDistributionDeviceStatusDelta(
-                        "NO-SUCH-DEVICE", null, "ONLINE", "2026-09-02T09:59:00+08:00",
-                        "2026-09-02T10:06:00+08:00"));
-
-        assertEquals(0, statusCount(ignoredUnknown, CockpitMonitoringPointStatus.OFFLINE));
-        assertEquals(3, statusCount(ignoredUnknown, CockpitMonitoringPointStatus.NORMAL));
-    }
-
-    @Test
-    void sixEmptyUntypedPointsRemainInLedgerAndStatusTotalsWithoutVerifiedCoordinates() throws Exception {
-        List<CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint> points = new java.util.ArrayList<>();
-        for (String code : Arrays.asList("GS001", "GS002", "PS001", "PS002", "RQ001", "RQ002")) {
-            points.add(new CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint(
-                    code, code, null, null, new BigDecimal("110.399876"), new BigDecimal("28.458287"),
-                    false, Collections.emptyList()));
-        }
-        CockpitMonitoringDistributionSnapshot snapshot = new CockpitMonitoringDistributionSnapshot(
-                SNAPSHOT_AT, SNAPSHOT_AT, Collections.emptyList(), points, Collections.emptyList());
-        when(adapter.load(null)).thenReturn(snapshot);
-        when(adapter.load(SNAPSHOT_AT)).thenReturn(snapshot);
-
-        mockMvc.perform(get(ENDPOINT).param("pointStatus", "EMPTY").param("pageSize", "2"))
-                .andExpect(status().isOk())
-                .andExpect(jsonPath("$.data.pagination.total").value(6))
-                .andExpect(jsonPath("$.data.pagination.totalPages").value(3))
-                .andExpect(jsonPath("$.data.pagination.hasMore").value(true))
-                .andExpect(jsonPath("$.data.monitoringPoints", hasSize(2)))
-                .andExpect(jsonPath("$.data.monitoringPoints[0].pointCode").value("GS001"))
-                .andExpect(jsonPath("$.data.monitoringPoints[0].pointStatus").value("EMPTY"))
-                .andExpect(jsonPath("$.data.monitoringPoints[0].industry").value(nullValue()))
-                .andExpect(jsonPath("$.data.monitoringPoints[0].deviceType.code").value(nullValue()))
-                .andExpect(jsonPath("$.data.monitoringPoints[0].coordinateValidation.renderable").value(false))
-                .andExpect(jsonPath("$.data.pointStatusDistribution[5].count").value(6))
-                .andExpect(jsonPath("$.data.districtDistribution[0].districtName").value("未分区"))
-                .andExpect(jsonPath("$.data.districtDistribution[0].pointCount").value(6))
-                .andExpect(jsonPath("$.data.industryDistribution[0].industry").value(nullValue()))
-                .andExpect(jsonPath("$.data.industryDistribution[0].pointCount").value(6))
-                .andExpect(jsonPath("$.data.deviceTypeDistribution[0].deviceType.code").value(nullValue()))
-                .andExpect(jsonPath("$.data.mapFilter.pointCodes", hasSize(6)))
-                .andExpect(jsonPath("$.data.mapFilter.deviceCodes", hasSize(0)))
-                .andExpect(jsonPath("$.data.excluded.unknownTypeCount").value(0))
-                .andExpect(jsonPath("$.data.excluded.invalidCoordinateCount").value(0))
-                .andExpect(jsonPath("$.data.freshness.status").value("CURRENT"))
-                .andExpect(jsonPath("$.data.freshness.reason").value("READY"));
-        mockMvc.perform(get(ENDPOINT).param("page", "3").param("pageSize", "2").param("snapshotAt", SNAPSHOT_AT))
-                .andExpect(status().isOk())
-                .andExpect(jsonPath("$.data.pagination.total").value(6))
-                .andExpect(jsonPath("$.data.pagination.hasMore").value(false))
-                .andExpect(jsonPath("$.data.monitoringPoints[0].pointCode").value("RQ001"));
-        mockMvc.perform(get(ENDPOINT).param("district", "UNASSIGNED"))
-                .andExpect(status().isOk()).andExpect(jsonPath("$.data.pagination.total").value(6));
-        for (String[] filter : Arrays.asList(new String[]{"industry", "GAS"},
-                new String[]{"deviceType", "GAS_SENSOR"}, new String[]{"district", "0"},
-                new String[]{"pointStatus", "NORMAL"})) {
-            mockMvc.perform(get(ENDPOINT).param(filter[0], filter[1]))
-                    .andExpect(status().isOk()).andExpect(jsonPath("$.data.pagination.total").value(0));
-        }
-    }
-
-    @Test
-    void coordinatesDoNotAffectStatisticsAndAssociatedDevicesNeverBecomeStandaloneMarkers() {
-        CockpitMonitoringPointDeviceSnapshot bound = device(
-                "BOUND", "关联设备", true, true, false, false, Collections.emptyList());
-        CockpitMonitoringDistributionSnapshot snapshot = new CockpitMonitoringDistributionSnapshot(
-                SNAPSHOT_AT, SNAPSHOT_AT, fixture().getTypes(),
-                Collections.singletonList(new CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint(
-                        "P", "点位", 0, "GAS_SENSOR", null, null, false, Collections.singletonList(bound))),
-                Arrays.asList(smartDevice("BOUND", "关联设备", 1, "GAS_SENSOR", "110.4", "28.4", false,
-                                true, true, false, false, Collections.emptyList()),
-                        smartDevice("FREE", "独立设备", 1, "GAS_SENSOR", "181", "28.4", true,
-                                true, false, false, false, Collections.emptyList())));
-        when(adapter.load(null)).thenReturn(snapshot);
-        CockpitMonitoringDistributionResponse all = service.load("all", null, null, null, null, 1, 100, null);
-        assertEquals(2, all.getPagination().getTotal());
-        assertEquals(1, all.getUnassociatedEquipment().size());
-        assertEquals("FREE", all.getUnassociatedEquipment().get(0).getDeviceCode());
-        assertTrue(!all.getMonitoringPoints().get(0).getCoordinateValidation().isRenderable());
-        assertTrue(!all.getUnassociatedEquipment().get(0).getCoordinateValidation().isRenderable());
-        assertEquals(0, all.getExcluded().getInvalidCoordinateCount());
-        assertEquals("READY", all.getFreshness().getReason());
-        assertEquals(1, statusCount(all, CockpitMonitoringPointStatus.ALARM));
-        assertEquals(0, statusCount(all, CockpitMonitoringPointStatus.NORMAL));
-
-        CockpitMonitoringDistributionResponse normal = service.load("normal", null, null, "NORMAL", null, 1, 100, null);
-        assertEquals(Collections.singletonList("FREE"), normal.getMapFilter().getDeviceCodes());
-        assertTrue(normal.getMapFilter().getPointCodes().isEmpty());
-        CockpitMonitoringDistributionResponse district = service.load("district", null, null, null, "1", 1, 100, null);
-        assertEquals(1, district.getPagination().getTotal(), "被筛掉的点位不能让关联设备变成独立设备");
-        assertEquals(Collections.singletonList("FREE"), district.getMapFilter().getDeviceCodes());
-    }
-
-    @Test
-    void exclusionsCountEachRejectedObjectOnceRegardlessOfAssociatedDeviceCount() {
-        CockpitMonitoringPointDeviceSnapshot d = device("D", "设备", true, false, false, false, Collections.emptyList());
-        CockpitMonitoringDistributionSnapshot snapshot = new CockpitMonitoringDistributionSnapshot(
-                SNAPSHOT_AT, SNAPSHOT_AT, fixture().getTypes(), Arrays.asList(
-                        point("EMPTY", "合法空点", null, null, "110.4", "28.4"),
-                        point("UNKNOWN", "未知类型", 0, "LEGACY", "110.4", "28.4", d, d, d),
-                        point("MISMATCH", "错种类", 0, "WATER_PLANT", "110.4", "28.4", d, d),
-                        point("NONFORMAL", "无行业", 0, "SMART_MANHOLE", "110.4", "28.4", d)), Arrays.asList(
-                        smartDevice("U", "未知", 0, "LEGACY", "110.4", "28.4", true, true, false, false, false, Collections.emptyList()),
-                        smartDevice("M", "错种类", 0, "WATER_PLANT", "110.4", "28.4", true, true, false, false, false, Collections.emptyList()),
-                        smartDevice("N", "无行业", 0, "SMART_MANHOLE", "110.4", "28.4", true, true, false, false, false, Collections.emptyList())));
-        when(adapter.load(null)).thenReturn(snapshot);
-        CockpitMonitoringDistributionResponse response = service.load("reconcile", null, null, null, null, 1, 100, null);
-        assertEquals(1, response.getPagination().getTotal());
-        assertEquals(1, statusCount(response, CockpitMonitoringPointStatus.EMPTY));
-        assertEquals(2, response.getExcluded().getUnknownTypeCount());
-        assertEquals(2, response.getExcluded().getTypeKindMismatchCount());
-        assertEquals(2, response.getExcluded().getNonFormalIndustryCount());
-        int excluded = response.getExcluded().getUnknownTypeCount() + response.getExcluded().getTypeKindMismatchCount()
-                + response.getExcluded().getNonFormalIndustryCount() + response.getExcluded().getInvalidCoordinateCount();
-        assertEquals(7, response.getPagination().getTotal() + excluded);
-    }
-
-    private int statusCount(CockpitMonitoringDistributionResponse response,
-            CockpitMonitoringPointStatus status) {
-        return response.getPointStatusDistribution()
-                .get(status.ordinal())
-                .getCount();
-    }
-
-    private boolean hasRequestMappingAnnotation(Method method) {
-        return method.isAnnotationPresent(org.springframework.web.bind.annotation.RequestMapping.class)
-                || method.isAnnotationPresent(GetMapping.class)
-                || method.isAnnotationPresent(PostMapping.class)
-                || method.isAnnotationPresent(PutMapping.class)
-                || method.isAnnotationPresent(PatchMapping.class)
-                || method.isAnnotationPresent(DeleteMapping.class);
-    }
-
-    private static boolean isReadOnlyMappedMethod(Method method) {
-        return method.isAnnotationPresent(GetMapping.class)
-                && !method.isAnnotationPresent(PostMapping.class)
-                && !method.isAnnotationPresent(PutMapping.class)
-                && !method.isAnnotationPresent(PatchMapping.class)
-                && !method.isAnnotationPresent(DeleteMapping.class);
-    }
-
-    private static CockpitMonitoringDistributionSnapshot fixture() {
-        return new CockpitMonitoringDistributionSnapshot(
-                SNAPSHOT_AT,
-                "2026-09-02T09:59:50+08:00",
-                Arrays.asList(
-                        new CockpitMonitoringDistributionSnapshot.SourceType(
-                                "GAS_SENSOR", "燃气浓度计", "GAS", "SMART_DEVICE"),
-                        new CockpitMonitoringDistributionSnapshot.SourceType(
-                                "WATER_SUPPLY_SENSOR", "供水压力计", "WATER_SUPPLY", "SMART_DEVICE"),
-                        new CockpitMonitoringDistributionSnapshot.SourceType(
-                                "SEWAGE_ENV", "污水环境监测", "DRAINAGE", "SMART_DEVICE"),
-                        new CockpitMonitoringDistributionSnapshot.SourceType(
-                                "WATER_PLANT", "水厂", "WATER_SUPPLY", "FACILITY"),
-                        new CockpitMonitoringDistributionSnapshot.SourceType(
-                                "SMART_MANHOLE", "智能井盖", null, "SMART_DEVICE")),
-                Arrays.asList(
-                        point("P-WS-1", "供水监测点-1", 1, "WATER_SUPPLY_SENSOR",
-                                "110.150", "28.460", device("WS-001", "供水压力计-001",
-                                        true, false, false, true, Collections.emptyList())),
-                        point("P-WS-2", "供水监测点-2", 1, "WATER_SUPPLY_SENSOR",
-                                "110.151", "28.461"),
-                        point("P-GAS-1", "燃气监测点-1", 0, "GAS_SENSOR",
-                                "110.130", "28.440",
-                                device("GAS-001", "燃气浓度计-001",
-                                        true, false, false, false, Collections.emptyList()),
-                                device("GAS-002", "燃气浓度计-002",
-                                        true, true, false, false, Collections.singletonList("浓度超限"))),
-                        point("P-GAS-2", "燃气监测点-2", 0, "GAS_SENSOR",
-                                "110.131", "28.441", device("GAS-003", "燃气浓度计-003",
-                                        false, false, false, false, Collections.emptyList())),
-                        // 快照里点位片区为 null(如实未分区)→ 与脏值设备一起落入未分区桶
-                        point("P-DR-1", "污水监测点-1", null, "SEWAGE_ENV",
-                                "110.170", "28.480", device("DRAIN-001", "污水环境监测仪-001",
-                                        true, false, true, false, Collections.singletonList("浓度预警"))),
-                        point("P-MISMATCH", "水厂设施点", 0, "WATER_PLANT",
-                                "110.120", "28.430", device("MISMATCH-001", "水厂设备",
-                                        true, false, false, false, Collections.emptyList())),
-                        point("P-UNKNOWN", "未知类型点", 0, "LEGACY",
-                                "110.121", "28.431", device("UNKNOWN-001", "旧设备",
-                                        true, false, false, false, Collections.emptyList()))),
-                Arrays.asList(
-                        // 脏值片区(7)不得冒充合法片区,归一后并入未分区
-                        smartDevice("EQ-FREE-003", "压力井盖外设备", 7, "WATER_SUPPLY_SENSOR",
-                                "110.171", "28.481", true,
-                                false, false, false, false, Arrays.asList("离线")),
-                        smartDevice("EQ-FREE-001", "井盖外燃气计", 0, "GAS_SENSOR",
-                                "110.132", "28.442", true,
-                                true, false, false, false, Collections.emptyList()),
-                        smartDevice("EQ-FREE-002", "污水环境监测-独立", 1, "SEWAGE_ENV",
-                                "110.152", "28.462", true,
-                                true, false, true, false, Collections.singletonList("设备预警")),
-                        smartDevice("EQ-FREE-004", "智能井盖独立", 0, "SMART_MANHOLE",
-                                "110.133", "28.443", true,
-                                true, false, false, false, Collections.emptyList()),
-                        smartDevice("EQ-FREE-005", "越界坐标设备", 0, "GAS_SENSOR",
-                                "181", "28.444", true,
-                                true, false, false, false, Collections.emptyList())));
-    }
-
-    private static CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint point(
-            String code, String name, Integer district, String typeCode,
-            String longitude, String latitude, CockpitMonitoringPointDeviceSnapshot... devices) {
-        return new CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint(
-                code, name, district, typeCode,
-                new BigDecimal(longitude), new BigDecimal(latitude), true,
-                Arrays.asList(devices));
-    }
-
-    private static CockpitMonitoringPointDeviceSnapshot device(
-            String code, String name, boolean online, boolean alarm, boolean warning,
-            boolean workOrderInProgress, List<String> statusReasons) {
-        return new CockpitMonitoringPointDeviceSnapshot(
-                code, name, name, online, alarm, warning, workOrderInProgress,
-                statusReasons, SNAPSHOT_AT);
-    }
-
-    private static CockpitMonitoringDistributionSnapshot.SourceSmartDevice smartDevice(
-            String code, String name, Integer district, String typeCode,
-            String longitude, String latitude, boolean verified,
-            boolean online, boolean alarm, boolean warning, boolean workOrderInProgress,
-            List<String> statusReasons) {
-        return new CockpitMonitoringDistributionSnapshot.SourceSmartDevice(
-                code, name, district, typeCode,
-                new BigDecimal(longitude), new BigDecimal(latitude), verified,
-                online, alarm, warning, workOrderInProgress, statusReasons, SNAPSHOT_AT);
-    }
-}

+ 0 - 191
.scratch/cockpit-real-data-wayfinding/implementation/t29/pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitPointStatusContractTest.java

@@ -1,191 +0,0 @@
-package com.zksy.web.controller.cockpit;
-
-import com.fasterxml.jackson.databind.JsonNode;
-import com.fasterxml.jackson.databind.ObjectMapper;
-import com.zksy.cockpit.map.*;
-import com.zksy.cockpit.monitoring.*;
-import com.zksy.cockpit.point.CockpitMonitoringPointDeviceSnapshot;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-import org.springframework.test.web.servlet.MockMvc;
-import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
-import org.springframework.test.web.servlet.setup.MockMvcBuilders;
-
-import java.math.BigDecimal;
-import java.time.Clock;
-import java.time.Instant;
-import java.time.ZoneOffset;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.List;
-
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.mockito.Mockito.*;
-import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
-import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
-import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
-
-/** 同一事实集穿过两个真实控制器/服务,锁定 HTTP 绑定、回显、分页前计数和匹配语义。 */
-class CockpitPointStatusContractTest {
-    private static final String TOKEN = "2026-09-02T10:00:00+08:00";
-    private static final String MAP = "/api/cockpit/v1/map-snapshot";
-    private static final String DISTRIBUTION = "/api/cockpit/v1/monitoring/distribution";
-    private final ObjectMapper json = new ObjectMapper();
-    private MockMvc mvc;
-    private CockpitMapSnapshotAdapter mapAdapter;
-    private CockpitMonitoringDistributionAdapter distributionAdapter;
-
-    @BeforeEach
-    void setUp() {
-        Clock clock = Clock.fixed(Instant.parse("2026-09-02T02:00:00Z"), ZoneOffset.UTC);
-        mapAdapter = mock(CockpitMapSnapshotAdapter.class);
-        distributionAdapter = mock(CockpitMonitoringDistributionAdapter.class);
-        when(mapAdapter.load(any())).thenReturn(mapFixture());
-        when(distributionAdapter.load(any())).thenReturn(distributionFixture());
-        mvc = MockMvcBuilders.standaloneSetup(
-                new CockpitMapSnapshotController(new CockpitMapSnapshotService(mapAdapter, clock)),
-                new CockpitMonitoringDistributionController(
-                        new CockpitMonitoringDistributionService(distributionAdapter, clock))).build();
-    }
-
-    @Test
-    void csvAndRepeatedParametersAreOrDeduplicatedAndEchoedIdentically() throws Exception {
-        for (String endpoint : Arrays.asList(MAP, DISTRIBUTION)) {
-            JsonNode result = read(endpoint, "WARNING,ALARM", "ALARM");
-            assertEquals("ALARM,WARNING", result.path("mapFilter").path("pointStatus").asText());
-            assertEquals(3, total(result));
-            assertEquals(2, result.path("monitoringPoints").size());
-            assertEquals(1, result.path("unassociatedEquipment").size());
-            assertEquals("D-A", result.path("unassociatedEquipment").get(0).path("deviceCode").asText());
-            assertEquals(3, result.path("mapFilter").path("deviceCodes").size());
-        }
-        assertEquals(read(MAP, "ALARM,WARNING").path("mapFilter"),
-                read(DISTRIBUTION, "WARNING", "ALARM").path("mapFilter"));
-    }
-
-    @Test
-    void globalEightBucketsDoNotChangeWithAnyFilterOrPageAndNeverDoubleCountDevices() throws Exception {
-        JsonNode baseline = read(MAP);
-        JsonNode counts = baseline.path("pointStatusDistribution");
-        assertEquals(8, counts.size());
-        assertEquals(1, count(counts, "ALARM"));
-        assertEquals(1, count(counts, "WARNING"));
-        assertEquals(1, count(counts, "NORMAL"));
-        assertEquals(1, count(counts, "EMPTY"));
-        assertEquals(0, count(counts, "UNKNOWN"));
-        assertEquals(4, count(counts, "UNASSOCIATED"));
-        int sum = 0;
-        for (JsonNode row : counts) sum += row.path("count").asInt();
-        assertEquals(8, sum);
-        for (String endpoint : Arrays.asList(MAP, DISTRIBUTION)) {
-            assertEquals(8, total(read(endpoint)));
-            assertEquals(counts, read(endpoint, "ALARM").path("pointStatusDistribution"));
-            JsonNode filtered = response(get(endpoint).param("pointStatus", "ALARM")
-                    .param("industry", "GAS").param("district", "0").param("deviceType", "GAS_SENSOR")
-                    .param("pageSize", "1").param("page", "2").param("snapshotAt", TOKEN));
-            assertEquals(2, total(filtered));
-            assertEquals(counts, filtered.path("pointStatusDistribution"));
-            JsonNode empty = response(get(endpoint).param("deviceType", "NOT_A_TYPE"));
-            assertEquals(0, total(empty));
-            assertEquals(counts, empty.path("pointStatusDistribution"));
-        }
-    }
-
-    @Test
-    void emptyUnknownAndUnassociatedUseTheirOwnObjectSemantics() throws Exception {
-        for (String endpoint : Arrays.asList(MAP, DISTRIBUTION)) {
-            JsonNode empty = read(endpoint, "EMPTY");
-            assertEquals(1, total(empty));
-            assertEquals("P-E", empty.path("monitoringPoints").get(0).path("pointCode").asText());
-            assertEquals(0, empty.path("unassociatedEquipment").size());
-            JsonNode unknown = read(endpoint, "UNKNOWN");
-            assertEquals(1, total(unknown));
-            assertEquals("D-U", unknown.path("unassociatedEquipment").get(0).path("deviceCode").asText());
-            assertEquals("UNKNOWN", unknown.path("unassociatedEquipment").get(0).path("deviceStatus").asText());
-            assertEquals(0, unknown.path("monitoringPoints").size());
-            assertEquals(4, total(read(endpoint, "UNASSOCIATED")));
-            JsonNode onlyAlarmDevices = read(endpoint, "UNASSOCIATED", "ALARM");
-            assertEquals(1, total(onlyAlarmDevices));
-            assertEquals(0, onlyAlarmDevices.path("monitoringPoints").size());
-            assertEquals(1, total(read(endpoint, "UNASSOCIATED,UNKNOWN")));
-            // 明确离线不能冒充 UNKNOWN;EMPTY 不适用于设备自身。
-            assertEquals("D-O", read(endpoint, "UNASSOCIATED,OFFLINE")
-                    .path("unassociatedEquipment").get(0).path("deviceCode").asText());
-            assertEquals(0, total(read(endpoint, "UNASSOCIATED,EMPTY")));
-        }
-    }
-
-    @Test
-    void invalidValuesIncludingOneBadRepeatedParameterAreRejectedBeforeReading() throws Exception {
-        for (String endpoint : Arrays.asList(MAP, DISTRIBUTION)) {
-            for (String value : Arrays.asList("BOGUS", "ALARM,BOGUS", "ALARM,", "EMPTY,,WARNING")) {
-                mvc.perform(get(endpoint).param("pointStatus", value))
-                        .andExpect(status().isBadRequest())
-                        .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"));
-            }
-            mvc.perform(get(endpoint).param("pointStatus", "ALARM", "BOGUS"))
-                    .andExpect(status().isBadRequest())
-                    .andExpect(jsonPath("$.data.category").value("INVALID_REQUEST"));
-        }
-        verifyNoInteractions(mapAdapter, distributionAdapter);
-    }
-
-    private JsonNode read(String endpoint, String... statuses) throws Exception {
-        MockHttpServletRequestBuilder request = get(endpoint);
-        if (statuses.length > 0) request.param("pointStatus", statuses);
-        return response(request);
-    }
-
-    private JsonNode response(MockHttpServletRequestBuilder request) throws Exception {
-        String content = mvc.perform(request).andExpect(status().isOk())
-                .andReturn().getResponse().getContentAsString();
-        return json.readTree(content).path("data");
-    }
-
-    private static int total(JsonNode data) {
-        return data.has("pagination") ? data.path("pagination").path("total").asInt() : data.path("total").asInt();
-    }
-
-    private static int count(JsonNode rows, String status) {
-        for (JsonNode row : rows) if (status.equals(row.path("status").asText())) return row.path("count").asInt();
-        throw new AssertionError("Missing bucket: " + status);
-    }
-
-    private static List<CockpitMonitoringPointDeviceSnapshot> devices(String code) {
-        if ("P-E".equals(code)) return Collections.emptyList();
-        return Collections.singletonList(new CockpitMonitoringPointDeviceSnapshot(
-                "D-" + code, code, "传感器", true, "P-A".equals(code), "P-W".equals(code), false,
-                Collections.emptyList(), null)); // 缺状态时间不代表未发布。
-    }
-
-    private static CockpitMapSnapshot mapFixture() {
-        List<CockpitMapSnapshot.SourceMonitoringPoint> points = new java.util.ArrayList<>();
-        for (String code : Arrays.asList("P-A", "P-W", "P-E", "P-N")) {
-            points.add(new CockpitMapSnapshot.SourceMonitoringPoint(code, code, 0,
-                    "P-E".equals(code) ? null : "GAS_SENSOR", null, null, false, devices(code)));
-        }
-        List<CockpitMapSnapshot.SourceSmartDevice> devices = new java.util.ArrayList<>();
-        for (String code : Arrays.asList("D-A", "D-N", "D-O", "D-U", "D-P-A")) {
-            devices.add(new CockpitMapSnapshot.SourceSmartDevice(code, code, 0, "GAS_SENSOR", null, null, false,
-                    "D-A".equals(code) ? "ALARM" : "D-O".equals(code) ? "OFFLINE" : "D-U".equals(code) ? null : "NORMAL"));
-        }
-        return new CockpitMapSnapshot(TOKEN, Collections.singletonList(
-                new CockpitMapSnapshot.SourceType("GAS_SENSOR", "传感器", "GAS", "SMART_DEVICE")), points, devices);
-    }
-
-    private static CockpitMonitoringDistributionSnapshot distributionFixture() {
-        List<CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint> points = new java.util.ArrayList<>();
-        for (String code : Arrays.asList("P-A", "P-W", "P-E", "P-N")) {
-            points.add(new CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint(code, code, 0,
-                    "P-E".equals(code) ? null : "GAS_SENSOR", null, null, false, devices(code)));
-        }
-        List<CockpitMonitoringDistributionSnapshot.SourceSmartDevice> devices = new java.util.ArrayList<>();
-        for (String code : Arrays.asList("D-A", "D-N", "D-O", "D-U", "D-P-A")) {
-            devices.add(new CockpitMonitoringDistributionSnapshot.SourceSmartDevice(code, code, 0, "GAS_SENSOR",
-                    null, null, false, !"D-O".equals(code), "D-A".equals(code), false, false,
-                    Collections.emptyList(), null, !"D-U".equals(code)));
-        }
-        return new CockpitMonitoringDistributionSnapshot(TOKEN, TOKEN, Collections.singletonList(
-                new CockpitMonitoringDistributionSnapshot.SourceType("GAS_SENSOR", "传感器", "GAS", "SMART_DEVICE")), points, devices);
-    }
-}

+ 0 - 46
.scratch/cockpit-real-data-wayfinding/implementation/t29/pipe-network-service/zksy-admin/src/test/java/com/zksy/web/controller/cockpit/CockpitPointStatusSnapshotSerializationTest.java

@@ -1,46 +0,0 @@
-package com.zksy.web.controller.cockpit;
-
-import com.zksy.cockpit.foundation.CockpitCoordinateValidator;
-import com.zksy.cockpit.map.CockpitMapSnapshot;
-import com.zksy.cockpit.point.CockpitMonitoringPointDeviceSnapshot;
-import com.zksy.cockpit.point.CockpitMonitoringPointStatus;
-import com.zksy.cockpit.point.CockpitMonitoringPointStatusResolver;
-import com.zksy.framework.config.FastJson2JsonRedisSerializer;
-import org.junit.jupiter.api.Test;
-import java.nio.charset.StandardCharsets;
-import java.util.Arrays;
-import java.util.Collections;
-import static org.junit.jupiter.api.Assertions.*;
-
-class CockpitPointStatusSnapshotSerializationTest {
-    private final FastJson2JsonRedisSerializer<Object> serializer = new FastJson2JsonRedisSerializer<>(Object.class);
-
-    @Test
-    void publishedAndMissingDeviceStatesSurviveTheRealRedisSerializer() {
-        CockpitMonitoringPointDeviceSnapshot unpublished = new CockpitMonitoringPointDeviceSnapshot(
-                "BOUND", "设备", null, null, null, CockpitCoordinateValidator.validate(null, null, false),
-                false, false, false, false, Collections.emptyList(), null, false);
-        CockpitMapSnapshot snapshot = new CockpitMapSnapshot("2026-09-02T10:00:00+08:00", Collections.emptyList(),
-                Collections.singletonList(new CockpitMapSnapshot.SourceMonitoringPoint(
-                        "P", "点位", null, null, null, null, false, Collections.singletonList(unpublished))),
-                Arrays.asList(new CockpitMapSnapshot.SourceSmartDevice("ALARM", "报警", null, null, null, null, false, "ALARM"),
-                        new CockpitMapSnapshot.SourceSmartDevice("UNKNOWN", "未发布", null, null, null, null, false, null)));
-        CockpitMapSnapshot restored = (CockpitMapSnapshot) serializer.deserialize(serializer.serialize(snapshot));
-        assertEquals("ALARM", restored.getSmartDevices().get(0).getDeviceStatus());
-        assertNull(restored.getSmartDevices().get(1).getDeviceStatus());
-        assertFalse(restored.getMonitoringPoints().get(0).getDevices().get(0).isStatusPublished());
-        // 新设备发布标记不改变既有点位六态聚合。
-        assertEquals(CockpitMonitoringPointStatus.OFFLINE,
-                CockpitMonitoringPointStatusResolver.resolve(restored.getMonitoringPoints().get(0).getDevices()));
-    }
-
-    @Test
-    void legacySnapshotWithoutNewFieldsDoesNotInventDeviceStatus() {
-        String payload = "{\"@type\":\"com.zksy.cockpit.map.CockpitMapSnapshot\","
-                + "\"snapshotAt\":\"2026-09-02T10:00:00+08:00\",\"types\":[],\"monitoringPoints\":[],"
-                + "\"smartDevices\":[{\"deviceCode\":\"LEGACY\",\"deviceName\":\"旧设备\",\"coordinateVerified\":false}]}";
-        CockpitMapSnapshot restored = (CockpitMapSnapshot) serializer.deserialize(payload.getBytes(StandardCharsets.UTF_8));
-        assertEquals("LEGACY", restored.getSmartDevices().get(0).getDeviceCode());
-        assertNull(restored.getSmartDevices().get(0).getDeviceStatus());
-    }
-}

+ 0 - 298
.scratch/cockpit-real-data-wayfinding/implementation/t29/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/BusinessCockpitMapSnapshotAdapter.java

@@ -1,298 +0,0 @@
-package com.zksy.cockpit.map;
-
-import com.zksy.base.domain.EquipmentBase;
-import com.zksy.base.domain.EquipmentType;
-import com.zksy.base.domain.MonitorPoint;
-import com.zksy.base.service.EquipmentBaseService;
-import com.zksy.base.service.EquipmentTypeService;
-import com.zksy.base.service.MonitorPointService;
-import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
-import com.zksy.cockpit.foundation.CockpitReadException;
-import com.zksy.cockpit.foundation.CockpitSnapshotExpiredException;
-import com.zksy.cockpit.foundation.CockpitSnapshotTime;
-import com.zksy.cockpit.foundation.CockpitTypeIndustry;
-import com.zksy.cockpit.point.BusinessCockpitDeviceFactResolver;
-import com.zksy.cockpit.foundation.CockpitDistrict;
-import com.zksy.cockpit.point.CockpitMonitoringPointDeviceSnapshot;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.context.annotation.Primary;
-import org.springframework.data.redis.core.RedisTemplate;
-import org.springframework.stereotype.Component;
-
-import java.time.Clock;
-import java.time.DateTimeException;
-import java.time.Duration;
-import java.time.OffsetDateTime;
-import java.time.ZoneId;
-import java.time.format.DateTimeFormatter;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.LinkedHashMap;
-import java.util.LinkedHashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Objects;
-import java.util.Set;
-import java.util.concurrent.TimeUnit;
-import java.util.concurrent.ConcurrentHashMap;
-import java.util.concurrent.ConcurrentMap;
-import java.util.stream.Collectors;
-
-/**
- * 地图快照的真实数据适配器(BT-5 纵向样板)。
- *
- * <p>组合既有表与服务(不新增表、不加投影):
- * <ul>
- *   <li>点位:{@code monitor_point}(MyBatis-Plus 逻辑删除自动过滤 {@code del_flag});</li>
- *   <li>点位→设备:{@code equipment_point_rel}(经 {@link EquipmentBaseService#getEquipmentByPoint});</li>
- *   <li>设备状态:{@code equipment_status}(在线/报警/当前状态);</li>
- *   <li>预警:{@code early_warning}(按 {@code device_code},未关闭即视为预警中);</li>
- *   <li>在途工单:{@code work_order}(注意现网状态是 **0-based**:0 待派单 … 5 已结案、7 已取消);</li>
- *   <li>类型字典:{@code equipment_type}(行业由 {@code parent_id} 根推导:1 供水、2 排水、3 燃气、4 窨井)。</li>
- * </ul>
- *
- * <p>无 {@code snapshotAt} 的首屏请求固定首次读取结果;显式 token 只查询已签发的版本,
- * 缺失或过期时拒绝,不用当前数据冒充旧快照。地图与详情端点通过共享快照存储复用该版本,
- * 进程本地缓存只用于减少 Redis 访问,不负责定义跨实例一致性。
- *
- * <p><b>如实接线,不臆造</b>:现网没有「坐标是否已校核」字段,故一律按未校核(
- * {@code coordinateVerified=false})上报,由冻结字典裁决为不可渲染;不为了「图上有东西」而伪造校验状态。
- * 现网 {@code monitor_point} 也没有行业/类型列,点位的 {@code typeCode} 只能在其关联设备类型唯一时推导。
- */
-@Component
-@Primary
-public class BusinessCockpitMapSnapshotAdapter implements CockpitMapSnapshotAdapter {
-
-    private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
-    private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
-    private static final Duration SNAPSHOT_RETENTION = Duration.ofMinutes(10);
-    private static final int MAX_CACHED_SNAPSHOTS = 256;
-
-    private final MonitorPointService monitorPointService;
-    private final EquipmentBaseService equipmentBaseService;
-    private final EquipmentTypeService equipmentTypeService;
-    private final BusinessCockpitDeviceFactResolver deviceFactResolver;
-    private final Clock clock;
-    private final RedisTemplate<Object, Object> redisTemplate;
-    private final ConcurrentMap<String, CockpitMapSnapshot> snapshots = new ConcurrentHashMap<>();
-
-    @Autowired
-    public BusinessCockpitMapSnapshotAdapter(
-            MonitorPointService monitorPointService,
-            EquipmentBaseService equipmentBaseService,
-            EquipmentTypeService equipmentTypeService,
-            BusinessCockpitDeviceFactResolver deviceFactResolver,
-            Clock clock,
-            RedisTemplate<Object, Object> redisTemplate) {
-        this.monitorPointService = monitorPointService;
-        this.equipmentBaseService = equipmentBaseService;
-        this.equipmentTypeService = equipmentTypeService;
-        this.deviceFactResolver = deviceFactResolver;
-        this.clock = clock;
-        this.redisTemplate = redisTemplate;
-    }
-
-    public BusinessCockpitMapSnapshotAdapter(
-            MonitorPointService monitorPointService,
-            EquipmentBaseService equipmentBaseService,
-            EquipmentTypeService equipmentTypeService,
-            BusinessCockpitDeviceFactResolver deviceFactResolver,
-            Clock clock) {
-        this(monitorPointService, equipmentBaseService, equipmentTypeService, deviceFactResolver, clock, null);
-    }
-
-    @Override
-    public CockpitMapSnapshot load(String snapshotAt) {
-        String resolvedSnapshotAt = snapshotAt == null ? businessTime() : snapshotAt;
-        OffsetDateTime requestedAt = parseSnapshotAt(resolvedSnapshotAt);
-        OffsetDateTime now = OffsetDateTime.now(clock);
-        validateSnapshotWindow(requestedAt, now, "snapshot timestamp outside retention window");
-
-        snapshots.keySet().removeIf(cachedAt ->
-                !OffsetDateTime.parse(cachedAt).isAfter(now.minus(SNAPSHOT_RETENTION)));
-        CockpitMapSnapshot cached = snapshots.get(resolvedSnapshotAt);
-        if (cached != null) {
-            validateSnapshotWindow(requestedAt, OffsetDateTime.now(clock), "snapshot timestamp expired during load");
-            return cached;
-        }
-
-        String cacheKey = "cockpit:map-snapshot:" + resolvedSnapshotAt;
-        if (redisTemplate != null) {
-            try {
-                CockpitMapSnapshot shared = (CockpitMapSnapshot) redisTemplate.opsForValue().get(cacheKey);
-                if (shared != null) {
-                    cacheLocally(resolvedSnapshotAt, shared);
-                    validateSnapshotWindow(requestedAt, OffsetDateTime.now(clock),
-                            "snapshot timestamp expired during load");
-                    return shared;
-                }
-            } catch (CockpitSnapshotExpiredException exception) {
-                throw exception;
-            } catch (RuntimeException exception) {
-                throw new CockpitReadException("snapshot cache unavailable");
-            }
-        }
-        if (snapshotAt != null) {
-            throw new CockpitSnapshotExpiredException("snapshot not found");
-        }
-        if (redisTemplate == null && snapshots.size() >= MAX_CACHED_SNAPSHOTS) {
-            throw new CockpitReadException("snapshot cache capacity reached");
-        }
-
-        CockpitMapSnapshot built = buildSnapshot(resolvedSnapshotAt);
-        OffsetDateTime writeTime = OffsetDateTime.now(clock);
-        validateSnapshotWindow(requestedAt, writeTime, "snapshot timestamp expired during load");
-        if (redisTemplate != null) {
-            try {
-                Duration remaining = Duration.between(writeTime, requestedAt.plus(SNAPSHOT_RETENTION));
-                long ttlSeconds = Math.max(1,
-                        remaining.getSeconds() + (remaining.getNano() > 0 ? 1 : 0));
-                Boolean stored = redisTemplate.opsForValue()
-                        .setIfAbsent(cacheKey, built, ttlSeconds, TimeUnit.SECONDS);
-                if (!Boolean.TRUE.equals(stored)) {
-                    CockpitMapSnapshot shared = (CockpitMapSnapshot) redisTemplate.opsForValue().get(cacheKey);
-                    if (shared == null) {
-                        throw new CockpitReadException("snapshot cache unavailable");
-                    }
-                    validateSnapshotWindow(requestedAt, OffsetDateTime.now(clock),
-                            "snapshot timestamp expired during load");
-                    built = shared;
-                }
-            } catch (CockpitSnapshotExpiredException | CockpitReadException exception) {
-                throw exception;
-            } catch (RuntimeException exception) {
-                throw new CockpitReadException("snapshot cache unavailable");
-            }
-        }
-        cacheLocally(resolvedSnapshotAt, built);
-        CockpitMapSnapshot result = snapshots.getOrDefault(resolvedSnapshotAt, built);
-        validateSnapshotWindow(requestedAt, OffsetDateTime.now(clock),
-                "snapshot timestamp expired during load");
-        return result;
-    }
-
-    private static OffsetDateTime parseSnapshotAt(String value) {
-        try {
-            return CockpitSnapshotTime.parseBusinessOffset(value);
-        } catch (DateTimeException exception) {
-            throw new CockpitInvalidRequestException("snapshot timestamp must use ISO-8601 +08:00");
-        }
-    }
-
-    private void cacheLocally(String snapshotAt, CockpitMapSnapshot snapshot) {
-        synchronized (snapshots) {
-            if (snapshots.containsKey(snapshotAt) || snapshots.size() < MAX_CACHED_SNAPSHOTS) {
-                snapshots.putIfAbsent(snapshotAt, snapshot);
-            }
-        }
-    }
-    private static void validateSnapshotWindow(
-            OffsetDateTime requestedAt, OffsetDateTime now, String message) {
-        if (!requestedAt.isAfter(now.minus(SNAPSHOT_RETENTION))
-                || !requestedAt.isBefore(now.plus(SNAPSHOT_RETENTION))) {
-            throw new CockpitSnapshotExpiredException(message);
-        }
-    }
-
-    private CockpitMapSnapshot buildSnapshot(String resolvedSnapshotAt) {
-
-        Map<Integer, EquipmentType> types = equipmentTypes();
-        List<MonitorPoint> points = safeList(monitorPointService.list());
-
-        Map<String, EquipmentBase> deviceIndex = new LinkedHashMap<>();
-        for (EquipmentBase device : safeList(equipmentBaseService.list())) {
-            if (device != null && !isBlank(device.getEquipmentCode())) {
-                deviceIndex.put(device.getEquipmentCode().trim(), device);
-            }
-        }
-
-        List<CockpitMapSnapshot.SourceMonitoringPoint> sourcePoints = new ArrayList<>();
-        for (MonitorPoint point : points) {
-            if (point == null || isBlank(point.getPointCode())) {
-                continue;
-            }
-            List<EquipmentBase> boundDevices = safeList(
-                    equipmentBaseService.getEquipmentByPoint(point.getPointId()));
-            List<CockpitMonitoringPointDeviceSnapshot> devices = new ArrayList<>();
-            Set<String> deviceTypes = new LinkedHashSet<>();
-            List<Integer> boundDistricts = new ArrayList<>();
-            for (EquipmentBase device : boundDevices) {
-                if (device == null || isBlank(device.getEquipmentCode())) {
-                    continue;
-                }
-                EquipmentType boundType = device.getEquipmentTypeId() == null
-                        ? null
-                        : types.get(device.getEquipmentTypeId());
-                String typeName = boundType == null ? null : boundType.getTypeName();
-                devices.add(deviceFactResolver.resolve(device, typeName));
-                if (boundType != null && boundType.getId() != null) {
-                    deviceTypes.add(String.valueOf(boundType.getId()));
-                }
-                boundDistricts.add(device.getDistrict());
-            }
-            sourcePoints.add(new CockpitMapSnapshot.SourceMonitoringPoint(
-                    point.getPointCode().trim(),
-                    point.getPointName(),
-                    // 点位无自有片区列,按「经其上挂设备归纳」取得;不一致/无设备 → 未分区。
-                    CockpitDistrict.aggregate(boundDistricts),
-                    deviceTypes.size() == 1 ? deviceTypes.iterator().next() : null,
-                    point.getLongitude(),
-                    point.getLatitude(),
-                    false,
-                    devices));
-        }
-
-        // 智能设备清单按「全量」上报:哪些算「未关联」由 CockpitMapSnapshotService 统一判定,避免双份规则。
-        List<CockpitMapSnapshot.SourceSmartDevice> smartDevices = new ArrayList<>();
-        for (EquipmentBase device : deviceIndex.values()) {
-            EquipmentType type = device.getEquipmentTypeId() == null
-                    ? null
-                    : types.get(device.getEquipmentTypeId());
-            smartDevices.add(new CockpitMapSnapshot.SourceSmartDevice(
-                    device.getEquipmentCode().trim(),
-                    device.getEquipmentName(),
-                    // 设备自身片区(equipment_base.district)即权威来源。
-                    CockpitDistrict.codeOf(device.getDistrict()),
-                    type == null || type.getId() == null ? null : String.valueOf(type.getId()),
-                    device.getLongitude(),
-                    device.getLatitude(),
-                    false,
-                    deviceFactResolver.resolve(device, type == null ? null : type.getTypeName())
-                            .getPublishedDeviceStatus()));
-        }
-
-        List<CockpitMapSnapshot.SourceType> sourceTypes = types.values().stream()
-                .filter(type -> type.getId() != null)
-                .map(type -> new CockpitMapSnapshot.SourceType(
-                        String.valueOf(type.getId()),
-                        type.getTypeName(),
-                        CockpitTypeIndustry.industryOf(type, types),
-                        "SMART_DEVICE"))
-                .collect(Collectors.toList());
-
-        return new CockpitMapSnapshot(resolvedSnapshotAt, sourceTypes, sourcePoints, smartDevices);
-    }
-
-    private Map<Integer, EquipmentType> equipmentTypes() {
-        Map<Integer, EquipmentType> result = new HashMap<>();
-        for (EquipmentType type : safeList(equipmentTypeService.list())) {
-            if (type != null && type.getId() != null) {
-                result.put(type.getId(), type);
-            }
-        }
-        return result;
-    }
-
-    private static <T> List<T> safeList(List<T> value) {
-        return value == null ? Collections.emptyList() : value.stream().filter(Objects::nonNull).collect(Collectors.toList());
-    }
-
-    private static boolean isBlank(String value) {
-        return value == null || value.trim().isEmpty();
-    }
-
-    private String businessTime() {
-        return TIME_FORMATTER.format(clock.instant().atZone(BUSINESS_ZONE));
-    }
-}

+ 0 - 136
.scratch/cockpit-real-data-wayfinding/implementation/t29/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMapSnapshot.java

@@ -1,136 +0,0 @@
-package com.zksy.cockpit.map;
-
-import com.zksy.cockpit.point.CockpitMonitoringPointDeviceSnapshot;
-
-import java.math.BigDecimal;
-import java.util.Collections;
-import java.util.List;
-
-public class CockpitMapSnapshot {
-    private final String snapshotAt;
-    private final List<SourceType> types;
-    private final List<SourceMonitoringPoint> monitoringPoints;
-    private final List<SourceSmartDevice> smartDevices;
-
-    public CockpitMapSnapshot(String snapshotAt, List<SourceType> types,
-            List<SourceMonitoringPoint> monitoringPoints, List<SourceSmartDevice> smartDevices) {
-        this.snapshotAt = snapshotAt;
-        this.types = types;
-        this.monitoringPoints = monitoringPoints;
-        this.smartDevices = smartDevices;
-    }
-
-    public static CockpitMapSnapshot empty(String snapshotAt) {
-        return new CockpitMapSnapshot(
-                snapshotAt, Collections.emptyList(), Collections.emptyList(), Collections.emptyList());
-    }
-
-    public String getSnapshotAt() {
-        return snapshotAt;
-    }
-
-    public List<SourceType> getTypes() {
-        return types;
-    }
-
-    public List<SourceMonitoringPoint> getMonitoringPoints() {
-        return monitoringPoints;
-    }
-
-    public List<SourceSmartDevice> getSmartDevices() {
-        return smartDevices;
-    }
-
-    /**
-     * 类型字典:行业由类型推导,口径与 `/monitoring/distribution` 一致。
-     * 地图快照对不可解析的类型**不做排除**,只在请求了 industry 过滤时按推导结果筛选。
-     */
-    public static class SourceType {
-        private final String code;
-        private final String name;
-        private final String industry;
-        private final String objectKind;
-
-        public SourceType(String code, String name, String industry, String objectKind) {
-            this.code = code;
-            this.name = name;
-            this.industry = industry;
-            this.objectKind = objectKind;
-        }
-
-        public String getCode() { return code; }
-        public String getName() { return name; }
-        public String getIndustry() { return industry; }
-        public String getObjectKind() { return objectKind; }
-    }
-
-    public static class SourceMonitoringPoint {
-        private final String pointCode;
-        private final String pointName;
-        private final Integer district;
-        private final String typeCode;
-        private final BigDecimal longitude;
-        private final BigDecimal latitude;
-        private final boolean coordinateVerified;
-        private final List<CockpitMonitoringPointDeviceSnapshot> devices;
-
-        public SourceMonitoringPoint(String pointCode, String pointName, Integer district, String typeCode,
-                BigDecimal longitude, BigDecimal latitude,
-                boolean coordinateVerified, List<CockpitMonitoringPointDeviceSnapshot> devices) {
-            this.pointCode = pointCode;
-            this.pointName = pointName;
-            this.district = district;
-            this.typeCode = typeCode;
-            this.longitude = longitude;
-            this.latitude = latitude;
-            this.coordinateVerified = coordinateVerified;
-            this.devices = devices;
-        }
-
-        public String getPointCode() { return pointCode; }
-        public String getPointName() { return pointName; }
-        public Integer getDistrict() { return district; }
-        public String getTypeCode() { return typeCode; }
-        public BigDecimal getLongitude() { return longitude; }
-        public BigDecimal getLatitude() { return latitude; }
-        public boolean isCoordinateVerified() { return coordinateVerified; }
-        public List<CockpitMonitoringPointDeviceSnapshot> getDevices() { return devices; }
-    }
-
-    public static class SourceSmartDevice {
-        private final String deviceCode;
-        private final String deviceName;
-        private final Integer district;
-        private final String typeCode;
-        private final BigDecimal longitude;
-        private final BigDecimal latitude;
-        private final boolean coordinateVerified;
-        private final String deviceStatus;
-
-        public SourceSmartDevice(String deviceCode, String deviceName, Integer district, String typeCode,
-                BigDecimal longitude, BigDecimal latitude, boolean coordinateVerified) {
-            this(deviceCode, deviceName, district, typeCode, longitude, latitude, coordinateVerified, null);
-        }
-
-        public SourceSmartDevice(String deviceCode, String deviceName, Integer district, String typeCode,
-                BigDecimal longitude, BigDecimal latitude, boolean coordinateVerified, String deviceStatus) {
-            this.deviceCode = deviceCode;
-            this.deviceName = deviceName;
-            this.district = district;
-            this.typeCode = typeCode;
-            this.longitude = longitude;
-            this.latitude = latitude;
-            this.coordinateVerified = coordinateVerified;
-            this.deviceStatus = deviceStatus;
-        }
-
-        public String getDeviceStatus() { return deviceStatus; }
-        public String getDeviceCode() { return deviceCode; }
-        public String getDeviceName() { return deviceName; }
-        public Integer getDistrict() { return district; }
-        public String getTypeCode() { return typeCode; }
-        public BigDecimal getLongitude() { return longitude; }
-        public BigDecimal getLatitude() { return latitude; }
-        public boolean isCoordinateVerified() { return coordinateVerified; }
-    }
-}

+ 0 - 102
.scratch/cockpit-real-data-wayfinding/implementation/t29/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMapSnapshotResponse.java

@@ -1,102 +0,0 @@
-package com.zksy.cockpit.map;
-
-import java.util.List;
-import com.zksy.cockpit.point.CockpitPointStatusQuery;
-
-public class CockpitMapSnapshotResponse {
-    private final String requestId;
-    private final String apiVersion;
-    private final boolean readOnly;
-    private final int page;
-    private final int pageSize;
-    private final String snapshotAt;
-    private final int total;
-    private final String batchUpdatedAt;
-    private final boolean complete;
-    private final List<CockpitMonitoringPointSummary> monitoringPoints;
-    private final List<CockpitUnassociatedEquipmentSummary> unassociatedEquipment;
-    private final CockpitCoordinateValidationSummary coordinateValidation;
-    private final MapFilter mapFilter;
-    private final List<CockpitPointStatusQuery.Count> pointStatusDistribution;
-
-    public CockpitMapSnapshotResponse(String requestId, String apiVersion, boolean readOnly, int page, int pageSize,
-            String snapshotAt, int total, String batchUpdatedAt, boolean complete,
-            List<CockpitMonitoringPointSummary> monitoringPoints,
-            List<CockpitUnassociatedEquipmentSummary> unassociatedEquipment,
-            CockpitCoordinateValidationSummary coordinateValidation,
-            MapFilter mapFilter, List<CockpitPointStatusQuery.Count> pointStatusDistribution) {
-        this.requestId = requestId;
-        this.apiVersion = apiVersion;
-        this.readOnly = readOnly;
-        this.page = page;
-        this.pageSize = pageSize;
-        this.snapshotAt = snapshotAt;
-        this.total = total;
-        this.batchUpdatedAt = batchUpdatedAt;
-        this.complete = complete;
-        this.monitoringPoints = monitoringPoints;
-        this.unassociatedEquipment = unassociatedEquipment;
-        this.coordinateValidation = coordinateValidation;
-        this.mapFilter = mapFilter;
-        this.pointStatusDistribution = pointStatusDistribution;
-    }
-
-    public String getRequestId() { return requestId; }
-    public String getApiVersion() { return apiVersion; }
-    public boolean isReadOnly() { return readOnly; }
-    public int getPage() { return page; }
-    public int getPageSize() { return pageSize; }
-    public String getSnapshotAt() { return snapshotAt; }
-    public int getTotal() { return total; }
-    public String getBatchUpdatedAt() { return batchUpdatedAt; }
-    public boolean isComplete() { return complete; }
-    public List<CockpitMonitoringPointSummary> getMonitoringPoints() { return monitoringPoints; }
-    public List<CockpitUnassociatedEquipmentSummary> getUnassociatedEquipment() { return unassociatedEquipment; }
-    public CockpitCoordinateValidationSummary getCoordinateValidation() { return coordinateValidation; }
-    public List<CockpitPointStatusQuery.Count> getPointStatusDistribution() { return pointStatusDistribution; }
-    public MapFilter getMapFilter() { return mapFilter; }
-
-    /**
-     * 生效范围回显:请求的过滤条件 + 过滤后**全量**(分页前)的点位/设备编码。
-     * 口径与 `/monitoring/distribution` 的 `mapFilter` 一致。
-     *
-     * <p>范围维度自执行票 12 起为**片区**(`district`):旧自由串 `region` 不再作为正式契约。
-     * 「未筛」与「只看未分区」的编码都是 `null`,靠 {@link #districtFilter} 区分
-     * (`NONE` / `UNASSIGNED` / `ASSIGNED`),不靠猜。
-     */
-    public static class MapFilter {
-        private final String industry;
-        private final Integer district;
-        private final String districtName;
-        private final String districtFilter;
-        private final String deviceType;
-        private final String pointStatus;
-        private final List<String> pointCodes;
-        private final List<String> deviceCodes;
-
-        public MapFilter(String industry, Integer district, String districtFilter, String deviceType, String pointStatus,
-                List<String> pointCodes, List<String> deviceCodes) {
-            this.industry = industry;
-            this.district = district;
-            this.districtFilter = districtFilter;
-            this.districtName = district == null ? null
-                    : com.zksy.cockpit.foundation.CockpitDistrict.nameOf(district);
-            this.deviceType = deviceType;
-            this.pointStatus = pointStatus;
-            this.pointCodes = pointCodes;
-            this.deviceCodes = deviceCodes;
-        }
-
-        public String getIndustry() { return industry; }
-        /** 生效片区编码;未筛或「只看未分区」时为 {@code null}(用 {@link #getDistrictFilter()} 区分)。 */
-        public Integer getDistrict() { return district; }
-        /** 生效片区展示名;未筛与未分区都为 {@code null 或「未分区」},详见 {@link #getDistrictFilter()}。 */
-        public String getDistrictName() { return districtName; }
-        /** `NONE`(未筛)/ `ASSIGNED`(按片区)/ `UNASSIGNED`(只看未分区)。 */
-        public String getDistrictFilter() { return districtFilter; }
-        public String getPointStatus() { return pointStatus; }
-        public String getDeviceType() { return deviceType; }
-        public List<String> getPointCodes() { return pointCodes; }
-        public List<String> getDeviceCodes() { return deviceCodes; }
-    }
-}

+ 0 - 431
.scratch/cockpit-real-data-wayfinding/implementation/t29/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitMapSnapshotService.java

@@ -1,431 +0,0 @@
-package com.zksy.cockpit.map;
-
-import com.zksy.cockpit.foundation.CockpitCoordinateValidation;
-import com.zksy.cockpit.foundation.CockpitCoordinateValidator;
-import com.zksy.cockpit.foundation.CockpitDistrict;
-import com.zksy.cockpit.foundation.CockpitErrorCategory;
-import com.zksy.cockpit.foundation.CockpitErrorPayload;
-import com.zksy.cockpit.foundation.CockpitInvalidRequestException;
-import com.zksy.cockpit.foundation.CockpitSnapshotExpiredException;
-import com.zksy.cockpit.foundation.CockpitSnapshotTime;
-import com.zksy.cockpit.point.CockpitMonitoringPointDeviceSnapshot;
-import com.zksy.cockpit.point.CockpitMonitoringPointStatus;
-import com.zksy.cockpit.point.CockpitMonitoringPointStatusResolver;
-import com.zksy.cockpit.point.CockpitPointStatusQuery;
-import org.springframework.stereotype.Service;
-
-import java.math.BigDecimal;
-import java.time.Clock;
-import java.time.DateTimeException;
-import java.time.ZoneId;
-import java.time.format.DateTimeFormatter;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collections;
-import java.util.Comparator;
-import java.util.LinkedHashMap;
-import java.util.LinkedHashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Objects;
-import java.util.Set;
-import java.util.stream.Collectors;
-
-@Service
-public class CockpitMapSnapshotService {
-    private static final int DEFAULT_PAGE_SIZE = 500;
-    private static final int MAX_PAGE_SIZE = 1000;
-    private static final String WGS84 = "WGS84";
-    private static final String SMART_DEVICE = "SMART_DEVICE";
-    private static final List<String> INDUSTRIES = Arrays.asList("GAS", "WATER_SUPPLY", "DRAINAGE", "MANHOLE");
-
-    private final CockpitMapSnapshotAdapter adapter;
-    private final Clock clock;
-
-    public CockpitMapSnapshotService(CockpitMapSnapshotAdapter adapter, Clock clock) {
-        this.adapter = adapter;
-        this.clock = clock;
-    }
-
-    /**
-     * 地图分页快照。全局筛选(industry / district / deviceType)生效时服务端完成过滤,
-     * `mapFilter` 回显实际生效范围;分页与 `snapshotAt` 语义不因过滤而改变。
-     *
-     * <p>范围维度自执行票 12 起为**片区**(`district` 0/1/2 + `UNASSIGNED`);
-     * 旧自由串 `region` 不再是正式契约,入参被忽略。
-     */
-    public CockpitMapSnapshotResponse load(String requestId, Integer page, Integer pageSize, String snapshotAt,
-            String industry, String district, String deviceType) {
-        return load(requestId, page, pageSize, snapshotAt, industry, district, deviceType, null);
-    }
-
-    public CockpitMapSnapshotResponse load(String requestId, Integer page, Integer pageSize, String snapshotAt,
-            String industry, String district, String deviceType, String pointStatus) {
-        CockpitPointStatusQuery statusQuery = CockpitPointStatusQuery.parse(pointStatus);
-        int safePage = page == null ? 1 : page;
-        int safePageSize = pageSize == null ? DEFAULT_PAGE_SIZE : pageSize;
-        validatePagination(safePage, safePageSize, snapshotAt);
-        validateSnapshotAt(snapshotAt);
-        String selectedIndustry = resolveIndustry(industry);
-        CockpitDistrict.Query selectedDistrict = CockpitDistrict.parseQuery(district);
-        if (selectedDistrict.invalid()) {
-            throw new CockpitInvalidRequestException(
-                    "district must be 0, 1, 2 or UNASSIGNED");
-        }
-        String selectedDeviceType = isBlank(deviceType) ? null : deviceType.trim();
-
-        CockpitMapSnapshot snapshot = adapter.load(snapshotAt);
-        if (snapshot == null) {
-            if (snapshotAt != null) {
-                throw new CockpitSnapshotExpiredException("snapshot not found");
-            }
-            snapshot = CockpitMapSnapshot.empty(resolveSnapshotAt(null));
-        }
-        if (snapshot.getSnapshotAt() == null) {
-            throw new CockpitSnapshotExpiredException("snapshot timestamp missing");
-        }
-        if (snapshotAt != null && !snapshotAt.equals(snapshot.getSnapshotAt())) {
-            throw new CockpitSnapshotExpiredException("snapshot timestamp changed");
-        }
-
-        Map<String, CockpitMapSnapshot.SourceType> types = types(snapshot);
-        List<CockpitMonitoringPointSummary> points = monitoringPoints(
-                snapshot, types, selectedIndustry, selectedDistrict, selectedDeviceType);
-        List<CockpitUnassociatedEquipmentSummary> equipment = unassociatedEquipment(
-                snapshot, types, selectedIndustry, selectedDistrict, selectedDeviceType);
-        CockpitPointStatusQuery.Distribution global = new CockpitPointStatusQuery.Distribution();
-        monitoringPoints(snapshot, types, null, CockpitDistrict.parseQuery(null), null)
-                .forEach(point -> global.addPoint(point.getPointStatus()));
-        unassociatedEquipment(snapshot, types, null, CockpitDistrict.parseQuery(null), null)
-                .forEach(device -> global.addUnassociatedDevice());
-        points = points.stream().filter(point -> statusQuery.matchesPoint(point.getPointStatus()))
-                .collect(Collectors.toList());
-        equipment = equipment.stream().filter(device -> statusQuery.matchesDevice(device.getDeviceStatus()))
-                .collect(Collectors.toList());
-        int total = points.size() + equipment.size();
-        int fromIndex = Math.min((safePage - 1) * safePageSize, total);
-        int toIndex = Math.min(fromIndex + safePageSize, total);
-        List<CockpitMonitoringPointSummary> pagePoints = points.subList(
-                clamp(fromIndex, 0, points.size()),
-                clamp(toIndex, 0, points.size()));
-        List<CockpitUnassociatedEquipmentSummary> pageEquipment = equipment.subList(
-                clamp(fromIndex - points.size(), 0, equipment.size()),
-                clamp(toIndex - points.size(), 0, equipment.size()));
-
-        return new CockpitMapSnapshotResponse(
-                requestId,
-                "v1",
-                true,
-                safePage,
-                safePageSize,
-                snapshot.getSnapshotAt(),
-                total,
-                snapshot.getSnapshotAt(),
-                toIndex >= total,
-                pagePoints,
-                pageEquipment,
-                coordinateValidation(points, equipment),
-                mapFilter(selectedIndustry, selectedDistrict, selectedDeviceType, statusQuery.echo(), points, equipment),
-                global.rows());
-    }
-
-    public CockpitErrorPayload invalidRequest(String requestId, String message) {
-        return new CockpitErrorPayload(
-                CockpitErrorCategory.INVALID_REQUEST,
-                message,
-                requestId,
-                businessTime());
-    }
-
-    public CockpitErrorPayload snapshotExpired(String requestId) {
-        return new CockpitErrorPayload(
-                CockpitErrorCategory.SNAPSHOT_EXPIRED,
-                "地图快照已失效,请重新加载",
-                requestId,
-                businessTime());
-    }
-
-    public CockpitErrorPayload serverError(String requestId) {
-        return new CockpitErrorPayload(
-                CockpitErrorCategory.SERVER_ERROR,
-                "驾驶舱地图快照数据暂不可用",
-                requestId,
-                businessTime());
-    }
-
-    private static void validatePagination(int page, int pageSize, String snapshotAt) {
-        if (page < 1 || pageSize < 1 || pageSize > MAX_PAGE_SIZE) {
-            throw new CockpitInvalidRequestException("地图快照分页参数不合法");
-        }
-        if (page > 1 && isBlank(snapshotAt)) {
-            throw new CockpitSnapshotExpiredException("snapshot token required for later pages");
-        }
-    }
-
-    private static void validateSnapshotAt(String snapshotAt) {
-        if (snapshotAt == null) {
-            return;
-        }
-        try {
-            CockpitSnapshotTime.parseBusinessOffset(snapshotAt);
-        } catch (DateTimeException exception) {
-            throw new CockpitInvalidRequestException("地图快照分页参数不合法");
-        }
-    }
-
-    private static String resolveIndustry(String industry) {
-        if (isBlank(industry)) {
-            return null;
-        }
-        String resolved = industry.trim();
-        if (!INDUSTRIES.contains(resolved)) {
-            throw new CockpitInvalidRequestException("industry must be GAS, WATER_SUPPLY, DRAINAGE or MANHOLE");
-        }
-        return resolved;
-    }
-
-    private static boolean isBlank(String value) {
-        return value == null || value.trim().isEmpty();
-    }
-
-    private String resolveSnapshotAt(String snapshotAt) {
-        return snapshotAt == null ? businessTime() : snapshotAt;
-    }
-
-    private static Map<String, CockpitMapSnapshot.SourceType> types(CockpitMapSnapshot snapshot) {
-        Map<String, CockpitMapSnapshot.SourceType> result = new LinkedHashMap<>();
-        if (snapshot.getTypes() == null) {
-            return result;
-        }
-        for (CockpitMapSnapshot.SourceType type : snapshot.getTypes()) {
-            if (type == null || isBlank(type.getCode())) {
-                continue;
-            }
-            result.put(type.getCode().trim(), type);
-        }
-        return result;
-    }
-
-    private static CockpitMapSnapshot.SourceType resolveType(
-            Map<String, CockpitMapSnapshot.SourceType> types, String typeCode) {
-        return isBlank(typeCode) ? null : types.get(typeCode.trim());
-    }
-
-    /**
-     * 行业推导口径同 `/monitoring/distribution`:类型可解析、`objectKind = SMART_DEVICE`
-     * 且行业属于正式行业;不满足时该对象的 `industry` 为 `null`(默认不排除对象)。
-     */
-    private static String industryOf(CockpitMapSnapshot.SourceType type) {
-        if (type == null || !SMART_DEVICE.equals(type.getObjectKind())) {
-            return null;
-        }
-        if (type.getIndustry() == null || !INDUSTRIES.contains(type.getIndustry())) {
-            return null;
-        }
-        return type.getIndustry();
-    }
-
-    private static String typeNameOf(CockpitMapSnapshot.SourceType type) {
-        return type == null ? null : type.getName();
-    }
-
-    private static String typeCodeOf(String typeCode) {
-        return isBlank(typeCode) ? null : typeCode.trim();
-    }
-
-    private static boolean matchesFilters(String industry, CockpitDistrict.Query district, String deviceType,
-            String typeCode, Integer rawDistrict, Map<String, CockpitMapSnapshot.SourceType> types) {
-        CockpitMapSnapshot.SourceType type = resolveType(types, typeCode);
-        if (industry != null && !industry.equals(industryOf(type))) {
-            return false;
-        }
-        if (deviceType != null && (type == null || !deviceType.equals(typeCodeOf(type.getCode())))) {
-            return false;
-        }
-        return CockpitDistrict.matches(district, rawDistrict);
-    }
-
-    private static List<CockpitMonitoringPointSummary> monitoringPoints(
-            CockpitMapSnapshot snapshot,
-            Map<String, CockpitMapSnapshot.SourceType> types,
-            String industry, CockpitDistrict.Query district, String deviceType) {
-        if (snapshot.getMonitoringPoints() == null) {
-            return Collections.emptyList();
-        }
-        return snapshot.getMonitoringPoints().stream()
-                .filter(Objects::nonNull)
-                .filter(source -> !isBlank(source.getPointCode()))
-                .filter(source -> matchesFilters(
-                        industry, district, deviceType, source.getTypeCode(), source.getDistrict(), types))
-                .map(source -> monitoringPoint(source, types))
-                .sorted(Comparator.comparing(CockpitMonitoringPointSummary::getPointCode,
-                        Comparator.nullsLast(String::compareTo)))
-                .collect(Collectors.toList());
-    }
-
-    private static CockpitMonitoringPointSummary monitoringPoint(
-            CockpitMapSnapshot.SourceMonitoringPoint source,
-            Map<String, CockpitMapSnapshot.SourceType> types) {
-        List<CockpitMonitoringPointDeviceSnapshot> devices = source.getDevices() == null
-                ? Collections.emptyList()
-                : source.getDevices().stream().filter(Objects::nonNull)
-                        .filter(device -> !isBlank(device.getDeviceCode())).collect(Collectors.toList());
-        CockpitMonitoringPointStatus pointStatus = CockpitMonitoringPointStatusResolver.resolve(devices);
-        List<String> deviceCodes = devices.stream()
-                .filter(Objects::nonNull)
-                .map(CockpitMonitoringPointDeviceSnapshot::getDeviceCode)
-                .filter(Objects::nonNull)
-                .map(String::trim)
-                .filter(code -> !code.isEmpty())
-                .distinct()
-                .collect(Collectors.toList());
-        CockpitMapSnapshot.SourceType type = resolveType(types, source.getTypeCode());
-        return new CockpitMonitoringPointSummary(
-                source.getPointCode(),
-                source.getPointName(),
-                pointStatus,
-                CockpitMonitoringPointStatusResolver.summaryReasons(pointStatus, devices),
-                deviceCodes,
-                source.getDistrict(),
-                industryOf(type),
-                typeCodeOf(source.getTypeCode()),
-                typeNameOf(type),
-                source.getLongitude(),
-                source.getLatitude(),
-                CockpitCoordinateValidator.validate(
-                        source.getLongitude(), source.getLatitude(), source.isCoordinateVerified()));
-    }
-
-    private static List<CockpitUnassociatedEquipmentSummary> unassociatedEquipment(
-            CockpitMapSnapshot snapshot,
-            Map<String, CockpitMapSnapshot.SourceType> types,
-            String industry, CockpitDistrict.Query district, String deviceType) {
-        if (snapshot.getSmartDevices() == null) {
-            return Collections.emptyList();
-        }
-        Set<String> associatedDeviceCodes = associatedDeviceCodes(snapshot);
-        return snapshot.getSmartDevices().stream()
-                .filter(Objects::nonNull)
-                .filter(device -> !isBlank(device.getDeviceCode()))
-                .filter(device -> !associatedDeviceCodes.contains(device.getDeviceCode().trim()))
-                .filter(source -> matchesFilters(
-                        industry, district, deviceType, source.getTypeCode(), source.getDistrict(), types))
-                .map(source -> unassociatedEquipment(source, types))
-                .sorted(Comparator.comparing(CockpitUnassociatedEquipmentSummary::getDeviceCode,
-                        Comparator.nullsLast(String::compareTo)))
-                .collect(Collectors.toList());
-    }
-
-    private static Set<String> associatedDeviceCodes(CockpitMapSnapshot snapshot) {
-        if (snapshot.getMonitoringPoints() == null) {
-            return Collections.emptySet();
-        }
-        return snapshot.getMonitoringPoints().stream()
-                .filter(Objects::nonNull)
-                .map(CockpitMapSnapshot.SourceMonitoringPoint::getDevices)
-                .filter(Objects::nonNull)
-                .flatMap(List::stream)
-                .filter(Objects::nonNull)
-                .map(CockpitMonitoringPointDeviceSnapshot::getDeviceCode)
-                .filter(Objects::nonNull)
-                .map(String::trim)
-                .filter(code -> !code.isEmpty())
-                .collect(Collectors.toSet());
-    }
-
-    private static CockpitUnassociatedEquipmentSummary unassociatedEquipment(
-            CockpitMapSnapshot.SourceSmartDevice source,
-            Map<String, CockpitMapSnapshot.SourceType> types) {
-        CockpitMapSnapshot.SourceType type = resolveType(types, source.getTypeCode());
-        return new CockpitUnassociatedEquipmentSummary(
-                source.getDeviceCode(),
-                source.getDeviceName(),
-                source.getDistrict(),
-                industryOf(type),
-                typeCodeOf(source.getTypeCode()),
-                typeNameOf(type),
-                source.getLongitude(),
-                source.getLatitude(),
-                CockpitCoordinateValidator.validate(
-                        source.getLongitude(), source.getLatitude(), source.isCoordinateVerified()),
-                CockpitPointStatusQuery.deviceBucket(source.getDeviceStatus()));
-    }
-
-    private static CockpitMapSnapshotResponse.MapFilter mapFilter(
-            String industry, CockpitDistrict.Query district, String deviceType, String pointStatus,
-            List<CockpitMonitoringPointSummary> points,
-            List<CockpitUnassociatedEquipmentSummary> equipment) {
-        Set<String> pointCodes = new LinkedHashSet<>();
-        Set<String> deviceCodes = new LinkedHashSet<>();
-        for (CockpitMonitoringPointSummary point : points) {
-            if (!isBlank(point.getPointCode())) {
-                pointCodes.add(point.getPointCode().trim());
-            }
-            if (point.getDeviceCodes() != null) {
-                for (String code : point.getDeviceCodes()) {
-                    if (!isBlank(code)) {
-                        deviceCodes.add(code.trim());
-                    }
-                }
-            }
-        }
-        for (CockpitUnassociatedEquipmentSummary device : equipment) {
-            if (!isBlank(device.getDeviceCode())) {
-                deviceCodes.add(device.getDeviceCode().trim());
-            }
-        }
-        return new CockpitMapSnapshotResponse.MapFilter(
-                industry,
-                district.active() ? district.code() : null,
-                !district.active() ? "NONE" : (district.unassignedOnly() ? "UNASSIGNED" : "ASSIGNED"),
-                deviceType, pointStatus, sorted(pointCodes), sorted(deviceCodes));
-    }
-
-    private static List<String> sorted(Set<String> values) {
-        List<String> result = new ArrayList<>(values);
-        Collections.sort(result, Comparator.nullsLast(String::compareTo));
-        return result;
-    }
-
-    private static int clamp(int value, int minimum, int maximum) {
-        return Math.max(minimum, Math.min(value, maximum));
-    }
-
-    private static CockpitCoordinateValidationSummary coordinateValidation(
-            List<CockpitMonitoringPointSummary> points, List<CockpitUnassociatedEquipmentSummary> equipment) {
-        int renderableCount = 0;
-        int invalidCount = 0;
-        int unverifiedCount = 0;
-        for (CockpitMonitoringPointSummary point : points) {
-            CockpitCoordinateValidation validation = point.getCoordinateValidation();
-            if (validation.isRenderable()) {
-                renderableCount++;
-            } else if ("INVALID".equals(validation.getState())) {
-                invalidCount++;
-            } else {
-                unverifiedCount++;
-            }
-        }
-        for (CockpitUnassociatedEquipmentSummary device : equipment) {
-            CockpitCoordinateValidation validation = device.getCoordinateValidation();
-            if (validation.isRenderable()) {
-                renderableCount++;
-            } else if ("INVALID".equals(validation.getState())) {
-                invalidCount++;
-            } else {
-                unverifiedCount++;
-            }
-        }
-        return new CockpitCoordinateValidationSummary(
-                WGS84,
-                points.size() + equipment.size(),
-                renderableCount,
-                invalidCount,
-                unverifiedCount);
-    }
-
-    private String businessTime() {
-        return DateTimeFormatter.ISO_OFFSET_DATE_TIME
-                .format(clock.instant().atZone(ZoneId.of("Asia/Shanghai")));
-    }
-}

+ 0 - 49
.scratch/cockpit-real-data-wayfinding/implementation/t29/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/map/CockpitUnassociatedEquipmentSummary.java

@@ -1,49 +0,0 @@
-package com.zksy.cockpit.map;
-
-import com.zksy.cockpit.foundation.CockpitCoordinateValidation;
-import com.zksy.cockpit.foundation.CockpitDistrict;
-
-import java.math.BigDecimal;
-
-public class CockpitUnassociatedEquipmentSummary {
-    private final String deviceCode;
-    private final String deviceName;
-    private final Integer district;
-    private final String industry;
-    private final String typeCode;
-    private final String typeName;
-    private final BigDecimal longitude;
-    private final BigDecimal latitude;
-    private final CockpitCoordinateValidation coordinateValidation;
-    private final String deviceStatus;
-
-    public CockpitUnassociatedEquipmentSummary(String deviceCode, String deviceName,
-            Integer district, String industry, String typeCode, String typeName,
-            BigDecimal longitude, BigDecimal latitude, CockpitCoordinateValidation coordinateValidation,
-            String deviceStatus) {
-        this.deviceCode = deviceCode;
-        this.deviceName = deviceName;
-        this.district = CockpitDistrict.codeOf(district);
-        this.industry = industry;
-        this.typeCode = typeCode;
-        this.typeName = typeName;
-        this.longitude = longitude;
-        this.latitude = latitude;
-        this.coordinateValidation = coordinateValidation;
-        this.deviceStatus = deviceStatus;
-    }
-
-    public String getDeviceStatus() { return deviceStatus; }
-    public String getDeviceCode() { return deviceCode; }
-    public String getDeviceName() { return deviceName; }
-    /** 片区编码(0-城南 1-城北 2-太常);未分区为 {@code null}。 */
-    public Integer getDistrict() { return district; }
-    /** 片区展示名;未分区如实回「未分区」。 */
-    public String getDistrictName() { return CockpitDistrict.nameOf(district); }
-    public String getIndustry() { return industry; }
-    public String getTypeCode() { return typeCode; }
-    public String getTypeName() { return typeName; }
-    public BigDecimal getLongitude() { return longitude; }
-    public BigDecimal getLatitude() { return latitude; }
-    public CockpitCoordinateValidation getCoordinateValidation() { return coordinateValidation; }
-}

+ 0 - 182
.scratch/cockpit-real-data-wayfinding/implementation/t29/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/BusinessCockpitMonitoringDistributionAdapter.java

@@ -1,182 +0,0 @@
-package com.zksy.cockpit.monitoring;
-
-import com.zksy.base.domain.EquipmentBase;
-import com.zksy.base.domain.EquipmentType;
-import com.zksy.base.domain.MonitorPoint;
-import com.zksy.base.service.EquipmentBaseService;
-import com.zksy.base.service.EquipmentTypeService;
-import com.zksy.base.service.MonitorPointService;
-import com.zksy.cockpit.foundation.CockpitDistrict;
-import com.zksy.cockpit.foundation.CockpitTypeIndustry;
-import com.zksy.cockpit.point.BusinessCockpitDeviceFactResolver;
-import com.zksy.cockpit.point.CockpitMonitoringPointDeviceSnapshot;
-import org.springframework.context.annotation.Primary;
-import org.springframework.stereotype.Component;
-
-import java.time.Clock;
-import java.time.ZoneId;
-import java.time.format.DateTimeFormatter;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.LinkedHashMap;
-import java.util.LinkedHashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Objects;
-import java.util.Set;
-import java.util.stream.Collectors;
-
-/**
- * 监测分布的真实数据适配器(BT-5 第 2 片)。
- *
- * <p>与地图快照适配器共享同一套构图({@link BusinessCockpitDeviceFactResolver} +
- * {@link CockpitTypeIndustry}),只换快照形状:分布的未关联智能设备额外携带实时事实
- * (在线/报警/预警/在途工单 + 原因 + 状态时间),点位的设备仍经 {@code CockpitMonitoringPointDeviceSnapshot} 描述。
- *
- * <p>如实接线,不臆造:现网无坐标校核字段 → 一律 {@code coordinateVerified=false};
- * {@code monitor_point} 无行业列 → 点位 {@code typeCode} 仅在其关联设备类型唯一时推导。
- * 片区(district)的权威来源是 {@code equipment_base.district}:点位按「经其上挂设备归纳」
- * 取得(不一致/无设备 → 未分区),设备直接用自身片区。
- */
-@Component
-@Primary
-public class BusinessCockpitMonitoringDistributionAdapter implements CockpitMonitoringDistributionAdapter {
-
-    private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
-    private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
-
-    private final MonitorPointService monitorPointService;
-    private final EquipmentBaseService equipmentBaseService;
-    private final EquipmentTypeService equipmentTypeService;
-    private final BusinessCockpitDeviceFactResolver deviceFactResolver;
-    private final Clock clock;
-
-    public BusinessCockpitMonitoringDistributionAdapter(
-            MonitorPointService monitorPointService,
-            EquipmentBaseService equipmentBaseService,
-            EquipmentTypeService equipmentTypeService,
-            BusinessCockpitDeviceFactResolver deviceFactResolver,
-            Clock clock) {
-        this.monitorPointService = monitorPointService;
-        this.equipmentBaseService = equipmentBaseService;
-        this.equipmentTypeService = equipmentTypeService;
-        this.deviceFactResolver = deviceFactResolver;
-        this.clock = clock;
-    }
-
-    @Override
-    public CockpitMonitoringDistributionSnapshot load(String snapshotAt) {
-        String resolvedSnapshotAt = snapshotAt == null ? businessTime() : snapshotAt;
-
-        Map<Integer, EquipmentType> types = equipmentTypes();
-        List<MonitorPoint> points = safeList(monitorPointService.list());
-
-        Map<String, EquipmentBase> deviceIndex = new LinkedHashMap<>();
-        for (EquipmentBase device : safeList(equipmentBaseService.list())) {
-            if (device != null && !isBlank(device.getEquipmentCode())) {
-                deviceIndex.put(device.getEquipmentCode().trim(), device);
-            }
-        }
-
-        List<CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint> sourcePoints = new ArrayList<>();
-        for (MonitorPoint point : points) {
-            if (point == null || isBlank(point.getPointCode())) {
-                continue;
-            }
-            List<EquipmentBase> boundDevices = safeList(
-                    equipmentBaseService.getEquipmentByPoint(point.getPointId()));
-            List<CockpitMonitoringPointDeviceSnapshot> devices = new ArrayList<>();
-            Set<String> deviceTypes = new LinkedHashSet<>();
-            List<Integer> boundDistricts = new ArrayList<>();
-            for (EquipmentBase device : boundDevices) {
-                if (device == null || isBlank(device.getEquipmentCode())) {
-                    continue;
-                }
-                EquipmentType boundType = device.getEquipmentTypeId() == null
-                        ? null
-                        : types.get(device.getEquipmentTypeId());
-                String typeName = boundType == null ? null : boundType.getTypeName();
-                devices.add(deviceFactResolver.resolve(device, typeName));
-                if (boundType != null && boundType.getId() != null) {
-                    deviceTypes.add(String.valueOf(boundType.getId()));
-                }
-                boundDistricts.add(device.getDistrict());
-            }
-            sourcePoints.add(new CockpitMonitoringDistributionSnapshot.SourceMonitoringPoint(
-                    point.getPointCode().trim(),
-                    point.getPointName(),
-                    // 点位无自有片区列,按「经其上挂设备归纳」取得;不一致/无设备 → 未分区。
-                    CockpitDistrict.aggregate(boundDistricts),
-                    deviceTypes.size() == 1 ? deviceTypes.iterator().next() : null,
-                    point.getLongitude(),
-                    point.getLatitude(),
-                    false,
-                    devices));
-        }
-
-        List<CockpitMonitoringDistributionSnapshot.SourceSmartDevice> smartDevices = new ArrayList<>();
-        for (EquipmentBase device : deviceIndex.values()) {
-            EquipmentType type = device.getEquipmentTypeId() == null
-                    ? null
-                    : types.get(device.getEquipmentTypeId());
-            String typeCode = type == null || type.getId() == null ? null : String.valueOf(type.getId());
-            String typeName = type == null ? null : type.getTypeName();
-            CockpitMonitoringPointDeviceSnapshot fact = deviceFactResolver.resolve(device, typeName);
-            smartDevices.add(new CockpitMonitoringDistributionSnapshot.SourceSmartDevice(
-                    device.getEquipmentCode().trim(),
-                    device.getEquipmentName(),
-                    // 设备自身片区(equipment_base.district)即权威来源。
-                    CockpitDistrict.codeOf(device.getDistrict()),
-                    typeCode,
-                    device.getLongitude(),
-                    device.getLatitude(),
-                    false,
-                    fact.isOnline(),
-                    fact.isAlarm(),
-                    fact.isWarning(),
-                    fact.isWorkOrderInProgress(),
-                    fact.getStatusReasons(),
-                    fact.getStatusTime(),
-                    fact.isStatusPublished()));
-        }
-
-        List<CockpitMonitoringDistributionSnapshot.SourceType> sourceTypes = types.values().stream()
-                .filter(type -> type.getId() != null)
-                .map(type -> new CockpitMonitoringDistributionSnapshot.SourceType(
-                        String.valueOf(type.getId()),
-                        type.getTypeName(),
-                        CockpitTypeIndustry.industryOf(type, types),
-                        "SMART_DEVICE"))
-                .collect(Collectors.toList());
-
-        return new CockpitMonitoringDistributionSnapshot(
-                resolvedSnapshotAt,
-                null,
-                sourceTypes,
-                sourcePoints,
-                smartDevices);
-    }
-
-    private Map<Integer, EquipmentType> equipmentTypes() {
-        Map<Integer, EquipmentType> result = new HashMap<>();
-        for (EquipmentType type : safeList(equipmentTypeService.list())) {
-            if (type != null && type.getId() != null) {
-                result.put(type.getId(), type);
-            }
-        }
-        return result;
-    }
-
-    private static <T> List<T> safeList(List<T> value) {
-        return value == null ? Collections.emptyList() : value.stream().filter(Objects::nonNull).collect(Collectors.toList());
-    }
-
-    private static boolean isBlank(String value) {
-        return value == null || value.trim().isEmpty();
-    }
-
-    private String businessTime() {
-        return TIME_FORMATTER.format(clock.instant().atZone(BUSINESS_ZONE));
-    }
-}

+ 0 - 393
.scratch/cockpit-real-data-wayfinding/implementation/t29/pipe-network-service/zksy-system/src/main/java/com/zksy/cockpit/monitoring/CockpitMonitoringDistributionResponse.java

@@ -1,393 +0,0 @@
-package com.zksy.cockpit.monitoring;
-
-import com.zksy.cockpit.foundation.CockpitCoordinateValidation;
-import com.zksy.cockpit.foundation.CockpitDistrict;
-import com.zksy.cockpit.point.CockpitMonitoringPointStatus;
-import com.zksy.cockpit.point.CockpitPointStatusQuery;
-
-import java.math.BigDecimal;
-import java.util.List;
-
-public class CockpitMonitoringDistributionResponse {
-    private final String requestId;
-    private final String apiVersion;
-    private final boolean readOnly;
-    private final String displayName;
-    private final String timezone;
-    private final Range range;
-    private final String updatedAt;
-    private final Freshness freshness;
-    private final List<DistrictDistribution> districtDistribution;
-    private final List<IndustryDistribution> industryDistribution;
-    private final List<DeviceTypeDistribution> deviceTypeDistribution;
-    private final List<CockpitPointStatusQuery.Count> pointStatusDistribution;
-    private final MapFilter mapFilter;
-    private final Pagination pagination;
-    private final List<MonitoringPointMarker> monitoringPoints;
-    private final List<UnassociatedEquipmentMarker> unassociatedEquipment;
-    private final Excluded excluded;
-
-    public CockpitMonitoringDistributionResponse(
-            String requestId,
-            String apiVersion,
-            boolean readOnly,
-            String displayName,
-            String timezone,
-            Range range,
-            String updatedAt,
-            Freshness freshness,
-            List<DistrictDistribution> districtDistribution,
-            List<IndustryDistribution> industryDistribution,
-            List<DeviceTypeDistribution> deviceTypeDistribution,
-            List<CockpitPointStatusQuery.Count> pointStatusDistribution,
-            MapFilter mapFilter,
-            Pagination pagination,
-            List<MonitoringPointMarker> monitoringPoints,
-            List<UnassociatedEquipmentMarker> unassociatedEquipment,
-            Excluded excluded) {
-        this.requestId = requestId;
-        this.apiVersion = apiVersion;
-        this.readOnly = readOnly;
-        this.displayName = displayName;
-        this.timezone = timezone;
-        this.range = range;
-        this.updatedAt = updatedAt;
-        this.freshness = freshness;
-        this.districtDistribution = districtDistribution;
-        this.industryDistribution = industryDistribution;
-        this.deviceTypeDistribution = deviceTypeDistribution;
-        this.pointStatusDistribution = pointStatusDistribution;
-        this.mapFilter = mapFilter;
-        this.pagination = pagination;
-        this.monitoringPoints = monitoringPoints;
-        this.unassociatedEquipment = unassociatedEquipment;
-        this.excluded = excluded;
-    }
-
-    public String getRequestId() { return requestId; }
-    public String getApiVersion() { return apiVersion; }
-    public boolean isReadOnly() { return readOnly; }
-    public String getDisplayName() { return displayName; }
-    public String getTimezone() { return timezone; }
-    public Range getRange() { return range; }
-    public String getUpdatedAt() { return updatedAt; }
-    public Freshness getFreshness() { return freshness; }
-    public List<DistrictDistribution> getDistrictDistribution() { return districtDistribution; }
-    public List<IndustryDistribution> getIndustryDistribution() { return industryDistribution; }
-    public List<DeviceTypeDistribution> getDeviceTypeDistribution() { return deviceTypeDistribution; }
-    public List<CockpitPointStatusQuery.Count> getPointStatusDistribution() { return pointStatusDistribution; }
-    public MapFilter getMapFilter() { return mapFilter; }
-    public Pagination getPagination() { return pagination; }
-    public List<MonitoringPointMarker> getMonitoringPoints() { return monitoringPoints; }
-    public List<UnassociatedEquipmentMarker> getUnassociatedEquipment() { return unassociatedEquipment; }
-    public Excluded getExcluded() { return excluded; }
-
-    public static class Range {
-        private final String type;
-
-        public Range(String type) {
-            this.type = type;
-        }
-
-        public String getType() {
-            return type;
-        }
-    }
-
-    public static class Freshness {
-        private final String status;
-        private final String reason;
-        private final String updatedAt;
-        private final String serverUpdatedAt;
-
-        public Freshness(String status, String reason, String updatedAt, String serverUpdatedAt) {
-            this.status = status;
-            this.reason = reason;
-            this.updatedAt = updatedAt;
-            this.serverUpdatedAt = serverUpdatedAt;
-        }
-
-        public String getStatus() { return status; }
-        public String getReason() { return reason; }
-        public String getUpdatedAt() { return updatedAt; }
-        public String getServerUpdatedAt() { return serverUpdatedAt; }
-    }
-
-    public static class StatusCounts {
-        private final int alarm;
-        private final int warning;
-        private final int workOrder;
-        private final int normal;
-        private final int offline;
-        private final int empty;
-
-        public StatusCounts(int alarm, int warning, int workOrder, int normal, int offline, int empty) {
-            this.alarm = alarm;
-            this.warning = warning;
-            this.workOrder = workOrder;
-            this.normal = normal;
-            this.offline = offline;
-            this.empty = empty;
-        }
-
-        public int getAlarm() { return alarm; }
-        public int getWarning() { return warning; }
-        public int getWorkOrder() { return workOrder; }
-        public int getNormal() { return normal; }
-        public int getOffline() { return offline; }
-        public int getEmpty() { return empty; }
-    }
-
-    /**
-     * 片区分布行。片区的权威来源是 {@code equipment_base.district}(0-城南 1-城北 2-太常),
-     * 点位按「经其上挂设备归纳」取得;未分区(无来源/不一致/脏值)回 {@code district=null} +
-     * {@code districtName="未分区"},不猜、不按行业冒充。
-     */
-    public static class DistrictDistribution {
-        private final Integer district;
-        private final String districtName;
-        private final int pointCount;
-        private final int deviceCount;
-        private final StatusCounts statusCounts;
-
-        public DistrictDistribution(Integer district, int pointCount, int deviceCount, StatusCounts statusCounts) {
-            this.district = CockpitDistrict.codeOf(district);
-            this.districtName = CockpitDistrict.nameOf(this.district);
-            this.pointCount = pointCount;
-            this.deviceCount = deviceCount;
-            this.statusCounts = statusCounts;
-        }
-
-        /** 片区编码;未分区为 {@code null},名称见 {@link #getDistrictName()}。 */
-        public Integer getDistrict() { return district; }
-        public String getDistrictName() { return districtName; }
-        public int getPointCount() { return pointCount; }
-        public int getDeviceCount() { return deviceCount; }
-        public StatusCounts getStatusCounts() { return statusCounts; }
-    }
-
-    public static class IndustryDistribution {
-        private final String industry;
-        private final int pointCount;
-        private final int deviceCount;
-        private final StatusCounts statusCounts;
-
-        public IndustryDistribution(String industry, int pointCount, int deviceCount, StatusCounts statusCounts) {
-            this.industry = industry;
-            this.pointCount = pointCount;
-            this.deviceCount = deviceCount;
-            this.statusCounts = statusCounts;
-        }
-
-        public String getIndustry() { return industry; }
-        public int getPointCount() { return pointCount; }
-        public int getDeviceCount() { return deviceCount; }
-        public StatusCounts getStatusCounts() { return statusCounts; }
-    }
-
-    public static class DeviceTypeDistribution {
-        private final DeviceType deviceType;
-        private final String industry;
-        private final int pointCount;
-        private final int deviceCount;
-        private final StatusCounts statusCounts;
-
-        public DeviceTypeDistribution(DeviceType deviceType, String industry, int pointCount, int deviceCount,
-                StatusCounts statusCounts) {
-            this.deviceType = deviceType;
-            this.industry = industry;
-            this.pointCount = pointCount;
-            this.deviceCount = deviceCount;
-            this.statusCounts = statusCounts;
-        }
-
-        public DeviceType getDeviceType() { return deviceType; }
-        public String getIndustry() { return industry; }
-        public int getPointCount() { return pointCount; }
-        public int getDeviceCount() { return deviceCount; }
-        public StatusCounts getStatusCounts() { return statusCounts; }
-    }
-
-    public static class DeviceType {
-        private final String code;
-        private final String name;
-
-        public DeviceType(String code, String name) {
-            this.code = code;
-            this.name = name;
-        }
-
-        public String getCode() { return code; }
-        public String getName() { return name; }
-    }
-
-    /**
-     * 生效范围回显。片区维度自执行票 12 起为 {@code district}:
-     * 「未筛」与「只看未分区」的编码都是 {@code null},靠 {@code districtFilter}
-     * ({@code NONE} / {@code ASSIGNED} / {@code UNASSIGNED})区分,不靠猜。
-     */
-    public static class MapFilter {
-        private final String industry;
-        private final String deviceType;
-        private final String pointStatus;
-        private final Integer district;
-        private final String districtName;
-        private final String districtFilter;
-        private final List<String> pointCodes;
-        private final List<String> deviceCodes;
-
-        public MapFilter(String industry, String deviceType, String pointStatus,
-                Integer district, String districtFilter,
-                List<String> pointCodes, List<String> deviceCodes) {
-            this.industry = industry;
-            this.deviceType = deviceType;
-            this.pointStatus = pointStatus;
-            this.district = district;
-            this.districtName = district == null ? null : CockpitDistrict.nameOf(district);
-            this.districtFilter = districtFilter;
-            this.pointCodes = pointCodes;
-            this.deviceCodes = deviceCodes;
-        }
-
-        public String getIndustry() { return industry; }
-        public String getDeviceType() { return deviceType; }
-        public String getPointStatus() { return pointStatus; }
-        /** 生效片区编码;未筛或「只看未分区」时为 {@code null}(用 {@link #getDistrictFilter()} 区分)。 */
-        public Integer getDistrict() { return district; }
-        /** 生效片区展示名;未筛为 {@code null},只看未分区为「未分区」。 */
-        public String getDistrictName() { return districtName; }
-        /** `NONE`(未筛)/ `ASSIGNED`(按片区)/ `UNASSIGNED`(只看未分区)。 */
-        public String getDistrictFilter() { return districtFilter; }
-        public List<String> getPointCodes() { return pointCodes; }
-        public List<String> getDeviceCodes() { return deviceCodes; }
-    }
-
-    public static class Pagination {
-        private final int page;
-        private final int pageSize;
-        private final int total;
-        private final int totalPages;
-        private final boolean hasMore;
-
-        public Pagination(int page, int pageSize, int total, int totalPages, boolean hasMore) {
-            this.page = page;
-            this.pageSize = pageSize;
-            this.total = total;
-            this.totalPages = totalPages;
-            this.hasMore = hasMore;
-        }
-
-        public int getPage() { return page; }
-        public int getPageSize() { return pageSize; }
-        public int getTotal() { return total; }
-        public int getTotalPages() { return totalPages; }
-        public boolean isHasMore() { return hasMore; }
-    }
-
-    public static class MonitoringPointMarker {
-        private final String pointCode;
-        private final String pointName;
-        private final Integer district;
-        private final String districtName;
-        private final String industry;
-        private final DeviceType deviceType;
-        private final CockpitMonitoringPointStatus pointStatus;
-        private final List<String> statusReasons;
-        private final BigDecimal longitude;
-        private final BigDecimal latitude;
-        private final CockpitCoordinateValidation coordinateValidation;
-
-        public MonitoringPointMarker(String pointCode, String pointName, Integer district, String industry,
-                DeviceType deviceType, CockpitMonitoringPointStatus pointStatus, List<String> statusReasons,
-                BigDecimal longitude, BigDecimal latitude, CockpitCoordinateValidation coordinateValidation) {
-            this.pointCode = pointCode;
-            this.pointName = pointName;
-            this.district = CockpitDistrict.codeOf(district);
-            this.districtName = CockpitDistrict.nameOf(this.district);
-            this.industry = industry;
-            this.deviceType = deviceType;
-            this.pointStatus = pointStatus;
-            this.statusReasons = statusReasons;
-            this.longitude = longitude;
-            this.latitude = latitude;
-            this.coordinateValidation = coordinateValidation;
-        }
-
-        public String getPointCode() { return pointCode; }
-        public String getPointName() { return pointName; }
-        public Integer getDistrict() { return district; }
-        public String getDistrictName() { return districtName; }
-        public String getIndustry() { return industry; }
-        public DeviceType getDeviceType() { return deviceType; }
-        public CockpitMonitoringPointStatus getPointStatus() { return pointStatus; }
-        public List<String> getStatusReasons() { return statusReasons; }
-        public BigDecimal getLongitude() { return longitude; }
-        public BigDecimal getLatitude() { return latitude; }
-        public CockpitCoordinateValidation getCoordinateValidation() { return coordinateValidation; }
-    }
-
-    public static class UnassociatedEquipmentMarker {
-        private final String deviceCode;
-        private final String deviceName;
-        private final Integer district;
-        private final String districtName;
-        private final String industry;
-        private final DeviceType deviceType;
-        private final CockpitMonitoringPointStatus pointStatus;
-        private final List<String> statusReasons;
-        private final BigDecimal longitude;
-        private final BigDecimal latitude;
-        private final CockpitCoordinateValidation coordinateValidation;
-
-        public UnassociatedEquipmentMarker(String deviceCode, String deviceName, Integer district, String industry,
-                DeviceType deviceType, CockpitMonitoringPointStatus pointStatus, List<String> statusReasons,
-                BigDecimal longitude, BigDecimal latitude, CockpitCoordinateValidation coordinateValidation) {
-            this.deviceCode = deviceCode;
-            this.deviceName = deviceName;
-            this.district = CockpitDistrict.codeOf(district);
-            this.districtName = CockpitDistrict.nameOf(this.district);
-            this.industry = industry;
-            this.deviceType = deviceType;
-            this.pointStatus = pointStatus;
-            this.statusReasons = statusReasons;
-            this.longitude = longitude;
-            this.latitude = latitude;
-            this.coordinateValidation = coordinateValidation;
-        }
-
-        public String getDeviceCode() { return deviceCode; }
-        public String getDeviceStatus() {
-            return CockpitPointStatusQuery.deviceBucket(pointStatus == null ? null : pointStatus.name());
-        }
-        public String getDeviceName() { return deviceName; }
-        public Integer getDistrict() { return district; }
-        public String getDistrictName() { return districtName; }
-        public String getIndustry() { return industry; }
-        public DeviceType getDeviceType() { return deviceType; }
-        public CockpitMonitoringPointStatus getPointStatus() { return pointStatus; }
-        public List<String> getStatusReasons() { return statusReasons; }
-        public BigDecimal getLongitude() { return longitude; }
-        public BigDecimal getLatitude() { return latitude; }
-        public CockpitCoordinateValidation getCoordinateValidation() { return coordinateValidation; }
-    }
-
-    public static class Excluded {
-        private final int unknownTypeCount;
-        private final int typeKindMismatchCount;
-        private final int nonFormalIndustryCount;
-        private final int invalidCoordinateCount;
-
-        public Excluded(int unknownTypeCount, int typeKindMismatchCount, int nonFormalIndustryCount,
-                int invalidCoordinateCount) {
-            this.unknownTypeCount = unknownTypeCount;
-            this.typeKindMismatchCount = typeKindMismatchCount;
-            this.nonFormalIndustryCount = nonFormalIndustryCount;
-            this.invalidCoordinateCount = invalidCoordinateCount;
-        }
-
-        public int getUnknownTypeCount() { return unknownTypeCount; }
-        public int getTypeKindMismatchCount() { return typeKindMismatchCount; }
-        public int getNonFormalIndustryCount() { return nonFormalIndustryCount; }
-        public int getInvalidCoordinateCount() { return invalidCoordinateCount; }
-    }
-}

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio