feat(charts): manage investigation charts in a scrolling workspace

This commit is contained in:
2026-11-10 17:56:23 +08:00
parent 865491cb23
commit 8721423cd3
80 changed files with 4672 additions and 470 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) =>
@@ -20,6 +20,8 @@ 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({
@@ -243,7 +246,7 @@ 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
@@ -397,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" />
@@ -482,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>
@@ -512,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" />
@@ -570,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}
> >
@@ -582,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);
@@ -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"}}