From 0ae0a41e61366f558429380bab9d15739247742c Mon Sep 17 00:00:00 2001 From: Huarch Date: Tue, 10 Nov 2026 17:56:23 +0800 Subject: [PATCH] feat(charts): manage investigation charts in a scrolling workspace --- docs/agent-answer-ui.md | 12 +- docs/investigation-dev-tools.md | 51 +- docs/style-design-guide.md | 4 + package.json | 2 + pnpm-lock.yaml | 71 ++ scripts/export-view-command-contract.mjs | 10 +- .../answer-ui-interaction-context.ts | 2 +- .../agent/answer-ui/answer-ui-view.test.tsx | 24 + .../agent/answer-ui/answer-ui-view.tsx | 16 +- .../answer-ui/chart-presentation.test.ts | 35 +- .../agent/answer-ui/chart-presentation.ts | 15 +- .../agent/answer-ui/interactive-chart.tsx | 302 +++--- .../answer-ui/interactive-data-table.tsx | 8 +- .../agent/answer-ui/responsive-chart.test.tsx | 130 +++ .../agent/answer-ui/responsive-chart.tsx | 34 +- .../components/agent-ui-envelope-renderer.tsx | 8 +- src/features/agent/index.ts | 1 + .../presentation/chart-publication.test.ts | 25 + .../agent/presentation/command-schema.ts | 68 +- .../core/components/base-layers-control.tsx | 25 +- .../map/core/components/control-panel.tsx | 28 +- .../map/core/components/draw-toolbar.tsx | 22 +- .../map/core/components/layer-control.tsx | 10 +- .../map/core/components/layer-panel.tsx | 27 +- .../map/core/components/map-control-styles.ts | 28 +- .../map/core/components/measure-panel.tsx | 35 +- src/features/map/core/components/toolbar.tsx | 5 +- src/features/map/core/components/zoom.tsx | 5 +- src/features/map/core/index.ts | 2 + .../workbench/components/geocoding-panel.tsx | 16 +- .../components/investigation-chart-group.tsx | 94 ++ .../investigation-chart-manager.tsx | 118 +++ .../components/investigation-chart-slot.tsx | 44 + .../investigation-presentation-panel.tsx | 76 +- .../presentation-business-results.tsx | 3 - .../components/workbench-top-bar-menus.tsx | 26 +- .../components/workbench-top-bar-styles.ts | 5 +- .../components/workbench-top-bar.tsx | 12 +- .../components/workbench-window-menu.tsx | 58 + .../hooks/use-chart-window-layout.ts | 43 + .../use-investigation-presentation.test.tsx | 56 + .../hooks/use-investigation-presentation.ts | 16 +- src/features/workbench/map-workbench-page.tsx | 1 + .../utils/chart-window-slots.test.ts | 64 ++ .../workbench/utils/chart-window-slots.ts | 38 + .../utils/investigation-chart-groups.test.ts | 115 ++ .../utils/investigation-chart-groups.ts | 64 ++ .../utils/investigation-dev-cases.test.ts | 16 +- .../utils/investigation-dev-cases.ts | 14 + .../utils/investigation-dev-complex-cases.ts | 52 +- .../utils/investigation-dev-preview.test.ts | 16 +- .../utils/investigation-dev-preview.ts | 21 +- .../utils/presentation-chart-blocks.test.ts | 19 + .../utils/presentation-chart-blocks.ts | 23 + src/shared/ui/floating-window-layer.test.ts | 44 + src/shared/ui/floating-window-layer.ts | 41 +- src/shared/ui/floating-window-viewport.ts | 5 + src/shared/ui/floating-window.tsx | 80 +- src/shared/ui/use-floating-window-drag.ts | 6 +- .../ui/use-floating-window-resize.test.ts | 131 +++ src/shared/ui/use-floating-window-resize.ts | 100 ++ src/styles.css | 6 +- .../agent-table-selection-style.e2e.ts | 33 +- .../browser/annotation-hover-stability.e2e.ts | 130 +++ tests/browser/base-layer-focus.e2e.ts | 6 +- .../browser/investigation-chart-groups.e2e.ts | 90 ++ .../browser/investigation-chart-layout.e2e.ts | 480 +++++++++ .../investigation-chart-manager.e2e.ts | 118 +++ .../browser/investigation-chart-resize.e2e.ts | 131 +++ .../investigation-chart-windows.e2e.ts | 108 +- tests/browser/investigation-dev-tools.e2e.ts | 54 +- .../investigation-execution-history.e2e.ts | 1 + .../investigation-legacy-chart-groups.e2e.ts | 174 +++ .../investigation-live-presentation.e2e.ts | 31 +- tests/browser/map-option-interaction.e2e.ts | 236 +++++ tests/browser/scada-comparison-chart.e2e.ts | 22 +- .../mock-investigation-presentation.ts | 9 +- tests/browser/workbench-window-menu.e2e.ts | 93 ++ tests/fixtures/chart-publication-cases.json | 992 ++++++++++++++++++ tests/fixtures/unified-presentation.jsonl | 6 +- 80 files changed, 4672 insertions(+), 470 deletions(-) create mode 100644 src/features/agent/answer-ui/responsive-chart.test.tsx create mode 100644 src/features/agent/presentation/chart-publication.test.ts create mode 100644 src/features/workbench/components/investigation-chart-group.tsx create mode 100644 src/features/workbench/components/investigation-chart-manager.tsx create mode 100644 src/features/workbench/components/investigation-chart-slot.tsx create mode 100644 src/features/workbench/components/workbench-window-menu.tsx create mode 100644 src/features/workbench/hooks/use-chart-window-layout.ts create mode 100644 src/features/workbench/utils/chart-window-slots.test.ts create mode 100644 src/features/workbench/utils/chart-window-slots.ts create mode 100644 src/features/workbench/utils/investigation-chart-groups.test.ts create mode 100644 src/features/workbench/utils/investigation-chart-groups.ts create mode 100644 src/features/workbench/utils/presentation-chart-blocks.test.ts create mode 100644 src/features/workbench/utils/presentation-chart-blocks.ts create mode 100644 src/shared/ui/floating-window-layer.test.ts create mode 100644 src/shared/ui/floating-window-viewport.ts create mode 100644 src/shared/ui/use-floating-window-resize.test.ts create mode 100644 src/shared/ui/use-floating-window-resize.ts create mode 100644 tests/browser/annotation-hover-stability.e2e.ts create mode 100644 tests/browser/investigation-chart-groups.e2e.ts create mode 100644 tests/browser/investigation-chart-layout.e2e.ts create mode 100644 tests/browser/investigation-chart-manager.e2e.ts create mode 100644 tests/browser/investigation-chart-resize.e2e.ts create mode 100644 tests/browser/investigation-legacy-chart-groups.e2e.ts create mode 100644 tests/browser/map-option-interaction.e2e.ts create mode 100644 tests/browser/workbench-window-menu.e2e.ts create mode 100644 tests/fixtures/chart-publication-cases.json diff --git a/docs/agent-answer-ui.md b/docs/agent-answer-ui.md index 47ba78f..1f4fdd7 100644 --- a/docs/agent-answer-ui.md +++ b/docs/agent-answer-ui.md @@ -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 内容实例,反向操作中断旧动画,避免旧退出回调隐藏重新展开的表单。 @@ -87,7 +95,7 @@ Agent 选择组件、内容顺序与受控语义,前端负责实际视觉、 | `Stack`、`Section` | 保留既有布局,不为静态分区附加动画 | | `Text`、`Result` | 静态说明和已有结果直接阅读,不反复播放入场或宣告历史状态 | | `Metric` | 直接展示原始数值、单位与来源 | -| `Table` | 搜索排序即时响应;无勾选列、选行提示或操作栏 | +| `Table` | 搜索排序即时响应;表头悬停与排序点击覆盖整列宽度;仅当前排序列显示升序或降序箭头,未排序时不显示;无勾选列、选行提示或操作栏 | | `Evidence` | 正文展开收起和箭头转动使用统一过渡;保留来源、记录时间与资料编号 | | `MapLocation` | 关联要素详情使用统一折叠;定位中的反馈及结果短时淡入 | | `FollowUpForm` | 精简与外层重复的补充提示;保留标题、说明、校验及草稿,提交反馈短时淡入 | diff --git a/docs/investigation-dev-tools.md b/docs/investigation-dev-tools.md index 10ae8ef..2bd6c12 100644 --- a/docs/investigation-dev-tools.md +++ b/docs/investigation-dev-tools.md @@ -20,13 +20,31 @@ 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. 在默认展开的“调查测试”页签准备数据。“载入合成样例”在当前地图中心生成合成空间展示、压力曲线、候选表格和一条候选数据;所选调查进度存在地图目标时,另加入该目标的高亮测试。所有合成结果均有明确标记。 2. 可选:点击“校验 JSONL”单独查看识别出的展示指令、界面块、调查数据数量。也可直接执行本地预览,执行入口会自动校验当前全部内容。 -3. 点击“执行本地预览”,自动校验通过后对照地图和左下展示面板中的图表、结构化界面与数据摘要;Dev 的“原始数据检查”可点击对象查看原始行内容。动作记录区分“实时指令”和“本地测试”,显示应用或失败结果。 +3. 点击“执行本地预览”,自动校验通过后对照地图和左下展示面板中的图表、结构化界面与数据摘要;手动打开“图表窗口”,核对单图、左右、四宫格的固定槽位、拖放交换、末行空槽与纵向滚动,再检查快速定位和子图表独立放大,并从顶部窗口列表切换已打开的窗口。Dev 的“原始数据检查”可点击对象查看原始行内容。动作记录区分“实时指令”和“本地测试”,显示应用或失败结果。 4. 按需“下载测试 JSONL”保存当前已校验输入;“清除临时展示”清除当前浏览器的高亮、图表和覆盖层。测试不改变服务端事件游标,不发起调查、审批、工单或设备写入。 样例与导出仅存在当前页面或用户下载的文件中,不写 Agent 工作区,不伪造已保存证据。Dev 不会自动枚举 Agent 生成的本地文件;要检查已有 JSONL,可将通过受控入口取得的文件内容粘贴到编辑区。当前最多 200 KB、40 行、8 个界面块;地图与图表继续遵守原展示协议的数量、几何及序列约束。 @@ -37,8 +55,10 @@ Dev 与工况详情共用右侧工具位置,切换调查时先关闭 Dev 回 | 案例 | 核对内容 | | --- | --- | -| 组合联动 | 地图点、调查现有目标高亮、压力曲线、候选表格与数据 | -| 地图定位与高亮 | 有目标时高亮真实关联目标;无目标时只显示合成点,不虚构资产查询编号 | +| 组合联动 | 合成空间点、压力曲线、候选表格与数据;定位和高亮在地图调试页签操作 | +| 复杂调查:多图多表联动 | 8 个压力点、4 个管段的同期复合图聚合为 2 个子图表,另有方案柱状图、2 个表格与合成覆盖面 | +| 复杂图表:多测点与长时序 | 每个对象包含 96 个时刻、1 条当前曲线和 7 组稀疏历史散点;右侧切换对象,下方显隐序列 | +| 复杂表格:宽表、长文本与边界数据 | 50 行 8 列宽表、空结果、零值、负数、重复值及缺口 | | 压力趋势对比 | 两条合成压力曲线、时间轴、单位与图例 | | 影响用户统计 | 合成用户数柱状图,不作为真实停水统计 | | 合成影响范围 | 当前地图中心附近的合成 Polygon | @@ -49,6 +69,7 @@ Dev 与工况详情共用右侧工具位置,切换调查时先关闭 Dev 回 | 错误:JSON 语法 | 第二行语法错误时整批拒绝,不执行第一行合法动作 | | 错误:不支持的动作 | 拒绝协议未支持的动作 | | 错误:图表数据不齐 | 拒绝时间轴和序列长度不一致的图表 | +| 错误:同期时刻错位 | 拒绝历史时间与横轴槽位不一致的复合图,整批不执行 | 切换前若执行过本地预览,会先清除它的当前临时地图、图表和业务结果,保留动作历史供回看;仅选择或编辑 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"} ``` -仓库内的可复制数据示例为 [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 写入接口。 @@ -75,15 +96,16 @@ Dev 与工况详情共用右侧工具位置,切换调查时先关闭 Dev 回 ```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 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 动作按接收顺序执行,切换会话取消正在执行及排队动作;过期动作不会执行。任务仍只记录发布状态,不等待浏览器。查看历史回答不会重放动作。 -动作 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 提升为指令 | | 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,并在各仓库独立运行测试。 ## 展示动作历史 diff --git a/docs/style-design-guide.md b/docs/style-design-guide.md index bb31c66..bea39ab 100644 --- a/docs/style-design-guide.md +++ b/docs/style-design-guide.md @@ -194,6 +194,10 @@ Agent 与主工作区、地图与报告使用共享 `Resizable` 分栏;连续 每个业务区最多一个醒目的主操作。按钮覆盖默认、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` 和关联说明表达,失败后保留输入。选中与焦点不改变控件尺寸,底图选择卡片使用主题边框和勾选标记,键盘焦点使用共享内描边。 弹层保留标题、关闭入口、焦点管理及触发控件的焦点返回。表单弹窗显式将初始焦点放在首要业务字段,选择框获焦不自动展开。Esc 从内到外处理,外层尊重已消费事件;弹窗容器可承接程序焦点,业务控件仍保留可见焦点。模态遮罩阻断底层地图操作。 diff --git a/package.json b/package.json index 2a38e8b..2c82ada 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,8 @@ }, "dependencies": { "@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", "@json-render/core": "^0.21.0", "@json-render/react": "^0.21.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4978f23..f27f2c9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,6 +15,12 @@ importers: '@ai-sdk/react': specifier: ^4.0.9 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': specifier: 5.3.0 version: 5.3.0 @@ -470,6 +476,27 @@ packages: resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} 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': resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} @@ -825,6 +852,9 @@ packages: engines: {node: '>=18'} hasBin: true + '@preact/signals-core@1.14.5': + resolution: {integrity: sha512-vcRv7Ityz5JVmeiVqIluLFzT/PDS/AlhHUpw3/aUWqcVUs6bDGIPXE/6RSC4evBNWYvnniozg5Syfj33al3gkg==} + '@radix-ui/number@1.1.2': resolution: {integrity: sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==} @@ -4567,6 +4597,45 @@ snapshots: '@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': optional: true @@ -4860,6 +4929,8 @@ snapshots: dependencies: playwright: 1.61.1 + '@preact/signals-core@1.14.5': {} + '@radix-ui/number@1.1.2': {} '@radix-ui/primitive@1.1.6': {} diff --git a/scripts/export-view-command-contract.mjs b/scripts/export-view-command-contract.mjs index 105d7e5..29ca4fb 100644 --- a/scripts/export-view-command-contract.mjs +++ b/scripts/export-view-command-contract.mjs @@ -1,5 +1,6 @@ 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 { zodToJsonSchema } from "zod-to-json-schema"; 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] ]; +// 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) { if (process.argv.includes("--check")) { if ((await readFile(url, "utf8")) !== content) throw new Error(`View command contract differs: ${fileURLToPath(url)}`); } else { + await mkdir(dirname(fileURLToPath(url)), { recursive: true }); await writeFile(url, content); } } diff --git a/src/features/agent/answer-ui/answer-ui-interaction-context.ts b/src/features/agent/answer-ui/answer-ui-interaction-context.ts index 2afbc93..fa809bc 100644 --- a/src/features/agent/answer-ui/answer-ui-interaction-context.ts +++ b/src/features/agent/answer-ui/answer-ui-interaction-context.ts @@ -7,10 +7,10 @@ export type AnswerUiMapTarget = { }; export type AnswerUiInteractions = { disabled: boolean; + chartPresentation?: "tile"; onLocate?: (target: AnswerUiMapTarget) => Promise | void; onSubmit?: (text: string) => Promise | void; evidenceFilter?: string; submitMode?: "send" | "draft"; - chartWindowKey?: string; }; export const AnswerInteractionContext = createContext({ disabled: true }); diff --git a/src/features/agent/answer-ui/answer-ui-view.test.tsx b/src/features/agent/answer-ui/answer-ui-view.test.tsx index 8f24ef9..4c6dd1f 100644 --- a/src/features/agent/answer-ui/answer-ui-view.test.tsx +++ b/src/features/agent/answer-ui/answer-ui-view.test.tsx @@ -189,6 +189,30 @@ describe("local interactive answer renderer", () => { expect(screen.getByRole("tab", { name: /^柱状$/ })).toHaveAttribute("aria-selected", "true"); 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(); + 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(); + + 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 () => { const locate = vi .fn() diff --git a/src/features/agent/answer-ui/answer-ui-view.tsx b/src/features/agent/answer-ui/answer-ui-view.tsx index d9b90e1..a11e258 100644 --- a/src/features/agent/answer-ui/answer-ui-view.tsx +++ b/src/features/agent/answer-ui/answer-ui-view.tsx @@ -1,6 +1,6 @@ import { LocateButton, FollowUpForm } from "./answer-action-components"; 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 { Info, TriangleAlert, @@ -103,17 +103,19 @@ const registry = withAnswerNodeIdentity(baseRegistry); type AnswerUiViewProps = { block: AnswerUiBlock; viewState?: AnswerViewState; onViewStateChange?: AnswerViewStateChange } & Partial; 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 - + ; } function AnswerUiBody({ block, disabled = false, + chartPresentation, onLocate, onSubmit, - chartWindowKey, submitMode = "send" }: { block: AnswerUiBlock } & Partial) { 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 renderNodes = (ids: string[]) => ids.map((id) => ); return ( - - - -
+ + + {chartPresentation !== "tile" ? : null} +
<> {taskLayout ?
{taskLayout.overview.length ?
{renderNodes(taskLayout.overview)}
: null} diff --git a/src/features/agent/answer-ui/chart-presentation.test.ts b/src/features/agent/answer-ui/chart-presentation.test.ts index b8bda05..8f3e90a 100644 --- a/src/features/agent/answer-ui/chart-presentation.test.ts +++ b/src/features/agent/answer-ui/chart-presentation.test.ts @@ -1,5 +1,5 @@ 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", () => { 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 }); }); + 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", () => { 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"] }, diff --git a/src/features/agent/answer-ui/chart-presentation.ts b/src/features/agent/answer-ui/chart-presentation.ts index 60ede01..d12bfee 100644 --- a/src/features/agent/answer-ui/chart-presentation.ts +++ b/src/features/agent/answer-ui/chart-presentation.ts @@ -9,13 +9,24 @@ export function chartSampleTime(time: string | null | undefined): string { 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)) .filter((value): value is number => value !== null && Number.isFinite(value)); if (!values.length) return {}; const min = Math.min(...values), max = Math.max(...values); 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. */ diff --git a/src/features/agent/answer-ui/interactive-chart.tsx b/src/features/agent/answer-ui/interactive-chart.tsx index 04fc88e..a3a1eeb 100644 --- a/src/features/agent/answer-ui/interactive-chart.tsx +++ b/src/features/agent/answer-ui/interactive-chart.tsx @@ -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 { 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 { Card, CardContent, CardHeader, CardTitle } from "@/shared/ui/card"; 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 { agentEase } from "../components/agent-motion"; 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 { chartPointTooltip, chartSampleTime, chartUsesIntegerTicks, chartValueBounds, chartValueLabel } from "./chart-presentation"; export type AnswerChartProps = { title: string; @@ -35,7 +36,7 @@ function PointSelect({ label, value, labels, onChange }: { {labels.map((text, index) => {text})} ; } -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 = {}; /** 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) { - 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("Chart", props); const reducedMotion = useReducedMotion(); const pointer = useRef(false); @@ -56,13 +59,13 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer const [plotWidth, setPlotWidth] = useState(360); useLayoutEffect(() => { const element = plot.current; - if (props.expanded || !element || typeof ResizeObserver === "undefined") return; + if (fitted || !element || typeof ResizeObserver === "undefined") return; const resize = () => setPlotWidth(element.clientWidth); resize(); const observer = new ResizeObserver(resize); observer.observe(element); return () => observer.disconnect(); - }, [view, props.expanded]); + }, [view, fitted]); const contentMotion = useAnimationControls(); const pendingTransition = useRef(false); useLayoutEffect(() => { @@ -75,7 +78,7 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer }, [contentMotion, view]); const setView = (next: string) => { if (next === view) return; - const animate = pointer.current && !reducedMotion; + const animate = pointer.current && !reducedMotion && !tile; setAnimateInteraction(animate); contentMotion.stop(); 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 setPoint = (point: number) => patch({ point }); const setInspecting = (inspecting: boolean) => patch({ inspecting }); - const visible = props.series.filter((_, index) => !hidden.includes(index)); - const labels = props.x_data.slice(range[0], range[1] + 1); + const hiddenKey = hidden.join(","); + 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 fullRange = range[0] === 0 && range[1] === props.x_data.length - 1; const summaries = visible.map((series) => { @@ -98,61 +104,63 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer const categoryColors = visible.length === 1; const horizontalBars = props.x_data.some((label) => label.length > 10); const numberLabel = chartValueLabel; - const option = (type: "line" | "bar") => { - const horizontal = type === "bar" && horizontalBars; - const seriesType = (series: SafeChartSeries) => view === (props.chart_type === "bar" ? "bar" : "line") ? series.type ?? type : type; - const includesBars = visible.some((series) => seriesType(series) === "bar"); - const categoryAxis = { type: "category", data: labels, boundaryGap: type === "bar", - axisLabel: { fontSize: 11, color: "#64748b", hideOverlap: !horizontal, interval: horizontal ? 0 : "auto", - ...(horizontal ? { width: props.expanded ? 150 : Math.min(210, Math.max(96, plotWidth * 0.28)), overflow: "break", lineHeight: 16 } : {}) }, - axisTick: { alignWithLabel: true }, axisLine: { lineStyle: { color: "#cbd5e1" } } }; - const valueAxis = { type: "value", scale: !includesBars, - ...(!includesBars ? chartValueBounds(visible, range[0], range[1]) : {}), - axisLabel: { fontSize: 11, color: "#64748b", formatter: numberLabel }, - splitLine: { lineStyle: { color: "#e2e8f0" } } }; - return { - animation: animateInteraction && !reducedMotion, animationDuration: 180, animationDurationUpdate: 180, - textStyle: { fontFamily: '"Noto Sans SC Variable", "PingFang SC", "Microsoft YaHei", sans-serif' }, - color: colors.filter((_, index) => !hidden.includes(index)), aria: { enabled: true }, - tooltip: { trigger: composite ? "item" : "axis", renderMode: composite ? "html" : "richText", confine: true, - textStyle: { fontFamily: '"Noto Sans SC Variable", "PingFang SC", "Microsoft YaHei", sans-serif', fontSize: 12 }, - ...(composite ? { formatter: (params: unknown) => { - const first = (Array.isArray(params) ? params[0] : params) as { dataIndex?: number; seriesIndex?: number } | undefined; - const focused = composite && typeof first?.seriesIndex === "number" ? visible.slice(first.seriesIndex, first.seriesIndex + 1) : visible; - const text = typeof first?.dataIndex === "number" - ? chartPointTooltip(labels[first.dataIndex], focused, range[0] + first.dataIndex) : ""; - // ECharts richText tooltips omit the font family. Plain DOM text keeps CJK - // readable and never interprets model-provided labels as HTML. - const content = document.createElement("div"); - content.textContent = text; - content.style.whiteSpace = "pre-line"; - return content; - } } : {}), axisPointer: { type: type === "bar" ? "shadow" : "line" } }, - grid: { top: 28, right: 24, bottom: (props.xAxisName ?? props.x_axis_name) ? 48 : 28, left: 12, containLabel: true }, - xAxis: { ...(horizontal ? valueAxis : categoryAxis), name: horizontal ? props.yAxisName ?? props.y_axis_name : props.xAxisName ?? props.x_axis_name, - nameLocation: "middle", nameGap: 30 }, - yAxis: horizontal ? { ...categoryAxis, inverse: true, name: props.xAxisName ?? props.x_axis_name } : { ...valueAxis, name: props.yAxisName ?? props.y_axis_name }, - series: visible.map((series) => ({ name: series.name, type: seriesType(series), - data: series.data.slice(range[0], range[1] + 1).map((value, index) => type === "bar" && categoryColors - ? { value, itemStyle: { color: colors[(range[0] + index) % colors.length] } } : value), - barMaxWidth: 32, barCategoryGap: "35%", connectNulls: false, - showSymbol: labels.length <= 24, symbolSize: seriesType(series) === "scatter" ? 8 : 6, emphasis: { focus: "series" } })) + const xAxisName = props.xAxisName ?? props.x_axis_name; + const yAxisName = props.yAxisName ?? props.y_axis_name; + // Keep tooltip functions and options stable across sizing, focus and table-only updates. + const options = useMemo(() => { + const option = (type: "line" | "bar") => { + const horizontal = type === "bar" && horizontalBars; + const horizontalAxisName = horizontal ? yAxisName : xAxisName; + const verticalAxisName = horizontal ? xAxisName : yAxisName; + const seriesType = (series: SafeChartSeries) => view === (props.chart_type === "bar" ? "bar" : "line") ? series.type ?? type : type; + const includesBars = visible.some((series) => seriesType(series) === "bar"); + const categoryAxis = { type: "category", data: labels, boundaryGap: type === "bar", + axisLabel: { fontSize: tile ? 10 : 11, color: "#64748b", hideOverlap: !horizontal, interval: horizontal ? 0 : "auto", + ...(tile ? { margin: 4, lineHeight: 12 } : {}), + ...(horizontal ? { width: fitted && !tile ? 150 : Math.min(210, Math.max(96, plotWidth * 0.28)), overflow: "break", lineHeight: tile ? 12 : 16 } : {}) }, + axisTick: { alignWithLabel: true }, axisLine: { lineStyle: { color: "#cbd5e1" } } }; + const valueAxis = { type: "value", scale: !includesBars, + ...(!includesBars ? chartValueBounds(visible, rangeStart, rangeEnd, chartUsesIntegerTicks(yAxisName)) : {}), + ...(chartUsesIntegerTicks(yAxisName) ? { minInterval: 1 } : {}), + axisLabel: { fontSize: tile ? 10 : 11, color: "#64748b", formatter: numberLabel, + ...(tile ? { margin: 4, lineHeight: 12 } : {}) }, + splitLine: { lineStyle: { color: "#e2e8f0" } } }; + return { + animation: animateInteraction && !reducedMotion, animationDuration: 180, animationDurationUpdate: 180, + textStyle: { fontFamily: '"Noto Sans SC Variable", "PingFang SC", "Microsoft YaHei", sans-serif' }, + color: colors.filter((_, index) => !hiddenSeries.has(index)), aria: { enabled: true }, + tooltip: { trigger: composite ? "item" : "axis", renderMode: composite ? "html" : "richText", confine: true, + textStyle: { fontFamily: '"Noto Sans SC Variable", "PingFang SC", "Microsoft YaHei", sans-serif', fontSize: 12 }, + ...(composite ? { formatter: (params: unknown) => { + const first = (Array.isArray(params) ? params[0] : params) as { dataIndex?: number; seriesIndex?: number } | undefined; + const focused = composite && typeof first?.seriesIndex === "number" ? visible.slice(first.seriesIndex, first.seriesIndex + 1) : visible; + const text = typeof first?.dataIndex === "number" + ? chartPointTooltip(labels[first.dataIndex], focused, rangeStart + first.dataIndex) : ""; + // ECharts richText tooltips omit the font family. Plain DOM text keeps CJK + // readable and never interprets model-provided labels as HTML. + const content = document.createElement("div"); + content.textContent = text; + content.style.whiteSpace = "pre-line"; + return content; + } } : {}), 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" } })) + }; }; - }; - const metadata = <> - {!props.embedded && view !== "table" ?
-
- 区间摘要 · 本地统计 -
-
- {summaries.map((item) =>
- {item.name} - {numberLabel(item.first)} → {numberLabel(item.last)} - 最小 {numberLabel(item.min)} · 最大 {numberLabel(item.max)} -
)} -
-
: null} - {view !== "table" && inspecting ?
@@ -170,69 +178,26 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer
)} -
: null} + : null; + const metadata = <> + {!props.embedded && !tile && view !== "table" ?
+
+ 区间摘要 · 本地统计 +
+
+ {summaries.map((item) =>
+ {item.name} + {numberLabel(item.first)} → {numberLabel(item.last)} + 最小 {numberLabel(item.min)} · 最大 {numberLabel(item.max)} +
)} +
+
: null} + {pointDetails}

