Selaa lähdekoodia

feat(cockpit): 票43 四轮收口 —— 饼图/环图整楔命中区 + 稳定标量联动键 + 堆叠条接力取整

工单堆叠图类别间小间隔:判定为绘制缺陷(left/width 各自独立取整露底色缝,
mock 报警向量实测「待验收→已结案」边界 1 个百分点;CSS 无分隔声明、露缝随计数
变化,非语义设计)。stackedBlock 改单累加器接力取整(startPct/widthPct,每段
起点=上一段终点、末段恒收 100),连续由构造保证;total/start/ratio 精确占比
原样保留,统计口径零改动。

饼图/环图交互(三轮驳回、两级根因):
- 触发根因:可见扇区是细环带(46/25、42/26),SVG 只有 painted 区接收指针,
  真实鼠标落在圆心孔/环带外全不触发。改 domain 产出整楔命中区(hitPath,
  内半径 0),渲染层画透明层承接悬停/聚焦/键盘,热区与视觉扇区同角度同边界。
- 联动根因一:chartHover 用 ref 存对象被 Vue 深层代理,owner===block 恒 false
  → is-hovered 永不生效(扇区不放大、图例不亮,浮层却照常出)。改 shallowRef。
- 联动根因二:联动键用对象身份,而外壳时钟每秒重渲染 + :view 是模板方法调用,
  每帧重算出全新 block → hover 停留数秒后比较静默失效、高亮自灭。联动键改
  chartScope(index, block)=视图名|块序号|块类型|块标题的跨重算稳定标量串。
- 放大档 1.05/1.06 → scale(1.1)+brightness(1.35)+白描边(小图上原档不足 2px
  位移,不够明显);扇区⇄图例双向联动 + 同一共享详情浮层;外层 svg role=group、
  命中楔 tabindex + 每片 aria-label(类别:数量);删原生 <title> 收敛单入口;
  图例数值紧跟标签成对(去 space-between / margin-left:auto / 标签只缩不涨)。

验证:t43 闸门扩到 23/23(P8 标量 scope 守卫 + P8b 运行时探针:对象身份键在
新对象下查不回、scope 键查得回),变异 M1–M8 全部打红;11 组离线回归全绿;
真实浏览器(real)hover 跨 3.5s 多时钟 tick,放大/联动/浮层全程保持不再自灭。
ChenJZ 4 päivää sitten
vanhempi
commit
7f71992227

+ 46 - 26
.scratch/cockpit-real-data-wayfinding/issues/43-untitled.md

@@ -23,14 +23,14 @@ Category: frontend-execution
 
 ## 进度
 
-- 2026-10-15:认领(`Status: claimed`)→ 诊断(范围 1)→ 实施(范围 2/3)→ 离线闸门 + 变异测试 + 回归 → 收口。
-- 2026-10-15:真实浏览器走查未执行(`AGENTS.md` 项目规则:不自动运行 dev/build/截图);走查入口 `http://127.0.0.1:8000/#/cockpit`,判据见「验收对照」第 2 条。
-- 2026-10-15:**改动未提交**(工作树混有票 37/38/39/40/45/46 等并行在制品,入库须按票 33/36/42 的裁剪纪律单独摘取本票 hunks)。
+- 2026-10-15 上午~傍晚:三轮(接力取整 → 整楔命中区 → shallowRef+放大档),每轮一次驳回,详见下方历史与本票 Comments。
+- 2026-10-16(**第四轮根因**,用户反馈:hover 停留几秒、鼠标未移出,放大与联动高亮**自己消失**):外壳时钟每秒 `clockText` 变化重渲染 + `:view="panelViewOf(panelId)"` 是模板方法调用 → 每帧重算出**全新 block 对象**;第三轮的 `owner === toRaw(block)` 身份比较在下一帧静默失效——浮层存的是标量所以还在,高亮却掉了,症状完全吻合。修复=联动键改 `chartScope(index, block)`=**视图名|块序号|块类型|块标题**的稳定标量串(数据刷新前后不变,仍区分并排同类图与块内不同行)。批量替换时漏掉 `clearChartHover` 6 处(前缀不含 set),mouseleave 仍传 block → 高亮清不掉,被 t43 P1 假红逮出修正;16 个调用点全部统一。
+- 2026-10-16 09:21(第四轮完成态走查,用户授权延续,real):**hover 持续 3.5 秒跨 3+ 时钟 tick**,饼图「报警」/环形图「排水」的放大+图例联动+浮层全程保持(页面时钟 09:20:23→09:21:41 跨多次重渲染)——不再自灭。t43 扩到 23/23(P8 标量 scope 守卫 + P8b 运行时探针:对象身份键在新对象下查不回、scope 键查得回;变异 M8 退化回对象身份 → P8 打红);11 组离线回归全绿。
 
-Status: claimed
+Status: resolved
 Type: task
 Blocked by:
-Labels: ready-for-agent, wayfinder:task
+Labels: ready-for-agent
 
 ## 诊断结论(范围 1)
 
@@ -49,48 +49,68 @@ Labels: ready-for-agent, wayfinder:task
 - **`components/PanelBlocks.vue`** 堆叠段样式改消费这两个整数百分比,删除段内的 `Math.round(...)` 独立取整。
 - **口径不变(票面非目标)**:`total` / `start` / `ratio` 原样保留未取整的精确占比,只改**绘制边界**;后端统计字段与工单状态码一字未动。
 
-### 交付 2:饼图/环图交互补齐(悬停放大已有,本票补键盘与可达性)
+### 交付 2:饼图/环图交互(第三轮最终形态 —— 整楔命中区 + shallowRef 联动 + 明显放大档)
 
-- **扇区可键盘聚焦**:饼图与环形图的每个扇区 `tabindex="0"` + `role="img"` + **每片自己的可访问名称** `` :aria-label="`${slice.label}:${slice.count}`" ``(类别与数量都在名称里,读屏可达)。
-- **focus 与 hover 走同一条路**:`@focus`/`@blur` 接**同一个**共享详情浮层 `showChartTip/hideChartTip`(票 37 走查扩展立的浮层),CSS `:focus-visible` 与 `:hover` **同一档放大**(饼 1.05 / 环 1.06)+ 增亮 + 一圈白色描边让焦点位置可见。交互风格与已验收横条统计一致(横条用 `:hover`/`:focus-visible` 同档反馈,见 `.panel-bar--button`)。
-- **外层 `<svg>` 由 `role="img"` 改 `role="group"`**:`img` 会把整棵子树对辅助技术隐藏,扇区的名称与焦点根本读不到 —— 这是「可访问名称」真正生效的前提。
-- **详情入口只留一个**:删除扇区原生 `<title>`(原生提示有 ~1s 延迟、样式不可控,且与自绘浮层双重叠加)。这与票 37 给趋势点定的成例一致(「趋势点改用无延迟的 `aria-label` 详情入口,避免与自绘浮层叠加原生 `title`」)。
-- **环形图图例悬停补齐**:此前饼图图例行接了浮层、环形图图例行漏接(同款交互分家)。现两处一致。
+> 第一轮把 tabindex/hover 挂在可见细环带上(46/25、42/26),真实指针落在圆心孔/环带外全部不触发;
+> 第二轮改整楔命中区后浮层触发修好了,但联动状态 `chartHover = ref(...)` 被 Vue 深层代理,
+> `owner === block` 恒 false,`.is-hovered` 从未生效——扇区放大/图例高亮全部落空(第三轮驳回)。
+> 第三轮:`shallowRef` 存联动状态(身份保持原始对象,整体替换即触发渲染);放大档加强到
+> scale 1.1 + brightness 1.35 + 白描边(原 1.05/1.06 在 64px 图上仅 ~1.5px 位移,不够"明显")。
+
+- **整楔命中区(`hitPath`)**:`statusPie.js` 的 `hitWedge` 与 `panelBlocks.js` 的 `donutBlock` 为每个扇区额外产出一条**从圆心出发的整楔**(内半径 0)路径。渲染层把它画在可见扇区之上(`fill: transparent`),**悬停/聚焦/键盘全部挂命中楔**——热区与视觉扇区同角度同边界,圆心孔不再是死区。
+- **扇区 ⇄ 图例双向联动(`chartHover = shallowRef`)**:任一侧悬停/聚焦,可见扇区加 `.is-hovered`(放大+提亮+描边)、对应图例行加 `.is-hovered`(底色+文字提亮),同一个共享详情浮层(票 37 的 `showChartTip`)。按 `owner=块 + key=行` 记账:两块同类图并排不串亮;清除比对 owner+key 防闪烁。
+- **键盘可达**:命中楔 `tabindex="0"` + `role="img"` + 每片 `aria-label`(`类别:数量`);`:focus-visible` 画白色描边。外层 `<svg>` `role="group"`(img 会藏子树使名称不可达)。
+- **详情入口只留一个**:扇区不带原生 `<title>`(票 37 趋势点成例)。
+- **图例标注与数值成对(维护者反馈②)**:饼图行去 `justify-content: space-between`、环形计数去 `margin-left: auto`、标签 `flex: 0 1 auto`(只缩不涨——`1 1 auto` 会把数值顶回行尾)。环形中心读数 `pointer-events: none` 让位给命中楔。
 
 ## 验收对照(票面四条)
 
 1. **不同统计块的颜色、间隔含义明确** —— 间隔缺陷已消除(连续条段),颜色沿用票 37 的 `barTone` 八态八色相,未新增含义不明的视觉元素。
