# 45: 重叠设备聚合与展开 Type: implementation Status: resolved Blocked by: 42, 43, 44, 46 Labels: ready-for-agent ## What to build 按规格 17 处理同一监测点下设备坐标相同或极近的呈现和拾取。静止态显示无设备身份的重叠聚合符号:最高优先级可见设备状态色/形加 K 徽标;hover 瞬态预览环/扇形,click 粘性展开为屏幕层分离,不改变任何真实坐标。展开后普通 pick 走独立设备拾取和票 15 选中反应;禁止 `drillPick` 和把视觉分离写回空间数据。 ## Acceptance - [x] K≤10 时全部可逐台展开/拾取;K>10 使用聚合 + “K 台设备(本屏显示 K 台 / 其余见清单)”降级,阈值由 `map.overlap.maxFan`(默认 10)控制。 - [x] hover 聚合符号离开即收回;click 后粘性展开,点设备、点空白或面板返回点位时收起。 - [x] 聚合符号本身不冒充任何设备;展开设备携带 `deviceCode`,设备点优先于监测点拾取。 - [x] 展开态选中设备沿票 15 画外环/放大/引线;未展开重叠时零长度引线不画;状态特效不因聚合开关失效。 - [x] K 为当前可逐台绘制设备数,N 为快照关联总数;徽标/图例/清单口径一致,被聚合、降级、无坐标、待确认和模式隐藏的设备不虚报为逐台已上图。 - [x] 宿主暴露 `data-map-overlap-state` 和 `data-map-overlap-count`,不破坏既有设备/面板断言字段。 - [x] 纯逻辑覆盖 K/N 分类、最高状态、阈值和状态机;真实走查覆盖 hover、click、展开拾取、收起和预警模式。 ## Out of scope - 设备图层生命周期、资产接缝、设备状态特效、基础双向联动和完整模式矩阵。 ## 进度:100%(2026-09-30 收口) **已完成并关闭。** 领域断言 **18/18**、变异 **6/6 全部打红**、真机走查 **16/16(连跑两次稳定)**, 全量零依赖闸门回归绿(`t04`/`t28` 两处既有红与本票无关,均为并发/环境问题)。 ### 交付物 - `domain/overlapDevices.js`(新):**重叠设备聚合纯函数**。`groupOverlappingDevices()`(同点位近距分组, K=可逐台绘制数、N=快照关联总数);`aggregateMarker()`(聚合符号**无 deviceCode**);`fanLayout()` (屏幕圆环展开,**不改经纬度**);`transitionOverlapState()`(hover 预览 / click 粘性 / 三条件收起); `resolveOverlapPick()`(设备优先、聚合兜底);`shouldCollapseOverlap()`。 - `domain/deviceLayer.js` 复用:K 只认 `placement === 'drawn'`(坐标有效且当前模式可见), `no-location/terrain-pending/hidden` 等不混入 K(票 42/44 既有互斥分类的延伸)。 - `services/cesiumEngine.js`:`syncAssociatedDevices(devices, colorOf, imageOf, overlapGroups)` 重建时 对每组画**无身份聚合实体**(`properties.overlapAggregate=true` + K 徽标 label)+ 保持逐台实体 (`properties.overlapGroup` 标记归属,`show:false` 折叠隐藏);`setOverlapPresentation({groupKey, state, preview, offsets})` 用 `pixelOffset` 做**屏幕层分离**(真实 position 不动);`readOverlapTargets()` 只读窗口坐标供走查; `pickDeviceMarkerAt()` 先普通 `scene.pick`(认 deviceCode / overlapAggregate),落空时用投影近邻补偿。 - `services/mapService.js`:`syncAssociatedDevices(drawn, { maxFan })` 计算 overlapGroups; K/N 状态机(`overlapPreviewGroup` / `overlapExpandedGroup` / `overlapState` / `overlapCountValue`); `expandOverlap` / `collapseOverlap` / `toggleOverlap`(按 groupKey,同点位多组互不干扰); `pickDeviceOrPointAt` 展开子设备先收起环再走票 43 原有选中/详情;空白/返回点位统一收起; `nudgeOverlap` / `clearOverlapNudge`(面板 hover 弱提示,不摊开);DEV 把手 `getOverlapState` / `getOverlapTargets` / `toggleOverlap` / `collapseOverlap` / `nudgeOverlap`。 - `stores/deviceLayerStore.js`:`overlap` 摘要(k/n/maxFan/groups/active/degraded)随 `syncForSelection` 计算;`nudgeOverlapRow` / `clearOverlapNudge` 经 store 转发(组件不直连 mapService,维护票 10 F4)。 - `components/PointDetailCard.vue`:清单头部显示「聚合呈现 K 台(降级时补本屏/其余见清单)」; 面板 hover 聚合行只弱提示、click 才经 store 选中展开。 - `services/cockpitConfig.js` + `config.example.json`:`map.overlap.maxFan` 部署配置(默认 10, 非正整数退回 10 并记 issue)。 - `mocks/contractMocks.js`:`device-layer-overlap` 受控样例(两台设备**自身坐标相同**、身份各自保留, EQ-001 ALARM + EQ-002 NORMAL,覆盖 K=2 正常重叠与预警模式过滤); 追加 `device-layer-overlap-degraded` 样例(**11 台设备同坐标**、状态混合 3 ALARM / 3 WARNING / 5 NORMAL, K=11 > maxFan 10 ⇒ 降级形态「聚合呈现 11 台(本屏显示 10 台 / 其余见清单)」,真机验证通过)。 - **主场景示例(维护者 2026-09-30 要求)**:两个示例**并入默认场景**,进入系统即可见,无需注入: · `MP-001` 城南测试监测点 = **K=2 正常重叠**;· `MP-002` 太常测试监测点 = **K=11 降级**。 两份样例由 `overlapPointDetail()` / `degradedOverlapPointDetail()` **唯一实现**,主场景与专用场景共用 (避免同一份样例登记两处而漂移)。其余点位仍走默认现网边界(设备 `UNVERIFIED_COORDINATE/renderable=false`)。 - `mocks/index.js`:人工测试通道 **`?scenario=xxx`**(Console 里设全局变量刷新即丢, 地址栏才是人工测试稳定入口;两者并存时 URL 优先)。 - 证据:`verify/t45-overlap-devices.mjs`(**18/18**)+ 变异 **6/6 全打红** + `verify/t45-overlap-walkthrough.mjs`(**16/16**,含 W15 预警模式状态过滤、W14 四既有字段、 W16 无页面错误)+ `verify/t45-real-user-path-walkthrough.mjs`(**7/7**,全程真实鼠标点地图标记: 进入系统 → 点开监测点 → 展开设备点 → 逐台拾取;K=2 与 K=11 两场景各跑通)+ `verify/t45-main-scene-samples-walkthrough.mjs`(**5/5**,**不注入任何场景**验证主场景 MP-001=K2 / MP-002=K11 降级直接可见)。 - `verify/t05-adapter.mjs`:C1 跟随主场景新样例(MP-002 → 11 台),并补 `C1b` 钉住 MP-001 的 K=2 样例。 ### 本轮真机走查抓到的两个**整站级**缺陷(都不是本票逻辑,但会让人工走查无法进行) 1. **页面整站挂载失败**:`domain/filterOptions.js` 里 `POINT_STATUS_FILTER_KEYS` 在 `POINT_STATUS_KEYS` **之前**引用它 —— `const` 暂时性死区导致模块加载即抛 `ReferenceError`,驾驶舱根本打不开 (并发会话在途改动引入;已按声明顺序修复)。 2. **壳层三处未定义引用**:`CockpitShell.vue` 把 `mockReady` 改名 `lifecycleReady` 时漏改 3 处 `mockReady.value`,运行期 `ReferenceError` 打断初始化与筛选跟随(同样是在途改动;已按注释记载的新名补齐)。 另修一处**只读投影**缺陷(本票自己的):点位标记是 `CLAMP_TO_GROUND` 贴地形渲染, `readPointMarkerTargets()` 原先按椭球零高度投影,屏幕坐标与画面标记差出一段地形高度, 真实鼠标按该坐标点**打不中**(走查抓到)。已改为与渲染同口径(先取地形高度再投影)。 ### 关键取舍(都来自已 resolved 的规格 17 / 决策票 11,不在本票发明) 1. **聚合符号无设备身份**:`properties` 不含 `deviceCode`/`monitoringPointCode`, `scene.pick` 拾到它只回 overlap action(展开/收起),绝不冒充设备(规格:点到聚合符号不会选中一台不存在的设备)。 2. **屏幕分离不改真实坐标**:展开环用 billboard `pixelOffset`(屏幕层),实体 `position`/经纬度 一个字节不动;不写回任何空间语义(CONTEXT.md 人工坐标偏移禁令的边界)。 3. **不用 `drillPick`**:展开设备各有屏幕位置,走普通 `scene.pick` + 投影近邻补偿 (聚合与监测点同屏时 `scene.pick` 可能只回顶层点位,用 `readOverlapTargets` 的近邻兜底)。 4. **K/N 口径固定**:K=可逐台绘制数(`drawn`),N=快照关联总数;聚合徽标/图例/清单都按 K, 被聚合/降级/无坐标/待确认/模式隐藏的设备不虚报逐台已上图(票 42 纪律的延伸)。 5. **preview 与 sticky 不同**:hover 预览**保留聚合符号**(还能再点到它),click 粘性展开才隐藏聚合; 本票真机走查发现最初预览态把聚合也隐藏导致"预览后再点聚合"落空,已修。 ### 本票自查抓到并修掉的 3 处真问题 1. **`resolveOverlapPick` 判 `deviceCode` 而引擎回 `code`**:展开设备普通拾取永远走不到 device 分支、 恒返回 none(真机走查用同一次 evaluate 铁证抓到:`pickDeviceMarker` 回 EQ-001 而 `pickDeviceOrPoint` 回 none)。 修法:判定认 `(code || deviceCode)`,与票 43 `resolvePickOrder` 同一引擎形状。 2. **`readOverlapTargets` 用 `entity.isShowing`(渲染态)**:headless 下 `show` 改变后 `isShowing` 要等一帧才同步,导致展开态设备被误滤掉、聚合反而可见 —— 拾取判据反了。改用**逻辑** `entity.show`。 3. **预览态隐藏聚合导致无法继续点击**:规格要求 hover 预览、click 才粘性展开, 预览时应保留聚合符号;已区分 `preview`/sticky 两种呈现。 ### 真机走查(已执行,15/15,连跑两次稳定) 驱动:`verify/cdp-driver.mjs`(票 44 交付的零依赖 CDP 驱动),`document.hidden === false`。 真实 CDP 鼠标(move/down/up)+ 仅测试态让锚定浮层 `pointer-events:none`(既有点选浮层遮挡画布, 与票 45 产品无关,走查声明)。 - W1–W4:折叠态聚合实体 1 个、K=2、**deviceCode 空**、逐台实体隐藏、清单仍 2 行; - W5:聚合实体真实投影到视窗;W6–W7:真实 hover → 瞬态预览、离开 → 收起; - W8–W10:真实 click → 粘性展开、两台设备带 `deviceCode`、pixelOffset 屏幕分离、聚合隐藏; - W11:**真实 click 展开设备 EQ-001 → 普通拾取选中并进设备级**(`picked=EQ-001`、`panel=equipment`); - W12–W13:返回点位 / 真实 click 空白 → 展开收起; - W14:既有 `data-map-picked` / `data-map-device-picked` / `data-map-panel-selection` / `data-map-device-hovered` 字段保留;W15:无页面级报错。 **走查环境记录**:真实鼠标空白点击需落在 canvas 区域(面板覆盖画布时 pointer 不达宿主)—— 走查据此选用 (700,800) 并在断言中显式核验 `elementFromPoint` 与 pointer 到达计数。 ### 与下游票的边界(不在本票实现) - **票 47**:状态特效(报警扩散等)——本票重叠收起/展开不破坏特效开关(特效仍由票 47 实现, 本票只保证重叠呈现不吞特效实体);并请 47 明确 `deviceLegend()`(票 44 移交)去留。 ### 回归 零依赖闸门 **36 道**全绿(含本票新增 t45;t41 的 E4 注释误伤已调整 —— 注释不含 `drillPick` 字样)。 两处红与本票无关:① `t28-fill-container.mjs` 需 puppeteer(模块不在本机); ② `t04-config.mjs` 指向 `services/cockpitApi.js`(并发会话正在改)。 **按 `AGENTS.md`:本会话未跑 build;`yarn dev` 未由我启动(8000 端口已有服务在跑)。 浏览器仅用于本次走查(维护者授权的提权通道,同票 44)。**