From e0fe4a5a10160ae5bfd6e33df996a0429b074a65 Mon Sep 17 00:00:00 2001 From: Huarch Date: Sun, 8 Nov 2026 03:04:14 +0800 Subject: [PATCH] fix(charts): preserve comparison types and draggable window headings Keep current lines and historical scatter points in a dedicated comparison chart type. Expose floating headings and use idempotent placement so StrictMode no longer masks production overlap. --- .../agent/answer-ui/answer-view-state.ts | 2 +- src/features/agent/answer-ui/catalog.ts | 4 +- .../agent/answer-ui/interactive-chart.tsx | 4 +- .../agent/presentation/command-schema.ts | 2 +- src/shared/ui/floating-window.tsx | 27 +++++++++--- .../investigation-chart-windows.e2e.ts | 44 +++++++++++++++++++ tests/browser/scada-comparison-chart.e2e.ts | 6 +-- 7 files changed, 73 insertions(+), 16 deletions(-) diff --git a/src/features/agent/answer-ui/answer-view-state.ts b/src/features/agent/answer-ui/answer-view-state.ts index 6203b94..e00cccc 100644 --- a/src/features/agent/answer-ui/answer-view-state.ts +++ b/src/features/agent/answer-ui/answer-view-state.ts @@ -31,7 +31,7 @@ export function normalizeAnswerNodeState(node: Node, input?: unknown): AnswerUiN const start = index(proposed[0], labels.length); const end = Math.max(start, index(proposed[1], labels.length, labels.length - 1)); const hidden = indices(state.hidden, series.length); - const chart: AnswerChartState = { type: "Chart", view: ["line", "bar", "table"].includes(String(state.view)) ? state.view as AnswerChartState["view"] : node.props.chart_type as "line" | "bar", + const chart: AnswerChartState = { type: "Chart", view: ["line", "bar", "table"].includes(String(state.view)) ? state.view as AnswerChartState["view"] : node.props.chart_type === "bar" ? "bar" : "line", hidden: hidden.length < series.length ? hidden : [], range: [start, end], point: index(state.point, labels.length), inspecting: state.inspecting === true }; const data = answerChartTableData(node, chart); const table = normalizeAnswerNodeState({ type: "Table", props: data, children: [] }, { ...record(state.table), type: "Table" }) as AnswerTableState; diff --git a/src/features/agent/answer-ui/catalog.ts b/src/features/agent/answer-ui/catalog.ts index e6766ec..fde4374 100644 --- a/src/features/agent/answer-ui/catalog.ts +++ b/src/features/agent/answer-ui/catalog.ts @@ -64,7 +64,7 @@ export const answerUiCatalog = defineCatalog(schema, { props: z .object({ title: shortText, - chart_type: z.enum(["line", "bar"]), + chart_type: z.enum(["line", "bar", "time_comparison"]), x_axis_name: z.string().max(128).optional(), y_axis_name: z.string().max(128).optional(), x_data: z.array(shortText).min(1).max(100), @@ -82,7 +82,7 @@ export const answerUiCatalog = defineCatalog(schema, { }) .strict(), description: - "展示查询或用户提供的真实数据;series 必须与 x_data 对齐。浏览者可切换图形与数据表。" + "展示真实数据。chart_type=line 为普通折线,bar 为柱状图,time_comparison 为当前曲线与历史散点的同期对比。同期对比的 x_data 为 UTC+8 的 HH:mm 或 HH:mm:ss 共享时刻槽,series.type 使用 line/scatter,每组 sample_times 与 data 等长,保留真实带时区日期,缺测均填 null;历史点放入对应时刻槽。浏览者可切换图形与数据表。" }, Evidence: { props: z diff --git a/src/features/agent/answer-ui/interactive-chart.tsx b/src/features/agent/answer-ui/interactive-chart.tsx index 2832e74..04fc88e 100644 --- a/src/features/agent/answer-ui/interactive-chart.tsx +++ b/src/features/agent/answer-ui/interactive-chart.tsx @@ -16,7 +16,7 @@ import { AnswerInteractionContext } from "./answer-ui-interaction-context"; export type AnswerChartProps = { title: string; - chart_type: "line" | "bar"; + chart_type: "line" | "bar" | "time_comparison"; x_data: string[]; series: SafeChartSeries[]; x_axis_name?: string; @@ -100,7 +100,7 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer const numberLabel = chartValueLabel; const option = (type: "line" | "bar") => { const horizontal = type === "bar" && horizontalBars; - const seriesType = (series: SafeChartSeries) => view === props.chart_type ? series.type ?? type : type; + 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", diff --git a/src/features/agent/presentation/command-schema.ts b/src/features/agent/presentation/command-schema.ts index 947fae1..ed51d99 100644 --- a/src/features/agent/presentation/command-schema.ts +++ b/src/features/agent/presentation/command-schema.ts @@ -15,7 +15,7 @@ export function createViewSchemas(zod: typeof z = z, publication = false) { const chartSchema = z .object({ title: z.string().max(200), - chart_type: z.enum(["line", "bar"]), + chart_type: z.enum(["line", "bar", "time_comparison"]), x_axis_name: z.string().max(128).optional(), y_axis_name: z.string().max(128).optional(), x_data: z.array(z.string().max(128)).min(1).max(100), diff --git a/src/shared/ui/floating-window.tsx b/src/shared/ui/floating-window.tsx index d2a9729..5b1fc16 100644 --- a/src/shared/ui/floating-window.tsx +++ b/src/shared/ui/floating-window.tsx @@ -1,4 +1,4 @@ -import { useEffect, useId, useRef, useState, useSyncExternalStore, type ReactNode } from "react"; +import { useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { Rnd } from "react-rnd"; import { Maximize2, Minimize2, X } from "lucide-react"; @@ -48,16 +48,31 @@ export function FloatingWindow({ title, triggerLabel, fillContent = false, openR }; useEffect(() => () => removeFloatingWindow(id), [id]); - useEffect(() => { + useLayoutEffect(() => { if (!open) return; - const offset = (floatingWindowOrder().filter((windowId) => windowId !== id).length % 5) * 24; - setBounds((current) => fitWindow({ ...current, x: current.x + offset, y: current.y + offset })); + const others = floatingWindowOrder().filter((windowId) => windowId !== id); + const headingHeight = Array.from(floatingWindowHost().querySelectorAll(".floating-window-heading")) + .reduce((height, element) => Math.max(height, element.offsetHeight), 48); + const step = headingHeight + 8; + const minHeight = Math.min(260, window.innerHeight - 16); + const slots = Math.max(1, Math.floor((window.innerHeight - 64 - minHeight - 8) / step) + 1); + const slot = others.length % slots; + const y = Math.max(8, Math.min(64 + slot * step, window.innerHeight - minHeight - 8)); + // Keep the preceding headings exposed. Shorter viewports reduce content + // height instead of clamping several windows onto the same title bar. + // Absolute placement also makes StrictMode's repeated setup idempotent. + setBounds(fitWindow({ x: (window.innerWidth - 800) / 2 + slot * 24, y, + width: 800, height: Math.min(560, window.innerHeight - y - 8) })); raiseFloatingWindow(id); if (focusOnOpen.current) heading.current?.focus({ preventScroll: true }); focusOnOpen.current = false; const resize = () => { setViewport({ width: window.innerWidth, height: window.innerHeight }); - setBounds((current) => fitWindow(current)); + setBounds((current) => { + const minimumHeight = Math.min(260, window.innerHeight - 16); + const y = Math.max(8, Math.min(current.y, window.innerHeight - minimumHeight - 8)); + return fitWindow({ ...current, y, height: Math.min(current.height, window.innerHeight - y - 8) }); + }); }; window.addEventListener("resize", resize); return () => { @@ -75,9 +90,7 @@ export function FloatingWindow({ title, triggerLabel, fillContent = false, openR aria-label={triggerLabel ?? `放大查看 ${title}`} title="放大查看" aria-haspopup="dialog" aria-expanded={open} onClick={() => { if (!open) { - const offset = (order.length % 5) * 24; setViewport({ width: window.innerWidth, height: window.innerHeight }); - setBounds(fitWindow({ x: (window.innerWidth - 800) / 2 + offset, y: 64 + offset, width: 800, height: 560 })); setMaximized(false); focusOnOpen.current = true; setManuallyOpen(true); diff --git a/tests/browser/investigation-chart-windows.e2e.ts b/tests/browser/investigation-chart-windows.e2e.ts index c77a6a2..b6b7ce8 100644 --- a/tests/browser/investigation-chart-windows.e2e.ts +++ b/tests/browser/investigation-chart-windows.e2e.ts @@ -9,6 +9,50 @@ const chart = (index: number) => ({ test.setTimeout(60_000); +for (const height of [900, 720]) { + test(`multiple chart headings stay accessible and drag on first press at ${height}px height`, async ({ page }, testInfo) => { + await page.setViewportSize({ width: 1440, height }); + const task = investigationFixture(1, "多图窗口拖动核查"); + const mock = await mockInvestigationPresentation(page, [task]); + await selectInvestigation(page, task.title); + for (let index = 1; index <= 4; index++) { + await mock.emit(task, { ...chart(index), chart_id: `pressure-${index}` }); + await expect(page.getByRole("dialog", { name: `合成压力分析 ${index}`, exact: true })).toBeVisible(); + } + if (height === 900) await page.setViewportSize({ width: 1440, height: 720 }); + for (let index = 1; index <= 4; index++) { + const popup = page.getByRole("dialog", { name: `合成压力分析 ${index}`, exact: true }); + const heading = popup.locator(".floating-window-heading"); + const box = (await heading.boundingBox())!; + expect((await popup.boundingBox())!.y).toBe(64 + (index - 1) * (box.height + 8)); + const target = { x: box.x + box.width / 2, y: box.y + box.height / 2 }; + expect(await page.evaluate(({ x, y }) => document.elementFromPoint(x, y)?.closest("[data-floating-window]")?.getAttribute("aria-labelledby"), target)) + .toBe(await popup.getAttribute("aria-labelledby")); + } + await testInfo.attach(`multiple-charts-before-drag-${height}`, { + body: await page.screenshot({ path: testInfo.outputPath(`multiple-charts-before-drag-${height}.png`) }), contentType: "image/png" + }); + for (const index of [1, 4, 2]) { + const popup = page.getByRole("dialog", { name: `合成压力分析 ${index}`, exact: true }); + const before = (await popup.boundingBox())!; + const heading = (await popup.locator(".floating-window-heading").boundingBox())!; + const start = { x: heading.x + heading.width - 110, y: heading.y + heading.height / 2 }; + await page.mouse.move(start.x, start.y); + await page.mouse.down(); + await page.mouse.move(start.x - 120, start.y - 32, { steps: 12 }); + 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())!.y - before.y)).toBe(-32); + expect((await popup.boundingBox())!.width).toBe(before.width); + expect((await popup.boundingBox())!.height).toBe(before.height); + } + await testInfo.attach(`multiple-charts-after-drag-${height}`, { + body: await page.screenshot({ path: testInfo.outputPath(`multiple-charts-after-drag-${height}.png`) }), contentType: "image/png" + }); + expect(mock.mutations).toEqual([]); + }); +} + test("live analysis opens a window, preserves its position and respects closing and task switches", async ({ page }, testInfo) => { const task = investigationFixture(1, "图表自动展示核查"); const other = investigationFixture(2, "另一项调查"); diff --git a/tests/browser/scada-comparison-chart.e2e.ts b/tests/browser/scada-comparison-chart.e2e.ts index a23aa35..960fdbd 100644 --- a/tests/browser/scada-comparison-chart.e2e.ts +++ b/tests/browser/scada-comparison-chart.e2e.ts @@ -9,8 +9,8 @@ const times = Array.from({ length: 13 }, (_, index) => `${String(9 + Math.floor( const block = { version: "answer-ui@1", block_id: "scada-week", phase: "final", fallback_text: "合成 SCADA 七日对照", spec: { root: "chart", elements: { chart: { type: "Chart", children: [], props: { - title: "压力与前七天同时刻对照(m)", chart_type: "line", y_axis_name: "压力(m)", x_axis_name: "时间(UTC0)", - x_data: times.map((time) => new Date(`2026-06-14T${time}:00+08:00`).toISOString().slice(11, 16)), + title: "压力与前七天同时刻对照(m)", chart_type: "time_comparison", y_axis_name: "压力(m)", x_axis_name: "时间", + x_data: times, series: [{ name: "当日三小时", type: "line", data: [34, 34.2, 33.8, 34, 33.5, 33, 32.5, 32, 31, 30, 29, 27, 25], sample_times: times.map((time) => new Date(`2026-06-14T${time}:00+08:00`).toISOString().replace(".000Z", "Z")) }, ...Array.from({ length: 7 }, (_, index) => ({ name: `6月${13 - index}日`, type: "scatter", @@ -45,7 +45,7 @@ for (const width of [1440, 375, 320]) { await panel.getByRole("button", { name: "发送 Agent 指令" }).click(); const ui = panel.locator(".answer-ui-chart"); await expect(ui).toBeVisible(); - await expect(ui).toContainText("时间(UTC+8)"); + await expect(ui.locator("svg text").filter({ hasText: /^时间$/ })).toBeVisible(); await expect(ui.getByRole("tab", { name: "复合", exact: true })).toBeVisible(); await expect(ui.getByLabel("显示的数据序列").getByRole("button")).toHaveCount(8); // Each historical day has exactly three unconnected SVG scatter symbols.