-2. **饼图扇区 hover/focus 有明显反馈并展示类别与数量** —— hover/focus 同档放大 + 描边 + 共享浮层显示「类别:数量」;键盘可达(`tabindex` + `aria-label`)。**真实浏览器走查未执行**(项目规则),走查判据:Tab 进入扇区应见描边与浮层、四类扇区逐一可读、堆叠条各类别边界不再露底色缝。
+2. **饼图扇区 hover/focus 有明显反馈并展示类别与数量** —— 第三轮以**运行时证据**判(不再以浮层出现代判):悬停/聚焦任一侧后,可见扇区 `class` 真带 `is-hovered` 且计算样式 transform/filter 变化、对应图例行同亮;浏览器正反证见「进度」与 Comments。
 3. **不改变统计口径** —— `total`/`start`/`ratio` 保持精确值;闸门 S4 钉住这一点。
-4. **零依赖逻辑闸门** —— 新增 [t43-stacked-gap-and-pie-interactions.mjs](../verify/t43-stacked-gap-and-pie-interactions.mjs) **13/13**。
+4. **零依赖逻辑闸门** —— [t43-stacked-gap-and-pie-interactions.mjs](../verify/t43-stacked-gap-and-pie-interactions.mjs) 第三轮扩到 **22 条**(新增 P9 shallowRef 静态守卫 + P9c 运行时身份探针)。
 
 ## 验证与对抗式自查
 
-**新闸门 t43(13/13,全离线)**:S 组堆叠连续(接力构造、**旧缺陷实证复现 + 新公式缝=0**、宽度和=100、0 计数行保留、口径不变、渲染层消费接力值);P 组交互(两图扇区 tabindex/aria-label/focus-blur、原生 title 已清、svg role=group、`:focus-visible` 与 `:hover` 同档、环形图图例浮层对齐);D 组 `donutBlock` 形状与可点接缝不回归。
+**闸门 t43(第三轮 22/22,全离线)**:S 组堆叠连续(接力构造、旧缺陷实证复现 + 新公式缝=0、宽度和=100、0 计数行保留、口径不变、渲染层消费接力值);P 组交互(P0 两图扇区带 `hitPath`、P1 命中楔承载 tabindex/aria-label/hover/focus、P1b 可见扇区被动化、P2 无原生 title、P3 svg role=group、P4 `.is-hovered` 放大档 + 命中楔焦点描边、P5 图例⇄扇区双向、P6 数值紧跟标签、P7 中心读数让位、P8 联动记账结构、**P9 联动状态必须 shallowRef + P9c Vue 运行时身份探针(ref 深层代理会破坏 owner===block,shallowRef 不会)**);D 组 `donutBlock` 形状与可点接缝不回归。
 
-**变异测试证明守卫有牙(3 处全部打红)**:
-- M1 domain 退回旧的独立取整 → **S1/S2/S3 红**(S2 显示缝重新变 1)。
-- M2 只删饼图扇区 `tabindex` → **P1-pie 红**(证明逐元素正则用 `(?:(?!</path>)[\s\S])*?` 锚定,不会从环形图那片借断言假绿)。
-- M3 恢复扇区原生 `<title>` → **P2 红**。
+**变异测试累计 7 处打红**:M1 独立取整→S 组红;M2 删 tabindex→P1 红;M3 恢复 `<title>`→P2 红;M4 图例删 setChartHover→P5 红;M5 恢复 margin-left:auto→P6 红;M6 标签改回生长(同时暴露 CSS 守卫必须先剥注释)→P6 红;**M7 `shallowRef` 改回 `ref`→P9 红(本轮根因的反向锁定)**。
 
-**回归(离线可跑的 10 组全绿)**:t43 13/13、t50 **30/30**(此前被跳过的 pinia store 断言本轮环境已恢复并实跑)、t42、t15 11/11、t23、t27、t16-stores、t16-alarm-order、t22、t19 16/16。另用仓库自带 `@vue/compiler-sfc` 对 `PanelBlocks.vue` 做**结构校验**(parse + compileTemplate 零错误,票 43 的 9 处接线在编译产物中逐条命中)。
+**回归(离线 11 组全绿)**:t43、t50 30/30、t42、t15、t23、t27、t16-stores、t16-alarm-order、t22、t19、t09。`@vue/compiler-sfc` 结构校验全过。
 
-**既有红闸门归因**:`t20-overview-panels` **60/66**,6 条红为 B4/C2/D2b/D3/E2/F3(片区端点、载荷引用、store 裸字段),与票 42 收口时记录的 HEAD 基线**同 6 条**,且与本票 diff(堆叠/饼/环)零交集 —— 非本票引入。
+**既有红闸门归因**:`t20-overview-panels` 60/66 的 6 条红与票 42 记录的 HEAD 基线同 6 条,与本票 diff 零交集。
 
-**事故与修复(如实记录)**:归因实验期间用 `git stash push -- <两个文件>` 往返,**`domain/panelBlocks.js` 被写坏**(内容变成 `.vue` 的模板、后被压成单行)。已恢复并逐层验证:① 从票 37 会话留下的 dangling stash blob 取回一份 js,核对后发现它是**票 37 之前的旧版**(旧图标名、无 `KPI_ICON_COLORS`、`barsBlock` 无 `hint`)——**没有采用**,否则会回退并行在制品;② 按本会话内已完整读过的原文(604 行)+ 本票那一处编辑重建,逐段核对;③ 结构校验:619 行 = 604 + 本票 15 行,导出符号 29 个齐全,`EllipsisText` 用点 15 处、`text-overflow` 声明 0 处(票 37 的单一口径未被破坏),`role` 用法逐处核对(2 个 svg group、2 个扇区 img、趋势/堆叠的 `role="img"` 原样保留);④ 行尾改回仓库的 CRLF(避免整文件行尾噪声污染维护者的裁剪提交);⑤ 上述全部闸门在重建后的文件上复跑转绿。`git stash list` 已空,未遗留 stash。
+**教训留档**:① 归因实验禁用 `git stash push -- <文件>`(本工作树会写坏文件),用票 42 的 HEAD worktree 对照法;② 带孔/细环 SVG 图形的 hover 断言必须打在**用户视角位置**(圆心、环带外);③ **联动类交互的验收判据必须是"被联动元素的状态本身"(class/计算样式),不是旁证(浮层)**;④ 把对象存进深层 `ref` 再用 `===` 比身份,会被 Vue 代理拆穿——跨响应式边界比身份要用 `shallowRef`/`toRaw` 或稳定标量键。
 
 ## 遗留与下一步
 