- {fullRange ? labels.length : `${labels.length} / ${props.x_data.length}`} 个数据点 · {visible.length} 组序列
来源:{props.source}
+ {fullRange ? labels.length : `${labels.length} / ${props.x_data.length}`} 个数据点 · {visible.length} 组序列{props.embedded ? "" : <>
来源:{props.source}}

; - return { pointer.current = true; }} - onKeyDownCapture={() => { - pointer.current = false; - setAnimateInteraction(false); - contentMotion.stop(); - contentMotion.set({ opacity: 1, y: 0 }); - }}> - - {props.embedded || props.expanded ? "图形与数据" : props.title} -
- {!props.embedded && !props.expanded ? - {() => } - : null} - - - -
-
- - {migrationNotice ?

{migrationNotice}

: null} - - [role=tab]]:relative [&>[role=tab]]:z-10 [&>[role=tab][data-state=active]]:bg-transparent [&>[role=tab][data-state=active]]:shadow-none`}> - - {composite ? "复合" : "折线"} - 柱状 - 数据 - - -
- {!fullRange ?

{props.x_data[range[0]]} 至 {props.x_data[range[1]]}

: null} - + const rangeControls =
setRange([start, Math.max(start, range[1])])} /> @@ -242,12 +207,63 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer
- +
; + return { pointer.current = true; }} + onKeyDownCapture={() => { + pointer.current = false; + setAnimateInteraction(false); + contentMotion.stop(); + contentMotion.set({ opacity: 1, y: 0 }); + }}> + {!props.embedded ? + {props.expanded ? "图形与数据" : props.title} +
button]:size-6" : ""}`}> + {!props.embedded && !props.expanded ? + {() => } + : null} + {tile ? + + {props.source} : + + } +
+
: null} + + {migrationNotice ?

{migrationNotice}

: null} + +
+ [role=tab]]:relative [&>[role=tab]]:z-10 [&>[role=tab][data-state=active]]:bg-transparent [&>[role=tab][data-state=active]]:shadow-none`}> + + {composite ? "复合" : "折线"} + 柱状 + 数据 + + {tile ? <> + + {rangeControls} + + {inspecting && view !== "table" ? + event.preventDefault()}>{pointDetails} + : null} + : props.embedded ? + + : null}
- - {(["line", "bar"] as const).map((type) => + + {!tile ?
+ {!fullRange ?

{props.x_data[range[0]]} 至 {props.x_data[range[1]]}

: null} + {!tile ? {rangeControls} : null} +
: null} + + {(["line", "bar"] as const).map((type) =>
{ if (event.target !== event.currentTarget) return; 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); }}> 点击图中数据点查看详情,也可使用回车和左右方向键。 -
- patch({ point: range[0] + index, inspecting: true })} /> +
+ patch({ point: range[0] + index, inspecting: true })} />
)} - + series.name)]} rows={labels.map((label, index) => [label, ...visible.map((series) => { 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}`))} /> - {!props.expanded ? metadata : null} +
+
+ {props.series.map((series, index) => )} +
+ {hidden.length ? : null} + {view === "bar" && categoryColors ?
= 700 ? 3 : plotWidth >= 420 ? 2 : 1},minmax(0,1fr))` }}> + {labels.map((label, index) =>
+ + {label} + {numberLabel(visible[0].data[range[0] + index])} +
)} +
: null} + {props.expanded && !tile ? metadata : null} +
+ {!fitted ? metadata : null} ; diff --git a/src/features/agent/answer-ui/interactive-data-table.tsx b/src/features/agent/answer-ui/interactive-data-table.tsx index 16b6add..18a311c 100644 --- a/src/features/agent/answer-ui/interactive-data-table.tsx +++ b/src/features/agent/answer-ui/interactive-data-table.tsx @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react"; +import { ArrowDown, ArrowUp } from "lucide-react"; import { Input } from "@/shared/ui/input"; import { Button } from "@/shared/ui/button"; import { FloatingWindow } from "@/shared/ui/floating-window"; @@ -45,13 +45,13 @@ export function InteractiveDataTable({ title, columns, rows, state: controlledSt {title} - {columns.map((column, index) => - )} diff --git a/src/features/agent/answer-ui/responsive-chart.test.tsx b/src/features/agent/answer-ui/responsive-chart.test.tsx new file mode 100644 index 0000000..6c10807 --- /dev/null +++ b/src/features/agent/answer-ui/responsive-chart.test.tsx @@ -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
; + }) }; +}); + +const observers: ResizeObserverCallback[] = []; +const frames = new Map(); +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(
+ {Array.from({ length: count }, (_, index) => undefined} />)} +
); + 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); +}); diff --git a/src/features/agent/answer-ui/responsive-chart.tsx b/src/features/agent/answer-ui/responsive-chart.tsx index d72d072..7d3dae2 100644 --- a/src/features/agent/answer-ui/responsive-chart.tsx +++ b/src/features/agent/answer-ui/responsive-chart.tsx @@ -1,7 +1,9 @@ import { useLayoutEffect, useRef, type ComponentProps } from "react"; import ReactECharts from "echarts-for-react"; -/** Resize the existing drawing at most once per frame without replacing its options. */ +const resizedWindows = new WeakMap(); + +/** Reflow the existing drawing, coalescing rapid pointer resizes without scaling it. */ export function ResponsiveChart({ option, height, onPointSelect }: { option: ComponentProps["option"]; height: number | "100%"; @@ -13,16 +15,32 @@ export function ResponsiveChart({ option, height, onPointSelect }: { useLayoutEffect(() => { const element = host.current; if (!element || typeof ResizeObserver === "undefined") return; + const shell = element.closest("[data-floating-window]"); let frame = 0; - const observer = new ResizeObserver(() => { - cancelAnimationFrame(frame); - frame = requestAnimationFrame(() => { - if (!ready.current || !element.clientWidth || !element.clientHeight) return; - chart.current?.getEchartsInstance().resize({ width: "auto", height: "auto", animation: { duration: 0 } }); - }); + let timer = 0; + const resize = () => { + frame = 0; + if (!ready.current || !element.clientWidth || !element.clientHeight) return; + // 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); - 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
>; embedded?: boolean; expanded?: boolean }) { - return >; embedded?: boolean; expanded?: boolean; plotHeight?: number; tile?: boolean }) { + return ; }); -function SafeChartEnvelope({ envelope, embedded = false, expanded = false }: { envelope: Extract; embedded?: boolean; expanded?: boolean }) { +function SafeChartEnvelope({ envelope, embedded = false, expanded = false, plotHeight, tile = false }: { envelope: Extract; embedded?: boolean; expanded?: boolean; plotHeight?: number; tile?: boolean }) { const parsed = parseChartSpec(envelope.spec); const raw = normalizeSafeChartData(envelope.data, 100); 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)} source="来源见关联依据。" embedded={embedded} - expanded={expanded} + expanded={expanded} plotHeight={plotHeight} tile={tile} xAxisName={spec.xAxisName} yAxisName={spec.yAxisName} />; diff --git a/src/features/agent/index.ts b/src/features/agent/index.ts index 97f70c7..eb3e2fe 100644 --- a/src/features/agent/index.ts +++ b/src/features/agent/index.ts @@ -116,3 +116,4 @@ export { readInteractionReply } from "./api/interaction-requests"; export type { InteractionRequestsController } from "./hooks/use-interaction-requests"; export { presentationCommandSchema, presentationOverlayDataSchema, PRESENTATION_ACTIONS, type PresentationCommand } from "./presentation/command"; +export { chartPublicationError, safeChartPublicationInput } from "./presentation/command-schema"; diff --git a/src/features/agent/presentation/chart-publication.test.ts b/src/features/agent/presentation/chart-publication.test.ts new file mode 100644 index 0000000..607793d --- /dev/null +++ b/src/features/agent/presentation/chart-publication.test.ts @@ -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); + }); +}); diff --git a/src/features/agent/presentation/command-schema.ts b/src/features/agent/presentation/command-schema.ts index ed51d99..b6ce31d 100644 --- a/src/features/agent/presentation/command-schema.ts +++ b/src/features/agent/presentation/command-schema.ts @@ -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; sample_times?: Array }>; +}; + +/** 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(); + 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(); + 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. */ -export function createViewSchemas(zod: typeof z = z, publication = false) { +export function createViewSchemas(zod: typeof z = z, publication = false, validateCharts = publication) { const z = zod; 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.every((time, index) => time === null || series.data[index] !== null))), "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 .tuple([z.number().finite().min(-180).max(180), z.number().finite().min(-90).max(90)]) .rest(z.number().finite()) @@ -213,8 +272,9 @@ export const { overlayDataSchema, variants: viewCommandVariants } = createViewSchemas(); -export const createViewCommandSchema = (options: { publication: boolean }) => - createViewSchemas(z, options.publication).command; +export const safeChartPublicationInput = createViewSchemas(z, true).safeChartInput; +export const createViewCommandSchema = (options: { publication: boolean; validateCharts?: boolean }) => + createViewSchemas(z, options.publication, options.validateCharts ?? options.publication).command; type Command = z.infer>; export type ViewCommand = | Exclude diff --git a/src/features/map/core/components/base-layers-control.tsx b/src/features/map/core/components/base-layers-control.tsx index b8a5120..3ff5907 100644 --- a/src/features/map/core/components/base-layers-control.tsx +++ b/src/features/map/core/components/base-layers-control.tsx @@ -2,13 +2,13 @@ import { Check, Map } from "lucide-react"; import { type FocusEvent, useCallback, useEffect, useRef, useState } from "react"; import { cn } from "@/shared/ui/cn"; import { + mapOptionInteractionVariants, MAP_CONTROL_RADIUS_CLASS_NAME, MAP_CONTROL_SURFACE_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_MAJOR_PANEL_RADIUS_CLASS_NAME, MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME, - MAP_SELECTION_CARD_FOCUS_CLASS_NAME, MAP_TOOL_PANEL_SURFACE_CLASS_NAME } from "./map-control-styles"; @@ -105,18 +105,18 @@ export function BaseLayersControl({ + + ))} : null} diff --git a/src/features/workbench/components/investigation-chart-group.tsx b/src/features/workbench/components/investigation-chart-group.tsx new file mode 100644 index 0000000..43134b0 --- /dev/null +++ b/src/features/workbench/components/investigation-chart-group.tsx @@ -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(null); + const [plotHeight, setPlotHeight] = useState(220); + useLayoutEffect(() => { + if (tile) return; + const viewport = card.current?.closest("[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) => ; + const content = (expanded: boolean, compact = false) => compact ?
+ {multiple ?
+ +
: null} +
+
+ {multiple ? objectButtons(true) : null} +
+
:
+
+
+ {multiple ?

{selected.label}{selected.command.chart.x_data[0]} 至 {selected.command.chart.x_data.at(-1)}

: null} + +
+ {multiple ? objectButtons() : null} +
+ {selected.command.evidence_refs.length ?
{references(selected.command.evidence_refs)}
: null} +
; + return + {tracked ? group.items.slice(1).map((item) => ) : null} + +
+

{title}

+ {!tile ?

{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" ? "类别" : "时间点"}

: null} +
+
button]:size-6" : ""}`}> + {tile && selected.command.evidence_refs.length ? + {references(selected.command.evidence_refs)} + : null} + {() => content(true)} + +
+
+ + {content(tile, tile)} + +
; +} diff --git a/src/features/workbench/components/investigation-chart-manager.tsx b/src/features/workbench/components/investigation-chart-manager.tsx new file mode 100644 index 0000000..a190afd --- /dev/null +++ b/src/features/workbench/components/investigation-chart-manager.tsx @@ -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(() => [ + ...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(null); + const [located, setLocated] = useState<{ id: string } | null>(null); + const [announcement, setAnnouncement] = useState(""); + const cards = useRef(new Map()); + const pendingLocation = useRef(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 + 图表窗口 {count}} + initialSize={{ width: 1120, height: 760 }} fillContent + // A session gets one automatic request. Updates never revoke a user's dismissal. + openRequestKey={autoOpen ? "live-charts" : undefined}> + {() =>
+
+
+

{taskTitle}

{reviewing ? "历史回看" : "当前结果"} · 拖到固定位置交换,支持单图放大

+ {count} 张图表 +
+
+ +
+
+ {layouts.map(({ id, label, icon: Icon }) => )} +
+ + + +
+
+
+

拖动整条位置栏到目标位置可交换图表。向下滚动查看全部图表,也可快速定位。

+

{announcement}

+ +
+ {count ?
+ {slots.map((id, index) => )} + {entries.map((entry) => { + const index = slots.indexOf(entry.id); + return { if (node) cards.current.set(id, node); else cards.current.delete(id); }}> + {entry.group ? : } + ; + })} +
:

当前没有图表,新的图表将在这里更新。

} +
+ + {(source) =>
{String(source.data.title)}
} +
+
+
} + + ; +} diff --git a/src/features/workbench/components/investigation-chart-slot.tsx b/src/features/workbench/components/investigation-chart-slot.tsx new file mode 100644 index 0000000..2b7f2a2 --- /dev/null +++ b/src/features/workbench/components/investigation-chart-slot.tsx @@ -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
+ {!occupied ?

位置 {index + 1}拖动图表到这里

: null} +
; +} + +/** 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
{ 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"> +
+ +
+
{children}
+
; +} diff --git a/src/features/workbench/components/investigation-presentation-panel.tsx b/src/features/workbench/components/investigation-presentation-panel.tsx index 543b6c8..4560131 100644 --- a/src/features/workbench/components/investigation-presentation-panel.tsx +++ b/src/features/workbench/components/investigation-presentation-panel.tsx @@ -1,8 +1,7 @@ import { useId, useState } from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; -import { ChevronDown, Eye, EyeOff, LocateFixed, MapPinned, Pause, X } from "lucide-react"; -import { AgentChartView, type AnswerUiBlock } from "@/features/agent"; -import { formatChartDisplayText } from "@/shared/utils/date-time-text"; +import { ChevronDown, Eye, EyeOff, LocateFixed, MapPinned, Pause } from "lucide-react"; +import type { AnswerUiBlock } from "@/features/agent"; import { Badge } from "@/shared/ui/badge"; import { Button } from "@/shared/ui/button"; 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 { 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 { PresentationBusinessResults } from "./presentation-business-results"; import type { PresentationHistoryControls } from "../utils/presentation-view-history"; @@ -36,6 +37,8 @@ type Props = { task: OperationalTask | null; localPreview?: boolean; charts: InvestigationChart[]; + chartWindowAutoOpen?: boolean; + chartWindowScope?: string; overlay: InvestigationOverlay | null; cameraPaused: boolean; status: "connecting" | "live" | "history" | "disconnected" | "error"; @@ -68,6 +71,8 @@ export function InvestigationPresentationPanel({ onLocateTargets, localPreview = false, charts, + chartWindowAutoOpen = false, + chartWindowScope, overlay, cameraPaused, status, @@ -80,13 +85,13 @@ export function InvestigationPresentationPanel({ onFitOverlay, onToggleLayer }: Props) { + const chartGroups = groupInvestigationCharts(charts); const [collapsed, setCollapsed] = useState(false); const [keyboardAction, setKeyboardAction] = useState(false); const reducedMotion = useReducedMotion(); const immediate = Boolean(reducedMotion) || keyboardAction; const contentId = useId(); 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 pending = selected ? history!.entries.filter((entry) => entry.id > selected.id).length : 0; const title = scope === "chat" ? "对话展示" : localPreview ? "本地预览展示" : "调查实时展示"; @@ -121,7 +126,7 @@ export function InvestigationPresentationPanel({
@@ -137,7 +142,7 @@ export function InvestigationPresentationPanel({ : statusLabels[status]} - 图表 {charts.length} · 范围 {overlay ? 1 : 0} · 数据 {records.length} + 图表 {chartGroups.length} · 范围 {overlay ? 1 : 0} · 数据 {records.length} {reviewing && pending > 0 ? ( @@ -146,6 +151,10 @@ export function InvestigationPresentationPanel({ ) : null}
+
+ +

) : null} - + {overlay ? ( @@ -291,51 +301,10 @@ export function InvestigationPresentationPanel({ ) : null} - {charts.map((item) => ( - - - -

-

- {formatChartDisplayText(item.chart.title)} -

-

- {item.chart.series.some((series) => series.type === "scatter") ? "折线与历史散点" : item.chart.chart_type === "bar" ? "柱状图" : "折线图"} ·{" "} - {item.chart.x_data.length} 个 - {item.chart.chart_type === "bar" ? "类别" : "时间点"} -

-
-
- - {() =>
- - {item.evidence_refs.length ?
{references(item.evidence_refs)}
: null} -
} -
- -
- - - - {references(item.evidence_refs)} - - + {chartGroups.map((group) => ( + + ))} @@ -343,7 +312,6 @@ export function InvestigationPresentationPanel({ blocks={blocks} records={records} reviewing={reviewing} - chartWindowKey={chartWindowKey} onLocateTargets={onLocateTargets} /> {!charts.length && diff --git a/src/features/workbench/components/presentation-business-results.tsx b/src/features/workbench/components/presentation-business-results.tsx index 4d7a681..b535204 100644 --- a/src/features/workbench/components/presentation-business-results.tsx +++ b/src/features/workbench/components/presentation-business-results.tsx @@ -7,7 +7,6 @@ type Props = { blocks: AnswerUiBlock[]; records: Array>; reviewing: boolean; - chartWindowKey?: string; onLocateTargets?: (targets: InvestigationMapTarget[]) => Promise; }; @@ -15,7 +14,6 @@ export function PresentationBusinessResults({ blocks, records, reviewing, - chartWindowKey, onLocateTargets }: Props) { if (!blocks.length && !records.length) return null; @@ -26,7 +24,6 @@ export function PresentationBusinessResults({ key={block.block_id} block={block} disabled={reviewing} - chartWindowKey={chartWindowKey} onLocate={ onLocateTargets && !reviewing ? (target) => diff --git a/src/features/workbench/components/workbench-top-bar-menus.tsx b/src/features/workbench/components/workbench-top-bar-menus.tsx index 128991e..d3f5857 100644 --- a/src/features/workbench/components/workbench-top-bar-menus.tsx +++ b/src/features/workbench/components/workbench-top-bar-menus.tsx @@ -20,6 +20,8 @@ import { useEffect, useRef, useState, type ReactNode } from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { ProjectSwitchDialog } from "./project-switch-dialog"; import { + mapOptionInteractionVariants, + MAP_ACTION_ROW_INTERACTION_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_MAJOR_PANEL_RADIUS_CLASS_NAME, @@ -53,11 +55,12 @@ const menuSurfaceClassName = cn( MAP_MAJOR_PANEL_RADIUS_CLASS_NAME, "acrylic-panel border p-2 text-slate-900" ); -const menuReadableRowClassName = +const alertReadableRowClassName = "surface-well focus:bg-[var(--action-soft)] focus:text-slate-950"; const alertActionClassName = cn( 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({ @@ -243,7 +246,7 @@ export function AlertMenu({ 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)]", MAP_COMPACT_RADIUS_CLASS_NAME, - menuReadableRowClassName + alertReadableRowClassName )} > void onLogout() : undefined} className={cn( "px-2 py-2", - !onLogout && "text-[var(--ink-muted)]", - MAP_COMPACT_RADIUS_CLASS_NAME + MAP_ACTION_ROW_INTERACTION_CLASS_NAME, + !onLogout && "text-[var(--ink-muted)]" )} >