Compare commits

..
Author SHA1 Message Date
jiang 0ae0a41e61 feat(charts): manage investigation charts in a scrolling workspace
Generic Container CI/CD / test-build-publish (push) Successful in 2m7s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 2m8s
2026-11-10 17:56:23 +08:00
jiang 865491cb23 fix: show alert focus rings only for keyboard input
Radix focus transfers can preserve :focus-visible during mouse hover and
when returning focus to the alert trigger. Track actual input modality so
pointer interactions keep the thin hover border and keyboard navigation
retains its focus rings.

Cover desktop and narrow layouts, pointer selection, keyboard return focus,
and switching input modes with browser regressions.
2026-11-10 10:33:43 +08:00
81 changed files with 4783 additions and 478 deletions
+10 -2
View File
@@ -78,7 +78,15 @@ Agent 选择组件、内容顺序与受控语义,前端负责实际视觉、
短结果及说明直接在对话内阅读;包含表格、图表、补充表单或长正文的回答提供展开入口。窗口尺寸、滚动与放大由宿主管理,不作为模型输出样式。 短结果及说明直接在对话内阅读;包含表格、图表、补充表单或长正文的回答提供展开入口。窗口尺寸、滚动与放大由宿主管理,不作为模型输出样式。
`Chart` 的页签、图例、区间摘要与数据点详情由前端固定实现,Agent 只提供标题、初始图形类型、类别、序列和来源。鼠标切换折线、柱状和数据表时使用 180ms Motion 滑块与内容过渡;键盘操作与减少动态效果模式即时完成。数据点详情只在点击图中数据点,或聚焦图形后按回车、方向键、Home/End 选点时出现,关闭后焦点回到图形;数据表页签不重复展示区间摘要和点详情,搜索与排序在视图切换后保留。 `Chart` 的页签、位于绘图区下方的图例、区间摘要与数据点详情由前端固定实现,Agent 只提供标题、初始图形类型、类别、序列和来源。鼠标切换折线、柱状和数据表时使用 180ms Motion 滑块与内容过渡;键盘操作与减少动态效果模式即时完成。数据点详情只在点击图中数据点,或聚焦图形后按回车、方向键、Home/End 选点时出现,关闭后焦点回到图形;数据表页签不重复展示区间摘要和点详情,搜索与排序在视图切换后保留。
SCADA 的压力、水头(m)及流量、管流(m³/h)数值轴使用对齐的整数上下界与整数刻度,不直接显示数据极值外加余量的小数端点。序列、数据表与点详情保持原始读数精度;其他单位及通用小量程图沿用原有刻度,不自动换算单位。
图表发布及 Dev 示例的对象聚合、同期校验和历史读取边界统一见 [调查展示 Dev 测试](investigation-dev-tools.md#对象复合图与验证规则)。结构化 Chart 保持单图语义,对象聚合通过 open_chart 的独立对象数据集实现。
[图表和窗口管理](investigation-dev-tools.md#图表和窗口管理)由浏览器宿主负责:总窗口收纳过程图组与报告 Chart,子图表保留独立弹窗放大。总窗口提供单图、左右、四宫格固定槽位,全部图表沿纵向连续排列,末行按列数补齐空槽;整条位置栏提供拖拽和悬停反馈,拖放到目标位置交换内容。窄窗口使用单图,快速定位会滚动到目标位置。同一图表更新保留位置,删图留空,新图先填空槽。顶部窗口列表可定位、置顶已打开的总窗口、子图表和表格,并统一关闭。窗口缩放期间,图表按窗口实际尺寸适配并保留图表实例与筛选状态;松开后确认最终尺寸,Escape 或指针取消恢复原尺寸。
任务打开、切换、刷新、已完成任务载入和历史恢复只更新内容,不弹窗;运行中新收到的 open_chart 由总窗口统一处理,关闭后的实时更新不重新弹出。排列、布局和窗口操作不新增模型输出或 wire 协议字段,也不改变图表校验与证据规则。具体自动弹出与关闭边界以上述管理说明为准。
其余组件使用 `answer-motion.tsx` 统一交互过渡,沿用现有实体面板、字体、语义颜色和圆角。首次加载即时显示;鼠标展开、收起使用 180ms 透明度和位移,键盘与减少动态效果模式即时完成。折叠中的内容立即设为 `inert` 并退出无障碍树,动画结束后隐藏;保留原 React 内容实例,反向操作中断旧动画,避免旧退出回调隐藏重新展开的表单。 其余组件使用 `answer-motion.tsx` 统一交互过渡,沿用现有实体面板、字体、语义颜色和圆角。首次加载即时显示;鼠标展开、收起使用 180ms 透明度和位移,键盘与减少动态效果模式即时完成。折叠中的内容立即设为 `inert` 并退出无障碍树,动画结束后隐藏;保留原 React 内容实例,反向操作中断旧动画,避免旧退出回调隐藏重新展开的表单。
@@ -87,7 +95,7 @@ Agent 选择组件、内容顺序与受控语义,前端负责实际视觉、
| `Stack`、`Section` | 保留既有布局,不为静态分区附加动画 | | `Stack`、`Section` | 保留既有布局,不为静态分区附加动画 |
| `Text`、`Result` | 静态说明和已有结果直接阅读,不反复播放入场或宣告历史状态 | | `Text`、`Result` | 静态说明和已有结果直接阅读,不反复播放入场或宣告历史状态 |
| `Metric` | 直接展示原始数值、单位与来源 | | `Metric` | 直接展示原始数值、单位与来源 |
| `Table` | 搜索排序即时响应;无勾选列、选行提示或操作栏 | | `Table` | 搜索排序即时响应;表头悬停与排序点击覆盖整列宽度;仅当前排序列显示升序或降序箭头,未排序时不显示;无勾选列、选行提示或操作栏 |
| `Evidence` | 正文展开收起和箭头转动使用统一过渡;保留来源、记录时间与资料编号 | | `Evidence` | 正文展开收起和箭头转动使用统一过渡;保留来源、记录时间与资料编号 |
| `MapLocation` | 关联要素详情使用统一折叠;定位中的反馈及结果短时淡入 | | `MapLocation` | 关联要素详情使用统一折叠;定位中的反馈及结果短时淡入 |
| `FollowUpForm` | 精简与外层重复的补充提示;保留标题、说明、校验及草稿,提交反馈短时淡入 | | `FollowUpForm` | 精简与外层重复的补充提示;保留标题、说明、校验及草稿,提交反馈短时淡入 |
+43 -8
View File
@@ -20,13 +20,31 @@ Dev 分为“调查测试”“通知测试”和“地图调试”三个页签
Dev 与工况详情共用右侧工具位置,切换调查时先关闭 Dev 回到任务列表,再打开 Dev 检查新的选择。实时展示面板位于地图左下方,打开 Dev 后仍可看见。新图表进入其滚动区时会滚动到图表,保留标题;手动收起面板后仍接收指令,展开再查看。切换项目、调查或执行代次会清理临时效果并取消未完成的本地预览。上述清理保留本次页面会话内的动作快照,只有切换项目会同时清空历史。已绑定调查时关闭跟随也会清理该调查的当前临时展示;未选择调查的本地预览不受跟随开关影响。面板外仍可操作地图,手动拖拽或滚轮暂停镜头自动跟随。 Dev 与工况详情共用右侧工具位置,切换调查时先关闭 Dev 回到任务列表,再打开 Dev 检查新的选择。实时展示面板位于地图左下方,打开 Dev 后仍可看见。新图表进入其滚动区时会滚动到图表,保留标题;手动收起面板后仍接收指令,展开再查看。切换项目、调查或执行代次会清理临时效果并取消未完成的本地预览。上述清理保留本次页面会话内的动作快照,只有切换项目会同时清空历史。已绑定调查时关闭跟随也会清理该调查的当前临时展示;未选择调查的本地预览不受跟随开关影响。面板外仍可操作地图,手动拖拽或滚轮暂停镜头自动跟随。
调查与本地预览在展示分析图表时自动打开可拖动、缩放和最大化的独立窗口,覆盖 `open_chart` 指令和结构化界面中的 `Chart`。自动打开不抢走键盘焦点;关闭窗口仅收起放大视图,卡片中的图表和放大入口继续保留。同一图表内容未变化时,其他地图动作不会使已关闭的窗口再次弹出;图表内容更新或进入、退出历史回看时可再次自动打开。回放窗口随所选快照更新,保留已有窗口的位置和尺寸,期间收到的实时数据不替换历史图表。关闭图表指令或切换任务时移除对应窗口;普通对话图表仍由用户手动放大。 ## 图表和窗口管理
调查、本地预览与对话展示面板提供“图表窗口”入口,在一个可拖动、缩放、最大化的总窗口中集中查看所有子图表,包括 open_chart 形成的对象图组与报告中的 Chart。每张子图表保留自身的独立弹窗放大入口,可同时打开多个放大窗口;关闭总窗口收起其内部开启的放大窗口,关闭子窗口只收起该放大视图。普通趋势总览、柱状图和报告 Chart 保持各自的图表语义。
总窗口使用固定槽位布局,提供“单图布局”“左右布局”“四宫格布局”,每行分别显示 1、2、2 个固定位置,单图与左右布局每行约占一个可视区高度,四宫格每个可视区约显示两行。全部图表沿纵向连续排列,通过滚动查看;图表随槽位尺寸适配,末行按列数补齐空槽。窄窗口使用单图布局,调宽后恢复先前选择的布局。头部定位、布局和重置控件置于同一条浅底工具栏,控件保持等高,窄窗口允许自然换行。“快速定位图表”列出全部图表,选择后滚动到对应位置并聚焦目标;目标的位置栏短暂显示淡色背景和“已定位”,不使用整卡外框,避免高亮被滚动区域裁切。
槽内图表使用紧凑控件,绘图区下方的图例独立滚动,筛选、详情与依据通过入口展开。多对象图在宽槽右侧显示对象按钮,窄槽使用对象选择器;独立放大后使用完整控件。
整条图表位置栏均为拖拽手柄,悬停反馈覆盖整栏;在目标位置释放后交换两处内容,拖到空槽则移动到空槽,其他位置不推移。悬停时整张卡片显示轻量浅色高亮,拖动源与目标位置保留整卡浅色提示;位置栏使用柔和浅底和深色文字,不使用深色横幅。图表保持原有清晰度,目标位置使用细线提示,轻量标题预览沿用中性边框。原图表留在原位,不克隆或随指针重绘。不再提供“移动到”下拉菜单,下方图表通过纵向滚动和快速定位查看。
槽位映射依据稳定的子图表标识:同一图表实时更新时保留位置,删除图表后保留中间空槽并移除末尾空行,新图优先填补空槽,再追加位置与行。同一次任务查看期间,关闭并重新打开总窗口保留槽位与布局;任务、项目或执行代次切换后重新建立当前内容。交换位置不改变图表数据、对象选择或服务端的展示事件顺序。
工作台顶部的窗口列表入口列出已打开的总图表窗口、独立子图表和表格窗口,选择条目可将对应窗口置顶并定位到标题栏。列表中的“关闭全部窗口”统一收起当前打开的窗口,不删除已加载的图表、表格或任务结果;图表窗口可从原展示入口重新打开。
总窗口与子图表使用同一缩放交互:指针拖动窗口边缘时,图表按窗口实际尺寸适配,保持已有图表实例与筛选、序列显隐和对象选择;松开后确认最终尺寸。拖动期间按 Escape 或发生 pointercancel 时取消调整、恢复原尺寸;标题栏的 Shift 加方向键继续用于逐步调整大小。
只有正在跟随的运行中调查新收到 open_chart 时,才自动弹出一次总窗口,不自动弹出各子图表,不抢走键盘焦点;后续指令按 chart_id 更新其中的数据。打开、切换、刷新任务、SSE 重连补载、历史回看和回到当前都只恢复内容,已完成任务也不弹窗。用户关闭总窗口后,本次任务查看期间的后续更新不使其再次弹出;手动收起展示面板期间的新指令也不请求弹窗。总窗口已经打开时,历史回看只更新其中的快照,实时数据不覆盖正在查看的历史。切换任务、项目或执行代次关闭原窗口并重新静默载入,返回之前看过的任务也不重放自动弹出意图。Chat 与本地 Dev 预览使用手动入口。
open_chart 仍只提交 chart_id/chart/evidence_refs,close_chart 仍按 chart_id 移除对象;不新增 display 或 objects 字段。排列、布局与窗口打开意图仅存在当前浏览器中,不进入保存的展示快照或事件。displayed 回执表示展示内容已加载,不保证弹出了独立窗口。
## 本地测试步骤 ## 本地测试步骤
1. 在默认展开的“调查测试”页签准备数据。“载入合成样例”在当前地图中心生成合成空间展示、压力曲线、候选表格和一条候选数据;所选调查进度存在地图目标时,另加入该目标的高亮测试。所有合成结果均有明确标记。 1. 在默认展开的“调查测试”页签准备数据。“载入合成样例”在当前地图中心生成合成空间展示、压力曲线、候选表格和一条候选数据;所选调查进度存在地图目标时,另加入该目标的高亮测试。所有合成结果均有明确标记。
2. 可选:点击“校验 JSONL”单独查看识别出的展示指令、界面块、调查数据数量。也可直接执行本地预览,执行入口会自动校验当前全部内容。 2. 可选:点击“校验 JSONL”单独查看识别出的展示指令、界面块、调查数据数量。也可直接执行本地预览,执行入口会自动校验当前全部内容。
3. 点击“执行本地预览”,自动校验通过后对照地图和左下展示面板中的图表、结构化界面与数据摘要;Dev 的“原始数据检查”可点击对象查看原始行内容。动作记录区分“实时指令”和“本地测试”,显示应用或失败结果。 3. 点击“执行本地预览”,自动校验通过后对照地图和左下展示面板中的图表、结构化界面与数据摘要;手动打开“图表窗口”,核对单图、左右、四宫格的固定槽位、拖放交换、末行空槽与纵向滚动,再检查快速定位和子图表独立放大,并从顶部窗口列表切换已打开的窗口。Dev 的“原始数据检查”可点击对象查看原始行内容。动作记录区分“实时指令”和“本地测试”,显示应用或失败结果。
4. 按需“下载测试 JSONL”保存当前已校验输入;“清除临时展示”清除当前浏览器的高亮、图表和覆盖层。测试不改变服务端事件游标,不发起调查、审批、工单或设备写入。 4. 按需“下载测试 JSONL”保存当前已校验输入;“清除临时展示”清除当前浏览器的高亮、图表和覆盖层。测试不改变服务端事件游标,不发起调查、审批、工单或设备写入。
样例与导出仅存在当前页面或用户下载的文件中,不写 Agent 工作区,不伪造已保存证据。Dev 不会自动枚举 Agent 生成的本地文件;要检查已有 JSONL,可将通过受控入口取得的文件内容粘贴到编辑区。当前最多 200 KB、40 行、8 个界面块;地图与图表继续遵守原展示协议的数量、几何及序列约束。 样例与导出仅存在当前页面或用户下载的文件中,不写 Agent 工作区,不伪造已保存证据。Dev 不会自动枚举 Agent 生成的本地文件;要检查已有 JSONL,可将通过受控入口取得的文件内容粘贴到编辑区。当前最多 200 KB、40 行、8 个界面块;地图与图表继续遵守原展示协议的数量、几何及序列约束。
@@ -37,8 +55,10 @@ Dev 与工况详情共用右侧工具位置,切换调查时先关闭 Dev 回
| 案例 | 核对内容 | | 案例 | 核对内容 |
| --- | --- | | --- | --- |
| 组合联动 | 地图点、调查现有目标高亮、压力曲线、候选表格与数据 | | 组合联动 | 合成空间点、压力曲线、候选表格与数据;定位和高亮在地图调试页签操作 |
| 地图定位与高亮 | 有目标时高亮真实关联目标;无目标时只显示合成点,不虚构资产查询编号 | | 复杂调查:多图多表联动 | 8 个压力点、4 个管段的同期复合图聚合为 2 个子图表,另有方案柱状图、2 个表格与合成覆盖面 |
| 复杂图表:多测点与长时序 | 每个对象包含 96 个时刻、1 条当前曲线和 7 组稀疏历史散点;右侧切换对象,下方显隐序列 |
| 复杂表格:宽表、长文本与边界数据 | 50 行 8 列宽表、空结果、零值、负数、重复值及缺口 |
| 压力趋势对比 | 两条合成压力曲线、时间轴、单位与图例 | | 压力趋势对比 | 两条合成压力曲线、时间轴、单位与图例 |
| 影响用户统计 | 合成用户数柱状图,不作为真实停水统计 | | 影响用户统计 | 合成用户数柱状图,不作为真实停水统计 |
| 合成影响范围 | 当前地图中心附近的合成 Polygon | | 合成影响范围 | 当前地图中心附近的合成 Polygon |
@@ -49,6 +69,7 @@ Dev 与工况详情共用右侧工具位置,切换调查时先关闭 Dev 回
| 错误:JSON 语法 | 第二行语法错误时整批拒绝,不执行第一行合法动作 | | 错误:JSON 语法 | 第二行语法错误时整批拒绝,不执行第一行合法动作 |
| 错误:不支持的动作 | 拒绝协议未支持的动作 | | 错误:不支持的动作 | 拒绝协议未支持的动作 |
| 错误:图表数据不齐 | 拒绝时间轴和序列长度不一致的图表 | | 错误:图表数据不齐 | 拒绝时间轴和序列长度不一致的图表 |
| 错误:同期时刻错位 | 拒绝历史时间与横轴槽位不一致的复合图,整批不执行 |
切换前若执行过本地预览,会先清除它的当前临时地图、图表和业务结果,保留动作历史供回看;仅选择或编辑 JSONL 不执行新指令。有内容时即可点击“执行本地预览”,无需先点单独校验;每次执行都会重新校验当前全部输入。任何一行错误都会拦截整批,显示具体错误,不执行部分指令;地图尚未就绪时也显示原因。仅空输入或正在执行时禁用按钮,不以调查选择或实时跟随作为本地执行前提。测试不会写入 Server、重启调查或发送 Agent 对话。案例定义位于 [investigation-dev-cases.ts](../src/features/workbench/utils/investigation-dev-cases.ts)。 切换前若执行过本地预览,会先清除它的当前临时地图、图表和业务结果,保留动作历史供回看;仅选择或编辑 JSONL 不执行新指令。有内容时即可点击“执行本地预览”,无需先点单独校验;每次执行都会重新校验当前全部输入。任何一行错误都会拦截整批,显示具体错误,不执行部分指令;地图尚未就绪时也显示原因。仅空输入或正在执行时禁用按钮,不以调查选择或实时跟随作为本地执行前提。测试不会写入 Server、重启调查或发送 Agent 对话。案例定义位于 [investigation-dev-cases.ts](../src/features/workbench/utils/investigation-dev-cases.ts)。
@@ -62,7 +83,7 @@ Dev 与工况详情共用右侧工具位置,切换调查时先关闭 Dev 回
{"record_type":"candidate","origin":"synthetic","source_evidence_refs":[],"element_id":"DEMO-PIPE-01","status":"example_only"} {"record_type":"candidate","origin":"synthetic","source_evidence_refs":[],"element_id":"DEMO-PIPE-01","status":"example_only"}
``` ```
仓库内的可复制数据示例为 [unified-presentation.jsonl](../tests/fixtures/unified-presentation.jsonl),覆盖十种 command、一个 `phase: final` 界面块和带来源的普通数据行;`P1` 是浏览器测试夹具中的测点,业务项目应使用已核实的实际编号。内置案例从同一个记录生成器输出,并在生成时通过当前校验器。错误案例单独保留,专门验证拒绝行为。 仓库内的可复制数据示例为 [unified-presentation.jsonl](../tests/fixtures/unified-presentation.jsonl),覆盖十种 command、两个可聚合的对象复合图、一个 `phase: final` 界面块和带来源的普通数据行;`P1` 是浏览器测试夹具中的测点,业务项目应使用已核实的实际编号。内置案例从同一个记录生成器输出,并在生成时通过当前校验器。错误案例单独保留,专门验证拒绝行为。
每行必须显式使用 `record_type: presentation`、`record_type: ui_block`,或带来源的调查数据类型。裸 command、裸 `answer-ui@1` block 和旧 chat 的 name/params 格式均拒绝。展示指令统一接受 `locate_features/zoom_to_map/highlight_features/clear_highlights/open_chart/close_chart/set_overlay/clear_overlay/render_scada_analysis/clear_scada_analysis`,不能加入项目、运行身份、URL、脚本或任意命令。Dev 的覆盖层样例直接提供合成几何,仅用于本地渲染测试;任务 Agent 的 `set_overlay` 可只传证据编号,由 Server 提取几何;传入 data 时必须与保存的证据快照一致。Chat 和 Dev 执行已提供并校验的 data。不能把 Dev 格式当成 Server 写入接口。 每行必须显式使用 `record_type: presentation`、`record_type: ui_block`,或带来源的调查数据类型。裸 command、裸 `answer-ui@1` block 和旧 chat 的 name/params 格式均拒绝。展示指令统一接受 `locate_features/zoom_to_map/highlight_features/clear_highlights/open_chart/close_chart/set_overlay/clear_overlay/render_scada_analysis/clear_scada_analysis`,不能加入项目、运行身份、URL、脚本或任意命令。Dev 的覆盖层样例直接提供合成几何,仅用于本地渲染测试;任务 Agent 的 `set_overlay` 可只传证据编号,由 Server 提取几何;传入 data 时必须与保存的证据快照一致。Chat 和 Dev 执行已提供并校验的 data。不能把 Dev 格式当成 Server 写入接口。
@@ -75,15 +96,16 @@ Dev 与工况详情共用右侧工具位置,切换调查时先关闭 Dev 回
```bash ```bash
pnpm exec vitest run src/features/workbench/utils/investigation-dev-preview.test.ts src/features/workbench/components/map-dev-panel.test.tsx src/features/workbench/hooks/investigation-presentation-follower.test.ts pnpm exec vitest run src/features/workbench/utils/investigation-dev-preview.test.ts src/features/workbench/components/map-dev-panel.test.tsx src/features/workbench/hooks/investigation-presentation-follower.test.ts
PLAYWRIGHT_PORT=5194 pnpm exec playwright test tests/browser/investigation-dev-tools.e2e.ts tests/browser/floating-panel-pointer.e2e.ts --workers=1 --reporter=line PLAYWRIGHT_PORT=5194 pnpm exec playwright test tests/browser/investigation-dev-tools.e2e.ts tests/browser/floating-panel-pointer.e2e.ts --workers=1 --reporter=line
PLAYWRIGHT_PORT=5195 pnpm exec playwright test tests/browser/investigation-chart-manager.e2e.ts tests/browser/investigation-chart-groups.e2e.ts tests/browser/investigation-chart-layout.e2e.ts tests/browser/investigation-chart-resize.e2e.ts --workers=1 --reporter=line
``` ```
浏览器用隔离 API/SSE fixture 验证所选任务到真实 MapLibre/React 展示的消费链、Dev 开启与收起、JSONL 校验和下载、任务切换清理、紧凑窗口及面板外地图输入;不调用付费模型或真实工单、设备接口。真实 Agent 是否发布了正确的事件、报告或 JSONL 文件,仍需按实际项目运行记录核对。 浏览器用隔离 API/SSE fixture 验证所选任务到真实 MapLibre/React 展示的消费链、Dev 开启与收起、JSONL 校验和下载、任务静默恢复、任务切换清理、子图表管理、紧凑窗口、实际尺寸适配及面板外地图输入;不调用付费模型或真实工单、设备接口。真实 Agent 是否发布了正确的事件、报告或 JSONL 文件,仍需按实际项目运行记录核对。
## 统一展示动作 ## 统一展示动作
Chat 使用 `present_view({command, reason})`,在线通过 SSE 的 `tjwater.frontend_action` 承载 `frontend-action@2`,动作字段为 command,不再接收 name/params。浏览器回传 `frontend-action-result@2`。任务事件和 Dev JSONL 的 command 使用相同结构,并进入同一执行器。Chat、调查和 Dev 预览分别拥有地图源、SCADA 图层及图表状态;清理、切换会话和切换任务不会误清其他入口。Chat 动作按接收顺序执行,切换会话取消正在执行及排队动作;过期动作不会执行。任务仍只记录发布状态,不等待浏览器。查看历史回答不会重放动作。 Chat 使用 `present_view({command, reason})`,在线通过 SSE 的 `tjwater.frontend_action` 承载 `frontend-action@2`,动作字段为 command,不再接收 name/params。浏览器回传 `frontend-action-result@2`。任务事件和 Dev JSONL 的 command 使用相同结构,并进入同一执行器。Chat、调查和 Dev 预览分别拥有地图源、SCADA 图层及图表状态;清理、切换会话和切换任务不会误清其他入口。Chat 动作按接收顺序执行,切换会话取消正在执行及排队动作;过期动作不会执行。任务仍只记录发布状态,不等待浏览器。查看历史回答不会重放动作。
动作 schema 的维护源是 `src/features/agent/presentation/command-schema.ts`。修改后在前端仓库运行 `node scripts/export-view-command-contract.mjs`,同步 Agent 主机/原生工具共用的 TypeScript schema 和 Agent/Server JSON Schema;运行同一命令加 `--check` 检查一致性。Server 任务入口继续执行证据归属和快照校验。 动作 schema 的维护源是 `src/features/agent/presentation/command-schema.ts`。修改后在前端仓库运行 `node scripts/export-view-command-contract.mjs`,同步 Agent 主机/原生工具共用的 TypeScript schema、Agent/Server JSON Schema,以及两端 tests/fixtures 下的统一 JSONL 示例与图表正反例;运行同一命令加 `--check` 检查内容一致性。JSON Schema 表达字段和数量约束,跨字段的时间语义由运行时校验和共享测试矩阵保证。Server 任务入口继续执行证据归属和快照校验。
## 已有案例与示例迁移 ## 已有案例与示例迁移
@@ -97,7 +119,20 @@ Chat 使用 `present_view({command, reason})`,在线通过 SSE 的 `tjwater.fr
| 调查数据 | 保留 `record_type/origin/source_evidence_refs`,不把内嵌 action 提升为指令 | | 调查数据 | 保留 `record_type/origin/source_evidence_refs`,不把内嵌 action 提升为指令 |
| Chat 调用 | `present_view({command, reason})`,不再调用旧的独立展示工具 | | Chat 调用 | `present_view({command, reason})`,不再调用旧的独立展示工具 |
本轮迁移只覆盖仓库内的内置案例、夹具和文档示例,未修改历史会话、数据库或外部下载文件。没有增加运行时旧格式转换。 迁移覆盖内置复杂案例、可复制 JSONL、跨端校验夹具及文档,不修改历史会话、数据库或外部下载文件。旧的已保存图表仍按结构校验读取;新输入与发布按下述规则校验。
## 对象复合图与验证规则
协议继续使用 `open_chart`,每个对象有独立稳定的 `chart_id`;不增加 `objects` 字段。多对象同期对比使用 `chart_type: time_comparison`。标题写为 `<对象ID> <同组标题>`,每条序列名以同一 `<对象ID> ` 开头,建议后接 `当前 YYYY-MM-DD` 或历史日期。对象 ID 以字母或数字开头,仅使用字母、数字、下划线、点或短横线。图例显示在绘图区下方;宽槽右侧的对象按钮单选切换数据集,窄槽使用对象选择器;独立放大的窄窗口把对象按钮移到下方。
仅 `open_chart` 中带历史 scatter 的明确对象图自动聚合。同组标题、chart_type、横纵轴名称、横轴槽位及当前序列名称去掉对象前缀后的部分、当前 sample_times 必须完全一致;不同单位或窗口分开。普通 line/bar 图和结构化回答中的独立 Chart 保持各自图表,统一在总窗口管理。关闭子窗口只收起放大视图,关闭聚合卡片移除组内所有对象;按原 chart_id 的 close_chart 仅移除该对象。
历史读取还兼容已保存的 `测点 <对象ID> <同组标题>`、`压力测点 <对象ID> <描述>` 和 `流量测点 <对象ID> <描述>` 标题;后两种以指标名称连接描述作为同组标题,压力和流量分开。对象归属仍要求序列名带同一对象前缀。历史 scatter 仅写 `MM-DD` 的旧图,只有在标题与唯一当前 line 明确指向同一对象,且每条日期序列至少有一个有效采样时间、有效值与时间一一对应、时间换算为 UTC+8 后均为同一完整日期并与日期名相符时,才兼容日期型历史图例;缺失时间、日期不符、跨对象序列或歧义标题保持单图。这些兼容只影响显示聚合,保留原指令、标题、序列、采样时间和证据,不修改保存的数据,也不放宽新发布的同期规则。
当前与历史共用 UTC+8 的 HH:mm 或 HH:mm:ss 槽位,时刻精度一致,窗口按顺序且短于一天,允许跨午夜。每对象最多 100 个槽位、8 组序列,包含当前 line 和历史 scatter,不能混入 bar。每组 sample_times 与 data 等长,有效值保留带时区的真实采样时间;缺测值和时间均为 null,零值保留。采样时间换算为 UTC+8 后须落在对应槽位;当前曲线共享同一窗口,每组历史来自此前某个对应日期,不能混入当天、未来或不同日期。普通 line/bar 不套用这些同期语义,但不能用 line 冒充 line 与历史 scatter 复合图。
Dev 的 presentation 与 ui_block.Chart、新的 Chat open_chart(Agent 主机与原生工具)及任务图表发布使用相同的同期规则。历史读取的结构校验不追溯拒绝旧混合图。Server 在任务展示和报告发布时另外校验证据归属与同期语义,错误码为 TIME_COMPARISON_INVALID;本地预览不证明证据有效或业务发布成功。共享正反例的维护源为 [chart-publication-cases.json](../tests/fixtures/chart-publication-cases.json),随导出脚本同步到 Agent 与 Server,并在各仓库独立运行测试。
## 展示动作历史 ## 展示动作历史
+4
View File
@@ -194,6 +194,10 @@ Agent 与主工作区、地图与报告使用共享 `Resizable` 分栏;连续
每个业务区最多一个醒目的主操作。按钮覆盖默认、hover、按压、焦点、禁用和加载态;hover 仅用于支持 hover 的设备,加载时阻止重复提交并保留可读状态。地图与业务控件沿用共享按压反馈,Agent 控件保留本地 shadcn/ui 交互状态;具有专用资源交互的 `AgentPersona` 保留原版悬停,不额外缩放或变暗。 每个业务区最多一个醒目的主操作。按钮覆盖默认、hover、按压、焦点、禁用和加载态;hover 仅用于支持 hover 的设备,加载时阻止重复提交并保留可读状态。地图与业务控件沿用共享按压反馈,Agent 控件保留本地 shadcn/ui 交互状态;具有专用资源交互的 `AgentPersona` 保留原版悬停,不额外缩放或变暗。
MapTool、地名搜索候选项、Header 普通菜单与窗口列表通过 `mapOptionInteractionVariants` 统一交互。普通项仅在支持 hover 的设备上切换为 `surface-reading` 底色、`surface-border` 边框与 `action-blue-hover` 标题及操作图标;键盘 `focus-visible`、菜单 `data-highlighted` 使用相同反馈并保留焦点提示。说明文字保持 muted,图层示意图保留业务颜色。整行、整张卡片响应,边框预留空间;行和卡片使用 12px 圆角,紧凑工具保持原有尺寸与圆角。颜色过渡为 150ms,减少动态效果时关闭,不增加缩放或位移。 标注工具切换选项时,图标继承按钮实时文字颜色;图标容器仅对底色做同周期过渡,避免按钮底色仍在渐变时图标先切白色。
选中项保留底色、文字和状态标识,悬停及键盘高亮仅加深边框;主操作使用蓝底白字并在高亮时加深,危险操作统一使用 `status-danger-soft/border/foreground`。禁用项不响应 hover 或按压;危险语义由业务显式声明,不按文案推断。`MAP_ACTION_ROW_INTERACTION_CLASS_NAME` 为普通菜单提供布局之外的默认底色、圆角与上述交互组合。此规则仅用于地图和工作台入口;Agent、调查展示等面板及共享 DropdownMenu 保留本地 shadcn/ui 主题,业务状态色继续独立表达。
地图与业务表单字段复用 `.form-field`,聚焦只改变原有 1px 边框;组合输入由外层承担焦点边界,无边框输入使用内描边。Agent 字段保留本地 shadcn/ui 输入组件的边框、焦点和错误样式。错误通过文字、`aria-invalid` 和关联说明表达,失败后保留输入。选中与焦点不改变控件尺寸,底图选择卡片使用主题边框和勾选标记,键盘焦点使用共享内描边。 地图与业务表单字段复用 `.form-field`,聚焦只改变原有 1px 边框;组合输入由外层承担焦点边界,无边框输入使用内描边。Agent 字段保留本地 shadcn/ui 输入组件的边框、焦点和错误样式。错误通过文字、`aria-invalid` 和关联说明表达,失败后保留输入。选中与焦点不改变控件尺寸,底图选择卡片使用主题边框和勾选标记,键盘焦点使用共享内描边。
弹层保留标题、关闭入口、焦点管理及触发控件的焦点返回。表单弹窗显式将初始焦点放在首要业务字段,选择框获焦不自动展开。Esc 从内到外处理,外层尊重已消费事件;弹窗容器可承接程序焦点,业务控件仍保留可见焦点。模态遮罩阻断底层地图操作。 弹层保留标题、关闭入口、焦点管理及触发控件的焦点返回。表单弹窗显式将初始焦点放在首要业务字段,选择框获焦不自动展开。Esc 从内到外处理,外层尊重已消费事件;弹窗容器可承接程序焦点,业务控件仍保留可见焦点。模态遮罩阻断底层地图操作。
+2
View File
@@ -24,6 +24,8 @@
}, },
"dependencies": { "dependencies": {
"@ai-sdk/react": "^4.0.9", "@ai-sdk/react": "^4.0.9",
"@dnd-kit/collision": "0.5.3",
"@dnd-kit/react": "0.5.3",
"@fontsource-variable/noto-sans-sc": "5.3.0", "@fontsource-variable/noto-sans-sc": "5.3.0",
"@json-render/core": "^0.21.0", "@json-render/core": "^0.21.0",
"@json-render/react": "^0.21.0", "@json-render/react": "^0.21.0",
+71
View File
@@ -15,6 +15,12 @@ importers:
'@ai-sdk/react': '@ai-sdk/react':
specifier: ^4.0.9 specifier: ^4.0.9
version: 4.0.34(react@19.2.7)(zod@3.25.76) version: 4.0.34(react@19.2.7)(zod@3.25.76)
'@dnd-kit/collision':
specifier: 0.5.3
version: 0.5.3
'@dnd-kit/react':
specifier: 0.5.3
version: 0.5.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@fontsource-variable/noto-sans-sc': '@fontsource-variable/noto-sans-sc':
specifier: 5.3.0 specifier: 5.3.0
version: 5.3.0 version: 5.3.0
@@ -470,6 +476,27 @@ packages:
resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==}
engines: {node: '>=18'} engines: {node: '>=18'}
'@dnd-kit/abstract@0.5.3':
resolution: {integrity: sha512-KZQ2vftkO9BHupVzlUqB5d7JETL7ITlvslx3hrj4aObwjliAzuzV/n5wCpQllNd0Hxvg7FaQtLekJ8dplqKwbA==}
'@dnd-kit/collision@0.5.3':
resolution: {integrity: sha512-DBo8czkI72xnsHRWOMTn/GAyYgSWy9bmde85mrZ7ShqLhcbIbcu4PVJ3ZU9MBikePCSAVOoUTZE2gS40jogrEw==}
'@dnd-kit/dom@0.5.3':
resolution: {integrity: sha512-JJe+Qu/JmofGzHes3np91sQYwp5spvLcknjRkq+VO59JTqSnQNPXlhHXavRXTwQFabaaRyRzXyH/faAvubCNGw==}
'@dnd-kit/geometry@0.5.3':
resolution: {integrity: sha512-U+zscNjoHfJ1xUV+XDGF89jPCoILLivWSb/U9mfM7h83f6o3X9AS9uIwG46bXUpCqPvlI/9JcOlyga5uvfyjgw==}
'@dnd-kit/react@0.5.3':
resolution: {integrity: sha512-BGn6bakgcRSoKMRQP8cRIGu59e5lGC3/sL0zVWmDMo/9CMPSxgjCcQltDn5TDTwAARnEbuwNEksor4rLtH/yEg==}
peerDependencies:
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
'@dnd-kit/state@0.5.3':
resolution: {integrity: sha512-NJTQkBglAT/SXLc+IOLiKwNvsTrWmPG5AO+7Gn0F0n8XyVaf4VCbTCuPuU48sDBfcpg23O0tfGkfZNiZbYTvMw==}
'@esbuild/aix-ppc64@0.28.1': '@esbuild/aix-ppc64@0.28.1':
resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -825,6 +852,9 @@ packages:
engines: {node: '>=18'} engines: {node: '>=18'}
hasBin: true hasBin: true
'@preact/signals-core@1.14.5':
resolution: {integrity: sha512-vcRv7Ityz5JVmeiVqIluLFzT/PDS/AlhHUpw3/aUWqcVUs6bDGIPXE/6RSC4evBNWYvnniozg5Syfj33al3gkg==}
'@radix-ui/number@1.1.2': '@radix-ui/number@1.1.2':
resolution: {integrity: sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==} resolution: {integrity: sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==}
@@ -4567,6 +4597,45 @@ snapshots:
'@csstools/css-tokenizer@3.0.4': {} '@csstools/css-tokenizer@3.0.4': {}
'@dnd-kit/abstract@0.5.3':
dependencies:
'@dnd-kit/geometry': 0.5.3
'@dnd-kit/state': 0.5.3
tslib: 2.8.1
'@dnd-kit/collision@0.5.3':
dependencies:
'@dnd-kit/abstract': 0.5.3
'@dnd-kit/geometry': 0.5.3
tslib: 2.8.1
'@dnd-kit/dom@0.5.3':
dependencies:
'@dnd-kit/abstract': 0.5.3
'@dnd-kit/collision': 0.5.3
'@dnd-kit/geometry': 0.5.3
'@dnd-kit/state': 0.5.3
tslib: 2.8.1
'@dnd-kit/geometry@0.5.3':
dependencies:
'@dnd-kit/state': 0.5.3
tslib: 2.8.1
'@dnd-kit/react@0.5.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@dnd-kit/abstract': 0.5.3
'@dnd-kit/dom': 0.5.3
'@dnd-kit/state': 0.5.3
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
tslib: 2.8.1
'@dnd-kit/state@0.5.3':
dependencies:
'@preact/signals-core': 1.14.5
tslib: 2.8.1
'@esbuild/aix-ppc64@0.28.1': '@esbuild/aix-ppc64@0.28.1':
optional: true optional: true
@@ -4860,6 +4929,8 @@ snapshots:
dependencies: dependencies:
playwright: 1.61.1 playwright: 1.61.1
'@preact/signals-core@1.14.5': {}
'@radix-ui/number@1.1.2': {} '@radix-ui/number@1.1.2': {}
'@radix-ui/primitive@1.1.6': {} '@radix-ui/primitive@1.1.6': {}
+9 -1
View File
@@ -1,5 +1,6 @@
import process from "node:process"; import process from "node:process";
import { readFile, writeFile } from "node:fs/promises"; import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import { fileURLToPath, URL } from "node:url"; import { fileURLToPath, URL } from "node:url";
import { zodToJsonSchema } from "zod-to-json-schema"; import { zodToJsonSchema } from "zod-to-json-schema";
import { viewCommandSchema } from "../src/features/agent/presentation/command-schema.ts"; import { viewCommandSchema } from "../src/features/agent/presentation/command-schema.ts";
@@ -22,11 +23,18 @@ const targets = [
], ],
[new URL("../../next-tjwater-server/contracts/view-command.schema.json", import.meta.url), schema] [new URL("../../next-tjwater-server/contracts/view-command.schema.json", import.meta.url), schema]
]; ];
// The same executable examples and rejection matrix travel with each independent repository.
for (const name of ["unified-presentation.jsonl", "chart-publication-cases.json"]) {
const fixture = await readFile(new URL(`../tests/fixtures/${name}`, import.meta.url), "utf8");
for (const repository of ["next-tjwater-agent", "next-tjwater-server"])
targets.push([new URL(`../../${repository}/tests/fixtures/${name}`, import.meta.url), fixture]);
}
for (const [url, content] of targets) { for (const [url, content] of targets) {
if (process.argv.includes("--check")) { if (process.argv.includes("--check")) {
if ((await readFile(url, "utf8")) !== content) if ((await readFile(url, "utf8")) !== content)
throw new Error(`View command contract differs: ${fileURLToPath(url)}`); throw new Error(`View command contract differs: ${fileURLToPath(url)}`);
} else { } else {
await mkdir(dirname(fileURLToPath(url)), { recursive: true });
await writeFile(url, content); await writeFile(url, content);
} }
} }
@@ -7,10 +7,10 @@ export type AnswerUiMapTarget = {
}; };
export type AnswerUiInteractions = { export type AnswerUiInteractions = {
disabled: boolean; disabled: boolean;
chartPresentation?: "tile";
onLocate?: (target: AnswerUiMapTarget) => Promise<unknown> | void; onLocate?: (target: AnswerUiMapTarget) => Promise<unknown> | void;
onSubmit?: (text: string) => Promise<void> | void; onSubmit?: (text: string) => Promise<void> | void;
evidenceFilter?: string; evidenceFilter?: string;
submitMode?: "send" | "draft"; submitMode?: "send" | "draft";
chartWindowKey?: string;
}; };
export const AnswerInteractionContext = createContext<AnswerUiInteractions>({ disabled: true }); export const AnswerInteractionContext = createContext<AnswerUiInteractions>({ disabled: true });
@@ -189,6 +189,30 @@ describe("local interactive answer renderer", () => {
expect(screen.getByRole("tab", { name: /^柱状$/ })).toHaveAttribute("aria-selected", "true"); expect(screen.getByRole("tab", { name: /^柱状$/ })).toHaveAttribute("aria-selected", "true");
expect(screen.queryByRole("table")).not.toBeInTheDocument(); expect(screen.queryByRole("table")).not.toBeInTheDocument();
}); });
it("updates chart titles and readings after the same spec is changed in place", async () => {
const user = userEvent.setup();
const fixture = block("Chart", {
title: "原始压力图", chart_type: "line", source: "监测数据",
x_data: ["08:00", "09:00"], series: [{ name: "原始压力", data: [0.3, 0.2] }]
});
const { rerender } = render(<AnswerUiView block={fixture} />);
await user.click(screen.getByRole("tab", { name: /^数据$/ }));
expect(screen.getByRole("table")).toHaveTextContent("0.2");
const props = fixture.spec.elements.node.props;
props.title = "更新压力图";
const series = props.series as { name: string; data: number[] }[];
series[0].name = "更新后压力";
series[0].data[1] = 0.17;
rerender(<AnswerUiView block={fixture} />);
expect(screen.getByRole("group", { name: "更新压力图" })).toBeVisible();
expect(screen.queryByRole("group", { name: "原始压力图" })).not.toBeInTheDocument();
await waitFor(() => expect(screen.getByRole("columnheader", { name: "更新后压力" })).toBeVisible());
expect(screen.getByRole("table")).toHaveTextContent("0.17");
expect(screen.getByRole("table")).not.toHaveTextContent("0.2");
expect(screen.getByRole("tab", { name: /^数据$/ })).toHaveAttribute("aria-selected", "true");
});
it("runs map location only after a user click and reports missing IDs", async () => { it("runs map location only after a user click and reports missing IDs", async () => {
const locate = vi const locate = vi
.fn() .fn()
@@ -1,6 +1,6 @@
import { LocateButton, FollowUpForm } from "./answer-action-components"; import { LocateButton, FollowUpForm } from "./answer-action-components";
import { Renderer, defineRegistry } from "@json-render/react"; import { Renderer, defineRegistry } from "@json-render/react";
import { useContext, useRef, useState, type ReactNode } from "react"; import { useContext, useMemo, useRef, useState, type ReactNode } from "react";
import { import {
Info, Info,
TriangleAlert, TriangleAlert,
@@ -103,17 +103,19 @@ const registry = withAnswerNodeIdentity(baseRegistry);
type AnswerUiViewProps = { block: AnswerUiBlock; viewState?: AnswerViewState; onViewStateChange?: AnswerViewStateChange } & Partial<AnswerUiInteractions>; type AnswerUiViewProps = { block: AnswerUiBlock; viewState?: AnswerViewState; onViewStateChange?: AnswerViewStateChange } & Partial<AnswerUiInteractions>;
export function AnswerUiView({ viewState, onViewStateChange, ...props }: AnswerUiViewProps) { export function AnswerUiView({ viewState, onViewStateChange, ...props }: AnswerUiViewProps) {
const serializedSpec = JSON.stringify(props.block.spec);
const displayedSpec = useMemo(() => withAnswerNodeIds(withAnswerDisplayTimes(JSON.parse(serializedSpec) as AnswerUiBlock["spec"])), [serializedSpec]);
return <AnswerViewStateProvider key={props.block.block_id} spec={props.block.spec} registry={registry} viewState={viewState} onViewStateChange={onViewStateChange}> return <AnswerViewStateProvider key={props.block.block_id} spec={props.block.spec} registry={registry} viewState={viewState} onViewStateChange={onViewStateChange}>
<AnswerUiBody {...props} block={{ ...props.block, spec: withAnswerNodeIds(withAnswerDisplayTimes(props.block.spec)) }} /> <AnswerUiBody {...props} block={{ ...props.block, spec: displayedSpec }} />
</AnswerViewStateProvider>; </AnswerViewStateProvider>;
} }
function AnswerUiBody({ function AnswerUiBody({
block, block,
disabled = false, disabled = false,
chartPresentation,
onLocate, onLocate,
onSubmit, onSubmit,
chartWindowKey,
submitMode = "send" submitMode = "send"
}: { block: AnswerUiBlock } & Partial<AnswerUiInteractions>) { }: { block: AnswerUiBlock } & Partial<AnswerUiInteractions>) {
const [{ evidenceFilter: filter }, setFilter] = useAnswerBlockState(); const [{ evidenceFilter: filter }, setFilter] = useAnswerBlockState();
@@ -132,10 +134,10 @@ function AnswerUiBody({
const isGroup = (ids: string[]) => hasTaskLayout && ids.length > 1 && !ids.every((id) => block.spec.elements[id].type === "Section"); const isGroup = (ids: string[]) => hasTaskLayout && ids.length > 1 && !ids.every((id) => block.spec.elements[id].type === "Section");
const renderNodes = (ids: string[]) => ids.map((id) => <Renderer key={id} spec={{ ...block.spec, root: id }} registry={registry} />); const renderNodes = (ids: string[]) => ids.map((id) => <Renderer key={id} spec={{ ...block.spec, root: id }} registry={registry} />);
return ( return (
<InteractionContext.Provider value={{ disabled, onLocate, onSubmit, submitMode, evidenceFilter: filter, chartWindowKey }}> <InteractionContext.Provider value={{ disabled, onLocate, onSubmit, submitMode, evidenceFilter: filter, chartPresentation }}>
<AnswerMotionScope className="answer-ui-root" data-answer-ui={block.block_id} data-answer-phase={block.phase} data-answer-intent={intent} data-answer-density={density}> <AnswerMotionScope className={chartPresentation === "tile" ? "answer-ui-root flex h-full min-h-0 flex-col" : "answer-ui-root"} data-answer-ui={block.block_id} data-answer-phase={block.phase} data-answer-intent={intent} data-answer-density={density}>
<AnswerContentNav block={block} contentRef={contentRef} /> {chartPresentation !== "tile" ? <AnswerContentNav block={block} contentRef={contentRef} /> : null}
<div ref={contentRef}> <div ref={contentRef} className={chartPresentation === "tile" ? "flex h-full min-h-0 flex-1 flex-col" : undefined}>
<> <>
{taskLayout ? <div className={hasTaskLayout ? "answer-task-layout" : "answer-task-layout-simple"} data-answer-task-layout={hasTaskLayout || undefined}> {taskLayout ? <div className={hasTaskLayout ? "answer-task-layout" : "answer-task-layout-simple"} data-answer-task-layout={hasTaskLayout || undefined}>
{taskLayout.overview.length ? <div className="answer-task-overview grid gap-3" aria-label="回答概览">{renderNodes(taskLayout.overview)}</div> : null} {taskLayout.overview.length ? <div className="answer-task-overview grid gap-3" aria-label="回答概览">{renderNodes(taskLayout.overview)}</div> : null}
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { chartPointTooltip, chartSampleTime, chartValueBounds, chartValueLabel } from "./chart-presentation"; import { chartPointTooltip, chartSampleTime, chartUsesIntegerTicks, chartValueBounds, chartValueLabel } from "./chart-presentation";
describe("monitoring chart comparison", () => { describe("monitoring chart comparison", () => {
it("displays UTC samples in UTC8 and keeps explicit UTC8 instants stable across midnight", () => { it("displays UTC samples in UTC8 and keeps explicit UTC8 instants stable across midnight", () => {
@@ -17,6 +17,39 @@ describe("monitoring chart comparison", () => {
expect(chartValueBounds([{ name: "零流量", data: [0, 0] }], 0, 1)).toEqual({ min: -0.08, max: 0.08 }); expect(chartValueBounds([{ name: "零流量", data: [0, 0] }], 0, 1)).toEqual({ min: -0.08, max: 0.08 });
}); });
it("uses aligned integer boundaries and steps without rounding monitoring readings", () => {
const series = [{ name: "流量", data: [2494.274, 4742.033] }];
const original = structuredClone(series);
expect(chartValueBounds(series, 0, 1, true)).toEqual({ min: 2000, max: 5000, interval: 1000 });
expect(series).toEqual(original);
});
it("keeps a narrow pressure range near its readings instead of forcing a broad zero-based axis", () => {
expect(chartValueBounds([{ name: "压力", data: [28.125, 28.875] }], 0, 1, true))
.toEqual({ min: 28, max: 29, interval: 1 });
expect(chartValueBounds([{ name: "压力", data: [9999, 28.14, 29.03, -9999] }], 1, 2, true))
.toEqual({ min: 28, max: 30, interval: 1 });
});
it("preserves negative flow and provides distinct integer ticks for zero flow", () => {
expect(chartValueBounds([{ name: "反向流量", data: [-108.8, 12.125] }], 0, 1, true))
.toEqual({ min: -150, max: 50, interval: 50 });
expect(chartValueBounds([{ name: "零流量", data: [0, 0] }], 0, 1, true))
.toEqual({ min: -1, max: 1, interval: 1 });
});
it("limits integer ticks to confirmed monitoring names and units while preserving generic small ranges", () => {
for (const name of ["压力(m)", "压力 ( m )", "压力水头(m)", "水头(m)", "流量(m³/h)", "流量 ( m3 / h )", "管流(m³/h)"])
expect(chartUsesIntegerTicks(name)).toBe(true);
for (const name of [undefined, "压力(MPa)", "流量(L/s)", "比率", ""])
expect(chartUsesIntegerTicks(name)).toBe(false);
const series = [{ name: "压力(MPa)", data: [0.2131, 0.2149] }];
const bounds = chartValueBounds(series, 0, 1, chartUsesIntegerTicks("压力(MPa)"));
expect(bounds.min).toBeGreaterThan(0.21);
expect(bounds.max).toBeLessThan(0.22);
expect(bounds).not.toHaveProperty("interval");
});
it("preserves the original historical date in tooltip and marks missing values explicitly", () => { it("preserves the original historical date in tooltip and marks missing values explicitly", () => {
const series = [{ name: "当前", data: [28], sample_times: ["2026-06-14T12:00:00+08:00"] }, const series = [{ name: "当前", data: [28], sample_times: ["2026-06-14T12:00:00+08:00"] },
{ name: "前一日", type: "scatter" as const, data: [37], sample_times: ["2026-06-13T12:00:00+08:00"] }, { name: "前一日", type: "scatter" as const, data: [37], sample_times: ["2026-06-13T12:00:00+08:00"] },
@@ -9,13 +9,24 @@ export function chartSampleTime(time: string | null | undefined): string {
return time ? formatDisplayTimestamp(time) : ""; return time ? formatDisplayTimestamp(time) : "";
} }
export function chartValueBounds(series: SafeChartSeries[], start: number, end: number) { /** Only confirmed monitoring units opt into integer ticks; small generic units retain their scale. */
export function chartUsesIntegerTicks(axisName: string | undefined): boolean {
const normalized = axisName?.replace(/\s+/g, "").replace(/(/g, "(").replace(/)/g, ")").replace(/³/g, "3").toLowerCase();
return /^(?:(?:压力|压力水头|水头)\(m\)|(?:流量|管流)\(m3\/h\))$/.test(normalized ?? "");
}
export function chartValueBounds(series: SafeChartSeries[], start: number, end: number, integerTicks = false) {
const values = series.flatMap((item) => item.data.slice(start, end + 1)) const values = series.flatMap((item) => item.data.slice(start, end + 1))
.filter((value): value is number => value !== null && Number.isFinite(value)); .filter((value): value is number => value !== null && Number.isFinite(value));
if (!values.length) return {}; if (!values.length) return {};
const min = Math.min(...values), max = Math.max(...values); const min = Math.min(...values), max = Math.max(...values);
const padding = (max - min || Math.abs(min) * 0.1 || 1) * 0.08; const padding = (max - min || Math.abs(min) * 0.1 || 1) * 0.08;
return { min: min - padding, max: max + padding }; const bounds = { min: min - padding, max: max + padding };
if (!integerTicks) return bounds;
const target = Math.max(1, (bounds.max - bounds.min) / 4);
const magnitude = 10 ** Math.floor(Math.log10(target));
const interval = ([1, 2, 5, 10].find((step) => step * magnitude >= target) ?? 10) * magnitude;
return { min: Math.floor(bounds.min / interval) * interval, max: Math.ceil(bounds.max / interval) * interval, interval };
} }
/** The axis label is a projected comparison time; the sample timestamp remains authoritative. */ /** The axis label is a projected comparison time; the sample timestamp remains authoritative. */
+169 -133
View File
@@ -1,6 +1,6 @@
import { memo, useContext, useLayoutEffect, useRef, useState } from "react"; import { memo, useContext, useLayoutEffect, useMemo, useRef, useState } from "react";
import { motion, useAnimationControls, useReducedMotion } from "motion/react"; import { motion, useAnimationControls, useReducedMotion } from "motion/react";
import { BarChart3, X, Database, SlidersHorizontal, Table2, TrendingUp } from "lucide-react"; import { BarChart3, X, Database, FileText, SlidersHorizontal, Table2, TrendingUp } from "lucide-react";
import { FloatingWindow } from "@/shared/ui/floating-window"; import { FloatingWindow } from "@/shared/ui/floating-window";
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/shared/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs";
@@ -11,8 +11,9 @@ import { InteractiveDataTable } from "./interactive-data-table";
import { ResponsiveChart } from "./responsive-chart"; import { ResponsiveChart } from "./responsive-chart";
import { agentEase } from "../components/agent-motion"; import { agentEase } from "../components/agent-motion";
import type { SafeChartSeries } from "../chart-data"; import type { SafeChartSeries } from "../chart-data";
import { chartPointTooltip, chartSampleTime, chartValueBounds, chartValueLabel } from "./chart-presentation"; import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { AnswerInteractionContext } from "./answer-ui-interaction-context"; import { AnswerInteractionContext } from "./answer-ui-interaction-context";
import { chartPointTooltip, chartSampleTime, chartUsesIntegerTicks, chartValueBounds, chartValueLabel } from "./chart-presentation";
export type AnswerChartProps = { export type AnswerChartProps = {
title: string; title: string;
@@ -35,7 +36,7 @@ function PointSelect({ label, value, labels, onChange }: {
<SelectContent>{labels.map((text, index) => <SelectItem key={index} value={String(index)}>{text}</SelectItem>)}</SelectContent> <SelectContent>{labels.map((text, index) => <SelectItem key={index} value={String(index)}>{text}</SelectItem>)}</SelectContent>
</Select>; </Select>;
} }
type ChartPresentation = { expanded?: boolean; plotHeight?: number; embedded?: boolean; xAxisName?: string; yAxisName?: string }; type ChartPresentation = { tile?: boolean; expanded?: boolean; plotHeight?: number; embedded?: boolean; xAxisName?: string; yAxisName?: string };
const standaloneRegistry = {}; const standaloneRegistry = {};
/** Legacy chart envelopes use the same controls and chart engine as structured answers. */ /** Legacy chart envelopes use the same controls and chart engine as structured answers. */
@@ -47,7 +48,9 @@ export function StandaloneInteractiveChart(props: AnswerChartProps & ChartPresen
} }
export const InteractiveChart = memo(function InteractiveChartView(props: AnswerChartProps & ChartPresentation) { export const InteractiveChart = memo(function InteractiveChartView(props: AnswerChartProps & ChartPresentation) {
const { chartWindowKey } = useContext(AnswerInteractionContext); const { chartPresentation } = useContext(AnswerInteractionContext);
const tile = props.tile ?? chartPresentation === "tile";
const fitted = Boolean(props.expanded || tile);
const [{ view, hidden, range, point, inspecting, table }, patch, migrationNotice] = useAnswerNodeState<AnswerChartState>("Chart", props); const [{ view, hidden, range, point, inspecting, table }, patch, migrationNotice] = useAnswerNodeState<AnswerChartState>("Chart", props);
const reducedMotion = useReducedMotion(); const reducedMotion = useReducedMotion();
const pointer = useRef(false); const pointer = useRef(false);
@@ -56,13 +59,13 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer
const [plotWidth, setPlotWidth] = useState(360); const [plotWidth, setPlotWidth] = useState(360);
useLayoutEffect(() => { useLayoutEffect(() => {
const element = plot.current; const element = plot.current;
if (props.expanded || !element || typeof ResizeObserver === "undefined") return; if (fitted || !element || typeof ResizeObserver === "undefined") return;
const resize = () => setPlotWidth(element.clientWidth); const resize = () => setPlotWidth(element.clientWidth);
resize(); resize();
const observer = new ResizeObserver(resize); const observer = new ResizeObserver(resize);
observer.observe(element); observer.observe(element);
return () => observer.disconnect(); return () => observer.disconnect();
}, [view, props.expanded]); }, [view, fitted]);
const contentMotion = useAnimationControls(); const contentMotion = useAnimationControls();
const pendingTransition = useRef(false); const pendingTransition = useRef(false);
useLayoutEffect(() => { useLayoutEffect(() => {
@@ -75,7 +78,7 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer
}, [contentMotion, view]); }, [contentMotion, view]);
const setView = (next: string) => { const setView = (next: string) => {
if (next === view) return; if (next === view) return;
const animate = pointer.current && !reducedMotion; const animate = pointer.current && !reducedMotion && !tile;
setAnimateInteraction(animate); setAnimateInteraction(animate);
contentMotion.stop(); contentMotion.stop();
contentMotion.set(animate ? { opacity: 0, y: 4 } : { opacity: 1, y: 0 }); contentMotion.set(animate ? { opacity: 0, y: 4 } : { opacity: 1, y: 0 });
@@ -85,8 +88,11 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer
const setRange = (range: [number, number]) => patch({ range, table: { query: "", sort: null, ...table, selection: [] } }, ["range"]); const setRange = (range: [number, number]) => patch({ range, table: { query: "", sort: null, ...table, selection: [] } }, ["range"]);
const setPoint = (point: number) => patch({ point }); const setPoint = (point: number) => patch({ point });
const setInspecting = (inspecting: boolean) => patch({ inspecting }); const setInspecting = (inspecting: boolean) => patch({ inspecting });
const visible = props.series.filter((_, index) => !hidden.includes(index)); const hiddenKey = hidden.join(",");
const labels = props.x_data.slice(range[0], range[1] + 1); const hiddenSeries = useMemo(() => new Set(hiddenKey.split(",").filter(Boolean).map(Number)), [hiddenKey]);
const visible = useMemo(() => props.series.filter((_, index) => !hiddenSeries.has(index)), [props.series, hiddenSeries]);
const [rangeStart, rangeEnd] = range;
const labels = useMemo(() => props.x_data.slice(rangeStart, rangeEnd + 1), [props.x_data, rangeStart, rangeEnd]);
const pointIndex = Math.min(range[1], Math.max(range[0], point)); const pointIndex = Math.min(range[1], Math.max(range[0], point));
const fullRange = range[0] === 0 && range[1] === props.x_data.length - 1; const fullRange = range[0] === 0 && range[1] === props.x_data.length - 1;
const summaries = visible.map((series) => { const summaries = visible.map((series) => {
@@ -98,61 +104,63 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer
const categoryColors = visible.length === 1; const categoryColors = visible.length === 1;
const horizontalBars = props.x_data.some((label) => label.length > 10); const horizontalBars = props.x_data.some((label) => label.length > 10);
const numberLabel = chartValueLabel; const numberLabel = chartValueLabel;
const option = (type: "line" | "bar") => { const xAxisName = props.xAxisName ?? props.x_axis_name;
const horizontal = type === "bar" && horizontalBars; const yAxisName = props.yAxisName ?? props.y_axis_name;
const seriesType = (series: SafeChartSeries) => view === (props.chart_type === "bar" ? "bar" : "line") ? series.type ?? type : type; // Keep tooltip functions and options stable across sizing, focus and table-only updates.
const includesBars = visible.some((series) => seriesType(series) === "bar"); const options = useMemo(() => {
const categoryAxis = { type: "category", data: labels, boundaryGap: type === "bar", const option = (type: "line" | "bar") => {
axisLabel: { fontSize: 11, color: "#64748b", hideOverlap: !horizontal, interval: horizontal ? 0 : "auto", const horizontal = type === "bar" && horizontalBars;
...(horizontal ? { width: props.expanded ? 150 : Math.min(210, Math.max(96, plotWidth * 0.28)), overflow: "break", lineHeight: 16 } : {}) }, const horizontalAxisName = horizontal ? yAxisName : xAxisName;
axisTick: { alignWithLabel: true }, axisLine: { lineStyle: { color: "#cbd5e1" } } }; const verticalAxisName = horizontal ? xAxisName : yAxisName;
const valueAxis = { type: "value", scale: !includesBars, const seriesType = (series: SafeChartSeries) => view === (props.chart_type === "bar" ? "bar" : "line") ? series.type ?? type : type;
...(!includesBars ? chartValueBounds(visible, range[0], range[1]) : {}), const includesBars = visible.some((series) => seriesType(series) === "bar");
axisLabel: { fontSize: 11, color: "#64748b", formatter: numberLabel }, const categoryAxis = { type: "category", data: labels, boundaryGap: type === "bar",
splitLine: { lineStyle: { color: "#e2e8f0" } } }; axisLabel: { fontSize: tile ? 10 : 11, color: "#64748b", hideOverlap: !horizontal, interval: horizontal ? 0 : "auto",
return { ...(tile ? { margin: 4, lineHeight: 12 } : {}),
animation: animateInteraction && !reducedMotion, animationDuration: 180, animationDurationUpdate: 180, ...(horizontal ? { width: fitted && !tile ? 150 : Math.min(210, Math.max(96, plotWidth * 0.28)), overflow: "break", lineHeight: tile ? 12 : 16 } : {}) },
textStyle: { fontFamily: '"Noto Sans SC Variable", "PingFang SC", "Microsoft YaHei", sans-serif' }, axisTick: { alignWithLabel: true }, axisLine: { lineStyle: { color: "#cbd5e1" } } };
color: colors.filter((_, index) => !hidden.includes(index)), aria: { enabled: true }, const valueAxis = { type: "value", scale: !includesBars,
tooltip: { trigger: composite ? "item" : "axis", renderMode: composite ? "html" : "richText", confine: true, ...(!includesBars ? chartValueBounds(visible, rangeStart, rangeEnd, chartUsesIntegerTicks(yAxisName)) : {}),
textStyle: { fontFamily: '"Noto Sans SC Variable", "PingFang SC", "Microsoft YaHei", sans-serif', fontSize: 12 }, ...(chartUsesIntegerTicks(yAxisName) ? { minInterval: 1 } : {}),
...(composite ? { formatter: (params: unknown) => { axisLabel: { fontSize: tile ? 10 : 11, color: "#64748b", formatter: numberLabel,
const first = (Array.isArray(params) ? params[0] : params) as { dataIndex?: number; seriesIndex?: number } | undefined; ...(tile ? { margin: 4, lineHeight: 12 } : {}) },
const focused = composite && typeof first?.seriesIndex === "number" ? visible.slice(first.seriesIndex, first.seriesIndex + 1) : visible; splitLine: { lineStyle: { color: "#e2e8f0" } } };
const text = typeof first?.dataIndex === "number" return {
? chartPointTooltip(labels[first.dataIndex], focused, range[0] + first.dataIndex) : ""; animation: animateInteraction && !reducedMotion, animationDuration: 180, animationDurationUpdate: 180,
// ECharts richText tooltips omit the font family. Plain DOM text keeps CJK textStyle: { fontFamily: '"Noto Sans SC Variable", "PingFang SC", "Microsoft YaHei", sans-serif' },
// readable and never interprets model-provided labels as HTML. color: colors.filter((_, index) => !hiddenSeries.has(index)), aria: { enabled: true },
const content = document.createElement("div"); tooltip: { trigger: composite ? "item" : "axis", renderMode: composite ? "html" : "richText", confine: true,
content.textContent = text; textStyle: { fontFamily: '"Noto Sans SC Variable", "PingFang SC", "Microsoft YaHei", sans-serif', fontSize: 12 },
content.style.whiteSpace = "pre-line"; ...(composite ? { formatter: (params: unknown) => {
return content; const first = (Array.isArray(params) ? params[0] : params) as { dataIndex?: number; seriesIndex?: number } | undefined;
} } : {}), axisPointer: { type: type === "bar" ? "shadow" : "line" } }, const focused = composite && typeof first?.seriesIndex === "number" ? visible.slice(first.seriesIndex, first.seriesIndex + 1) : visible;
grid: { top: 28, right: 24, bottom: (props.xAxisName ?? props.x_axis_name) ? 48 : 28, left: 12, containLabel: true }, const text = typeof first?.dataIndex === "number"
xAxis: { ...(horizontal ? valueAxis : categoryAxis), name: horizontal ? props.yAxisName ?? props.y_axis_name : props.xAxisName ?? props.x_axis_name, ? chartPointTooltip(labels[first.dataIndex], focused, rangeStart + first.dataIndex) : "";
nameLocation: "middle", nameGap: 30 }, // ECharts richText tooltips omit the font family. Plain DOM text keeps CJK
yAxis: horizontal ? { ...categoryAxis, inverse: true, name: props.xAxisName ?? props.x_axis_name } : { ...valueAxis, name: props.yAxisName ?? props.y_axis_name }, // readable and never interprets model-provided labels as HTML.
series: visible.map((series) => ({ name: series.name, type: seriesType(series), const content = document.createElement("div");
data: series.data.slice(range[0], range[1] + 1).map((value, index) => type === "bar" && categoryColors content.textContent = text;
? { value, itemStyle: { color: colors[(range[0] + index) % colors.length] } } : value), content.style.whiteSpace = "pre-line";
barMaxWidth: 32, barCategoryGap: "35%", connectNulls: false, return content;
showSymbol: labels.length <= 24, symbolSize: seriesType(series) === "scatter" ? 8 : 6, emphasis: { focus: "series" } })) } } : {}), axisPointer: { type: type === "bar" ? "shadow" : "line" } },
grid: { top: tile ? verticalAxisName ? 16 : 10 : 28, right: 24,
bottom: tile ? horizontalAxisName ? 24 : 16 : xAxisName ? 48 : 28, left: 12, containLabel: true },
xAxis: { ...(horizontal ? valueAxis : categoryAxis), name: horizontalAxisName,
nameLocation: "middle", nameGap: tile ? 16 : 30,
...(tile ? { nameTextStyle: { fontSize: 10, lineHeight: 12 } } : {}) },
yAxis: { ...(horizontal ? { ...categoryAxis, inverse: true } : valueAxis), name: verticalAxisName,
...(tile ? { nameGap: 4, nameTextStyle: { fontSize: 10, lineHeight: 12, align: "left" } } : {}) },
series: visible.map((series) => ({ name: series.name, type: seriesType(series),
data: series.data.slice(rangeStart, rangeEnd + 1).map((value, index) => type === "bar" && categoryColors
? { value, itemStyle: { color: colors[(rangeStart + index) % colors.length] } } : value),
barMaxWidth: 32, barCategoryGap: "35%", connectNulls: false,
showSymbol: labels.length <= 24, symbolSize: seriesType(series) === "scatter" ? 8 : 6, emphasis: { focus: "series" } }))
};
}; };
}; return { line: option("line"), bar: option("bar") };
const metadata = <> }, [animateInteraction, categoryColors, composite, fitted, hiddenSeries, horizontalBars, labels, numberLabel, plotWidth,
{!props.embedded && view !== "table" ? <div className="rounded-lg bg-muted/40 p-2.5" role="group" aria-label="当前区间摘要"> props.chart_type, rangeStart, rangeEnd, reducedMotion, tile, view, visible, xAxisName, yAxisName]);
<div className="mb-2 flex flex-wrap items-center justify-between gap-1"> const pointDetails = view !== "table" && inspecting ? <motion.section aria-label="数据点详情" className="rounded-lg border border-border p-2.5"
<span className="text-[11px] font-medium">区间摘要 <span className="font-normal text-muted-foreground">· 本地统计</span></span>
</div>
<div className="space-y-2">
{summaries.map((item) => <div key={item.name} className="answer-chart-range-summary text-[11px] leading-5">
<span className="min-w-0 break-words text-muted-foreground">{item.name}</span>
<span className="tabular-nums">{numberLabel(item.first)} → {numberLabel(item.last)}</span>
<span className="text-muted-foreground">最小 {numberLabel(item.min)} · 最大 {numberLabel(item.max)}</span>
</div>)}
</div>
</div> : null}
{view !== "table" && inspecting ? <motion.section aria-label="数据点详情" className="rounded-lg border border-border p-2.5"
initial={pointer.current && !reducedMotion ? { opacity: 0, y: 4 } : false} initial={pointer.current && !reducedMotion ? { opacity: 0, y: 4 } : false}
animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.18, ease: agentEase }}> animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.18, ease: agentEase }}>
<div className="mb-2 flex items-center justify-between gap-2"> <div className="mb-2 flex items-center justify-between gap-2">
@@ -170,69 +178,26 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer
</dd> </dd>
</div>)} </div>)}
</dl> </dl>
</motion.section> : null} </motion.section> : null;
const metadata = <>
{!props.embedded && !tile && view !== "table" ? <div className="rounded-lg bg-muted/40 p-2.5" role="group" aria-label="当前区间摘要">
<div className="mb-2 flex flex-wrap items-center justify-between gap-1">
<span className="text-[11px] font-medium">区间摘要 <span className="font-normal text-muted-foreground">· 本地统计</span></span>
</div>
<div className="space-y-2">
{summaries.map((item) => <div key={item.name} className="answer-chart-range-summary text-[11px] leading-5">
<span className="min-w-0 break-words text-muted-foreground">{item.name}</span>
<span className="tabular-nums">{numberLabel(item.first)} → {numberLabel(item.last)}</span>
<span className="text-muted-foreground">最小 {numberLabel(item.min)} · 最大 {numberLabel(item.max)}</span>
</div>)}
</div>
</div> : null}
{pointDetails}
<p className="flex items-start gap-1.5 border-t pt-2.5 text-[11px] leading-5 text-muted-foreground"> <p className="flex items-start gap-1.5 border-t pt-2.5 text-[11px] leading-5 text-muted-foreground">
<Database size={12} className="mt-1 shrink-0" /><span className="min-w-0 break-words [overflow-wrap:anywhere]">{fullRange ? labels.length : `${labels.length} / ${props.x_data.length}`} 个数据点 · {visible.length} 组序列<br />来源:{props.source}</span> <Database size={12} className="mt-1 shrink-0" /><span className="min-w-0 break-words [overflow-wrap:anywhere]">{fullRange ? labels.length : `${labels.length} / ${props.x_data.length}`} 个数据点 · {visible.length} 组序列{props.embedded ? "" : <><br />来源:{props.source}</>}</span>
</p> </p>
</>; </>;
return <Collapsible asChild><Card className={`answer-ui-chart overflow-hidden ${props.embedded || props.expanded ? "border-0 shadow-none" : ""} ${props.expanded ? "@container flex h-full min-h-0 min-w-0 flex-1 flex-col" : ""}`} role={props.embedded ? undefined : "group"} aria-label={props.embedded ? undefined : props.title} const rangeControls = <div className="space-y-2">
onPointerDownCapture={() => { pointer.current = true; }}
onKeyDownCapture={() => {
pointer.current = false;
setAnimateInteraction(false);
contentMotion.stop();
contentMotion.set({ opacity: 1, y: 0 });
}}>
<CardHeader className="shrink-0 flex-row items-center justify-between gap-2 space-y-0 px-3.5 py-2.5">
<CardTitle className="flex min-w-0 items-center gap-2 text-xs leading-5 tracking-normal"><TrendingUp size={15} className="shrink-0" aria-hidden />{props.embedded || props.expanded ? "图形与数据" : props.title}</CardTitle>
<div className="flex shrink-0 items-center gap-1">
{!props.embedded && !props.expanded ? <FloatingWindow title={props.title} fillContent
openRequestKey={chartWindowKey === undefined ? undefined : JSON.stringify([chartWindowKey, props])}>
{() => <InteractiveChart {...props} expanded />}
</FloatingWindow> : null}
<CollapsibleTrigger asChild>
<Button type="button" variant="ghost" size="icon-sm" aria-label="筛选数据范围" title="筛选数据范围"
className="shrink-0 text-muted-foreground">
<SlidersHorizontal size={14} aria-hidden />
</Button>
</CollapsibleTrigger>
</div>
</CardHeader>
<CardContent className={`${props.expanded ? "flex min-h-0 flex-1 flex-col" : "space-y-3"} px-3.5 pb-3.5 pt-0`}>
{migrationNotice ? <p role="status" className="text-xs leading-5 text-muted-foreground">{migrationNotice}</p> : null}
<Tabs value={view} onValueChange={setView} className={props.expanded ? "grid min-h-0 flex-1 grid-cols-1 grid-rows-[auto_auto_minmax(120px,1fr)_minmax(0,auto)] gap-x-3 gap-y-2 @min-[560px]:grid-cols-[minmax(0,1fr)_176px] @min-[560px]:grid-rows-[auto_auto_minmax(120px,1fr)]" : undefined}>
<TabsList aria-label={`${props.title}显示方式`} className={`${props.expanded ? "col-start-1 row-start-1 min-w-0" : "mb-3"} relative grid w-full grid-cols-3 [&>[role=tab]]:relative [&>[role=tab]]:z-10 [&>[role=tab][data-state=active]]:bg-transparent [&>[role=tab][data-state=active]]:shadow-none`}>
<motion.span aria-hidden className="pointer-events-none absolute inset-y-1 left-1 rounded-md bg-card shadow-xs"
style={{ width: "calc((100% - 0.5rem) / 3)" }}
initial={false} animate={{ x: view === "line" ? "0%" : view === "bar" ? "100%" : "200%" }}
transition={{ duration: animateInteraction && !reducedMotion ? 0.18 : 0, ease: agentEase }} />
<TabsTrigger value="line"><TrendingUp size={13} />{composite ? "复合" : "折线"}</TabsTrigger>
<TabsTrigger value="bar"><BarChart3 size={13} />柱状</TabsTrigger>
<TabsTrigger value="table"><Table2 size={13} />数据</TabsTrigger>
</TabsList>
<aside className={props.expanded ? "col-start-1 row-start-4 max-h-32 min-h-0 min-w-0 space-y-2 overflow-y-auto border-t border-border pt-2 @min-[560px]:col-start-2 @min-[560px]:row-start-1 @min-[560px]:row-span-3 @min-[560px]:max-h-none @min-[560px]:border-t-0 @min-[560px]:border-l @min-[560px]:pt-0 @min-[560px]:pl-3" : undefined}>
<div className={`answer-ui-chart-series mb-2 flex flex-wrap gap-2 ${props.expanded ? "@min-[560px]:flex-col @min-[560px]:flex-nowrap @min-[560px]:items-stretch" : ""}`} aria-label="显示的数据序列">
{props.series.map((series, index) => <Button key={index} size="sm" variant="outline"
className={`h-auto min-h-8 min-w-0 max-w-full gap-1.5 whitespace-normal text-left text-[11px] after:inset-0 ${props.expanded ? "justify-start" : ""}`} aria-pressed={!hidden.includes(index)}
disabled={visible.length === 1 && !hidden.includes(index)}
onClick={() => { setAnimateInteraction(pointer.current && !reducedMotion); patch({ hidden: hidden.includes(index) ? hidden.filter((item) => item !== index) : [...hidden, index],
table: { query: "", ...table, sort: null, selection: [] } }, ["hidden", "table/sort"]); }}>
<span className="size-2 shrink-0 rounded-full" style={{ background: hidden.includes(index) ? "#cbd5e1" : colors[index] }} /><span className="min-w-0 break-words">{series.name}</span>
</Button>)}
</div>
{hidden.length ? <Button type="button" variant="ghost" size="sm" onClick={() => patch({ hidden: [] }, ["hidden"])}>显示全部序列</Button> : null}
{view === "bar" && categoryColors ? <div aria-label="类别图例" className={`mb-3 grid gap-1.5 rounded-md bg-muted/30 p-2 ${props.expanded ? "" : "max-h-40 overflow-y-auto"}`} style={{ gridTemplateColumns: `repeat(${props.expanded ? 1 : plotWidth >= 700 ? 3 : plotWidth >= 420 ? 2 : 1},minmax(0,1fr))` }}>
{labels.map((label, index) => <div key={index} className="flex min-w-0 items-start gap-2 text-[11px] leading-5">
<span className="mt-1.5 size-2 shrink-0 rounded-sm" style={{ background: colors[(range[0] + index) % colors.length] }} />
<span className="min-w-0 break-words">{label}</span>
<span className="ml-auto shrink-0 tabular-nums">{numberLabel(visible[0].data[range[0] + index])}</span>
</div>)}
</div> : null}
{props.expanded ? metadata : null}
</aside>
<div className={props.expanded ? "col-start-1 row-start-2 min-w-0" : undefined}>
{!fullRange ? <p className="mb-2 text-[11px] text-muted-foreground">{props.x_data[range[0]]} 至 {props.x_data[range[1]]}</p> : null}
<CollapsibleContent className="mb-2 space-y-2 rounded-lg border bg-muted/30 p-2.5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<PointSelect label="区间起点" value={range[0]} labels={props.x_data} <PointSelect label="区间起点" value={range[0]} labels={props.x_data}
onChange={(start) => setRange([start, Math.max(start, range[1])])} /> onChange={(start) => setRange([start, Math.max(start, range[1])])} />
@@ -242,12 +207,63 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer
</div> </div>
<Button type="button" variant="ghost" size="sm" disabled={fullRange} <Button type="button" variant="ghost" size="sm" disabled={fullRange}
onClick={() => setRange([0, props.x_data.length - 1])}>重置范围</Button> onClick={() => setRange([0, props.x_data.length - 1])}>重置范围</Button>
</CollapsibleContent> </div>;
return <Collapsible asChild><Card className={`answer-ui-chart overflow-hidden ${props.embedded || props.expanded ? "border-0 shadow-none" : ""} ${fitted ? "@container flex h-full min-h-0 min-w-0 flex-1 flex-col" : ""}`} data-chart-tile={tile || undefined} role={props.embedded ? undefined : "group"} aria-label={props.embedded ? undefined : props.title}
onPointerDownCapture={() => { pointer.current = true; }}
onKeyDownCapture={() => {
pointer.current = false;
setAnimateInteraction(false);
contentMotion.stop();
contentMotion.set({ opacity: 1, y: 0 });
}}>
{!props.embedded ? <CardHeader className={`shrink-0 flex-row items-center justify-between gap-2 space-y-0 ${tile ? "px-2 py-1.5" : "px-3.5 py-2.5"}`}>
<CardTitle className="flex min-w-0 items-center gap-2 text-xs leading-5 tracking-normal"><TrendingUp size={15} className="shrink-0" aria-hidden />{props.expanded ? "图形与数据" : props.title}</CardTitle>
<div className={`flex shrink-0 items-center gap-1 ${tile ? "[&>button]:size-6" : ""}`}>
{!props.embedded && !props.expanded ? <FloatingWindow title={props.title} fillContent>
{() => <InteractiveChart {...props} expanded tile={false} />}
</FloatingWindow> : null}
{tile ? <Popover><PopoverTrigger asChild>
<Button type="button" variant="ghost" size="icon-sm" aria-label="查看图表来源" title="查看图表来源"><FileText size={14} aria-hidden /></Button>
</PopoverTrigger><PopoverContent align="end" className="workbench-shadcn-surface z-[90] max-h-72 overflow-auto text-xs leading-5">{props.source}</PopoverContent></Popover> : <CollapsibleTrigger asChild>
<Button type="button" variant="ghost" size="icon-sm" aria-label="筛选数据范围" title="筛选数据范围" className="shrink-0 text-muted-foreground"><SlidersHorizontal size={14} aria-hidden /></Button>
</CollapsibleTrigger>}
</div>
</CardHeader> : null}
<CardContent className={`${fitted ? "flex min-h-0 flex-1 flex-col" : "space-y-3"} ${props.embedded ? "px-0 pb-0" : tile ? "px-2 pb-2" : "px-3.5 pb-3.5"} pt-0`}>
{migrationNotice ? <p role="status" className="text-xs leading-5 text-muted-foreground">{migrationNotice}</p> : null}
<Tabs value={view} onValueChange={setView} className={fitted ? `grid min-h-0 flex-1 grid-cols-1 ${tile ? "grid-rows-[auto_minmax(100px,1fr)_minmax(32px,auto)] gap-y-1" : "grid-rows-[auto_auto_minmax(180px,1fr)_auto] gap-y-2"}` : undefined}>
<div className={`flex min-w-0 items-center gap-2 ${fitted ? "col-start-1 row-start-1" : "mb-3"}`}>
<TabsList aria-label={`${props.title}显示方式`} className={`relative grid min-w-0 flex-1 grid-cols-3 ${tile ? "min-h-7 p-0.5" : ""} [&>[role=tab]]:relative [&>[role=tab]]:z-10 [&>[role=tab][data-state=active]]:bg-transparent [&>[role=tab][data-state=active]]:shadow-none`}>
<motion.span aria-hidden className="pointer-events-none absolute inset-y-1 left-1 rounded-md bg-card shadow-xs"
style={{ width: "calc((100% - 0.5rem) / 3)" }}
initial={false} animate={{ x: view === "line" ? "0%" : view === "bar" ? "100%" : "200%" }}
transition={{ duration: animateInteraction && !reducedMotion ? 0.18 : 0, ease: agentEase }} />
<TabsTrigger value="line" className={tile ? "min-h-6 px-1.5 py-0.5 text-[11px]" : undefined}><TrendingUp size={13} />{composite ? "复合" : "折线"}</TabsTrigger>
<TabsTrigger value="bar" className={tile ? "min-h-6 px-1.5 py-0.5 text-[11px]" : undefined}><BarChart3 size={13} />柱状</TabsTrigger>
<TabsTrigger value="table" className={tile ? "min-h-6 px-1.5 py-0.5 text-[11px]" : undefined}><Table2 size={13} />数据</TabsTrigger>
</TabsList>
{tile ? <>
<Popover><PopoverTrigger asChild><Button type="button" variant="ghost" size="icon-sm" aria-label="筛选数据范围" title="筛选数据范围" className={`h-7 shrink-0 ${fullRange ? "w-7" : "w-auto gap-1 px-1.5"}`}><SlidersHorizontal size={14} aria-hidden />
{!fullRange ? <span className="max-w-20 truncate text-[10px]" title={`${props.x_data[range[0]]} 至 ${props.x_data[range[1]]}`}>{props.x_data[range[0]]} 至 {props.x_data[range[1]]}</span> : null}
</Button></PopoverTrigger>
<PopoverContent align="end" className="workbench-shadcn-surface z-[90] p-3">{rangeControls}</PopoverContent>
</Popover>
{inspecting && view !== "table" ? <Popover open={inspecting} onOpenChange={setInspecting}><PopoverTrigger asChild><Button type="button" variant="ghost" size="icon-sm" aria-label="数据点详情" title="数据点详情" className="size-7 shrink-0"><Database size={14} aria-hidden /></Button></PopoverTrigger>
<PopoverContent align="end" className="workbench-shadcn-surface z-[90] max-h-[60dvh] overflow-auto p-2" onOpenAutoFocus={(event) => event.preventDefault()}>{pointDetails}</PopoverContent>
</Popover> : null}
</> : props.embedded ? <CollapsibleTrigger asChild>
<Button type="button" variant="ghost" size="icon-sm" aria-label="筛选数据范围" title="筛选数据范围"><SlidersHorizontal size={14} aria-hidden /></Button>
</CollapsibleTrigger> : null}
</div> </div>
<motion.div className={props.expanded ? "col-start-1 row-start-3 min-h-0 min-w-0" : undefined} data-chart-view={view} initial={false} animate={contentMotion}>
{(["line", "bar"] as const).map((type) => <TabsContent key={type} value={type} className={props.expanded ? "h-full min-h-0" : undefined}> {!tile ? <div className={fitted ? "col-start-1 row-start-2 min-w-0" : undefined}>
{!fullRange ? <p className="mb-2 text-[11px] text-muted-foreground">{props.x_data[range[0]]} 至 {props.x_data[range[1]]}</p> : null}
{!tile ? <CollapsibleContent className="mb-2 space-y-2 rounded-lg border bg-muted/30 p-2.5">{rangeControls}</CollapsibleContent> : null}
</div> : null}
<motion.div className={fitted ? `col-start-1 ${tile ? "row-start-2" : "row-start-3"} min-h-0 min-w-0` : undefined} data-chart-view={view} initial={false} animate={contentMotion}>
{(["line", "bar"] as const).map((type) => <TabsContent key={type} value={type} className={fitted ? "h-full min-h-0" : undefined}>
<div ref={view === type ? plot : undefined} role="group" aria-label={`${props.title},按回车查看数据点,方向键切换数据点`} tabIndex={0} <div ref={view === type ? plot : undefined} role="group" aria-label={`${props.title},按回车查看数据点,方向键切换数据点`} tabIndex={0}
className={`${props.expanded ? (type === "bar" && horizontalBars ? "h-full min-h-0 overflow-x-hidden overflow-y-auto" : "h-full min-h-0 overflow-hidden") : ""} rounded-md focus-visible:outline-2 focus-visible:outline-ring`} className={`${fitted ? (type === "bar" && horizontalBars ? "h-full min-h-0 overflow-x-hidden overflow-y-auto" : "h-full min-h-0 overflow-hidden") : ""} rounded-md focus-visible:outline-2 focus-visible:outline-ring`}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.target !== event.currentTarget) return; if (event.target !== event.currentTarget) return;
if (["Enter", "ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) { if (["Enter", "ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) {
@@ -259,13 +275,13 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer
if (event.key === "Escape") setInspecting(false); if (event.key === "Escape") setInspecting(false);
}}> }}>
<span className="sr-only">点击图中数据点查看详情,也可使用回车和左右方向键。</span> <span className="sr-only">点击图中数据点查看详情,也可使用回车和左右方向键。</span>
<div role="img" aria-label={props.title} style={{ height: props.expanded ? "100%" : props.plotHeight ?? 220, <div role="img" aria-label={props.title} style={{ height: fitted ? "100%" : props.plotHeight ?? 220,
minHeight: props.expanded && type === "bar" && horizontalBars ? labels.length * 36 + 48 : undefined }}> minHeight: fitted && type === "bar" && horizontalBars ? labels.length * (tile ? 28 : 36) + 48 : undefined }}>
<ResponsiveChart option={option(type)} height="100%" onPointSelect={(index) => patch({ point: range[0] + index, inspecting: true })} /> <ResponsiveChart option={options[type]} height="100%" onPointSelect={(index) => patch({ point: range[0] + index, inspecting: true })} />
</div> </div>
</div> </div>
</TabsContent>)} </TabsContent>)}
<TabsContent value="table" className={props.expanded ? "h-full overflow-auto" : undefined} forceMount style={view === "table" ? undefined : { display: "none" }}> <TabsContent value="table" className={fitted ? "h-full overflow-auto" : undefined} forceMount style={view === "table" ? undefined : { display: "none" }}>
<InteractiveDataTable title={props.title} columns={["时间或类别", ...visible.map((series) => series.name)]} <InteractiveDataTable title={props.title} columns={["时间或类别", ...visible.map((series) => series.name)]}
rows={labels.map((label, index) => [label, ...visible.map((series) => { rows={labels.map((label, index) => [label, ...visible.map((series) => {
const offset = range[0] + index, time = chartSampleTime(series.sample_times?.[offset]); const offset = range[0] + index, time = chartSampleTime(series.sample_times?.[offset]);
@@ -274,7 +290,27 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer
state={table} onStateChange={(updates) => patch({ table: { query: "", sort: null, selection: [], ...table, ...updates } }, Object.keys(updates).map((key) => `table/${key}`))} /> state={table} onStateChange={(updates) => patch({ table: { query: "", sort: null, selection: [], ...table, ...updates } }, Object.keys(updates).map((key) => `table/${key}`))} />
</TabsContent> </TabsContent>
</motion.div> </motion.div>
{!props.expanded ? metadata : null} <div aria-label="图表图例" className={fitted ? `col-start-1 ${tile ? "row-start-3 min-h-8 max-h-14" : `row-start-4 min-h-0 ${props.embedded ? "max-h-56" : "max-h-40"}`} min-w-0 overflow-y-auto border-t border-border ${tile ? "pt-1" : "pt-2"}` : "border-t border-border pt-3"}>
<div className={`answer-ui-chart-series flex flex-wrap ${tile ? "gap-1" : "mb-2 gap-2"}`} aria-label="显示的数据序列">
{props.series.map((series, index) => <Button key={index} size="sm" variant="outline"
className={`h-auto ${tile ? "min-h-6 px-2 py-0.5" : "min-h-8"} min-w-0 max-w-full justify-start gap-1.5 whitespace-normal text-left text-[11px] after:inset-0`} aria-pressed={!hidden.includes(index)}
disabled={visible.length === 1 && !hidden.includes(index)}
onClick={() => { setAnimateInteraction(pointer.current && !reducedMotion && !tile); patch({ hidden: hidden.includes(index) ? hidden.filter((item) => item !== index) : [...hidden, index],
table: { query: "", ...table, sort: null, selection: [] } }, ["hidden", "table/sort"]); }}>
<span className="size-2 shrink-0 rounded-full" style={{ background: hidden.includes(index) ? "#cbd5e1" : colors[index] }} /><span className="min-w-0 break-words">{series.name}</span>
</Button>)}
</div>
{hidden.length ? <Button type="button" variant="ghost" size="sm" onClick={() => patch({ hidden: [] }, ["hidden"])}>显示全部序列</Button> : null}
{view === "bar" && categoryColors ? <div aria-label="类别图例" className={`mb-3 grid gap-1.5 rounded-md bg-muted/30 p-2 ${fitted ? "" : "max-h-40 overflow-y-auto"}`} style={{ gridTemplateColumns: `repeat(${fitted ? 1 : plotWidth >= 700 ? 3 : plotWidth >= 420 ? 2 : 1},minmax(0,1fr))` }}>
{labels.map((label, index) => <div key={index} className="flex min-w-0 items-start gap-2 text-[11px] leading-5">
<span className="mt-1.5 size-2 shrink-0 rounded-sm" style={{ background: colors[(range[0] + index) % colors.length] }} />
<span className="min-w-0 break-words">{label}</span>
<span className="ml-auto shrink-0 tabular-nums">{numberLabel(visible[0].data[range[0] + index])}</span>
</div>)}
</div> : null}
{props.expanded && !tile ? metadata : null}
</div>
{!fitted ? metadata : null}
</Tabs> </Tabs>
</CardContent> </CardContent>
</Card></Collapsible>; </Card></Collapsible>;
@@ -1,5 +1,5 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react"; import { ArrowDown, ArrowUp } from "lucide-react";
import { Input } from "@/shared/ui/input"; import { Input } from "@/shared/ui/input";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { FloatingWindow } from "@/shared/ui/floating-window"; import { FloatingWindow } from "@/shared/ui/floating-window";
@@ -45,13 +45,13 @@ export function InteractiveDataTable({ title, columns, rows, state: controlledSt
<Table> <Table>
<TableCaption className="caption-top font-medium">{title}</TableCaption> <TableCaption className="caption-top font-medium">{title}</TableCaption>
<TableHeader className="bg-muted/50"><TableRow> <TableHeader className="bg-muted/50"><TableRow>
{columns.map((column, index) => <TableHead key={index} scope="col" className="whitespace-nowrap" {columns.map((column, index) => <TableHead key={index} scope="col" className="whitespace-nowrap p-0"
aria-sort={sort?.column === index ? sort.descending ? "descending" : "ascending" : "none"}> aria-sort={sort?.column === index ? sort.descending ? "descending" : "ascending" : "none"}>
<Button size="sm" variant="ghost" className="h-9 gap-1 px-1 text-xs" aria-label={`按${column}排序`} <Button variant="ghost" className="h-10 w-full justify-start gap-1 rounded-none px-4 text-xs focus-visible:ring-inset focus-visible:ring-offset-0" aria-label={`按${column}排序`}
onClick={() => patch({ sort: sort?.column === index onClick={() => patch({ sort: sort?.column === index
? sort.descending ? null : { column: index, descending: true } ? sort.descending ? null : { column: index, descending: true }
: { column: index, descending: false } })}> : { column: index, descending: false } })}>
{column}{sort?.column === index ? sort.descending ? <ArrowDown size={12} /> : <ArrowUp size={12} /> : <ArrowUpDown size={12} />} {column}{sort?.column === index ? sort.descending ? <ArrowDown size={12} aria-hidden /> : <ArrowUp size={12} aria-hidden /> : null}
</Button> </Button>
</TableHead>)} </TableHead>)}
</TableRow></TableHeader> </TableRow></TableHeader>
@@ -0,0 +1,130 @@
import { act, cleanup, render } from "@testing-library/react";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { ResponsiveChart } from "./responsive-chart";
const engine = vi.hoisted(() => ({ resize: vi.fn() }));
vi.mock("echarts-for-react", async () => {
const React = await import("react");
return { default: React.forwardRef(function MockChart({ onChartReady }: { onChartReady?: (instance: typeof engine) => void }, ref) {
React.useImperativeHandle(ref, () => ({ getEchartsInstance: () => engine }), []);
React.useLayoutEffect(() => { onChartReady?.(engine); }, [onChartReady]);
return <div data-testid="chart-engine" />;
}) };
});
const observers: ResizeObserverCallback[] = [];
const frames = new Map<number, FrameRequestCallback>();
let frameId = 0;
beforeEach(() => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "performance"] });
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { frames.set(++frameId, callback); return frameId; });
vi.stubGlobal("cancelAnimationFrame", (id: number) => { frames.delete(id); });
vi.stubGlobal("ResizeObserver", class {
constructor(callback: ResizeObserverCallback) { observers.push(callback); }
observe() {}
disconnect() {}
});
});
afterEach(() => {
cleanup();
engine.resize.mockReset();
observers.length = 0;
frames.clear();
vi.unstubAllGlobals();
vi.useRealTimers();
});
function fixture(resizing: boolean, count = 1) {
const observerStart = observers.length;
const view = render(<div data-floating-window="" data-floating-window-resizing={resizing ? "true" : undefined}>
{Array.from({ length: count }, (_, index) => <ResponsiveChart key={index} option={{}} height="100%" onPointSelect={() => undefined} />)}
</div>);
const shell = view.container.firstElementChild as HTMLElement;
const size = { width: 320, height: 200 };
for (const host of shell.children) Object.defineProperties(host, { clientWidth: { get: () => size.width }, clientHeight: { get: () => size.height } });
const notify = (index = 0) => observers[observerStart + index]([], {} as ResizeObserver);
const paint = () => { const pending = [...frames.values()]; frames.clear(); pending.forEach((callback) => callback(performance.now())); };
engine.resize.mockClear();
return { view, shell, size, notify, paint };
}
it("coalesces active resizes to the latest actual dimensions with a 64ms interval", () => {
const chart = fixture(true);
const sizes: number[] = [];
engine.resize.mockImplementation(() => { sizes.push(chart.size.width); });
chart.notify();
chart.notify();
expect(frames.size).toBe(1);
chart.paint();
act(() => vi.advanceTimersByTime(16));
chart.size.width = 400;
chart.notify();
chart.paint();
expect(engine.resize).toHaveBeenCalledTimes(1);
chart.size.width = 520;
chart.notify();
act(() => vi.advanceTimersByTime(48));
chart.paint();
expect(sizes).toEqual([320, 520]);
expect(engine.resize).toHaveBeenLastCalledWith({ width: "auto", height: "auto", animation: { duration: 0 } });
});
it("spaces charts in the same window from the last completed draw", () => {
const chart = fixture(true, 2);
engine.resize.mockImplementation(() => { vi.advanceTimersByTime(70); });
chart.notify(0);
chart.notify(1);
chart.paint();
expect(engine.resize).toHaveBeenCalledTimes(1);
act(() => vi.advanceTimersByTime(64));
chart.paint();
expect(engine.resize).toHaveBeenCalledTimes(2);
});
it("does not delay a different window behind another window's draw", () => {
const first = fixture(true);
const second = fixture(true);
first.notify();
second.notify();
first.paint();
expect(engine.resize).toHaveBeenCalledTimes(2);
});
it("flushes the final geometry on release without waiting for the remaining interval", async () => {
const chart = fixture(true);
chart.notify();
chart.paint();
act(() => vi.advanceTimersByTime(16));
chart.notify();
chart.paint();
expect(vi.getTimerCount()).toBe(1);
await act(async () => { delete chart.shell.dataset.floatingWindowResizing; await Promise.resolve(); });
expect(vi.getTimerCount()).toBe(0);
chart.paint();
expect(engine.resize).toHaveBeenCalledTimes(2);
});
it("keeps normal layout changes immediate instead of applying pointer throttling", () => {
const chart = fixture(false);
chart.notify();
chart.paint();
act(() => vi.advanceTimersByTime(16));
chart.notify();
chart.paint();
expect(engine.resize).toHaveBeenCalledTimes(2);
expect(vi.getTimerCount()).toBe(0);
});
it("cancels scheduled drawing on unmount", () => {
const chart = fixture(true);
chart.notify();
chart.paint();
act(() => vi.advanceTimersByTime(16));
chart.notify();
chart.paint();
chart.view.unmount();
act(() => vi.advanceTimersByTime(100));
chart.paint();
expect(engine.resize).toHaveBeenCalledTimes(1);
expect(vi.getTimerCount()).toBe(0);
});
@@ -1,7 +1,9 @@
import { useLayoutEffect, useRef, type ComponentProps } from "react"; import { useLayoutEffect, useRef, type ComponentProps } from "react";
import ReactECharts from "echarts-for-react"; import ReactECharts from "echarts-for-react";
/** Resize the existing drawing at most once per frame without replacing its options. */ const resizedWindows = new WeakMap<HTMLElement, number>();
/** Reflow the existing drawing, coalescing rapid pointer resizes without scaling it. */
export function ResponsiveChart({ option, height, onPointSelect }: { export function ResponsiveChart({ option, height, onPointSelect }: {
option: ComponentProps<typeof ReactECharts>["option"]; option: ComponentProps<typeof ReactECharts>["option"];
height: number | "100%"; height: number | "100%";
@@ -13,16 +15,32 @@ export function ResponsiveChart({ option, height, onPointSelect }: {
useLayoutEffect(() => { useLayoutEffect(() => {
const element = host.current; const element = host.current;
if (!element || typeof ResizeObserver === "undefined") return; if (!element || typeof ResizeObserver === "undefined") return;
const shell = element.closest<HTMLElement>("[data-floating-window]");
let frame = 0; let frame = 0;
const observer = new ResizeObserver(() => { let timer = 0;
cancelAnimationFrame(frame); const resize = () => {
frame = requestAnimationFrame(() => { frame = 0;
if (!ready.current || !element.clientWidth || !element.clientHeight) return; if (!ready.current || !element.clientWidth || !element.clientHeight) return;
chart.current?.getEchartsInstance().resize({ width: "auto", height: "auto", animation: { duration: 0 } }); // Space expensive drawings in one window instead of redrawing every chart together.
}); const remaining = 64 - (performance.now() - (shell ? resizedWindows.get(shell) ?? -Infinity : -Infinity));
if (shell?.dataset.floatingWindowResizing === "true" && remaining > 0) {
timer = window.setTimeout(() => { timer = 0; frame = requestAnimationFrame(resize); }, remaining);
return;
}
chart.current?.getEchartsInstance().resize({ width: "auto", height: "auto", animation: { duration: 0 } });
if (shell) resizedWindows.set(shell, performance.now());
};
const schedule = () => { if (!frame && !timer) frame = requestAnimationFrame(resize); };
const observer = new ResizeObserver(schedule);
const settled = new MutationObserver(() => {
if (shell?.dataset.floatingWindowResizing === "true") return;
clearTimeout(timer);
timer = 0;
schedule();
}); });
observer.observe(element); observer.observe(element);
return () => { cancelAnimationFrame(frame); observer.disconnect(); }; if (shell) settled.observe(shell, { attributes: true, attributeFilter: ["data-floating-window-resizing"] });
return () => { clearTimeout(timer); cancelAnimationFrame(frame); observer.disconnect(); settled.disconnect(); };
}, []); }, []);
return <div ref={host} style={{ height }}> return <div ref={host} style={{ height }}>
<ReactECharts ref={chart} option={option} opts={{ renderer: "svg" }} notMerge autoResize={false} <ReactECharts ref={chart} option={option} opts={{ renderer: "svg" }} notMerge autoResize={false}
@@ -72,13 +72,13 @@ function AgentUiResultCard({ result }: { result: AgentUiResult }) {
); );
} }
export const AgentChartView = memo(function AgentChartView({ chart, embedded = false, expanded = false }: { chart: NonNullable<ReturnType<typeof parseFrontendChart>>; embedded?: boolean; expanded?: boolean }) { export const AgentChartView = memo(function AgentChartView({ chart, embedded = false, expanded = false, plotHeight, tile = false }: { chart: NonNullable<ReturnType<typeof parseFrontendChart>>; embedded?: boolean; expanded?: boolean; plotHeight?: number; tile?: boolean }) {
return <SafeChartEnvelope embedded={embedded} expanded={expanded} envelope={{ kind: "chart", schemaVersion: "agent-ui@1", grammar: "echarts-safe-subset", surface: "side_panel", spec: { title: chart.title, chart_type: chart.chart_type, return <SafeChartEnvelope embedded={embedded} expanded={expanded} plotHeight={plotHeight} tile={tile} envelope={{ kind: "chart", schemaVersion: "agent-ui@1", grammar: "echarts-safe-subset", surface: "side_panel", spec: { title: chart.title, chart_type: chart.chart_type,
...(chart.x_axis_name ? { x_axis_name: chart.x_axis_name } : {}), ...(chart.x_axis_name ? { x_axis_name: chart.x_axis_name } : {}),
...(chart.y_axis_name ? { y_axis_name: chart.y_axis_name } : {}) }, data: { x_data: chart.x_data, series: chart.series } }} />; ...(chart.y_axis_name ? { y_axis_name: chart.y_axis_name } : {}) }, data: { x_data: chart.x_data, series: chart.series } }} />;
}); });
function SafeChartEnvelope({ envelope, embedded = false, expanded = false }: { envelope: Extract<UIEnvelope, { kind: "chart" }>; embedded?: boolean; expanded?: boolean }) { function SafeChartEnvelope({ envelope, embedded = false, expanded = false, plotHeight, tile = false }: { envelope: Extract<UIEnvelope, { kind: "chart" }>; embedded?: boolean; expanded?: boolean; plotHeight?: number; tile?: boolean }) {
const parsed = parseChartSpec(envelope.spec); const parsed = parseChartSpec(envelope.spec);
const raw = normalizeSafeChartData(envelope.data, 100); const raw = normalizeSafeChartData(envelope.data, 100);
const displayed = withChartDisplayTimes({ title: parsed.title ?? "Agent 图表", x_axis_name: parsed.xAxisName, const displayed = withChartDisplayTimes({ title: parsed.title ?? "Agent 图表", x_axis_name: parsed.xAxisName,
@@ -97,7 +97,7 @@ function SafeChartEnvelope({ envelope, embedded = false, expanded = false }: { e
series={data.series.slice(0, 8)} series={data.series.slice(0, 8)}
source="来源见关联依据。" source="来源见关联依据。"
embedded={embedded} embedded={embedded}
expanded={expanded} expanded={expanded} plotHeight={plotHeight} tile={tile}
xAxisName={spec.xAxisName} xAxisName={spec.xAxisName}
yAxisName={spec.yAxisName} yAxisName={spec.yAxisName}
/>; />;
+1
View File
@@ -116,3 +116,4 @@ export { readInteractionReply } from "./api/interaction-requests";
export type { InteractionRequestsController } from "./hooks/use-interaction-requests"; export type { InteractionRequestsController } from "./hooks/use-interaction-requests";
export { presentationCommandSchema, presentationOverlayDataSchema, PRESENTATION_ACTIONS, type PresentationCommand } from "./presentation/command"; export { presentationCommandSchema, presentationOverlayDataSchema, PRESENTATION_ACTIONS, type PresentationCommand } from "./presentation/command";
export { chartPublicationError, safeChartPublicationInput } from "./presentation/command-schema";
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { z } from "zod";
import cases from "../../../../tests/fixtures/chart-publication-cases.json";
import examples from "../../../../tests/fixtures/unified-presentation.jsonl?raw";
import { createViewSchemas, safeChartInput, safeChartPublicationInput } from "./command-schema";
describe("shared chart publication conformance", () => {
it.each(cases)("$name", ({ chart, accepted }) => {
expect(safeChartPublicationInput.safeParse(chart).success).toBe(accepted);
for (const publication of [false, true]) {
const schema = createViewSchemas(z, publication, true).command;
expect(schema.safeParse({ action: "open_chart", chart_id: "comparison", evidence_refs: ["dev:synthetic"], chart }).success).toBe(accepted);
}
});
it("keeps historical mixed charts structurally readable", () => {
expect(safeChartInput.safeParse(cases.find((item) => item.name === "old mixed charts are read only")!.chart).success).toBe(true);
});
it("accepts every exported command example through the publication schema", () => {
const schema = createViewSchemas(z, true).command;
for (const record of examples.trim().split("\n").map((line) => JSON.parse(line)))
if (record.record_type === "presentation") expect(schema.safeParse(record.command).success).toBe(true);
});
});
@@ -7,8 +7,63 @@ export function createChartSampleTimeSchema(zod: typeof z = z) {
); );
} }
type PublicationChart = {
chart_type: string;
x_data: string[];
series: Array<{ type?: string; data: Array<number | null>; sample_times?: Array<string | null> }>;
};
/** Mirrors Server time_comparison_chart.py for new publications, never historical reads. */
export function chartPublicationError(chart: PublicationChart): string | null {
const kinds = new Set(chart.series.map((series) => series.type ?? (chart.chart_type === "time_comparison" ? "line" : chart.chart_type)));
if (chart.chart_type !== "time_comparison") return kinds.has("line") && kinds.has("scatter")
? "曲线与历史散点复合图须使用 chart_type=time_comparison" : null;
if (kinds.size !== 2 || !kinds.has("line") || !kinds.has("scatter"))
return "time_comparison 必须包含当前 line 和历史 scatter,不能混入 bar";
const labels = chart.x_data;
if (!labels.every((label) => /^(?:[01][0-9]|2[0-3]):[0-5][0-9](?::[0-5][0-9])?$/.test(label)) || new Set(labels.map((label) => label.length)).size !== 1)
return "time_comparison.x_data 必须使用一致精度的 HH:mm 或 HH:mm:ss 共享时刻槽";
const seconds = labels.map((label) => label.split(":").reduce((value, part, index) => value + Number(part) * [3600, 60, 1][index], 0));
const offsets = [0];
for (let index = 1; index < seconds.length; index++) {
const step = (seconds[index] - seconds[index - 1] + 86400) % 86400;
if (!step) return "x_data 不能重复时刻,历史点应复用当前槽位";
offsets.push(offsets[index - 1] + step);
}
if (offsets.at(-1)! >= 86400) return "x_data 必须按时间递增且小于一天";
const current = new Set<number>();
const historical: number[] = [];
for (const series of chart.series) {
const times = series.sample_times;
if (!times || times.length !== labels.length) return "sample_times 必须提供并与 x_data 等长";
const bases = new Set<number>();
for (let index = 0; index < times.length; index++) {
const at = times[index];
if ((series.data[index] === null) !== (at === null)) return "有效值必须有真实采样时间,缺测值和时间均填 null";
if (at === null) continue;
const local = new Date(Date.parse(at) + 8 * 3600_000);
const base = local.getTime() - offsets[index] * 1000;
const year = local.getUTCFullYear(), baseYear = new Date(base).getUTCFullYear();
if (!Number.isFinite(base) || year < 1 || year > 9999 || baseYear < 1 || baseYear > 9999)
return "sample_times 超出同期对比可表达的日期范围";
const microseconds = /\.(\d+)/.exec(at)?.[1].slice(0, 6) ?? "0";
if (local.toISOString().slice(11, labels[index].length === 8 ? 19 : 16) !== labels[index] ||
Number(microseconds) !== 0 || labels[index].length === 5 && local.getUTCSeconds() !== 0)
return "sample_times 换算为 UTC+8 后须与 x_data 对应时刻对齐";
bases.add(base);
}
if (bases.size > 1) return "同一序列的 sample_times 必须按同一日期窗口对齐";
if ((series.type ?? "line") === "line") for (const base of bases) current.add(base);
else historical.push(...bases);
}
if (current.size !== 1) return "当前 line 必须包含真实采样时间,且所有当前曲线使用同一窗口";
const start = [...current][0];
return historical.some((base) => start <= base || (start - base) % 86400_000 !== 0)
? "历史 scatter 必须来自当前窗口之前的对应日期" : null;
}
/** Shared host/native schema factory. Native OpenCode supplies its own Zod instance. */ /** Shared host/native schema factory. Native OpenCode supplies its own Zod instance. */
export function createViewSchemas(zod: typeof z = z, publication = false) { export function createViewSchemas(zod: typeof z = z, publication = false, validateCharts = publication) {
const z = zod; const z = zod;
const presentationIdentifierSchema = z.string().trim().min(1).max(128); const presentationIdentifierSchema = z.string().trim().min(1).max(128);
@@ -39,7 +94,11 @@ export function createViewSchemas(zod: typeof z = z, publication = false) {
(!series.sample_times || series.sample_times.length === value.x_data.length && (!series.sample_times || series.sample_times.length === value.x_data.length &&
series.sample_times.every((time, index) => time === null || series.data[index] !== null))), series.sample_times.every((time, index) => time === null || series.data[index] !== null))),
"series must align with x_data" "series must align with x_data"
); ).superRefine((value, ctx) => {
if (!validateCharts) return;
const message = chartPublicationError(value);
if (message) ctx.addIssue({ code: "custom", message });
});
const position = z const position = z
.tuple([z.number().finite().min(-180).max(180), z.number().finite().min(-90).max(90)]) .tuple([z.number().finite().min(-180).max(180), z.number().finite().min(-90).max(90)])
.rest(z.number().finite()) .rest(z.number().finite())
@@ -213,8 +272,9 @@ export const {
overlayDataSchema, overlayDataSchema,
variants: viewCommandVariants variants: viewCommandVariants
} = createViewSchemas(); } = createViewSchemas();
export const createViewCommandSchema = (options: { publication: boolean }) => export const safeChartPublicationInput = createViewSchemas(z, true).safeChartInput;
createViewSchemas(z, options.publication).command; export const createViewCommandSchema = (options: { publication: boolean; validateCharts?: boolean }) =>
createViewSchemas(z, options.publication, options.validateCharts ?? options.publication).command;
type Command = z.infer<ReturnType<typeof createViewCommandSchema>>; type Command = z.infer<ReturnType<typeof createViewCommandSchema>>;
export type ViewCommand = export type ViewCommand =
| Exclude<Command, { action: "set_overlay" }> | Exclude<Command, { action: "set_overlay" }>
@@ -2,13 +2,13 @@ import { Check, Map } from "lucide-react";
import { type FocusEvent, useCallback, useEffect, useRef, useState } from "react"; import { type FocusEvent, useCallback, useEffect, useRef, useState } from "react";
import { cn } from "@/shared/ui/cn"; import { cn } from "@/shared/ui/cn";
import { import {
mapOptionInteractionVariants,
MAP_CONTROL_RADIUS_CLASS_NAME, MAP_CONTROL_RADIUS_CLASS_NAME,
MAP_CONTROL_SURFACE_CLASS_NAME, MAP_CONTROL_SURFACE_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME, MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_RADIUS_CLASS_NAME,
MAP_READABLE_SURFACE_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME,
MAP_SELECTION_CARD_FOCUS_CLASS_NAME,
MAP_TOOL_PANEL_SURFACE_CLASS_NAME MAP_TOOL_PANEL_SURFACE_CLASS_NAME
} from "./map-control-styles"; } from "./map-control-styles";
@@ -105,18 +105,18 @@ export function BaseLayersControl({
<button <button
key={option.id} key={option.id}
type="button" type="button"
data-map-option=""
disabled={option.disabled} disabled={option.disabled}
aria-pressed={active} aria-pressed={active}
onClick={() => onSelectLayer(option.id)} onClick={() => onSelectLayer(option.id)}
className={cn( className={cn(
"flex min-h-[76px] flex-col p-1 text-left transition duration-150", "flex min-h-[76px] flex-col p-1 text-left",
MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_RADIUS_CLASS_NAME,
MAP_SELECTION_CARD_FOCUS_CLASS_NAME,
MAP_READABLE_SURFACE_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME,
active active
? "border-[var(--action-blue)] text-[var(--action-blue-hover)] shadow-xs" ? "border-[var(--action-blue)] text-[var(--action-blue-hover)] shadow-xs"
: "text-slate-700 hover:border-[var(--palette-brand)] hover:bg-[var(--action-soft)]/40", : "text-slate-700",
option.disabled && "cursor-not-allowed opacity-50" mapOptionInteractionVariants({ state: active ? "selected" : "default" })
)} )}
> >
<BaseLayerSwatch option={option} active={active} /> <BaseLayerSwatch option={option} active={active} />
@@ -159,22 +159,22 @@ export function BaseLayersControl({
<button <button
key={option.id} key={option.id}
type="button" type="button"
data-map-option=""
disabled={option.disabled} disabled={option.disabled}
aria-pressed={active} aria-pressed={active}
onClick={() => onSelectLayer(option.id)} onClick={() => onSelectLayer(option.id)}
className={cn( className={cn(
"flex h-[84px] w-[84px] flex-col p-1 text-center transition duration-150", "flex h-[84px] w-[84px] flex-col p-1 text-center text-slate-800",
MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_RADIUS_CLASS_NAME,
MAP_SELECTION_CARD_FOCUS_CLASS_NAME,
MAP_READABLE_SURFACE_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME,
active ? "border-[var(--action-blue)] shadow-xs" : "border-[var(--surface-divider)] hover:border-[var(--palette-brand)] hover:bg-[var(--action-soft)]/40", active ? "border-[var(--action-blue)] shadow-xs" : "border-[var(--surface-divider)]",
option.disabled && "cursor-not-allowed opacity-50" mapOptionInteractionVariants({ state: active ? "selected" : "default" })
)} )}
> >
<BaseLayerSwatch option={option} active={active} /> <BaseLayerSwatch option={option} active={active} />
<span <span
data-base-layer-label="true" data-base-layer-label="true"
className="block h-4 shrink-0 truncate px-0.5 pt-1 text-xs font-semibold leading-none text-slate-800" className="block h-4 shrink-0 truncate px-0.5 pt-1 text-xs font-semibold leading-none"
> >
{option.label} {option.label}
</span> </span>
@@ -185,9 +185,10 @@ export function BaseLayersControl({
<div className={cn("absolute bottom-0 right-0 grid h-[96px] w-[96px] place-items-center p-1.5", MAP_CONTROL_RADIUS_CLASS_NAME, MAP_CONTROL_SURFACE_CLASS_NAME)}> <div className={cn("absolute bottom-0 right-0 grid h-[96px] w-[96px] place-items-center p-1.5", MAP_CONTROL_RADIUS_CLASS_NAME, MAP_CONTROL_SURFACE_CLASS_NAME)}>
<button <button
type="button" type="button"
data-map-option=""
aria-label={`${title}:当前 ${activeLayer?.label ?? ""}`} aria-label={`${title}:当前 ${activeLayer?.label ?? ""}`}
title={`${title}:当前 ${activeLayer?.label ?? ""}`} title={`${title}:当前 ${activeLayer?.label ?? ""}`}
className={cn("grid h-[84px] w-[84px] place-items-center p-1 text-slate-700 shadow-xs transition hover:border-[var(--palette-brand)] hover:text-[var(--action-blue-hover)]", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME, MAP_SELECTION_CARD_FOCUS_CLASS_NAME)} className={cn("grid h-[84px] w-[84px] place-items-center p-1 text-slate-700 shadow-xs", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME, mapOptionInteractionVariants())}
> >
<span className={cn("relative h-full w-full overflow-hidden border border-white shadow-inner", MAP_ICON_CELL_RADIUS_CLASS_NAME)}> <span className={cn("relative h-full w-full overflow-hidden border border-white shadow-inner", MAP_ICON_CELL_RADIUS_CLASS_NAME)}>
{activeLayer ? ( {activeLayer ? (
@@ -195,7 +196,7 @@ export function BaseLayersControl({
) : ( ) : (
<Map size={18} aria-hidden="true" className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" /> <Map size={18} aria-hidden="true" className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
)} )}
<span className="surface-reading absolute bottom-1 left-1 right-1 rounded px-1 py-0.5 text-xs font-semibold leading-none text-slate-800"> <span className="surface-reading absolute bottom-1 left-1 right-1 rounded px-1 py-0.5 text-xs font-semibold leading-none">
{activeLayer?.label ?? title} {activeLayer?.label ?? title}
</span> </span>
</span> </span>
@@ -2,6 +2,7 @@ import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { cn } from "@/shared/ui/cn"; import { cn } from "@/shared/ui/cn";
import { import {
mapOptionInteractionVariants,
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME, MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
@@ -79,14 +80,15 @@ export function MapModeButton({ icon: Icon, label, active = false, disabled = fa
return ( return (
<button <button
type="button" type="button"
data-map-option=""
aria-pressed={active} aria-pressed={active}
disabled={disabled} disabled={disabled}
onClick={onClick} onClick={onClick}
className={cn( className={cn(
"flex h-14 flex-col items-center justify-center gap-1 text-xs font-semibold transition duration-150", "flex h-14 flex-col items-center justify-center gap-1 border border-transparent text-xs font-semibold",
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
active ? "bg-[var(--action-blue)] text-white" : "surface-control text-slate-600 hover:text-[var(--action-blue-hover)]", active ? "bg-[var(--action-blue)] text-white" : "surface-control text-slate-600",
disabled && "cursor-not-allowed opacity-55 hover:text-slate-600" mapOptionInteractionVariants({ state: active ? "selected" : "default" })
)} )}
> >
<Icon size={17} aria-hidden="true" /> <Icon size={17} aria-hidden="true" />
@@ -109,13 +111,14 @@ export function MapActionChip({
return ( return (
<button <button
type="button" type="button"
data-map-option=""
disabled={disabled} disabled={disabled}
onClick={onClick} onClick={onClick}
className={cn( className={cn(
"flex h-10 items-center justify-center gap-1.5 text-xs font-semibold text-slate-700 transition duration-150 hover:border-[var(--surface-border)] hover:text-[var(--action-blue-hover)]", "flex h-10 items-center justify-center gap-1.5 text-xs font-semibold text-slate-700",
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_READABLE_SURFACE_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME,
disabled && "cursor-not-allowed opacity-55 hover:border-white/70 hover:text-slate-700" mapOptionInteractionVariants()
)} )}
> >
<Icon size={14} aria-hidden="true" /> <Icon size={14} aria-hidden="true" />
@@ -157,23 +160,20 @@ export function MapActionRow({
return ( return (
<button <button
type="button" type="button"
data-map-option=""
disabled={disabled} disabled={disabled}
onClick={onClick} onClick={onClick}
className={cn( className={cn(
"flex min-h-12 w-full items-center gap-2.5 border px-2.5 py-2 text-left", "flex min-h-12 w-full items-center gap-2.5 border px-2.5 py-2 text-left",
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
isPrimary isPrimary
? "border-[var(--action-blue)] bg-[var(--action-blue)] text-white shadow-lg shadow-[color:var(--action-blue)]/25 hover:bg-[var(--action-blue-hover)]" ? "border-[var(--action-blue)] bg-[var(--action-blue)] text-white shadow-lg shadow-[color:var(--action-blue)]/25"
: strong : strong
? "border-[var(--surface-border)] bg-[var(--action-soft)]/95 text-[var(--action-blue-pressed)]" ? "border-[var(--surface-border)] bg-[var(--action-soft)]/95 text-[var(--action-blue-pressed)]"
: "surface-control border-transparent text-slate-700 hover:border-[var(--surface-border)] hover:bg-[var(--surface-reading)] hover:text-[var(--action-blue-hover)]", : "surface-control border-transparent text-slate-700",
isDanger && "hover:border-red-100 hover:text-red-700",
selected && !isPrimary && "border-[var(--surface-border)] bg-[var(--action-soft)] text-[var(--action-blue-pressed)]", selected && !isPrimary && "border-[var(--surface-border)] bg-[var(--action-soft)] text-[var(--action-blue-pressed)]",
mapOptionInteractionVariants({ state: isDanger ? "danger" : isPrimary ? "primary" : selected || strong ? "selected" : "default" }),
muted && "opacity-70", muted && "opacity-70",
disabled &&
(isPrimary
? "cursor-not-allowed opacity-55 hover:border-[var(--action-blue)] hover:bg-[var(--action-blue-hover)] hover:text-white"
: "cursor-not-allowed opacity-55 hover:border-white/70 hover:bg-[var(--surface-reading)]/95 hover:text-slate-700"),
disabled && "shadow-none" disabled && "shadow-none"
)} )}
> >
@@ -185,8 +185,8 @@ export function MapActionRow({
? "bg-[var(--surface-reading)]/16 text-white" ? "bg-[var(--surface-reading)]/16 text-white"
: selected || strong : selected || strong
? "bg-[var(--action-blue)] text-white" ? "bg-[var(--action-blue)] text-white"
: "bg-[var(--surface-control)] text-[var(--ink-muted)]", : "bg-[var(--surface-control)] text-inherit",
isDanger && "bg-red-50 text-red-600" isDanger && "bg-[var(--status-danger-soft)] text-[var(--status-danger-foreground)]"
)} )}
> >
<Icon size={15} aria-hidden="true" /> <Icon size={15} aria-hidden="true" />
@@ -7,6 +7,7 @@ import {
TooltipTrigger TooltipTrigger
} from "@/shared/ui/tooltip"; } from "@/shared/ui/tooltip";
import { import {
mapOptionInteractionVariants,
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_CONTROL_SURFACE_CLASS_NAME, MAP_CONTROL_SURFACE_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
@@ -46,27 +47,26 @@ export function MapDrawToolbar({ items, label = "绘制工具", className = "",
<button <button
key={item.id} key={item.id}
type="button" type="button"
data-map-option=""
aria-pressed={item.active} aria-pressed={item.active}
disabled={item.disabled} disabled={item.disabled}
onClick={item.onClick} onClick={item.onClick}
className={cn( className={cn(
"flex min-h-10 items-center gap-2 border px-2.5 py-2 text-left text-xs font-semibold transition duration-150", "flex min-h-10 items-center gap-2 border px-2.5 py-2 text-left text-xs font-semibold",
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
item.active item.active
? "border-[var(--action-blue)] bg-[var(--action-blue)] text-white shadow-[var(--action-shadow-primary)]" ? "border-[var(--action-blue)] bg-[var(--action-blue)] text-white shadow-[var(--action-shadow-primary)]"
: "text-slate-700 hover:border-[var(--surface-border)] hover:bg-[var(--surface-reading)] hover:text-[var(--action-blue-hover)]", : "text-slate-700",
!item.active && MAP_READABLE_SURFACE_CLASS_NAME, !item.active && MAP_READABLE_SURFACE_CLASS_NAME,
isDanger && "hover:border-red-100 hover:text-red-700", mapOptionInteractionVariants({ state: isDanger ? "danger" : item.active ? "selected" : "default" })
item.disabled &&
"cursor-not-allowed opacity-55 hover:border-white/70 hover:bg-[var(--surface-reading)]/95 hover:text-slate-700"
)} )}
> >
<span <span
className={cn( className={cn(
"grid h-7 w-7 shrink-0 place-items-center", "grid h-7 w-7 shrink-0 place-items-center text-inherit transition-[background-color] duration-150 motion-reduce:transition-none",
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
item.active ? "bg-[var(--surface-reading)]/16 text-white" : "bg-[var(--surface-control)] text-[var(--ink-muted)]", item.active ? "bg-[var(--surface-reading)]/16" : "bg-[var(--surface-control)]",
isDanger && "bg-red-50 text-red-600" isDanger && "bg-[var(--status-danger-soft)] text-[var(--status-danger-foreground)]"
)} )}
> >
<Icon size={15} aria-hidden="true" /> <Icon size={15} aria-hidden="true" />
@@ -100,6 +100,7 @@ export function MapDrawToolbar({ items, label = "绘制工具", className = "",
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
type="button" type="button"
data-map-option=""
aria-label={item.label} aria-label={item.label}
aria-pressed={item.active} aria-pressed={item.active}
disabled={item.disabled} disabled={item.disabled}
@@ -109,9 +110,8 @@ export function MapDrawToolbar({ items, label = "绘制工具", className = "",
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
item.active item.active
? "border-[var(--action-blue)] bg-[var(--action-blue)] text-white shadow-[var(--action-shadow-primary)]" ? "border-[var(--action-blue)] bg-[var(--action-blue)] text-white shadow-[var(--action-shadow-primary)]"
: "hover:bg-[var(--action-soft)] hover:text-[var(--action-blue-hover)]", : undefined,
isDanger && "hover:bg-red-50 hover:text-red-700", mapOptionInteractionVariants({ state: isDanger ? "danger" : item.active ? "selected" : "default" })
item.disabled && "opacity-45 hover:bg-transparent hover:text-slate-600"
)} )}
> >
<Icon size={16} aria-hidden="true" /> <Icon size={16} aria-hidden="true" />
@@ -1,6 +1,7 @@
import { Eye, EyeOff, Layers3, Lock } from "lucide-react"; import { Eye, EyeOff, Layers3, Lock } from "lucide-react";
import { cn } from "@/shared/ui/cn"; import { cn } from "@/shared/ui/cn";
import { import {
mapOptionInteractionVariants,
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME, MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
@@ -79,23 +80,24 @@ export function MapLayerControl({
<button <button
key={item.id} key={item.id}
type="button" type="button"
data-map-option=""
disabled={disabled} disabled={disabled}
aria-pressed={item.visible} aria-pressed={item.visible}
onClick={() => onToggleLayer(item.id, !item.visible)} onClick={() => onToggleLayer(item.id, !item.visible)}
className={cn( className={cn(
"flex min-h-12 w-full items-center gap-3 border px-2.5 text-left text-sm transition duration-150", "flex min-h-12 w-full items-center gap-3 border px-2.5 text-left text-sm",
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
item.visible item.visible
? "surface-control border-[var(--surface-border)] text-slate-900" ? "surface-control border-[var(--surface-border)] text-slate-900"
: "surface-well border-transparent text-[var(--ink-muted)] hover:border-[var(--surface-divider)] hover:bg-[var(--surface-reading)]", : "surface-well border-transparent text-[var(--ink-muted)]",
disabled && "cursor-not-allowed opacity-60 hover:bg-transparent" mapOptionInteractionVariants({ state: item.visible ? "selected" : "default" })
)} )}
> >
<span <span
className={cn( className={cn(
"grid h-8 w-8 shrink-0 place-items-center", "grid h-8 w-8 shrink-0 place-items-center",
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
item.visible ? "bg-[var(--action-blue)] text-white" : "bg-[var(--surface-control)] text-[var(--ink-muted)]" item.visible ? "bg-[var(--action-blue)] text-white" : "bg-[var(--surface-control)] text-inherit"
)} )}
> >
{item.locked ? <Lock size={15} aria-hidden="true" /> : <Icon size={15} aria-hidden="true" />} {item.locked ? <Lock size={15} aria-hidden="true" /> : <Icon size={15} aria-hidden="true" />}
@@ -4,12 +4,12 @@ import { cn } from "@/shared/ui/cn";
import type { BaseLayerOption } from "./base-layers-control"; import type { BaseLayerOption } from "./base-layers-control";
import type { MapLayerControlItem, MapLayerControlVisual } from "./layer-control"; import type { MapLayerControlItem, MapLayerControlVisual } from "./layer-control";
import { import {
mapOptionInteractionVariants,
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_RADIUS_CLASS_NAME,
MAP_READABLE_SURFACE_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME,
MAP_READABLE_SURFACE_STRONG_CLASS_NAME, MAP_READABLE_SURFACE_STRONG_CLASS_NAME
MAP_SELECTION_CARD_FOCUS_CLASS_NAME
} from "./map-control-styles"; } from "./map-control-styles";
export type MapLayerPanelProps = { export type MapLayerPanelProps = {
@@ -61,14 +61,14 @@ function LayerVisibilitySection({ items, onToggleLayer }: LayerVisibilitySection
onToggle={(event) => handleGroupToggle(group.id, event.currentTarget.open)} onToggle={(event) => handleGroupToggle(group.id, event.currentTarget.open)}
className="surface-well group/layer overflow-hidden rounded-xl border" className="surface-well group/layer overflow-hidden rounded-xl border"
> >
<summary className="flex min-h-11 cursor-pointer list-none items-center gap-2 px-2.5 py-1.5 text-left marker:content-none active:scale-[0.99]"> <summary data-map-option="" className={cn("flex min-h-11 cursor-pointer list-none items-center gap-2 border border-transparent px-2.5 py-1.5 text-left text-slate-700 marker:content-none", mapOptionInteractionVariants())}>
<ChevronRight <ChevronRight
size={14} size={14}
aria-hidden="true" aria-hidden="true"
className="shrink-0 text-[var(--ink-muted)] transition-transform duration-150 ease-out motion-reduce:transition-none group-open/layer:rotate-90" className="shrink-0 text-inherit transition-transform duration-150 ease-out motion-reduce:transition-none group-open/layer:rotate-90"
/> />
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block truncate text-xs font-semibold text-slate-700">{group.label}</span> <span className="block truncate text-xs font-semibold">{group.label}</span>
{group.description ? ( {group.description ? (
<span className="mt-0.5 block truncate text-[10px] leading-tight text-[var(--ink-muted)]"> <span className="mt-0.5 block truncate text-[10px] leading-tight text-[var(--ink-muted)]">
{group.description} {group.description}
@@ -169,18 +169,19 @@ function LayerVisibilityRow({
return ( return (
<button <button
type="button" type="button"
data-map-option=""
disabled={disabled} disabled={disabled}
aria-pressed={item.visible} aria-pressed={item.visible}
aria-label={`${item.label}${meta ? `,${meta}` : ""}`} aria-label={`${item.label}${meta ? `,${meta}` : ""}`}
title={meta} title={meta}
onClick={() => onToggleLayer(item.id, !item.visible)} onClick={() => onToggleLayer(item.id, !item.visible)}
className={cn( className={cn(
"flex min-h-12 w-full items-center gap-2.5 border px-2 py-1.5 text-left transition-[background-color,border-color,color,box-shadow,transform] duration-150 ease-out motion-reduce:transition-none active:scale-[0.98] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)] focus-visible:ring-offset-1", "flex min-h-12 w-full items-center gap-2.5 border px-2 py-1.5 text-left",
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
item.visible item.visible
? "border-[var(--surface-border)]/90 bg-[var(--surface-reading)] text-slate-950 shadow-xs shadow-slate-950/5" ? "border-[var(--surface-border)]/90 bg-[var(--surface-reading)] text-slate-950 shadow-xs shadow-slate-950/5"
: "border-transparent bg-[var(--surface-control)]/55 text-slate-600 [@media(hover:hover)]:hover:border-[var(--surface-divider)] [@media(hover:hover)]:hover:bg-[var(--surface-reading)]/75", : "border-transparent bg-[var(--surface-control)]/55 text-slate-600",
disabled && "cursor-not-allowed opacity-55 active:scale-100" mapOptionInteractionVariants({ state: item.visible ? "selected" : "default" })
)} )}
> >
<LayerVisual visual={item.visual} visible={item.visible} /> <LayerVisual visual={item.visual} visible={item.visible} />
@@ -195,7 +196,7 @@ function LayerVisibilityRow({
<span <span
className={cn( className={cn(
"grid h-8 w-8 shrink-0 place-items-center rounded-lg transition-[background-color,color,opacity] duration-150 motion-reduce:transition-none", "grid h-8 w-8 shrink-0 place-items-center rounded-lg transition-[background-color,color,opacity] duration-150 motion-reduce:transition-none",
item.visible ? "bg-[var(--action-soft)] text-[var(--action-blue-hover)]" : "bg-transparent text-[var(--ink-muted)]" item.visible ? "bg-[var(--action-soft)] text-[var(--action-blue-hover)]" : "bg-transparent text-inherit"
)} )}
> >
{item.locked ? <Lock size={15} aria-hidden="true" /> : <Icon size={15} aria-hidden="true" />} {item.locked ? <Lock size={15} aria-hidden="true" /> : <Icon size={15} aria-hidden="true" />}
@@ -297,18 +298,18 @@ function BaseLayerSection({ options, activeLayerId, onSelectLayer }: BaseLayerSe
<button <button
key={option.id} key={option.id}
type="button" type="button"
data-map-option=""
disabled={option.disabled} disabled={option.disabled}
aria-pressed={active} aria-pressed={active}
onClick={() => onSelectLayer(option.id)} onClick={() => onSelectLayer(option.id)}
className={cn( className={cn(
"flex min-h-[76px] flex-col p-1 text-left transition duration-150", "flex min-h-[76px] flex-col p-1 text-left",
MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_RADIUS_CLASS_NAME,
MAP_SELECTION_CARD_FOCUS_CLASS_NAME,
active ? MAP_READABLE_SURFACE_STRONG_CLASS_NAME : MAP_READABLE_SURFACE_CLASS_NAME, active ? MAP_READABLE_SURFACE_STRONG_CLASS_NAME : MAP_READABLE_SURFACE_CLASS_NAME,
active active
? "border-[var(--action-blue)] text-[var(--action-blue-hover)]" ? "border-[var(--action-blue)] text-[var(--action-blue-hover)]"
: "text-slate-700 hover:border-[var(--palette-brand)] hover:bg-[var(--action-soft)]/40", : "text-slate-700",
option.disabled && "cursor-not-allowed opacity-50" mapOptionInteractionVariants({ state: active ? "selected" : "default" })
)} )}
> >
<BaseLayerSwatch option={option} active={active} /> <BaseLayerSwatch option={option} active={active} />
@@ -1,4 +1,4 @@
import { buttonInteractionVariants } from "@/shared/ui/button-interaction"; import { cva } from "class-variance-authority";
export const MAP_MAJOR_PANEL_RADIUS_CLASS_NAME = "rounded-2xl"; export const MAP_MAJOR_PANEL_RADIUS_CLASS_NAME = "rounded-2xl";
@@ -28,5 +28,27 @@ export const MAP_READABLE_SURFACE_CLASS_NAME =
export const MAP_READABLE_SURFACE_STRONG_CLASS_NAME = export const MAP_READABLE_SURFACE_STRONG_CLASS_NAME =
"surface-control border"; "surface-control border";
export const MAP_CONTROL_HOVER_CLASS_NAME = export const mapOptionInteractionVariants = cva(
buttonInteractionVariants({ tone: "neutral" }); "map-option-interaction outline-hidden transition-colors duration-150 motion-reduce:transition-none focus-visible:outline-1 focus-visible:outline-solid focus-visible:outline-[var(--action-focus-ring)] focus-visible:-outline-offset-1 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-55 data-[disabled]:pointer-events-none data-[disabled]:opacity-55",
{
variants: {
state: {
default: "[@media(hover:hover)]:hover:border-[var(--surface-border)] [@media(hover:hover)]:hover:bg-[var(--surface-reading)] [@media(hover:hover)]:hover:text-[var(--action-blue-hover)] focus-visible:border-[var(--surface-border)] focus-visible:bg-[var(--surface-reading)] focus-visible:text-[var(--action-blue-hover)] data-[highlighted]:border-[var(--surface-border)] data-[highlighted]:bg-[var(--surface-reading)] data-[highlighted]:text-[var(--action-blue-hover)]",
selected: "[@media(hover:hover)]:hover:border-[var(--action-border-hover)] focus-visible:border-[var(--action-border-hover)] data-[highlighted]:border-[var(--action-border-hover)]",
primary: "[@media(hover:hover)]:hover:border-[var(--action-blue-hover)] [@media(hover:hover)]:hover:bg-[var(--action-blue-hover)] [@media(hover:hover)]:hover:text-white focus-visible:border-[var(--action-blue-hover)] focus-visible:bg-[var(--action-blue-hover)] focus-visible:text-white data-[highlighted]:border-[var(--action-blue-hover)] data-[highlighted]:bg-[var(--action-blue-hover)] data-[highlighted]:text-white",
danger: "[@media(hover:hover)]:hover:border-[var(--status-danger-border)] [@media(hover:hover)]:hover:bg-[var(--status-danger-soft)] [@media(hover:hover)]:hover:text-[var(--status-danger-foreground)] focus-visible:border-[var(--status-danger-border)] focus-visible:bg-[var(--status-danger-soft)] focus-visible:text-[var(--status-danger-foreground)] data-[highlighted]:border-[var(--status-danger-border)] data-[highlighted]:bg-[var(--status-danger-soft)] data-[highlighted]:text-[var(--status-danger-foreground)]"
}
},
defaultVariants: { state: "default" }
}
);
export const MAP_CONTROL_HOVER_CLASS_NAME = mapOptionInteractionVariants();
export const MAP_ACTION_ROW_INTERACTION_CLASS_NAME = [
MAP_COMPACT_RADIUS_CLASS_NAME,
"surface-control border border-transparent text-slate-700",
// Radix menu items receive focus while the pointer highlights a row.
"focus:bg-[var(--surface-reading)] focus:text-[var(--action-blue-hover)]",
mapOptionInteractionVariants()
].join(" ");
@@ -2,6 +2,7 @@ import type { LucideIcon } from "lucide-react";
import { Copy, Ruler, Trash2 } from "lucide-react"; import { Copy, Ruler, Trash2 } from "lucide-react";
import { cn } from "@/shared/ui/cn"; import { cn } from "@/shared/ui/cn";
import { import {
mapOptionInteractionVariants,
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_RADIUS_CLASS_NAME,
@@ -91,16 +92,17 @@ export function MapMeasurePanel({
<button <button
key={mode.id} key={mode.id}
type="button" type="button"
data-map-option=""
aria-pressed={mode.id === activeModeId} aria-pressed={mode.id === activeModeId}
disabled={mode.disabled} disabled={mode.disabled}
onClick={() => onSelectMode?.(mode.id)} onClick={() => onSelectMode?.(mode.id)}
className={cn( className={cn(
"flex h-10 min-w-0 touch-manipulation items-center justify-center gap-1.5 px-2 text-xs font-semibold transition-[color,background-color,box-shadow,transform] duration-150 ease-out focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)] focus-visible:ring-offset-1 focus-visible:ring-offset-[var(--action-focus-offset)] active:scale-95 motion-reduce:transition-none", "flex h-10 min-w-0 touch-manipulation items-center justify-center gap-1.5 border border-transparent px-2 text-xs font-semibold",
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
mode.id === activeModeId mode.id === activeModeId
? "bg-[var(--action-blue)] text-white shadow-[var(--action-shadow-primary)]" ? "bg-[var(--action-blue)] text-white shadow-[var(--action-shadow-primary)]"
: "text-slate-600 [@media(hover:hover)]:hover:bg-[var(--surface-reading)]/85 [@media(hover:hover)]:hover:text-[var(--action-blue-hover)]", : "text-slate-600",
mode.disabled && "cursor-not-allowed opacity-45 active:scale-100" mapOptionInteractionVariants({ state: mode.id === activeModeId ? "selected" : "default" })
)} )}
> >
<mode.icon size={15} strokeWidth={2} aria-hidden="true" className="shrink-0" /> <mode.icon size={15} strokeWidth={2} aria-hidden="true" className="shrink-0" />
@@ -134,14 +136,16 @@ export function MapMeasurePanel({
<button <button
key={unit.id} key={unit.id}
type="button" type="button"
data-map-option=""
aria-pressed={unit.id === activeUnitId} aria-pressed={unit.id === activeUnitId}
onClick={() => onSelectUnit?.(unit.id)} onClick={() => onSelectUnit?.(unit.id)}
className={cn( className={cn(
"h-10 min-w-10 touch-manipulation px-2 text-xs font-semibold transition-[color,background-color,box-shadow,transform] duration-150 ease-out focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)] focus-visible:ring-offset-1 focus-visible:ring-offset-[var(--action-focus-offset)] active:scale-95 motion-reduce:transition-none", "h-10 min-w-10 touch-manipulation border border-transparent px-2 text-xs font-semibold",
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
unit.id === activeUnitId unit.id === activeUnitId
? "bg-[var(--action-blue)] text-white shadow-[var(--action-shadow-primary)]" ? "bg-[var(--action-blue)] text-white shadow-[var(--action-shadow-primary)]"
: "text-slate-600 [@media(hover:hover)]:hover:bg-[var(--surface-reading)]/85 [@media(hover:hover)]:hover:text-[var(--action-blue-hover)]" : "text-slate-600",
mapOptionInteractionVariants({ state: unit.id === activeUnitId ? "selected" : "default" })
)} )}
> >
{unit.label} {unit.label}
@@ -228,28 +232,27 @@ function MeasureActionButton({
return ( return (
<button <button
type="button" type="button"
data-map-option=""
title={action.description} title={action.description}
data-state={active ? "active" : undefined} data-state={active ? "active" : undefined}
disabled={action.disabled} disabled={action.disabled}
onClick={action.onClick} onClick={action.onClick}
className={cn( className={cn(
"flex w-full touch-manipulation items-center gap-2 font-semibold transition-[color,background-color,border-color,box-shadow,transform] duration-150 ease-out focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--action-focus-offset)] active:scale-[0.98] motion-reduce:transition-none", "flex w-full touch-manipulation items-center gap-2 font-semibold",
primary ? "h-12 justify-start border px-2.5 text-sm" : "h-10 justify-center border text-xs", primary ? "h-12 justify-start border px-2.5 text-sm" : "h-10 justify-center border text-xs",
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
primary primary
? active ? active
? "border-[var(--palette-brand)] bg-[var(--surface-reading)] text-slate-950 shadow-[0_3px_10px_rgba(37,99,235,0.14)] ring-1 ring-inset ring-[var(--surface-border)] [@media(hover:hover)]:hover:border-[var(--palette-brand)] [@media(hover:hover)]:hover:bg-[var(--action-soft)]/70" ? "border-[var(--palette-brand)] bg-[var(--surface-reading)] text-slate-950 shadow-[0_3px_10px_rgba(37,99,235,0.14)] ring-1 ring-inset ring-[var(--surface-border)]"
: action.variant === "primary" : "border-[var(--action-blue)] bg-[var(--action-blue)] text-white shadow-xs"
? "border-[var(--surface-divider)] bg-[var(--surface-reading)]/90 text-slate-900 shadow-xs [@media(hover:hover)]:hover:border-[var(--surface-border)] [@media(hover:hover)]:hover:bg-[var(--surface-reading)]"
: "border-slate-800 bg-slate-800 text-white shadow-xs [@media(hover:hover)]:hover:border-slate-900 [@media(hover:hover)]:hover:bg-slate-900"
: cn( : cn(
MAP_READABLE_SURFACE_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME,
danger danger
? "text-red-600 [@media(hover:hover)]:hover:border-red-200 [@media(hover:hover)]:hover:bg-red-50" ? "text-[var(--status-danger-foreground)]"
: "text-slate-700 [@media(hover:hover)]:hover:border-[var(--surface-border)] [@media(hover:hover)]:hover:text-[var(--action-blue-hover)]" : "text-slate-700"
), ),
action.disabled && mapOptionInteractionVariants({ state: danger ? "danger" : active ? "selected" : primary ? "primary" : "default" }),
"cursor-not-allowed opacity-45 shadow-none active:scale-100 [@media(hover:hover)]:hover:border-[var(--surface-divider)] [@media(hover:hover)]:hover:bg-transparent [@media(hover:hover)]:hover:text-slate-700" action.disabled && "shadow-none"
)} )}
> >
{primary ? ( {primary ? (
@@ -258,7 +261,7 @@ function MeasureActionButton({
"grid h-8 w-8 shrink-0 place-items-center rounded-lg", "grid h-8 w-8 shrink-0 place-items-center rounded-lg",
active active
? "bg-[var(--action-blue)] text-white" ? "bg-[var(--action-blue)] text-white"
: "bg-[var(--surface-reading)] text-slate-700 ring-1 ring-inset ring-[var(--surface-divider)]" : "bg-[var(--surface-reading)]/16 text-white"
)} )}
> >
<Icon size={15} strokeWidth={2.2} aria-hidden="true" /> <Icon size={15} strokeWidth={2.2} aria-hidden="true" />
@@ -269,7 +272,7 @@ function MeasureActionButton({
{primary ? ( {primary ? (
<span className="min-w-0 flex-1 text-left"> <span className="min-w-0 flex-1 text-left">
<span className="block truncate text-sm font-semibold leading-4">{action.label}</span> <span className="block truncate text-sm font-semibold leading-4">{action.label}</span>
<span className="mt-0.5 block truncate text-[10px] font-medium leading-3 text-[var(--ink-muted)]"> <span className={cn("mt-0.5 block truncate text-[10px] font-medium leading-3", active ? "text-[var(--ink-muted)]" : "text-white/85")}>
{action.description} {action.description}
</span> </span>
</span> </span>
+3 -2
View File
@@ -8,6 +8,7 @@ import {
} from "@/shared/ui/tooltip"; } from "@/shared/ui/tooltip";
import { import {
MAP_CONTROL_HOVER_CLASS_NAME, MAP_CONTROL_HOVER_CLASS_NAME,
mapOptionInteractionVariants,
MAP_CONTROL_RADIUS_CLASS_NAME, MAP_CONTROL_RADIUS_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_CONTROL_SURFACE_CLASS_NAME MAP_CONTROL_SURFACE_CLASS_NAME
@@ -70,9 +71,9 @@ export function MapToolbar({
"relative grid h-10 w-10 shrink-0 place-items-center border border-transparent text-slate-700", "relative grid h-10 w-10 shrink-0 place-items-center border border-transparent text-slate-700",
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
tool.active tool.active
? "border-[var(--action-blue)] bg-[var(--action-blue)] text-white shadow-[var(--action-shadow-primary)]" ? cn("border-[var(--action-blue)] bg-[var(--action-blue)] text-white shadow-[var(--action-shadow-primary)]", mapOptionInteractionVariants({ state: "selected" }))
: MAP_CONTROL_HOVER_CLASS_NAME, : MAP_CONTROL_HOVER_CLASS_NAME,
tool.disabled && "text-[var(--ink-muted)] opacity-45 hover:bg-transparent hover:text-[var(--ink-muted)] hover:ring-0" tool.disabled && "text-[var(--ink-muted)] opacity-45"
)} )}
> >
<Icon size={20} strokeWidth={2.15} aria-hidden="true" /> <Icon size={20} strokeWidth={2.15} aria-hidden="true" />
+3 -2
View File
@@ -10,6 +10,7 @@ import {
} from "./camera-actions"; } from "./camera-actions";
import { import {
MAP_CONTROL_HOVER_CLASS_NAME, MAP_CONTROL_HOVER_CLASS_NAME,
mapOptionInteractionVariants,
MAP_CONTROL_RADIUS_CLASS_NAME, MAP_CONTROL_RADIUS_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_CONTROL_SURFACE_CLASS_NAME MAP_CONTROL_SURFACE_CLASS_NAME
@@ -158,9 +159,9 @@ function ControlButton({
"grid h-10 w-10 place-items-center border border-transparent", "grid h-10 w-10 place-items-center border border-transparent",
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
"bg-transparent text-slate-700", "bg-transparent text-slate-700",
MAP_CONTROL_HOVER_CLASS_NAME, pressed ? mapOptionInteractionVariants({ state: "selected" }) : MAP_CONTROL_HOVER_CLASS_NAME,
pressed && "bg-[var(--action-soft)] text-[var(--action-blue-hover)]", pressed && "bg-[var(--action-soft)] text-[var(--action-blue-hover)]",
disabled && "text-[var(--ink-muted)] opacity-45 hover:bg-transparent hover:text-[var(--ink-muted)]" disabled && "text-[var(--ink-muted)] opacity-45"
)} )}
> >
{Icon ? <Icon size={18} strokeWidth={2.2} aria-hidden="true" /> : content} {Icon ? <Icon size={18} strokeWidth={2.2} aria-hidden="true" /> : content}
+2
View File
@@ -29,6 +29,8 @@ export {
export { MapLayerPanel, type MapLayerPanelProps } from "./components/layer-panel"; export { MapLayerPanel, type MapLayerPanelProps } from "./components/layer-panel";
export { MapLegend, MapLegendList, MapLegendSwatch, type MapLegendItem } from "./components/legend"; export { MapLegend, MapLegendList, MapLegendSwatch, type MapLegendItem } from "./components/legend";
export { export {
mapOptionInteractionVariants,
MAP_ACTION_ROW_INTERACTION_CLASS_NAME,
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_CONTROL_HOVER_CLASS_NAME, MAP_CONTROL_HOVER_CLASS_NAME,
MAP_CONTROL_RADIUS_CLASS_NAME, MAP_CONTROL_RADIUS_CLASS_NAME,
@@ -1,5 +1,6 @@
import { ChevronDown, Crosshair, LocateFixed, MapPin, Search, Trash2 } from "lucide-react"; import { ChevronDown, Crosshair, LocateFixed, MapPin, Search, Trash2 } from "lucide-react";
import { useId, useLayoutEffect, useRef } from "react"; import { useId, useLayoutEffect, useRef } from "react";
import { mapOptionInteractionVariants } from "@/features/map/core";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input"; import { Input } from "@/shared/ui/input";
import { cn } from "@/shared/ui/cn"; import { cn } from "@/shared/ui/cn";
@@ -8,7 +9,7 @@ import { MAX_PLACE_SEARCH_RESULTS, type PlaceSearchItem } from "../api/place-sea
const RESULT_VIEWPORT_CLASS_NAME = "h-72 min-h-0 max-h-72 overflow-y-auto overscroll-contain pr-1 [scrollbar-gutter:stable] [scrollbar-width:thin]"; const RESULT_VIEWPORT_CLASS_NAME = "h-72 min-h-0 max-h-72 overflow-y-auto overscroll-contain pr-1 [scrollbar-gutter:stable] [scrollbar-width:thin]";
function PlaceSummary({ item, index, annotation = item.district }: { item: PlaceSearchItem; index?: number; annotation?: string }) { function PlaceSummary({ item, index, annotation = item.district, interactive = false }: { item: PlaceSearchItem; index?: number; annotation?: string; interactive?: boolean }) {
return ( return (
<> <>
{index !== undefined ? <span aria-hidden="true" className="w-5 shrink-0 self-start text-center text-[11px] font-normal leading-5 tabular-nums text-[var(--ink-muted)]"> {index !== undefined ? <span aria-hidden="true" className="w-5 shrink-0 self-start text-center text-[11px] font-normal leading-5 tabular-nums text-[var(--ink-muted)]">
@@ -16,7 +17,7 @@ function PlaceSummary({ item, index, annotation = item.district }: { item: Place
</span> : null} </span> : null}
<span className="min-w-0 flex-1 space-y-1"> <span className="min-w-0 flex-1 space-y-1">
<span className="flex items-start justify-between gap-2 leading-5"> <span className="flex items-start justify-between gap-2 leading-5">
<span className="min-w-0 flex-1 break-words font-semibold text-[var(--ink)]">{item.name}</span> <span className={cn("min-w-0 flex-1 break-words font-semibold", !interactive && "text-[var(--ink)]")}>{item.name}</span>
<span aria-label={index === undefined && item.category ? `地点类型:${item.category}` : undefined} className="max-w-[40%] shrink-0 break-words text-right text-[11px] font-normal text-[var(--ink-muted)]">{annotation}</span> <span aria-label={index === undefined && item.category ? `地点类型:${item.category}` : undefined} className="max-w-[40%] shrink-0 break-words text-right text-[11px] font-normal text-[var(--ink-muted)]">{annotation}</span>
</span> </span>
<span className="block break-words text-[11px] font-normal leading-4 text-[var(--ink-muted)]">{item.address}</span> <span className="block break-words text-[11px] font-normal leading-4 text-[var(--ink-muted)]">{item.address}</span>
@@ -123,16 +124,17 @@ export function GeocodingPanel({ geocoding }: { geocoding: WorkbenchGeocoding })
{!selectionCollapsed ? <ul ref={candidateListRef} aria-busy={pending} className={cn(RESULT_VIEWPORT_CLASS_NAME, "bg-transparent", pending && "opacity-60")}> {!selectionCollapsed ? <ul ref={candidateListRef} aria-busy={pending} className={cn(RESULT_VIEWPORT_CLASS_NAME, "bg-transparent", pending && "opacity-60")}>
{results.items.map((item, index) => ( {results.items.map((item, index) => (
<li key={item.id} className="border-b border-[var(--surface-divider)] last:border-b-0"> <li key={item.id} className="border-b border-[var(--surface-divider)] last:border-b-0">
<Button type="button" variant="ghost" aria-pressed={result?.id === item.id} data-selection-tone="soft" <button type="button" aria-pressed={result?.id === item.id}
aria-label={`定位 ${item.name} ${item.address}`} disabled={pending || !enabled} aria-label={`定位 ${item.name} ${item.address}`} disabled={pending || !enabled}
className={cn("h-auto min-h-16 w-full justify-start gap-3 rounded-lg border-0 whitespace-normal px-1 py-3 text-left text-xs focus-visible:ring-inset focus-visible:ring-offset-0 [@media(hover:hover)]:hover:bg-[var(--surface-control)] [@media(hover:hover)]:hover:text-[var(--ink)]", className={cn("flex min-h-16 w-full items-center justify-start gap-3 rounded-xl border border-transparent whitespace-normal px-1 py-3 text-left text-xs text-[var(--ink)]",
result?.id === item.id && "bg-[var(--action-selection-soft)] text-[var(--action-blue-hover)] [&_span]:text-[var(--action-blue-hover)] [@media(hover:hover)]:hover:bg-[var(--action-selection-soft)]")} mapOptionInteractionVariants({ state: result?.id === item.id ? "selected" : "default" }),
result?.id === item.id && "border-[var(--action-selection-border)] bg-[var(--action-selection-soft)] text-[var(--action-blue-hover)]")}
onClick={(event) => { onClick={(event) => {
geocoding.select(item.id); geocoding.select(item.id);
if (event.detail === 0) window.requestAnimationFrame(() => changeSelectionRef.current?.focus()); if (event.detail === 0) window.requestAnimationFrame(() => changeSelectionRef.current?.focus());
}}> }}>
<PlaceSummary item={item} index={index} /> <PlaceSummary item={item} index={index} interactive />
</Button> </button>
</li> </li>
))} ))}
</ul> : null} </ul> : null}
@@ -0,0 +1,94 @@
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { Check, FileText, X } from "lucide-react";
import { AgentChartView } from "@/features/agent";
import { formatChartDisplayText } from "@/shared/utils/date-time-text";
import { Button } from "@/shared/ui/button";
import { Card, CardContent, CardHeader } from "@/shared/ui/card";
import { FloatingWindow } from "@/shared/ui/floating-window";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/ui/select";
import type { InvestigationChartGroup as ChartGroup } from "../utils/investigation-chart-groups";
export function InvestigationChartGroup({ group, reviewing, onCloseChart, references, tracked = true, tile = false }: {
group: ChartGroup;
tracked?: boolean;
tile?: boolean;
reviewing: boolean;
onCloseChart: (id: string) => void;
references: (ids: string[]) => ReactNode;
}) {
const card = useRef<HTMLDivElement>(null);
const [plotHeight, setPlotHeight] = useState(220);
useLayoutEffect(() => {
if (tile) return;
const viewport = card.current?.closest<HTMLElement>("[data-investigation-content]");
if (!viewport || typeof ResizeObserver === "undefined") return;
const resize = () => setPlotHeight(Math.max(120, Math.min(220, viewport.clientHeight - 24)));
resize();
const observer = new ResizeObserver(resize);
observer.observe(viewport);
return () => observer.disconnect();
}, [tile]);
const [selectedId, setSelectedId] = useState(group.items[0].command.chart_id);
const selected = group.items.find((item) => item.command.chart_id === selectedId) ?? group.items[0];
const multiple = group.items.length > 1;
const title = formatChartDisplayText(multiple ? group.title : selected.command.chart.title);
const objectButtons = (compact = false) => <aside aria-label="选择图表对象"
className={compact ? "hidden min-h-0 min-w-0 overflow-auto border-l border-border pl-2 @min-[420px]:block" : "min-h-0 min-w-0 overflow-auto border-t border-border pt-3 @min-[560px]:border-t-0 @min-[560px]:border-l @min-[560px]:pl-3 @min-[560px]:pt-0"}>
<div className={`${compact ? "mb-1" : "mb-2"} flex items-center justify-between text-[11px] text-muted-foreground`}>
<span>监测对象</span><span className="tabular-nums">{group.items.length} 个</span>
</div>
<div className={compact ? "grid grid-cols-1 gap-1" : "grid grid-cols-2 gap-2 @min-[560px]:grid-cols-1"}>
{group.items.map((item) => <Button key={item.command.chart_id} type="button" variant="outline" size="sm"
aria-label={`切换对象 ${item.label}`} aria-pressed={selected.command.chart_id === item.command.chart_id}
className={`h-auto ${compact ? "min-h-8 gap-1.5 px-2 py-1" : "min-h-10 gap-2 px-3 py-2"} min-w-0 justify-start whitespace-normal text-left font-normal after:inset-0 aria-pressed:border-primary aria-pressed:bg-accent aria-pressed:text-accent-foreground`}
onClick={() => setSelectedId(item.command.chart_id)}>
<span className={`size-2 shrink-0 rounded-full ${selected.command.chart_id === item.command.chart_id ? "bg-primary" : "bg-muted-foreground/50"}`} aria-hidden />
<span className="min-w-0 flex-1 break-words tabular-nums">{item.label}</span>
{selected.command.chart_id === item.command.chart_id ? <Check size={14} aria-hidden /> : null}
</Button>)}
</div>
</aside>;
const content = (expanded: boolean, compact = false) => compact ? <div className="@container flex h-full min-h-0 min-w-0 flex-col gap-1">
{multiple ? <div className="shrink-0 @min-[420px]:hidden">
<Select value={selected.command.chart_id} onValueChange={setSelectedId}>
<SelectTrigger aria-label="选择图表对象" className="h-7 text-xs"><SelectValue /></SelectTrigger>
<SelectContent className="workbench-shadcn-surface z-[90]">{group.items.map((item) => <SelectItem key={item.command.chart_id} value={item.command.chart_id}>{item.label}</SelectItem>)}</SelectContent>
</Select>
</div> : null}
<div className={`grid min-h-0 min-w-0 flex-1 grid-cols-1 grid-rows-1 gap-2 ${multiple ? "@min-[420px]:grid-cols-[minmax(0,1fr)_128px]" : ""}`}>
<div className="flex min-h-0 min-w-0 flex-col"><AgentChartView key={selected.command.chart_id} chart={selected.command.chart} embedded expanded tile /></div>
{multiple ? objectButtons(true) : null}
</div>
</div> : <div className={`@container min-w-0 ${expanded ? "flex h-full min-h-0 flex-col" : ""}`}>
<div className={`grid min-w-0 grid-cols-1 gap-3 ${expanded ? multiple ? "flex-none @min-[560px]:min-h-0 @min-[560px]:flex-1" : "min-h-0 flex-1 grid-rows-1" : ""} ${multiple ? "@min-[560px]:grid-cols-[minmax(0,1fr)_176px] @min-[560px]:grid-rows-1" : ""}`}>
<div className={`min-w-0 ${expanded ? `flex min-h-0 flex-col ${multiple ? "h-[480px] @min-[560px]:h-full" : "h-full"}` : ""}`}>
{multiple ? <p className="mb-1 shrink-0 text-xs font-medium tabular-nums">{selected.label}<span className="ml-2 font-normal text-muted-foreground">{selected.command.chart.x_data[0]} 至 {selected.command.chart.x_data.at(-1)}</span></p> : null}
<AgentChartView key={selected.command.chart_id} chart={selected.command.chart} embedded expanded={expanded} tile={false} plotHeight={plotHeight} />
</div>
{multiple ? objectButtons() : null}
</div>
{selected.command.evidence_refs.length ? <div className={expanded ? "max-h-[20%] shrink-0 overflow-auto" : undefined}>{references(selected.command.evidence_refs)}</div> : null}
</div>;
return <Card ref={card} data-investigation-chart={tracked ? group.items[0].command.chart_id : undefined} data-chart-group-tile={tile || undefined}
role="group" aria-label={title} className={`relative min-w-0 border-border shadow-none ${tile ? "flex h-full min-h-0 flex-col overflow-hidden" : ""}`}>
{tracked ? group.items.slice(1).map((item) => <span key={item.command.chart_id} data-investigation-chart={item.command.chart_id} className="pointer-events-none absolute inset-0" />) : null}
<CardHeader className={`flex-row items-start justify-between gap-2 space-y-0 ${tile ? "shrink-0 px-2 py-1.5" : "p-3 pb-2"}`}>
<div className="min-w-0">
<h3 className={`break-words text-xs font-semibold [overflow-wrap:anywhere] ${tile ? "leading-4" : "leading-5"}`}>{title}</h3>
{!tile ? <p className="mt-1 text-[11px] text-muted-foreground">{multiple ? `${group.items.length} 个对象 · 点击切换 · ` : ""}{selected.command.chart.series.some((series) => series.type === "scatter") ? "折线与历史散点" : selected.command.chart.chart_type === "bar" ? "柱状图" : "折线图"} · {selected.command.chart.x_data.length} 个{selected.command.chart.chart_type === "bar" ? "类别" : "时间点"}</p> : null}
</div>
<div className={`flex shrink-0 items-center gap-1 ${tile ? "[&>button]:size-6" : ""}`}>
{tile && selected.command.evidence_refs.length ? <Popover><PopoverTrigger asChild><Button type="button" variant="ghost" size="icon-sm" aria-label={`查看图表依据 ${title}`} title="查看图表依据"><FileText size={14} aria-hidden /></Button></PopoverTrigger>
<PopoverContent align="end" className="workbench-shadcn-surface z-[90] max-h-72 overflow-auto p-2">{references(selected.command.evidence_refs)}</PopoverContent>
</Popover> : null}
<FloatingWindow title={title} fillContent>{() => content(true)}</FloatingWindow>
<Button type="button" variant="ghost" size="icon-sm" disabled={reviewing} aria-label={`关闭图表 ${title}`}
onClick={() => group.items.forEach((item) => onCloseChart(item.command.chart_id))}><X size={14} aria-hidden /></Button>
</div>
</CardHeader>
<CardContent className={tile ? "flex min-h-0 flex-1 flex-col px-2 pb-2 pt-0" : "px-3 pb-2 pt-0"}>
{content(tile, tile)}
</CardContent>
</Card>;
}
@@ -0,0 +1,118 @@
import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
import { DragDropProvider, DragOverlay, type DragEndEvent } from "@dnd-kit/react";
import { Columns2, GripVertical, LayoutGrid, LocateFixed, RotateCcw, Square } from "lucide-react";
import { AnswerUiView, type AnswerUiBlock } from "@/features/agent";
import { Button } from "@/shared/ui/button";
import { FloatingWindow } from "@/shared/ui/floating-window";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/ui/select";
import { formatChartDisplayText } from "@/shared/utils/date-time-text";
import { useChartWindowLayout } from "../hooks/use-chart-window-layout";
import type { InvestigationChartGroup } from "../utils/investigation-chart-groups";
import { presentationChartBlocks } from "../utils/presentation-chart-blocks";
import { InvestigationChartGroup as ChartGroup } from "./investigation-chart-group";
import { ChartDropSlot, ManagedChartSlot } from "./investigation-chart-slot";
type ManagedChart = { id: string; title: string } & ({ group: InvestigationChartGroup; block?: never } | { block: AnswerUiBlock; group?: never });
const layouts = [{ id: "one", label: "单图布局", icon: Square }, { id: "two", label: "左右布局", icon: Columns2 }, { id: "four", label: "四宫格布局", icon: LayoutGrid }] as const;
export function InvestigationChartManager({ groups, blocks, autoOpen, taskTitle, reviewing, onCloseChart, references }: {
groups: InvestigationChartGroup[];
blocks: AnswerUiBlock[];
/** Transient live intent, never derived from restored chart data or history. */
autoOpen: boolean;
taskTitle: string;
reviewing: boolean;
onCloseChart: (id: string) => void;
references: (ids: string[]) => ReactNode;
}) {
const entries = useMemo<ManagedChart[]>(() => [
...groups.map((group) => ({ id: `command:${group.id}`, title: formatChartDisplayText(group.title), group })),
...presentationChartBlocks(blocks).map((block) => ({ id: `report:${block.block_id}`, title: formatChartDisplayText(String(block.spec.elements[block.spec.root].props.title)), block }))
], [groups, blocks]);
const titles = useMemo(() => new Map(entries.map(({ id, title }) => [id, title])), [entries]);
const arrangement = useChartWindowLayout(entries.map(({ id }) => id));
const { layout, slots, columns } = arrangement;
const scrollRef = useRef<HTMLDivElement>(null);
const [located, setLocated] = useState<{ id: string } | null>(null);
const [announcement, setAnnouncement] = useState("");
const cards = useRef(new Map<string, HTMLElement>());
const pendingLocation = useRef<string | null>(null);
const helpId = useId();
useEffect(() => {
if (!located) return;
const timer = window.setTimeout(() => setLocated(null), 1600);
return () => window.clearTimeout(timer);
}, [located]);
const move = (id: string, target: number) => {
arrangement.move(id, target);
setAnnouncement(`${titles.get(id)},已移至位置 ${target + 1}`);
};
const drop = (event: DragEndEvent) => {
const { source, target } = event.operation;
if (!event.canceled && source && target && typeof target.data.slot === "number") move(String(source.id), target.data.slot);
};
const count = entries.length;
return <span className="inline-flex shrink-0 [&>button]:w-8 [&>button]:px-0 @min-[340px]:[&>button]:w-auto @min-[340px]:[&>button]:px-2">
<FloatingWindow title="图表管理" navigationTitle={`${taskTitle} · 图表管理`} triggerLabel="打开图表管理" triggerContent={<span className="hidden @min-[340px]:inline">图表窗口 <span className="tabular-nums">{count}</span></span>}
initialSize={{ width: 1120, height: 760 }} fillContent
// A session gets one automatic request. Updates never revoke a user's dismissal.
openRequestKey={autoOpen ? "live-charts" : undefined}>
{() => <section ref={arrangement.containerRef} aria-label="全部图表" className="@container flex h-full min-h-0 min-w-0 flex-col gap-2">
<div className="shrink-0 space-y-2 border-b border-border pb-2">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1"><p className="break-words text-sm font-medium">{taskTitle}</p><p className="mt-1 text-xs text-muted-foreground">{reviewing ? "历史回看" : "当前结果"} · 拖到固定位置交换,支持单图放大</p></div>
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">{count} 张图表</span>
</div>
<div role="toolbar" aria-label="图表管理工具" className="flex min-w-0 flex-wrap items-center gap-1 rounded-lg bg-muted/50 p-1">
<Select value="" onValueChange={(id) => { setLocated({ id }); pendingLocation.current = id; }} disabled={!count}>
<SelectTrigger aria-label="快速定位图表" className="h-8 w-auto min-w-0 max-w-72 flex-[1_1_160px] gap-2 border-transparent bg-transparent px-2 text-xs shadow-none [--field-focus-border:transparent] hover:bg-background/70 focus-visible:ring-1 focus-visible:ring-border [&>span]:flex-1 [&>span]:truncate [&>span]:text-left"><LocateFixed size={14} className="shrink-0" aria-hidden /><SelectValue placeholder="快速定位图表" /></SelectTrigger>
<SelectContent position="popper" className="workbench-shadcn-surface z-[90] max-w-[calc(100vw-24px)]"
onCloseAutoFocus={(event) => {
const id = pendingLocation.current;
pendingLocation.current = null;
const card = id ? cards.current.get(id) : undefined;
if (card) {
event.preventDefault();
card.scrollIntoView({ block: "start", inline: "nearest", behavior: "auto" });
card.focus({ preventScroll: true });
}
}}>
{slots.map((id, index) => id ? <SelectItem key={id} value={id} className="whitespace-normal text-xs [&>span:last-child]:break-words">位置 {index + 1} · {titles.get(id)}</SelectItem> : null)}
</SelectContent>
</Select>
<div className="ml-auto flex shrink-0 items-center gap-1">
<div role="group" aria-label="图表排列方式" className="flex items-center gap-1">
{layouts.map(({ id, label, icon: Icon }) => <Button key={id} type="button" variant={layout === id ? "secondary" : "ghost"} size="icon-sm"
aria-label={label} title={label} aria-pressed={layout === id} onClick={() => arrangement.setLayout(id)}><Icon size={14} aria-hidden /></Button>)}
</div>
<span className="inline-flex border-l border-border pl-1">
<Button type="button" variant="ghost" size="sm" className="h-8 gap-1.5 px-2 text-xs" aria-label="重置图表排列" disabled={!count}
onClick={() => { arrangement.reset(); scrollRef.current?.scrollTo({ top: 0, behavior: "instant" }); setAnnouncement("已恢复默认图表位置"); }}><RotateCcw size={14} aria-hidden />重置</Button>
</span>
</div>
</div>
</div>
<p id={helpId} className="sr-only">拖动整条位置栏到目标位置可交换图表。向下滚动查看全部图表,也可快速定位。</p>
<p role="status" aria-live="polite" className="sr-only">{announcement}</p>
<DragDropProvider onDragEnd={drop}>
<div ref={scrollRef} tabIndex={0} aria-label="图表滚动区" className="min-h-0 flex-1 overflow-auto overscroll-contain p-0.5" style={{ containerType: "size", scrollbarGutter: "stable" }} data-chart-manager-scroll="">
{count ? <div data-chart-layout={layout} className={`isolate grid min-h-full min-w-0 content-start gap-3 ${layout === "four" ? "grid-cols-2 auto-rows-[max(256px,calc((100cqh-12px)/2))]" : layout === "two" ? "grid-cols-2 auto-rows-[max(256px,100cqh)]" : "grid-cols-1 auto-rows-[max(256px,100cqh)]"}`}>
{slots.map((id, index) => <ChartDropSlot key={index} index={index} columns={columns} occupied={Boolean(id)} />)}
{entries.map((entry) => {
const index = slots.indexOf(entry.id);
return <ManagedChartSlot key={entry.id} id={entry.id} title={entry.title} index={index} columns={columns}
located={located?.id === entry.id} helpId={helpId}
register={(id, node) => { if (node) cards.current.set(id, node); else cards.current.delete(id); }}>
{entry.group ? <ChartGroup group={entry.group} tile tracked={false} reviewing={reviewing} onCloseChart={onCloseChart} references={references} /> : <AnswerUiView block={entry.block} chartPresentation="tile" disabled={reviewing} />}
</ManagedChartSlot>;
})}
</div> : <p role="status" className="py-8 text-center text-sm text-muted-foreground">当前没有图表,新的图表将在这里更新。</p>}
</div>
<DragOverlay dropAnimation={null} style={{ position: "absolute", zIndex: 100 }}>
{(source) => <div data-chart-drag-preview="" className="workbench-shadcn-surface flex max-w-80 items-center gap-2 rounded-lg border border-border bg-background px-3 py-2 text-xs text-foreground shadow-xs"><GripVertical size={14} className="shrink-0 text-muted-foreground" aria-hidden /><span className="min-w-0 break-words">{String(source.data.title)}</span></div>}
</DragOverlay>
</DragDropProvider>
</section>}
</FloatingWindow>
</span>;
}
@@ -0,0 +1,44 @@
import { useDraggable, useDroppable } from "@dnd-kit/react";
import { pointerIntersection } from "@dnd-kit/collision";
import { GripVertical } from "lucide-react";
import type { CSSProperties, ReactNode } from "react";
function chartSlotPosition(index: number, columns: number): CSSProperties {
return { gridRow: Math.floor(index / columns) + 1, gridColumn: index % columns + 1 };
}
export function ChartDropSlot({ index, columns, occupied }: { index: number; columns: number; occupied: boolean }) {
const { ref, isDropTarget } = useDroppable({ id: `slot:${index}`, type: "slot", accept: "chart", data: { slot: index }, collisionDetector: pointerIntersection });
return <div ref={ref} data-chart-slot={index} style={chartSlotPosition(index, columns)}
className={`pointer-events-none relative flex min-h-0 min-w-0 items-center justify-center rounded-xl border border-dashed ${isDropTarget ? "z-10 border-primary/40 bg-primary/5 ring-1 ring-primary/15 ring-inset" : occupied ? "border-transparent" : "border-border bg-muted/20"}`}>
{!occupied ? <p className="text-center text-xs leading-6 text-muted-foreground"><span className="block tabular-nums">位置 {index + 1}</span>拖动图表到这里</p> : null}
</div>;
}
/** The chart stays under the same grid parent when its CSS position changes. */
export function ManagedChartSlot({ id, title, index, columns, located, helpId, register, children }: {
id: string;
title: string;
index: number;
columns: number;
located: boolean;
helpId: string;
register: (id: string, element: HTMLElement | null) => void;
children: ReactNode;
}) {
const { ref, handleRef, isDragSource } = useDraggable({ id, type: "chart", data: { title } });
return <article ref={(node) => { ref(node); register(id, node); }} tabIndex={-1}
data-managed-chart={id} data-chart-title={title} data-slot-index={index} data-chart-located={located} data-chart-dragging={isDragSource}
aria-label={`图表窗口 ${title}`} style={chartSlotPosition(index, columns)}
className="group/chart-slot relative z-0 flex min-h-0 min-w-0 flex-col rounded-xl outline-hidden">
<div className={`flex h-9 shrink-0 items-center rounded-lg ${located ? "bg-primary/10" : ""}`}>
<button ref={handleRef} type="button" data-slot="button" className={`flex h-full w-full touch-none select-none items-center justify-start gap-1 rounded-lg border border-transparent px-2 text-xs font-normal outline-hidden focus-visible:outline-1 focus-visible:outline-ring focus-visible:-outline-offset-1 active:cursor-grabbing ${isDragSource ? "cursor-grabbing bg-muted text-foreground" : "cursor-grab bg-transparent text-muted-foreground [@media(hover:hover)]:hover:bg-muted/70 [@media(hover:hover)]:hover:text-foreground"}`}
aria-label={`拖动图表 ${title}`} aria-describedby={helpId}>
<GripVertical size={14} aria-hidden /><span className="tabular-nums">位置 {index + 1}</span>
{located ? <span aria-hidden="true" className="ml-auto shrink-0 text-xs font-medium text-primary">已定位</span> : null}
</button>
</div>
<div className="min-h-0 min-w-0 flex-1">{children}</div>
<div aria-hidden="true" data-chart-hover-highlight="" className={`pointer-events-none absolute inset-0 z-10 rounded-xl bg-primary/[0.04] ${isDragSource ? "opacity-100" : "opacity-0 [@media(hover:hover)]:group-hover/chart-slot:opacity-100"}`} />
</article>;
}
@@ -1,8 +1,7 @@
import { useId, useState } from "react"; import { useId, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ChevronDown, Eye, EyeOff, LocateFixed, MapPinned, Pause, X } from "lucide-react"; import { ChevronDown, Eye, EyeOff, LocateFixed, MapPinned, Pause } from "lucide-react";
import { AgentChartView, type AnswerUiBlock } from "@/features/agent"; import type { AnswerUiBlock } from "@/features/agent";
import { formatChartDisplayText } from "@/shared/utils/date-time-text";
import { Badge } from "@/shared/ui/badge"; import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { Card, CardContent, CardHeader } from "@/shared/ui/card"; import { Card, CardContent, CardHeader } from "@/shared/ui/card";
@@ -19,7 +18,9 @@ import type { OperationalTask } from "../api/operational-task-client";
import type { InvestigationOverlay } from "../api/investigation-presentation"; import type { InvestigationOverlay } from "../api/investigation-presentation";
import type { InvestigationChart } from "../hooks/use-investigation-presentation"; import type { InvestigationChart } from "../hooks/use-investigation-presentation";
import { FloatingWindow } from "@/shared/ui/floating-window"; import { InvestigationChartManager } from "./investigation-chart-manager";
import { InvestigationChartGroup } from "./investigation-chart-group";
import { groupInvestigationCharts } from "../utils/investigation-chart-groups";
import { PresentationHistorySwitcher } from "./presentation-history-switcher"; import { PresentationHistorySwitcher } from "./presentation-history-switcher";
import { PresentationBusinessResults } from "./presentation-business-results"; import { PresentationBusinessResults } from "./presentation-business-results";
import type { PresentationHistoryControls } from "../utils/presentation-view-history"; import type { PresentationHistoryControls } from "../utils/presentation-view-history";
@@ -36,6 +37,8 @@ type Props = {
task: OperationalTask | null; task: OperationalTask | null;
localPreview?: boolean; localPreview?: boolean;
charts: InvestigationChart[]; charts: InvestigationChart[];
chartWindowAutoOpen?: boolean;
chartWindowScope?: string;
overlay: InvestigationOverlay | null; overlay: InvestigationOverlay | null;
cameraPaused: boolean; cameraPaused: boolean;
status: "connecting" | "live" | "history" | "disconnected" | "error"; status: "connecting" | "live" | "history" | "disconnected" | "error";
@@ -68,6 +71,8 @@ export function InvestigationPresentationPanel({
onLocateTargets, onLocateTargets,
localPreview = false, localPreview = false,
charts, charts,
chartWindowAutoOpen = false,
chartWindowScope,
overlay, overlay,
cameraPaused, cameraPaused,
status, status,
@@ -80,13 +85,13 @@ export function InvestigationPresentationPanel({
onFitOverlay, onFitOverlay,
onToggleLayer onToggleLayer
}: Props) { }: Props) {
const chartGroups = groupInvestigationCharts(charts);
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const [keyboardAction, setKeyboardAction] = useState(false); const [keyboardAction, setKeyboardAction] = useState(false);
const reducedMotion = useReducedMotion(); const reducedMotion = useReducedMotion();
const immediate = Boolean(reducedMotion) || keyboardAction; const immediate = Boolean(reducedMotion) || keyboardAction;
const contentId = useId(); const contentId = useId();
const reviewing = history?.selectedId != null; const reviewing = history?.selectedId != null;
const chartWindowKey = scope === "chat" ? undefined : JSON.stringify([scope, task?.task_id, reviewing ? "history" : "live"]);
const selected = history?.entries.find((entry) => entry.id === history.selectedId); const selected = history?.entries.find((entry) => entry.id === history.selectedId);
const pending = selected ? history!.entries.filter((entry) => entry.id > selected.id).length : 0; const pending = selected ? history!.entries.filter((entry) => entry.id > selected.id).length : 0;
const title = scope === "chat" ? "对话展示" : localPreview ? "本地预览展示" : "调查实时展示"; const title = scope === "chat" ? "对话展示" : localPreview ? "本地预览展示" : "调查实时展示";
@@ -121,7 +126,7 @@ export function InvestigationPresentationPanel({
<motion.header <motion.header
layout={immediate || localPreview ? false : "position"} layout={immediate || localPreview ? false : "position"}
transition={presentationTransition(immediate)} transition={presentationTransition(immediate)}
className={`shrink-0 border-b border-border px-3 ${localPreview ? `${collapsed ? "h-[110px]" : "min-h-[110px]"} py-2` : "py-2.5"}`} className={`@container shrink-0 border-b border-border px-3 ${localPreview ? `${collapsed ? "h-[110px]" : "min-h-[110px]"} py-2` : "py-2.5"}`}
> >
<div className={localPreview ? "grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-2 gap-y-1" : "flex items-start justify-between gap-2"}> <div className={localPreview ? "grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-2 gap-y-1" : "flex items-start justify-between gap-2"}>
<div className={localPreview ? "contents" : "min-w-0 space-y-1"}> <div className={localPreview ? "contents" : "min-w-0 space-y-1"}>
@@ -137,7 +142,7 @@ export function InvestigationPresentationPanel({
: statusLabels[status]} : statusLabels[status]}
</Badge> </Badge>
<span className="text-[11px] text-muted-foreground tabular-nums"> <span className="text-[11px] text-muted-foreground tabular-nums">
图表 {charts.length} · 范围 {overlay ? 1 : 0} · 数据 {records.length} 图表 {chartGroups.length} · 范围 {overlay ? 1 : 0} · 数据 {records.length}
</span> </span>
{reviewing && pending > 0 ? ( {reviewing && pending > 0 ? (
<span role="status" className="text-[11px] text-muted-foreground"> <span role="status" className="text-[11px] text-muted-foreground">
@@ -146,6 +151,10 @@ export function InvestigationPresentationPanel({
) : null} ) : null}
</div> </div>
</div> </div>
<div className={`flex shrink-0 items-center gap-1 ${localPreview ? "col-start-2 row-start-1" : ""}`}>
<InvestigationChartManager key={chartWindowScope ?? JSON.stringify([scope, task?.task_id, task?.execution_generation])}
groups={chartGroups} blocks={blocks} autoOpen={chartWindowAutoOpen} taskTitle={task?.title ?? title}
reviewing={reviewing} onCloseChart={onCloseChart} references={references} />
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
@@ -161,6 +170,7 @@ export function InvestigationPresentationPanel({
<ChevronDown size={16} aria-hidden /> <ChevronDown size={16} aria-hidden />
</motion.span> </motion.span>
</Button> </Button>
</div>
</div> </div>
<p <p
className={ className={
@@ -242,7 +252,7 @@ export function InvestigationPresentationPanel({
</div> </div>
</section> </section>
) : null} ) : null}
<AnimatePresence initial={false} mode="popLayout"> <AnimatePresence key={chartWindowScope} initial={false} mode="popLayout">
{overlay ? ( {overlay ? (
<PresentationItem key={`overlay-${overlay.overlay_id}`} immediate={immediate}> <PresentationItem key={`overlay-${overlay.overlay_id}`} immediate={immediate}>
<Card role="region" aria-label="调查覆盖层" className="border-border shadow-none"> <Card role="region" aria-label="调查覆盖层" className="border-border shadow-none">
@@ -291,51 +301,10 @@ export function InvestigationPresentationPanel({
</Card> </Card>
</PresentationItem> </PresentationItem>
) : null} ) : null}
{charts.map((item) => ( {chartGroups.map((group) => (
<PresentationItem key={item.chart_id} immediate={immediate}> <PresentationItem key={`${chartWindowScope}:${group.id}`} immediate={immediate}>
<Card <InvestigationChartGroup group={group} reviewing={reviewing ?? false}
data-investigation-chart={item.chart_id} onCloseChart={onCloseChart} references={references} />
role="group"
aria-label={formatChartDisplayText(item.chart.title)}
className="border-border shadow-none"
>
<CardHeader className="flex-row items-start justify-between gap-2 space-y-0 p-3 pb-2">
<div className="min-w-0">
<h3 className="break-words text-xs font-semibold leading-5 [overflow-wrap:anywhere]">
{formatChartDisplayText(item.chart.title)}
</h3>
<p className="mt-1 text-[11px] text-muted-foreground">
{item.chart.series.some((series) => series.type === "scatter") ? "折线与历史散点" : item.chart.chart_type === "bar" ? "柱状图" : "折线图"} ·{" "}
{item.chart.x_data.length} 个
{item.chart.chart_type === "bar" ? "类别" : "时间点"}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<FloatingWindow title={formatChartDisplayText(item.chart.title)} fillContent
openRequestKey={chartWindowKey === undefined ? undefined : JSON.stringify([chartWindowKey, item])}>
{() => <div className="flex h-full min-h-0 min-w-0 flex-col gap-2">
<AgentChartView chart={item.chart} embedded expanded />
{item.evidence_refs.length ? <div className="max-h-[35%] shrink-0 overflow-auto">{references(item.evidence_refs)}</div> : null}
</div>}
</FloatingWindow>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="shrink-0"
disabled={reviewing}
aria-label={`关闭图表 ${formatChartDisplayText(item.chart.title)}`}
onClick={() => onCloseChart(item.chart_id)}
>
<X size={14} aria-hidden />
</Button>
</div>
</CardHeader>
<CardContent className="px-3 pb-2 pt-0">
<AgentChartView chart={item.chart} embedded />
{references(item.evidence_refs)}
</CardContent>
</Card>
</PresentationItem> </PresentationItem>
))} ))}
</AnimatePresence> </AnimatePresence>
@@ -343,7 +312,6 @@ export function InvestigationPresentationPanel({
blocks={blocks} blocks={blocks}
records={records} records={records}
reviewing={reviewing} reviewing={reviewing}
chartWindowKey={chartWindowKey}
onLocateTargets={onLocateTargets} onLocateTargets={onLocateTargets}
/> />
{!charts.length && {!charts.length &&
@@ -7,7 +7,6 @@ type Props = {
blocks: AnswerUiBlock[]; blocks: AnswerUiBlock[];
records: Array<Record<string, unknown>>; records: Array<Record<string, unknown>>;
reviewing: boolean; reviewing: boolean;
chartWindowKey?: string;
onLocateTargets?: (targets: InvestigationMapTarget[]) => Promise<unknown>; onLocateTargets?: (targets: InvestigationMapTarget[]) => Promise<unknown>;
}; };
@@ -15,7 +14,6 @@ export function PresentationBusinessResults({
blocks, blocks,
records, records,
reviewing, reviewing,
chartWindowKey,
onLocateTargets onLocateTargets
}: Props) { }: Props) {
if (!blocks.length && !records.length) return null; if (!blocks.length && !records.length) return null;
@@ -26,7 +24,6 @@ export function PresentationBusinessResults({
key={block.block_id} key={block.block_id}
block={block} block={block}
disabled={reviewing} disabled={reviewing}
chartWindowKey={chartWindowKey}
onLocate={ onLocate={
onLocateTargets && !reviewing onLocateTargets && !reviewing
? (target) => ? (target) =>
@@ -16,10 +16,12 @@ import {
UserRound, UserRound,
type LucideIcon type LucideIcon
} from "lucide-react"; } from "lucide-react";
import { useRef, useState, type ReactNode } from "react"; import { useEffect, useRef, useState, type ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ProjectSwitchDialog } from "./project-switch-dialog"; import { ProjectSwitchDialog } from "./project-switch-dialog";
import { import {
mapOptionInteractionVariants,
MAP_ACTION_ROW_INTERACTION_CLASS_NAME,
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME, MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
@@ -53,11 +55,12 @@ const menuSurfaceClassName = cn(
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME, MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
"acrylic-panel border p-2 text-slate-900" "acrylic-panel border p-2 text-slate-900"
); );
const menuReadableRowClassName = const alertReadableRowClassName =
"surface-well focus:bg-[var(--action-soft)] focus:text-slate-950"; "surface-well focus:bg-[var(--action-soft)] focus:text-slate-950";
const alertActionClassName = cn( const alertActionClassName = cn(
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
"h-10 w-10 shrink-0 justify-center border border-transparent bg-transparent p-0 text-[var(--ink-muted)] transition-colors hover:border-[var(--action-blue)] hover:bg-[var(--action-blue)] hover:text-white focus:border-[var(--action-blue)] focus:bg-[var(--action-blue)] focus:text-white data-[highlighted]:border-[var(--action-blue)] data-[highlighted]:bg-[var(--action-blue)] data-[highlighted]:text-white active:scale-95" "h-10 w-10 shrink-0 justify-center border border-transparent bg-transparent p-0 text-[var(--ink-muted)] focus:bg-[var(--surface-reading)] focus:text-[var(--action-blue-hover)]",
mapOptionInteractionVariants()
); );
export function AnalysisViewMenu({ export function AnalysisViewMenu({
@@ -143,13 +146,28 @@ export function AlertMenu({
onAnalyzeAlertsWithAgent: () => void | Promise<void>; onAnalyzeAlertsWithAgent: () => void | Promise<void>;
}) { }) {
const reduceMotion = useReducedMotion(); const reduceMotion = useReducedMotion();
const keyboardInteraction = useRef(false); const [keyboardInteraction, setKeyboardInteraction] = useState(false);
const skipRemovalMotion = reduceMotion === true || keyboardInteraction.current; const skipRemovalMotion = reduceMotion === true || keyboardInteraction;
const hasAlerts = alerts.length > 0; const hasAlerts = alerts.length > 0;
const hasCriticalAlerts = alerts.some((alert) => alert.severity !== "warning"); const hasCriticalAlerts = alerts.some((alert) => alert.severity !== "warning");
const alertSummaryTone = hasCriticalAlerts ? "danger" : hasAlerts ? "warning" : "neutral"; const alertSummaryTone = hasCriticalAlerts ? "danger" : hasAlerts ? "warning" : "neutral";
const latestAlertTime = alerts[0]?.time; const latestAlertTime = alerts[0]?.time;
useEffect(() => {
// Radix can preserve :focus-visible through pointer-driven focus transfers.
// Track actual input, including Tab from controls outside this portaled menu.
const handleKeyboard = () => setKeyboardInteraction(true);
const handlePointer = () => setKeyboardInteraction(false);
document.addEventListener("keydown", handleKeyboard, true);
document.addEventListener("pointerdown", handlePointer, true);
document.addEventListener("pointermove", handlePointer, true);
return () => {
document.removeEventListener("keydown", handleKeyboard, true);
document.removeEventListener("pointerdown", handlePointer, true);
document.removeEventListener("pointermove", handlePointer, true);
};
}, []);
return ( return (
<DropdownMenu modal={false} open={open} onOpenChange={onOpenChange}> <DropdownMenu modal={false} open={open} onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -158,6 +176,7 @@ export function AlertMenu({
className={cn( className={cn(
"group relative px-2", "group relative px-2",
headerControlButtonClassName, headerControlButtonClassName,
!keyboardInteraction && "focus-visible:ring-0 focus-visible:ring-offset-0",
hasAlerts && !compact && hasAlerts && !compact &&
(hasCriticalAlerts ? "status-tone-danger" : "status-tone-warning"), (hasCriticalAlerts ? "status-tone-danger" : "status-tone-warning"),
hasAlerts && !compact && hasAlerts && !compact &&
@@ -197,10 +216,7 @@ export function AlertMenu({
)} )}
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={8} className={cn("w-[min(392px,calc(100vw-24px))]", menuSurfaceClassName)} <DropdownMenuContent align="end" sideOffset={8} className={cn("w-[min(392px,calc(100vw-24px))]", menuSurfaceClassName)}>
onKeyDownCapture={() => { keyboardInteraction.current = true; }}
onPointerDownCapture={() => { keyboardInteraction.current = false; }}
>
<AlertQueueSummary <AlertQueueSummary
count={alerts.length} count={alerts.length}
latestAlertTime={latestAlertTime} latestAlertTime={latestAlertTime}
@@ -230,12 +246,15 @@ export function AlertMenu({
tone === "warning" ? "status-tone-warning" : "status-tone-danger", tone === "warning" ? "status-tone-warning" : "status-tone-danger",
"relative my-1 min-h-[76px] border border-transparent px-3 py-2 transition-colors hover:border-[var(--action-border-hover)] hover:bg-[var(--action-selection-soft)] focus-within:border-[var(--action-border-hover)] focus-within:bg-[var(--action-selection-soft)]", "relative my-1 min-h-[76px] border border-transparent px-3 py-2 transition-colors hover:border-[var(--action-border-hover)] hover:bg-[var(--action-selection-soft)] focus-within:border-[var(--action-border-hover)] focus-within:bg-[var(--action-selection-soft)]",
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
menuReadableRowClassName alertReadableRowClassName
)} )}
> >
<DropdownMenuItem <DropdownMenuItem
asChild asChild
className="absolute inset-0 z-0 h-full w-full cursor-pointer rounded-[inherit] border-0 bg-transparent p-0 hover:bg-transparent focus:bg-transparent focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--action-focus-ring)]" className={cn(
"absolute inset-0 z-0 h-full w-full cursor-pointer rounded-[inherit] border-0 bg-transparent p-0 hover:bg-transparent focus:bg-transparent focus-visible:ring-inset focus-visible:ring-[var(--action-focus-ring)]",
keyboardInteraction && "focus-visible:ring-2"
)}
onSelect={() => onSelectAlert(alert)} onSelect={() => onSelectAlert(alert)}
> >
<button <button
@@ -381,8 +400,8 @@ export function UserMenu({
onSelect={onLogout ? () => void onLogout() : undefined} onSelect={onLogout ? () => void onLogout() : undefined}
className={cn( className={cn(
"px-2 py-2", "px-2 py-2",
!onLogout && "text-[var(--ink-muted)]", MAP_ACTION_ROW_INTERACTION_CLASS_NAME,
MAP_COMPACT_RADIUS_CLASS_NAME !onLogout && "text-[var(--ink-muted)]"
)} )}
> >
<LogOut size={15} aria-hidden="true" /> <LogOut size={15} aria-hidden="true" />
@@ -466,7 +485,7 @@ function MenuPlainButton({
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
disabled disabled
? "border-[var(--surface-divider)] bg-[var(--surface-reading)] text-[var(--ink-muted)]" ? "border-[var(--surface-divider)] bg-[var(--surface-reading)] text-[var(--ink-muted)]"
: "border-[var(--surface-divider)] bg-[var(--surface-reading)] text-slate-800 hover:border-[var(--surface-border)] hover:bg-[var(--action-soft)]/55 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)]" : cn(MAP_ACTION_ROW_INTERACTION_CLASS_NAME, "focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)]")
)} )}
> >
<span className="truncate text-xs font-semibold leading-4">{label}</span> <span className="truncate text-xs font-semibold leading-4">{label}</span>
@@ -496,7 +515,7 @@ function MenuPrimaryButton({
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
disabled disabled
? "border-[var(--surface-divider)] bg-[var(--surface-reading)] text-[var(--ink-muted)]" ? "border-[var(--surface-divider)] bg-[var(--surface-reading)] text-[var(--ink-muted)]"
: "border-[var(--surface-border)] bg-[var(--action-blue)] text-white shadow-lg shadow-[color:var(--action-blue)]/20 hover:bg-[var(--action-blue-hover)] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)]" : cn("border-[var(--surface-border)] bg-[var(--action-blue)] text-white shadow-lg shadow-[color:var(--action-blue)]/20", mapOptionInteractionVariants({ state: "primary" }))
)} )}
> >
<Icon size={14} aria-hidden="true" /> <Icon size={14} aria-hidden="true" />
@@ -554,9 +573,8 @@ function MenuAction({
return ( return (
<DropdownMenuItem <DropdownMenuItem
className={cn( className={cn(
"my-0.5 items-center border border-transparent px-2 py-2 transition", "my-0.5 items-center px-2 py-2 transition-colors",
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_ACTION_ROW_INTERACTION_CLASS_NAME
menuReadableRowClassName
)} )}
onSelect={onSelect} onSelect={onSelect}
> >
@@ -566,13 +584,13 @@ function MenuAction({
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
tone === "primary" && "border-[var(--surface-border)] bg-[var(--action-soft)] text-[var(--action-blue-hover)]", tone === "primary" && "border-[var(--surface-border)] bg-[var(--action-soft)] text-[var(--action-blue-hover)]",
tone === "warning" && "status-icon status-tone-warning", tone === "warning" && "status-icon status-tone-warning",
tone === "default" && "border-[var(--surface-divider)] bg-[var(--surface-reading)] text-slate-600" tone === "default" && "border-[var(--surface-divider)] bg-[var(--surface-reading)]"
)} )}
> >
<Icon size={15} aria-hidden="true" /> <Icon size={15} aria-hidden="true" />
</span> </span>
<span className="min-w-0"> <span className="min-w-0">
<span className="block text-sm font-semibold leading-5 text-slate-900">{label}</span> <span className="block text-sm font-semibold leading-5">{label}</span>
<span className="mt-0.5 block truncate text-xs text-[var(--ink-muted)]">{description}</span> <span className="mt-0.5 block truncate text-xs text-[var(--ink-muted)]">{description}</span>
</span> </span>
</DropdownMenuItem> </DropdownMenuItem>
@@ -1,9 +1,12 @@
import { buttonInteractionVariants } from "@/shared/ui/button-interaction"; import { buttonInteractionVariants } from "@/shared/ui/button-interaction";
import { mapOptionInteractionVariants } from "@/features/map/core";
import { cn } from "@/shared/ui/cn"; import { cn } from "@/shared/ui/cn";
export const headerControlButtonClassName = export const headerControlButtonClassName =
cn( cn(
buttonInteractionVariants({ tone: "neutral" }), buttonInteractionVariants({ tone: "neutral" }),
mapOptionInteractionVariants(),
"data-[state=open]:border-[var(--surface-border)] data-[state=open]:bg-[var(--surface-reading)] data-[state=open]:text-[var(--action-blue-hover)]",
"inline-flex h-8 items-center gap-2 whitespace-nowrap rounded-full border border-transparent bg-transparent text-sm font-semibold after:absolute after:-inset-1 after:content-['']" "inline-flex h-8 items-center gap-2 whitespace-nowrap rounded-full border border-transparent bg-transparent text-sm font-semibold after:absolute after:-inset-1 after:content-['']"
); );
@@ -11,6 +14,6 @@ export const compactHeaderControlButtonClassName =
"h-10 w-10 justify-center px-0"; "h-10 w-10 justify-center px-0";
export const headerControlIconClassName = export const headerControlIconClassName =
"header-control-icon grid h-5 w-5 shrink-0 place-items-center rounded-full bg-[var(--surface-control)]/80 text-[var(--ink-muted)] transition-colors group-hover:bg-[var(--action-soft)] group-hover:text-[var(--action-blue-hover)] group-data-[state=open]:bg-[var(--action-soft)] group-data-[state=open]:text-[var(--action-blue-hover)]"; "header-control-icon grid h-5 w-5 shrink-0 place-items-center rounded-full bg-[var(--surface-control)]/80 text-inherit";
export const compactHeaderTextClassName = "hidden min-[1800px]:inline"; export const compactHeaderTextClassName = "hidden min-[1800px]:inline";
@@ -1,4 +1,4 @@
import { useState } from "react"; import { useState, useSyncExternalStore } from "react";
import { import {
Box, Box,
CalendarClock, CalendarClock,
@@ -14,6 +14,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { MAP_ICON_CELL_RADIUS_CLASS_NAME } from "@/features/map/core"; import { MAP_ICON_CELL_RADIUS_CLASS_NAME } from "@/features/map/core";
import { cn } from "@/shared/ui/cn"; import { cn } from "@/shared/ui/cn";
import { floatingWindowEntries, subscribeFloatingWindows } from "@/shared/ui/floating-window-layer";
import type { import type {
WorkbenchAlert, WorkbenchAlert,
WorkbenchAlertDataSource, WorkbenchAlertDataSource,
@@ -21,6 +22,7 @@ import type {
} from "../types"; } from "../types";
import type { AnalysisArtifact, ArtifactRequestedView } from "../workspace/workspace-model"; import type { AnalysisArtifact, ArtifactRequestedView } from "../workspace/workspace-model";
import { AlertMenu, AnalysisViewMenu, UserMenu, type WorkbenchProjectSelection } from "./workbench-top-bar-menus"; import { AlertMenu, AnalysisViewMenu, UserMenu, type WorkbenchProjectSelection } from "./workbench-top-bar-menus";
import { WorkbenchWindowMenu } from "./workbench-window-menu";
import { import {
compactHeaderTextClassName, compactHeaderTextClassName,
headerControlButtonClassName, headerControlButtonClassName,
@@ -63,7 +65,7 @@ export type WorkbenchTopBarProps = {
onLogout?: () => Promise<void>; onLogout?: () => Promise<void>;
}; };
type HeaderMenuId = "alerts" | "analysis-view" | "compact-alerts" | "user"; type HeaderMenuId = "alerts" | "analysis-view" | "compact-alerts" | "user" | "windows";
export function WorkbenchTopBar({ export function WorkbenchTopBar({
dataTime, dataTime,
@@ -101,12 +103,13 @@ export function WorkbenchTopBar({
onLogout onLogout
}: WorkbenchTopBarProps) { }: WorkbenchTopBarProps) {
const [openMenu, setOpenMenu] = useState<HeaderMenuId | null>(null); const [openMenu, setOpenMenu] = useState<HeaderMenuId | null>(null);
const hasWindows = useSyncExternalStore(subscribeFloatingWindows, floatingWindowEntries).length > 0;
const createMenuOpenChange = (menuId: HeaderMenuId) => (open: boolean) => { const createMenuOpenChange = (menuId: HeaderMenuId) => (open: boolean) => {
setOpenMenu(open ? menuId : null); setOpenMenu(open ? menuId : null);
}; };
return ( return (
<header className="workbench-top-bar acrylic-navigation pointer-events-auto absolute left-0 right-0 top-0 z-30 flex h-14 select-none items-center justify-between gap-2 border-b px-2 sm:gap-3 sm:px-4 md:px-5"> <header data-floating-window-inset-top="" className="workbench-top-bar acrylic-navigation pointer-events-auto absolute left-0 right-0 top-0 z-30 flex h-14 select-none items-center justify-between gap-2 border-b px-2 sm:gap-3 sm:px-4 md:px-5">
<div className="flex min-w-0 items-center gap-2 sm:gap-2.5 lg:shrink-0"> <div className="flex min-w-0 items-center gap-2 sm:gap-2.5 lg:shrink-0">
<div <div
className={cn( className={cn(
@@ -116,7 +119,7 @@ export function WorkbenchTopBar({
> >
<Droplets size={18} aria-hidden="true" /> <Droplets size={18} aria-hidden="true" />
</div> </div>
<div className="min-w-0"> <div className={cn("min-w-0", hasWindows && "max-[399px]:sr-only")}>
<h1 aria-label="供水管网智慧运维系统" className="truncate text-sm font-semibold leading-5 text-slate-950"> <h1 aria-label="供水管网智慧运维系统" className="truncate text-sm font-semibold leading-5 text-slate-950">
<span className="sm:hidden">供水</span> <span className="sm:hidden">供水</span>
<span className="hidden sm:inline">供水管网智慧运维系统</span> <span className="hidden sm:inline">供水管网智慧运维系统</span>
@@ -172,6 +175,7 @@ export function WorkbenchTopBar({
</div> </div>
<div className="flex shrink-0 items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
<WorkbenchWindowMenu open={openMenu === "windows"} onOpenChange={createMenuOpenChange("windows")} />
<button <button
type="button" type="button"
aria-label="项目分析成果" aria-label="项目分析成果"
@@ -0,0 +1,58 @@
import { useRef, useSyncExternalStore } from "react";
import { AppWindow, Check, PanelsTopLeft, X } from "lucide-react";
import { mapOptionInteractionVariants, MAP_ACTION_ROW_INTERACTION_CLASS_NAME, MAP_MAJOR_PANEL_RADIUS_CLASS_NAME } from "@/features/map/core";
import { cn } from "@/shared/ui/cn";
import { floatingWindowEntries, subscribeFloatingWindows } from "@/shared/ui/floating-window-layer";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/shared/ui/dropdown-menu";
import { headerControlButtonClassName, headerControlIconClassName } from "./workbench-top-bar-styles";
export function WorkbenchWindowMenu({ open, onOpenChange }: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const entries = useSyncExternalStore(subscribeFloatingWindows, floatingWindowEntries);
const trigger = useRef<HTMLButtonElement>(null);
const afterClose = useRef<(() => void) | null>(null);
if (!entries.length) return null;
return <DropdownMenu modal={false} open={open} onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild>
<button ref={trigger} type="button" aria-label={`窗口管理,当前 ${entries.length} 个窗口`} title="窗口管理"
className={cn("group relative px-1.5 sm:px-2", headerControlButtonClassName)}>
<span className={headerControlIconClassName}><PanelsTopLeft size={14} aria-hidden /></span>
<span className="hidden text-xs xl:inline">窗口</span>
<span aria-hidden className="absolute -right-0.5 -top-0.5 grid min-w-3.5 place-items-center rounded-full bg-[var(--action-blue)] px-0.5 text-[9px] leading-3.5 text-white tabular-nums xl:static xl:min-w-4 xl:px-1 xl:text-xs xl:leading-4">{entries.length}</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" aria-label="已打开的窗口" aria-labelledby={undefined}
className={cn("acrylic-panel z-[90] w-80 max-w-[calc(100vw-24px)] border p-2 text-slate-900", MAP_MAJOR_PANEL_RADIUS_CLASS_NAME)}
onCloseAutoFocus={(event) => {
const focus = afterClose.current;
afterClose.current = null;
if (focus) { event.preventDefault(); focus(); }
}}>
<DropdownMenuLabel className="flex items-center justify-between px-2 py-1.5 text-xs text-[var(--ink-muted)]">
<span>已打开的窗口</span><span className="tabular-nums">{entries.length} 个</span>
</DropdownMenuLabel>
{[...entries].reverse().map((entry, index) => <DropdownMenuItem key={entry.id}
aria-label={`定位窗口 ${entry.title}`} aria-current={index === 0 ? "true" : undefined}
className={cn("min-h-10 items-start px-2 py-2.5 text-xs", index === 0
? cn("rounded-xl border border-[var(--action-selection-border)] bg-[var(--action-selection-soft)] text-[var(--action-blue-hover)] focus:bg-[var(--action-selection-soft)] focus:text-[var(--action-blue-hover)]", mapOptionInteractionVariants({ state: "selected" }))
: MAP_ACTION_ROW_INTERACTION_CLASS_NAME)}
onSelect={() => {
afterClose.current = () => entry.focus();
onOpenChange(false);
}}>
<AppWindow size={15} className="mt-0.5 shrink-0" aria-hidden />
<span className="min-w-0 flex-1 break-words leading-5">{entry.title}</span>
{index === 0 ? <Check size={14} className="mt-0.5 shrink-0 text-[var(--action-blue)]" aria-hidden /> : null}
</DropdownMenuItem>)}
<DropdownMenuSeparator className="my-1.5 bg-[var(--surface-divider)]/70" />
<DropdownMenuItem className={cn("min-h-10 px-2 text-xs", MAP_ACTION_ROW_INTERACTION_CLASS_NAME)} onSelect={() => {
const fallback = trigger.current?.closest("header")?.querySelector<HTMLButtonElement>('button[aria-label="打开用户菜单"]');
afterClose.current = () => fallback?.focus({ preventScroll: true });
onOpenChange(false);
[...entries].reverse().forEach((entry) => entry.close());
}}><X size={15} aria-hidden />关闭全部窗口</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>;
}
@@ -0,0 +1,43 @@
import { useLayoutEffect, useState } from "react";
import { columnsForLayout, reconcileChartSlots, swapChartSlots, type ChartLayout, type ChartSlot } from "../utils/chart-window-slots";
/** Keep chart identities in fixed positions throughout the scrolling grid. */
export function useChartWindowLayout(ids: string[]) {
const [container, setContainer] = useState<HTMLElement | null>(null);
const [narrow, setNarrow] = useState(window.innerWidth < 760);
const [preferredLayout, setPreferredLayout] = useState<ChartLayout>("four");
const layout = narrow ? "one" : preferredLayout;
const columns = columnsForLayout(layout);
const [slots, setSlots] = useState<ChartSlot[]>([]);
const available = reconcileChartSlots(slots, ids, columns);
if (available !== slots) setSlots(available);
useLayoutEffect(() => {
if (!container) return;
const measure = () => setNarrow(container.clientWidth < 720);
measure();
const observer = new ResizeObserver(measure);
observer.observe(container);
return () => observer.disconnect();
}, [container]);
return {
containerRef: setContainer,
layout,
preferredLayout,
narrow,
setLayout: setPreferredLayout,
slots: available,
columns,
move: (id: string, target: number) => {
const source = available.indexOf(id);
const next = swapChartSlots(available, source, target);
if (next !== available) {
setSlots(next);
}
},
reset: () => {
setSlots(reconcileChartSlots([], ids, columns));
}
};
}
@@ -95,3 +95,59 @@ it("advances saved snapshots on its timer, ignores new results and stops when th
act(() => vi.advanceTimersByTime(3000)); act(() => vi.advanceTimersByTime(3000));
expect(result.current.records).toEqual([{ value: 3 }]); expect(result.current.records).toEqual([{ value: 3 }]);
}); });
it("requests only the live task manager and forgets automatic intent across task switches", async () => {
vi.useFakeTimers();
const task = { task_id: "task-a", title: "运行中调查", execution_generation: 1, status: "running", evidence: [] } as unknown as import("../api/operational-task-client").OperationalTask;
const command = { action: "open_chart" as const, chart_id: "pressure", evidence_refs: ["test"], chart: {
title: "压力", chart_type: "line" as const, x_data: ["10:00"], series: [{ name: "压力", data: [20] }]
} };
const panel = document.createElement("div");
panel.dataset.presentationScope = "investigation";
const marker = document.createElement("div");
marker.dataset.investigationChart = "pressure";
marker.getBoundingClientRect = () => ({ height: 10 }) as DOMRect;
panel.append(marker);
document.body.append(panel);
const initial = { ...options, scope: "investigation" as const, task, enabled: true };
const { result, rerender } = renderHook((next) => useInvestigationPresentation(next), { initialProps: initial });
const execute = async (origin: "live" | "preview" | "chat") => {
await act(async () => {
const pending = result.current.executeCommand(command, new AbortController().signal, origin);
await vi.advanceTimersByTimeAsync(700);
await pending;
});
};
try {
expect(result.current.chartWindowAutoOpen).toBe(false);
await execute("preview");
expect(result.current.chartWindowAutoOpen).toBe(false);
await execute("chat");
expect(result.current.chartWindowAutoOpen).toBe(false);
panel.dataset.investigationCollapsed = "true";
await execute("live");
expect(result.current.chartWindowAutoOpen).toBe(false);
panel.dataset.investigationCollapsed = "false";
await execute("live");
expect(result.current.chartWindowAutoOpen).toBe(true);
rerender({ ...initial, task: { ...task, task_id: "task-b" } });
expect(result.current.chartWindowAutoOpen).toBe(false);
rerender(initial);
expect(result.current.chartWindowAutoOpen).toBe(false);
rerender({ ...initial, enabled: false });
await execute("live");
expect(result.current.chartWindowAutoOpen).toBe(false);
rerender({ ...initial, task: { ...task, status: "completed", lifecycle_status: "completed" } });
await execute("live");
expect(result.current.chartWindowAutoOpen).toBe(false);
rerender(initial);
await act(() => result.current.runPreview({ blocks: [], records: [{ value: "历史结果" }], commands: [] }));
act(() => result.current.history.onSelect(result.current.history.entries[0].id));
await execute("live");
expect(result.current.chartWindowAutoOpen).toBe(false);
act(() => result.current.history.onSelect(null));
expect(result.current.chartWindowAutoOpen).toBe(false);
} finally {
panel.remove();
}
});
@@ -114,6 +114,9 @@ async function waitForChart(
} }
export function useInvestigationPresentation(options: Options) { export function useInvestigationPresentation(options: Options) {
const windowScope = JSON.stringify([options.scope, options.scopeId, options.projectId, options.task?.task_id, options.task?.execution_generation]);
const [chartWindow, setChartWindow] = useState({ scope: windowScope, requested: false });
if (chartWindow.scope !== windowScope) setChartWindow({ scope: windowScope, requested: false });
const valuesRef = useRef(options); const valuesRef = useRef(options);
valuesRef.current = options; valuesRef.current = options;
const storeRef = useRef(new PresentationViewHistory()); const storeRef = useRef(new PresentationViewHistory());
@@ -155,6 +158,14 @@ export function useInvestigationPresentation(options: Options) {
: "live", : "live",
[] []
); );
const requestChartWindow = useCallback((origin: InvestigationPresentationActivity["source"]) => {
const current = valuesRef.current;
if (storeRef.current.selectedId !== null || current.scope === "chat" || current.scope === "preview") return;
if (origin !== "live" || !current.enabled || current.task?.status !== "running") return;
const panel = document.querySelector<HTMLElement>(`[data-presentation-scope="${current.scope ?? "investigation"}"]`);
if (panel?.dataset.investigationCollapsed === "true") return;
setChartWindow({ scope: JSON.stringify([current.scope, current.scopeId, current.projectId, current.task?.task_id, current.task?.execution_generation]), requested: true });
}, []);
const update = useCallback( const update = useCallback(
(updater: (view: PresentationView) => PresentationView) => { (updater: (view: PresentationView) => PresentationView) => {
storeRef.current.update(updater(storeRef.current.current)); storeRef.current.update(updater(storeRef.current.current));
@@ -289,6 +300,7 @@ export function useInvestigationPresentation(options: Options) {
}) })
); );
signal.throwIfAborted(); signal.throwIfAborted();
if (command.action === "open_chart") requestChartWindow(origin);
if (historyId !== null) store.finish(historyId, "completed", undefined, actionSnapshot, pendingEntry); if (historyId !== null) store.finish(historyId, "completed", undefined, actionSnapshot, pendingEntry);
publish(); publish();
setActivity((current) => setActivity((current) =>
@@ -327,7 +339,7 @@ export function useInvestigationPresentation(options: Options) {
throw cause; throw cause;
} }
}, },
[scene, publish, update] [scene, publish, update, requestChartWindow]
); );
useEffect(() => { useEffect(() => {
@@ -555,6 +567,8 @@ export function useInvestigationPresentation(options: Options) {
const visible = storeRef.current.visible; const visible = storeRef.current.visible;
const reviewing = storeRef.current.selectedId !== null; const reviewing = storeRef.current.selectedId !== null;
return { return {
chartWindowAutoOpen: chartWindow.scope === windowScope && chartWindow.requested,
chartWindowScope: windowScope,
charts: visible.charts, charts: visible.charts,
blocks: visible.blocks, blocks: visible.blocks,
records: visible.records, records: visible.records,
@@ -1477,6 +1477,7 @@ function MapWorkbenchContent({
{ scope: "preview", surface: previewPresentation, task: null, visible: previewPresentation.hasHistory || previewPresentation.localPreview } { scope: "preview", surface: previewPresentation, task: null, visible: previewPresentation.hasHistory || previewPresentation.localPreview }
].filter((item) => item.visible).map(({ scope, surface, task }) => <InvestigationPresentationPanel ].filter((item) => item.visible).map(({ scope, surface, task }) => <InvestigationPresentationPanel
key={scope} scope={scope} embedded blocks={surface.blocks} records={surface.records} evidence={surface.evidence} history={surface.history} onLocateTargets={surface.locateTargets} task={task} localPreview={scope === "preview"} charts={surface.charts} overlay={surface.overlay} cameraPaused={surface.cameraPaused} key={scope} scope={scope} embedded blocks={surface.blocks} records={surface.records} evidence={surface.evidence} history={surface.history} onLocateTargets={surface.locateTargets} task={task} localPreview={scope === "preview"} charts={surface.charts} overlay={surface.overlay} cameraPaused={surface.cameraPaused}
chartWindowAutoOpen={surface.chartWindowAutoOpen} chartWindowScope={surface.chartWindowScope}
status={scope === "chat" ? "live" : surface.status} error={surface.error} status={scope === "chat" ? "live" : surface.status} error={surface.error}
width={Math.min(380, Math.max(180, businessWorkspaceWidth - (rightPanelOpen ? (rightPanelExpanded ? (viewportWidth >= 1536 ? 960 : 880) : 432) + 64 : 64) - 24))} width={Math.min(380, Math.max(180, businessWorkspaceWidth - (rightPanelOpen ? (rightPanelExpanded ? (viewportWidth >= 1536 ? 960 : 880) : 432) + 64 : 64) - 24))}
relatedLayers={scope === "investigation" ? presentationRelatedLayers : []} onToggleLayer={handleTogglePresentationLayer} relatedLayers={scope === "investigation" ? presentationRelatedLayers : []} onToggleLayer={handleTogglePresentationLayer}
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import { columnsForLayout, reconcileChartSlots, swapChartSlots, type ChartSlot } from "./chart-window-slots";
describe("fixed chart slots", () => {
it("maps supported layouts to grid columns", () => {
expect([columnsForLayout("one"), columnsForLayout("two"), columnsForLayout("four")]).toEqual([1, 2, 2]);
});
it("keeps chart positions when publications update in another order", () => {
const slots: ChartSlot[] = ["flow", null, "pressure", "plans"];
expect(reconcileChartSlots(slots, ["plans", "pressure", "flow"], 2)).toBe(slots);
});
it("leaves deleted positions vacant without moving surviving charts", () => {
expect(reconcileChartSlots(["pressure", "flow", "plans", null], ["pressure", "plans"], 2))
.toEqual(["pressure", null, "plans", null]);
});
it("fills the earliest vacant slots before appending a padded row", () => {
expect(reconcileChartSlots(["pressure", null, "flow", null], ["new-1", "flow", "new-2", "pressure", "new-3"], 2))
.toEqual(["pressure", "new-1", "flow", "new-2", "new-3", null]);
});
it("uses vacancies created by a deletion in the same update", () => {
expect(reconcileChartSlots(["pressure", "removed", "flow", null], ["pressure", "flow", "new"], 2))
.toEqual(["pressure", "new", "flow", null]);
});
it("preserves linear positions when the column count changes", () => {
const slots: ChartSlot[] = ["a", null, "b", "c", "d", null];
expect(reconcileChartSlots(slots, ["a", "b", "c", "d"], 1)).toEqual(["a", null, "b", "c", "d"]);
expect(reconcileChartSlots(slots, ["a", "b", "c", "d"], 2)).toBe(slots);
});
it("trims only empty trailing rows, retaining leading and middle vacancies", () => {
expect(reconcileChartSlots([null, null, "a", null, null, null, null, null], ["a"], 2))
.toEqual([null, null, "a", null]);
expect(reconcileChartSlots(["a", null, null, null], ["a"], 2)).toEqual(["a", null]);
expect(reconcileChartSlots(["a", null, null, null], [], 2)).toEqual([]);
});
it("builds default slots in available order and never duplicates a chart", () => {
expect(reconcileChartSlots([], ["a", "b", "a"], 2)).toEqual(["a", "b"]);
expect(reconcileChartSlots(["a", "a", "b", null], ["a", "b", "c"], 2)).toEqual(["a", "c", "b", null]);
});
it("swaps occupied slots without inserting or shifting adjacent charts", () => {
const slots: ChartSlot[] = ["a", "b", "c", "d"];
expect(swapChartSlots(slots, 0, 2)).toEqual(["c", "b", "a", "d"]);
expect(slots).toEqual(["a", "b", "c", "d"]);
});
it("swaps across rows and permits an empty destination", () => {
expect(swapChartSlots(["a", "b", "c", "d", "e", null, null, null], 0, 5))
.toEqual([null, "b", "c", "d", "e", "a", null, null]);
});
it("keeps the original array for invalid or unchanged swaps", () => {
const slots: ChartSlot[] = ["a", null, null];
for (const [source, target] of [[-1, 1], [0, 3], [3, 0], [0.5, 1], [0, Number.NaN], [0, 0], [1, 2]]) {
expect(swapChartSlots(slots, source, target)).toBe(slots);
}
});
});
@@ -0,0 +1,38 @@
export type ChartSlot = string | null;
export type ChartLayout = "one" | "two" | "four";
export function columnsForLayout(layout: ChartLayout): 1 | 2 {
return layout === "one" ? 1 : 2;
}
/** Retain fixed positions, fill vacancies, and pad the final row without compacting. */
export function reconcileChartSlots(slots: ChartSlot[], availableIds: string[], columns: 1 | 2): ChartSlot[] {
const available = new Set(availableIds);
const retained = new Set<string>();
const next = slots.map((id) => {
if (id === null || !available.has(id) || retained.has(id)) return null;
retained.add(id);
return id;
});
for (const id of available) {
if (retained.has(id)) continue;
const empty = next.indexOf(null);
if (empty === -1) next.push(id);
else next[empty] = id;
}
let last = next.length - 1;
while (last >= 0 && next[last] === null) last -= 1;
const length = Math.ceil((last + 1) / columns) * columns;
if (next.length > length) next.length = length;
while (next.length < length) next.push(null);
return next.length === slots.length && next.every((id, index) => id === slots[index]) ? slots : next;
}
/** Exchange two fixed positions, including vacant destinations; never shift neighbors. */
export function swapChartSlots(slots: ChartSlot[], sourceIndex: number, targetIndex: number): ChartSlot[] {
if (!Number.isInteger(sourceIndex) || !Number.isInteger(targetIndex) || sourceIndex < 0 || targetIndex < 0 ||
sourceIndex >= slots.length || targetIndex >= slots.length || slots[sourceIndex] === slots[targetIndex]) return slots;
const next = [...slots];
[next[sourceIndex], next[targetIndex]] = [next[targetIndex], next[sourceIndex]];
return next;
}
@@ -0,0 +1,115 @@
import { describe, expect, it } from "vitest";
import { groupInvestigationCharts } from "./investigation-chart-groups";
import type { InvestigationChart } from "../hooks/use-investigation-presentation";
const comparison = (id: string, metric = "压力", unit = "m", date = "2026-07-16"): InvestigationChart => ({
action: "open_chart", chart_id: `chart_tc_${id}`, evidence_refs: [`evidence:${id}`],
chart: { title: `${id} ${metric}七日同时刻对照(${unit})`, chart_type: "time_comparison",
x_axis_name: "时间", y_axis_name: `${metric}(${unit})`, x_data: ["07:45", "08:00"],
series: [
{ name: `${id} 当前 ${date}`, type: "line", data: [30, 25], sample_times: [`${date}T07:45:00+08:00`, `${date}T08:00:00+08:00`] },
{ name: `${id} 2026-07-09`, type: "scatter", data: [null, 32], sample_times: [null, "2026-07-09T08:00:00+08:00"] }
] }
});
const legacyComparison = (id: string, metric = "压力", unit = "m", date = "2026-07-16") => {
const command = comparison(id, metric, unit, date);
command.chart.title = `${id} ${metric}三小时趋势与七天同期对照(${unit})`;
command.chart.series[0].name = `${id} ${date.slice(5)}(${unit})`;
command.chart.series[1].name = "07-09";
return command;
};
describe("investigation comparison groups", () => {
it("groups pressure objects and pipe flow objects separately without changing data or evidence", () => {
const charts = [comparison("170486"), comparison("175618"), comparison("542017", "管流", "m³/h"), comparison("486033", "管流", "m³/h")];
const groups = groupInvestigationCharts(charts);
expect(groups).toHaveLength(2);
expect(groups[0].items.map((item) => item.label)).toEqual(["170486", "175618"]);
expect(groups[1].items.map((item) => item.label)).toEqual(["542017", "486033"]);
expect(groups[0].title).toBe("压力七日同时刻对照(m)");
expect(groups[0].items[1].command).toBe(charts[1]);
expect(charts[1].chart.series[1].data).toEqual([null, 32]);
});
it("groups saved pressure and flow charts whose titles put the metric before the object ID", () => {
const charts = [["170486", "压力", "m"], ["175618", "压力", "m"], ["541061", "流量", "m³/h"], ["542017", "流量", "m³/h"]].map(([id, metric, unit]) => {
const command = comparison(id, metric, unit);
command.chart.title = `${metric}测点 ${id} 当前曲线与前七天同期对比(${unit})`;
return command;
});
const groups = groupInvestigationCharts(charts);
expect(groups.map((group) => ({ title: group.title, objects: group.items.map((item) => item.label) }))).toEqual([
{ title: "压力当前曲线与前七天同期对比(m)", objects: ["170486", "175618"] },
{ title: "流量当前曲线与前七天同期对比(m³/h)", objects: ["541061", "542017"] }
]);
});
it("recognizes the same explicit object comparison in all three saved title formats", () => {
const charts = [comparison("170486"), comparison("175618"), comparison("159673")];
charts[0].chart.title = "170486 压力三小时与历史对照(m)";
charts[1].chart.title = "测点 175618 压力三小时与历史对照(m)";
charts[2].chart.title = "压力测点 159673 三小时与历史对照(m)";
const groups = groupInvestigationCharts(charts);
expect(groups).toHaveLength(1);
expect(groups[0].title).toBe("压力三小时与历史对照(m)");
expect(groups[0].items.map((item) => item.label)).toEqual(["170486", "175618", "159673"]);
});
it("groups saved date-only historical labels without rewriting commands, readings or evidence", () => {
const charts = [legacyComparison("170486"), legacyComparison("175618")];
charts[1].chart.series[1].data[1] = 0;
const original = structuredClone(charts);
const groups = groupInvestigationCharts(charts);
expect(groups).toHaveLength(1);
expect(groups[0].items.map((item) => item.label)).toEqual(["170486", "175618"]);
groups[0].items.forEach((item, index) => {
expect(item.command).toBe(charts[index]);
expect(item.command.chart.series).toBe(charts[index].chart.series);
expect(item.command.chart.series[1].sample_times).toBe(charts[index].chart.series[1].sample_times);
expect(item.command.evidence_refs).toBe(charts[index].evidence_refs);
});
expect(charts).toEqual(original);
});
it("keeps legacy comparisons with different units, windows or current dates separate", () => {
const shifted = legacyComparison("175618");
shifted.chart.x_data = ["08:00", "08:15"];
shifted.chart.series[0].sample_times = ["2026-07-16T08:00:00+08:00", "2026-07-16T08:15:00+08:00"];
shifted.chart.series[1].sample_times = [null, "2026-07-09T08:15:00+08:00"];
const charts = [legacyComparison("170486"), legacyComparison("P2", "压力", "MPa"), shifted, legacyComparison("P3", "压力", "m", "2026-07-17")];
expect(groupInvestigationCharts(charts)).toHaveLength(charts.length);
});
it("leaves ambiguous or mixed-object legacy charts and unproven historical dates separate", () => {
const ambiguous = legacyComparison("P2"); ambiguous.chart.title = "压力测点历史对照";
const foreign = legacyComparison("P3"); foreign.chart.series[1].name = "P4 07-09";
const mixed = legacyComparison("P4"); mixed.chart.series.push({ ...mixed.chart.series[0], name: "P5 当前 2026-07-16" });
const missing = legacyComparison("P5"); delete missing.chart.series[1].sample_times;
const mismatched = legacyComparison("P6"); mismatched.chart.series[1].sample_times = [null, "2026-07-10T08:00:00+08:00"];
const empty = legacyComparison("P7"); empty.chart.series[1].data = [null, null]; empty.chart.series[1].sample_times = [null, null];
const sameDateInUtc8 = legacyComparison("P8"); sameDateInUtc8.chart.series[1].sample_times = [null, "2026-07-09T00:00:00Z"];
const charts = [legacyComparison("170486"), ambiguous, foreign, mixed, missing, mismatched, empty, sameDateInUtc8];
const groups = groupInvestigationCharts(charts);
expect(groups).toHaveLength(charts.length - 1);
expect(groups[0].items.map((item) => item.label)).toEqual(["170486", "P8"]);
});
it("keeps incompatible units, sampling windows, comparison dates and ordinary trends separate", () => {
const base = comparison("170486");
const window = comparison("175618"); window.chart.x_data = ["08:00", "08:15"];
const trend = comparison("159673"); trend.chart.series = [trend.chart.series[0]];
expect(groupInvestigationCharts([base, comparison("P2", "压力", "MPa"), comparison("P3", "压力", "m", "2026-07-17"), window, trend])).toHaveLength(5);
const noTimes = [comparison("P4"), comparison("P5", "压力", "m", "2026-07-17")];
for (const command of noTimes) for (const series of command.chart.series) delete series.sample_times;
expect(groupInvestigationCharts(noTimes)).toHaveLength(2);
});
it("keeps group identity stable as objects arrive or are closed and leaves ambiguous titles alone", () => {
const first = comparison("170486"), second = comparison("175618");
expect(groupInvestigationCharts([first])[0].id).toBe(groupInvestigationCharts([second, first])[0].id);
const ambiguous = comparison("159673"); ambiguous.chart.title = "压力同期对照";
expect(groupInvestigationCharts([first, ambiguous])).toHaveLength(2);
expect(groupInvestigationCharts([])).toEqual([]);
});
});
@@ -0,0 +1,64 @@
import type { InvestigationChart } from "../hooks/use-investigation-presentation";
export type InvestigationChartGroup = {
id: string;
title: string;
items: Array<{ label: string; command: InvestigationChart }>;
};
type ChartSeries = InvestigationChart["chart"]["series"][number];
/** Saved date-only legends identify a day only when their actual observations prove it. */
function matchesHistoricalDate(series: ChartSeries) {
if (!/^(?:0[1-9]|1[0-2])-(?:0[1-9]|[12][0-9]|3[01])$/.test(series.name) ||
!series.sample_times || series.sample_times.length !== series.data.length) return false;
let observedDate: string | null = null;
for (let index = 0; index < series.data.length; index++) {
const at = series.sample_times[index];
if (series.data[index] === null) {
if (at !== null) return false;
continue;
}
if (typeof at !== "string") return false;
const time = Date.parse(at);
if (!Number.isFinite(time)) return false;
const date = new Date(time + 8 * 3600_000).toISOString().slice(0, 10);
if (date.slice(5) !== series.name || observedDate !== null && observedDate !== date) return false;
observedDate = date;
}
return observedDate !== null;
}
function matchesObjectSeries(command: InvestigationChart, id: string) {
const { chart } = command;
const prefixed = (series: ChartSeries) => series.name.startsWith(`${id} `);
if (chart.series.every(prefixed)) return true;
const current = chart.series.filter((series) => series.type !== "scatter");
return current.length === 1 && prefixed(current[0]) &&
(current[0].type ?? (chart.chart_type === "time_comparison" ? "line" : chart.chart_type)) === "line" &&
chart.series.every((series) => prefixed(series) || series.type === "scatter" && matchesHistoricalDate(series));
}
/** Group only explicitly named objects with the same comparison, axes and sampling window. */
export function groupInvestigationCharts(charts: InvestigationChart[]): InvestigationChartGroup[] {
const groups = new Map<string, InvestigationChartGroup>();
for (const command of charts) {
const { chart } = command;
const match = /^(?:(压力|流量)测点\s+|测点\s+)?([A-Za-z0-9][A-Za-z0-9_.-]*)\s+(.+)$/.exec(chart.title);
const object = match ? { id: match[2], title: `${match[1] ?? ""}${match[3]}` } : null;
const comparable = object && chart.series.some((series) => series.type === "scatter") && matchesObjectSeries(command, object.id);
const id = comparable ? JSON.stringify([
object.title, chart.chart_type, chart.x_axis_name, chart.y_axis_name, chart.x_data,
chart.series.filter((series) => series.type !== "scatter").map((series) => [
series.name.slice(object.id.length).trim(), series.sample_times ?? null
])
]) : command.chart_id;
let group = groups.get(id);
if (!group) {
group = { id, title: comparable ? object.title : chart.title, items: [] };
groups.set(id, group);
}
group.items.push({ label: comparable ? object.id : chart.title, command });
}
return [...groups.values()];
}
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { createInvestigationDevCase, investigationDevCases } from "./investigation-dev-cases"; import { createInvestigationDevCase, investigationDevCases } from "./investigation-dev-cases";
import { groupInvestigationCharts } from "./investigation-chart-groups";
import { parseInvestigationDevJsonl } from "./investigation-dev-preview"; import { parseInvestigationDevJsonl } from "./investigation-dev-preview";
@@ -47,9 +48,18 @@ describe("switchable investigation Dev cases", () => {
expect(nodes[0].props.rows).toHaveLength(50); expect(nodes[0].props.rows).toHaveLength(50);
expect(nodes[1].props.rows).toEqual([]); expect(nodes[1].props.rows).toEqual([]);
const charts = parseInvestigationDevJsonl(createInvestigationDevCase("complex_charts")); const charts = parseInvestigationDevJsonl(createInvestigationDevCase("complex_charts"));
const pressure = Object.values(charts.blocks[0].spec.elements)[0].props; const commands = charts.commands.filter((command) => command.action === "open_chart");
expect(pressure.x_data).toHaveLength(96); expect(charts.blocks).toHaveLength(0);
expect(pressure.series).toHaveLength(8); expect(commands).toHaveLength(13);
const groups = groupInvestigationCharts(commands);
expect(groups.map((group) => group.items.length)).toEqual([8, 4, 1]);
for (const { chart } of commands.slice(0, 12)) {
expect(chart.chart_type).toBe("time_comparison");
expect(chart.x_data).toHaveLength(96);
expect(chart.series).toHaveLength(8);
expect(chart.series[0].sample_times).toHaveLength(96);
expect(chart.series[1].data.filter((value) => value !== null)).toHaveLength(3);
}
}); });
it.each(["data_only", "candidates_table", "valves_table"] as const)( it.each(["data_only", "candidates_table", "valves_table"] as const)(
@@ -65,6 +65,11 @@ export const investigationDevCases = [
id: "invalid_chart", id: "invalid_chart",
label: "错误:图表数据不齐", label: "错误:图表数据不齐",
expected: "第 1 行图表校验失败,时间轴和序列长度不一致时不能执行。" expected: "第 1 行图表校验失败,时间轴和序列长度不一致时不能执行。"
},
{
id: "invalid_comparison",
label: "错误:同期时刻错位",
expected: "第 1 行拒绝历史采样时间与横轴槽位不一致的复合图,整批不执行。"
} }
] as const; ] as const;
@@ -258,5 +263,14 @@ export function createInvestigationDevCase(
} }
} }
]); ]);
case "invalid_comparison":
return invalidJsonl([{ record_type: "presentation", command: {
action: "open_chart", chart_id: "dev-invalid-comparison", evidence_refs: ["dev:synthetic"],
chart: { title: "错误案例:历史点时刻错位", chart_type: "time_comparison", x_data: ["08:00", "08:15"],
series: [
{ name: "当前", type: "line", data: [32, 30], sample_times: ["2026-07-16T08:00:00+08:00", "2026-07-16T08:15:00+08:00"] },
{ name: "历史", type: "scatter", data: [33, null], sample_times: ["2026-07-15T08:15:00+08:00", null] }
] }
} }]);
} }
} }
@@ -3,9 +3,9 @@ import type { AnswerUiBlock } from "@/features/agent";
import { createPresentationRecord, createUiRecord, serializeInvestigationDevJsonl } from "./investigation-dev-preview"; import { createPresentationRecord, createUiRecord, serializeInvestigationDevJsonl } from "./investigation-dev-preview";
export const complexInvestigationDevCases = [ export const complexInvestigationDevCases = [
{ id: "complex_investigation", label: "复杂调查:多图多表联动", expected: "3 张图、2 张表与合成覆盖面;可同时放大对比,单位分别展示,不查询真实要素。" }, { id: "complex_investigation", label: "复杂调查:多图多表联动", expected: "压力与管流分别聚合,另有方案柱状图、2 张表与合成覆盖面;右侧切换对象,下方控制图例。" },
{ id: "complex_tables", label: "复杂表格:宽表、长文本与边界数据", expected: "50 行 8 列宽表、空表和含零值、负数、重复值的对照表;检查搜索、数值排序和横向滚动。" }, { id: "complex_tables", label: "复杂表格:宽表、长文本与边界数据", expected: "50 行 8 列宽表、空表和含零值、负数、重复值的对照表;检查搜索、数值排序和横向滚动。" },
{ id: "complex_charts", label: "复杂图表:多测点与长时序", expected: "96 个时间点、8 条压力序列,另有流量与方案柱状图;可切换数据表、筛选区间并独立放大。" } { id: "complex_charts", label: "复杂图表:多测点与长时序", expected: "8 个压力点与 4 个管段各含 96 个时刻、当前曲线与七日历史散点,聚合为 2 个子图表,由总图表窗口统一管理,另有方案柱状图。" }
] as const; ] as const;
type ComplexCaseId = (typeof complexInvestigationDevCases)[number]["id"]; type ComplexCaseId = (typeof complexInvestigationDevCases)[number]["id"];
@@ -19,25 +19,40 @@ const ui = (id: string, node: Node) => createUiRecord({
const table = (id: string, title: string, columns: string[], rows: string[][]) => const table = (id: string, title: string, columns: string[], rows: string[][]) =>
ui(id, { type: "Table", props: { title, columns, rows }, children: [] }); ui(id, { type: "Table", props: { title, columns, rows }, children: [] });
const times = Array.from({ length: 96 }, (_, index) => `${String(Math.floor(index / 4)).padStart(2, "0")}:${String(index % 4 * 15).padStart(2, "0")}`); const times = Array.from({ length: 96 }, (_, index) => `${String(Math.floor(index / 4)).padStart(2, "0")}:${String(index % 4 * 15).padStart(2, "0")}`);
const pressure = { const comparisonChart = (id: string, title: string, axis: string, baseline: number[], current: number[]) => ({
title: "合成全天压力对比", chart_type: "line" as const, x_data: times, title: `${id} ${title}`, chart_type: "time_comparison" as const,
series: Array.from({ length: 8 }, (_, series) => ({ x_axis_name: "时间", y_axis_name: axis, x_data: times,
name: `合成节点 ${INVESTIGATION_DEV_NETWORK.nodeAnchors[series].id} · ${series < 4 ? "主干管" : "末端支线"}压力水头(m)`, series: [{ name: `${id} 当前 2026-07-16`, type: "line" as const, data: current,
data: times.map((_, index) => Number((34 + series * 0.6 + Math.sin(index / 7 + series) * 2.4 - (index >= 32 && index < 44 ? 7 - series * 0.4 : 0)).toFixed(2))) sample_times: times.map((time) => `2026-07-16T${time}:00+08:00`) },
})) ...Array.from({ length: 7 }, (_, day) => {
}; const date = `2026-07-${String(9 + day).padStart(2, "0")}`;
const flow = { return { name: `${id} ${date}`, type: "scatter" as const,
title: "合成分区流量趋势", chart_type: "line" as const, x_data: times, data: times.map((_, index) => index >= 40 && index <= 42 ? Number((baseline[index] + day * 0.1).toFixed(2)) : null),
series: Array.from({ length: 4 }, (_, series) => ({ name: `合成分区 ${series + 1} 流量(m³/h)`, sample_times: times.map((time, index) => index >= 40 && index <= 42 ? `${date}T${time}:00+08:00` : null) };
data: times.map((_, index) => Number((120 + series * 28 + Math.sin(index / 9 + series) * 24 + (index >= 32 && index < 44 ? 38 : 0)).toFixed(1))) })) })]
}; });
const pressure = Array.from({ length: 8 }, (_, object) => {
const baseline = times.map((_, index) => Number((34 + object * 0.6 + Math.sin(index / 7 + object) * 2.4).toFixed(2)));
return comparisonChart(INVESTIGATION_DEV_NETWORK.nodeAnchors[object].id, "合成全天压力对比", "压力水头(m)", baseline,
baseline.map((value, index) => Number((value - (index >= 32 && index < 44 ? 7 - object * 0.4 : 0)).toFixed(2))));
});
const flow = Array.from({ length: 4 }, (_, object) => {
const baseline = times.map((_, index) => Number((120 + object * 28 + Math.sin(index / 9 + object) * 24).toFixed(1)));
return comparisonChart(INVESTIGATION_DEV_NETWORK.pipeIds[object], "合成分区流量趋势", "流量(m³/h)", baseline,
baseline.map((value, index) => Number((value + (index >= 32 && index < 44 ? 38 : 0)).toFixed(1))));
});
const chart = (id: string, value: ReturnType<typeof comparisonChart>) => createPresentationRecord({
action: "open_chart", chart_id: `dev-complex-${id}`, evidence_refs: ["dev:synthetic"], chart: value
});
const objectCharts = () => [
...pressure.map((value, index) => chart(`pressure-${index + 1}`, value)),
...flow.map((value, index) => chart(`flow-${index + 1}`, value))
];
const comparison = { const comparison = {
title: "合成候选方案影响对比", chart_type: "bar" as const, title: "合成候选方案影响对比", chart_type: "bar" as const,
x_data: ["合成方案 A:局部隔离", "合成方案 B:扩大核查", "合成方案 C:保持供水", "合成方案 D:分段验证", "合成方案 E:待补充资料"], x_data: ["合成方案 A:局部隔离", "合成方案 B:扩大核查", "合成方案 C:保持供水", "合成方案 D:分段验证", "合成方案 E:待补充资料"],
series: [{ name: "合成影响用户数(户)", data: [245, 610, 0, 128, 72] }] series: [{ name: "合成影响用户数(户)", data: [245, 610, 0, 128, 72] }]
}; };
const chart = (id: string, value: typeof pressure | typeof flow | typeof comparison) =>
ui(id, { type: "Chart", props: { ...value, source: "本地确定性合成数据,仅供展示测试;曲线变化和方案名称均非实际分析结果。" }, children: [] });
const columns = ["序号", "管段 ID", "合成片区", "长度(m)", "压力变化(m)", "影响户数(户)", "核查状态", "核查说明"]; const columns = ["序号", "管段 ID", "合成片区", "长度(m)", "压力变化(m)", "影响户数(户)", "核查状态", "核查说明"];
const rows = Array.from({ length: 50 }, (_, index) => [ const rows = Array.from({ length: 50 }, (_, index) => [
@@ -53,7 +68,8 @@ const rows = Array.from({ length: 50 }, (_, index) => [
export function createComplexInvestigationDevCase(caseId: ComplexCaseId, [longitude, latitude]: [number, number]): string { export function createComplexInvestigationDevCase(caseId: ComplexCaseId, [longitude, latitude]: [number, number]): string {
if (caseId === "complex_charts") return serializeInvestigationDevJsonl([ if (caseId === "complex_charts") return serializeInvestigationDevJsonl([
chart("pressure", pressure), chart("flow", flow), chart("comparison", comparison) ...objectCharts(),
createPresentationRecord({ action: "open_chart", chart_id: "dev-complex-plans", evidence_refs: ["dev:synthetic"], chart: comparison })
]); ]);
if (caseId === "complex_tables") return serializeInvestigationDevJsonl([ if (caseId === "complex_tables") return serializeInvestigationDevJsonl([
table("wide-table", "合成候选核查宽表", columns, rows), table("wide-table", "合成候选核查宽表", columns, rows),
@@ -69,7 +85,7 @@ export function createComplexInvestigationDevCase(caseId: ComplexCaseId, [longit
createPresentationRecord({ action: "set_overlay", mode: "geometry", overlay_id: "dev-complex-coverage", title: "合成分区覆盖示意", evidence_id: "dev:synthetic", createPresentationRecord({ action: "set_overlay", mode: "geometry", overlay_id: "dev-complex-coverage", title: "合成分区覆盖示意", evidence_id: "dev:synthetic",
data: { type: "FeatureCollection", features: [-0.002, 0.002].map((dx, index) => ({ type: "Feature", properties: { project_code: INVESTIGATION_DEV_NETWORK.projectCode, origin: "synthetic", label: `合成片区 ${index + 1}` }, data: { type: "FeatureCollection", features: [-0.002, 0.002].map((dx, index) => ({ type: "Feature", properties: { project_code: INVESTIGATION_DEV_NETWORK.projectCode, origin: "synthetic", label: `合成片区 ${index + 1}` },
geometry: { type: "Polygon", coordinates: [[point(dx - 0.001, -0.001), point(dx + 0.001, -0.001), point(dx + 0.001, 0.001), point(dx - 0.001, 0.001), point(dx - 0.001, -0.001)]] } })) } }), geometry: { type: "Polygon", coordinates: [[point(dx - 0.001, -0.001), point(dx + 0.001, -0.001), point(dx + 0.001, 0.001), point(dx - 0.001, 0.001), point(dx - 0.001, -0.001)]] } })) } }),
chart("pressure", pressure), chart("flow", flow), ...objectCharts(),
createPresentationRecord({ action: "open_chart", chart_id: "dev-complex-plans", evidence_refs: ["dev:synthetic"], chart: comparison }), createPresentationRecord({ action: "open_chart", chart_id: "dev-complex-plans", evidence_refs: ["dev:synthetic"], chart: comparison }),
table("candidates", "合成候选方案核查", columns, rows.slice(0, 24)), table("candidates", "合成候选方案核查", columns, rows.slice(0, 24)),
table("valves", "合成阀门核查清单", ["阀门", "合成片区", "位置描述", "核查要求", "当前状态"], table("valves", "合成阀门核查清单", ["阀门", "合成片区", "位置描述", "核查要求", "当前状态"],
@@ -5,8 +5,22 @@ import {
parseInvestigationDevJsonl parseInvestigationDevJsonl
} from "./investigation-dev-preview"; } from "./investigation-dev-preview";
import type { OperationalTask } from "../api/operational-task-client"; import type { OperationalTask } from "../api/operational-task-client";
import chartCases from "../../../../tests/fixtures/chart-publication-cases.json";
describe("investigation Dev JSONL", () => { describe("investigation Dev JSONL", () => {
it.each(chartCases)("uses publication semantics for command and UI examples: $name", ({ chart, accepted }) => {
const records = [
{ record_type: "presentation", command: { action: "open_chart", chart_id: "comparison", chart, evidence_refs: ["dev:synthetic"] } },
{ record_type: "ui_block", block: { version: "answer-ui@1", block_id: "comparison", fallback_text: "合成测试", spec: {
root: "chart", elements: { chart: { type: "Chart", props: { ...chart, source: "合成测试" }, children: [] } }
} } }
];
for (const record of records) {
const parse = () => parseInvestigationDevJsonl(JSON.stringify(record));
if (accepted) expect(parse).not.toThrow();
else expect(parse).toThrow("第 1 行");
}
});
it("separates explicit display actions, validated UI blocks and synthetic investigation data", () => { it("separates explicit display actions, validated UI blocks and synthetic investigation data", () => {
const result = parseInvestigationDevJsonl( const result = parseInvestigationDevJsonl(
createInvestigationDevSample() createInvestigationDevSample()
@@ -130,7 +144,7 @@ describe("migrated example data", () => {
await import("../../../../tests/fixtures/unified-presentation.jsonl?raw"); await import("../../../../tests/fixtures/unified-presentation.jsonl?raw");
const { PRESENTATION_ACTIONS } = await import("@/features/agent"); const { PRESENTATION_ACTIONS } = await import("@/features/agent");
const preview = parseInvestigationDevJsonl(input); const preview = parseInvestigationDevJsonl(input);
expect(preview.commands.map((command) => command.action).sort()).toEqual( expect([...new Set(preview.commands.map((command) => command.action))].sort()).toEqual(
[...PRESENTATION_ACTIONS].sort() [...PRESENTATION_ACTIONS].sort()
); );
expect(preview.blocks).toHaveLength(1); expect(preview.blocks).toHaveLength(1);
@@ -2,6 +2,8 @@ import { z } from "zod";
import { import {
parseAnswerUiBlock, parseAnswerUiBlock,
presentationCommandSchema, presentationCommandSchema,
safeChartPublicationInput,
chartPublicationError,
type AnswerUiBlock type AnswerUiBlock
} from "@/features/agent"; } from "@/features/agent";
import { type InvestigationPresentationCommand } from "../api/investigation-presentation"; import { type InvestigationPresentationCommand } from "../api/investigation-presentation";
@@ -36,7 +38,11 @@ export const investigationActionLabels: Record<InvestigationPresentationCommand[
}; };
const references = z.array(z.string().trim().min(1).max(128)).max(100); const references = z.array(z.string().trim().min(1).max(128)).max(100);
const command = presentationCommandSchema; const command = presentationCommandSchema.superRefine((value, ctx) => {
if (value.action !== "open_chart") return;
const chart = safeChartPublicationInput.safeParse(value.chart);
if (!chart.success) for (const issue of chart.error.issues) ctx.addIssue({ ...issue, path: ["chart", ...issue.path] });
});
const presentationRecord = z.object({ record_type: z.literal("presentation"), command }).strict(); const presentationRecord = z.object({ record_type: z.literal("presentation"), command }).strict();
const uiRecord = z const uiRecord = z
.object({ .object({
@@ -78,7 +84,7 @@ export function parseInvestigationDevJsonl(input: string): InvestigationDevPrevi
const record = value as Record<string, unknown>; const record = value as Record<string, unknown>;
if (record.record_type === "presentation") { if (record.record_type === "presentation") {
const parsed = presentationRecord.safeParse(value); const parsed = presentationRecord.safeParse(value);
if (!parsed.success) throw new Error(`第 ${line} 行展示指令不合法,请检查动作、参数和范围。`); if (!parsed.success) throw new Error(`第 ${line} 行展示指令不合法:${parsed.error.issues[0]?.message ?? "请检查动作、参数和范围。"}`);
const data = parsed.data; const data = parsed.data;
result.commands.push(data.command); result.commands.push(data.command);
} else if (record.record_type === "ui_block") { } else if (record.record_type === "ui_block") {
@@ -86,6 +92,7 @@ export function parseInvestigationDevJsonl(input: string): InvestigationDevPrevi
const block = parseAnswerUiBlock(wrapped.success ? wrapped.data.block : null); const block = parseAnswerUiBlock(wrapped.success ? wrapped.data.block : null);
if (!block || blockIds.has(block.block_id)) if (!block || blockIds.has(block.block_id))
throw new Error(`第 ${line} 行界面块不合法或 block_id 重复。`); throw new Error(`第 ${line} 行界面块不合法或 block_id 重复。`);
validateChartNodes(block, `第 ${line} 行`);
blockIds.add(block.block_id); blockIds.add(block.block_id);
result.blocks.push(block); result.blocks.push(block);
if (result.blocks.length > 8) throw new Error("一次最多预览 8 个界面块。"); if (result.blocks.length > 8) throw new Error("一次最多预览 8 个界面块。");
@@ -107,6 +114,7 @@ export function createPresentationRecord(command: InvestigationPresentationComma
export function createUiRecord(block: AnswerUiBlock, evidenceRefs: string[] = []) { export function createUiRecord(block: AnswerUiBlock, evidenceRefs: string[] = []) {
const normalized = parseAnswerUiBlock({ ...block, phase: block.phase ?? "final" }); const normalized = parseAnswerUiBlock({ ...block, phase: block.phase ?? "final" });
if (!normalized) throw new Error("界面块未通过当前组件协议校验。"); if (!normalized) throw new Error("界面块未通过当前组件协议校验。");
validateChartNodes(normalized, "界面块");
return uiRecord.parse({ return uiRecord.parse({
record_type: "ui_block", record_type: "ui_block",
block: normalized, block: normalized,
@@ -114,6 +122,15 @@ export function createUiRecord(block: AnswerUiBlock, evidenceRefs: string[] = []
}); });
} }
function validateChartNodes(block: AnswerUiBlock, prefix: string) {
for (const node of Object.values(block.spec.elements)) {
if (node.type !== "Chart") continue;
// The answer catalog owns its text limits; only publication time semantics are shared.
const message = chartPublicationError(node.props as Parameters<typeof chartPublicationError>[0]);
if (message) throw new Error(`${prefix}图表不合法:${message}`);
}
}
export function serializeInvestigationDevJsonl(records: unknown[]) { export function serializeInvestigationDevJsonl(records: unknown[]) {
const input = records.map((record) => JSON.stringify(record)).join("\n"); const input = records.map((record) => JSON.stringify(record)).join("\n");
parseInvestigationDevJsonl(input); parseInvestigationDevJsonl(input);
@@ -0,0 +1,19 @@
import { expect, it } from "vitest";
import type { AnswerUiBlock } from "@/features/agent";
import { presentationChartBlocks } from "./presentation-chart-blocks";
it("collects report charts in document order with distinct identities and intact props", () => {
const chart = { type: "Chart", props: { title: "压力", source: "保存的证据", series: [] }, children: [] };
const block: AnswerUiBlock = { version: "answer-ui@1", block_id: "report", fallback_text: "报告", spec: {
root: "root", elements: {
root: { type: "Stack", props: {}, children: ["second", "first", "text"] },
first: chart, second: chart, orphan: chart,
text: { type: "Text", props: { text: "结论" }, children: [] }
}
} };
const results = presentationChartBlocks([block]);
expect(results.map((item) => item.block_id)).toEqual(["report:second", "report:first"]);
expect(results[0].spec).toEqual({ root: "second", elements: { second: chart } });
expect(results[0].spec.elements.second.props).toBe(chart.props);
expect(block.spec.root).toBe("root");
});
@@ -0,0 +1,23 @@
import type { AnswerUiBlock } from "@/features/agent";
/** Include only reachable Chart nodes, preserving report props and their source. */
export function presentationChartBlocks(blocks: AnswerUiBlock[]): AnswerUiBlock[] {
return blocks.flatMap((block) => {
const charts: AnswerUiBlock[] = [];
const visited = new Set<string>();
const visit = (id: string) => {
if (visited.has(id)) return;
visited.add(id);
const element = block.spec.elements[id];
if (!element) return;
if (element.type === "Chart") charts.push({
...block,
block_id: `${block.block_id}:${id}`,
spec: { root: id, elements: { [id]: element } }
});
else element.children?.forEach(visit);
};
visit(block.spec.root);
return charts;
});
}
@@ -0,0 +1,44 @@
import { afterEach, expect, it, vi } from "vitest";
import { floatingWindowEntries, floatingWindowOrder, raiseFloatingWindow, registerFloatingWindow, removeFloatingWindow, subscribeFloatingWindows } from "./floating-window-layer";
afterEach(() => floatingWindowOrder().forEach(removeFloatingWindow));
it("keeps a stable snapshot, follows stacking order and removes window metadata", () => {
const notify = vi.fn();
const stop = subscribeFloatingWindows(notify);
const first = { id: "first", title: "压力图", focus: vi.fn(), close: vi.fn() };
registerFloatingWindow(first);
const snapshot = floatingWindowEntries();
expect(floatingWindowEntries()).toBe(snapshot);
registerFloatingWindow({ id: "second", title: "流量图", focus: vi.fn(), close: vi.fn() });
expect(floatingWindowEntries().map((entry) => entry.id)).toEqual(["first", "second"]);
raiseFloatingWindow("first");
expect(floatingWindowEntries().map((entry) => entry.id)).toEqual(["second", "first"]);
removeFloatingWindow("first");
expect(floatingWindowEntries().map((entry) => entry.id)).toEqual(["second"]);
removeFloatingWindow("second");
expect(floatingWindowEntries()).toEqual([]);
expect(notify).toHaveBeenCalledTimes(5);
stop();
});
it("updates titles without moving windows and calls the latest actions without snapshot churn", () => {
const focus = vi.fn(), close = vi.fn();
registerFloatingWindow({ id: "chart", title: "旧标题", focus: vi.fn(), close: vi.fn() });
const initial = floatingWindowEntries();
registerFloatingWindow({ id: "chart", title: "旧标题", focus, close });
expect(floatingWindowEntries()).toBe(initial);
initial[0].focus();
initial[0].close();
expect(focus).toHaveBeenCalledOnce();
expect(close).toHaveBeenCalledOnce();
registerFloatingWindow({ id: "chart", title: "新标题", focus, close });
expect(floatingWindowEntries()[0].title).toBe("新标题");
expect(initial[0].title).toBe("旧标题");
expect(floatingWindowOrder()).toEqual(["chart"]);
removeFloatingWindow("chart");
initial[0].focus();
initial[0].close();
expect(focus).toHaveBeenCalledOnce();
expect(close).toHaveBeenCalledOnce();
});
+38 -3
View File
@@ -1,23 +1,58 @@
let order: string[] = []; let order: string[] = [];
const listeners = new Set<() => void>(); const listeners = new Set<() => void>();
let host: HTMLDivElement | undefined; let host: HTMLDivElement | undefined;
export type FloatingWindowEntry = { id: string; title: string; focus: () => void; close: () => void };
const registered = new Map<string, {
entry: FloatingWindowEntry;
actions: Pick<FloatingWindowEntry, "focus" | "close">;
}>();
let entries: readonly FloatingWindowEntry[] = [];
function publishWindows() {
entries = order.flatMap((id) => {
const record = registered.get(id);
return record ? [record.entry] : [];
});
listeners.forEach((listener) => listener());
}
export const floatingWindowOrder = () => order; export const floatingWindowOrder = () => order;
/** Stable between changes, ordered from the back window to the foreground. */
export const floatingWindowEntries = () => entries;
export const subscribeFloatingWindows = (listener: () => void) => { export const subscribeFloatingWindows = (listener: () => void) => {
listeners.add(listener); listeners.add(listener);
return () => { listeners.delete(listener); }; return () => { listeners.delete(listener); };
}; };
export function registerFloatingWindow(entry: FloatingWindowEntry) {
const previous = registered.get(entry.id);
if (previous) {
// Callbacks can change without invalidating the subscribed presentation.
previous.actions.focus = entry.focus;
previous.actions.close = entry.close;
if (previous.entry.title === entry.title) return;
previous.entry = { ...previous.entry, title: entry.title };
} else {
const actions = { focus: entry.focus, close: entry.close };
registered.set(entry.id, { actions, entry: { id: entry.id, title: entry.title,
focus: () => { if (registered.get(entry.id)?.actions === actions) actions.focus(); },
close: () => { if (registered.get(entry.id)?.actions === actions) actions.close(); } } });
if (!order.includes(entry.id)) order = [...order, entry.id];
}
publishWindows();
}
export function raiseFloatingWindow(id: string) { export function raiseFloatingWindow(id: string) {
if (order.at(-1) === id) return; if (order.at(-1) === id) return;
order = [...order.filter((item) => item !== id), id]; order = [...order.filter((item) => item !== id), id];
listeners.forEach((listener) => listener()); publishWindows();
} }
export function removeFloatingWindow(id: string) { export function removeFloatingWindow(id: string) {
if (!order.includes(id)) return; if (!order.includes(id) && !registered.has(id)) return;
registered.delete(id);
order = order.filter((item) => item !== id); order = order.filter((item) => item !== id);
listeners.forEach((listener) => listener()); publishWindows();
} }
export function floatingWindowHost() { export function floatingWindowHost() {
@@ -0,0 +1,5 @@
/** Hosts can reserve a top navigation area without raising it above modal dialogs. */
export function floatingWindowTopInset() {
return Math.max(8, ...Array.from(document.querySelectorAll<HTMLElement>("[data-floating-window-inset-top]"))
.map((element) => element.getBoundingClientRect().bottom + 8));
}
+49 -31
View File
@@ -1,24 +1,39 @@
import { useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore, type ReactNode } from "react"; import { useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore, type ReactNode } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { Rnd } from "react-rnd";
import { Maximize2, Minimize2, X } from "lucide-react"; import { Maximize2, Minimize2, X } from "lucide-react";
import { Button } from "./button"; import { Button } from "./button";
import { floatingWindowHost, floatingWindowOrder, raiseFloatingWindow, removeFloatingWindow, subscribeFloatingWindows } from "./floating-window-layer"; import { floatingWindowHost, floatingWindowOrder, registerFloatingWindow, raiseFloatingWindow, removeFloatingWindow, subscribeFloatingWindows } from "./floating-window-layer";
import { floatingWindowTopInset } from "./floating-window-viewport";
import { useFloatingWindowDrag } from "./use-floating-window-drag"; import { useFloatingWindowDrag } from "./use-floating-window-drag";
import { useFloatingWindowResize, type ResizeEdge } from "./use-floating-window-resize";
type Bounds = { x: number; y: number; width: number; height: number }; type Bounds = { x: number; y: number; width: number; height: number };
const resizeHandles: Record<ResizeEdge, React.CSSProperties> = {
top: { top: 0, left: 18, right: 18, height: 8, cursor: "ns-resize" },
right: { right: 0, top: 18, bottom: 18, width: 8, cursor: "ew-resize" },
bottom: { bottom: 0, left: 18, right: 18, height: 8, cursor: "ns-resize" },
left: { left: 0, top: 18, bottom: 18, width: 8, cursor: "ew-resize" },
topRight: { top: 0, right: 0, width: 18, height: 18, cursor: "nesw-resize" },
topLeft: { top: 0, left: 0, width: 18, height: 18, cursor: "nwse-resize" },
bottomLeft: { bottom: 0, left: 0, width: 18, height: 18, cursor: "nesw-resize" },
bottomRight: { bottom: 0, right: 0, width: 18, height: 18, cursor: "nwse-resize" }
};
function fitWindow(bounds: Bounds): Bounds { function fitWindow(bounds: Bounds): Bounds {
const top = floatingWindowTopInset();
const width = Math.min(bounds.width, window.innerWidth - 16); const width = Math.min(bounds.width, window.innerWidth - 16);
const height = Math.min(bounds.height, window.innerHeight - 16); const height = Math.min(bounds.height, window.innerHeight - top - 8);
return { width, height, return { width, height,
x: Math.max(8, Math.min(bounds.x, window.innerWidth - width - 8)), x: Math.max(8, Math.min(bounds.x, window.innerWidth - width - 8)),
y: Math.max(8, Math.min(bounds.y, window.innerHeight - height - 8)) }; y: Math.max(top, Math.min(bounds.y, window.innerHeight - height - 8)) };
} }
export function FloatingWindow({ title, triggerLabel, fillContent = false, openRequestKey, children }: { export function FloatingWindow({ title, navigationTitle, triggerLabel, fillContent = false, openRequestKey, triggerContent, initialSize = { width: 800, height: 560 }, children }: {
title: string; title: string;
navigationTitle?: string;
triggerLabel?: string; triggerLabel?: string;
triggerContent?: ReactNode;
initialSize?: { width: number; height: number };
fillContent?: boolean; fillContent?: boolean;
/** A new display request opens the window without taking keyboard focus. */ /** A new display request opens the window without taking keyboard focus. */
openRequestKey?: string; openRequestKey?: string;
@@ -33,8 +48,9 @@ export function FloatingWindow({ title, triggerLabel, fillContent = false, openR
if (request.key !== openRequestKey) setRequest({ key: openRequestKey, dismissed: false }); if (request.key !== openRequestKey) setRequest({ key: openRequestKey, dismissed: false });
const open = manuallyOpen || (openRequestKey !== undefined && !request.dismissed); const open = manuallyOpen || (openRequestKey !== undefined && !request.dismissed);
const [maximized, setMaximized] = useState(false); const [maximized, setMaximized] = useState(false);
const [bounds, setBounds] = useState<Bounds>(() => fitWindow({ x: (window.innerWidth - 800) / 2, y: 64, width: 800, height: 560 })); const [bounds, setBounds] = useState<Bounds>(() => fitWindow({ x: (window.innerWidth - initialSize.width) / 2, y: 64, ...initialSize }));
const [viewport, setViewport] = useState({ width: window.innerWidth, height: window.innerHeight }); const [viewport, setViewport] = useState({ width: window.innerWidth, height: window.innerHeight });
const initialSizeRef = useRef(initialSize);
const order = useSyncExternalStore(subscribeFloatingWindows, floatingWindowOrder); const order = useSyncExternalStore(subscribeFloatingWindows, floatingWindowOrder);
const trigger = useRef<HTMLButtonElement>(null); const trigger = useRef<HTMLButtonElement>(null);
const heading = useRef<HTMLDivElement>(null); const heading = useRef<HTMLDivElement>(null);
@@ -48,6 +64,14 @@ export function FloatingWindow({ title, triggerLabel, fillContent = false, openR
trigger.current?.focus({ preventScroll: true }); trigger.current?.focus({ preventScroll: true });
}; };
const closeRef = useRef(close);
closeRef.current = close;
useLayoutEffect(() => {
if (open) registerFloatingWindow({ id, title: navigationTitle ?? title,
focus: () => { raiseFloatingWindow(id); heading.current?.focus({ preventScroll: true }); },
close: () => closeRef.current() });
}, [id, open, title, navigationTitle]);
useEffect(() => () => removeFloatingWindow(id), [id]); useEffect(() => () => removeFloatingWindow(id), [id]);
useLayoutEffect(() => { useLayoutEffect(() => {
if (!open) return; if (!open) return;
@@ -62,8 +86,8 @@ export function FloatingWindow({ title, triggerLabel, fillContent = false, openR
// Keep the preceding headings exposed. Shorter viewports reduce content // Keep the preceding headings exposed. Shorter viewports reduce content
// height instead of clamping several windows onto the same title bar. // height instead of clamping several windows onto the same title bar.
// Absolute placement also makes StrictMode's repeated setup idempotent. // Absolute placement also makes StrictMode's repeated setup idempotent.
setBounds(fitWindow({ x: (window.innerWidth - 800) / 2 + slot * 24, y, setBounds(fitWindow({ x: (window.innerWidth - initialSizeRef.current.width) / 2 + slot * 24, y,
width: 800, height: Math.min(560, window.innerHeight - y - 8) })); width: initialSizeRef.current.width, height: Math.min(initialSizeRef.current.height, window.innerHeight - y - 8) }));
raiseFloatingWindow(id); raiseFloatingWindow(id);
if (focusOnOpen.current) heading.current?.focus({ preventScroll: true }); if (focusOnOpen.current) heading.current?.focus({ preventScroll: true });
focusOnOpen.current = false; focusOnOpen.current = false;
@@ -83,15 +107,17 @@ export function FloatingWindow({ title, triggerLabel, fillContent = false, openR
}, [id, open]); }, [id, open]);
const current = maximized const current = maximized
? { x: 8, y: 8, width: viewport.width - 16, height: viewport.height - 16 } ? { x: 8, y: floatingWindowTopInset(), width: viewport.width - 16, height: viewport.height - floatingWindowTopInset() - 8 }
: bounds; : bounds;
const startDrag = useFloatingWindowDrag({ enabled: open && !maximized, position: current, const startDrag = useFloatingWindowDrag({ enabled: open && !maximized, position: current,
width: current.width, height: current.height, width: current.width, height: current.height,
onCommit: (position) => setBounds((previous) => fitWindow({ ...previous, ...position })) }); onCommit: (position) => setBounds((previous) => fitWindow({ ...previous, ...position })) });
const startResize = useFloatingWindowResize({ enabled: open && !maximized, bounds: current, onCommit: (next) => setBounds(fitWindow(next)) });
return <> return <>
<Button ref={trigger} type="button" variant="ghost" size="icon-sm" className="shrink-0" <Button ref={trigger} type="button" variant="ghost" size={triggerContent ? "sm" : "icon-sm"} className="shrink-0"
aria-label={triggerLabel ?? `放大查看 ${title}`} title="放大查看" aria-haspopup="dialog" aria-expanded={open} aria-label={triggerLabel ?? `放大查看 ${title}`} title={triggerLabel ?? "放大查看"} aria-haspopup="dialog" aria-expanded={open}
onClick={() => { onClick={() => {
if (!open) { if (!open) {
setViewport({ width: window.innerWidth, height: window.innerHeight }); setViewport({ width: window.innerWidth, height: window.innerHeight });
@@ -100,27 +126,12 @@ export function FloatingWindow({ title, triggerLabel, fillContent = false, openR
setManuallyOpen(true); setManuallyOpen(true);
} else heading.current?.focus({ preventScroll: true }); } else heading.current?.focus({ preventScroll: true });
raise(); raise();
}}><Maximize2 size={15} aria-hidden /></Button> }}><Maximize2 size={15} aria-hidden />{triggerContent}</Button>
{open ? createPortal( {open ? createPortal(
<Rnd size={{ width: current.width, height: current.height }} position={{ x: current.x, y: current.y }} <div style={{ position: "absolute", left: 0, top: 0, width: current.width, height: current.height,
bounds="parent" minWidth={Math.min(320, viewport.width - 16)} minHeight={Math.min(260, viewport.height - 16)} transform: `translate(${current.x}px, ${current.y}px)`, zIndex: order.indexOf(id) + 1 }}
maxWidth={viewport.width - 16} maxHeight={viewport.height - 16}
dragHandleClassName="floating-window-heading" cancel="button,input,select,textarea,a"
disableDragging enableResizing={!maximized}
onResizeStop={(_, __, element, ___, position) => setBounds(fitWindow({ ...position, width: element.offsetWidth, height: element.offsetHeight }))}
style={{ zIndex: order.indexOf(id) + 1, display: "flex", flexDirection: "column" }}
resizeHandleClasses={{ bottomRight: "floating-window-resize" }}
resizeHandleStyles={{
top: { top: 0, height: 8 }, right: { right: 0, width: 8 },
bottom: { bottom: 0, height: 8 }, left: { left: 0, width: 8 },
topRight: { top: 0, right: 0, width: 18, height: 18 },
topLeft: { top: 0, left: 0, width: 18, height: 18 },
bottomLeft: { bottom: 0, left: 0, width: 18, height: 18 },
bottomRight: { right: 0, bottom: 0, width: 18, height: 18 }
}}
resizeHandleComponent={{ bottomRight: <span aria-hidden className="absolute bottom-1 right-1 size-2 border-b-2 border-r-2 border-muted-foreground/60" /> }}
className="workbench-shadcn-surface pointer-events-auto flex flex-col overflow-hidden rounded-xl border border-border bg-background text-foreground shadow-lg" className="workbench-shadcn-surface pointer-events-auto flex flex-col overflow-hidden rounded-xl border border-border bg-background text-foreground shadow-lg"
role="dialog" aria-modal="false" aria-labelledby={titleId} data-floating-window="" role="dialog" aria-modal="false" aria-labelledby={titleId} data-floating-window="" data-floating-window-id={id}
onPointerDownCapture={raise} onFocusCapture={raise} onPointerDownCapture={raise} onFocusCapture={raise}
onKeyDown={(event: React.KeyboardEvent) => { onKeyDown={(event: React.KeyboardEvent) => {
if (event.key === "Escape" && !event.defaultPrevented) { event.stopPropagation(); close(); } if (event.key === "Escape" && !event.defaultPrevented) { event.stopPropagation(); close(); }
@@ -154,6 +165,13 @@ export function FloatingWindow({ title, triggerLabel, fillContent = false, openR
{children({ width: current.width - 26, height: current.height - 76 })} {children({ width: current.width - 26, height: current.height - 76 })}
</div> </div>
</div> </div>
</Rnd>, floatingWindowHost()) : null} {!maximized ? (Object.entries(resizeHandles) as Array<[ResizeEdge, React.CSSProperties]>).map(([edge, style]) => (
<div key={edge} aria-hidden data-window-resize-handle={edge}
className={`absolute z-10 touch-none ${edge === "bottomRight" ? "floating-window-resize" : ""}`}
style={style} onPointerDown={(event) => startResize(event, edge)}>
{edge === "bottomRight" ? <span className="pointer-events-none absolute bottom-1 right-1 size-2 border-b-2 border-r-2 border-muted-foreground/60" /> : null}
</div>
)) : null}
</div>, floatingWindowHost()) : null}
</>; </>;
} }
+5 -1
View File
@@ -1,5 +1,6 @@
import { useLayoutEffect, useRef, type PointerEvent as ReactPointerEvent } from "react"; import { useLayoutEffect, useRef, type PointerEvent as ReactPointerEvent } from "react";
import { flushSync } from "react-dom"; import { flushSync } from "react-dom";
import { floatingWindowTopInset } from "./floating-window-viewport";
type Position = { x: number; y: number }; type Position = { x: number; y: number };
@@ -15,9 +16,11 @@ export function useFloatingWindowDrag({ enabled, position, width, height, onComm
useLayoutEffect(() => { useLayoutEffect(() => {
const stop = () => cancel.current?.(); const stop = () => cancel.current?.();
window.addEventListener("resize", stop); window.addEventListener("resize", stop);
window.addEventListener("blur", stop);
return () => { return () => {
stop(); stop();
window.removeEventListener("resize", stop); window.removeEventListener("resize", stop);
window.removeEventListener("blur", stop);
}; };
}, [enabled]); }, [enabled]);
@@ -34,6 +37,7 @@ export function useFloatingWindowDrag({ enabled, position, width, height, onComm
const startY = event.clientY; const startY = event.clientY;
const maxX = window.innerWidth - width - 8; const maxX = window.innerWidth - width - 8;
const maxY = window.innerHeight - height - 8; const maxY = window.innerHeight - height - 8;
const minY = floatingWindowTopInset();
const previousTranslate = element.style.translate; const previousTranslate = element.style.translate;
const previousWillChange = element.style.willChange; const previousWillChange = element.style.willChange;
let next = position; let next = position;
@@ -42,7 +46,7 @@ export function useFloatingWindowDrag({ enabled, position, width, height, onComm
const updatePosition = (pointer: PointerEvent) => { const updatePosition = (pointer: PointerEvent) => {
next = { next = {
x: Math.max(8, Math.min(position.x + pointer.clientX - startX, maxX)), x: Math.max(8, Math.min(position.x + pointer.clientX - startX, maxX)),
y: Math.max(8, Math.min(position.y + pointer.clientY - startY, maxY)) y: Math.max(minY, Math.min(position.y + pointer.clientY - startY, maxY))
}; };
}; };
const move = (pointer: PointerEvent) => { const move = (pointer: PointerEvent) => {
@@ -0,0 +1,131 @@
import { act, cleanup, renderHook } from "@testing-library/react";
import type { PointerEvent as ReactPointerEvent } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { resizedFloatingBounds, useFloatingWindowResize, type ResizeEdge } from "./use-floating-window-resize";
afterEach(() => {
cleanup();
document.body.replaceChildren();
document.body.style.userSelect = "";
vi.unstubAllGlobals();
});
describe("floating window resize constraints", () => {
const start = { x: 300, y: 200, width: 800, height: 560 };
const viewport = { width: 1440, height: 900 };
it("preserves opposite edges when resizing from the left and top", () => {
expect(resizedFloatingBounds(start, "topLeft", -400, -300, viewport)).toEqual({ x: 8, y: 8, width: 1092, height: 752 });
expect(resizedFloatingBounds(start, "topLeft", 1200, 1200, viewport)).toEqual({ x: 780, y: 500, width: 320, height: 260 });
});
it("limits the bottom and right edges to the viewport", () => {
expect(resizedFloatingBounds(start, "bottomRight", 2000, 2000, viewport)).toEqual({ x: 300, y: 200, width: 1132, height: 692 });
});
it("keeps a narrow window inside the viewport and respects reserved top navigation", () => {
expect(resizedFloatingBounds({ x: 8, y: 64, width: 304, height: 400 }, "topLeft", -200, -200, { width: 320, height: 720, top: 64 }))
.toEqual({ x: 8, y: 64, width: 304, height: 400 });
});
it.each<[ResizeEdge, { x: number; y: number; width: number; height: number }]>([
["top", { x: 300, y: 240, width: 800, height: 520 }],
["right", { x: 300, y: 200, width: 840, height: 560 }],
["bottom", { x: 300, y: 200, width: 800, height: 600 }],
["left", { x: 340, y: 200, width: 760, height: 560 }],
["topRight", { x: 300, y: 240, width: 840, height: 520 }],
["topLeft", { x: 340, y: 240, width: 760, height: 520 }],
["bottomRight", { x: 300, y: 200, width: 840, height: 600 }],
["bottomLeft", { x: 340, y: 200, width: 760, height: 600 }]
])("resizes %s while preserving the opposite edges", (edge, expected) => {
expect(resizedFloatingBounds(start, edge, 40, 40, viewport)).toEqual(expected);
});
});
function resizeFixture() {
const frames = new Map<number, FrameRequestCallback>();
let frameId = 0;
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { frames.set(++frameId, callback); return frameId; });
vi.stubGlobal("cancelAnimationFrame", (id: number) => { frames.delete(id); });
const shell = document.createElement("div");
shell.dataset.floatingWindow = "";
Object.assign(shell.style, { width: "320px", height: "260px" });
const heading = document.createElement("div");
heading.className = "floating-window-heading";
heading.tabIndex = 0;
const content = document.createElement("div");
content.dataset.floatingWindowContent = "";
content.style.overflow = "auto";
const drawing = document.createElement("div");
drawing.dataset.floatingWindowDocument = "";
Object.assign(drawing.style, { width: "100%", height: "100%", fontSize: "12px" });
const handle = document.createElement("div");
content.append(drawing);
shell.append(heading, content, handle);
document.body.append(shell);
document.body.style.userSelect = "text";
const captures = new Set<number>();
handle.setPointerCapture = vi.fn((id: number) => { captures.add(id); });
handle.hasPointerCapture = (id: number) => captures.has(id);
handle.releasePointerCapture = vi.fn((id: number) => { captures.delete(id); });
const onCommit = vi.fn();
const hook = renderHook(({ enabled }) => useFloatingWindowResize({ enabled,
bounds: { x: 100, y: 100, width: 320, height: 260 }, onCommit
}), { initialProps: { enabled: true } });
const pointer = (type: string, x = 164, y = 132) => {
const event = new MouseEvent(type, { clientX: x, clientY: y, bubbles: true });
Object.defineProperty(event, "pointerId", { value: 7 });
handle.dispatchEvent(event);
};
const start = () => act(() => hook.result.current({
isPrimary: true, button: 0, currentTarget: handle, pointerId: 7, clientX: 100, clientY: 100,
preventDefault: vi.fn(), stopPropagation: vi.fn()
} as unknown as ReactPointerEvent<HTMLDivElement>, "bottomRight"));
const paint = () => { const pending = [...frames.values()]; frames.clear(); pending.forEach((callback) => callback(16)); };
return { shell, content, drawing, handle, hook, onCommit, pointer, start, paint, frames };
}
describe("floating resize lifecycle", () => {
it("coalesces shell updates per frame without freezing or scaling its document", () => {
const fixture = resizeFixture();
fixture.start();
fixture.pointer("pointermove", 132, 116);
fixture.pointer("pointermove");
expect(fixture.frames.size).toBe(1);
expect(fixture.shell.style.width).toBe("320px");
fixture.paint();
expect(fixture.shell.style.width).toBe("384px");
expect(fixture.shell.style.height).toBe("292px");
expect(fixture.drawing.style.cssText).toBe("width: 100%; height: 100%; font-size: 12px;");
expect(fixture.content.style.overflow).toBe("auto");
expect(fixture.onCommit).not.toHaveBeenCalled();
fixture.pointer("pointerup");
expect(fixture.onCommit).toHaveBeenCalledExactlyOnceWith({ x: 100, y: 100, width: 384, height: 292 });
expect(fixture.shell.dataset.floatingWindowResizing).toBeUndefined();
expect(fixture.handle.hasPointerCapture(7)).toBe(false);
expect(document.body.style.userSelect).toBe("text");
});
it.each(["pointercancel", "lostpointercapture", "blur", "resize", "Escape", "disable", "unmount"])(
"restores shell styles and releases capture on %s", (reason) => {
const fixture = resizeFixture();
fixture.start();
fixture.pointer("pointermove");
fixture.paint();
expect(fixture.shell.style.width).toBe("384px");
if (reason === "Escape") window.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", cancelable: true }));
else if (reason === "blur" || reason === "resize") window.dispatchEvent(new Event(reason));
else if (reason === "disable") fixture.hook.rerender({ enabled: false });
else if (reason === "unmount") fixture.hook.unmount();
else fixture.pointer(reason);
expect(fixture.shell.style.width).toBe("320px");
expect(fixture.shell.style.height).toBe("260px");
expect(fixture.shell.style.translate).toBe("");
expect(fixture.drawing.style.cssText).toBe("width: 100%; height: 100%; font-size: 12px;");
expect(fixture.content.style.overflow).toBe("auto");
expect(fixture.shell.dataset.floatingWindowResizing).toBeUndefined();
expect(document.body.style.userSelect).toBe("text");
expect(fixture.handle.hasPointerCapture(7)).toBe(false);
expect(fixture.onCommit).not.toHaveBeenCalled();
fixture.paint();
expect(fixture.shell.style.width).toBe("320px");
}
);
});
+100
View File
@@ -0,0 +1,100 @@
import { useLayoutEffect, useRef, type PointerEvent as ReactPointerEvent } from "react";
import { flushSync } from "react-dom";
import { floatingWindowTopInset } from "./floating-window-viewport";
export type FloatingBounds = { x: number; y: number; width: number; height: number };
export type ResizeEdge = "top" | "right" | "bottom" | "left" | "topRight" | "topLeft" | "bottomRight" | "bottomLeft";
export function resizedFloatingBounds(start: FloatingBounds, edge: ResizeEdge, dx: number, dy: number, viewport: { width: number; height: number; top?: number }): FloatingBounds {
const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(value, max));
const minWidth = Math.min(320, viewport.width - 16);
const insetTop = viewport.top ?? 8;
const minHeight = Math.min(260, viewport.height - insetTop - 8);
let left = start.x, top = start.y, right = start.x + start.width, bottom = start.y + start.height;
const direction = edge.toLowerCase();
if (direction.includes("left")) left = clamp(left + dx, 8, right - minWidth);
if (direction.includes("right")) right = clamp(right + dx, left + minWidth, viewport.width - 8);
if (direction.includes("top")) top = clamp(top + dy, insetTop, bottom - minHeight);
if (direction.includes("bottom")) bottom = clamp(bottom + dy, top + minHeight, viewport.height - 8);
return { x: left, y: top, width: right - left, height: bottom - top };
}
/** Resize the shell per frame so its contents reflow naturally; commit bounds on release. */
export function useFloatingWindowResize({ enabled, bounds, onCommit }: {
enabled: boolean;
bounds: FloatingBounds;
onCommit: (bounds: FloatingBounds) => void;
}) {
const cancel = useRef<(() => void) | null>(null);
useLayoutEffect(() => {
const stop = () => cancel.current?.();
window.addEventListener("resize", stop);
window.addEventListener("blur", stop);
return () => { stop(); window.removeEventListener("resize", stop); window.removeEventListener("blur", stop); };
}, [enabled]);
return (event: ReactPointerEvent<HTMLDivElement>, edge: ResizeEdge) => {
if (!enabled || !event.isPrimary || event.button !== 0 || cancel.current) return;
const handle = event.currentTarget;
const shell = handle.closest<HTMLElement>("[data-floating-window]");
if (!shell) return;
event.preventDefault();
event.stopPropagation();
shell.querySelector<HTMLElement>(".floating-window-heading")?.focus({ preventScroll: true });
const pointerId = event.pointerId;
const startX = event.clientX, startY = event.clientY;
const viewport = { width: window.innerWidth, height: window.innerHeight, top: floatingWindowTopInset() };
const shellStyle = { width: shell.style.width, height: shell.style.height, translate: shell.style.translate };
const selection = window.document.body.style.userSelect;
let next = bounds;
let frame = 0;
shell.dataset.floatingWindowResizing = "true";
window.document.body.style.userSelect = "none";
const update = (pointer: PointerEvent) => {
next = resizedFloatingBounds(bounds, edge, pointer.clientX - startX, pointer.clientY - startY, viewport);
};
const paint = () => {
frame = 0;
shell.style.width = `${next.width}px`;
shell.style.height = `${next.height}px`;
shell.style.translate = `${next.x - bounds.x}px ${next.y - bounds.y}px`;
};
const move = (pointer: PointerEvent) => {
if (pointer.pointerId !== pointerId) return;
update(pointer);
if (!frame) frame = requestAnimationFrame(paint);
};
const cleanup = () => {
cancel.current = null;
cancelAnimationFrame(frame);
handle.removeEventListener("pointermove", move);
handle.removeEventListener("pointerup", finish);
handle.removeEventListener("pointercancel", abort);
handle.removeEventListener("lostpointercapture", abort);
window.removeEventListener("keydown", escape, true);
Object.assign(shell.style, shellStyle);
window.document.body.style.userSelect = selection;
delete shell.dataset.floatingWindowResizing;
if (handle.hasPointerCapture(pointerId)) handle.releasePointerCapture(pointerId);
};
const abort = (pointer: PointerEvent) => { if (pointer.pointerId === pointerId) cleanup(); };
const escape = (key: KeyboardEvent) => {
if (key.key !== "Escape") return;
key.preventDefault(); key.stopPropagation(); cleanup();
};
const finish = (pointer: PointerEvent) => {
if (pointer.pointerId !== pointerId) return;
update(pointer);
cleanup();
if (next.x !== bounds.x || next.y !== bounds.y || next.width !== bounds.width || next.height !== bounds.height)
flushSync(() => onCommit(next));
};
cancel.current = cleanup;
handle.addEventListener("pointermove", move);
handle.addEventListener("pointerup", finish);
handle.addEventListener("pointercancel", abort);
handle.addEventListener("lostpointercapture", abort);
window.addEventListener("keydown", escape, true);
handle.setPointerCapture(pointerId);
};
}
+4 -2
View File
@@ -296,7 +296,7 @@ button {
touch-action: manipulation; touch-action: manipulation;
} }
button:not([data-slot="button"]) { button:not([data-slot="button"]):not(.map-option-interaction) {
transition-duration: 120ms; transition-duration: 120ms;
transition-property: color, background-color, border-color, box-shadow, filter, outline-color; transition-property: color, background-color, border-color, box-shadow, filter, outline-color;
transition-timing-function: cubic-bezier(0.2, 0, 0, 1); transition-timing-function: cubic-bezier(0.2, 0, 0, 1);
@@ -2366,7 +2366,9 @@ details[open] > summary > .disclosure-accordion-indicator {
.agent-report-workspace .investigation-report-header h2 { font-size: 20px; line-height: 28px; } .agent-report-workspace .investigation-report-header h2 { font-size: 20px; line-height: 28px; }
.agent-report-workspace .investigation-conclusion-preview { font-size: 16px; line-height: 26px; } .agent-report-workspace .investigation-conclusion-preview { font-size: 16px; line-height: 26px; }
.agent-report-workspace .answer-ui-root [data-interactive-table] table { font-size: 15px; line-height: 1.65; } .agent-report-workspace .answer-ui-root [data-interactive-table] table { font-size: 15px; line-height: 1.65; }
.agent-report-workspace .answer-ui-root [data-interactive-table] :is(th, td) { padding: 10px 12px; } .agent-report-workspace .answer-ui-root [data-interactive-table] th { padding: 0; }
.agent-report-workspace .answer-ui-root [data-interactive-table] th > button { min-height: 56px; padding: 10px 16px; }
.agent-report-workspace .answer-ui-root [data-interactive-table] td { padding: 10px 12px; }
.agent-report-workspace .investigation-report-header { padding: 0 0 16px; } .agent-report-workspace .investigation-report-header { padding: 0 0 16px; }
.agent-report-workspace .investigation-report > .investigation-report-section { .agent-report-workspace .investigation-report > .investigation-report-section {
margin: 0; margin: 0;
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test"; import { expect, test, type Locator, type TestInfo } from "@playwright/test";
import { mockAgentApi } from "./support/mock-agent-api"; import { mockAgentApi } from "./support/mock-agent-api";
import { mockWorkbenchContext } from "./support/mock-workbench-context"; import { mockWorkbenchContext } from "./support/mock-workbench-context";
import { responsesFixture } from "./support/responses-fixture"; import { responsesFixture } from "./support/responses-fixture";
@@ -7,6 +7,22 @@ const title = "空间候选管段(距离近,非故障定位)";
const ids = ["19025", "19026", "19022", "19027"]; const ids = ["19025", "19026", "19022", "19027"];
const distances = [283.85, 283.86, 291.45, 292.44]; const distances = [283.85, 283.86, 291.45, 292.44];
async function checkHeaderTarget(ui: Locator, testInfo: TestInfo, phase: string) {
const header = ui.getByRole("columnheader", { name: "距离(米)" });
const button = header.getByRole("button");
const cell = (await header.boundingBox())!;
const target = (await button.boundingBox())!;
await testInfo.attach(`${phase}-header-target`, { body: JSON.stringify({ cell, target }), contentType: "application/json" });
expect(Math.abs(target.x - cell.x)).toBeLessThanOrEqual(1);
expect(Math.abs(target.y - cell.y)).toBeLessThanOrEqual(1);
expect(Math.abs(target.width - cell.width)).toBeLessThanOrEqual(1);
expect(Math.abs(target.height - cell.height)).toBeLessThanOrEqual(1);
const background = await button.evaluate((element) => getComputedStyle(element).backgroundColor);
await header.hover({ position: { x: cell.width - 6, y: cell.height / 2 } });
await expect.poll(() => button.evaluate((element) => getComputedStyle(element).backgroundColor)).not.toBe(background);
await expect(ui.getByRole("columnheader").first().getByRole("button")).toHaveCSS("background-color", background);
}
for (const width of [1440, 375]) for (const kind of ["Table", "Chart"] as const) { for (const width of [1440, 375]) for (const kind of ["Table", "Chart"] as const) {
test(`${kind} data stays display-only inline and expanded at ${width}px`, async ({ page }, testInfo) => { test(`${kind} data stays display-only inline and expanded at ${width}px`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height: 1000 }); await page.setViewportSize({ width, height: 1000 });
@@ -38,16 +54,25 @@ for (const width of [1440, 375]) for (const kind of ["Table", "Chart"] as const)
await expect(ui.getByRole("table")).toContainText("19025"); await expect(ui.getByRole("table")).toContainText("19025");
await expect(ui.getByRole("table")).toContainText("283.85"); await expect(ui.getByRole("table")).toContainText("283.85");
await expect(ui.getByRole("columnheader")).toHaveCount(2); await expect(ui.getByRole("columnheader")).toHaveCount(2);
await expect(ui.locator("thead svg")).toHaveCount(0);
await expect(ui.getByRole("checkbox")).toHaveCount(0); await expect(ui.getByRole("checkbox")).toHaveCount(0);
await expect(ui.getByRole("button", { name: /加入分析|针对.*提问|取消选行/ })).toHaveCount(0); await expect(ui.getByRole("button", { name: /加入分析|针对.*提问|取消选行/ })).toHaveCount(0);
await expect(ui.getByRole("region", { name: "分析内容篮" })).toHaveCount(0); await expect(ui.getByRole("region", { name: "分析内容篮" })).toHaveCount(0);
await page.getByRole("button", { name: "关闭通知", exact: true }).evaluateAll((buttons) => buttons.forEach((button) => (button as HTMLButtonElement).click())); await page.getByRole("button", { name: "关闭通知", exact: true }).evaluateAll((buttons) => buttons.forEach((button) => (button as HTMLButtonElement).click()));
await page.evaluate(() => document.fonts.ready); await page.evaluate(() => document.fonts.ready);
await checkHeaderTarget(ui, testInfo, "inline");
await ui.locator("table").screenshot({ path: testInfo.outputPath("inline-display.png") }); await ui.locator("table").screenshot({ path: testInfo.outputPath("inline-display.png") });
const sort = ui.getByRole("button", { name: "按距离(米)排序", exact: true }); const sort = ui.getByRole("button", { name: "按距离(米)排序", exact: true });
await sort.click(); const header = ui.getByRole("columnheader", { name: "距离(米)" });
const column = (await header.boundingBox())!;
await header.click({ position: { x: column.width - 6, y: column.height / 2 } });
await expect(ui.getByRole("columnheader", { name: "距离(米)" })).toHaveAttribute("aria-sort", "ascending");
await expect(sort.locator("svg.lucide-arrow-up")).toHaveCount(1);
await expect(ui.locator("thead svg")).toHaveCount(1);
await sort.click(); await sort.click();
await expect(ui.getByRole("columnheader", { name: "距离(米)" })).toHaveAttribute("aria-sort", "descending"); await expect(ui.getByRole("columnheader", { name: "距离(米)" })).toHaveAttribute("aria-sort", "descending");
await expect(sort.locator("svg.lucide-arrow-down")).toHaveCount(1);
await expect(ui.locator("thead svg")).toHaveCount(1);
await expect(ui.locator("tbody tr").first()).toContainText("19027"); await expect(ui.locator("tbody tr").first()).toContainText("19027");
await ui.getByRole("textbox", { name: `搜索${title}` }).fill("19025"); await ui.getByRole("textbox", { name: `搜索${title}` }).fill("19025");
await expect(ui.locator("tbody tr")).toHaveCount(1); await expect(ui.locator("tbody tr")).toHaveCount(1);
@@ -56,15 +81,19 @@ for (const width of [1440, 375]) for (const kind of ["Table", "Chart"] as const)
ui = workspace.locator('[data-answer-ui="selection-style"]'); ui = workspace.locator('[data-answer-ui="selection-style"]');
await expect(ui.getByRole("textbox", { name: `搜索${title}` })).toHaveValue("19025"); await expect(ui.getByRole("textbox", { name: `搜索${title}` })).toHaveValue("19025");
await expect(ui.getByRole("columnheader", { name: "距离(米)" })).toHaveAttribute("aria-sort", "descending"); await expect(ui.getByRole("columnheader", { name: "距离(米)" })).toHaveAttribute("aria-sort", "descending");
await expect(ui.getByRole("button", { name: "按距离(米)排序", exact: true }).locator("svg.lucide-arrow-down")).toHaveCount(1);
await expect(ui.locator("thead svg")).toHaveCount(1);
await expect(ui.getByRole("checkbox")).toHaveCount(0); await expect(ui.getByRole("checkbox")).toHaveCount(0);
await expect(ui.locator('tbody tr[data-state="selected"]')).toHaveCount(0); await expect(ui.locator('tbody tr[data-state="selected"]')).toHaveCount(0);
await expect(ui.getByRole("button", { name: /加入分析|针对.*提问|取消选行/ })).toHaveCount(0); await expect(ui.getByRole("button", { name: /加入分析|针对.*提问|取消选行/ })).toHaveCount(0);
await ui.getByRole("textbox", { name: `搜索${title}` }).fill(""); await ui.getByRole("textbox", { name: `搜索${title}` }).fill("");
await page.getByRole("button", { name: "关闭通知", exact: true }).evaluateAll((buttons) => buttons.forEach((button) => (button as HTMLButtonElement).click())); await page.getByRole("button", { name: "关闭通知", exact: true }).evaluateAll((buttons) => buttons.forEach((button) => (button as HTMLButtonElement).click()));
await checkHeaderTarget(ui, testInfo, "expanded");
await ui.locator("[data-interactive-table]").screenshot({ path: testInfo.outputPath("expanded-display.png") }); await ui.locator("[data-interactive-table]").screenshot({ path: testInfo.outputPath("expanded-display.png") });
await ui.getByRole("button", { name: "按距离(米)排序", exact: true }).focus(); await ui.getByRole("button", { name: "按距离(米)排序", exact: true }).focus();
await page.keyboard.press("Enter"); await page.keyboard.press("Enter");
await expect(ui.getByRole("columnheader", { name: "距离(米)" })).toHaveAttribute("aria-sort", "none"); await expect(ui.getByRole("columnheader", { name: "距离(米)" })).toHaveAttribute("aria-sort", "none");
await expect(ui.locator("thead svg")).toHaveCount(0);
await ui.getByRole("textbox", { name: `搜索${title}` }).fill("没有匹配的管段"); await ui.getByRole("textbox", { name: `搜索${title}` }).fill("没有匹配的管段");
await expect(ui.getByRole("cell", { name: "当前筛选没有匹配的数据。" })).toHaveAttribute("colspan", "2"); await expect(ui.getByRole("cell", { name: "当前筛选没有匹配的数据。" })).toHaveAttribute("colspan", "2");
await expect(ui.getByRole("checkbox")).toHaveCount(0); await expect(ui.getByRole("checkbox")).toHaveCount(0);
+87
View File
@@ -0,0 +1,87 @@
import { expect, test, type Locator, type Page } from "@playwright/test";
import { investigationFixture, mockInvestigationPresentation } from "./support/mock-investigation-presentation";
import type { OperationalTaskNotification } from "../../src/features/workbench/api/operational-task-client";
async function openAlertQueue(page: Page) {
const task = investigationFixture(1, "通知焦点测试调查");
const notification: OperationalTaskNotification = {
notification_id: "96000000-0000-4000-8000-000000000021", task_id: task.task_id,
kind: "human_input_required", title: "待核验漏点与阀门", description: "请核对现场情况后继续调查。",
required_permission: null, lifecycle_status: "active", subject_type: "question", subject_id: null,
resolved_at: null, resolution_reason: null, status: "unread", version: 1,
created_at: task.created_at, read_at: null, read_by: null
};
await mockInvestigationPresentation(page, [task], [], [notification]);
await page.goto("/", { waitUntil: "domcontentloaded" });
const trigger = page.getByRole("button", { name: /查看待办与告警/ });
await expect(trigger).toHaveAccessibleName("查看待办与告警,当前 1 条待处理事项", { timeout: 15_000 });
await trigger.click();
await expect(page.getByRole("menu")).toBeVisible();
return {
trigger,
item: page.getByRole("menuitem", { name: `打开通知:${notification.title}` }),
row: page.locator(`[data-alert-id="operational-task-notification:${notification.notification_id}"]`)
};
}
async function ringShadow(element: Locator) {
return element.evaluate((node) => getComputedStyle(node).getPropertyValue("--tw-ring-shadow").trim());
}
for (const width of [1920, 375]) {
test(`鼠标悬停通知保持细边框且键盘导航仍有焦点环 ${width}`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height: 900 });
const { trigger, item, row } = await openAlertQueue(page);
await item.hover();
await expect(item).toBeFocused();
await expect(row).toHaveCSS("background-color", "rgb(218, 229, 253)");
await expect(row).toHaveCSS("border-width", "1px");
await expect(item).toHaveCSS("box-shadow", "none");
await expect(item).toHaveCSS("outline-style", "none");
await testInfo.attach("pointer-alert-focus", {
body: await page.getByRole("menu").screenshot({ path: testInfo.outputPath("pointer-alert-focus.png") }),
contentType: "image/png"
});
// Switching to keyboard on the same focused item must immediately show its indicator.
await page.keyboard.press("Home");
await expect(item).toBeFocused();
await expect.poll(() => ringShadow(item)).toContain("inset 0 0 0 calc(2px");
await testInfo.attach("keyboard-alert-focus", {
body: await page.getByRole("menu").screenshot({ path: testInfo.outputPath("keyboard-alert-focus.png") }),
contentType: "image/png"
});
await page.keyboard.press("Escape");
await expect(page.getByRole("menu")).toHaveCount(0);
await expect(trigger).toBeFocused();
await expect.poll(() => ringShadow(trigger)).toContain("calc(2px + 2px)");
});
test(`鼠标选择通知后顶部按钮无外环且再次切换键盘可见焦点 ${width}`, async ({ page }, testInfo) => {
await page.setViewportSize({ width, height: 900 });
const { trigger, item } = await openAlertQueue(page);
await item.click();
await expect(page.getByRole("menu")).toHaveCount(0);
await expect(trigger).toBeFocused();
await expect.poll(() => ringShadow(trigger)).toContain("calc(0px + 0px)");
await expect(trigger).toHaveCSS("outline-style", "none");
await testInfo.attach("pointer-alert-trigger", {
body: await trigger.screenshot({ path: testInfo.outputPath("pointer-alert-trigger.png"), animations: "disabled" }),
contentType: "image/png"
});
// Tab originates on another control, outside the menu and trigger handlers.
await page.keyboard.press("Tab");
await page.keyboard.press("Shift+Tab");
await expect(trigger).toBeFocused();
await expect.poll(() => ringShadow(trigger)).toContain("calc(2px + 2px)");
await testInfo.attach("keyboard-alert-trigger", {
body: await trigger.screenshot({ path: testInfo.outputPath("keyboard-alert-trigger.png"), animations: "disabled" }),
contentType: "image/png"
});
// Pointer movement must also clear an existing keyboard indicator.
await trigger.hover();
await expect.poll(() => ringShadow(trigger)).toContain("calc(0px + 0px)");
});
}
@@ -0,0 +1,130 @@
import { expect, test, type Page, type TestInfo } from "@playwright/test";
import { mockInvestigationPresentation } from "./support/mock-investigation-presentation";
const labels = ["点标注", "线标注", "范围标注"];
type ButtonFrame = {
label: string;
sameNode: boolean;
isConnected: boolean;
hovered: boolean;
pressed: string | null;
disabled: boolean;
background: string;
color: string;
opacity: string;
rect: { x: number; y: number; width: number; height: number };
icon: { background: string; color: string; transitions: string[] };
svgColor: string;
};
type Frame = { elapsed: number; buttons: ButtonFrame[] };
type SamplingProbe = { frames: Frame[]; done: Promise<void>; finishAction: () => number };
async function startSampling(page: Page) {
await page.evaluate((labels) => {
const toolbar = [...document.querySelectorAll<HTMLElement>('[role="toolbar"][aria-label="绘制工具"]')]
.find((node) => node.getBoundingClientRect().width > 0)!;
const find = (label: string) => [...toolbar.querySelectorAll<HTMLButtonElement>("button")].find((button) => button.textContent?.trim() === label)!;
const initial = labels.map(find);
const frames: Frame[] = [];
const started = performance.now();
let actionFinishedAt: number | null = null;
let finish: () => void;
const done = new Promise<void>((resolve) => { finish = resolve; });
const read = () => {
const elapsed = performance.now() - started;
frames.push({ elapsed, buttons: labels.map((label, index) => {
const button = find(label);
const icon = button.querySelector<HTMLElement>(":scope > span")!;
const svg = icon.querySelector("svg")!;
const style = getComputedStyle(button);
const iconStyle = getComputedStyle(icon);
const bounds = button.getBoundingClientRect();
return { label, sameNode: button === initial[index], isConnected: button.isConnected,
hovered: button.matches(":hover"), pressed: button.getAttribute("aria-pressed"), disabled: button.disabled,
background: style.backgroundColor, color: style.color, opacity: style.opacity,
rect: { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height },
icon: { background: iconStyle.backgroundColor, color: iconStyle.color,
transitions: icon.getAnimations().filter((animation) => animation instanceof CSSTransition).map((animation) => (animation as CSSTransition).transitionProperty) },
svgColor: getComputedStyle(svg).color
};
}) });
// Actionability waits can outlast 500ms; keep every frame through the action
// and at least one full 150ms color transition after it has completed.
if (elapsed < 500 || actionFinishedAt === null || elapsed < actionFinishedAt + 200) requestAnimationFrame(read);
else finish();
};
Object.assign(window, { annotationHoverProbe: { frames, done,
finishAction: () => { actionFinishedAt = performance.now() - started; return actionFinishedAt; }
} });
read();
}, labels);
}
async function collectSampling(page: Page): Promise<{ frames: Frame[]; actionFinishedAt: number }> {
return page.evaluate(async () => {
const probe = (window as unknown as { annotationHoverProbe: SamplingProbe }).annotationHoverProbe;
const actionFinishedAt = probe.finishAction();
await probe.done;
return { frames: probe.frames, actionFinishedAt };
});
}
async function screenshot(page: Page, testInfo: TestInfo, name: string) {
const panel = page.getByRole("region", { name: "标注", exact: true });
await testInfo.attach(name, { body: await panel.screenshot({ path: testInfo.outputPath(`${name}.png`), animations: "allow" }), contentType: "image/png" });
}
test.use({ reducedMotion: "no-preference", hasTouch: false });
for (const width of [1440, 375]) {
test(`annotation icons track button colors through hover, selection, cancellation and rapid reselection at ${width}px`, async ({ page }, testInfo) => {
test.setTimeout(60_000);
await page.setViewportSize({ width, height: 900 });
await mockInvestigationPresentation(page, []);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.waitForFunction(() => Boolean(window.__waterNetworkMap?.getLayer("scada-pressure-hit")));
await page.getByRole("button", { name: "标注:管理地图标注", exact: true }).click();
const panel = page.getByRole("region", { name: "标注", exact: true });
const point = panel.getByRole("button", { name: "点标注", exact: true });
const line = panel.getByRole("button", { name: "线标注", exact: true });
const polygon = panel.getByRole("button", { name: "范围标注", exact: true });
await expect(point).toBeEnabled();
await point.click({ trial: true });
await page.evaluate(() => document.fonts.ready);
await page.mouse.move(1, 1);
const sample = async (name: string, action: () => Promise<unknown>, selected: string | null, capture = false) => {
if (capture) await screenshot(page, testInfo, `${name}-before`);
await startSampling(page);
await action();
if (capture) await screenshot(page, testInfo, `${name}-during`);
const { frames, actionFinishedAt } = await collectSampling(page);
expect(frames.at(-1)!.elapsed - actionFinishedAt, `${name}: sampling must cover at least 200ms after the completed action`).toBeGreaterThanOrEqual(200);
const failures = frames.flatMap((frame) => frame.buttons.flatMap((button, index) => {
const initial = frames[0].buttons[index];
const shifted = (Object.keys(button.rect) as Array<keyof ButtonFrame["rect"]>).some((key) => Math.abs(button.rect[key] - initial.rect[key]) > 0.5);
return !button.sameNode || !button.isConnected || shifted || button.disabled !== initial.disabled || button.opacity !== "1"
|| button.svgColor !== button.color || button.icon.color !== button.color
? [{ elapsed: frame.elapsed, ...button }] : [];
}));
if (failures.length) await testInfo.attach(`${name}-failed-frames`, { body: JSON.stringify(failures.slice(0, 12), null, 2), contentType: "application/json" });
expect(failures, `${name}: icon colors, DOM identity, geometry and enabled state must remain in sync on every animation frame`).toEqual([]);
for (const button of frames.at(-1)!.buttons) expect(button.pressed).toBe(String(button.label === selected));
if (name.startsWith("hover-")) for (let index = 0; index < labels.length; index++) {
const changes = frames.slice(1).filter((frame, frameIndex) => frame.buttons[index].hovered !== frames[frameIndex].buttons[index].hovered).length;
expect(changes, "a single pointer entry or exit must not make hover blink repeatedly").toBeLessThanOrEqual(1);
}
if (capture) await screenshot(page, testInfo, `${name}-after`);
};
await sample("hover-point-first", () => point.hover(), null);
await sample("hover-line-first", () => line.hover(), null);
await sample("hover-point-repeat", () => point.hover(), null);
await sample("hover-line-repeat", () => line.hover(), null);
await sample("select-point", () => point.click(), "点标注");
await sample("select-line-stay", () => line.click(), "线标注", true);
await sample("select-polygon-stay", () => polygon.click(), "范围标注");
await sample("cancel-polygon-stay", () => polygon.click(), null);
await sample("select-point-again", () => point.click(), "点标注");
await sample("select-line-return-to-point", async () => { await line.click(); await point.hover(); }, "线标注");
await sample("cancel-line-return-to-point", async () => { await line.click(); await point.hover(); }, null);
await sample("rapid-reselection", async () => { await point.click(); await line.click(); await point.click(); }, "点标注", true);
});
}
+5 -1
View File
@@ -63,7 +63,11 @@ test("standalone basemap controls share the same pointer and keyboard boundary",
async function renderControls(page: Page) { async function renderControls(page: Page) {
await page.evaluate(async () => { await page.evaluate(async () => {
const paths = ["/node_modules/.vite/deps/react.js", "/node_modules/.vite/deps/react-dom_client.js", "/src/features/map/core/components/base-layers-control.tsx"]; const entry = await fetch("/src/main.tsx").then((response) => response.text());
const reactPath = entry.match(/["']([^"']*\/react\.js(?:\?[^"']*)?)["']/)?.[1];
const domPath = entry.match(/["']([^"']*\/react-dom_client\.js(?:\?[^"']*)?)["']/)?.[1];
if (!reactPath || !domPath) throw new Error("App React dependency URLs not found");
const paths = [reactPath, domPath, "/src/features/map/core/components/base-layers-control.tsx"];
const [react, dom, controls] = await Promise.all(paths.map((path) => import(path))); const [react, dom, controls] = await Promise.all(paths.map((path) => import(path)));
const h = react.default.createElement; const h = react.default.createElement;
function Examples() { function Examples() {
@@ -0,0 +1,90 @@
import { expect, test } from "@playwright/test";
import { basename, resolve } from "node:path";
import { investigationFixture, mockInvestigationPresentation, selectInvestigation } from "./support/mock-investigation-presentation";
const pressureIds = ["170486", "175618", "159673", "135160", "174754"];
const flowIds = ["542017", "486033", "524529", "541061", "542121"];
const times = Array.from({ length: 13 }, (_, i) => `${String(5 + Math.floor(i / 4)).padStart(2, "0")}:${String(i % 4 * 15).padStart(2, "0")}`);
const chart = (id: string, flow: boolean) => ({
action: "open_chart", chart_id: `chart_tc_${id}`, evidence_refs: [`synthetic:${id}`],
chart: { title: `${id} ${flow ? "管流" : "压力"}七日同时刻对照(${flow ? "m³/h" : "m"})`, chart_type: "time_comparison",
x_axis_name: "时间", y_axis_name: `${flow ? "流量(m³/h)" : "压力(m)"}`, x_data: times,
series: [{ name: `${id} 当前 2026-07-16`, type: "line", data: times.map((_, i) => 30 - i / 2),
sample_times: times.map((time) => `2026-07-16T${time}:00+08:00`) },
...Array.from({ length: 7 }, (_, day) => ({ name: `${id} 2026-07-${String(9 + day).padStart(2, "0")}`, type: "scatter",
data: times.map((_, i) => i < 10 ? null : 30 + day / 2),
sample_times: times.map((time, i) => i < 10 ? null : `2026-07-${String(9 + day).padStart(2, "0")}T${time}:00+08:00`) }))] }
});
test.use({ reducedMotion: "reduce" });
for (const width of [1440, 375, 320]) {
test(`groups comparisons with object selection and legends below the plot at ${width}px`, async ({ page }, testInfo) => {
test.setTimeout(60_000);
await page.setViewportSize({ width: 1440, height: 900 });
// This workspace links dependencies outside Vite's root. Serve the installed font for visual checks.
await page.route("**/@fs/**/noto-sans-sc-*.woff2", (route) => route.fulfill({
path: resolve("node_modules/@fontsource-variable/noto-sans-sc/files", basename(new URL(route.request().url()).pathname)),
contentType: "font/woff2"
}));
const task = investigationFixture(1, "同类图表聚合核查");
const mock = await mockInvestigationPresentation(page, [task]);
await selectInvestigation(page, task.title);
await page.setViewportSize({ width, height: 900 });
for (const [ids, flow] of [[pressureIds, false], [flowIds, true]] as const) for (const id of ids) {
await mock.emit(task, chart(id, flow));
await expect(page.locator(`[data-presentation-scope="investigation"] [data-investigation-chart="chart_tc_${id}"]`)).toHaveCount(1);
}
const manager = page.getByRole("dialog", { name: "图表管理", exact: true });
await expect(manager).toBeVisible();
await expect(manager.getByRole("group", { name: "压力七日同时刻对照(m)", exact: true })).toBeVisible();
await expect(page.locator("[data-floating-window]")).toHaveCount(1);
await manager.getByRole("button", { name: "关闭窗口", exact: true }).click();
const panel = page.getByLabel("调查实时展示", { exact: true });
await panel.getByRole("button", { name: "放大查看 压力七日同时刻对照(m)", exact: true }).click();
const pressure = page.getByRole("dialog", { name: "压力七日同时刻对照(m)", exact: true });
const flow = page.getByRole("dialog", { name: "管流七日同时刻对照(m³/h)", exact: true });
await expect(pressure).toBeVisible();
await expect(flow).toHaveCount(0);
await expect(page.locator("[data-floating-window]")).toHaveCount(1);
if (width < 640) await pressure.getByRole("button", { name: "最大化窗口", exact: true }).click();
await expect(pressure.getByLabel("选择图表对象").getByRole("button")).toHaveCount(5);
const objects = pressure.getByLabel("选择图表对象");
const select = objects.getByRole("button", { name: "切换对象 175618", exact: true });
const bounds = await pressure.boundingBox();
await select.click();
expect(await pressure.boundingBox()).toEqual(bounds);
await expect(select).toHaveAttribute("aria-pressed", "true");
await expect(objects.getByRole("button", { pressed: true })).toHaveCount(1);
await expect(pressure.getByRole("img", { name: "175618 压力七日同时刻对照(m)", exact: true })).toBeVisible();
const legends = pressure.getByLabel("图表图例");
await expect(legends.getByRole("button")).toHaveCount(8);
const drawing = pressure.getByRole("img", { name: "175618 压力七日同时刻对照(m)", exact: true });
const plotBox = (await drawing.boundingBox())!;
const legendBox = (await legends.boundingBox())!;
const objectBox = (await objects.boundingBox())!;
expect(legendBox.y).toBeGreaterThanOrEqual(plotBox.y + plotBox.height - 1);
if (width > 640) expect(objectBox.x).toBeGreaterThanOrEqual(plotBox.x + plotBox.width);
else expect(objectBox.y).toBeGreaterThanOrEqual(legendBox.y + legendBox.height - 1);
expect(plotBox.height).toBeGreaterThanOrEqual(120);
expect(await pressure.evaluate((node) => node.scrollWidth - node.clientWidth)).toBeLessThanOrEqual(1);
await page.evaluate(() => document.fonts.ready);
await testInfo.attach(`grouped-pressure-${width}`, { body: await pressure.screenshot({ path: testInfo.outputPath(`grouped-pressure-${width}.png`) }), contentType: "image/png" });
await legends.getByRole("button", { name: "175618 2026-07-09", exact: true }).click();
await expect(legends.getByRole("button", { name: "175618 2026-07-09", exact: true })).toHaveAttribute("aria-pressed", "false");
await pressure.getByRole("tab", { name: "数据", exact: true }).click();
await expect(pressure.getByRole("table").getByRole("row")).toHaveCount(14);
await expect(pressure.getByRole("table")).toContainText("2026-07-16 08:00:00+08:00");
await objects.getByRole("button", { name: "切换对象 159673", exact: true }).focus();
await page.keyboard.press("Enter");
await expect(pressure.getByRole("img", { name: "159673 压力七日同时刻对照(m)", exact: true })).toBeVisible();
await mock.emit(task, { action: "close_chart", chart_id: "chart_tc_159673" });
await expect(objects.getByRole("button")).toHaveCount(4);
await expect(objects.getByRole("button", { pressed: true })).toHaveCount(1);
await expect(panel.getByText("部分未完成", { exact: true })).toHaveCount(0);
await pressure.getByRole("button", { name: "关闭窗口", exact: true }).click();
await panel.getByRole("button", { name: "关闭图表 压力七日同时刻对照(m)", exact: true }).click();
await expect(panel.locator('[data-investigation-chart^="chart_tc_1"]')).toHaveCount(0);
await expect(panel.locator("[data-investigation-chart]")).toHaveCount(5);
expect(mock.mutations).toEqual([]);
});
}
@@ -0,0 +1,480 @@
import { expect, test, type Locator, type Page } from "@playwright/test";
import { basename, resolve } from "node:path";
import { writeFile } from "node:fs/promises";
import { investigationFixture, mockInvestigationPresentation, selectInvestigation } from "./support/mock-investigation-presentation";
const times = Array.from({ length: 13 }, (_, index) => `${String(5 + Math.floor(index / 4)).padStart(2, "0")}:${String(index % 4 * 15).padStart(2, "0")}`);
const allDayTimes = Array.from({ length: 96 }, (_, index) => `${String(Math.floor(index / 4)).padStart(2, "0")}:${String(index % 4 * 15).padStart(2, "0")}`);
const chart = (id: string, value = 28) => ({ action: "open_chart", chart_id: id, evidence_refs: ["synthetic:pressure"],
chart: { title: `合成监测 ${id}`, chart_type: "line", x_data: times, series: [
{ name: "压力(m)", data: times.map((_, index) => index === 12 ? value : 32 - index / 4) },
{ name: "参考(m)", data: times.map((_, index) => 31 - index / 8) }
] } });
const comparison = (id: string, flow = false) => {
const labels = flow ? allDayTimes : times;
return { action: "open_chart", chart_id: `comparison_${id}`, evidence_refs: [`synthetic:${id}`],
chart: { title: `${id} ${flow ? "管流" : "压力"}七日同时刻对照(${flow ? "m³/h" : "m"})`, chart_type: "time_comparison", x_axis_name: "时间", y_axis_name: flow ? "流量(m³/h)" : "压力(m)", x_data: labels,
series: [{ name: `${id} 当前 2026-07-16`, type: "line", data: labels.map((_, index) => flow ? 1237 + Math.sin(index / 8) * 245 : 32 - index / 4),
sample_times: labels.map((time) => `2026-07-16T${time}:00+08:00`) },
...Array.from({ length: 7 }, (_, day) => ({ name: `${id} 2026-07-${String(9 + day).padStart(2, "0")}`, type: "scatter",
data: labels.map((_, index) => index < labels.length - 3 ? null : flow ? 1360 + day * 21 : 30 + day / 2),
sample_times: labels.map((time, index) => index < labels.length - 3 ? null : `2026-07-${String(9 + day).padStart(2, "0")}T${time}:00+08:00`) }))]
} };
};
const card = (manager: Locator, id: string) => manager.locator(`[data-managed-chart][data-chart-title="合成监测 ${id}"]`);
const slots = (manager: Locator) => manager.locator("[data-chart-slot]:visible");
const positions = (manager: Locator) => manager.locator("[data-managed-chart]").evaluateAll((nodes) => Object.fromEntries(nodes.map((node) => [
node.getAttribute("data-chart-title"), Number(node.getAttribute("data-slot-index"))
])));
async function expectCompleteLegendRow(article: Locator) {
const legend = article.getByLabel("图表图例", { exact: true });
const row = article.getByLabel("显示的数据序列", { exact: true }).getByRole("button").first();
const rowHeight = (await row.boundingBox())!.height;
const box = await legend.evaluate((node) => ({
height: node.clientHeight, padding: Number.parseFloat(getComputedStyle(node).paddingTop)
}));
expect(box.height, "a full legend button must fit without being clipped by its scrolling container").toBeGreaterThanOrEqual(rowHeight + box.padding - 1);
const legendBounds = (await legend.boundingBox())!;
const groupBounds = (await article.locator("[data-chart-group-tile]").boundingBox())!;
expect(legendBounds.y + legendBounds.height).toBeLessThanOrEqual(groupBounds.y + groupBounds.height + 1);
}
async function coordinateBounds(plot: Locator) {
return plot.locator("svg").evaluate((svg) => {
const width = svg.getBoundingClientRect().width;
const lines = [...svg.querySelectorAll("path")].filter((path) => getComputedStyle(path).stroke === "rgb(226, 232, 240)")
.map((path) => path.getBoundingClientRect()).filter((box) => box.width > width / 2 && box.height <= 1);
if (lines.length < 2) throw new Error("Expected horizontal value-axis grid lines to measure the actual coordinate area");
const x = Math.min(...lines.map((line) => line.x));
const y = Math.min(...lines.map((line) => line.y));
return { x, y, width: Math.max(...lines.map((line) => line.right)) - x, height: Math.max(...lines.map((line) => line.bottom)) - y, gridLines: lines.length };
});
}
async function expectNoHorizontalOverflow(manager: Locator) {
const measured = await manager.evaluate((node) => [node, ...node.querySelectorAll<HTMLElement>("[data-chart-manager-scroll], [data-chart-layout], [data-managed-chart]")]
.filter((element) => element.clientWidth > 0)
.map((element) => ({
label: element.getAttribute("data-chart-title") ?? element.getAttribute("aria-label") ?? element.tagName,
width: element.clientWidth,
scrollWidth: element.scrollWidth
})));
for (const element of measured) expect(element.scrollWidth - element.width, `${element.label} must not create a horizontal scrollbar`).toBeLessThanOrEqual(1);
return measured;
}
async function resolvedColor(element: Locator, token: string, property: string) {
return element.evaluate((node, { token, property }) => {
const probe = document.createElement("span");
probe.style.setProperty(property, `var(${token})`);
node.append(probe);
const color = getComputedStyle(probe).getPropertyValue(property);
probe.remove();
return color;
}, { token, property });
}
async function setup(page: Page, count: number) {
await page.route("**/@fs/**/noto-sans-sc-*.woff2", (route) => route.fulfill({
path: resolve("node_modules/@fontsource-variable/noto-sans-sc/files", basename(new URL(route.request().url()).pathname)), contentType: "font/woff2"
}));
const task = investigationFixture(1, "固定图表槽位验收");
const mock = await mockInvestigationPresentation(page, [task]);
await selectInvestigation(page, task.title);
const panel = page.getByLabel("调查实时展示", { exact: true });
await expect(panel.getByText("实时跟随", { exact: true })).toBeVisible();
for (let index = 1; index <= count; index++) await mock.emit(task, chart(`P${index}`));
const manager = page.getByRole("dialog", { name: "图表管理", exact: true });
await expect(manager.locator("[data-managed-chart]")).toHaveCount(count);
return { manager, mock, task, panel };
}
test.use({ reducedMotion: "reduce", hasTouch: false, screenshot: "only-on-failure" });
test.setTimeout(90_000);
test("quick chart location highlights the position bar without an outer frame", async ({ page }, testInfo) => {
const { manager } = await setup(page, 4);
await page.evaluate(() => document.fonts.ready);
for (const width of [1440, 1600, 375]) {
await page.setViewportSize({ width, height: 900 });
const toolbar = manager.getByRole("toolbar", { name: "图表管理工具", exact: true });
const locate = toolbar.getByRole("combobox", { name: "快速定位图表", exact: true });
await expect(toolbar).not.toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
await expect(locate).toHaveCSS("border-top-color", "rgba(0, 0, 0, 0)");
await expect(locate).toHaveCSS("height", "32px");
const locateBounds = (await locate.boundingBox())!;
expect(locateBounds.width).toBeLessThanOrEqual(288);
for (const control of await toolbar.getByRole("button").all()) {
const bounds = (await control.boundingBox())!;
expect(Math.abs(bounds.height - locateBounds.height)).toBeLessThanOrEqual(1);
if (width > 640) expect(Math.abs(bounds.y - locateBounds.y)).toBeLessThanOrEqual(1);
}
await expect(toolbar.getByRole("group", { name: "图表排列方式", exact: true })).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
expect(await toolbar.evaluate((node) => node.scrollWidth - node.clientWidth)).toBeLessThanOrEqual(1);
const target = card(manager, "P4");
await manager.getByRole("combobox", { name: "快速定位图表", exact: true }).click();
await page.getByRole("option").filter({ hasText: "合成监测 P4" }).click();
await expect(target).toBeFocused();
await expect(target).toHaveAttribute("data-chart-located", "true");
const hasVisibleOutline = () => target.evaluate((node) => {
const style = getComputedStyle(node);
return style.outlineStyle !== "none" && Number.parseFloat(style.outlineWidth) > 0 && style.outlineColor !== "rgba(0, 0, 0, 0)";
});
expect(await hasVisibleOutline()).toBe(false);
await expect(target).toHaveCSS("box-shadow", "none");
const bar = target.locator(":scope > div").first();
await expect(bar).not.toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
await expect(target.getByText("已定位", { exact: true })).toBeVisible();
const bounds = await target.boundingBox();
await testInfo.attach(`chart-location-${width}`, { body: await manager.screenshot({ path: testInfo.outputPath(`chart-location-${width}.png`) }), contentType: "image/png" });
await expect(target).toHaveAttribute("data-chart-located", "false");
await expect(bar).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
expect(await hasVisibleOutline()).toBe(false);
expect(await target.boundingBox()).toEqual(bounds);
await locate.focus();
await locate.press("ArrowDown");
await page.getByRole("option").filter({ hasText: "合成监测 P4" }).focus();
await page.keyboard.press("Enter");
await expect(target).toBeFocused();
expect(await hasVisibleOutline()).toBe(false);
}
});
test("fixed chart slots swap without inserting or redrawing during pointer movement and fill vacancies before adding a row", async ({ page }, testInfo) => {
const { manager, mock, task } = await setup(page, 4);
const source = card(manager, "P1");
await manager.getByRole("button", { name: "四宫格布局", exact: true }).click();
await expect(slots(manager)).toHaveCount(4);
await card(manager, "P1").getByLabel("显示的数据序列").getByRole("button", { name: "参考(m)", exact: true }).click();
const instance = await card(manager, "P1").locator(".echarts-for-react").getAttribute("_echarts_instance_");
const chartsBefore = await page.locator(".echarts-for-react").count();
const handle = card(manager, "P1").getByRole("button", { name: "拖动图表 合成监测 P1", exact: true });
const positionBar = handle.locator("..");
const start = (await handle.boundingBox())!;
const bar = (await positionBar.boundingBox())!;
for (const [buttonEdge, barEdge] of [[start.x, bar.x], [start.y, bar.y], [start.x + start.width, bar.x + bar.width], [start.y + start.height, bar.y + bar.height]]) {
expect(Math.abs(buttonEdge - barEdge), "the drag button must cover every edge of the position bar").toBeLessThanOrEqual(1);
}
await expect(manager.getByRole("combobox", { name: /^移动图表 / })).toHaveCount(0);
await expect(manager.getByText("移动到", { exact: true })).toHaveCount(0);
const rightBlank = { x: start.width - 12, y: start.height / 2 };
await page.mouse.move(1, 1);
const highlight = source.locator("[data-chart-hover-highlight]");
await expect(highlight).toHaveCSS("opacity", "0");
await expect(highlight).toHaveCSS("pointer-events", "none");
const sourceBounds = (await source.boundingBox())!;
const highlightBounds = (await highlight.boundingBox())!;
for (const [highlightEdge, sourceEdge] of [[highlightBounds.x, sourceBounds.x], [highlightBounds.y, sourceBounds.y], [highlightBounds.x + highlightBounds.width, sourceBounds.x + sourceBounds.width], [highlightBounds.y + highlightBounds.height, sourceBounds.y + sourceBounds.height]]) {
expect(Math.abs(highlightEdge - sourceEdge), "the hover highlight must cover every edge of the whole chart card").toBeLessThanOrEqual(1);
}
const highlightBackground = await highlight.evaluate((node) => getComputedStyle(node).backgroundColor);
const highlightAlpha = Number(highlightBackground.match(/\/\s*([\d.]+)\s*\)$/)?.[1] ?? highlightBackground.match(/^rgba\(.+,\s*([\d.]+)\s*\)$/)?.[1] ?? 1);
expect(highlightAlpha, "the whole-card tint must have a real, subtle background").toBeGreaterThan(0);
expect(highlightAlpha).toBeLessThanOrEqual(0.06);
// Hover the chart body edge, away from both its title bar and ECharts tooltips.
await source.hover({ position: { x: sourceBounds.width - 6, y: sourceBounds.height / 2 } });
await expect(highlight).toHaveCSS("opacity", "1");
await testInfo.attach("whole-card-hover", { body: await source.screenshot({ path: testInfo.outputPath("whole-card-hover.png") }), contentType: "image/png" });
await page.mouse.move(1, 1);
await expect(highlight).toHaveCSS("opacity", "0");
const idleBackground = await handle.evaluate((node) => getComputedStyle(node).backgroundColor);
await handle.hover({ position: rightBlank });
await expect(highlight).toHaveCSS("opacity", "1");
await expect(handle).not.toHaveCSS("background-color", idleBackground);
expect(await handle.evaluate((node, point) => {
const bounds = node.getBoundingClientRect();
return document.elementFromPoint(bounds.x + point.x, bounds.y + point.y)?.closest("button") === node;
}, rightBlank)).toBe(true);
await testInfo.attach("position-bar-right-hover", { body: await positionBar.screenshot({ path: testInfo.outputPath("position-bar-right-hover.png") }), contentType: "image/png" });
const destination = (await manager.locator('[data-chart-slot="3"]').boundingBox())!;
await card(manager, "P1").evaluate(async (node) => {
await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
Object.assign(window, { chartSwapSvgMutations: 0 });
const observer = new MutationObserver((records) => {
(window as unknown as { chartSwapSvgMutations: number }).chartSwapSvgMutations += records.length;
});
node.querySelectorAll(".echarts-for-react svg").forEach((svg) => observer.observe(svg, { subtree: true, attributes: true, childList: true }));
Object.assign(window, { stopChartSwapWatch: () => observer.disconnect() });
});
await page.mouse.move(start.x + rightBlank.x, start.y + rightBlank.y);
await page.mouse.down();
await page.mouse.move(start.x + rightBlank.x + 12, start.y + rightBlank.y + 8, { steps: 3 });
await page.mouse.move(destination.x + destination.width / 2, destination.y + destination.height / 2, { steps: 16 });
await page.evaluate(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())));
const during = await page.evaluate(() => {
const state = window as unknown as { chartSwapSvgMutations: number; stopChartSwapWatch: () => void };
state.stopChartSwapWatch();
return state.chartSwapSvgMutations;
});
expect(during).toBe(0);
await expect(source).toHaveAttribute("data-chart-dragging", "true");
await expect(highlight).toHaveCSS("opacity", "1");
await expect(manager.locator('[data-chart-slot="3"]')).not.toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
await expect(source).toHaveCSS("opacity", "1");
await expect(handle).not.toHaveCSS("color", "rgb(255, 255, 255)");
await expect(handle).not.toHaveCSS("background-color", await resolvedColor(handle, "--action-blue", "background-color"));
const drawingOpacity = await source.locator(".echarts-for-react svg").evaluate((svg) => {
const ancestors = [];
for (let node: Element | null = svg; node; node = node.parentElement) {
ancestors.push({ element: node.tagName, className: node.getAttribute("class"), opacity: getComputedStyle(node).opacity });
if (node.matches("[data-managed-chart]")) break;
}
return ancestors;
});
for (const ancestor of drawingOpacity) expect(ancestor.opacity, `${ancestor.element} must keep the plot fully opaque during dragging`).toBe("1");
const overlay = page.locator("[data-chart-drag-preview]");
await expect(overlay).toBeVisible();
await expect(overlay).toHaveCSS("border-top-color", await resolvedColor(overlay, "--surface-border", "border-top-color"));
await expectNoHorizontalOverflow(manager);
await testInfo.attach("dragging-source", { body: await source.screenshot({ path: testInfo.outputPath("dragging-source.png") }), contentType: "image/png" });
await testInfo.attach("dragging-manager", { body: await manager.screenshot({ path: testInfo.outputPath("dragging-manager.png") }), contentType: "image/png" });
await testInfo.attach("dragging-plot-opacity", { body: JSON.stringify(drawingOpacity), contentType: "application/json" });
// A lightweight preview must not clone the chart engine while the original stays in its slot.
await expect(manager.locator(".echarts-for-react")).toHaveCount(4);
await expect(page.locator(".echarts-for-react")).toHaveCount(chartsBefore);
await page.mouse.up();
await expect(source).toHaveAttribute("data-chart-dragging", "false");
await expect.poll(() => positions(manager)).toEqual({ "合成监测 P1": 3, "合成监测 P2": 1, "合成监测 P3": 2, "合成监测 P4": 0 });
await expect(card(manager, "P1").locator(".echarts-for-react")).toHaveAttribute("_echarts_instance_", instance!);
await expect(card(manager, "P1").getByLabel("显示的数据序列").getByRole("button", { name: "参考(m)", exact: true })).toHaveAttribute("aria-pressed", "false");
await mock.emit(task, chart("P2", 17));
await card(manager, "P2").getByRole("tab", { name: "数据", exact: true }).click();
await expect(card(manager, "P2").getByRole("table")).toContainText("17");
await expect(card(manager, "P2")).toHaveAttribute("data-slot-index", "1");
await mock.emit(task, { action: "close_chart", chart_id: "P2" });
await expect(card(manager, "P2")).toHaveCount(0);
await expect(slots(manager)).toHaveCount(4);
await expect(card(manager, "P1")).toHaveAttribute("data-slot-index", "3");
await mock.emit(task, chart("P5"));
await expect(card(manager, "P5")).toHaveAttribute("data-slot-index", "1");
await mock.emit(task, chart("P6"));
await expect(card(manager, "P6")).toHaveAttribute("data-slot-index", "4");
await expect(card(manager, "P6")).toBeVisible();
await expect(card(manager, "P6")).not.toBeInViewport();
await expect(slots(manager)).toHaveCount(6);
await expect(manager.getByRole("button", { name: /[上下]一页图表/ })).toHaveCount(0);
const scroll = manager.locator("[data-chart-manager-scroll]");
await expect.poll(() => scroll.evaluate((node) => node.scrollHeight - node.clientHeight)).toBeGreaterThan(100);
await scroll.hover();
await page.mouse.wheel(0, 700);
await expect(card(manager, "P6")).toBeInViewport();
await manager.getByRole("combobox", { name: "快速定位图表", exact: true }).click();
await page.getByRole("option").filter({ hasText: "合成监测 P6" }).click();
await expect(card(manager, "P6")).toBeVisible();
await expect(card(manager, "P6")).toBeFocused();
await expect(slots(manager)).toHaveCount(6);
await expect.poll(() => scroll.evaluate((node) => node.scrollTop)).toBeGreaterThan(0);
await manager.getByRole("button", { name: "重置图表排列", exact: true }).click();
await expect.poll(() => scroll.evaluate((node) => node.scrollTop)).toBe(0);
await expect(card(manager, "P1")).toBeVisible();
await expect(card(manager, "P1").locator(".echarts-for-react")).toHaveAttribute("_echarts_instance_", instance!);
await page.evaluate(() => document.fonts.ready);
await testInfo.attach("fixed-slots-desktop", { body: await manager.screenshot({ path: testInfo.outputPath("fixed-slots-desktop.png") }), contentType: "image/png" });
expect(mock.mutations).toEqual([]);
});
test("scrolling layouts adapt charts and retain empty places without turning into an insertion list", async ({ page }, testInfo) => {
const { manager, mock } = await setup(page, 3);
const dimensions: Record<string, { width: number; height: number }> = {};
for (const [label, layout, count] of [["单图布局", "one", 1], ["左右布局", "two", 2], ["四宫格布局", "four", 4]] as const) {
await manager.getByRole("button", { name: label, exact: true }).click();
await expect(manager.locator("[data-chart-layout]")).toHaveAttribute("data-chart-layout", layout);
await expect(slots(manager)).toHaveCount(layout === "one" ? 3 : 4);
const first = (await slots(manager).first().boundingBox())!;
dimensions[layout] = first;
if (count > 1) {
const second = (await slots(manager).nth(1).boundingBox())!;
expect(second.x).toBeGreaterThan(first.x + first.width - 1);
expect(Math.abs(second.y - first.y)).toBeLessThanOrEqual(1);
}
if (count === 4) {
const third = (await slots(manager).nth(2).boundingBox())!;
expect(third.y).toBeGreaterThan(first.y + first.height - 1);
expect(Math.abs(third.x - first.x)).toBeLessThanOrEqual(1);
expect(await manager.locator('[data-managed-chart][data-slot-index="3"]').count()).toBe(0);
}
expect(await manager.evaluate((node) => node.scrollWidth - node.clientWidth)).toBeLessThanOrEqual(1);
for (const article of await manager.locator("[data-managed-chart]:visible").all()) await expectCompleteLegendRow(article);
await testInfo.attach(`fixed-layout-${layout}`, { body: await manager.screenshot({ path: testInfo.outputPath(`fixed-layout-${layout}.png`) }), contentType: "image/png" });
}
expect(dimensions.one.width).toBeGreaterThan(dimensions.two.width);
expect(dimensions.one.height).toBeGreaterThan(dimensions.four.height);
const heading = manager.getByLabel("窗口标题栏,可用方向键移动,Shift 加方向键调整大小", { exact: true });
await heading.focus();
for (let index = 0; index < 20; index++) await page.keyboard.press("Shift+ArrowUp");
expect((await manager.boundingBox())!.height).toBeLessThanOrEqual(441);
await manager.getByRole("combobox", { name: "快速定位图表", exact: true }).click();
await page.getByRole("option").filter({ hasText: "合成监测 P3" }).click();
await expect(card(manager, "P3")).toBeFocused();
const viewport = (await manager.locator("[data-chart-manager-scroll]").boundingBox())!;
for (const target of [card(manager, "P3").getByRole("button", { name: "拖动图表 合成监测 P3", exact: true }), card(manager, "P3").getByRole("heading", { level: 3 })]) {
const bounds = (await target.boundingBox())!;
expect(bounds.y).toBeGreaterThanOrEqual(viewport.y - 1);
expect(bounds.y + bounds.height).toBeLessThanOrEqual(viewport.y + viewport.height + 1);
}
expect(await manager.locator("[data-chart-manager-scroll]").evaluate((node) => node.scrollTop)).toBeGreaterThan(0);
await testInfo.attach("fixed-layout-short-locate", { body: await manager.screenshot({ path: testInfo.outputPath("fixed-layout-short-locate.png") }), contentType: "image/png" });
expect(mock.mutations).toEqual([]);
});
test("default keyboard dragging swaps fixed slots and narrow scrolling grids retain location and enlargement", async ({ page }, testInfo) => {
const { manager, mock, task } = await setup(page, 4);
await manager.getByRole("button", { name: "四宫格布局", exact: true }).click();
const source = card(manager, "P1");
const sourceBounds = (await source.boundingBox())!;
const targetBounds = (await manager.locator('[data-chart-slot="3"]').boundingBox())!;
const moveX = targetBounds.x + targetBounds.width / 2 - sourceBounds.x - sourceBounds.width / 2;
const moveY = targetBounds.y + targetBounds.height / 2 - sourceBounds.y - sourceBounds.height / 2;
await source.getByRole("button", { name: "拖动图表 合成监测 P1", exact: true }).focus();
await page.keyboard.press("Space");
await expect(source).toHaveAttribute("data-chart-dragging", "true");
await expect(source).toHaveCSS("opacity", "1");
// The SDK's default KeyboardSensor advances 50px with Shift + an arrow.
for (let step = 0; step < Math.round(Math.abs(moveX) / 50); step++) await page.keyboard.press(moveX > 0 ? "Shift+ArrowRight" : "Shift+ArrowLeft");
for (let step = 0; step < Math.round(Math.abs(moveY) / 50); step++) await page.keyboard.press(moveY > 0 ? "Shift+ArrowDown" : "Shift+ArrowUp");
await expect(manager.locator('[data-chart-slot="3"]')).toHaveClass(/border-primary/);
await page.keyboard.press("Space");
await expect.poll(() => positions(manager)).toEqual({ "合成监测 P1": 3, "合成监测 P2": 1, "合成监测 P3": 2, "合成监测 P4": 0 });
await expect(source).toHaveAttribute("data-chart-dragging", "false");
await expect(source).toHaveCSS("opacity", "1");
await mock.emit(task, chart("P5"));
await expect(card(manager, "P5")).toHaveAttribute("data-slot-index", "4");
for (const width of [375, 320]) {
await page.setViewportSize({ width, height: 812 });
await expect(manager.locator("[data-chart-layout]")).toHaveAttribute("data-chart-layout", "one");
await expect(slots(manager)).toHaveCount(5);
await manager.getByRole("combobox", { name: "快速定位图表", exact: true }).click();
await page.getByRole("option").filter({ hasText: "合成监测 P1" }).click();
await expect(card(manager, "P1")).toBeVisible();
await expect(card(manager, "P1")).toHaveAttribute("data-slot-index", "3");
await expect(card(manager, "P1")).toBeFocused();
const scroll = manager.locator("[data-chart-manager-scroll]");
await scroll.focus();
await scroll.press("End");
await expect(card(manager, "P5")).toBeInViewport();
await scroll.press("Home");
await expect(card(manager, "P4")).toBeInViewport();
await manager.getByRole("combobox", { name: "快速定位图表", exact: true }).click();
await page.getByRole("option").filter({ hasText: "合成监测 P1" }).click();
await expect(card(manager, "P1")).toBeInViewport();
expect(await manager.evaluate((node) => node.scrollWidth - node.clientWidth)).toBeLessThanOrEqual(1);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width);
await page.evaluate(() => document.fonts.ready);
await testInfo.attach(`fixed-slots-${width}`, { body: await manager.screenshot({ path: testInfo.outputPath(`fixed-slots-${width}.png`) }), contentType: "image/png" });
await manager.getByRole("combobox", { name: "快速定位图表", exact: true }).click();
await page.getByRole("option").filter({ hasText: "合成监测 P4" }).click();
await expect(card(manager, "P4")).toBeVisible();
await expect.poll(() => manager.locator("[data-chart-manager-scroll]").evaluate((node) => node.scrollTop)).toBeLessThanOrEqual(2);
}
await page.setViewportSize({ width: 1440, height: 900 });
await manager.getByRole("button", { name: "最大化窗口", exact: true }).click();
await expect(manager.locator("[data-chart-layout]")).toHaveAttribute("data-chart-layout", "four");
await expect(slots(manager)).toHaveCount(6);
await manager.getByRole("combobox", { name: "快速定位图表", exact: true }).click();
await page.getByRole("option").filter({ hasText: "合成监测 P1" }).click();
await card(manager, "P1").getByRole("button", { name: "放大查看 合成监测 P1", exact: true }).click();
await expect(page.getByRole("dialog", { name: "合成监测 P1", exact: true })).toBeVisible();
expect(mock.mutations).toEqual([]);
});
test("compound comparisons fit each slot with bottom scrolling legends and accessible object selection", async ({ page }, testInfo) => {
const { manager, mock, task } = await setup(page, 1);
for (const id of ["170486", "175618", "159673"]) await mock.emit(task, comparison(id));
await mock.emit(task, chart("P2"));
for (const id of ["542017", "486033", "524529"]) await mock.emit(task, comparison(id, true));
await expect(manager.locator("[data-managed-chart]")).toHaveCount(4);
const compound = manager.locator('[data-managed-chart][data-chart-title="压力七日同时刻对照(m)"]');
await expect(compound).toHaveAttribute("data-slot-index", "1");
await manager.getByRole("button", { name: "四宫格布局", exact: true }).click();
const objectButtons = compound.locator('aside[aria-label="选择图表对象"]');
await expect(objectButtons).toBeVisible();
await expect(objectButtons.getByRole("button")).toHaveCount(3);
await objectButtons.getByRole("button", { name: "切换对象 175618", exact: true }).click();
const plot = compound.getByRole("img", { name: "175618 压力七日同时刻对照(m)", exact: true });
await expect(plot).toBeVisible();
const legend = compound.getByLabel("图表图例", { exact: true });
await expect(legend.getByRole("button")).toHaveCount(8);
await page.evaluate(() => document.fonts.ready);
await expect.poll(() => plot.locator("svg").boundingBox().then((box) => box?.height ?? 0)).toBeGreaterThanOrEqual(100);
const geometry = async () => {
const slot = (await compound.boundingBox())!;
const drawing = (await plot.boundingBox())!;
const svg = (await plot.locator("svg").boundingBox())!;
const footer = (await legend.boundingBox())!;
const coordinates = await coordinateBounds(plot);
for (const box of [drawing, svg, footer]) {
expect(box.x).toBeGreaterThanOrEqual(slot.x - 1);
expect(box.y).toBeGreaterThanOrEqual(slot.y - 1);
expect(box.x + box.width).toBeLessThanOrEqual(slot.x + slot.width + 1);
expect(box.y + box.height).toBeLessThanOrEqual(slot.y + slot.height + 1);
}
expect(drawing.height).toBeGreaterThanOrEqual(100);
expect(coordinates.height, "compact axes must leave at least 40px for the actual plotted data").toBeGreaterThanOrEqual(40);
expect(footer.y).toBeGreaterThanOrEqual(drawing.y + drawing.height - 1);
expect(footer.height).toBeLessThanOrEqual(56);
await expectCompleteLegendRow(compound);
expect(slot.height).toBeGreaterThanOrEqual(234);
const overflow = await expectNoHorizontalOverflow(manager);
return { slot, drawing, svg, coordinates, footer, overflow };
};
const fourGeometry = await geometry();
const flow = manager.locator('[data-managed-chart][data-chart-title="管流七日同时刻对照(m³/h)"]');
const flowDrawing = flow.getByRole("img", { name: "542017 管流七日同时刻对照(m³/h)", exact: true });
await expect(flowDrawing).toBeVisible();
await expectCompleteLegendRow(flow);
await expect(flow.getByLabel("图表图例", { exact: true }).getByRole("button")).toHaveCount(8);
expect((await coordinateBounds(flowDrawing)).height, "large flow values and eight legend entries must preserve the coordinate area").toBeGreaterThanOrEqual(40);
expect((await objectButtons.boundingBox())!.x).toBeGreaterThanOrEqual(fourGeometry.drawing.x + fourGeometry.drawing.width);
const scroll = manager.locator("[data-chart-manager-scroll]");
const scrollBefore = await scroll.evaluate((node) => node.scrollTop);
const legendScroll = await legend.evaluate((node) => {
node.scrollTop = node.scrollHeight;
return { height: node.clientHeight, content: node.scrollHeight, top: node.scrollTop, overflow: getComputedStyle(node).overflowY };
});
expect(legendScroll.overflow).toBe("auto");
expect(legendScroll.content).toBeGreaterThan(legendScroll.height);
expect(legendScroll.top).toBeGreaterThan(0);
expect(await scroll.evaluate((node) => node.scrollTop)).toBe(scrollBefore);
await legend.getByRole("button", { name: "175618 2026-07-15", exact: true }).click();
await expect(legend.getByRole("button", { name: "175618 2026-07-15", exact: true })).toHaveAttribute("aria-pressed", "false");
await testInfo.attach("compound-fixed-four", { body: await manager.screenshot({ path: testInfo.outputPath("compound-fixed-four.png") }), contentType: "image/png" });
await manager.getByRole("button", { name: "单图布局", exact: true }).click();
await manager.getByRole("combobox", { name: "快速定位图表", exact: true }).click();
await page.getByRole("option").filter({ hasText: "压力七日同时刻对照(m)" }).click();
await expect(compound).toBeVisible();
await expect(objectButtons).toBeVisible();
await expect(compound.getByRole("combobox", { name: "选择图表对象", exact: true })).toBeHidden();
await expect(compound).toBeFocused();
const oneGeometry = await geometry();
await testInfo.attach("compound-fixed-one", { body: await manager.screenshot({ path: testInfo.outputPath("compound-fixed-one.png") }), contentType: "image/png" });
await page.setViewportSize({ width: 375, height: 812 });
await expect(manager.locator("[data-chart-layout]")).toHaveAttribute("data-chart-layout", "one");
await expect(compound).toBeVisible();
await expect(objectButtons).toBeHidden();
await compound.getByRole("combobox", { name: "选择图表对象", exact: true }).click();
await page.getByRole("option", { name: "159673", exact: true }).click();
await expect(compound.getByRole("img", { name: "159673 压力七日同时刻对照(m)", exact: true })).toBeVisible();
await expect.poll(() => compound.getByRole("img", { name: "159673 压力七日同时刻对照(m)", exact: true }).locator("svg").boundingBox().then((box) => box?.height ?? 0)).toBeGreaterThanOrEqual(100);
const narrowPlot = (await compound.getByRole("img", { name: "159673 压力七日同时刻对照(m)", exact: true }).boundingBox())!;
const narrowSvg = (await compound.getByRole("img", { name: "159673 压力七日同时刻对照(m)", exact: true }).locator("svg").boundingBox())!;
const narrowSlot = (await compound.boundingBox())!;
const narrowLegend = (await legend.boundingBox())!;
const narrowCoordinates = await coordinateBounds(compound.getByRole("img", { name: "159673 压力七日同时刻对照(m)", exact: true }));
expect(narrowPlot.height).toBeGreaterThanOrEqual(100);
expect(narrowPlot.x + narrowPlot.width).toBeLessThanOrEqual(narrowSlot.x + narrowSlot.width + 1);
expect(narrowSvg.x + narrowSvg.width).toBeLessThanOrEqual(narrowSlot.x + narrowSlot.width + 1);
expect(narrowSvg.y + narrowSvg.height).toBeLessThanOrEqual(narrowSlot.y + narrowSlot.height + 1);
expect(narrowLegend.y).toBeGreaterThanOrEqual(narrowPlot.y + narrowPlot.height - 1);
expect(narrowLegend.y + narrowLegend.height).toBeLessThanOrEqual(narrowSlot.y + narrowSlot.height + 1);
expect(await manager.evaluate((node) => node.scrollWidth - node.clientWidth)).toBeLessThanOrEqual(1);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(375);
await testInfo.attach("compound-fixed-narrow", { body: await manager.screenshot({ path: testInfo.outputPath("compound-fixed-narrow.png") }), contentType: "image/png" });
const geometryPath = testInfo.outputPath("compound-slot-geometry.json");
await writeFile(geometryPath, JSON.stringify({ four: fourGeometry, one: oneGeometry, legendScroll, narrow: { slot: narrowSlot, drawing: narrowPlot, svg: narrowSvg, coordinates: narrowCoordinates, footer: narrowLegend } }, null, 2));
await testInfo.attach("compound-slot-geometry", { path: geometryPath, contentType: "application/json" });
expect(mock.mutations).toEqual([]);
});
@@ -0,0 +1,118 @@
import { expect, test } from "@playwright/test";
import { basename, resolve } from "node:path";
import { investigationFixture, mockInvestigationPresentation, selectInvestigation } from "./support/mock-investigation-presentation";
const chart = (id: string, value = 28) => ({ action: "open_chart", chart_id: id, evidence_refs: ["synthetic:pressure"],
chart: { title: `合成监测 ${id}`, chart_type: "line", x_data: ["07:45", "08:00"], series: [{ name: "压力(m)", data: [32, value] }] } });
test.use({ reducedMotion: "reduce" });
test.setTimeout(60_000);
test.beforeEach(async ({ page }) => {
await page.route("**/@fs/**/noto-sans-sc-*.woff2", (route) => route.fulfill({
path: resolve("node_modules/@fontsource-variable/noto-sans-sc/files", basename(new URL(route.request().url()).pathname)), contentType: "font/woff2"
}));
});
test("one manager receives live charts and keeps independent enlarged children", async ({ page }, testInfo) => {
const task = investigationFixture(1, "多图统一窗口验收");
const mock = await mockInvestigationPresentation(page, [task]);
await selectInvestigation(page, task.title);
await mock.emit(task, chart("P1"));
const manager = page.getByRole("dialog", { name: "图表管理", exact: true });
await expect(manager).toBeVisible();
await mock.emit(task, chart("P2"));
await expect(manager.getByRole("img", { name: "合成监测 P2", exact: true })).toBeVisible();
await expect(page.locator("[data-floating-window]")).toHaveCount(1);
await expect(manager).toContainText("2 张图表");
await manager.getByRole("button", { name: "放大查看 合成监测 P1", exact: true }).click();
const first = page.getByRole("dialog", { name: "合成监测 P1", exact: true });
await expect(first).toBeVisible();
await manager.locator(".floating-window-heading").focus();
await manager.getByRole("button", { name: "放大查看 合成监测 P2", exact: true }).click();
const second = page.getByRole("dialog", { name: "合成监测 P2", exact: true });
await expect(first).toBeVisible();
await expect(second).toBeVisible();
await expect(page.locator("[data-floating-window]")).toHaveCount(3);
const bounds = await second.boundingBox();
await mock.emit(task, chart("P2", 17));
await second.getByRole("tab", { name: "数据", exact: true }).click();
await expect(second.getByRole("table")).toContainText("17");
expect(await second.boundingBox()).toEqual(bounds);
await expect(page.locator("[data-floating-window]")).toHaveCount(3);
await second.getByRole("button", { name: "关闭窗口", exact: true }).click();
await first.locator(".floating-window-heading").focus();
await first.getByRole("button", { name: "最大化窗口", exact: true }).click();
expect((await first.boundingBox())!.width).toBe(1424);
await first.getByRole("button", { name: "关闭窗口", exact: true }).click();
await manager.locator(".floating-window-heading").focus();
await manager.getByRole("button", { name: "最大化窗口", exact: true }).click();
await page.evaluate(() => document.fonts.ready);
await testInfo.attach("chart-manager-desktop", { body: await manager.screenshot({ path: testInfo.outputPath("chart-manager-desktop.png") }), contentType: "image/png" });
for (const width of [375, 320]) {
await page.setViewportSize({ width, height: 720 });
const box = (await manager.boundingBox())!;
expect(box.x).toBeGreaterThanOrEqual(0);
expect(box.x + box.width).toBeLessThanOrEqual(width);
expect(await manager.evaluate((node) => node.scrollWidth - node.clientWidth)).toBeLessThanOrEqual(1);
await testInfo.attach(`chart-manager-${width}`, { body: await manager.screenshot({ path: testInfo.outputPath(`chart-manager-${width}.png`) }), contentType: "image/png" });
}
await manager.getByRole("button", { name: "关闭窗口", exact: true }).click();
await mock.emit(task, chart("P3"));
const panel = page.getByLabel("调查实时展示", { exact: true });
await expect(panel.locator('[data-investigation-chart="P3"]')).toHaveCount(1);
await expect(page.locator("[data-floating-window]")).toHaveCount(0);
await panel.getByRole("button", { name: "打开图表管理", exact: true }).click();
await expect(manager).toContainText("3 张图表");
expect(mock.mutations).toEqual([]);
});
test("opening, switching, refreshing and reconnecting tasks restores charts silently, including completed reports", async ({ page }) => {
const running = investigationFixture(1, "运行中历史图表");
const completed = investigationFixture(2, "已完成历史图表");
completed.status = "completed";
completed.lifecycle_status = "completed";
const completedRun = completed.active_run!.run_id;
completed.active_run = null;
completed.latest_report = { revision: 1, execution_generation: 1, source_session_id: "investigation-dev-test", published_at: completed.created_at,
published_by: "fixture", report: { title: "已完成报告", current_conclusion: "合成监测核查完成", conclusion_boundary: "仅供浏览器验证",
stage: "final", outcome: "concluded", stop_reason: null, data_gaps: [], evidence_refs: ["synthetic:pressure"], recommendations: [], pending_items: [],
ui_blocks: [{ evidence_refs: ["synthetic:pressure"], block: { version: "answer-ui@1", block_id: "saved-report-chart", phase: "final", fallback_text: "报告图表",
spec: { root: "chart", elements: { chart: { type: "Chart", props: { ...chart("报告").chart, source: "合成保存证据" }, children: [] } } } } }] } };
const saved = [running, completed].map((task, index) => ({ task_id: task.task_id, event_seq: index + 1,
run_id: task.active_run?.run_id ?? completedRun, execution_generation: 1, occurred_at: task.created_at, command: chart(`P${index + 1}`) }));
const mock = await mockInvestigationPresentation(page, [running, completed], saved);
await selectInvestigation(page, running.title);
const panel = page.getByLabel("调查实时展示", { exact: true });
const manager = page.getByRole("dialog", { name: "图表管理", exact: true });
await expect(panel.locator('[data-investigation-chart="P1"]')).toHaveCount(1);
await expect(page.locator("[data-floating-window]")).toHaveCount(0);
// Create a live request before returning to the same task later.
await mock.emit(running, chart("P1", 23));
await expect(manager).toBeVisible();
await manager.getByRole("button", { name: "关闭窗口", exact: true }).click();
const tasks = page.getByRole("region", { name: "工况任务", exact: true });
await tasks.getByText(completed.title, { exact: true }).first().click();
await expect(panel.locator('[data-investigation-chart="P2"]')).toHaveCount(1);
await expect(panel.getByRole("img", { name: "合成监测 报告", exact: true })).toBeVisible();
await expect(page.locator("[data-floating-window]")).toHaveCount(0);
await panel.getByRole("button", { name: "打开图表管理", exact: true }).click();
await expect(manager).toContainText("2 张图表");
await expect(manager.getByRole("img", { name: "合成监测 报告", exact: true })).toBeVisible();
await manager.getByRole("button", { name: "关闭窗口", exact: true }).click();
await tasks.getByText(running.title, { exact: true }).first().click();
await expect(panel.locator('[data-investigation-chart="P1"]')).toHaveCount(1);
await expect(page.locator("[data-floating-window]")).toHaveCount(0);
await panel.getByRole("button", { name: "打开图表管理", exact: true }).click();
await expect(manager).toBeVisible();
await page.reload({ waitUntil: "domcontentloaded" });
await expect(page.locator("[data-floating-window]")).toHaveCount(0);
await selectInvestigation(page, completed.title);
await expect(panel.locator('[data-investigation-chart="P2"]')).toHaveCount(1);
await expect(page.locator("[data-floating-window]")).toHaveCount(0);
await page.evaluate(() => (window as unknown as { setInvestigationTestConnection: (value: boolean) => void }).setInvestigationTestConnection(false));
await expect(panel).toContainText("连接中断");
await page.evaluate(() => (window as unknown as { setInvestigationTestConnection: (value: boolean) => void }).setInvestigationTestConnection(true));
await expect(panel).toContainText("实时跟随", { timeout: 15_000 });
await expect(page.locator("[data-floating-window]")).toHaveCount(0);
expect(mock.mutations).toEqual([]);
});
@@ -0,0 +1,131 @@
import { expect, test } from "@playwright/test";
import { mockInvestigationPresentation } from "./support/mock-investigation-presentation";
test.use({ reducedMotion: "reduce", viewport: { width: 1600, height: 1000 } });
for (const target of ["manager", "child"] as const) {
test(`resizing ${target} reflows charts at their actual dimensions without stretching text`, async ({ page }, testInfo) => {
test.setTimeout(90_000);
await mockInvestigationPresentation(page, []);
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "切换地图 Dev Panel" }).click();
const tools = page.getByTestId("investigation-dev-tools");
await tools.getByRole("combobox", { name: "JSONL 测试案例", exact: true }).click();
await page.getByRole("option", { name: "复杂图表:多测点与长时序", exact: true }).click();
await tools.getByRole("button", { name: "执行本地预览", exact: true }).click();
await expect(tools.getByRole("status")).toContainText("本地预览完成", { timeout: 20_000 });
const panel = page.getByLabel("本地预览展示", { exact: true });
await panel.getByRole("button", { name: "打开图表管理", exact: true }).click();
const manager = page.getByRole("dialog", { name: "图表管理", exact: true });
if (target === "child") await manager.getByRole("button", { name: "放大查看 合成全天压力对比", exact: true }).click();
const popup = target === "manager" ? manager : page.getByRole("dialog", { name: "合成全天压力对比", exact: true });
await expect(popup.locator(".echarts-for-react svg").first()).toBeVisible();
await popup.locator(".floating-window-heading").hover();
await page.evaluate(() => document.fonts.ready);
const instances = await popup.locator(".echarts-for-react").evaluateAll((nodes) => nodes.map((node) => node.getAttribute("_echarts_instance_")));
const drawingSize = await popup.locator(".echarts-for-react").first().evaluate((node) => ({ width: node.clientWidth, height: node.clientHeight }));
const font = await popup.locator(".echarts-for-react svg text").first().evaluate((node) => ({
size: getComputedStyle(node).fontSize, height: node.getBoundingClientRect().height
}));
const before = (await popup.boundingBox())!;
const handle = (await popup.locator(".floating-window-resize").boundingBox())!;
const session = await page.context().newCDPSession(page);
await session.send("Emulation.setCPUThrottlingRate", { rate: 4 });
await session.send("Performance.enable");
await popup.evaluate(async (node) => {
await new Promise<void>((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
Object.assign(window, { chartResizeMutations: 0 });
const observer = new MutationObserver((records) => {
(window as unknown as { chartResizeMutations: number }).chartResizeMutations += records.length;
});
node.querySelectorAll(".echarts-for-react svg").forEach((svg) => observer.observe(svg, { subtree: true, attributes: true, childList: true }));
});
const first = await session.send("Performance.getMetrics");
const started = Date.now();
await page.mouse.move(handle.x + handle.width - 3, handle.y + handle.height - 3);
await page.mouse.down();
await page.mouse.move(handle.x + handle.width - 3 + 128, handle.y + handle.height - 3 + 64, { steps: 32 });
// While the pointer is still held, layout and the chart drawing must adapt together.
await expect.poll(() => popup.locator(".echarts-for-react").first().evaluate((node) => Math.abs(node.clientWidth - Number(node.querySelector("svg")?.getAttribute("width"))))).toBeLessThan(1);
await expect.poll(() => popup.locator(".echarts-for-react").first().evaluate((node) => Math.abs(node.clientHeight - Number(node.querySelector("svg")?.getAttribute("height"))))).toBeLessThan(1);
const during = await page.evaluate(() => (window as unknown as { chartResizeMutations: number }).chartResizeMutations);
const preview = (await popup.boundingBox())!;
await expect(popup.locator("[data-floating-window-document]")).toHaveCSS("transform", "none");
expect(await popup.locator("[data-floating-window-document]").evaluate((node) => ({ width: (node as HTMLElement).style.width, height: (node as HTMLElement).style.height }))).toEqual({ width: "", height: "" });
const resizedFont = await popup.locator(".echarts-for-react svg text").first().evaluate((node) => ({ size: getComputedStyle(node).fontSize, height: node.getBoundingClientRect().height }));
expect(resizedFont.size).toBe(font.size);
expect(Math.abs(resizedFont.height - font.height)).toBeLessThan(1);
const actualDrawing = await popup.locator(".echarts-for-react").first().evaluate((node) => ({ width: node.clientWidth, height: node.clientHeight }));
expect(actualDrawing.width).toBeGreaterThan(drawingSize.width);
expect(actualDrawing.height).toBeGreaterThanOrEqual(drawingSize.height);
await page.mouse.up();
const last = await session.send("Performance.getMetrics");
const metric = (name: string) => (last.metrics.find((item) => item.name === name)?.value ?? 0) - (first.metrics.find((item) => item.name === name)?.value ?? 0);
const summary = { target, svgMutationsDuringResize: during, elapsedMs: Date.now() - started,
scriptMs: metric("ScriptDuration") * 1000, layoutMs: metric("LayoutDuration") * 1000, taskMs: metric("TaskDuration") * 1000 };
console.info("Chart resize profile", JSON.stringify(summary));
await testInfo.attach("resize-profile", { body: JSON.stringify(summary, null, 2), contentType: "application/json" });
expect(Math.round(preview.width - before.width)).toBe(128);
expect(Math.round(preview.height - before.height)).toBe(64);
expect(during).toBeGreaterThan(0);
// Bound the expensive work on the same 4x CPU fixture used to reproduce the lag.
expect(summary.elapsedMs).toBeLessThan(12_000);
expect(summary.scriptMs).toBeLessThan(6_000);
expect(await popup.locator(".echarts-for-react").evaluateAll((nodes) => nodes.map((node) => node.getAttribute("_echarts_instance_")))).toEqual(instances);
await expect.poll(() => popup.locator(".echarts-for-react").first().evaluate((node) => Math.abs(node.clientWidth - Number(node.querySelector("svg")?.getAttribute("width"))))).toBeLessThan(1);
const layout = await popup.evaluate((node) => ({
plots: Array.from(node.querySelectorAll<HTMLElement>(".echarts-for-react")).filter((plot) => plot.getBoundingClientRect().height > 0).map((plot) => plot.clientHeight),
legends: Array.from(node.querySelectorAll<HTMLElement>('[data-chart-group-tile="true"]')).map((card) => {
const legend = card.querySelector<HTMLElement>('[aria-label="图表图例"]')!;
const first = legend.querySelector("button")!;
return { height: legend.clientHeight, firstRowHeight: first.getBoundingClientRect().height,
bottomOverflow: legend.getBoundingClientRect().bottom - card.getBoundingClientRect().bottom };
})
}));
for (const height of layout.plots) expect(height).toBeGreaterThanOrEqual(100);
for (const legend of layout.legends) {
expect(legend.height).toBeGreaterThanOrEqual(legend.firstRowHeight - 1);
expect(legend.bottomOverflow).toBeLessThanOrEqual(1);
}
await testInfo.attach("resize-layout", { body: JSON.stringify(layout, null, 2), contentType: "application/json" });
await session.send("Emulation.setCPUThrottlingRate", { rate: 1 });
const chartSurface = target === "manager" ? manager.getByRole("group", { name: "合成全天压力对比", exact: true }) : popup;
const selectedObject = chartSurface.getByRole("button", { name: /^切换对象 / }).nth(1);
await selectedObject.click();
const hiddenSeries = chartSurface.getByLabel("显示的数据序列").getByRole("button").last();
await hiddenSeries.click();
await expect(selectedObject).toHaveAttribute("aria-pressed", "true");
await expect(hiddenSeries).toHaveAttribute("aria-pressed", "false");
const selectedInstance = await chartSurface.locator(".echarts-for-react").first().getAttribute("_echarts_instance_");
const beforeCancel = (await popup.boundingBox())!;
const cancelHandle = popup.locator(".floating-window-resize");
const cancelBox = (await cancelHandle.boundingBox())!;
await cancelHandle.evaluate((node) => node.addEventListener("pointerdown", (event) => {
(node as HTMLElement).dataset.testPointerId = String((event as PointerEvent).pointerId);
}, { once: true }));
await page.mouse.move(cancelBox.x + cancelBox.width - 3, cancelBox.y + cancelBox.height - 3);
await page.mouse.down();
await page.mouse.move(cancelBox.x + cancelBox.width - 3 + 48, cancelBox.y + cancelBox.height - 3 + 32, { steps: 4 });
await expect.poll(async () => Math.round((await popup.boundingBox())!.width - beforeCancel.width)).toBe(48);
await cancelHandle.evaluate((node) => node.dispatchEvent(new PointerEvent("pointercancel", {
pointerId: Number((node as HTMLElement).dataset.testPointerId)
})));
await page.mouse.up();
await expect.poll(() => popup.boundingBox()).toEqual(beforeCancel);
await expect(popup.locator("[data-floating-window-document]")).toHaveCSS("transform", "none");
await expect(selectedObject).toHaveAttribute("aria-pressed", "true");
await expect(hiddenSeries).toHaveAttribute("aria-pressed", "false");
const boundaryBox = (await cancelHandle.boundingBox())!;
await page.mouse.move(boundaryBox.x + boundaryBox.width - 3, boundaryBox.y + boundaryBox.height - 3);
await page.mouse.down();
await page.mouse.move(1599, 999, { steps: 8 });
await page.mouse.up();
const final = (await popup.boundingBox())!;
expect(Math.round(final.x + final.width)).toBe(1592);
expect(Math.round(final.y + final.height)).toBe(992);
await expect(selectedObject).toHaveAttribute("aria-pressed", "true");
await expect(hiddenSeries).toHaveAttribute("aria-pressed", "false");
await expect(chartSurface.locator(".echarts-for-react").first()).toHaveAttribute("_echarts_instance_", selectedInstance!);
await expect.poll(() => chartSurface.locator(".echarts-for-react").first().evaluate((node) => Math.abs(node.clientWidth - Number(node.querySelector("svg")?.getAttribute("width"))))).toBeLessThan(1);
await testInfo.attach(`resize-${target}`, { body: await popup.screenshot({ path: testInfo.outputPath(`resize-${target}.png`) }), contentType: "image/png" });
});
}
@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test"; import { expect, test, type Page } from "@playwright/test";
import { investigationFixture, mockInvestigationPresentation, selectInvestigation } from "./support/mock-investigation-presentation"; import { investigationFixture, mockInvestigationPresentation, selectInvestigation } from "./support/mock-investigation-presentation";
const chart = (index: number) => ({ const chart = (index: number) => ({
@@ -7,15 +7,51 @@ const chart = (index: number) => ({
series: [{ name: "压力(m)", data: [30, 30 - index] }] } series: [{ name: "压力(m)", data: [30, 30 - index] }] }
}); });
const managedPages = new WeakSet<Page>();
async function openChild(page: Page, title: string, live = true) {
const manager = page.getByRole("dialog", { name: "图表管理", exact: true });
if (live && !managedPages.has(page)) {
await expect(manager).toBeVisible();
managedPages.add(page);
}
if (await manager.isVisible()) await manager.getByRole("button", { name: "关闭窗口", exact: true }).click();
await page.getByLabel("调查实时展示", { exact: true }).getByRole("button", { name: `放大查看 ${title}`, exact: true }).click();
}
test.setTimeout(60_000); test.setTimeout(60_000);
// Keep unrelated avatar animation out of chart geometry and drag measurements. // Keep unrelated avatar animation out of chart geometry and drag measurements.
test.use({ reducedMotion: "reduce" }); test.use({ reducedMotion: "reduce" });
test("losing window focus cancels a drag and allows a fresh gesture", async ({ page }) => {
const task = investigationFixture(1, "图窗失焦恢复核查");
const mock = await mockInvestigationPresentation(page, [task]);
await selectInvestigation(page, task.title);
await mock.emit(task, chart(1));
await openChild(page, "合成压力分析 1");
const popup = page.getByRole("dialog", { name: "合成压力分析 1", exact: true });
const before = (await popup.boundingBox())!;
await page.mouse.move(before.x + 180, before.y + 24);
await page.mouse.down();
await page.mouse.move(before.x + 100, before.y + 64, { steps: 4 });
await expect.poll(async () => Math.round((await popup.boundingBox())!.x - before.x)).toBe(-80);
await page.evaluate(() => window.dispatchEvent(new Event("blur")));
expect(await popup.boundingBox()).toEqual(before);
expect(await popup.evaluate((node) => (node as HTMLElement).style.willChange)).toBe("");
await page.mouse.up();
await page.mouse.move(before.x + 180, before.y + 24);
await page.mouse.down();
await page.mouse.move(before.x + 140, before.y + 44, { steps: 4 });
await page.mouse.up();
expect(Math.round((await popup.boundingBox())!.x - before.x)).toBe(-40);
expect(Math.round((await popup.boundingBox())!.y - before.y)).toBe(20);
});
test("cancelled drags release capture and preserve restore, resizing and keyboard controls", async ({ page }) => { test("cancelled drags release capture and preserve restore, resizing and keyboard controls", async ({ page }) => {
const task = investigationFixture(1, "图窗拖动取消核查"); const task = investigationFixture(1, "图窗拖动取消核查");
const mock = await mockInvestigationPresentation(page, [task]); const mock = await mockInvestigationPresentation(page, [task]);
await selectInvestigation(page, task.title); await selectInvestigation(page, task.title);
await mock.emit(task, chart(1)); await mock.emit(task, chart(1));
await openChild(page, "合成压力分析 1");
const popup = page.getByRole("dialog", { name: "合成压力分析 1", exact: true }); const popup = page.getByRole("dialog", { name: "合成压力分析 1", exact: true });
await expect(popup).toBeVisible(); await expect(popup).toBeVisible();
await page.getByRole("button", { name: "关闭通知", exact: true }).evaluateAll((buttons) => buttons.forEach((button) => (button as HTMLButtonElement).click())); await page.getByRole("button", { name: "关闭通知", exact: true }).evaluateAll((buttons) => buttons.forEach((button) => (button as HTMLButtonElement).click()));
@@ -37,6 +73,7 @@ test("cancelled drags release capture and preserve restore, resizing and keyboar
expect(await popup.evaluate((node) => (node as HTMLElement).style.willChange)).toBe(""); expect(await popup.evaluate((node) => (node as HTMLElement).style.willChange)).toBe("");
await popup.getByRole("button", { name: "最大化窗口", exact: true }).click(); await popup.getByRole("button", { name: "最大化窗口", exact: true }).click();
expect((await popup.boundingBox())!.x).toBe(8); expect((await popup.boundingBox())!.x).toBe(8);
expect((await popup.boundingBox())!.y).toBe(64);
await popup.getByRole("button", { name: "还原窗口", exact: true }).click(); await popup.getByRole("button", { name: "还原窗口", exact: true }).click();
expect(await popup.boundingBox()).toEqual(original); expect(await popup.boundingBox()).toEqual(original);
await heading.focus(); await heading.focus();
@@ -69,6 +106,8 @@ test("profiles a single drag with eight comparison chart windows", async ({ page
data: slots.map((_, point) => point < 10 ? null : 30 + day / 10), data: slots.map((_, point) => point < 10 ? null : 30 + day / 10),
sample_times: slots.map((time, point) => point < 10 ? null : `2026-07-${String(15 - day).padStart(2, "0")}T${time}:00+08:00`) }))] sample_times: slots.map((time, point) => point < 10 ? null : `2026-07-${String(15 - day).padStart(2, "0")}T${time}:00+08:00`) }))]
} }); } });
await expect(page.getByLabel("调查实时展示", { exact: true }).getByRole("button", { name: `放大查看 合成七日同期曲线 ${index}`, exact: true })).toBeVisible();
await openChild(page, `合成七日同期曲线 ${index}`);
await expect(page.getByRole("dialog", { name: `合成七日同期曲线 ${index}`, exact: true })).toBeVisible(); await expect(page.getByRole("dialog", { name: `合成七日同期曲线 ${index}`, exact: true })).toBeVisible();
} }
await expect(page.locator("[data-floating-window]")).toHaveCount(8); await expect(page.locator("[data-floating-window]")).toHaveCount(8);
@@ -144,6 +183,8 @@ for (const height of [900, 720]) {
await selectInvestigation(page, task.title); await selectInvestigation(page, task.title);
for (let index = 1; index <= 4; index++) { for (let index = 1; index <= 4; index++) {
await mock.emit(task, { ...chart(index), chart_id: `pressure-${index}` }); await mock.emit(task, { ...chart(index), chart_id: `pressure-${index}` });
await expect(page.getByLabel("调查实时展示", { exact: true }).getByRole("button", { name: `放大查看 合成压力分析 ${index}`, exact: true })).toBeVisible();
await openChild(page, `合成压力分析 ${index}`);
await expect(page.getByRole("dialog", { name: `合成压力分析 ${index}`, exact: true })).toBeVisible(); await expect(page.getByRole("dialog", { name: `合成压力分析 ${index}`, exact: true })).toBeVisible();
} }
await page.getByRole("button", { name: "关闭通知", exact: true }).evaluateAll((buttons) => buttons.forEach((button) => (button as HTMLButtonElement).click())); await page.getByRole("button", { name: "关闭通知", exact: true }).evaluateAll((buttons) => buttons.forEach((button) => (button as HTMLButtonElement).click()));
@@ -172,7 +213,7 @@ for (const height of [900, 720]) {
await page.mouse.move(start.x - 120, start.y - 32, { steps: 12 }); await page.mouse.move(start.x - 120, start.y - 32, { steps: 12 });
await page.mouse.up(); await page.mouse.up();
await expect.poll(async () => Math.round((await popup.boundingBox())!.x - before.x)).toBe(-120); await expect.poll(async () => Math.round((await popup.boundingBox())!.x - before.x)).toBe(-120);
await expect.poll(async () => Math.round((await popup.boundingBox())!.y - before.y)).toBe(-32); await expect.poll(async () => Math.round((await popup.boundingBox())!.y - before.y)).toBe(Math.max(64, before.y - 32) - before.y);
expect((await popup.boundingBox())!.width).toBe(before.width); expect((await popup.boundingBox())!.width).toBe(before.width);
expect((await popup.boundingBox())!.height).toBe(before.height); expect((await popup.boundingBox())!.height).toBe(before.height);
} }
@@ -183,49 +224,36 @@ for (const height of [900, 720]) {
}); });
} }
test("live analysis opens a window, preserves its position and respects closing and task switches", async ({ page }, testInfo) => { test("live analysis updates one manager and respects dismissal", async ({ page }, testInfo) => {
const task = investigationFixture(1, "图表自动展示核查"); const task = investigationFixture(1, "图表统一展示核查");
const other = investigationFixture(2, "另一项调查"); const mock = await mockInvestigationPresentation(page, [task]);
const mock = await mockInvestigationPresentation(page, [task, other]);
await selectInvestigation(page, task.title); await selectInvestigation(page, task.title);
const panel = page.getByLabel("调查实时展示", { exact: true }); const panel = page.getByLabel("调查实时展示", { exact: true });
await expect(panel.getByText("实时跟随", { exact: true })).toBeVisible();
const collapse = panel.getByRole("button", { name: "收起展示", exact: true }); const collapse = panel.getByRole("button", { name: "收起展示", exact: true });
await collapse.focus(); await collapse.focus();
await mock.emit(task, chart(1)); await mock.emit(task, chart(1));
let popup = page.getByRole("dialog", { name: "合成压力分析 1", exact: true }); const manager = page.getByRole("dialog", { name: "图表管理", exact: true });
await expect(popup).toBeVisible(); await expect(manager).toBeVisible();
await expect(popup.getByRole("img", { name: "合成压力分析 1", exact: true })).toBeVisible();
await expect(collapse).toBeFocused(); await expect(collapse).toBeFocused();
const heading = popup.getByLabel("窗口标题栏,可用方向键移动,Shift 加方向键调整大小"); await expect(page.locator("[data-floating-window]")).toHaveCount(1);
await heading.focus(); await manager.locator(".floating-window-heading").focus();
await page.keyboard.press("ArrowRight"); await page.keyboard.press("ArrowRight");
const moved = (await popup.boundingBox())!; const moved = await manager.boundingBox();
await mock.emit(task, chart(2)); await mock.emit(task, chart(2));
popup = page.getByRole("dialog", { name: "合成压力分析 2", exact: true }); await expect(manager.getByRole("img", { name: "合成压力分析 2", exact: true })).toBeVisible();
await expect(popup).toBeVisible(); expect(await manager.boundingBox()).toEqual(moved);
expect((await popup.boundingBox())!.x).toBe(moved.x);
await page.keyboard.press("Escape"); await page.keyboard.press("Escape");
await expect(page.locator("[data-floating-window]")).toHaveCount(0); await expect(page.locator("[data-floating-window]")).toHaveCount(0);
await mock.emit(task, { action: "zoom_to_map", center: [121.48, 31.1], zoom: 13 });
await expect(panel.getByRole("combobox", { name: "切换展示动作" })).toContainText("3 个动作");
await expect(page.locator("[data-floating-window]")).toHaveCount(0);
await panel.getByRole("button", { name: "放大查看 合成压力分析 2", exact: true }).click();
await expect(popup).toBeVisible();
await testInfo.attach("live-chart-window", { body: await page.screenshot({ path: testInfo.outputPath("live-chart-window.png") }), contentType: "image/png" });
await popup.getByRole("button", { name: "关闭窗口", exact: true }).click();
await mock.emit(task, chart(3)); await mock.emit(task, chart(3));
await expect(page.getByRole("dialog", { name: "合成压力分析 3", exact: true })).toBeVisible(); await expect(panel.getByRole("img", { name: "合成压力分析 3", exact: true })).toBeVisible();
await mock.emit(task, { action: "close_chart", chart_id: "pressure" });
await expect(page.locator("[data-floating-window]")).toHaveCount(0);
await mock.emit(task, chart(4));
await expect(page.getByRole("dialog", { name: "合成压力分析 4", exact: true })).toBeVisible();
await page.getByRole("region", { name: "工况任务", exact: true }).getByText(other.title, { exact: true }).first().click();
await expect(page.locator("[data-floating-window]")).toHaveCount(0); await expect(page.locator("[data-floating-window]")).toHaveCount(0);
await panel.getByRole("button", { name: "打开图表管理", exact: true }).click();
await expect(manager.getByRole("img", { name: "合成压力分析 3", exact: true })).toBeVisible();
await testInfo.attach("live-chart-manager", { body: await manager.screenshot(), contentType: "image/png" });
expect(mock.mutations).toEqual([]); expect(mock.mutations).toEqual([]);
}); });
test("playback windows follow saved snapshots while live results remain separate", async ({ page }, testInfo) => { test("playback restores charts without opening windows while live results remain separate", async ({ page }, testInfo) => {
const task = investigationFixture(1, "图表回放核查"); const task = investigationFixture(1, "图表回放核查");
const commands = [chart(1), { action: "zoom_to_map", center: [121.48, 31.1], zoom: 13 }, chart(2), { action: "close_chart", chart_id: "pressure" }]; const commands = [chart(1), { action: "zoom_to_map", center: [121.48, 31.1], zoom: 13 }, chart(2), { action: "close_chart", chart_id: "pressure" }];
const saved = commands.map((command, index) => ({ task_id: task.task_id, event_seq: index + 1, const saved = commands.map((command, index) => ({ task_id: task.task_id, event_seq: index + 1,
@@ -237,6 +265,8 @@ test("playback windows follow saved snapshots while live results remain separate
await expect(page.locator("[data-floating-window]")).toHaveCount(0); await expect(page.locator("[data-floating-window]")).toHaveCount(0);
await panel.getByRole("button", { name: "播放历史", exact: true }).click(); await panel.getByRole("button", { name: "播放历史", exact: true }).click();
await panel.getByRole("button", { name: "暂停历史播放", exact: true }).press("Enter"); await panel.getByRole("button", { name: "暂停历史播放", exact: true }).press("Enter");
await expect(page.locator("[data-floating-window]")).toHaveCount(0);
await openChild(page, "合成压力分析 1", false);
const popup = page.getByRole("dialog", { name: "合成压力分析 1", exact: true }); const popup = page.getByRole("dialog", { name: "合成压力分析 1", exact: true });
await expect(popup).toBeVisible(); await expect(popup).toBeVisible();
await mock.emit(task, chart(3)); await mock.emit(task, chart(3));
@@ -249,17 +279,19 @@ test("playback windows follow saved snapshots while live results remain separate
await expect(panel.getByRole("combobox", { name: "切换展示动作" })).toContainText("2."); await expect(panel.getByRole("combobox", { name: "切换展示动作" })).toContainText("2.");
await expect(page.locator("[data-floating-window]")).toHaveCount(0); await expect(page.locator("[data-floating-window]")).toHaveCount(0);
await page.keyboard.press("ArrowRight"); await page.keyboard.press("ArrowRight");
await expect(page.getByRole("dialog", { name: "合成压力分析 2", exact: true })).toBeVisible(); await expect(page.locator("[data-floating-window]")).toHaveCount(0);
await expect(panel.getByRole("img", { name: "合成压力分析 2", exact: true })).toBeVisible();
await expect(progress).toBeFocused(); await expect(progress).toBeFocused();
await testInfo.attach("playback-chart-window", { body: await page.screenshot({ path: testInfo.outputPath("playback-chart-window.png") }), contentType: "image/png" }); await testInfo.attach("playback-chart-window", { body: await page.screenshot({ path: testInfo.outputPath("playback-chart-window.png") }), contentType: "image/png" });
await page.keyboard.press("ArrowRight"); await page.keyboard.press("ArrowRight");
await expect(page.locator("[data-floating-window]")).toHaveCount(0); await expect(page.locator("[data-floating-window]")).toHaveCount(0);
await panel.getByRole("button", { name: "回到当前", exact: true }).click(); await panel.getByRole("button", { name: "回到当前", exact: true }).click();
await expect(page.getByRole("dialog", { name: "合成压力分析 3", exact: true })).toBeVisible(); await expect(panel.getByRole("img", { name: "合成压力分析 3", exact: true })).toBeVisible();
await expect(page.locator("[data-floating-window]")).toHaveCount(0);
expect(mock.mutations).toEqual([]); expect(mock.mutations).toEqual([]);
}); });
test("structured analysis charts open responsive windows in local preview and history", async ({ page }, testInfo) => { test("structured analysis charts retain manual enlargement without opening during history", async ({ page }, testInfo) => {
const task = investigationFixture(1, "结构化分析展示核查"); const task = investigationFixture(1, "结构化分析展示核查");
const mock = await mockInvestigationPresentation(page, [task]); const mock = await mockInvestigationPresentation(page, [task]);
await selectInvestigation(page, task.title); await selectInvestigation(page, task.title);
@@ -272,6 +304,9 @@ test("structured analysis charts open responsive windows in local preview and hi
} }
} })); } }));
await tools.getByRole("button", { name: "执行本地预览", exact: true }).click(); await tools.getByRole("button", { name: "执行本地预览", exact: true }).click();
const preview = page.getByLabel("本地预览展示", { exact: true });
await expect(page.locator("[data-floating-window]")).toHaveCount(0);
await preview.getByRole("button", { name: "放大查看 合成压力分析 1", exact: true }).click();
const popup = page.getByRole("dialog", { name: "合成压力分析 1", exact: true }); const popup = page.getByRole("dialog", { name: "合成压力分析 1", exact: true });
await expect(popup).toBeVisible(); await expect(popup).toBeVisible();
await expect(popup.getByRole("img", { name: "合成压力分析 1", exact: true })).toBeVisible(); await expect(popup.getByRole("img", { name: "合成压力分析 1", exact: true })).toBeVisible();
@@ -285,9 +320,8 @@ test("structured analysis charts open responsive windows in local preview and hi
} }
await popup.getByRole("button", { name: "关闭窗口", exact: true }).click(); await popup.getByRole("button", { name: "关闭窗口", exact: true }).click();
await page.setViewportSize({ width: 1440, height: 900 }); await page.setViewportSize({ width: 1440, height: 900 });
const preview = page.getByLabel("本地预览展示", { exact: true });
await preview.getByRole("button", { name: "播放历史", exact: true }).click(); await preview.getByRole("button", { name: "播放历史", exact: true }).click();
await expect(popup).toBeVisible(); await expect(page.locator("[data-floating-window]")).toHaveCount(0);
expect(mock.mutations).toEqual([]); expect(mock.mutations).toEqual([]);
}); });
@@ -296,6 +330,7 @@ test("live charts fit desktop and narrow windows without outer scrollbars", asyn
const mock = await mockInvestigationPresentation(page, [task]); const mock = await mockInvestigationPresentation(page, [task]);
await selectInvestigation(page, task.title); await selectInvestigation(page, task.title);
await mock.emit(task, chart(1)); await mock.emit(task, chart(1));
await openChild(page, "合成压力分析 1");
const popup = page.getByRole("dialog", { name: "合成压力分析 1", exact: true }); const popup = page.getByRole("dialog", { name: "合成压力分析 1", exact: true });
const content = popup.locator("[data-floating-window-content]"); const content = popup.locator("[data-floating-window-content]");
await expect(popup.getByRole("img", { name: "合成压力分析 1", exact: true })).toBeVisible(); await expect(popup.getByRole("img", { name: "合成压力分析 1", exact: true })).toBeVisible();
@@ -330,6 +365,7 @@ test("SCADA chart axes and legends fit every nested container", async ({ page },
series: [{ name: "测点 123748(实测回放)", data: [26.2, 25.7, 23.5] }, series: [{ name: "测点 123748(实测回放)", data: [26.2, 25.7, 23.5] },
{ name: "测点 159673(实测回放)", data: [24.9, 24.87, 21.6] }] { name: "测点 159673(实测回放)", data: [24.9, 24.87, 21.6] }]
} }); } });
await openChild(page, "SCADA 回放压力:异常前后(2026-07-16)");
const popup = page.getByRole("dialog", { name: "SCADA 回放压力:异常前后(2026-07-16)", exact: true }); const popup = page.getByRole("dialog", { name: "SCADA 回放压力:异常前后(2026-07-16)", exact: true });
await expect(popup).toBeVisible(); await expect(popup).toBeVisible();
await expect.poll(() => popup.evaluate((node) => Array.from(node.querySelectorAll<HTMLElement>("*")) await expect.poll(() => popup.evaluate((node) => Array.from(node.querySelectorAll<HTMLElement>("*"))
@@ -362,6 +398,8 @@ test("SCADA pressure and flow keep full comparison data without timezone wording
...Array.from({ length: 7 }, (_, day) => ({ name: `参考 07-${String(9 + day).padStart(2, "0")}`, type: "scatter", ...Array.from({ length: 7 }, (_, day) => ({ name: `参考 07-${String(9 + day).padStart(2, "0")}`, type: "scatter",
data: labels.map((_, i) => i < 10 ? null : 28 + day) }))] data: labels.map((_, i) => i < 10 ? null : 28 + day) }))]
} }); } });
await expect(page.getByLabel("调查实时展示", { exact: true }).getByRole("button", { name: `放大查看 回放${metric}:${device}(${unit},07-16 04:45–07:45)`, exact: true })).toBeVisible();
await openChild(page, `回放${metric}:${device}(${unit},07-16 04:45–07:45)`);
const popup = page.getByRole("dialog", { name: `回放${metric}:${device}(${unit},07-16 04:45–07:45)`, exact: true }); const popup = page.getByRole("dialog", { name: `回放${metric}:${device}(${unit},07-16 04:45–07:45)`, exact: true });
await expect(popup).toBeVisible(); await expect(popup).toBeVisible();
await expect(popup.getByText(/上海时间|UTC\+8|Asia\/Shanghai/)).toHaveCount(0); await expect(popup.getByText(/上海时间|UTC\+8|Asia\/Shanghai/)).toHaveCount(0);
+42 -12
View File
@@ -12,6 +12,7 @@ test("live investigation charts remain visible while Dev is open", async ({ page
chart: { title: "实时合成压力核查", chart_type: "line", x_data: ["08:00", "08:15"], series: [{ name: "压力水头(m)", data: [32, 28] }] } }); chart: { title: "实时合成压力核查", chart_type: "line", x_data: ["08:00", "08:15"], series: [{ name: "压力水头(m)", data: [32, 28] }] } });
await expect(page.locator('[data-investigation-chart="live-pressure"]')).toBeVisible(); await expect(page.locator('[data-investigation-chart="live-pressure"]')).toBeVisible();
await expect(page.getByLabel("调查实时展示", { exact: true })).not.toContainText("图表当前不可见"); await expect(page.getByLabel("调查实时展示", { exact: true })).not.toContainText("图表当前不可见");
await page.getByRole("dialog", { name: "图表管理", exact: true }).getByRole("button", { name: "关闭窗口", exact: true }).click();
await page.getByRole("button", { name: "收起展示", exact: true }).click(); await page.getByRole("button", { name: "收起展示", exact: true }).click();
await mock.emit(task, { action: "open_chart", chart_id: "while-collapsed", evidence_refs: ["dev:synthetic"], await mock.emit(task, { action: "open_chart", chart_id: "while-collapsed", evidence_refs: ["dev:synthetic"],
chart: { title: "收起期间的合成图表", chart_type: "line", x_data: ["08:00"], series: [{ name: "压力水头(m)", data: [32] }] } }); chart: { title: "收起期间的合成图表", chart_type: "line", x_data: ["08:00"], series: [{ name: "压力水头(m)", data: [32] }] } });
@@ -198,7 +199,8 @@ test("JSONL cases switch directly, reset validation and remove the previous loca
for (const [label, error] of [ for (const [label, error] of [
["错误:JSON 语法", "第 2 行不是合法 JSON"], ["错误:JSON 语法", "第 2 行不是合法 JSON"],
["错误:不支持的动作", "第 1 行展示指令不合法"], ["错误:不支持的动作", "第 1 行展示指令不合法"],
["错误:图表数据不齐", "第 1 行展示指令不合法"] ["错误:图表数据不齐", "第 1 行展示指令不合法"],
["错误:同期时刻错位", "换算为 UTC+8 后须与 x_data"]
]) { ]) {
await choose(label); await choose(label);
await tools.getByRole("button", { name: "校验 JSONL", exact: true }).click(); await tools.getByRole("button", { name: "校验 JSONL", exact: true }).click();
@@ -223,7 +225,7 @@ test("JSONL cases preview tables, charts and map overlays without an investigati
await expect(cases).toBeEnabled(); await expect(cases).toBeEnabled();
await cases.click(); await cases.click();
await expect(page.getByRole("listbox")).toBeVisible(); await expect(page.getByRole("listbox")).toBeVisible();
await expect(page.getByRole("option")).toHaveCount(14); await expect(page.getByRole("option")).toHaveCount(15);
await page.getByRole("button", { name: "关闭通知", exact: true }).evaluateAll((buttons) => buttons.forEach((button) => (button as HTMLButtonElement).click())); await page.getByRole("button", { name: "关闭通知", exact: true }).evaluateAll((buttons) => buttons.forEach((button) => (button as HTMLButtonElement).click()));
await testInfo.attach("case-dropdown-without-task", { await testInfo.attach("case-dropdown-without-task", {
body: await page.screenshot({ path: testInfo.outputPath("dropdown-without-task.png") }), contentType: "image/png" body: await page.screenshot({ path: testInfo.outputPath("dropdown-without-task.png") }), contentType: "image/png"
@@ -391,9 +393,10 @@ test("individual charts float, move and resize while the preview stays in place"
await page.mouse.down(); await page.mouse.down();
await page.mouse.move(handle.x + 88, handle.y + 58, { steps: 8 }); await page.mouse.move(handle.x + 88, handle.y + 58, { steps: 8 });
const resizedPlot = firstPlot.locator("svg"); const resizedPlot = firstPlot.locator("svg");
await expect(first).toHaveAttribute("data-floating-window-resizing", "true");
await page.mouse.up();
await expect.poll(async () => Math.abs(Number(await resizedPlot.getAttribute("width")) - (await firstPlot.boundingBox())!.width)).toBeLessThan(1); await expect.poll(async () => Math.abs(Number(await resizedPlot.getAttribute("width")) - (await firstPlot.boundingBox())!.width)).toBeLessThan(1);
await expect.poll(async () => Math.abs(Number(await resizedPlot.getAttribute("height")) - (await firstPlot.boundingBox())!.height)).toBeLessThan(1); await expect.poll(async () => Math.abs(Number(await resizedPlot.getAttribute("height")) - (await firstPlot.boundingBox())!.height)).toBeLessThan(1);
await page.mouse.up();
await expect.poll(async () => (await first.boundingBox())!.width - beforeResize.width).toBeGreaterThan(70); await expect.poll(async () => (await first.boundingBox())!.width - beforeResize.width).toBeGreaterThan(70);
await expect.poll(async () => (await first.boundingBox())!.height - beforeResize.height).toBeGreaterThan(40); await expect.poll(async () => (await first.boundingBox())!.height - beforeResize.height).toBeGreaterThan(40);
expect(await panel.boundingBox()).toEqual(original); expect(await panel.boundingBox()).toEqual(original);
@@ -544,7 +547,7 @@ test("complex investigation cases show multi-chart and multi-table results witho
await expect(page.getByRole("option", { name: "地图定位与高亮", exact: true })).toHaveCount(0); await expect(page.getByRole("option", { name: "地图定位与高亮", exact: true })).toHaveCount(0);
await page.getByRole("option", { name: "复杂调查:多图多表联动", exact: true }).click(); await page.getByRole("option", { name: "复杂调查:多图多表联动", exact: true }).click();
await tools.getByRole("button", { name: "执行本地预览", exact: true }).click(); await tools.getByRole("button", { name: "执行本地预览", exact: true }).click();
await expect(tools.getByRole("status")).toContainText("本地预览完成:2 条展示指令、4 个界面块"); await expect(tools.getByRole("status")).toContainText("本地预览完成:14 条展示指令、2 个界面块", { timeout: 20_000 });
const panel = page.getByLabel("本地预览展示", { exact: true }); const panel = page.getByLabel("本地预览展示", { exact: true });
await expect(panel.getByRole("button", { name: "放大查看 合成全天压力对比", exact: true })).toBeVisible(); await expect(panel.getByRole("button", { name: "放大查看 合成全天压力对比", exact: true })).toBeVisible();
await expect(panel.getByRole("button", { name: "放大查看 合成分区流量趋势", exact: true })).toHaveCount(1); await expect(panel.getByRole("button", { name: "放大查看 合成分区流量趋势", exact: true })).toHaveCount(1);
@@ -566,7 +569,7 @@ test("expanded bars fill the window while preserving legends, type size and the
await tools.getByRole("combobox", { name: "JSONL 测试案例", exact: true }).click(); await tools.getByRole("combobox", { name: "JSONL 测试案例", exact: true }).click();
await page.getByRole("option", { name: "复杂调查:多图多表联动", exact: true }).click(); await page.getByRole("option", { name: "复杂调查:多图多表联动", exact: true }).click();
await tools.getByRole("button", { name: "执行本地预览", exact: true }).click(); await tools.getByRole("button", { name: "执行本地预览", exact: true }).click();
await expect(tools.getByRole("status")).toContainText("本地预览完成"); await expect(tools.getByRole("status")).toContainText("本地预览完成", { timeout: 20_000 });
await page.getByLabel("本地预览展示", { exact: true }).getByRole("button", { name: "放大查看 合成候选方案影响对比", exact: true }).click(); await page.getByLabel("本地预览展示", { exact: true }).getByRole("button", { name: "放大查看 合成候选方案影响对比", exact: true }).click();
const floating = page.getByRole("dialog", { name: "合成候选方案影响对比", exact: true }); const floating = page.getByRole("dialog", { name: "合成候选方案影响对比", exact: true });
const legend = floating.getByLabel("类别图例", { exact: true }); const legend = floating.getByLabel("类别图例", { exact: true });
@@ -579,20 +582,25 @@ test("expanded bars fill the window while preserving legends, type size and the
const svg = plot.locator("svg"); const svg = plot.locator("svg");
await expect.poll(async () => Number(await svg.getAttribute("width"))).toBe((await plot.boundingBox())!.width); await expect.poll(async () => Number(await svg.getAttribute("width"))).toBe((await plot.boundingBox())!.width);
const drawing = (await plot.boundingBox())!; const drawing = (await plot.boundingBox())!;
const sidebar = (await floating.locator("aside").boundingBox())!; const footer = floating.getByLabel("图表图例");
const plotViewport = floating.getByRole("group", { name: /按回车查看数据点/ });
const initialViewport = (await plotViewport.boundingBox())!;
const initialWindowHeight = (await floating.boundingBox())!.height;
expect((await footer.boundingBox())!.y).toBeGreaterThanOrEqual(initialViewport.y + initialViewport.height);
const instance = await plot.locator(".echarts-for-react").getAttribute("_echarts_instance_"); const instance = await plot.locator(".echarts-for-react").getAttribute("_echarts_instance_");
const fontSize = await svg.locator("text").first().evaluate((element) => getComputedStyle(element).fontSize); const fontSize = await svg.locator("text").first().evaluate((element) => getComputedStyle(element).fontSize);
await floating.locator(".floating-window-heading").focus(); await floating.locator(".floating-window-heading").focus();
await page.keyboard.press("Shift+ArrowRight"); await page.keyboard.press("Shift+ArrowRight");
await page.keyboard.press("Shift+ArrowDown"); await page.keyboard.press("Shift+ArrowDown");
await expect.poll(async () => (await plot.boundingBox())!.width).toBe(drawing.width + 16); await expect.poll(async () => (await plot.boundingBox())!.width).toBe(drawing.width + 16);
await expect.poll(async () => (await plot.boundingBox())!.height).toBe(drawing.height + 16); await expect.poll(async () => (await floating.boundingBox())!.height).toBe(initialWindowHeight + 16);
expect((await plotViewport.boundingBox())!.height).toBeGreaterThanOrEqual(initialViewport.height);
await floating.getByRole("button", { name: "最大化窗口", exact: true }).click(); await floating.getByRole("button", { name: "最大化窗口", exact: true }).click();
await expect.poll(async () => (await plot.boundingBox())!.width).toBeGreaterThan(drawing.width + 400); await expect.poll(async () => (await plot.boundingBox())!.width).toBeGreaterThan(drawing.width + 400);
await expect.poll(async () => (await plot.boundingBox())!.height).toBeGreaterThan(drawing.height + 100); await expect.poll(async () => (await plot.boundingBox())!.height).toBeGreaterThan(drawing.height + 100);
await expect.poll(async () => Number(await svg.getAttribute("width"))).toBe((await plot.boundingBox())!.width); await expect.poll(async () => Number(await svg.getAttribute("width"))).toBe((await plot.boundingBox())!.width);
await expect.poll(async () => Number(await svg.getAttribute("height"))).toBe((await plot.boundingBox())!.height); await expect.poll(async () => Number(await svg.getAttribute("height"))).toBe((await plot.boundingBox())!.height);
expect((await floating.locator("aside").boundingBox())!.width).toBe(sidebar.width); expect((await footer.boundingBox())!.y).toBeGreaterThanOrEqual((await plotViewport.boundingBox())!.y + (await plotViewport.boundingBox())!.height);
expect(await svg.locator("text").first().evaluate((element) => getComputedStyle(element).fontSize)).toBe(fontSize); expect(await svg.locator("text").first().evaluate((element) => getComputedStyle(element).fontSize)).toBe(fontSize);
await expect(plot.locator(".echarts-for-react")).toHaveAttribute("_echarts_instance_", instance!); await expect(plot.locator(".echarts-for-react")).toHaveAttribute("_echarts_instance_", instance!);
await page.getByRole("button", { name: "关闭通知", exact: true }).evaluateAll((buttons) => buttons.forEach((button) => (button as HTMLButtonElement).click())); await page.getByRole("button", { name: "关闭通知", exact: true }).evaluateAll((buttons) => buttons.forEach((button) => (button as HTMLButtonElement).click()));
@@ -609,7 +617,12 @@ test("expanded bars fill the window while preserving legends, type size and the
await expect(floating.getByRole("table")).toContainText("合成方案 E:待补充资料"); await expect(floating.getByRole("table")).toContainText("合成方案 E:待补充资料");
}); });
test("full-day charts expose all eight series and retain legend selection while resizing", async ({ page }) => { test("full-day chart examples group objects and retain their eight series while resizing", async ({ page }, testInfo) => {
test.setTimeout(60_000);
await page.route("**/@fs/**/noto-sans-sc-*.woff2", (route) => route.fulfill({
path: `node_modules/@fontsource-variable/noto-sans-sc/files/${new URL(route.request().url()).pathname.split("/").at(-1)}`,
contentType: "font/woff2"
}));
await mockInvestigationPresentation(page, []); await mockInvestigationPresentation(page, []);
await page.goto("/", { waitUntil: "domcontentloaded" }); await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "切换地图 Dev Panel" }).click(); await page.getByRole("button", { name: "切换地图 Dev Panel" }).click();
@@ -617,23 +630,37 @@ test("full-day charts expose all eight series and retain legend selection while
await tools.getByRole("combobox", { name: "JSONL 测试案例", exact: true }).click(); await tools.getByRole("combobox", { name: "JSONL 测试案例", exact: true }).click();
await page.getByRole("option", { name: "复杂图表:多测点与长时序", exact: true }).click(); await page.getByRole("option", { name: "复杂图表:多测点与长时序", exact: true }).click();
await tools.getByRole("button", { name: "执行本地预览", exact: true }).click(); await tools.getByRole("button", { name: "执行本地预览", exact: true }).click();
await expect(tools.getByRole("status")).toContainText("本地预览完成"); await expect(tools.getByRole("status")).toContainText("本地预览完成", { timeout: 20_000 });
await page.getByLabel("本地预览展示", { exact: true }).getByRole("button", { name: "放大查看 合成全天压力对比", exact: true }).click(); await page.getByLabel("本地预览展示", { exact: true }).getByRole("button", { name: "放大查看 合成全天压力对比", exact: true }).click();
const floating = page.getByRole("dialog", { name: "合成全天压力对比", exact: true }); const floating = page.getByRole("dialog", { name: "合成全天压力对比", exact: true });
await floating.locator(".floating-window-heading").click({ position: { x: 100, y: 20 } });
const objects = floating.getByLabel("选择图表对象");
await expect(objects.getByRole("button")).toHaveCount(8);
const object = INVESTIGATION_DEV_NETWORK.nodeAnchors[1].id;
await objects.getByRole("button", { name: `切换对象 ${object}`, exact: true }).click();
await expect(objects.getByRole("button", { pressed: true })).toHaveCount(1);
const series = floating.getByLabel("显示的数据序列").getByRole("button"); const series = floating.getByLabel("显示的数据序列").getByRole("button");
await expect(series).toHaveCount(8); await expect(series).toHaveCount(8);
await series.last().click(); await series.last().click();
await expect(series.last()).toHaveAttribute("aria-pressed", "false"); await expect(series.last()).toHaveAttribute("aria-pressed", "false");
const plot = floating.getByRole("img", { name: "合成全天压力对比", exact: true }); const plot = floating.getByRole("img", { name: `${object} 合成全天压力对比`, exact: true });
const drawing = (await plot.boundingBox())!; const drawing = (await plot.boundingBox())!;
const windowHeight = (await floating.boundingBox())!.height;
await floating.locator(".floating-window-heading").focus(); await floating.locator(".floating-window-heading").focus();
await page.keyboard.press("Shift+ArrowDown"); await page.keyboard.press("Shift+ArrowDown");
await expect.poll(async () => (await plot.boundingBox())!.height).toBe(drawing.height + 16); await expect.poll(async () => (await floating.boundingBox())!.height).toBe(windowHeight + 16);
expect((await plot.boundingBox())!.height).toBeGreaterThanOrEqual(drawing.height);
await expect(series.last()).toHaveAttribute("aria-pressed", "false"); await expect(series.last()).toHaveAttribute("aria-pressed", "false");
await floating.getByRole("button", { name: "显示全部序列", exact: true }).click(); await floating.getByRole("button", { name: "显示全部序列", exact: true }).click();
await expect(series.last()).toHaveAttribute("aria-pressed", "true"); await expect(series.last()).toHaveAttribute("aria-pressed", "true");
await floating.getByRole("tab", { name: "数据", exact: true }).click(); await floating.getByRole("tab", { name: "数据", exact: true }).click();
await expect(floating.locator("tbody tr")).toHaveCount(96); await expect(floating.locator("tbody tr")).toHaveCount(96);
await expect(floating.getByRole("table")).toContainText("2026-07-16 23:45:00+08:00");
await floating.getByRole("tab", { name: "复合", exact: true }).click();
await floating.getByRole("button", { name: "最大化窗口", exact: true }).click();
await expect.poll(async () => (await plot.boundingBox())!.height).toBeGreaterThan(drawing.height + 100);
await page.evaluate(() => document.fonts.ready);
await testInfo.attach("migrated-chart-example", { body: await floating.screenshot({ path: testInfo.outputPath("migrated-chart-example.png") }), contentType: "image/png" });
}); });
for (const reducedMotion of ["no-preference", "reduce"] as const) { for (const reducedMotion of ["no-preference", "reduce"] as const) {
@@ -746,6 +773,9 @@ test("live actions continue recording without interrupting a historical view and
await expect(panel.getByRole("group", { name: "压力快照 1", exact: true })).toBeVisible(); await expect(panel.getByRole("group", { name: "压力快照 1", exact: true })).toBeVisible();
const switcher = panel.getByRole("combobox", { name: "切换展示动作" }); const switcher = panel.getByRole("combobox", { name: "切换展示动作" });
await expect(switcher).toContainText("压力快照 1"); await expect(switcher).toContainText("压力快照 1");
const manager = page.getByRole("dialog", { name: "图表管理", exact: true });
await expect(manager).toBeVisible();
await manager.getByRole("button", { name: "关闭窗口", exact: true }).click();
await switcher.click(); await switcher.click();
await expect(page.getByRole("option")).toHaveCount(1); await expect(page.getByRole("option")).toHaveCount(1);
await expect(page.getByRole("option", { name: /当前(最新).*压力快照 1/ })).toBeVisible(); await expect(page.getByRole("option", { name: /当前(最新).*压力快照 1/ })).toBeVisible();
@@ -72,6 +72,7 @@ const previousReport: NonNullable<OperationalTask["latest_report"]> = {
}; };
const task: OperationalTask = { const task: OperationalTask = {
execution_scope: "production",
project_id: projectId, project_id: projectId,
task_id: taskId, task_id: taskId,
task_type: "event_investigation", task_type: "event_investigation",
@@ -0,0 +1,174 @@
import { expect, test, type Locator, type Page } from "@playwright/test";
import { basename, resolve } from "node:path";
import { investigationFixture, mockInvestigationPresentation, selectInvestigation, type SavedPresentationEvent } from "./support/mock-investigation-presentation";
const pressureIds = ["170486", "175618", "159673", "135160", "174754", "123781", "142493"];
const flowIds = ["541061", "542017", "486033"];
const times = Array.from({ length: 13 }, (_, index) => `${String(5 + Math.floor(index / 4)).padStart(2, "0")}:${String(index % 4 * 15).padStart(2, "0")}`);
const pressureTitle = "压力当前曲线与前七天同期对比(m)";
const flowTitle = "流量当前曲线与前七天同期对比(m³/h)";
// These synthetic fixtures preserve the naming shape found in read-only persisted
// SCADA task charts. Values, timestamps and references are self-contained test data.
function comparison(id: string, flow = false, dateOnlyHistory = false) {
const metric = flow ? "流量" : "压力";
const unit = flow ? "m³/h" : "m";
const offset = (flow ? flowIds : pressureIds).indexOf(id);
const base = (flow ? 100 : 32) + offset;
return { action: "open_chart", chart_id: `legacy-${flow ? "flow" : "pressure"}-${id}`, evidence_refs: [`synthetic:legacy-shape:${id}`],
chart: { title: dateOnlyHistory ? `测点 ${id} ${metric}三小时与历史对照(${unit})` : `${metric}测点 ${id} 当前曲线与前七天同期对比(${unit})`,
chart_type: dateOnlyHistory ? "line" : "time_comparison", x_axis_name: "时间", y_axis_name: `${metric}(${unit})`, x_data: times,
series: [{ name: `${id} 当日`, type: "line", data: times.map((_, index) => base - index / 4),
sample_times: times.map((time) => `2026-07-16T${time}:00+08:00`) },
...Array.from({ length: 7 }, (_, day) => ({ name: `${dateOnlyHistory ? "" : `${id} `}07-${String(9 + day).padStart(2, "0")}`, type: "scatter",
data: times.map((_, index) => index < 10 ? null : base + day / 2),
sample_times: times.map((time, index) => index < 10 ? null : `2026-07-${String(9 + day).padStart(2, "0")}T${time}:00+08:00`) }))]
} };
}
const trend = (flow: boolean) => ({ action: "open_chart", chart_id: `trend-${flow ? "flow" : "pressure"}`, evidence_refs: ["synthetic:legacy-trend"],
chart: { title: `${flow ? "流量" : "压力"}测点当前三小时总览`, chart_type: "line", x_data: times,
series: [{ name: "合成当前趋势", data: times.map((_, index) => (flow ? 100 : 32) - index / 4) }] } });
function savedTask(index: number, title: string, commands: unknown[]) {
const task = investigationFixture(index, title);
const run = task.active_run!;
task.status = "waiting_human";
task.lifecycle_status = "waiting_human";
task.latest_run = { ...run, status: "succeeded", finished_at: task.updated_at };
task.active_run = null;
const events: SavedPresentationEvent[] = commands.map((command, sequence) => ({ task_id: task.task_id,
run_id: run.run_id, event_seq: sequence + 1, execution_generation: 1, occurred_at: task.created_at, command }));
return { task, events };
}
const managed = (manager: Locator, title: string) => manager.locator(`[data-managed-chart][data-chart-title="${title}"]`);
const plot = (article: Locator) => article.locator('[data-chart-view] div[role="img"]:not(.echarts-for-react)');
async function locate(page: Page, manager: Locator, title: string) {
await manager.getByRole("combobox", { name: "快速定位图表", exact: true }).click();
await page.getByRole("option").filter({ hasText: title }).click();
await expect(managed(manager, title)).toBeVisible();
}
async function expectBottomLegend(article: Locator) {
const drawing = (await plot(article).boundingBox())!;
const legend = (await article.getByLabel("图表图例", { exact: true }).boundingBox())!;
const bounds = (await article.boundingBox())!;
expect(legend.y).toBeGreaterThanOrEqual(drawing.y + drawing.height - 1);
expect(legend.y + legend.height).toBeLessThanOrEqual(bounds.y + bounds.height + 1);
expect(drawing.height).toBeGreaterThanOrEqual(100);
}
test.use({ reducedMotion: "reduce", hasTouch: true, screenshot: "only-on-failure" });
test.setTimeout(90_000);
test.beforeEach(async ({ page }) => {
await page.route("**/@fs/**/noto-sans-sc-*.woff2", (route) => route.fulfill({
path: resolve("node_modules/@fontsource-variable/noto-sans-sc/files", basename(new URL(route.request().url()).pathname)), contentType: "font/woff2"
}));
});
test("saved waiting-human SCADA charts restore silently into two trends and two object groups", async ({ page }, testInfo) => {
const { task, events } = savedTask(21, "合成历史 SCADA 旧标题恢复", [trend(false), trend(true),
...pressureIds.map((id) => comparison(id)), ...flowIds.map((id) => comparison(id, true))]);
const mock = await mockInvestigationPresentation(page, [task], events);
await selectInvestigation(page, task.title);
const panel = page.getByLabel("调查实时展示", { exact: true });
await expect(panel.locator("[data-investigation-chart]")).toHaveCount(12);
await expect(page.locator("[data-floating-window]")).toHaveCount(0);
await panel.getByRole("button", { name: "打开图表管理", exact: true }).click();
const manager = page.getByRole("dialog", { name: "图表管理", exact: true });
await expect(manager.locator("[data-managed-chart]")).toHaveCount(4);
await manager.getByRole("button", { name: "关闭窗口", exact: true }).click();
await page.reload({ waitUntil: "domcontentloaded" });
await selectInvestigation(page, task.title);
await expect(panel.locator("[data-investigation-chart]")).toHaveCount(12);
await expect(page.locator("[data-floating-window]")).toHaveCount(0);
await panel.getByRole("button", { name: "打开图表管理", exact: true }).click();
await expect(manager.locator("[data-managed-chart]")).toHaveCount(4);
await expect(manager).toContainText("4 张图表");
const pressure = managed(manager, pressureTitle);
const flow = managed(manager, flowTitle);
await expect(pressure).toBeVisible();
await expect(flow).toBeVisible();
await expect(managed(manager, "压力测点当前三小时总览")).toBeVisible();
await expect(managed(manager, "流量测点当前三小时总览")).toBeVisible();
const pressureObjects = pressure.locator('aside[aria-label="选择图表对象"]');
const flowObjects = flow.locator('aside[aria-label="选择图表对象"]');
await expect(pressureObjects.getByRole("button")).toHaveCount(7);
await expect(flowObjects.getByRole("button")).toHaveCount(3);
await pressureObjects.getByRole("button", { name: "切换对象 175618", exact: true }).click();
await expect(plot(pressure)).toHaveAttribute("aria-label", "压力测点 175618 当前曲线与前七天同期对比(m)");
const legend = pressure.getByLabel("图表图例", { exact: true });
await expect(legend.getByRole("button")).toHaveCount(8);
await expect(legend.getByRole("button", { name: "175618 当日", exact: true })).toBeVisible();
await expectBottomLegend(pressure);
await flowObjects.getByRole("button", { name: "切换对象 486033", exact: true }).click();
await expect(plot(flow)).toHaveAttribute("aria-label", "流量测点 486033 当前曲线与前七天同期对比(m³/h)");
await expectBottomLegend(flow);
await pressure.getByRole("tab", { name: "数据", exact: true }).click();
const table = pressure.getByRole("table");
await expect(table.getByRole("row")).toHaveCount(14);
await expect(table.getByRole("row").last()).toContainText("2026-07-16 08:00:00+08:00");
await expect(table.getByRole("row").last()).toContainText("2026-07-09 08:00:00+08:00");
await expect(table.getByRole("row").last()).toContainText("30 (2026-07-16");
await pressure.getByRole("tab", { name: "复合", exact: true }).click();
await pressure.getByRole("button", { name: `放大查看 ${pressureTitle}`, exact: true }).click();
const child = page.getByRole("dialog", { name: pressureTitle, exact: true });
await expect(child).toBeVisible();
await expect(manager).toBeVisible();
await expect(page.locator("[data-floating-window]")).toHaveCount(2);
await child.getByRole("button", { name: "切换对象 142493", exact: true }).click();
await expect(child.getByRole("img", { name: "压力测点 142493 当前曲线与前七天同期对比(m)", exact: true })).toBeVisible();
await child.getByRole("button", { name: "关闭窗口", exact: true }).click();
await expect(page.locator("[data-floating-window]")).toHaveCount(1);
await page.evaluate(() => document.fonts.ready);
await testInfo.attach("legacy-scada-groups-desktop", { body: await manager.screenshot({ path: testInfo.outputPath("legacy-scada-groups-desktop.png") }), contentType: "image/png" });
for (const width of [375, 320]) {
await page.setViewportSize({ width, height: 812 });
await expect(manager.locator("[data-chart-layout]")).toHaveAttribute("data-chart-layout", "one");
await locate(page, manager, pressureTitle);
const id = width === 375 ? "175618" : "159673";
await pressure.getByRole("combobox", { name: "选择图表对象", exact: true }).tap();
await page.getByRole("option", { name: id, exact: true }).tap();
await expect(plot(pressure)).toHaveAttribute("aria-label", `压力测点 ${id} 当前曲线与前七天同期对比(m)`);
await expectBottomLegend(pressure);
expect(await manager.evaluate((node) => node.scrollWidth - node.clientWidth)).toBeLessThanOrEqual(1);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(width);
await testInfo.attach(`legacy-scada-groups-${width}`, { body: await manager.screenshot({ path: testInfo.outputPath(`legacy-scada-groups-${width}.png`) }), contentType: "image/png" });
await locate(page, manager, flowTitle);
await flow.getByRole("combobox", { name: "选择图表对象", exact: true }).tap();
await page.getByRole("option", { name: "541061", exact: true }).tap();
await expect(plot(flow)).toHaveAttribute("aria-label", "流量测点 541061 当前曲线与前七天同期对比(m³/h)");
expect(await manager.evaluate((node) => node.scrollWidth - node.clientWidth)).toBeLessThanOrEqual(1);
}
expect(mock.mutations).toEqual([]);
});
test("saved generic point titles group date-only historical scatter labels without changing sampling times", async ({ page }, testInfo) => {
const { task, events } = savedTask(22, "合成早期测点图恢复", [comparison("170486", false, true), comparison("175618", false, true)]);
const mock = await mockInvestigationPresentation(page, [task], events);
await selectInvestigation(page, task.title);
const panel = page.getByLabel("调查实时展示", { exact: true });
await expect(panel.locator("[data-investigation-chart]")).toHaveCount(2);
await expect(page.locator("[data-floating-window]")).toHaveCount(0);
await panel.getByRole("button", { name: "打开图表管理", exact: true }).click();
const manager = page.getByRole("dialog", { name: "图表管理", exact: true });
await expect(manager.locator("[data-managed-chart]")).toHaveCount(1);
const group = managed(manager, "压力三小时与历史对照(m)");
await expect(group.locator('aside[aria-label="选择图表对象"]').getByRole("button")).toHaveCount(2);
await group.getByRole("button", { name: "切换对象 175618", exact: true }).click();
await expect(plot(group)).toHaveAttribute("aria-label", "测点 175618 压力三小时与历史对照(m)");
const legend = group.getByLabel("图表图例", { exact: true });
await expect(legend.getByRole("button")).toHaveCount(8);
await expect(legend.getByRole("button", { name: "07-09", exact: true })).toBeVisible();
await expectBottomLegend(group);
await group.getByRole("tab", { name: "数据", exact: true }).click();
const row = group.getByRole("table").getByRole("row").filter({ hasText: /^07:30/ });
await expect(row).toContainText("2026-07-16 07:30:00+08:00");
await expect(row).toContainText("2026-07-09 07:30:00+08:00");
await expect(row).toContainText("33 (2026-07-09");
await page.evaluate(() => document.fonts.ready);
await testInfo.attach("legacy-date-only-history", { body: await manager.screenshot({ path: testInfo.outputPath("legacy-date-only-history.png") }), contentType: "image/png" });
expect(mock.mutations).toEqual([]);
});
@@ -62,6 +62,7 @@ test("调查进度替换整图,跟随只展示新指令并在关闭时清理
}; };
}); });
let task: OperationalTask = { let task: OperationalTask = {
execution_scope: "production",
project_id: projectId, task_id: taskId, task_type: "event_investigation", workflow: "water_quality_complaint", project_id: projectId, task_id: taskId, task_type: "event_investigation", workflow: "water_quality_complaint",
lifecycle_status: "open", status: "running", priority: "normal", revision: 1, execution_generation: 1, lifecycle_status: "open", status: "running", priority: "normal", revision: 1, execution_generation: 1,
execution_request_id: requestId, title: "合成投诉联动调查", summary: "浏览器验证用合成投诉,不是真实工单", execution_request_id: requestId, title: "合成投诉联动调查", summary: "浏览器验证用合成投诉,不是真实工单",
@@ -106,6 +107,7 @@ test("调查进度替换整图,跟随只展示新指令并在关闭时清理
return route.fulfill({ json: { task_id: taskId, execution_generation: 1, return route.fulfill({ json: { task_id: taskId, execution_generation: 1,
active_run: { run_id: runId, generation: 1, status: "running" }, event_cursor: eventCursor } }); active_run: { run_id: runId, generation: 1, status: "running" }, event_cursor: eventCursor } });
} }
if (route.request().url().includes("/presentation-events")) return route.fulfill({ json: { task_id: taskId, event_cursor: eventCursor, items: [], next_after: null } });
if (route.request().url().includes("/reports")) return route.fulfill({ json: { items: [], next_offset: null } }); if (route.request().url().includes("/reports")) return route.fulfill({ json: { items: [], next_offset: null } });
return route.fulfill({ json: task }); return route.fulfill({ json: task });
}); });
@@ -127,6 +129,7 @@ test("调查进度替换整图,跟随只展示新指令并在关闭时清理
await panel.getByText("合成投诉联动调查", { exact: true }).first().click(); await panel.getByText("合成投诉联动调查", { exact: true }).first().click();
await expect(panel.getByText("正在核对投诉点位", { exact: true }).first()).toBeVisible(); await expect(panel.getByText("正在核对投诉点位", { exact: true }).first()).toBeVisible();
const diagram = panel.getByLabel("调查脉络", { exact: true }); const diagram = panel.getByLabel("调查脉络", { exact: true });
await diagram.scrollIntoViewIfNeeded();
await expect(diagram.locator("svg g.node.tjRunning")).toHaveCount(1); await expect(diagram.locator("svg g.node.tjRunning")).toHaveCount(1);
const animations = () => page.evaluate(() => (window as unknown as { reportAnimations: string[] }).reportAnimations); const animations = () => page.evaluate(() => (window as unknown as { reportAnimations: string[] }).reportAnimations);
expect(await animations()).toEqual([]); expect(await animations()).toEqual([]);
@@ -142,7 +145,7 @@ test("调查进度替换整图,跟随只展示新指令并在关闭时清理
await expect(diagram).not.toContainText("投诉点位核对"); await expect(diagram).not.toContainText("投诉点位核对");
await expect(diagram.locator("svg g.node.tjRunning")).toHaveCount(0); await expect(diagram.locator("svg g.node.tjRunning")).toHaveCount(0);
await expect(diagram).toHaveAttribute("data-retained-reader", "true"); await expect(diagram).toHaveAttribute("data-retained-reader", "true");
await expect.poll(animations).toEqual(["当前调查进展"]); await expect.poll(animations).toContain("当前调查进展");
task = { ...task, revision: 3, latest_report: { revision: 1, execution_generation: 1, task = { ...task, revision: 3, latest_report: { revision: 1, execution_generation: 1,
source_session_id: "investigation-test", published_at: now, published_by: "agent", report: { source_session_id: "investigation-test", published_at: now, published_by: "agent", report: {
@@ -153,24 +156,24 @@ test("调查进度替换整图,跟随只展示新指令并在关闭时清理
await panel.getByRole("button", { name: "刷新工况任务", exact: true }).click(); await panel.getByRole("button", { name: "刷新工况任务", exact: true }).click();
const report = panel.getByRole("region", { name: "任务报告", exact: true }); const report = panel.getByRole("region", { name: "任务报告", exact: true });
await expect(report).toContainText("合成结论:等待现场核查"); await expect(report).toContainText("合成结论:等待现场核查");
await expect.poll(animations).toEqual(["当前调查进展", "任务报告"]); await expect.poll(animations).toContain("任务报告");
await report.getByText("判断说明与建议", { exact: true }).click(); await report.evaluate((element) => element.setAttribute("data-retained-reader", "true"));
await report.locator("details").evaluate((element) => element.setAttribute("data-retained-reader", "true")); const beforeReportUpdate = (await animations()).filter((label) => label === "任务报告").length;
task = { ...task, revision: 4, latest_report: { ...task.latest_report!, revision: 2, task = { ...task, revision: 4, latest_report: { ...task.latest_report!, revision: 2,
report: { ...task.latest_report!.report, current_conclusion: "合成结论:现场核查已更新" } } }; report: { ...task.latest_report!.report, current_conclusion: "合成结论:现场核查已更新" } } };
await panel.getByRole("button", { name: "刷新工况任务", exact: true }).click(); await panel.getByRole("button", { name: "刷新工况任务", exact: true }).click();
await expect(report).toContainText("合成结论:现场核查已更新"); await expect(report).toContainText("合成结论:现场核查已更新");
await expect(report.locator("details")).toHaveAttribute("open", ""); await expect(report).toHaveAttribute("data-retained-reader", "true");
await expect(report.locator("details")).toHaveAttribute("data-retained-reader", "true"); await expect.poll(() => animations().then((entries) => entries.filter((label) => label === "任务报告").length)).toBeGreaterThan(beforeReportUpdate);
await expect.poll(animations).toEqual(["当前调查进展", "任务报告", "任务报告"]);
await testInfo.attach("report-update", { body: await report.screenshot(), contentType: "image/png" }); await testInfo.attach("report-update", { body: await report.screenshot(), contentType: "image/png" });
await page.emulateMedia({ reducedMotion: "reduce" }); await page.emulateMedia({ reducedMotion: "reduce" });
const beforeReducedMotion = await animations();
task = { ...task, revision: 5, latest_report: { ...task.latest_report!, revision: 3, task = { ...task, revision: 5, latest_report: { ...task.latest_report!, revision: 3,
report: { ...task.latest_report!.report, current_conclusion: "合成结论:减少动态效果下更新" } } }; report: { ...task.latest_report!.report, current_conclusion: "合成结论:减少动态效果下更新" } } };
await panel.getByRole("button", { name: "刷新工况任务", exact: true }).click(); await panel.getByRole("button", { name: "刷新工况任务", exact: true }).click();
await expect(report).toContainText("合成结论:减少动态效果下更新"); await expect(report).toContainText("合成结论:减少动态效果下更新");
expect(await animations()).toEqual(["当前调查进展", "任务报告", "任务报告"]); expect(await animations()).toEqual(beforeReducedMotion);
await page.emulateMedia({ reducedMotion: "no-preference" }); await page.emulateMedia({ reducedMotion: "no-preference" });
await page.getByRole("button", { name: "切换地图 Dev Panel" }).click(); await page.getByRole("button", { name: "切换地图 Dev Panel" }).click();
const follow = page.getByRole("checkbox", { name: "跟随所选调查" }); const follow = page.getByRole("checkbox", { name: "跟随所选调查" });
@@ -185,9 +188,11 @@ test("调查进度替换整图,跟随只展示新指令并在关闭时清理
expect(checkboxAlignment).toEqual({ width: 20, height: 20, offsetX: 0, offsetY: 0 }); expect(checkboxAlignment).toEqual({ width: 20, height: 20, offsetX: 0, offsetY: 0 });
await expect.poll(() => cursorReads).toBeGreaterThan(0); await expect.poll(() => cursorReads).toBeGreaterThan(0);
const dev = page.getByTestId("map-dev-panel"); const dev = page.getByTestId("map-dev-panel");
const sections = dev.locator("details"); await expect(dev.getByRole("tab", { name: "调查测试", exact: true })).toHaveAttribute("aria-selected", "true");
await expect(sections).toHaveCount(5); await expect(dev.getByRole("region", { name: "调查联动", exact: true })).toBeVisible();
await expect(sections.filter({ hasText: "调查联动" })).toHaveAttribute("open", ""); await dev.getByRole("tab", { name: "地图调试", exact: true }).click();
const sections = dev.getByRole("tabpanel", { name: "地图调试", exact: true }).locator("details");
await expect(sections).toHaveCount(4);
await expect(sections.filter({ hasText: "要素定位" })).toHaveAttribute("open", ""); await expect(sections.filter({ hasText: "要素定位" })).toHaveAttribute("open", "");
await expect(sections.filter({ hasText: "水流调试" })).not.toHaveAttribute("open", ""); await expect(sections.filter({ hasText: "水流调试" })).not.toHaveAttribute("open", "");
await testInfo.attach("dev-panel-default", { body: await page.screenshot({ path: testInfo.outputPath("dev-panel-default.png") }), contentType: "image/png" }); await testInfo.attach("dev-panel-default", { body: await page.screenshot({ path: testInfo.outputPath("dev-panel-default.png") }), contentType: "image/png" });
@@ -238,6 +243,9 @@ test("调查进度替换整图,跟随只展示新指令并在关闭时清理
await expect(page.locator('[data-investigation-chart="pressure"]')).toHaveCount(0); await expect(page.locator('[data-investigation-chart="pressure"]')).toHaveCount(0);
await emit({ action: "open_chart", ...chart }); await emit({ action: "open_chart", ...chart });
await expect(page.locator('[data-investigation-chart="pressure"]')).toBeVisible(); await expect(page.locator('[data-investigation-chart="pressure"]')).toBeVisible();
const chartManager = page.getByRole("dialog", { name: "图表管理", exact: true });
await expect(chartManager).toBeVisible();
await chartManager.getByRole("button", { name: "关闭窗口", exact: true }).click();
await emit({ action: "close_chart", chart_id: "pressure" }); await emit({ action: "close_chart", chart_id: "pressure" });
await expect(page.locator('[data-investigation-chart="pressure"]')).toHaveCount(0); await expect(page.locator('[data-investigation-chart="pressure"]')).toHaveCount(0);
const data = { type: "FeatureCollection", features: [ const data = { type: "FeatureCollection", features: [
@@ -263,6 +271,7 @@ test("调查进度替换整图,跟随只展示新指令并在关闭时清理
await testInfo.attach("live-investigation-mobile", { body: await page.screenshot({ path: testInfo.outputPath("live-investigation-mobile.png") }), contentType: "image/png" }); await testInfo.attach("live-investigation-mobile", { body: await page.screenshot({ path: testInfo.outputPath("live-investigation-mobile.png") }), contentType: "image/png" });
await page.setViewportSize({ width: 1440, height: 900 }); await page.setViewportSize({ width: 1440, height: 900 });
await page.getByRole("button", { name: "切换地图 Dev Panel" }).click(); await page.getByRole("button", { name: "切换地图 Dev Panel" }).click();
await dev.getByRole("tab", { name: "调查测试", exact: true }).click();
await follow.uncheck(); await follow.uncheck();
await expect(follow).not.toBeChecked(); await expect(follow).not.toBeChecked();
await follow.focus(); await follow.focus();
+236
View File
@@ -0,0 +1,236 @@
import { expect, test, type Locator, type Page } from "@playwright/test";
import { mockInvestigationPresentation } from "./support/mock-investigation-presentation";
test.use({ reducedMotion: "reduce" });
async function themeColor(element: Locator, token: string, property: string) {
return cssColor(element, `var(${token})`, property);
}
async function cssColor(element: Locator, color: string, property: string) {
return element.evaluate((node, { color, property }) => {
const probe = document.createElement("span");
probe.style.setProperty(property, color);
node.append(probe);
const value = getComputedStyle(probe).getPropertyValue(property);
probe.remove();
return value;
}, { color, property });
}
async function expectOrdinaryInteraction(row: Locator, label?: string, description?: string) {
await expect(row).toHaveCSS("background-color", await themeColor(row, "--surface-reading", "background-color"));
await expect(row).toHaveCSS("border-top-color", await themeColor(row, "--surface-border", "border-top-color"));
await expect(row).toHaveCSS("color", await themeColor(row, "--action-blue-hover", "color"));
if (label) await expect(row.getByText(label, { exact: true })).toHaveCSS("color", await themeColor(row, "--action-blue-hover", "color"));
if (await row.locator("svg").count()) await expect(row.locator("svg").first()).toHaveCSS("color", await themeColor(row, "--action-blue-hover", "color"));
if (description) await expect(row.getByText(description, { exact: true })).toHaveCSS("color", await themeColor(row, "--ink-muted", "color"));
}
async function appearance(row: Locator) {
return row.evaluate((node) => {
const style = getComputedStyle(node);
return { background: style.backgroundColor, border: style.borderTopColor, color: style.color };
});
}
async function expectPreservedOnHover(page: Page, row: Locator, selected = false) {
await page.mouse.move(1, 1);
const before = await appearance(row);
await row.scrollIntoViewIfNeeded();
const bounds = (await row.boundingBox())!;
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2);
if (selected) {
await expect(row).toHaveCSS("background-color", before.background);
await expect(row).toHaveCSS("color", before.color);
await expect(row).toHaveCSS("border-top-color", await themeColor(row, "--action-border-hover", "border-top-color"));
} else {
await expect.poll(() => appearance(row)).toEqual(before);
}
}
async function expectContained(row: Locator, width: number) {
const bounds = (await row.boundingBox())!;
expect(bounds.x).toBeGreaterThanOrEqual(0);
expect(bounds.x + bounds.width).toBeLessThanOrEqual(width);
expect(await row.evaluate((node) => node.scrollWidth - node.clientWidth)).toBeLessThanOrEqual(1);
}
for (const width of [1440, 375]) {
test(`map options share hover and keyboard colors while keeping selected and disabled states at ${width}px`, async ({ page }, testInfo) => {
test.setTimeout(90_000);
await page.setViewportSize({ width, height: 900 });
const mock = await mockInvestigationPresentation(page, []);
// Enable an alternate basemap with a synthetic token; every tile stays local.
await page.route("**/runtime-config.js", (route) => route.fulfill({ contentType: "application/javascript", body: `globalThis.__TJWATER_CONFIG__=${JSON.stringify({
TJWATER_AUTH_MODE: "disabled", TJWATER_SERVER_API_BASE_URL: "https://tjwater-api.invalid",
TJWATER_AGENT_URL: "http://127.0.0.1:8787", TJWATER_ENABLE_DEV_PANEL: "true",
TJWATER_MAP_URL: "https://workbench-map.invalid/geoserver", TJWATER_GEOSERVER_WORKSPACE: "tjwater_next",
TJWATER_MAPBOX_ACCESS_TOKEN: "", TJWATER_TIANDITU_TOKEN: "synthetic-browser-test", TJWATER_ENABLE_MSW: "false"
})};` }));
await page.route("https://*.tianditu.gov.cn/**", (route) => route.fulfill({ contentType: "image/png", body: Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABpfZFQAAAAABJRU5ErkJggg==", "base64") }));
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.waitForFunction(() => Boolean(window.__waterNetworkMap?.getLayer("scada-pressure-hit")));
await page.getByRole("button", { name: "更多:更多地图操作", exact: true }).click();
const more = page.getByRole("region", { name: "更多", exact: true });
const refresh = more.getByRole("button", { name: /刷新瓦片/ });
const exportView = more.getByRole("button", { name: /当前分辨率/ });
const restingRefresh = await appearance(refresh);
const restingSibling = await appearance(exportView);
await refresh.hover();
await expectOrdinaryInteraction(refresh, "刷新瓦片", "请求地图重新渲染");
expect(await appearance(exportView)).toEqual(restingSibling);
await exportView.hover();
await expect.poll(() => appearance(refresh)).toEqual(restingRefresh);
await expectOrdinaryInteraction(exportView, "当前分辨率", "按当前地图画布尺寸导出");
await refresh.hover();
await expectContained(more, width);
await testInfo.attach(`more-hover-${width}`, { body: await more.screenshot({ path: testInfo.outputPath(`more-hover-${width}.png`) }), contentType: "image/png" });
await page.getByRole("button", { name: "测量:距离与范围测量", exact: true }).click();
const measure = page.getByRole("region", { name: "测量", exact: true });
const distance = measure.getByRole("button", { name: "距离", exact: true });
const area = measure.getByRole("button", { name: "面积", exact: true });
await expect(distance).toHaveAttribute("aria-pressed", "true");
await expectPreservedOnHover(page, distance, true);
await area.hover();
await expectOrdinaryInteraction(area, "面积");
const meters = measure.getByRole("button", { name: "m", exact: true });
const kilometers = measure.getByRole("button", { name: "km", exact: true });
await expectPreservedOnHover(page, kilometers, true);
await meters.hover();
await expectOrdinaryInteraction(meters);
const clear = measure.getByRole("button", { name: "清除测量", exact: true });
await expect(clear).toBeDisabled();
await expectPreservedOnHover(page, clear);
const start = measure.getByRole("button", { name: /开始测量/ });
await start.hover();
await expect(start).toHaveCSS("background-color", await themeColor(start, "--action-blue-hover", "background-color"));
await expect(start).toHaveCSS("color", "rgb(255, 255, 255)");
await expect(start.getByText("开始测量", { exact: true })).toHaveCSS("color", "rgb(255, 255, 255)");
await expect(start.locator("svg").first()).toHaveCSS("color", "rgb(255, 255, 255)");
await expect(start.getByText("在地图中连续取点", { exact: true })).toHaveCSS("color", await cssColor(start, "color-mix(in oklab, white 85%, transparent)", "color"));
await start.click();
for (const close of await page.getByRole("button", { name: "关闭通知", exact: true }).all()) {
if (await close.isVisible()) await close.click();
}
const canvas = page.locator("canvas.maplibregl-canvas");
await canvas.click({ position: { x: 100, y: 175 }, timeout: 10_000 });
await canvas.click({ position: { x: 150, y: 205 }, timeout: 10_000 });
const copy = measure.getByRole("button", { name: "复制结果", exact: true });
await expect(copy).toBeEnabled();
await copy.hover();
await expectOrdinaryInteraction(copy, "复制结果");
await expect(clear).toBeEnabled();
await clear.hover();
await expect(clear).toHaveCSS("background-color", await themeColor(clear, "--status-danger-soft", "background-color"));
await expect(clear).toHaveCSS("border-top-color", await themeColor(clear, "--status-danger-border", "border-top-color"));
await expect(clear).toHaveCSS("color", await themeColor(clear, "--status-danger-foreground", "color"));
await expect(clear.locator("svg").first()).toHaveCSS("color", await themeColor(clear, "--status-danger-foreground", "color"));
await expectContained(measure, width);
await testInfo.attach(`measure-hover-${width}`, { body: await measure.screenshot({ path: testInfo.outputPath(`measure-hover-${width}.png`) }), contentType: "image/png" });
await page.getByRole("button", { name: "图层:管理地图图层", exact: true }).click();
const layers = page.getByRole("region", { name: "图层", exact: true });
const noBasemap = layers.getByRole("button", { name: "无底图", exact: true });
await noBasemap.scrollIntoViewIfNeeded();
await noBasemap.hover();
await expectOrdinaryInteraction(noBasemap, "无底图");
const vector = layers.getByRole("button", { name: "天地图矢量", exact: true });
await expect(vector).toHaveAttribute("aria-pressed", "true");
await expectPreservedOnHover(page, vector, true);
const light = layers.getByRole("button", { name: "浅色", exact: true });
await expect(light).toBeDisabled();
await expectPreservedOnHover(page, light);
await expectContained(layers, width);
await testInfo.attach(`basemap-hover-${width}`, { body: await layers.screenshot({ path: testInfo.outputPath(`basemap-hover-${width}.png`) }), contentType: "image/png" });
await page.getByRole("button", { name: "图层:管理地图图层", exact: true }).click();
const header = page.locator(".workbench-top-bar");
const user = header.getByRole("button", { name: "打开用户菜单", exact: true });
await user.click();
const status = page.getByRole("menuitem", { name: /查看运行状态/ });
await status.hover();
await expectOrdinaryInteraction(status, "查看运行状态", "检查数据源与运行健康度");
const logout = page.getByRole("menuitem", { name: "退出登录", exact: true });
await expect(logout).toHaveAttribute("aria-disabled", "true");
await expectPreservedOnHover(page, logout);
const userMenu = page.getByRole("menu");
await expectContained(userMenu, width);
await page.keyboard.press("Escape");
await expect(userMenu).toHaveCount(0);
await page.mouse.move(1, 1);
await user.focus();
await expect(user).toBeFocused();
await user.press("ArrowDown");
await expect(userMenu).toBeVisible();
const menuItems = userMenu.getByRole("menuitem");
for (let index = 0; index < await menuItems.count(); index++) {
await expect(menuItems.nth(index)).toBeFocused();
if (await status.evaluate((node) => document.activeElement === node)) break;
await page.keyboard.press("ArrowDown");
}
await expect(status).toBeFocused();
await expect(status).toHaveAttribute("data-highlighted", "");
await expectOrdinaryInteraction(status, "查看运行状态", "检查数据源与运行健康度");
await expect(status).toHaveCSS("outline-width", "1px");
await expect(status).toHaveCSS("outline-offset", "-1px");
await testInfo.attach(`header-keyboard-${width}`, { body: await userMenu.screenshot({ path: testInfo.outputPath(`header-keyboard-${width}.png`) }), contentType: "image/png" });
await page.keyboard.press("Escape");
await expect(userMenu).toHaveCount(0);
// Dev tools are a desktop entry; verify the resulting window menu at the target width.
if (width < 800) await page.setViewportSize({ width: 1440, height: 900 });
await page.getByRole("button", { name: "切换地图 Dev Panel", exact: true }).click();
const tools = page.getByTestId("investigation-dev-tools");
await tools.getByLabel("调查展示 JSONL", { exact: true }).fill(JSON.stringify({ record_type: "presentation", command: {
action: "open_chart", chart_id: "hover-chart", evidence_refs: ["dev:synthetic"], chart: {
title: "交互验证压力趋势", chart_type: "line", x_data: ["08:00", "08:15"], series: [{ name: "压力", data: [30, 28] }]
}
} }));
await expect(tools.getByRole("combobox", { name: "JSONL 测试案例", exact: true })).toBeEnabled({ timeout: 15_000 });
await tools.getByRole("button", { name: "执行本地预览", exact: true }).click();
await expect(tools.getByRole("status")).toContainText("本地预览完成");
await page.getByLabel("本地预览展示", { exact: true }).getByRole("button", { name: "打开图表管理", exact: true }).click();
await expect(page.getByRole("dialog", { name: "图表管理", exact: true })).toBeVisible();
await page.setViewportSize({ width, height: 900 });
const navigator = header.getByRole("button", { name: /窗口管理,当前/ });
await navigator.click();
const windowMenu = page.getByRole("menu", { name: "已打开的窗口", exact: true });
const currentWindow = windowMenu.getByRole("menuitem", { name: /定位窗口 .*图表管理/ });
await expect(currentWindow).toHaveAttribute("aria-current", "true");
await expectPreservedOnHover(page, currentWindow, true);
await expect(currentWindow).toHaveCSS("background-color", await themeColor(currentWindow, "--action-selection-soft", "background-color"));
await expect(currentWindow).toHaveCSS("color", await themeColor(currentWindow, "--action-blue-hover", "color"));
const closeWindows = windowMenu.getByRole("menuitem", { name: "关闭全部窗口", exact: true });
await closeWindows.hover();
await expectOrdinaryInteraction(closeWindows);
await expectContained(windowMenu, width);
await expectContained(header, width);
expect(await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)).toBeLessThanOrEqual(1);
await testInfo.attach(`window-hover-${width}`, { body: await windowMenu.screenshot({ path: testInfo.outputPath(`window-hover-${width}.png`) }), contentType: "image/png" });
expect(mock.mutations).toEqual([]);
});
}
test.describe("touch map options", () => {
test.use({ hasTouch: true, isMobile: true, viewport: { width: 375, height: 812 } });
test("touch taps keep ordinary rows free of sticky hover colors", async ({ page }, testInfo) => {
await mockInvestigationPresentation(page, []);
await page.goto("/", { waitUntil: "domcontentloaded" });
expect(await page.evaluate(() => matchMedia("(hover: hover)").matches)).toBe(false);
await page.getByRole("button", { name: "更多:更多地图操作", exact: true }).tap();
const more = page.getByRole("region", { name: "更多", exact: true });
const refresh = more.getByRole("button", { name: /刷新瓦片/ });
await refresh.tap({ trial: true });
const before = await appearance(refresh);
const bounds = await refresh.boundingBox();
await refresh.tap();
await expect.poll(() => appearance(refresh)).toEqual(before);
expect(await refresh.boundingBox()).toEqual(bounds);
await expect(refresh).not.toHaveCSS("color", await themeColor(refresh, "--action-blue-hover", "color"));
await expectContained(more, 375);
await testInfo.attach("touch-options-375", { body: await more.screenshot({ path: testInfo.outputPath("touch-options-375.png") }), contentType: "image/png" });
});
});
+12 -10
View File
@@ -53,11 +53,14 @@ for (const width of [1440, 375, 320]) {
await expect(ui.locator(`svg path[fill="${color}"]`)).toHaveCount(3); await expect(ui.locator(`svg path[fill="${color}"]`)).toHaveCount(3);
} }
const axisValues = () => ui.locator("svg text").allTextContents().then((labels) => labels.filter((label) => /^-?\d+(\.\d+)?$/.test(label)).map(Number)); const axisValues = () => ui.locator("svg text").allTextContents().then((labels) => labels.filter((label) => /^-?\d+(\.\d+)?$/.test(label)).map(Number));
expect((await axisValues()).every((value) => value > 0)).toBe(true); const initialTicks = await axisValues();
expect(initialTicks.length).toBeGreaterThanOrEqual(2);
expect(initialTicks.every((value) => value > 0 && Number.isInteger(value))).toBe(true);
await page.evaluate(() => document.fonts.ready); await page.evaluate(() => document.fonts.ready);
await testInfo.attach(`scada-week-${width}`, { body: await ui.screenshot({ path: testInfo.outputPath(`scada-week-${width}.png`) }), contentType: "image/png" }); await testInfo.attach(`scada-week-${width}`, { body: await ui.screenshot({ path: testInfo.outputPath(`scada-week-${width}.png`) }), contentType: "image/png" });
await ui.locator('svg path[fill="#059669"]').last().hover({ position: { x: 4, y: 6 } }); // Hover the topmost historical day so neighboring scatter symbols cannot intercept.
await expect(ui).toContainText("采样时间 2026-06-13 12:00:00+08:00"); await ui.locator('svg path[fill="#a16207"]').last().hover({ position: { x: 4, y: 4 } });
await expect(ui).toContainText("采样时间 2026-06-07 12:00:00+08:00");
await page.evaluate(() => document.fonts.ready); await page.evaluate(() => document.fonts.ready);
await testInfo.attach(`scada-week-tooltip-${width}`, { body: await ui.screenshot({ path: testInfo.outputPath(`scada-week-tooltip-${width}.png`) }), contentType: "image/png" }); await testInfo.attach(`scada-week-tooltip-${width}`, { body: await ui.screenshot({ path: testInfo.outputPath(`scada-week-tooltip-${width}.png`) }), contentType: "image/png" });
const plot = ui.getByRole("group", { name: /按回车查看数据点/ }); const plot = ui.getByRole("group", { name: /按回车查看数据点/ });
@@ -91,22 +94,21 @@ for (const width of [1440, 375, 320]) {
const restored = (await floating.boundingBox())!; const restored = (await floating.boundingBox())!;
await floating.getByRole("button", { name: "最大化窗口", exact: true }).click(); await floating.getByRole("button", { name: "最大化窗口", exact: true }).click();
await expect.poll(async () => (await floating.boundingBox())!.width).toBe(width - 16); await expect.poll(async () => (await floating.boundingBox())!.width).toBe(width - 16);
await expect.poll(async () => (await floating.boundingBox())!.height).toBe((width < 640 ? 640 : 1000) - 16); await expect.poll(async () => (await floating.boundingBox())!.height).toBe((width < 640 ? 640 : 1000) - 72);
await expect.poll(async () => Math.abs(Number(await drawing.locator("svg").getAttribute("width")) - (await drawing.boundingBox())!.width)).toBeLessThan(1); await expect.poll(async () => Math.abs(Number(await drawing.locator("svg").getAttribute("width")) - (await drawing.boundingBox())!.width)).toBeLessThan(1);
await expect.poll(async () => Math.abs(Number(await drawing.locator("svg").getAttribute("height")) - (await drawing.boundingBox())!.height)).toBeLessThan(1); await expect.poll(async () => Math.abs(Number(await drawing.locator("svg").getAttribute("height")) - (await drawing.boundingBox())!.height)).toBeLessThan(1);
await expect(drawing.locator(".echarts-for-react")).toHaveAttribute("_echarts_instance_", instance!); await expect(drawing.locator(".echarts-for-react")).toHaveAttribute("_echarts_instance_", instance!);
const content = floating.locator("[data-floating-window-content]"); const content = floating.locator("[data-floating-window-content]");
expect(await content.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1); expect(await content.evaluate((element) => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1);
const plotBox = (await drawing.boundingBox())!; const plotBox = (await drawing.boundingBox())!;
const sidebar = (await floating.locator("aside").boundingBox())!; const legend = (await floating.getByLabel("图表图例").boundingBox())!;
if (width < 640) expect(sidebar.y).toBeGreaterThanOrEqual(plotBox.y + plotBox.height); expect(legend.y).toBeGreaterThanOrEqual(plotBox.y + plotBox.height);
else expect(sidebar.x).toBeGreaterThanOrEqual(plotBox.x + plotBox.width); expect(plotBox.height).toBeGreaterThanOrEqual(180);
expect(plotBox.height).toBeGreaterThanOrEqual(220);
await floating.getByLabel("显示的数据序列").getByRole("button", { name: "6月12日", exact: true }).click(); await floating.getByLabel("显示的数据序列").getByRole("button", { name: "6月12日", exact: true }).click();
await expect(ui.getByLabel("显示的数据序列").getByRole("button", { name: "6月12日", exact: true })).toHaveAttribute("aria-pressed", "false"); await expect(ui.getByLabel("显示的数据序列").getByRole("button", { name: "6月12日", exact: true })).toHaveAttribute("aria-pressed", "false");
await floating.getByLabel("显示的数据序列").getByRole("button", { name: "6月12日", exact: true }).click(); await floating.getByLabel("显示的数据序列").getByRole("button", { name: "6月12日", exact: true }).click();
await floating.locator('svg path[fill="#059669"]').last().hover({ position: { x: 4, y: 6 } }); await floating.locator('svg path[fill="#a16207"]').last().hover({ position: { x: 4, y: 4 } });
await expect(floating).toContainText("采样时间 2026-06-13 12:00:00+08:00"); await expect(floating).toContainText("采样时间 2026-06-07 12:00:00+08:00");
await floating.locator(".floating-window-heading").hover(); await floating.locator(".floating-window-heading").hover();
await page.evaluate(() => document.fonts.ready); await page.evaluate(() => document.fonts.ready);
await testInfo.attach(`scada-week-fullscreen-${width}`, { body: await floating.screenshot({ path: testInfo.outputPath(`scada-week-fullscreen-${width}.png`) }), contentType: "image/png" }); await testInfo.attach(`scada-week-fullscreen-${width}`, { body: await floating.screenshot({ path: testInfo.outputPath(`scada-week-fullscreen-${width}.png`) }), contentType: "image/png" });
@@ -132,10 +132,15 @@ export async function mockInvestigationPresentation(page: Page, tasks: Operation
export async function selectInvestigation(page: Page, title: string) { export async function selectInvestigation(page: Page, title: string) {
await page.goto("/", { waitUntil: "domcontentloaded" }); await page.goto("/", { waitUntil: "domcontentloaded" });
await page.getByRole("button", { name: "工况任务", exact: true }).click(); await page.getByRole("button", { name: /^(?:打开)?工况任务$/ }).click();
const panel = page.getByRole("region", { name: "工况任务", exact: true }); const panel = page.getByRole("region", { name: "工况任务", exact: true });
const expand = panel.getByRole("button", { name: "展开工况任务" }); const expand = panel.getByRole("button", { name: "展开工况任务" });
if (await expand.isVisible()) await expand.click(); if (await expand.isVisible()) await expand.click();
await panel.getByRole("button", { name: /调查任务/ }).click(); await panel.getByRole("button", { name: /调查任务/ }).click();
await panel.getByText(title, { exact: true }).first().click(); const task = panel.getByRole("button").filter({ hasText: title }).first();
if (!await task.isVisible()) {
const collapse = panel.getByRole("button", { name: "收起工况任务", exact: true });
if (await collapse.isVisible()) await collapse.click();
}
await task.click();
} }
@@ -0,0 +1,93 @@
import { expect, test } from "@playwright/test";
import { basename, resolve } from "node:path";
import { mockInvestigationPresentation } from "./support/mock-investigation-presentation";
test.use({ reducedMotion: "reduce" });
for (const width of [1440, 375, 320]) {
test(`header locates managed, chart and table windows without changing their state at ${width}px`, async ({ page }, testInfo) => {
test.setTimeout(60_000);
await page.setViewportSize({ width: 1440, height: 900 });
await page.route("**/@fs/**/noto-sans-sc-*.woff2", (route) => route.fulfill({
path: resolve("node_modules/@fontsource-variable/noto-sans-sc/files", basename(new URL(route.request().url()).pathname)), contentType: "font/woff2"
}));
const mock = await mockInvestigationPresentation(page, []);
await page.goto("/", { waitUntil: "domcontentloaded" });
const header = page.locator(".workbench-top-bar");
const navigator = header.getByRole("button", { name: /窗口管理,当前/ });
await expect(navigator).toHaveCount(0);
await page.getByRole("button", { name: "切换地图 Dev Panel" }).click();
const tools = page.getByTestId("investigation-dev-tools");
await tools.getByLabel("调查展示 JSONL", { exact: true }).fill([
{ record_type: "presentation", command: { action: "open_chart", chart_id: "window-pressure", evidence_refs: ["dev:synthetic"], chart: {
title: "窗口定位压力趋势", chart_type: "line", x_data: ["07:45", "08:00"], series: [{ name: "压力(m)", data: [32, 28] }]
} } },
{ record_type: "ui_block", block: { version: "answer-ui@1", block_id: "window-table", fallback_text: "窗口定位表格", spec: { root: "table", elements: {
table: { type: "Table", props: { title: "窗口定位核查表", columns: ["测点", "压力"], rows: [["P1", "28"], ["P2", "32"]] }, children: [] }
} } } }
].map((record) => JSON.stringify(record)).join("\n"));
await expect(tools.getByRole("combobox", { name: "JSONL 测试案例", exact: true })).toBeEnabled({ timeout: 15_000 });
await tools.getByRole("button", { name: "执行本地预览", exact: true }).click();
await expect(tools.getByRole("status")).toContainText("本地预览完成");
const panel = page.getByLabel("本地预览展示", { exact: true });
await panel.getByRole("button", { name: "放大查看表格 窗口定位核查表", exact: true }).click();
const table = page.getByRole("dialog", { name: "窗口定位核查表 · 表格", exact: true });
await expect(table).toBeVisible();
await panel.getByRole("button", { name: "打开图表管理", exact: true }).click();
const manager = page.getByRole("dialog", { name: "图表管理", exact: true });
await manager.getByRole("button", { name: "放大查看 窗口定位压力趋势", exact: true }).click();
const child = page.getByRole("dialog", { name: "窗口定位压力趋势", exact: true });
await expect(child).toBeVisible();
await child.getByRole("tab", { name: "数据", exact: true }).click();
const search = table.getByRole("textbox", { name: /搜索窗口定位核查表/ });
await table.locator(".floating-window-heading").focus();
await search.fill("P2");
await page.setViewportSize({ width, height: 900 });
await expect(navigator).toHaveAttribute("aria-label", "窗口管理,当前 3 个窗口");
if (width === 1440) {
const alignment = await navigator.evaluate((node) => {
const label = node.children[1].getBoundingClientRect();
const count = node.children[2].getBoundingClientRect();
return { labelCenter: label.y + label.height / 2, countCenter: count.y + count.height / 2 };
});
expect(Math.abs(alignment.labelCenter - alignment.countCenter)).toBeLessThanOrEqual(1);
}
await expect.poll(() => header.evaluate((node) => node.scrollWidth - node.clientWidth)).toBeLessThanOrEqual(1);
const tableBounds = await table.boundingBox();
await navigator.click();
const menu = page.getByRole("menu", { name: "已打开的窗口", exact: true });
await expect(menu.getByRole("menuitem")).toHaveCount(4);
await menu.getByRole("menuitem", { name: "定位窗口 窗口定位核查表 · 表格", exact: true }).click();
await expect(table.locator(".floating-window-heading")).toBeFocused();
await expect(navigator).toHaveAttribute("aria-expanded", "false");
expect(await table.boundingBox()).toEqual(tableBounds);
await expect(search).toHaveValue("P2");
const managerBounds = await manager.boundingBox();
await navigator.focus();
await page.keyboard.press("Enter");
await menu.getByRole("menuitem", { name: /定位窗口 .*图表管理/ }).focus();
await page.keyboard.press("Enter");
await expect(manager.locator(".floating-window-heading")).toBeFocused();
expect(await manager.boundingBox()).toEqual(managerBounds);
await navigator.click();
await header.getByRole("button", { name: "打开用户菜单", exact: true }).click();
await expect(menu).toHaveCount(0);
await expect(page.getByRole("menuitem", { name: /查看运行状态/ })).toBeVisible();
await page.keyboard.press("Escape");
await child.locator(".floating-window-heading").focus();
await child.getByRole("button", { name: "最大化窗口", exact: true }).click();
const maximized = (await child.boundingBox())!;
expect(maximized.y).toBe(64);
expect(maximized.height).toBe(828);
await expect(child.getByRole("tab", { name: "数据", exact: true })).toHaveAttribute("aria-selected", "true");
await navigator.click();
await expect(menu).toBeVisible();
expect(await menu.evaluate((node) => node.scrollWidth - node.clientWidth)).toBeLessThanOrEqual(1);
await page.evaluate(() => document.fonts.ready);
await testInfo.attach(`window-menu-${width}`, { body: await page.screenshot({ path: testInfo.outputPath(`window-menu-${width}.png`) }), contentType: "image/png" });
await menu.getByRole("menuitem", { name: "关闭全部窗口", exact: true }).click();
await expect(page.locator("[data-floating-window]")).toHaveCount(0);
await expect(navigator).toHaveCount(0);
await expect(header.getByRole("button", { name: "打开用户菜单", exact: true })).toBeFocused();
expect(mock.mutations).toEqual([]);
});
}
+992
View File
@@ -0,0 +1,992 @@
[
{
"name": "zero and sparse historical data",
"accepted": true,
"chart": {
"title": "P1 合成压力七日对照(m)",
"chart_type": "time_comparison",
"x_axis_name": "时间",
"y_axis_name": "压力(m)",
"x_data": [
"08:00",
"08:15",
"08:30"
],
"series": [
{
"name": "P1 当前 2026-07-16",
"type": "line",
"data": [
0,
28,
30
],
"sample_times": [
"2026-07-16T08:00:00+08:00",
"2026-07-16T08:15:00+08:00",
"2026-07-16T08:30:00+08:00"
]
},
{
"name": "P1 2026-07-09",
"type": "scatter",
"data": [
null,
32,
33
],
"sample_times": [
null,
"2026-07-09T08:15:00+08:00",
"2026-07-09T08:30:00+08:00"
]
}
]
}
},
{
"name": "old mixed charts are read only",
"accepted": false,
"chart": {
"title": "P1 合成压力七日对照(m)",
"chart_type": "line",
"x_axis_name": "时间",
"y_axis_name": "压力(m)",
"x_data": [
"08:00",
"08:15",
"08:30"
],
"series": [
{
"name": "P1 当前 2026-07-16",
"type": "line",
"data": [
0,
28,
30
],
"sample_times": [
"2026-07-16T08:00:00+08:00",
"2026-07-16T08:15:00+08:00",
"2026-07-16T08:30:00+08:00"
]
},
{
"name": "P1 2026-07-09",
"type": "scatter",
"data": [
null,
32,
33
],
"sample_times": [
null,
"2026-07-09T08:15:00+08:00",
"2026-07-09T08:30:00+08:00"
]
}
]
}
},
{
"name": "missing history timestamps",
"accepted": false,
"chart": {
"title": "P1 合成压力七日对照(m)",
"chart_type": "time_comparison",
"x_axis_name": "时间",
"y_axis_name": "压力(m)",
"x_data": [
"08:00",
"08:15",
"08:30"
],
"series": [
{
"name": "P1 当前 2026-07-16",
"type": "line",
"data": [
0,
28,
30
],
"sample_times": [
"2026-07-16T08:00:00+08:00",
"2026-07-16T08:15:00+08:00",
"2026-07-16T08:30:00+08:00"
]
},
{
"name": "P1 2026-07-09",
"type": "scatter",
"data": [
null,
32,
33
]
}
]
}
},
{
"name": "missing observed timestamp",
"accepted": false,
"chart": {
"title": "P1 合成压力七日对照(m)",
"chart_type": "time_comparison",
"x_axis_name": "时间",
"y_axis_name": "压力(m)",
"x_data": [
"08:00",
"08:15",
"08:30"
],
"series": [
{
"name": "P1 当前 2026-07-16",
"type": "line",
"data": [
0,
28,
30
],
"sample_times": [
"2026-07-16T08:00:00+08:00",
"2026-07-16T08:15:00+08:00",
"2026-07-16T08:30:00+08:00"
]
},
{
"name": "P1 2026-07-09",
"type": "scatter",
"data": [
null,
32,
33
],
"sample_times": [
null,
null,
"2026-07-09T08:30:00+08:00"
]
}
]
}
},
{
"name": "timestamp on a missing value",
"accepted": false,
"chart": {
"title": "P1 合成压力七日对照(m)",
"chart_type": "time_comparison",
"x_axis_name": "时间",
"y_axis_name": "压力(m)",
"x_data": [
"08:00",
"08:15",
"08:30"
],
"series": [
{
"name": "P1 当前 2026-07-16",
"type": "line",
"data": [
0,
28,
30
],
"sample_times": [
"2026-07-16T08:00:00+08:00",
"2026-07-16T08:15:00+08:00",
"2026-07-16T08:30:00+08:00"
]
},
{
"name": "P1 2026-07-09",
"type": "scatter",
"data": [
null,
32,
33
],
"sample_times": [
"2026-07-09T08:00:00+08:00",
"2026-07-09T08:15:00+08:00",
"2026-07-09T08:30:00+08:00"
]
}
]
}
},
{
"name": "wrong projected slot",
"accepted": false,
"chart": {
"title": "P1 合成压力七日对照(m)",
"chart_type": "time_comparison",
"x_axis_name": "时间",
"y_axis_name": "压力(m)",
"x_data": [
"08:00",
"08:15",
"08:30"
],
"series": [
{
"name": "P1 当前 2026-07-16",
"type": "line",
"data": [
0,
28,
30
],
"sample_times": [
"2026-07-16T08:00:00+08:00",
"2026-07-16T08:15:00+08:00",
"2026-07-16T08:30:00+08:00"
]
},
{
"name": "P1 2026-07-09",
"type": "scatter",
"data": [
null,
32,
33
],
"sample_times": [
null,
"2026-07-09T08:30:00+08:00",
"2026-07-09T08:30:00+08:00"
]
}
]
}
},
{
"name": "mixed historical dates",
"accepted": false,
"chart": {
"title": "P1 合成压力七日对照(m)",
"chart_type": "time_comparison",
"x_axis_name": "时间",
"y_axis_name": "压力(m)",
"x_data": [
"08:00",
"08:15",
"08:30"
],
"series": [
{
"name": "P1 当前 2026-07-16",
"type": "line",
"data": [
0,
28,
30
],
"sample_times": [
"2026-07-16T08:00:00+08:00",
"2026-07-16T08:15:00+08:00",
"2026-07-16T08:30:00+08:00"
]
},
{
"name": "P1 2026-07-09",
"type": "scatter",
"data": [
null,
32,
33
],
"sample_times": [
null,
"2026-07-10T08:15:00+08:00",
"2026-07-09T08:30:00+08:00"
]
}
]
}
},
{
"name": "same day history",
"accepted": false,
"chart": {
"title": "P1 合成压力七日对照(m)",
"chart_type": "time_comparison",
"x_axis_name": "时间",
"y_axis_name": "压力(m)",
"x_data": [
"08:00",
"08:15",
"08:30"
],
"series": [
{
"name": "P1 当前 2026-07-16",
"type": "line",
"data": [
0,
28,
30
],
"sample_times": [
"2026-07-16T08:00:00+08:00",
"2026-07-16T08:15:00+08:00",
"2026-07-16T08:30:00+08:00"
]
},
{
"name": "P1 2026-07-09",
"type": "scatter",
"data": [
null,
32,
33
],
"sample_times": [
null,
"2026-07-16T08:15:00+08:00",
"2026-07-16T08:30:00+08:00"
]
}
]
}
},
{
"name": "future history",
"accepted": false,
"chart": {
"title": "P1 合成压力七日对照(m)",
"chart_type": "time_comparison",
"x_axis_name": "时间",
"y_axis_name": "压力(m)",
"x_data": [
"08:00",
"08:15",
"08:30"
],
"series": [
{
"name": "P1 当前 2026-07-16",
"type": "line",
"data": [
0,
28,
30
],
"sample_times": [
"2026-07-16T08:00:00+08:00",
"2026-07-16T08:15:00+08:00",
"2026-07-16T08:30:00+08:00"
]
},
{
"name": "P1 2026-07-09",
"type": "scatter",
"data": [
null,
32,
33
],
"sample_times": [
null,
"2026-07-17T08:15:00+08:00",
"2026-07-17T08:30:00+08:00"
]
}
]
}
},
{
"name": "duplicate slots",
"accepted": false,
"chart": {
"title": "P1 合成压力七日对照(m)",
"chart_type": "time_comparison",
"x_axis_name": "时间",
"y_axis_name": "压力(m)",
"x_data": [
"08:00",
"08:00",
"08:30"
],
"series": [
{
"name": "P1 当前 2026-07-16",
"type": "line",
"data": [
0,
28,
30
],
"sample_times": [
"2026-07-16T08:00:00+08:00",
"2026-07-16T08:15:00+08:00",
"2026-07-16T08:30:00+08:00"
]
},
{
"name": "P1 2026-07-09",
"type": "scatter",
"data": [
null,
32,
33
],
"sample_times": [
null,
"2026-07-09T08:15:00+08:00",
"2026-07-09T08:30:00+08:00"
]
}
]
}
},
{
"name": "date appended axis",
"accepted": false,
"chart": {
"title": "P1 合成压力七日对照(m)",
"chart_type": "time_comparison",
"x_axis_name": "时间",
"y_axis_name": "压力(m)",
"x_data": [
"07-16 08:00",
"07-16 08:15",
"07-16 08:30"
],
"series": [
{
"name": "P1 当前 2026-07-16",
"type": "line",
"data": [
0,
28,
30
],
"sample_times": [
"2026-07-16T08:00:00+08:00",
"2026-07-16T08:15:00+08:00",
"2026-07-16T08:30:00+08:00"
]
},
{
"name": "P1 2026-07-09",
"type": "scatter",
"data": [
null,
32,
33
],
"sample_times": [
null,
"2026-07-09T08:15:00+08:00",
"2026-07-09T08:30:00+08:00"
]
}
]
}
},
{
"name": "inconsistent slot precision",
"accepted": false,
"chart": {
"title": "P1 合成压力七日对照(m)",
"chart_type": "time_comparison",
"x_axis_name": "时间",
"y_axis_name": "压力(m)",
"x_data": [
"08:00:00",
"08:15",
"08:30"
],
"series": [
{
"name": "P1 当前 2026-07-16",
"type": "line",
"data": [
0,
28,
30
],
"sample_times": [
"2026-07-16T08:00:00+08:00",
"2026-07-16T08:15:00+08:00",
"2026-07-16T08:30:00+08:00"
]
},
{
"name": "P1 2026-07-09",
"type": "scatter",
"data": [
null,
32,
33
],
"sample_times": [
null,
"2026-07-09T08:15:00+08:00",
"2026-07-09T08:30:00+08:00"
]
}
]
}
},
{
"name": "bars mixed with comparison",
"accepted": false,
"chart": {
"title": "P1 合成压力七日对照(m)",
"chart_type": "time_comparison",
"x_axis_name": "时间",
"y_axis_name": "压力(m)",
"x_data": [
"08:00",
"08:15",
"08:30"
],
"series": [
{
"name": "P1 当前 2026-07-16",
"type": "line",
"data": [
0,
28,
30
],
"sample_times": [
"2026-07-16T08:00:00+08:00",
"2026-07-16T08:15:00+08:00",
"2026-07-16T08:30:00+08:00"
]
},
{
"name": "P1 2026-07-09",
"type": "bar",
"data": [
null,
32,
33
],
"sample_times": [
null,
"2026-07-09T08:15:00+08:00",
"2026-07-09T08:30:00+08:00"
]
}
]
}
},
{
"name": "no historical series",
"accepted": false,
"chart": {
"title": "P1 合成压力七日对照(m)",
"chart_type": "time_comparison",
"x_axis_name": "时间",
"y_axis_name": "压力(m)",
"x_data": [
"08:00",
"08:15",
"08:30"
],
"series": [
{
"name": "P1 当前 2026-07-16",
"type": "line",
"data": [
0,
28,
30
],
"sample_times": [
"2026-07-16T08:00:00+08:00",
"2026-07-16T08:15:00+08:00",
"2026-07-16T08:30:00+08:00"
]
}
]
}
},
{
"name": "no current observations",
"accepted": false,
"chart": {
"title": "P1 合成压力七日对照(m)",
"chart_type": "time_comparison",
"x_axis_name": "时间",
"y_axis_name": "压力(m)",
"x_data": [
"08:00",
"08:15",
"08:30"
],
"series": [
{
"name": "P1 当前 2026-07-16",
"type": "line",
"data": [
null,
null,
null
],
"sample_times": [
null,
null,
null
]
},
{
"name": "P1 2026-07-09",
"type": "scatter",
"data": [
null,
32,
33
],
"sample_times": [
null,
"2026-07-09T08:15:00+08:00",
"2026-07-09T08:30:00+08:00"
]
}
]
}
},
{
"name": "missing samples with missing timestamps",
"accepted": true,
"chart": {
"title": "P1 合成压力七日对照(m)",
"chart_type": "time_comparison",
"x_axis_name": "时间",
"y_axis_name": "压力(m)",
"x_data": [
"08:00",
"08:15",
"08:30"
],
"series": [
{
"name": "P1 当前 2026-07-16",
"type": "line",
"data": [
null,
28,
30
],
"sample_times": [
null,
"2026-07-16T08:15:00+08:00",
"2026-07-16T08:30:00+08:00"
]
},
{
"name": "P1 2026-07-09",
"type": "scatter",
"data": [
null,
32,
33
],
"sample_times": [
null,
"2026-07-09T08:15:00+08:00",
"2026-07-09T08:30:00+08:00"
]
}
]
}
},
{
"name": "UTC spelling preserves projected instant",
"accepted": true,
"chart": {
"title": "P1 合成压力七日对照(m)",
"chart_type": "time_comparison",
"x_axis_name": "时间",
"y_axis_name": "压力(m)",
"x_data": [
"08:00",
"08:15",
"08:30"
],
"series": [
{
"name": "P1 当前 2026-07-16",
"type": "line",
"data": [
0,
28,
30
],
"sample_times": [
"2026-07-16T08:00:00+08:00",
"2026-07-16T08:15:00+08:00",
"2026-07-16T08:30:00+08:00"
]
},
{
"name": "P1 2026-07-09",
"type": "scatter",
"data": [
null,
32,
33
],
"sample_times": [
null,
"2026-07-09T00:15:00Z",
"2026-07-09T00:30:00Z"
]
}
]
}
},
{
"name": "ordinary line",
"accepted": true,
"chart": {
"title": "P1 合成压力七日对照(m)",
"chart_type": "line",
"x_axis_name": "时间",
"y_axis_name": "压力(m)",
"x_data": [
"08:00",
"08:15",
"08:30"
],
"series": [
{
"name": "合成读数",
"data": [
0,
null,
30
]
}
]
}
},
{
"name": "ordinary bar",
"accepted": true,
"chart": {
"title": "P1 合成压力七日对照(m)",
"chart_type": "bar",
"x_axis_name": "时间",
"y_axis_name": "压力(m)",
"x_data": [
"08:00",
"08:15",
"08:30"
],
"series": [
{
"name": "合成读数",
"data": [
0,
null,
30
]
}
]
}
},
{
"name": "midnight rollover keeps one window",
"accepted": true,
"chart": {
"title": "跨午夜同期对比",
"chart_type": "time_comparison",
"x_data": [
"23:45",
"00:00"
],
"series": [
{
"name": "当前",
"type": "line",
"data": [
0,
25
],
"sample_times": [
"2026-07-16T23:45:00+08:00",
"2026-07-17T00:00:00+08:00"
]
},
{
"name": "历史",
"type": "scatter",
"data": [
26,
27
],
"sample_times": [
"2026-07-15T23:45:00+08:00",
"2026-07-16T00:00:00+08:00"
]
}
]
}
},
{
"name": "microseconds are not aligned",
"accepted": false,
"chart": {
"title": "P1 合成压力七日对照(m)",
"chart_type": "time_comparison",
"x_axis_name": "时间",
"y_axis_name": "压力(m)",
"x_data": [
"08:00",
"08:15",
"08:30"
],
"series": [
{
"name": "P1 当前 2026-07-16",
"type": "line",
"data": [
0,
28,
30
],
"sample_times": [
"2026-07-16T08:00:00.000001+08:00",
"2026-07-16T08:15:00+08:00",
"2026-07-16T08:30:00+08:00"
]
},
{
"name": "P1 2026-07-09",
"type": "scatter",
"data": [
null,
32,
33
],
"sample_times": [
null,
"2026-07-09T08:15:00+08:00",
"2026-07-09T08:30:00+08:00"
]
}
]
}
},
{
"name": "fraction beyond Python microsecond precision",
"accepted": true,
"chart": {
"title": "P1 合成压力七日对照(m)",
"chart_type": "time_comparison",
"x_axis_name": "时间",
"y_axis_name": "压力(m)",
"x_data": [
"08:00",
"08:15",
"08:30"
],
"series": [
{
"name": "P1 当前 2026-07-16",
"type": "line",
"data": [
0,
28,
30
],
"sample_times": [
"2026-07-16T08:00:00.0000001+08:00",
"2026-07-16T08:15:00+08:00",
"2026-07-16T08:30:00+08:00"
]
},
{
"name": "P1 2026-07-09",
"type": "scatter",
"data": [
null,
32,
33
],
"sample_times": [
null,
"2026-07-09T08:15:00+08:00",
"2026-07-09T08:30:00+08:00"
]
}
]
}
},
{
"name": "calendar overflow",
"accepted": false,
"chart": {
"title": "calendar overflow",
"chart_type": "time_comparison",
"x_data": [
"07:45"
],
"series": [
{
"name": "当前",
"type": "line",
"data": [
1
],
"sample_times": [
"9999-12-31T23:45:00Z"
]
},
{
"name": "历史缺测",
"type": "scatter",
"data": [
null
],
"sample_times": [
null
]
}
]
}
},
{
"name": "calendar underflow",
"accepted": false,
"chart": {
"title": "calendar underflow",
"chart_type": "time_comparison",
"x_data": [
"08:00"
],
"series": [
{
"name": "当前",
"type": "line",
"data": [
1
],
"sample_times": [
"0001-01-01T00:00:00+23:59"
]
},
{
"name": "历史缺测",
"type": "scatter",
"data": [
null
],
"sample_times": [
null
]
}
]
}
}
]
+4 -2
View File
@@ -1,12 +1,14 @@
{"record_type":"presentation","command":{"action":"locate_features","layer":"scada","feature_ids":["P1"]}} {"record_type":"presentation","command":{"action":"locate_features","layer":"scada","feature_ids":["P1"]}}
{"record_type":"presentation","command":{"action":"zoom_to_map","center":[121.48,31.1],"zoom":14}} {"record_type":"presentation","command":{"action":"zoom_to_map","center":[121.48,31.1],"zoom":14}}
{"record_type":"presentation","command":{"action":"highlight_features","layer":"scada","feature_ids":["P1"],"effect":"pulse"}} {"record_type":"presentation","command":{"action":"highlight_features","layer":"scada","feature_ids":["P1"],"effect":"pulse"}}
{"record_type":"presentation","command":{"action":"open_chart","chart_id":"fixture-pressure","chart":{"title":"合成压力对比","chart_type":"line","x_data":["08:00","08:15","08:30"],"series":[{"name":"异常日压力水头(m)","data":[32,28,30]},{"name":"对照压力水头(m)","data":[33,32,32]}]},"evidence_refs":["dev:synthetic"]}} {"record_type":"presentation","command":{"action":"open_chart","chart_id":"fixture-pressure-P1","evidence_refs":["dev:synthetic"],"chart":{"title":"P1 合成压力七日对照(m)","chart_type":"time_comparison","x_axis_name":"时间","y_axis_name":"压力(m)","x_data":["08:00","08:15","08:30"],"series":[{"name":"P1 当前 2026-07-16","type":"line","data":[32,28,30],"sample_times":["2026-07-16T08:00:00+08:00","2026-07-16T08:15:00+08:00","2026-07-16T08:30:00+08:00"]},{"name":"P1 2026-07-09","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-09T08:15:00+08:00","2026-07-09T08:30:00+08:00"]},{"name":"P1 2026-07-10","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-10T08:15:00+08:00","2026-07-10T08:30:00+08:00"]},{"name":"P1 2026-07-11","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-11T08:15:00+08:00","2026-07-11T08:30:00+08:00"]},{"name":"P1 2026-07-12","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-12T08:15:00+08:00","2026-07-12T08:30:00+08:00"]},{"name":"P1 2026-07-13","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-13T08:15:00+08:00","2026-07-13T08:30:00+08:00"]},{"name":"P1 2026-07-14","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-14T08:15:00+08:00","2026-07-14T08:30:00+08:00"]},{"name":"P1 2026-07-15","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-15T08:15:00+08:00","2026-07-15T08:30:00+08:00"]}]}}}
{"record_type":"presentation","command":{"action":"open_chart","chart_id":"fixture-pressure-P2","evidence_refs":["dev:synthetic"],"chart":{"title":"P2 合成压力七日对照(m)","chart_type":"time_comparison","x_axis_name":"时间","y_axis_name":"压力(m)","x_data":["08:00","08:15","08:30"],"series":[{"name":"P2 当前 2026-07-16","type":"line","data":[31,27,29],"sample_times":["2026-07-16T08:00:00+08:00","2026-07-16T08:15:00+08:00","2026-07-16T08:30:00+08:00"]},{"name":"P2 2026-07-09","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-09T08:15:00+08:00","2026-07-09T08:30:00+08:00"]},{"name":"P2 2026-07-10","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-10T08:15:00+08:00","2026-07-10T08:30:00+08:00"]},{"name":"P2 2026-07-11","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-11T08:15:00+08:00","2026-07-11T08:30:00+08:00"]},{"name":"P2 2026-07-12","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-12T08:15:00+08:00","2026-07-12T08:30:00+08:00"]},{"name":"P2 2026-07-13","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-13T08:15:00+08:00","2026-07-13T08:30:00+08:00"]},{"name":"P2 2026-07-14","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-14T08:15:00+08:00","2026-07-14T08:30:00+08:00"]},{"name":"P2 2026-07-15","type":"scatter","data":[null,32,33],"sample_times":[null,"2026-07-15T08:15:00+08:00","2026-07-15T08:30:00+08:00"]}]}}}
{"record_type":"presentation","command":{"action":"set_overlay","overlay_id":"fixture-area","title":"合成空间样例","mode":"geometry","evidence_id":"dev:synthetic","data":{"type":"FeatureCollection","features":[{"type":"Feature","properties":{"origin":"synthetic"},"geometry":{"type":"Point","coordinates":[121.48,31.1]}}]}}} {"record_type":"presentation","command":{"action":"set_overlay","overlay_id":"fixture-area","title":"合成空间样例","mode":"geometry","evidence_id":"dev:synthetic","data":{"type":"FeatureCollection","features":[{"type":"Feature","properties":{"origin":"synthetic"},"geometry":{"type":"Point","coordinates":[121.48,31.1]}}]}}}
{"record_type":"presentation","command":{"action":"render_scada_analysis","items":[{"device_id":"P1","level":"high"}]}} {"record_type":"presentation","command":{"action":"render_scada_analysis","items":[{"device_id":"P1","level":"high"}]}}
{"record_type":"ui_block","evidence_refs":[],"block":{"version":"answer-ui@1","block_id":"fixture-summary","phase":"final","fallback_text":"合成候选对比,仅供展示测试。","spec":{"root":"table","elements":{"table":{"type":"Table","props":{"title":"合成候选对比","columns":["候选","说明"],"rows":[["DEMO-PIPE-01","仅供展示测试"],["DEMO-PIPE-02","未执行定位分析"]]},"children":[]}}}}} {"record_type":"ui_block","evidence_refs":[],"block":{"version":"answer-ui@1","block_id":"fixture-summary","phase":"final","fallback_text":"合成候选对比,仅供展示测试。","spec":{"root":"table","elements":{"table":{"type":"Table","props":{"title":"合成候选对比","columns":["候选","说明"],"rows":[["DEMO-PIPE-01","仅供展示测试"],["DEMO-PIPE-02","未执行定位分析"]]},"children":[]}}}}}
{"record_type":"candidate","origin":"synthetic","source_evidence_refs":[],"element_id":"DEMO-PIPE-01","rank":1,"status":"example_only"} {"record_type":"candidate","origin":"synthetic","source_evidence_refs":[],"element_id":"DEMO-PIPE-01","rank":1,"status":"example_only"}
{"record_type":"presentation","command":{"action":"close_chart","chart_id":"fixture-pressure"}} {"record_type":"presentation","command":{"action":"close_chart","chart_id":"fixture-pressure-P1"}}
{"record_type":"presentation","command":{"action":"close_chart","chart_id":"fixture-pressure-P2"}}
{"record_type":"presentation","command":{"action":"clear_overlay","overlay_id":"fixture-area"}} {"record_type":"presentation","command":{"action":"clear_overlay","overlay_id":"fixture-area"}}
{"record_type":"presentation","command":{"action":"clear_highlights"}} {"record_type":"presentation","command":{"action":"clear_highlights"}}
{"record_type":"presentation","command":{"action":"clear_scada_analysis"}} {"record_type":"presentation","command":{"action":"clear_scada_analysis"}}