-- **未提交**:本票 hunks = `domain/panelBlocks.js`(`stackedBlock` 接力取整 + 注释)、`components/PanelBlocks.vue`(堆叠段样式、饼/环扇区 tabindex+aria-label+focus/blur、svg role、删 `<title>`、环形图图例浮层、`:focus-visible` CSS)、`verify/t43-stacked-gap-and-pie-interactions.mjs`(新闸门)。入库按票 33/36/42 的裁剪纪律单独摘取。
-- **浏览器走查**:待维护者明确要求后执行(项目规则禁止自动运行);判据见「验收对照」第 2 条。
-- **相邻票边界**:票 44 的「条段之间有明确分割」是**视觉密度优化**(有意加分割装饰),与本票「消除无意义舍入缝」不冲突但相邻 —— 若票 44 要加显式分隔,应作为**有意的、与计数无关的**固定分隔样式实现,不要复用本票消除的舍入缝。
+- **入库状态**:第一轮 hunks 已随并行票 37 批次 `a40aedc` 推送 `origin/master`;第二/三轮 hunks(`domain/panelBlocks.js`、`domain/statusPie.js`、`components/PanelBlocks.vue`、`verify/t43-*.mjs`)**未提交**——工作树混有并行票 45/46/47 在制品,入库须单独裁剪。
+- **相邻票边界**:票 44 的「条段之间有明确分割」是有意装饰,与本票消除的舍入缝无关,勿混用。
+
+## Answer
+
+**(第三轮最终)** 双向联动的真正根因不在几何、不在 CSS,而在**响应式身份比较**:`chartHover = ref(null)` 存入 `{owner: block}` 后被 Vue 深层代理,读回的 owner 是代理对象,与模板 v-for 拿到的原始 block `===` 恒 false → `.is-hovered` 永不生效(扇区不放大、图例不亮),而浮层不依赖这个比较所以照常弹出——一个根因同时解释「只出详情」与「联动失败」。修复=`shallowRef` + 两侧 `toRaw()` 比较;放大档加到 scale(1.1)+brightness(1.35)+白描边(原 1.05/1.06 在 64–72px 图上仅 1.5–2px 位移,不够"明显")。**验收判据本轮改为"被联动元素自身的状态"**(class/视觉放大),不再以浮层出现代判:真实浏览器(real)悬停扇区→扇区放大+描边+图例亮、悬停图例→扇区放大+描边(双向成立)、环形图与键盘 focus 同法通过;变异 M7(shallowRef 改回 ref)被 P9 打红,P9c 在真 Vue 里钉住机制本身。t43 22/22、11 组回归全绿。第二/三轮 hunks 未提交(混有并行票 45/46/47 在制品,入库须单独裁剪)。
 
 ## Comments
 
 ### local — 2026-10-15T03:55:21.458Z
 诊断+实施完成(2026-10-15):① 间隔根因=绘制缺陷——堆叠段 left/width 各自独立取整露出底色缝(mock 报警向量 [0,1,2,1,1,3,1,0] 实测「待验收→已结案」边界 1 个百分点,反证非语义设计:CSS 无任何分隔声明、露缝与否随计数变化)。修复=domain 单累加器接力取整(startPct/widthPct),连续由构造保证;total/start/ratio 口径原样保留。② 饼/环交互补齐:扇区 tabindex=0 + 每片 aria-label(类别:数量)、focus/blur 接同一共享浮层、:focus-visible 与 :hover 同档放大+描边、外层 svg role=img→group(img 会藏子树使名称不可达)、删原生 <title> 收敛单一详情入口(票 37 趋势点成例)、环形图图例悬停与饼图对齐。验证:t43 新闸门 13/13、变异 M1/M2/M3 全部打红、10 组离线回归全绿(t50 30/30)、@vue/compiler-sfc 结构校验 9 项全过。t20 60/66 的 6 条红=票 42 记录的 HEAD 既有失败,与本票零交集。事故如实记录:归因实验的 git stash 往返写坏了 panelBlocks.js,已按本会话完整读取的原文+本票编辑重建(619=604+15 行、导出 29 符号齐、EllipsisText 15 用点、CRLF 归一),全部闸门在重建文件上复跑转绿。浏览器走查已获用户授权,但被并行会话在制品 mapService.js 的半完成语法错误阻断(Vite 模块图编译失败),用户指示等其修复后重试。改动未提交(混有并行在制品,按票 33/36/42 裁剪纪律单独摘取)。
 
+### local — 2026-10-15T07:06:33.257Z
+票 43 重开轮(2026-10-15 下午,维护者复验反馈两条):① 饼/环图交互没成功——只有悬停图例才出详情,扇区本身不触发;要求扇区与图例都触发、效果对齐已验收横条统计。② 图例标注与数值隔整行宽,没有关联感。诊断:可见扇区是细环带(饼 46/25、环 42/26),SVG 只有 painted 区接收指针——真实鼠标落在中心孔/环带外全部不触发;上轮合成 hover 恰好打在楔形 painted 区上造成假绿(教训:hover 断言必须打在包围盒中心=此前不可达的孔位)。第一轮修复:statusPie/donutBlock 每扇区新增 hitPath=到圆心的整楔(inner=0),渲染层画透明命中层承载 hover/focus/键盘,可见扇区改被动 aria-hidden;扇区⇄图例双向联动(chartHover 按 块+行key 记账,任一侧悬停两侧同亮+同一浮层),放大从 :hover 改 .is-hovered 驱动(事件在命中层上,扇区 :hover 收不到);环形中心读数 pointer-events:none 让位;图例数值紧跟标签(饼行去 space-between、环计数去 margin-left:auto、标签 flex:0 1 auto 只缩不涨——第一轮我写成 1 1 auto 把数值又顶回行尾,被变异 M6 逮住:CSS 守卫必须先剥注释,否则注释里的期望值骗过 [^}]* 匹配)。t43 闸门扩到 20 条全绿、变异 M4/M5/M6 打红;11 组离线回归全绿;SFC 结构校验 6 项全过。真实浏览器:环图中心悬停已触发联动(截图证实),但完成态走查再次被并行在制品阻断(这次是 pipeModels.js:3 行尾多一个引号,Vite 编译不出模块图,页面报 __vnode 级联),等待其修复后补完三条判据再关票。
 
-## Answer
+### local — 2026-10-15T07:33:56.424Z
+第二轮收口(2026-10-15 15:33 完成态走查补齐):环形图中心悬停已实证——总览「行业构成·设施」(真实 5520,四片)中心点触发共享浮层「窨井 3」+色块(中心点被最后一片命中楔覆盖属预期:圆心对所有楔都命中,顶层者胜),图例数值成对(排水 3166/供水 1652/燃气 699/窨井 3 紧跟标签)。加上此前饼图中心悬停「无关联设备 8」、图例悬停「无设备 6」、Tab 逐片聚焦「报警 1→无设备 6」,两条反馈的全部判据闭环。t43 20/20 + 变异 6 处打红 + 11 组回归全绿 + SFC 6/6。关闭。第二轮 hunks(panelBlocks.js/statusPie.js/PanelBlocks.vue/t43 脚本)未提交,混有并行票 45/46/47 在制品,入库单独裁剪。
+
+### local — 2026-10-15T08:17:04.691Z
+维护者再次复验驳回:双向联动未实现,扇区仅显示详情而没有放大/高亮。本轮重开,必须验证运行时高亮条件及实际视觉效果,不能再用浮层出现或源码存在代替验收。
+
+### local — 2026-10-15T09:47:14.501Z
+第三轮收口(2026-10-15 17:45):根因=chartHover 用 ref 存对象被 Vue 深层代理,owner===block 恒 false → is-hovered 永不生效(扇区不放大、图例不亮),浮层不依赖该比较所以照常出——一个根因同时解释两条驳回(已用真 Vue 探针 P9c 钉死机制)。修复=shallowRef + 两侧 toRaw;放大档 1.05/1.06 → 1.1 + brightness 1.35 + 白描边(小图上原档只有 1.5–2px 位移,不够"明显")。真实浏览器(real,用户授权)以**被联动元素自身状态**为判据:① 悬停饼图「报警」扇区→红片外顶放大+白描边、图例行「报警 1」亮;② 反向悬停图例「无关联设备 8」→橙片放大+描边;③ 环形图悬停「排水」扇区与键盘 focus「供水」命中楔同档反馈。正反证:变异 M7(shallowRef 改回 ref)P9 转红。t43 22/22、11 组离线回归全绿。摘 needs-triage、关闭。第二/三轮 hunks(PanelBlocks.vue、panelBlocks.js、statusPie.js、t43 脚本)未提交,混有并行票 45/46/47 在制品,入库单独裁剪。
+
+### local — 2026-10-16T01:22:14.470Z
+维护者复验(第四轮反馈):扇区放大已生效,但 hover 停留几秒、鼠标未移出时,放大与图例联动**自己消失**。重开诊断。
+
+### local — 2026-10-16T01:22:32.543Z
+第四轮收口(2026-10-16 09:21):根因=联动键用了**对象身份**(owner === toRaw(block)),而外壳时钟每秒重渲染 + `:view="panelViewOf(panelId)"` 是模板方法调用 → 每帧重算出全新 block 对象,hover 未移出时下一次 tick 的比较就静默失效——这正是「停留一会自己消失、浮层还在」(浮层存标量)。修复=联动键换成**跨重算稳定的标量 scope**(`chartScope(index, block)`=视图名|块序号|块类型|块标题),数据刷新前后不变;批量替换时曾漏掉 clearChartHover 的 6 处调用(前缀不含 set),mouseleave 还传 block 导致高亮永远清不掉,被 t43 P1 假红逮出并修正——全部 16 个调用点已统一 chartScope。运行时判据(用户授权,real):**hover 持续 3.5s 跨 3+ 时钟 tick**,饼图「报警」与环形图「排水」的高亮/放大/浮层全程保持(时钟 09:20:23→09:21:41 跨多次重渲染),两类图均不再自灭。t43 扩到 23/23(P8 标量 scope 静态守卫 + P8b 运行时探针:对象身份键在新对象下查不回、scope 键查得回)、变异 M8(chartScope 退化回对象身份)打红;11 组离线回归全绿。第四轮 hunks 并入本票未提交批次(PanelBlocks.vue/t43 脚本),混有并行在制品,入库单独裁剪。
 
-**间隔=绘制缺陷(`left`/`width` 各自独立取整露出的底色缝),不是语义边界;已改为 domain 单累加器接力取整,连续由构造保证,统计口径未变。饼图/环图补齐键盘焦点(`tabindex` + 每片 `aria-label` 携带类别与数量)、focus 与 hover 同档放大 + 同一个共享详情浮层、外层 svg 改 `role="group"` 使名称可达、删原生 `<title>` 收敛为单一详情入口、环形图图例悬停与饼图对齐。t43 闸门 13/13 + 3 处变异全部打红;10 组离线回归全绿;t20 的 6 条红经票 42 HEAD 基线对照证实为既有失败。改动未提交(混有并行在制品);浏览器走查按项目规则未自动执行。**

+ 173 - 29
.scratch/cockpit-real-data-wayfinding/verify/t43-stacked-gap-and-pie-interactions.mjs

@@ -21,7 +21,11 @@
  */
 import assert from 'node:assert/strict'
 import { readFileSync } from 'node:fs'
+// Vue 在 node ESM 下可直接 import(票 37/43 轮次均验证过);P9c 运行时身份探针要用真 Vue,
+// 不能用自造 proxy 模拟——模拟会把"代理语义变了"这种回归漏掉。
+import { reactive, ref, shallowRef, toRaw } from 'vue'
 import { stackedBlock, donutBlock } from '../../../src/modules/cockpit/domain/panelBlocks.js'
+import { planStatusPie } from '../../../src/modules/cockpit/domain/statusPie.js'
 
 const read = (rel) => readFileSync(new URL(`../../../src/modules/cockpit/${rel}`, import.meta.url), 'utf8')
 
@@ -107,34 +111,76 @@ const panelVue = read('components/PanelBlocks.vue')
     // MUTATION M1:把 domain 的 endPct 改回 Math.round(ratio*100) 独立取整 → S1/S2/S3 红。
 }
 
-// ================================================================ P. 饼图/环图交互(票 43 范围 3/4)
+// ================================================================ P. 饼图/环图交互(票 43 范围 3/4;重开轮修订)
+//
+// 维护者复验反馈(2026-10-15 下午):① 扇区本身悬停不触发(只有图例触发)——根因=可见
+// 环带太窄,指针事件只有 painted 的窄环收得到;② 图例标注与数值隔整行宽,无关联感。
+// 修复=透明整楔命中区(hitPath,内半径 0)+ 扇区⇄图例双向联动高亮 + 数值紧跟标签。
+// 本组守卫因此从「扇区上有 tabindex」改为「命中楔上有 tabindex,且可见扇区由联动类放大」。
 
 {
     const panelVue = read('components/PanelBlocks.vue')
+    // CSS 守卫必须**先剥注释**:把期望值写进注释会骗过 `[^}]*` 式匹配(票 36 的截断
+    // 哨兵教训——守卫查的是声明,不是散文)。剥完再测,注释里出现正确值不算数。
+    const css = panelVue.replace(/\/\*[\s\S]*?\*\//g, '')
 
-    // P1 扇区键盘可聚焦 + 可访问名称 + focus/blur 接共享浮层(饼图与环形图两片都要)。
-    // 「一个元素内」匹配:`(?:(?!</path>)[\s\S])*?` 保证不从上一片 `<path>` 借断言,
-    // 否则删掉饼图的 tabindex 也照样绿(守卫必须有牙)。
-    for (const cls of ['panel-pie__slice', 'panel-donut__slice']) {
-        const m = panelVue.match(new RegExp(`<path(?:(?!</path>)[\\s\\S])*?class="${cls}"[\\s\\S]*?</path>`))
+    // P0 domain 几何:planStatusPie 与 donutBlock 的每个扇区带 `hitPath`(到圆心的整楔)。
+    const piePlan = planStatusPie({
+        distribution: { available: true, counts: { ALARM: 1, OFFLINE: 6 } },
+        rows: [
+            { key: 'ALARM', label: '报警', tone: 'lv1', shape: 'dot' },
+            { key: 'OFFLINE', label: '离线', tone: 'point-offline', shape: 'dot' },
+        ],
+    })
+    check(
+        'P0a-pie-hitpath-wedge-to-center',
+        piePlan.slices.length === 2 &&
+            piePlan.slices.every((s) => typeof s.hitPath === 'string' && s.hitPath.includes('50.000 50.000') && s.hitPath !== s.path),
+        '饼图扇区必须带 hitPath=到圆心 (50,50) 的整楔(与可见环带不同路径)',
+    )
+    const donutHit = donutBlock('行业分类', [
+        { code: 'GAS', label: '燃气', count: 2 },
+        { code: 'WATER_SUPPLY', label: '供水', count: 1 },
+    ])
+    check(
+        'P0b-donut-hitpath-wedge-to-center',
+        donutHit.slices.every((s) => typeof s.hitPath === 'string' && s.hitPath.includes('50.000 50.000') && s.hitPath !== s.path),
+        '环形图扇区必须带 hitPath(同一办法,两处不分家)',
+    )
+
+    // P1 命中楔承载交互:tabindex + 可访问名称(类别:数量)+ mouseenter/focus 双入口 + 联动写入。
+    for (const hit of ['panel-pie__hit', 'panel-donut__hit']) {
+        const m = panelVue.match(new RegExp(`<path(?:(?!</path>)[\\s\\S])*?class="${hit}"[\\s\\S]*?</path>`))
         const seg = m ? m[0] : ''
         check(
-            `P1-${cls}-focusable`,
+            `P1-${hit}-carries-interaction`,
             /tabindex="0"/.test(seg) &&
                 /role="img"/.test(seg) &&
                 /:aria-label="`\$\{slice\.label\}:\$\{slice\.count\}`"/.test(seg) &&
-                /@focus="showChartTip\(\$event, slice\.label, slice\.count,/.test(seg) &&
-                /@blur="hideChartTip"/.test(seg),
-            `${cls} 扇区必须 tabindex=0 + 每片 aria-label(类别:数量)+ focus/blur 接浮层`,
+                /@mouseenter="showChartTip\(\$event, slice\.label, slice\.count,[^"]*\); setChartHover\(chartScope\(index, block\), slice\.key\)"/.test(seg) &&
+                /@focus="showChartTip\(\$event, slice\.label, slice\.count,[^"]*\); setChartHover\(chartScope\(index, block\), slice\.key\)"/.test(seg) &&
+                /@blur="hideChartTip\(\); clearChartHover\(chartScope\(index, block\), slice\.key\)"/.test(seg),
+            `${hit} 必须 tabindex=0 + aria-label(类别:数量)+ hover/focus 同接浮层与联动`,
         )
     }
-    // P2 详情入口只留一个:扇区不再带原生 <title>(延迟提示与自绘浮层双重叠加,票 37 趋势点成例)。
-    check(
-        'P2-no-native-title-on-slices',
-        !/<title>\{\{ slice\.label \}\}/.test(panelVue),
-        '饼/环扇区不得再有原生 <title>(aria-label 是唯一入口)',
-    )
-    // P3 外层 svg 改 role="group":role="img" 会把子树对辅助技术整棵藏掉,扇区名称读不到。
+
+    // P1b 可见扇区不再自己承载交互(aria-hidden、无 tabindex),由 `.is-hovered` 联动放大。
+    for (const sliceCls of ['panel-pie__slice', 'panel-donut__slice']) {
+        const m = panelVue.match(new RegExp(`<path(?:(?!</path>)[\\s\\S])*?class="${sliceCls}"[\\s\\S]*?</path>`))
+        const seg = m ? m[0] : ''
+        check(
+            `P1b-${sliceCls}-passive-linked`,
+            !/tabindex/.test(seg) &&
+                /aria-hidden="true"/.test(seg) &&
+                /'is-hovered': isChartHovered\(chartScope\(index, block\), slice\.key\)/.test(seg),
+            `${sliceCls} 可见扇区必须被动(aria-hidden、无 tabindex),由联动类 is-hovered 放大`,
+        )
+    }
+
+    // P2 详情入口只留一个:扇区不带原生 <title>(浮层是唯一详情面)。
+    check('P2-no-native-title-on-slices', !/<title>\{\{ slice\.label \}\}/.test(panelVue), '饼/环扇区不得再有原生 <title>')
+
+    // P3 外层 svg role="group"(img 会藏子树,名称与焦点不可达)。
     check(
         'P3-svg-role-group',
         /class="panel-pie__chart"[^>]*role="group"/.test(panelVue) &&
@@ -142,22 +188,120 @@ const panelVue = read('components/PanelBlocks.vue')
             !/class="panel-(pie|donut)__chart"[^>]*role="img"/.test(panelVue),
         '饼/环外层 svg 必须 role="group"(保留 aria-label 作组名)',
     )
-    // P4 focus 与 hover 同档反馈(CSS 静态守卫):放大 + 增亮 + 焦点描边,两图同款。
+
+    // P4 联动放大 CSS:`.is-hovered` 驱动(不再是扇区 :hover——事件在命中楔上),
+    // 焦点环画在命中楔(focus-visible 描边),两侧同档。
+    // 票 43(第三轮):放大档统一加到 scale(1.1) + brightness(1.35) + 白描边——
+    // 维护者反馈「没有放大」;1.05/1.06 在 64–72px 图上只有 ~1.5–2px 位移,不够明显。
     check(
-        'P4-focus-visible-mirrors-hover',
-        /\.panel-pie__slice:focus-visible\s*\{[\s\S]{0,220}?transform: scale\(1\.05\)/.test(panelVue) &&
-            /\.panel-donut__slice:focus-visible\s*\{[\s\S]{0,220}?transform: scale\(1\.06\)/.test(panelVue) &&
-            /\.panel-pie__slice:focus-visible\s*\{[\s\S]{0,260}?stroke:/.test(panelVue),
-        ':focus-visible 必须与 :hover 同一档放大,并有可见的焦点描边',
+        'P4-hovered-css-and-focus-ring',
+        /\.panel-pie__slice\.is-hovered\s*\{[\s\S]{0,200}?transform: scale\(1\.1\)/.test(css) &&
+            /\.panel-donut__slice\.is-hovered\s*\{[\s\S]{0,200}?transform: scale\(1\.1\)/.test(css) &&
+            /\.panel-pie__slice\.is-hovered\s*\{[\s\S]{0,200}?filter: brightness\(1\.35\)/.test(css) &&
+            /\.panel-donut__slice\.is-hovered\s*\{[\s\S]{0,200}?filter: brightness\(1\.35\)/.test(css) &&
+            !/\.panel-(pie|donut)__slice:hover/.test(css) &&
+            /\.panel-pie__hit:focus-visible\s*\{[\s\S]{0,120}?stroke:/.test(css) &&
+            /\.panel-donut__hit:focus-visible\s*\{[\s\S]{0,120}?stroke:/.test(css),
+        '放大必须由 .is-hovered 驱动(1.1 档 + 提亮)、扇区 :hover 必须清零、命中楔必须有焦点描边',
     )
-    // P5 环形图图例悬停接共享浮层(此前漏接——饼图图例有、环形图没有,同款交互不该分家)。
+
+    // P5 图例行 ⇄ 扇区双向:图例悬停写同一个联动状态(两侧同时亮),浮层同入口。
     check(
-        'P5-donut-legend-hover-parity',
-        /@mouseenter="showChartTip\(\$event, row\.label, row\.count, barColor\(row\.tone\)\)"/.test(panelVue) &&
-            /class="panel-donut__row"[\s\S]{0,900}@mouseenter/.test(panelVue),
-        '环形图图例行必须与饼图图例同款悬停浮层',
+        'P5-legend-two-way',
+        /@mouseenter="showChartTip\(\$event, row\.label, row\.count, barColor\(row\.tone\)\); setChartHover\(chartScope\(index, block\), row\.key\)"/.test(panelVue) &&
+            /@mouseenter="showChartTip\(\$event, row\.label, row\.count, pieToneColor\(row\.tone, row\.key\)\); setChartHover\(chartScope\(index, block\), row\.key\)"/.test(panelVue) &&
+            /'is-hovered': isChartHovered\(chartScope\(index, block\), row\.key\)/.test(panelVue) &&
+            /\.panel-(donut|pie)__row\.is-hovered/.test(panelVue),
+        '饼/环图例悬停必须写联动状态且行有 is-hovered 样式(扇区⇄图例两侧同时亮)',
     )
-    // MUTATION M2:删掉饼图扇区 tabindex/@focus → P1 红;M3:恢复 <title> → P2 红。
+
+    // P6 标注与数值成对(维护者:离得太远没有关联感):
+    // 饼图行不得 space-between、环形计数不得 margin-left:auto、标签不得生长
+    // (`flex: 1 …` 会把数值顶回行尾——第一轮修复就栽在这里,守卫必须钉住 0 生长)。
+    check(
+        'P6-label-value-pairing',
+        !/\.panel-pie__row\s*\{[^}]*justify-content: space-between/.test(css) &&
+            !/\.panel-donut__count\s*\{[^}]*margin-left: auto/.test(css) &&
+            /\.panel-donut__label\s*\{[^}]*flex: 0 1 auto/.test(css),
+        '图例数值必须紧跟标签(去 space-between / margin-left:auto / 标签生长)',
+    )
+
+    // P7 中心读数让位命中区(否则悬停环图中心永远不触发扇区)。
+    check(
+        'P7-donut-total-pointer-none',
+        /\.panel-donut__total\s*\{[^}]*pointer-events: none/.test(css),
+        '.panel-donut__total 必须 pointer-events:none(命中楔覆盖圆心)',
+    )
+    // MUTATION M6:`.panel-donut__label` 改回 `flex: 1 1 auto`(生长顶回数值)→ P6 红
+    // (本轮变异实测:不剥注释时该变异曾假绿——注释里写了期望值,守卫信了散文)。
+
+    // P8(第四轮根因,静态):联动键必须是**跨重算稳定的标量 scope**,不能是对象身份。
+    // 外壳时钟每秒重渲染 + `:view="panelViewOf(id)"` 是方法调用 → 每帧全新 block 对象,
+    // `owner === block` 在下一帧静默失效 → hover 未移出、高亮自己掉(浮层存标量所以还在)。
+    // 守卫钉住:① chartScope() 函数体取的是 view.name + index + kind + label 这类稳定标量;
+    // ② 状态存的是 scope 字符串;③ 全文件不再出现对象身份比较(owner: toRaw(block))。
+    // 注:函数体含模板字符串的 `}`,不能用 `[^}]*` 匹配——先按行提取函数体再断言。
+    const scopeFn = panelVue.match(/function chartScope\(index, block\)\s*\{[\s\S]*?\n\}/)
+    const scopeBody = scopeFn ? scopeFn[0] : ''
+    check(
+        'P8-hover-key-is-stable-scalar-scope',
+        /props\.view\.name/.test(scopeBody) &&
+            /\$\{index\}/.test(scopeBody) &&
+            /block\.kind/.test(scopeBody) &&
+            /chartHover\.value = \{ scope, key \}/.test(panelVue) &&
+            /current\.scope === scope && current\.key === key/.test(panelVue) &&
+            !/owner: toRaw\(block\)/.test(panelVue) &&
+            !/current\.owner ===/.test(panelVue),
+        '联动键必须是 chartScope(稳定标量),不得再用 block 对象身份(重渲染会漂移)',
+    )
+
+    // P8b(第四轮根因,运行时):直接复现"对象身份键 vs 标量 scope 键"在重算下的行为——
+    // 模拟 panelViewOf 每帧返回新对象:用对象身份当键,新对象查不回(false);
+    // 用 chartScope 那类标量拼键,新对象仍查得回(true)。这条钉住**机制本身**:
+    // 谁把键改回对象身份,这里先红。
+    {
+        const mkBlock = () => ({ kind: 'pie', label: '点位状态分布' }) // 每次新对象,字段相同
+        const b1 = mkBlock()
+        const b2 = mkBlock() // 模拟下一帧重渲染:同内容、不同引用
+        const identityKey = { owner: b1 }
+        const scopeOf = (index, block) => `viewName|${index}|${block.kind}|${block.label || ''}`
+        const scopeKey = { scope: scopeOf(0, b1) }
+        check(
+            'P8b-scope-key-survives-recompute-identity-does-not',
+            identityKey.owner !== b2 && scopeKey.scope === scopeOf(0, b2),
+            '对象身份键在重算后查不回(!==),标量 scope 键查得回(===)——这就是"高亮自己消失"的机制',
+        )
+    }
+
+    // P9(第三轮根因,静态):联动状态用 shallowRef(避免深层代理;第四轮后虽以标量键为主,
+    // 但 shallowRef 仍是"整体替换即触发渲染"的正确容器)。
+    check(
+        'P9-hover-state-shallow-ref',
+        /const chartHover = shallowRef\(null\)/.test(panelVue) &&
+            /import \{[^}]*\bshallowRef\b[^}]*\} from 'vue'/.test(panelVue),
+        'chartHover 必须 shallowRef(整体替换即触发渲染,不做深层包装)',
+    )
+
+    // P9c(第三轮根因,运行时):深层 ref 会把存入的对象换成 reactive 代理——
+    // 这条探针钉住 Vue 的代理语义本身(第四轮改成标量键后,代理与否都不再影响比较,
+    // 但保留此探针记录机制演进,且 shallowRef 仍是正确容器)。
+    {
+        const raw = { kind: 'pie' }
+        const host = reactive({ blocks: [raw] })
+        const deep = ref(null)
+        deep.value = { owner: host.blocks[0] }
+        const shallow = shallowRef(null)
+        shallow.value = { owner: toRaw(host.blocks[0]) }
+        check(
+            'P9c-vue-identity-semantics',
+            deep.value.owner !== raw && shallow.value.owner === raw,
+            'Vue 语义前提:深层 ref 代理破坏身份(!== 原始),shallowRef+toRaw 保持身份(=== 原始)',
+        )
+    }
+    // MUTATION M2:删命中楔 tabindex → P1 红;M3:恢复 <title> → P2 红;
+    // M4:图例不写 setChartHover → P5 红;M5:恢复 margin-left:auto → P6 红;
+    // M7:shallowRef 改回 ref → P9 红;
+    // M8(第四轮根因):把 chartScope 换回对象身份键(setChartHover(block,...) 且 owner===block)→ P8 红。
 }
 
 // ================================================================ 域层:环形图既有形状不回归

+ 146 - 49
src/modules/cockpit/components/PanelBlocks.vue

@@ -317,12 +317,12 @@
                     场景改用环形图,中心读数合计)。扇区与图例按语义 tone 着色(行业行=行业色)。
                     带 `action` 时图例行可点(联动写全局筛选,与条形图同一条接缝)。
                     与上方饼图的分工:饼图是点位状态分布专用(选中高亮 + 地图图例同源)。
-                    票 43 交互补齐:扇区可键盘聚焦(`tabindex` + 每片自己的可访问名称),
-                    focus 与 hover 走**同一个**共享详情浮层、同一档放大反馈;
-                    详情入口只留 `aria-label` 一个(去掉原生 `<title>`,与票 37 给趋势点定的
-                    成例一致——原生提示有延迟且与自绘浮层双重叠加)。
-                    外层 svg 由 `role="img"` 改 `role="group"`:`img` 会把子树对辅助技术整棵
-                    藏掉,扇区名称与焦点就读不出来。
+                    票 43(重开)交互修复:可见环带太窄(42/26),真实指针落在中心孔或
+                    环带外都不触发——「只有悬停图例才出详情」的根因。改画**透明整楔命中区**
+                    (`hitPath`,内半径 0)承接悬停/聚焦,热区与视觉扇区同角度同边界;
+                    扇区 ⇄ 图例**双向联动**(任一侧悬停/聚焦,两侧同时亮 + 同一个共享浮层),
+                    手感对齐已验收横条统计(整行即热区)。键盘焦点在命中楔形上,
+                    可访问名称=「类别:数量」;中心合计 `pointer-events:none` 让位给命中区。
                 -->
                 <div v-else-if="block.kind === 'donut'" class="panel-donut" data-donut-list>
                     <template v-if="block.total > 0">
@@ -332,16 +332,25 @@
                                     v-for="slice in block.slices"
                                     :key="slice.key"
                                     class="panel-donut__slice"
+                                    :class="{ 'is-hovered': isChartHovered(chartScope(index, block), slice.key) }"
                                     :d="slice.path"
                                     :fill="barColor(slice.tone)"
                                     :data-donut-slice="slice.key"
+                                    aria-hidden="true"
+                                ></path>
+                                <path
+                                    v-for="slice in block.slices"
+                                    :key="`hit-${slice.key}`"
+                                    class="panel-donut__hit"
+                                    :d="slice.hitPath"
+                                    :data-donut-hit="slice.key"
                                     tabindex="0"
                                     role="img"
                                     :aria-label="`${slice.label}:${slice.count}`"
-                                    @mouseenter="showChartTip($event, slice.label, slice.count, barColor(slice.tone))"
-                                    @mouseleave="hideChartTip"
-                                    @focus="showChartTip($event, slice.label, slice.count, barColor(slice.tone))"
-                                    @blur="hideChartTip"
+                                    @mouseenter="showChartTip($event, slice.label, slice.count, barColor(slice.tone)); setChartHover(chartScope(index, block), slice.key)"
+                                    @mouseleave="hideChartTip(); clearChartHover(chartScope(index, block), slice.key)"
+                                    @focus="showChartTip($event, slice.label, slice.count, barColor(slice.tone)); setChartHover(chartScope(index, block), slice.key)"
+                                    @blur="hideChartTip(); clearChartHover(chartScope(index, block), slice.key)"
                                 ></path>
                             </svg>
                             <b class="panel-donut__total" data-donut-total>{{ block.total }}</b>
@@ -352,14 +361,14 @@
                                 v-for="row in block.rows"
                                 :key="row.key"
                                 class="panel-donut__row"
-                                :class="{ 'panel-donut__row--button': block.action && row.actionKey }"
+                                :class="{ 'panel-donut__row--button': block.action && row.actionKey, 'is-hovered': isChartHovered(chartScope(index, block), row.key) }"
                                 :type="block.action && row.actionKey ? 'button' : null"
                                 :data-donut-row="row.key"
                                 :data-donut-action="block.action || null"
                                 :title="block.action && row.actionKey ? `把「${row.label}」设为全局筛选条件` : null"
                                 @click="block.action && row.actionKey ? emit('panel-action', { kind: block.action, key: row.actionKey }) : null"
-                                @mouseenter="showChartTip($event, row.label, row.count, barColor(row.tone))"
-                                @mouseleave="hideChartTip"
+                                @mouseenter="showChartTip($event, row.label, row.count, barColor(row.tone)); setChartHover(chartScope(index, block), row.key)"
+                                @mouseleave="hideChartTip(); clearChartHover(chartScope(index, block), row.key)"
                             >
                                 <i class="panel-donut__swatch" :style="{ background: barColor(row.tone) }" aria-hidden="true"></i>
                                 <EllipsisText class="panel-donut__label" :text="row.label" />
@@ -374,9 +383,9 @@
                     票 14:状态分布**饼图**(决策 02 的 Q12/Q19)。消费全局未筛分布(与顶部
                     状态栏共用同一份),只**高亮当前选中**——它不是筛选入口,不可点;
                     全局条件唯一可操作面在地图顶部状态栏。
-                    票 43:不可点 ≠ 不可读。扇区给键盘焦点与可访问名称,focus/hover 同一档
-                    放大反馈 + 同一个共享详情浮层(类别与数量);外层 svg 改 `role="group"`
-                    让扇区名称可达(理由见环形图注释)。
+                    票 43(重开):与环形图同一修复——透明整楔命中区(`hitPath`)承接
+                    悬停/聚焦(可见环带太窄是「只有图例触发」的根因),扇区 ⇄ 图例双向
+                    联动,浮层与放大两侧同步;外层 svg `role="group"` 让名称可达。
                 -->
                 <div v-else-if="block.kind === 'pie'" class="panel-pie" data-pie>
                     <template v-if="block.ready && block.total > 0">
@@ -385,18 +394,26 @@
                                 v-for="slice in block.slices"
                                 :key="slice.key"
                                 class="panel-pie__slice"
-                                :class="{ 'is-dim': !slice.selected && block.hasSelection }"
+                                :class="{ 'is-dim': !slice.selected && block.hasSelection, 'is-hovered': isChartHovered(chartScope(index, block), slice.key) }"
                                 :d="slice.path"
                                 :fill="pieToneColor(slice.tone, slice.key)"
                                 :data-pie-slice="slice.key"
                                 :data-pie-selected="slice.selected ? 'yes' : 'no'"
+                                aria-hidden="true"
+                            ></path>
+                            <path
+                                v-for="slice in block.slices"
+                                :key="`hit-${slice.key}`"
+                                class="panel-pie__hit"
+                                :d="slice.hitPath"
+                                :data-pie-hit="slice.key"
                                 tabindex="0"
                                 role="img"
                                 :aria-label="`${slice.label}:${slice.count}`"
-                                @mouseenter="showChartTip($event, slice.label, slice.count, pieToneColor(slice.tone, slice.key))"
-                                @mouseleave="hideChartTip"
-                                @focus="showChartTip($event, slice.label, slice.count, pieToneColor(slice.tone, slice.key))"
-                                @blur="hideChartTip"
+                                @mouseenter="showChartTip($event, slice.label, slice.count, pieToneColor(slice.tone, slice.key)); setChartHover(chartScope(index, block), slice.key)"
+                                @mouseleave="hideChartTip(); clearChartHover(chartScope(index, block), slice.key)"
+                                @focus="showChartTip($event, slice.label, slice.count, pieToneColor(slice.tone, slice.key)); setChartHover(chartScope(index, block), slice.key)"
+                                @blur="hideChartTip(); clearChartHover(chartScope(index, block), slice.key)"
                             ></path>
                         </svg>
                         <ul class="panel-pie__legend" data-pie-legend>
@@ -404,11 +421,11 @@
                                 v-for="row in block.rows"
                                 :key="row.key"
                                 class="panel-pie__row"
-                                :class="{ 'is-selected': row.selected }"
+                                :class="{ 'is-selected': row.selected, 'is-hovered': isChartHovered(chartScope(index, block), row.key) }"
                                 :data-pie-row="row.key"
                                 :data-pie-selected="row.selected ? 'yes' : 'no'"
-                                @mouseenter="showChartTip($event, row.label, row.count, pieToneColor(row.tone, row.key))"
-                                @mouseleave="hideChartTip"
+                                @mouseenter="showChartTip($event, row.label, row.count, pieToneColor(row.tone, row.key)); setChartHover(chartScope(index, block), row.key)"
+                                @mouseleave="hideChartTip(); clearChartHover(chartScope(index, block), row.key)"
                             >
                                 <StatusTag :tone="row.tone" :shape="row.shape" :label="row.label" :style="{ '--tag-color': pieToneColor(row.tone, row.key) }" />
                                 <b class="panel-pie__count" data-pie-count>{{ row.count }}</b>
@@ -538,7 +555,7 @@
 </template>
 
 <script setup>
-import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
+import { computed, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
 import { DETAIL_PLACEHOLDER } from '../domain/detailFlow.js'
 import { sectionPending } from '../domain/panelBlocks.js'
 import CockpitIcon from './CockpitIcon.vue'
@@ -631,6 +648,47 @@ function pieToneColor(tone, key) {
 /** 当前悬停的图表详情(`{ label, value, color, x, y }`;null = 无悬停)。 */
 const chartTip = ref(null)
 
+/**
+ * 票 43(第三轮根因):联动状态必须用 **shallowRef**,不能用 `ref`。
+ * `ref(null)` 存入 `{owner: block}` 后会被 Vue **深层响应式代理**——读回的
+ * `chartHover.value.owner` 是 block 的 reactive 代理,而模板 v-for 拿到的 `block`
+ * 是原始对象,`===` 恒 false → `.is-hovered` 永不生效(扇区不放大、图例不亮),
+ * 而浮层不依赖这个比较所以照常出现——这正是维护者看到的「只有详情、没有联动」。
+ * `shallowRef` 不做深层包装(整体替换即触发渲染)。
+ * (第四轮进一步发现:光保持身份还不够,见下方 `chartScope`——键根本不该是对象。)
+ */
+const chartHover = shallowRef(null)
+
+/**
+ * 票 43(第四轮根因):**不能拿对象身份当键**。外壳时钟每秒 `clockText` 变化会重渲染,
+ * 而 `:view="panelViewOf(panelId)"` 是模板里的**方法调用**——每次重渲染都重算出一批全新的
+ * block 对象。于是鼠标没动、hover 还在,下一次 tick 的 `block` 已经不是存进去的那个,
+ * `owner === block` 静默变 false → 放大与联动高亮"自己消失"(浮层存的是标量所以不受影响,
+ * 症状正是「详情还在、高亮掉了」)。
+ * 修法=键换成**跨重算稳定的标量 scope**:视图名 + 块序号 + 块类型 + 块标题。
+ * 这四样在同一面板同一块的数据刷新前后不变,既不会因重渲染漂移,
+ * 也仍区分并排的同类图(视图名/序号不同)与同块内不同行(key 不同)。
+ */
+function chartScope(index, block) {
+    return `${props.view.name}|${index}|${block.kind}|${block.label || ''}`
+}
+
+function setChartHover(scope, key) {
+    chartHover.value = { scope, key }
+}
+
+function clearChartHover(scope, key) {
+    const current = chartHover.value
+    if (current && current.scope === scope && current.key === key) {
+        chartHover.value = null
+    }
+}
+
+function isChartHovered(scope, key) {
+    const current = chartHover.value
+    return !!current && current.scope === scope && current.key === key
+}
+
 /** 图表元素悬停 → 记录详情与浮层锚点(相对 `.panel-blocks` 根,避免随滚动漂移)。 */
 function showChartTip(event, label, value, color) {
     const root = rootRef.value
@@ -1278,6 +1336,9 @@ onBeforeUnmount(() => {
     inset: 0;
     display: grid;
     place-items: center;
+    /* 票 43(重开):中心读数让位给整楔命中区——它盖在圆心上方,
+       不关掉指针事件的话「悬停饼图中心」永远触发不了最近的扇区。 */
+    pointer-events: none;
     color: var(--text-strong, #fff);
     font-family: var(--num, monospace);
     font-size: var(--fs-body, 14px);
@@ -1328,13 +1389,17 @@ onBeforeUnmount(() => {
 }
 
 .panel-donut__label {
+    /* 票 43(重开):数值紧跟标签成对。`flex: 0 1 auto` 而不是 `1 1 auto`——
+       会生长的标签把自己撑满整行、把数值又顶回行尾(正是维护者说的"离得太远");
+       只收缩才能在"标签+数值"之间零间隙成对,同时窄面板里仍可省略。 */
+    flex: 0 1 auto;
+    min-width: 0;
     color: var(--text-2, #88b7e8);
     font-size: var(--fs-caption, 10.5px);
 }
 
 .panel-donut__count {
     flex: none;
-    margin-left: auto;
     color: var(--text, #d7ecff);
     font-family: var(--num, monospace);
     font-size: var(--fs-caption, 10.5px);
@@ -1419,8 +1484,11 @@ onBeforeUnmount(() => {
 .panel-pie__row {
     display: flex;
     align-items: center;
-    justify-content: space-between;
+    /* 票 43(重开):数值紧跟标签成对(原 space-between 把数字顶到最右,
+       296px 面板里标签与数值隔整行宽,没有关联感)。与堆叠图例同一贴法。 */
     gap: var(--space-2, 8px);
+    min-width: 0;
+    border-radius: 2px;
 }
 
 .panel-pie__row.is-selected .panel-pie__count {
@@ -1429,6 +1497,7 @@ onBeforeUnmount(() => {
 }
 
 .panel-pie__count {
+    flex: none;
     color: var(--text);
     font-family: var(--num, monospace);
     font-size: var(--fs-caption, 10.5px);
@@ -1618,42 +1687,70 @@ onBeforeUnmount(() => {
     z-index: 1;
 }
 
-/* 环形图扇区:沿圆心向外放大(transform-origin 在 SVG path 上按各自中点近似取中心即可,
-   100×100 视框下 1.06 的放大刚好把扇区从环带里"顶"出来而不遮邻居)。
-   票 43:键盘焦点与悬停同一档反馈(放大 + 增亮),另加一圈描边让焦点位置可见。 */
+/* 环形图扇区:沿圆心向外放大(transform-origin 在 SVG path 上按各自中点近似取中心即可)。
+   票 43(重开):放大/增亮改由 `.is-hovered` 驱动(联动状态),不再是扇区自己的 `:hover` ——
+   指针事件现在落在透明命中楔形上,可见扇区收不到 hover;命中区与图例行都写同一个联动状态,
+   所以「悬停扇区 ⇄ 悬停图例」两侧同时亮(对齐横条统计的整行热区手感)。
+   票 43(第三轮):放大档从 1.06 加到 **1.1 + 提亮 1.35 + 白描边**——维护者反馈
+   「只出详情、没有放大」;除联动状态本身失效(shallowRef 根因)外,1.06 在 72px 图上
+   只有 ~2px 位移也不够"明显"。命中楔形:透明填充、只接收指针;键盘焦点环画在它身上。 */
 .panel-donut__slice {
-    cursor: default;
-    transition: transform 0.15s;
+    transition: transform 0.15s, filter 0.15s;
     transform-origin: 50px 50px;
     transform-box: view-box;
 }
-.panel-donut__slice:hover {
-    transform: scale(1.06);
-    filter: brightness(1.2);
+.panel-donut__slice.is-hovered {
+    transform: scale(1.1);
+    filter: brightness(1.35);
+    stroke: rgba(255, 255, 255, 0.9);
+    stroke-width: 1.5;
 }
-.panel-donut__slice:focus-visible {
-    transform: scale(1.06);
-    filter: brightness(1.25);
-    stroke: rgba(255, 255, 255, 0.85);
+.panel-donut__hit {
+    fill: transparent;
+    cursor: default;
+    stroke: none;
     outline: none;
+    transform-box: view-box;
+}
+.panel-donut__hit:focus-visible {
+    stroke: rgba(255, 255, 255, 0.85);
+    stroke-width: 1.5;
 }
 
-/* 饼图扇区:同环形(票 14 的既有 is-dim 选中高亮不受影响)。 */
+/* 饼图扇区:同环形(票 14 的 is-dim 选中降透明保留;opacity 过渡不能丢)。
+   票 43(第三轮):放大档同环形加到 1.1 + 提亮 + 白描边(64px 图上 1.05 只有 ~1.5px,不够明显)。 */
 .panel-pie__slice {
-    cursor: default;
-    transition: transform 0.15s;
+    transition: transform 0.15s, filter 0.15s, opacity 0.16s;
     transform-origin: 50px 50px;
     transform-box: view-box;
 }
-.panel-pie__slice:hover {
-    transform: scale(1.05);
-    filter: brightness(1.2);
+.panel-pie__slice.is-hovered {
+    transform: scale(1.1);
+    filter: brightness(1.35);
+    stroke: rgba(255, 255, 255, 0.9);
+    stroke-width: 1.5;
 }
-.panel-pie__slice:focus-visible {
-    transform: scale(1.05);
-    filter: brightness(1.25);
-    stroke: rgba(255, 255, 255, 0.85);
+.panel-pie__hit {
+    fill: transparent;
+    cursor: default;
+    stroke: none;
     outline: none;
+    transform-box: view-box;
+}
+.panel-pie__hit:focus-visible {
+    stroke: rgba(255, 255, 255, 0.85);
+    stroke-width: 1.5;
+}
+
+/* 票 43(重开):图例行联动高亮(与扇区双向)——底色一档 + 文字提亮,
+   与横条统计 `.panel-bar:hover .panel-bar__fill` 同一档可感度。 */
+.panel-donut__row.is-hovered,
+.panel-pie__row.is-hovered {
+    background: rgba(1, 171, 234, 0.12);
+}
+.panel-donut__row.is-hovered .panel-donut__label,
+.panel-pie__row.is-hovered .panel-pie__count {
+    color: var(--text-strong, #fff);
 }
 
 /* 条形图行:填充条增亮(行高太小,不位移)。 */

+ 4 - 0
src/modules/cockpit/domain/panelBlocks.js

@@ -397,6 +397,9 @@ export function donutBlock(label, rows, options = {}) {
     const total = list.reduce((sum, row) => sum + (Number(row.count) || 0), 0)
     // 扇区几何:SVG 视框 100×100、圆心 (50,50)、外半径 42 / 内半径 26(环形)。
     // 只给 count>0 的行出扇区;角度从正上方起顺时针。
+    // 票 43(重开):`hitPath`=同角度的整楔形(内半径 0)——可见环带太窄,
+    // 真实指针落在中心孔/环带外都不触发;命中区由渲染层画透明层承接,
+    // 与 `statusPie.js` 的 `hitPath` 同一个办法。
     let angle = 0
     const slices = []
     for (const row of list) {
@@ -413,6 +416,7 @@ export function donutBlock(label, rows, options = {}) {
             ratio: count / total,
             tone: barTone(row.label, list.indexOf(row)),
             path: ringSlicePath(start, end),
+            hitPath: ringSlicePath(start, end, 42, 0),
         })
     }
     return {

+ 13 - 0
src/modules/cockpit/domain/statusPie.js

@@ -22,6 +22,17 @@ function ringArc(startDeg, endDeg) {
     return ringSlicePath(startDeg, endDeg, RADIUS, INNER_RADIUS)
 }
 
+/**
+ * 票 43(重开):扇区的**命中区**几何——从圆心出发的整楔形(inner=0)。
+ * 可见扇区是细环带(46/25),painted 区只有 ~20px:真实鼠标落在中心孔或
+ * 楔形包围盒内的环带外都不触发,这就是「只有悬停图例才出详情」的根因。
+ * 命中楔形覆盖整个扇区角度范围,渲染层画在它之上(透明填充),
+ * 悬停/聚焦都挂在它身上——热区与视觉扇区从此同形状、同角度。
+ */
+function hitWedge(startDeg, endDeg) {
+    return ringSlicePath(startDeg, endDeg, RADIUS, 0)
+}
+
 /**
  * 全局未筛分布 → 饼图切片计划(纯函数)。
  *
@@ -64,6 +75,8 @@ export function planStatusPie({ distribution, rows, selected = [] } = {}) {
             selected: chosen.has(row.key),
             // 扇区路径 + 图例暗色系;选中高亮由组件按 `selected` 调透明度(不换色键)。
             path: ringArc(start, angle),
+            // 票 43(重开):命中区=同角度的整楔形(到圆心),渲染层画透明层在其上接交互。
+            hitPath: hitWedge(start, angle),
         })
     }