feat(workbench): expand SCADA monitoring and investigation presentation

This commit is contained in:
2026-11-07 19:50:14 +08:00
parent 0801e161ec
commit ba71e2d529
133 changed files with 6754 additions and 1438 deletions
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import type { Spec } from "@json-render/core";
import { withAnswerDisplayTimes } from "./answer-display-time";
describe("answer presentation time zone", () => {
it("formats report text, tables, evidence and axes while preserving IDs and source samples", () => {
const spec: Spec = { root: "root", elements: {
root: { type: "Result", props: { title: "诊断", summary: "目标2026-07-16T00:00:00Z,当前08:00(UTC+8)" }, children: [] },
table: { type: "Table", props: { title: "读数", columns: ["时间"], rows: [["2026-07-16T00:00:00Z"]] }, children: [] },
evidence: { type: "Evidence", props: { observed_at: "2026-07-16T00:00:00Z", evidence_id: "2026-07-16T00:00:00Z" }, children: [] },
chart: { type: "Chart", props: { x_axis_name: "时间(UTC0)", x_data: ["00:00 UTC"],
series: [{ name: "压力", data: [20], sample_times: ["2026-07-16T00:00:00Z"] }] }, children: [] }
} };
const original = JSON.stringify(spec);
const result = withAnswerDisplayTimes(spec);
expect(result.elements.root.props.summary).toBe("目标2026/7/16 08:00:00(UTC+8),当前08:00(UTC+8)");
expect(result.elements.table.props.rows).toEqual([["2026/7/16 08:00:00(UTC+8)"]]);
expect(result.elements.evidence.props.observed_at).toBe("2026/7/16 08:00:00(UTC+8)");
expect(result.elements.evidence.props.evidence_id).toBe("2026-07-16T00:00:00Z");
expect(result.elements.chart.props.x_axis_name).toBe("时间(UTC+8)");
expect(result.elements.chart.props.x_data).toEqual(["08:00"]);
expect(JSON.stringify(spec)).toBe(original);
expect(withAnswerDisplayTimes(result)).toEqual(result);
});
it("converts bare times when their table header or chart axis explicitly declares UTC", () => {
const spec: Spec = { root: "chart", elements: {
chart: { type: "Chart", props: { x_axis_name: "时间(UTC0)", x_data: ["00:00", "2026-12-31 17:00", "08:00+08:00"],
series: [{ name: "压力", data: [20, 21, 22] }] }, children: [] },
table: { type: "Table", props: { columns: ["观测时间(UTC)", "测点编号"],
rows: [["2026-12-31 17:00", "00:00"]] }, children: [] }
} };
const result = withAnswerDisplayTimes(spec);
expect(result.elements.chart.props.x_data).toEqual(["08:00", "2027/1/1 01:00:00", "08:00+08:00"]);
expect(result.elements.table.props.columns).toEqual(["观测时间(UTC+8)", "测点编号"]);
expect(result.elements.table.props.rows).toEqual([["2027/1/1 01:00:00(UTC+8)", "00:00"]]);
expect(withAnswerDisplayTimes(result)).toEqual(result);
});
});
@@ -0,0 +1,37 @@
import type { Spec } from "@json-render/core";
import { formatDisplayText } from "@/shared/utils/date-time-text";
const textFields = new Set(["title", "description", "summary", "text", "label", "value", "source", "observed_at"]);
const utcLabel = /UTC(?:\s*\+?0(?::00)?)?(?![\w+:-])/;
const timeAxis = (value: string) => formatDisplayText(value).replace(/UTC(?:\s*\+?0(?::00)?)?(?![\w+:-])/g, "UTC+8");
const bareTime = /^(?:\d{4}-\d{2}-\d{2}[T ])?\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?$/;
function displayCell(value: string, heading: string): string {
return formatDisplayText(utcLabel.test(heading) && bareTime.test(value) ? `${value} UTC` : value);
}
/** Render display fields in UTC8 while keeping the stored spec and identifiers intact. */
export function withAnswerDisplayTimes(spec: Spec): Spec {
return { ...spec, elements: Object.fromEntries(Object.entries(spec.elements).map(([id, node]) => {
const props = { ...node.props };
for (const key of textFields) {
if (typeof props[key] === "string") props[key] = formatDisplayText(props[key]);
}
if (node.type === "Table") {
const columns = props.columns as string[];
props.rows = (props.rows as string[][]).map((row) => row.map((cell, index) => displayCell(cell, columns[index] ?? "")));
props.columns = columns.map(timeAxis);
}
if (node.type === "Chart") {
const axis = typeof props.x_axis_name === "string" ? props.x_axis_name : "";
props.x_data = (props.x_data as string[]).map((label) => {
const displayed = displayCell(label, axis);
return timeAxis(axis).includes("UTC+8") ? displayed.replace(/(UTC\+8)$/, "") : displayed;
});
if (typeof props.x_axis_name === "string") props.x_axis_name = timeAxis(props.x_axis_name);
props.series = (props.series as Array<Record<string, unknown>>).map((series) => ({ ...series,
name: typeof series.name === "string" ? formatDisplayText(series.name) : series.name }));
}
return [id, { ...node, props }];
})) };
}
@@ -11,5 +11,6 @@ export type AnswerUiInteractions = {
onSubmit?: (text: string) => Promise<void> | void;
evidenceFilter?: string;
submitMode?: "send" | "draft";
chartWindowKey?: string;
};
export const AnswerInteractionContext = createContext<AnswerUiInteractions>({ disabled: true });
@@ -28,6 +28,7 @@ import { AnswerContentNav } from "./answer-content-nav";
import { getAnswerTaskLayout } from "./answer-task-layout";
import { AnswerViewStateProvider, useAnswerBlockState, useAnswerNodeState, withAnswerNodeIdentity, withAnswerNodeIds } from "./answer-view-state-provider";
import { type AnswerEvidenceState, type AnswerViewState, type AnswerViewStateChange } from "./answer-view-state";
import { withAnswerDisplayTimes } from "./answer-display-time";
const { registry: baseRegistry } = defineRegistry(answerUiCatalog, {
actions: {},
@@ -103,7 +104,7 @@ const registry = withAnswerNodeIdentity(baseRegistry);
type AnswerUiViewProps = { block: AnswerUiBlock; viewState?: AnswerViewState; onViewStateChange?: AnswerViewStateChange } & Partial<AnswerUiInteractions>;
export function AnswerUiView({ viewState, onViewStateChange, ...props }: AnswerUiViewProps) {
return <AnswerViewStateProvider key={props.block.block_id} spec={props.block.spec} registry={registry} viewState={viewState} onViewStateChange={onViewStateChange}>
<AnswerUiBody {...props} block={{ ...props.block, spec: withAnswerNodeIds(props.block.spec) }} />
<AnswerUiBody {...props} block={{ ...props.block, spec: withAnswerNodeIds(withAnswerDisplayTimes(props.block.spec)) }} />
</AnswerViewStateProvider>;
}
@@ -112,6 +113,7 @@ function AnswerUiBody({
disabled = false,
onLocate,
onSubmit,
chartWindowKey,
submitMode = "send"
}: { block: AnswerUiBlock } & Partial<AnswerUiInteractions>) {
const [{ evidenceFilter: filter }, setFilter] = useAnswerBlockState();
@@ -130,7 +132,7 @@ 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) => <Renderer key={id} spec={{ ...block.spec, root: id }} registry={registry} />);
return (
<InteractionContext.Provider value={{ disabled, onLocate, onSubmit, submitMode, evidenceFilter: filter }}>
<InteractionContext.Provider value={{ disabled, onLocate, onSubmit, submitMode, evidenceFilter: filter, chartWindowKey }}>
<AnswerMotionScope className="answer-ui-root" data-answer-ui={block.block_id} data-answer-phase={block.phase} data-answer-intent={intent} data-answer-density={density}>
<AnswerContentNav block={block} contentRef={contentRef} />
<div ref={contentRef}>
@@ -10,6 +10,10 @@ import {
const NodeContext = createContext<string | undefined>(undefined);
const ChangeContext = createContext<AnswerViewStateChange | undefined>(undefined);
export function AnswerNodeStateScope({ id, children }: { id: string; children: ReactNode }) {
return <NodeContext.Provider value={id}>{children}</NodeContext.Provider>;
}
/** The renderer clones props; local element metadata survives that adaptation. Never modify model props. */
export function withAnswerNodeIds(spec: Spec): Spec {
return { ...spec, elements: Object.fromEntries(Object.entries(spec.elements).map(([id, node]) => [id, { ...node, __answerNodeId: id }])) };
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { Spec } from "@json-render/core";
import { answerViewStateDirtyPaths, migrateAnswerViewState, normalizeAnswerViewState } from "./answer-view-state";
import { answerChartTableData, answerViewStateDirtyPaths, migrateAnswerViewState, normalizeAnswerViewState } from "./answer-view-state";
function spec(type: string, props: Record<string, unknown>): Spec {
return { root: "node", elements: { node: { type, props, children: [] } } };
@@ -10,6 +10,15 @@ const table = (rows = [["a", "1"], ["b", "2"]], columns = ["对象", "读数"])
const form = (fields = [{ name: "record", kind: "text" }]) => spec("FollowUpForm", { fields });
describe("answer view state compatibility", () => {
it("uses the same UTC8 sample display in chart tables while preserving stored samples", () => {
const series = [{ name: "压力", data: [20], sample_times: ["2026-07-16T00:00:00Z"] }];
const current = spec("Chart", { chart_type: "line", x_data: ["08:00"],
series });
const state = normalizeAnswerViewState(current).nodes.node;
if (state.type !== "Chart") throw new Error("Expected chart state");
expect(answerChartTableData(current.elements.node, state).rows[0][1]).toBe("20 (2026-07-16 08:00:00+08:00)");
expect(series[0].sample_times[0]).toBe("2026-07-16T00:00:00Z");
});
it("ignores legacy row selection, retains search, and clears sort when columns change", () => {
const before = table();
const state = normalizeAnswerViewState(before, { nodes: { node: { type: "Table", query: "a", sort: { column: 1, descending: true }, selection: [0] } } });
@@ -1,4 +1,5 @@
import type { Spec } from "@json-render/core";
import { chartSampleTime } from "./chart-presentation";
// `selection` is an empty legacy v1 wire field, never an interactive row selection.
export type AnswerTableState = { type: "Table"; query: string; sort: { column: number; descending: boolean } | null; selection: number[] };
@@ -53,9 +54,12 @@ export function normalizeAnswerNodeState(node: Node, input?: unknown): AnswerUiN
}
export function answerChartTableData(node: Node, state: AnswerChartState) {
const labels = node.props.x_data as string[], series = node.props.series as Array<{ name: string; data: number[] }>;
const labels = node.props.x_data as string[], series = node.props.series as Array<{ name: string; data: Array<number | null>; sample_times?: Array<string | null> }>;
const visible = series.filter((_, index) => !state.hidden.includes(index));
return { columns: ["时间或类别", ...visible.map(({ name }) => name)], rows: labels.slice(state.range[0], state.range[1] + 1).map((label, index) => [label, ...visible.map(({ data }) => String(data[state.range[0] + index]))]) };
return { columns: ["时间或类别", ...visible.map(({ name }) => name)], rows: labels.slice(state.range[0], state.range[1] + 1).map((label, index) => [label, ...visible.map(({ data, sample_times }) => {
const offset = state.range[0] + index, time = chartSampleTime(sample_times?.[offset]);
return `${data[offset] == null ? "缺测" : data[offset]}${time ? ` (${time})` : ""}`;
})]) };
}
export function normalizeAnswerViewState(spec: Spec, input?: unknown): AnswerViewState {
+6 -1
View File
@@ -1,6 +1,7 @@
import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react/schema";
import { z } from "zod";
import { createChartSampleTimeSchema } from "../presentation/command-schema.ts";
const shortText = z.string().max(200);
const text = z.string().max(4000);
@@ -64,11 +65,15 @@ export const answerUiCatalog = defineCatalog(schema, {
.object({
title: shortText,
chart_type: z.enum(["line", "bar"]),
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),
series: z
.array(
z
.object({ name: shortText, data: z.array(z.number().finite()).min(1).max(100) })
.object({ name: shortText, type: z.enum(["line", "bar", "scatter"]).optional(),
data: z.array(z.number().finite().nullable()).min(1).max(100),
sample_times: z.array(createChartSampleTimeSchema().nullable()).min(1).max(100).optional() })
.strict()
)
.min(1)
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { chartPointTooltip, chartSampleTime, chartValueBounds, chartValueLabel } from "./chart-presentation";
describe("monitoring chart comparison", () => {
it("displays UTC samples in UTC8 and keeps explicit UTC8 instants stable across midnight", () => {
expect(chartSampleTime("2026-12-31T16:00:00Z")).toBe("2027-01-01 00:00:00+08:00");
expect(chartSampleTime("2027-01-01T00:00:00+08:00")).toBe("2027-01-01 00:00:00+08:00");
expect(chartSampleTime(null)).toBe("");
});
it("frames current pressure and all historical points with a nonzero lower bound", () => {
const series = [{ name: "当前", data: [34, 32, 28] }, { name: "前一日", data: [null, 36, 37] }];
const bounds = chartValueBounds(series, 0, 2);
expect(bounds.min).toBeGreaterThan(0);
expect(bounds.min).toBeLessThan(28);
expect(bounds.max).toBeGreaterThan(37);
expect(chartValueBounds([{ name: "无数据", data: [null] }], 0, 0)).toEqual({});
expect(chartValueBounds([{ name: "零流量", data: [0, 0] }], 0, 1)).toEqual({ min: -0.08, max: 0.08 });
});
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"] },
{ name: "缺测日", data: [null], sample_times: [null] }];
const tooltip = chartPointTooltip("12:00", series, 0);
expect(tooltip).toContain("当前: 28\n采样时间 2026-06-14 12:00:00+08:00");
expect(tooltip).toContain("前一日: 37\n采样时间 2026-06-13 12:00:00+08:00");
expect(tooltip).not.toContain("缺测日");
expect(chartValueLabel(null)).toBe("缺测");
expect(chartValueLabel(0)).toBe("0");
});
});
@@ -0,0 +1,28 @@
import type { SafeChartSeries } from "../chart-data";
import { formatDisplayTimestamp } from "@/shared/utils/date-time";
export function chartValueLabel(value: number | null | undefined): string {
return value == null ? "缺测" : new Intl.NumberFormat("zh-CN", { maximumFractionDigits: 2 }).format(value);
}
export function chartSampleTime(time: string | null | undefined): string {
return time ? formatDisplayTimestamp(time) : "";
}
export function chartValueBounds(series: SafeChartSeries[], start: number, end: number) {
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 };
}
/** The axis label is a projected comparison time; the sample timestamp remains authoritative. */
export function chartPointTooltip(label: string, series: SafeChartSeries[], index: number): string {
const rows = series.filter((item) => item.data[index] != null).map((item) => {
const time = chartSampleTime(item.sample_times?.[index]);
return `${item.name}: ${chartValueLabel(item.data[index])}${time ? `\n采样时间 ${time}` : ""}`;
});
return [label, ...rows].join("\n");
}
@@ -11,6 +11,19 @@ const block = {
}
};
describe("untrusted answer components", () => {
it("accepts a line with seven sparse history series while enforcing sample timestamp alignment", () => {
const x_data = Array.from({ length: 13 }, (_, index) => String(index));
const series = [{ name: "当前", type: "line", data: x_data.map(() => 31) },
...Array.from({ length: 7 }, (_, index) => ({ name: `前${index + 1}日`, type: "scatter",
data: [...Array(10).fill(null), 33, 34, 35],
sample_times: [...Array(10).fill(null), ...[30, 45, 59].map((minute) => `2026-06-${String(13 - index).padStart(2, "0")}T11:${minute}:00+08:00`)] }))];
const props = { title: "压力(m)", source: "合成样例", chart_type: "line", x_data, series };
const make = (value: unknown) => ({ ...block, spec: { root: "chart", elements: { chart: { type: "Chart", props: value, children: [] } } } });
expect(parseAnswerUiBlock(make(props))).not.toBeNull();
expect(parseAnswerUiBlock(make({ ...props, series: [...series, series[0]] }))).toBeNull();
expect(parseAnswerUiBlock(make({ ...props, series: [{ ...series[1], sample_times: [null] }] }))).toBeNull();
expect(parseAnswerUiBlock(make({ ...props, series: [{ ...series[1], sample_times: Array(13).fill("2026-06-13T12:00:00+08:00") }] }))).toBeNull();
});
it("keeps explicit UI phases and rejects invalid phase values", () => {
expect(parseAnswerUiBlock(block)?.phase).toBeUndefined();
expect(parseAnswerUiBlock({ ...block, phase: "process" })?.phase).toBe("process");
+4 -2
View File
@@ -56,8 +56,10 @@ export function parseAnswerUiBlock(value: unknown): AnswerUiBlock | null {
if (!definition?.props.safeParse(node.props).success) return null;
if (!["Stack", "Section"].includes(node.type) && node.children.length) return null;
if (node.type === "Chart") {
const data = node.props as { x_data: unknown[]; series: Array<{ data: unknown[] }> };
if (data.series.some((series) => series.data.length !== data.x_data.length)) return null;
const data = node.props as { x_data: unknown[]; series: Array<{ data: unknown[]; sample_times?: Array<string | null> }> };
if (data.series.some((series) => series.data.length !== data.x_data.length ||
series.sample_times && (series.sample_times.length !== data.x_data.length ||
series.sample_times.some((time, index) => time !== null && series.data[index] === null)))) return null;
}
if (node.type === "Table") {
const data = node.props as { columns: unknown[]; rows: unknown[][] };
+191 -105
View File
@@ -1,23 +1,29 @@
import { useLayoutEffect, useRef, useState } from "react";
import { memo, useContext, useLayoutEffect, useRef, useState } from "react";
import { motion, useAnimationControls, useReducedMotion } from "motion/react";
import ReactECharts from "echarts-for-react";
import { BarChart3, X, Database, 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";
import { Button } from "@/shared/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/ui/select";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/shared/ui/collapsible";
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 { AnswerInteractionContext } from "./answer-ui-interaction-context";
export type AnswerChartProps = {
title: string;
chart_type: "line" | "bar";
x_data: string[];
series: Array<{ name: string; data: number[] }>;
series: SafeChartSeries[];
x_axis_name?: string;
y_axis_name?: string;
source: string;
};
import { useAnswerNodeState } from "./answer-view-state-provider";
import { AnswerNodeStateScope, AnswerViewStateProvider, useAnswerNodeState } from "./answer-view-state-provider";
import type { AnswerChartState } from "./answer-view-state";
const colors = ["#2563eb", "#059669", "#d97706", "#7c3aed", "#0891b2", "#e11d48", "#475569", "#a16207"];
@@ -29,12 +35,34 @@ function PointSelect({ label, value, labels, onChange }: {
<SelectContent>{labels.map((text, index) => <SelectItem key={index} value={String(index)}>{text}</SelectItem>)}</SelectContent>
</Select>;
}
export function InteractiveChart(props: AnswerChartProps) {
type ChartPresentation = { 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. */
export function StandaloneInteractiveChart(props: AnswerChartProps & ChartPresentation) {
const spec = { root: "chart", elements: { chart: { type: "Chart", props: {
title: props.title, chart_type: props.chart_type, x_data: props.x_data, series: props.series, source: props.source
}, children: [] } } };
return <AnswerViewStateProvider spec={spec} registry={standaloneRegistry}><AnswerNodeStateScope id="chart"><InteractiveChart {...props} /></AnswerNodeStateScope></AnswerViewStateProvider>;
}
export const InteractiveChart = memo(function InteractiveChartView(props: AnswerChartProps & ChartPresentation) {
const { chartWindowKey } = useContext(AnswerInteractionContext);
const [{ view, hidden, range, point, inspecting, table }, patch, migrationNotice] = useAnswerNodeState<AnswerChartState>("Chart", props);
const reducedMotion = useReducedMotion();
const pointer = useRef(false);
const plot = useRef<HTMLDivElement>(null);
const [animateInteraction, setAnimateInteraction] = useState(false);
const [plotWidth, setPlotWidth] = useState(360);
useLayoutEffect(() => {
const element = plot.current;
if (props.expanded || !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]);
const contentMotion = useAnimationControls();
const pendingTransition = useRef(false);
useLayoutEffect(() => {
@@ -62,111 +90,65 @@ export function InteractiveChart(props: AnswerChartProps) {
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) => {
const values = series.data.slice(range[0], range[1] + 1);
return { name: series.name, first: values[0], last: values.at(-1)!, min: Math.min(...values), max: Math.max(...values) };
const values = series.data.slice(range[0], range[1] + 1).filter((value): value is number => value !== null);
return { name: series.name, first: values[0], last: values.at(-1),
min: values.length ? Math.min(...values) : null, max: values.length ? Math.max(...values) : null };
});
const option = (type: "line" | "bar") => ({
animation: animateInteraction && !reducedMotion,
animationDuration: 180,
animationDurationUpdate: 180,
color: colors.filter((_, index) => !hidden.includes(index)),
aria: { enabled: true },
tooltip: { trigger: "axis", renderMode: "richText" },
grid: { top: 16, right: 12, bottom: 38, left: 12, containLabel: true },
xAxis: { type: "category", data: labels, axisLabel: { fontSize: 11, color: "#64748b", hideOverlap: true } },
yAxis: { type: "value", axisLabel: { fontSize: 11, color: "#64748b" }, splitLine: { lineStyle: { color: "#e2e8f0" } } },
series: visible.map((series) => ({ name: series.name, type, data: series.data.slice(range[0], range[1] + 1),
showSymbol: true, symbolSize: 7, emphasis: { focus: "series" } }))
});
return <Collapsible asChild><Card className="answer-ui-chart overflow-hidden" role="group" aria-label={props.title}
onPointerDownCapture={() => { pointer.current = true; }}
onKeyDownCapture={() => {
pointer.current = false;
setAnimateInteraction(false);
contentMotion.stop();
contentMotion.set({ opacity: 1, y: 0 });
}}>
<CardHeader className="flex-row items-center justify-between gap-2 space-y-0 px-3.5 py-2.5">
<CardTitle className="flex min-w-0 items-center gap-2 text-xs leading-5 tracking-normal"><TrendingUp size={15} className="shrink-0" aria-hidden />{props.title}</CardTitle>
<CollapsibleTrigger asChild>
<Button type="button" variant="ghost" size="icon-sm" aria-label="筛选数据范围" title="筛选数据范围"
className="shrink-0 text-muted-foreground">
<SlidersHorizontal size={14} aria-hidden />
</Button>
</CollapsibleTrigger>
</CardHeader>
<CardContent className="space-y-3 px-3.5 pb-3.5 pt-0">
{migrationNotice ? <p role="status" className="text-xs leading-5 text-muted-foreground">{migrationNotice}</p> : null}
<Tabs value={view} onValueChange={setView}>
<TabsList aria-label={`${props.title}显示方式`} className="relative mb-3 grid w-full grid-cols-3 [&>[role=tab]]:relative [&>[role=tab]]:z-10 [&>[role=tab][data-state=active]]:bg-transparent [&>[role=tab][data-state=active]]:shadow-none">
<motion.span aria-hidden className="pointer-events-none absolute inset-y-1 left-1 rounded-md bg-card shadow-xs"
style={{ width: "calc((100% - 0.5rem) / 3)" }}
initial={false} animate={{ x: view === "line" ? "0%" : view === "bar" ? "100%" : "200%" }}
transition={{ duration: animateInteraction && !reducedMotion ? 0.18 : 0, ease: agentEase }} />
<TabsTrigger value="line"><TrendingUp size={13} />折线</TabsTrigger>
<TabsTrigger value="bar"><BarChart3 size={13} />柱状</TabsTrigger>
<TabsTrigger value="table"><Table2 size={13} />数据</TabsTrigger>
</TabsList>
<div className="answer-ui-chart-series mb-2 flex flex-wrap gap-2" aria-label="显示的数据序列">
{props.series.map((series, index) => <Button key={index} size="sm" variant="outline"
className="h-8 gap-1.5 text-[11px]" aria-pressed={!hidden.includes(index)}
disabled={visible.length === 1 && !hidden.includes(index)}
onClick={() => { setAnimateInteraction(pointer.current && !reducedMotion); patch({ hidden: hidden.includes(index) ? hidden.filter((item) => item !== index) : [...hidden, index],
table: { query: "", ...table, sort: null, selection: [] } }, ["hidden", "table/sort"]); }}>
<span className="size-2 rounded-full" style={{ background: hidden.includes(index) ? "#cbd5e1" : colors[index] }} />{series.name}
</Button>)}
</div>
{!fullRange ? <p className="mb-2 text-[11px] text-muted-foreground">{props.x_data[range[0]]} 至 {props.x_data[range[1]]}</p> : null}
<CollapsibleContent className="mb-2 space-y-2 rounded-lg border bg-muted/30 p-2.5">
<div className="flex items-center gap-2">
<PointSelect label="区间起点" value={range[0]} labels={props.x_data}
onChange={(start) => setRange([start, Math.max(start, range[1])])} />
<span className="text-xs text-muted-foreground">至</span>
<PointSelect label="区间终点" value={range[1]} labels={props.x_data}
onChange={(end) => setRange([Math.min(range[0], end), end])} />
</div>
<Button type="button" variant="ghost" size="sm" disabled={fullRange}
onClick={() => setRange([0, props.x_data.length - 1])}>重置范围</Button>
</CollapsibleContent>
<motion.div data-chart-view={view} initial={false} animate={contentMotion}>
{(["line", "bar"] as const).map((type) => <TabsContent key={type} value={type}>
<div ref={view === type ? plot : undefined} role="group" aria-label={`${props.title},按回车查看数据点,方向键切换数据点`} tabIndex={0}
className="rounded-md focus-visible:outline-2 focus-visible:outline-ring"
onKeyDown={(event) => {
if (event.target !== event.currentTarget) return;
if (["Enter", "ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) {
event.preventDefault();
const next = event.key === "Home" ? range[0] : event.key === "End" ? range[1]
: Math.min(range[1], Math.max(range[0], pointIndex + (event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0)));
patch({ point: next, inspecting: true });
}
if (event.key === "Escape") setInspecting(false);
}}>
<span className="sr-only">点击图中数据点查看详情,也可使用回车和左右方向键。</span>
<ReactECharts option={option(type)} opts={{ renderer: "svg" }} notMerge style={{ height: 220 }}
onEvents={{ click: (event: { componentType?: string; dataIndex?: number }) => {
if (event.componentType === "series" && typeof event.dataIndex === "number") {
patch({ point: range[0] + event.dataIndex, inspecting: true });
}
} }} />
</div>
</TabsContent>)}
<TabsContent value="table" forceMount style={view === "table" ? undefined : { display: "none" }}>
<InteractiveDataTable title={props.title} columns={["时间或类别", ...visible.map((series) => series.name)]}
rows={labels.map((label, index) => [label, ...visible.map((series) => String(series.data[range[0] + index]))])}
state={table} onStateChange={(updates) => patch({ table: { query: "", sort: null, selection: [], ...table, ...updates } }, Object.keys(updates).map((key) => `table/${key}`))} />
</TabsContent>
</motion.div>
</Tabs>
{view !== "table" ? <div className="rounded-lg bg-muted/40 p-2.5" role="group" aria-label="当前区间摘要">
const composite = props.series.some((series) => series.type === "scatter");
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 ? 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 metadata = <>
{!props.embedded && view !== "table" ? <div className="rounded-lg bg-muted/40 p-2.5" role="group" aria-label="当前区间摘要">
<div className="mb-2 flex flex-wrap items-center justify-between gap-1">
<span className="text-[11px] font-medium">区间摘要 <span className="font-normal text-muted-foreground">· 本地统计</span></span>
</div>
<div className="space-y-2">
{summaries.map((item) => <div key={item.name} className="answer-chart-range-summary text-[11px] leading-5">
<span className="min-w-0 break-words text-muted-foreground">{item.name}</span>
<span className="tabular-nums">{item.first} → {item.last}</span>
<span className="text-muted-foreground">最小 {item.min} · 最大 {item.max}</span>
<span className="tabular-nums">{numberLabel(item.first)} → {numberLabel(item.last)}</span>
<span className="text-muted-foreground">最小 {numberLabel(item.min)} · 最大 {numberLabel(item.max)}</span>
</div>)}
</div>
</div> : null}
@@ -183,13 +165,117 @@ export function InteractiveChart(props: AnswerChartProps) {
<dl className="my-2 space-y-1.5 text-xs">
{visible.map((series, index) => <div key={index} className="flex items-baseline justify-between gap-3">
<dt className="min-w-0 text-muted-foreground">{series.name}</dt>
<dd className="shrink-0 font-medium tabular-nums">{series.data[pointIndex]}</dd>
<dd className="min-w-0 text-right font-medium tabular-nums">{numberLabel(series.data[pointIndex])}
{series.sample_times?.[pointIndex] ? <span className="block break-words text-[10px] font-normal text-muted-foreground">{chartSampleTime(series.sample_times[pointIndex])}</span> : null}
</dd>
</div>)}
</dl>
</motion.section> : null}
<p className="flex items-start gap-1.5 border-t pt-2.5 text-[11px] leading-5 text-muted-foreground">
<Database size={12} className="mt-1 shrink-0" /><span>{fullRange ? labels.length : `${labels.length} / ${props.x_data.length}`} 个数据点 · {visible.length} 组序列<br />来源:{props.source}</span>
</p>
</>;
return <Collapsible asChild><Card className={`answer-ui-chart overflow-hidden ${props.embedded || props.expanded ? "border-0 shadow-none" : ""} ${props.expanded ? "@container flex h-full min-h-[360px] min-w-0 flex-1 flex-col" : ""}`} role={props.embedded ? undefined : "group"} aria-label={props.embedded ? undefined : props.title}
onPointerDownCapture={() => { pointer.current = true; }}
onKeyDownCapture={() => {
pointer.current = false;
setAnimateInteraction(false);
contentMotion.stop();
contentMotion.set({ opacity: 1, y: 0 });
}}>
<CardHeader className="shrink-0 flex-row items-center justify-between gap-2 space-y-0 px-3.5 py-2.5">
<CardTitle className="flex min-w-0 items-center gap-2 text-xs leading-5 tracking-normal"><TrendingUp size={15} className="shrink-0" aria-hidden />{props.embedded || props.expanded ? "图形与数据" : props.title}</CardTitle>
<div className="flex shrink-0 items-center gap-1">
{!props.embedded && !props.expanded ? <FloatingWindow title={props.title} fillContent
openRequestKey={chartWindowKey === undefined ? undefined : JSON.stringify([chartWindowKey, props])}>
{() => <InteractiveChart {...props} expanded />}
</FloatingWindow> : null}
<CollapsibleTrigger asChild>
<Button type="button" variant="ghost" size="icon-sm" aria-label="筛选数据范围" title="筛选数据范围"
className="shrink-0 text-muted-foreground">
<SlidersHorizontal size={14} aria-hidden />
</Button>
</CollapsibleTrigger>
</div>
</CardHeader>
<CardContent className={`${props.expanded ? "flex min-h-0 flex-1 flex-col" : "space-y-3"} px-3.5 pb-3.5 pt-0`}>
{migrationNotice ? <p role="status" className="text-xs leading-5 text-muted-foreground">{migrationNotice}</p> : null}
<Tabs value={view} onValueChange={setView} className={props.expanded ? "grid min-h-0 flex-1 grid-cols-1 grid-rows-[auto_auto_minmax(220px,1fr)_minmax(0,auto)] gap-x-4 gap-y-2 @min-[640px]:grid-cols-[minmax(0,1fr)_208px] @min-[640px]:grid-rows-[auto_auto_minmax(220px,1fr)]" : undefined}>
<TabsList aria-label={`${props.title}显示方式`} className={`${props.expanded ? "col-start-1 row-start-1" : "mb-3"} relative grid w-full grid-cols-3 [&>[role=tab]]:relative [&>[role=tab]]:z-10 [&>[role=tab][data-state=active]]:bg-transparent [&>[role=tab][data-state=active]]:shadow-none`}>
<motion.span aria-hidden className="pointer-events-none absolute inset-y-1 left-1 rounded-md bg-card shadow-xs"
style={{ width: "calc((100% - 0.5rem) / 3)" }}
initial={false} animate={{ x: view === "line" ? "0%" : view === "bar" ? "100%" : "200%" }}
transition={{ duration: animateInteraction && !reducedMotion ? 0.18 : 0, ease: agentEase }} />
<TabsTrigger value="line"><TrendingUp size={13} />{composite ? "复合" : "折线"}</TabsTrigger>
<TabsTrigger value="bar"><BarChart3 size={13} />柱状</TabsTrigger>
<TabsTrigger value="table"><Table2 size={13} />数据</TabsTrigger>
</TabsList>
<aside className={props.expanded ? "col-start-1 row-start-4 max-h-44 min-h-0 space-y-3 overflow-y-auto border-t border-border pt-3 @min-[640px]:col-start-2 @min-[640px]:row-start-1 @min-[640px]:row-span-3 @min-[640px]:max-h-none @min-[640px]:border-t-0 @min-[640px]:border-l @min-[640px]:pt-0 @min-[640px]:pl-3" : undefined}>
<div className={`answer-ui-chart-series mb-2 flex flex-wrap gap-2 ${props.expanded ? "@min-[640px]:flex-col @min-[640px]:flex-nowrap @min-[640px]:items-stretch" : ""}`} aria-label="显示的数据序列">
{props.series.map((series, index) => <Button key={index} size="sm" variant="outline"
className={`h-auto min-h-8 max-w-full gap-1.5 whitespace-normal text-left text-[11px] ${props.expanded ? "justify-start" : ""}`} aria-pressed={!hidden.includes(index)}
disabled={visible.length === 1 && !hidden.includes(index)}
onClick={() => { setAnimateInteraction(pointer.current && !reducedMotion); patch({ hidden: hidden.includes(index) ? hidden.filter((item) => item !== index) : [...hidden, index],
table: { query: "", ...table, sort: null, selection: [] } }, ["hidden", "table/sort"]); }}>
<span className="size-2 shrink-0 rounded-full" style={{ background: hidden.includes(index) ? "#cbd5e1" : colors[index] }} /><span className="min-w-0 break-words">{series.name}</span>
</Button>)}
</div>
{hidden.length ? <Button type="button" variant="ghost" size="sm" onClick={() => patch({ hidden: [] }, ["hidden"])}>显示全部序列</Button> : null}
{view === "bar" && categoryColors ? <div aria-label="类别图例" className={`mb-3 grid gap-1.5 rounded-md bg-muted/30 p-2 ${props.expanded ? "" : "max-h-40 overflow-y-auto"}`} style={{ gridTemplateColumns: `repeat(${props.expanded ? 1 : plotWidth >= 700 ? 3 : plotWidth >= 420 ? 2 : 1},minmax(0,1fr))` }}>
{labels.map((label, index) => <div key={index} className="flex min-w-0 items-start gap-2 text-[11px] leading-5">
<span className="mt-1.5 size-2 shrink-0 rounded-sm" style={{ background: colors[(range[0] + index) % colors.length] }} />
<span className="min-w-0 break-words">{label}</span>
<span className="ml-auto shrink-0 tabular-nums">{numberLabel(visible[0].data[range[0] + index])}</span>
</div>)}
</div> : null}
{props.expanded ? metadata : null}
</aside>
<div className={props.expanded ? "col-start-1 row-start-2" : undefined}>
{!fullRange ? <p className="mb-2 text-[11px] text-muted-foreground">{props.x_data[range[0]]} 至 {props.x_data[range[1]]}</p> : null}
<CollapsibleContent className="mb-2 space-y-2 rounded-lg border bg-muted/30 p-2.5">
<div className="flex items-center gap-2">
<PointSelect label="区间起点" value={range[0]} labels={props.x_data}
onChange={(start) => setRange([start, Math.max(start, range[1])])} />
<span className="text-xs text-muted-foreground">至</span>
<PointSelect label="区间终点" value={range[1]} labels={props.x_data}
onChange={(end) => setRange([Math.min(range[0], end), end])} />
</div>
<Button type="button" variant="ghost" size="sm" disabled={fullRange}
onClick={() => setRange([0, props.x_data.length - 1])}>重置范围</Button>
</CollapsibleContent>
</div>
<motion.div className={props.expanded ? "col-start-1 row-start-3 min-h-0" : undefined} data-chart-view={view} initial={false} animate={contentMotion}>
{(["line", "bar"] as const).map((type) => <TabsContent key={type} value={type} className={props.expanded ? "h-full min-h-0" : undefined}>
<div ref={view === type ? plot : undefined} role="group" aria-label={`${props.title},按回车查看数据点,方向键切换数据点`} tabIndex={0}
className={`${props.expanded ? "h-full min-h-0 overflow-auto" : ""} rounded-md focus-visible:outline-2 focus-visible:outline-ring`}
onKeyDown={(event) => {
if (event.target !== event.currentTarget) return;
if (["Enter", "ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) {
event.preventDefault();
const next = event.key === "Home" ? range[0] : event.key === "End" ? range[1]
: Math.min(range[1], Math.max(range[0], pointIndex + (event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0)));
patch({ point: next, inspecting: true });
}
if (event.key === "Escape") setInspecting(false);
}}>
<span className="sr-only">点击图中数据点查看详情,也可使用回车和左右方向键。</span>
<div role="img" aria-label={props.title} style={{ height: props.expanded ? "100%" : props.plotHeight ?? 220,
minHeight: props.expanded && type === "bar" && horizontalBars ? labels.length * 36 + 48 : undefined }}>
<ResponsiveChart option={option(type)} height="100%" onPointSelect={(index) => patch({ point: range[0] + index, inspecting: true })} />
</div>
</div>
</TabsContent>)}
<TabsContent value="table" className={props.expanded ? "h-full overflow-auto" : undefined} forceMount style={view === "table" ? undefined : { display: "none" }}>
<InteractiveDataTable title={props.title} columns={["时间或类别", ...visible.map((series) => series.name)]}
rows={labels.map((label, index) => [label, ...visible.map((series) => {
const offset = range[0] + index, time = chartSampleTime(series.sample_times?.[offset]);
return `${series.data[offset] == null ? "缺测" : series.data[offset]}${time ? ` (${time})` : ""}`;
})])}
state={table} onStateChange={(updates) => patch({ table: { query: "", sort: null, selection: [], ...table, ...updates } }, Object.keys(updates).map((key) => `table/${key}`))} />
</TabsContent>
</motion.div>
{!props.expanded ? metadata : null}
</Tabs>
</CardContent>
</Card></Collapsible>;
}
});
@@ -2,16 +2,18 @@ import { useMemo } from "react";
import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react";
import { Input } from "@/shared/ui/input";
import { Button } from "@/shared/ui/button";
import { FloatingWindow } from "@/shared/ui/floating-window";
import { cn } from "@/shared/ui/cn";
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/shared/ui/table";
import { useAnswerNodeState } from "./answer-view-state-provider";
import type { AnswerTableState } from "./answer-view-state";
export function InteractiveDataTable({ title, columns, rows, state: controlledState, onStateChange }: {
export function InteractiveDataTable({ title, columns, rows, state: controlledState, onStateChange, expanded = false }: {
title: string; columns: string[]; rows: string[][];
state?: Omit<AnswerTableState, "type">;
onStateChange?: (patch: Partial<Omit<AnswerTableState, "type">>) => void;
expanded?: boolean;
}) {
const [localState, patchLocal] = useAnswerNodeState<AnswerTableState>("Table", { title, columns, rows });
const state = controlledState ?? localState;
@@ -34,6 +36,9 @@ export function InteractiveDataTable({ title, columns, rows, state: controlledSt
<Input aria-label={`搜索${title}`} placeholder="搜索当前数据" value={query} className="h-9 min-w-0 flex-1 text-xs"
onChange={(event) => { patch({ query: event.currentTarget.value }); }} />
<span className="text-[11px] tabular-nums text-muted-foreground">{filtered.length} / {rows.length} 行</span>
{!expanded ? <FloatingWindow title={`${title} · 表格`} triggerLabel={`放大查看表格 ${title}`}>
{() => <InteractiveDataTable title={title} columns={columns} rows={rows} state={state} onStateChange={patch} expanded />}
</FloatingWindow> : null}
</div>
<div role="region" aria-label={title} tabIndex={0}
className="answer-ui-table overflow-x-auto rounded-lg border border-border focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring">
@@ -0,0 +1,37 @@
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. */
export function ResponsiveChart({ option, height, onPointSelect }: {
option: ComponentProps<typeof ReactECharts>["option"];
height: number | "100%";
onPointSelect: (index: number) => void;
}) {
const host = useRef<HTMLDivElement>(null);
const chart = useRef<ReactECharts>(null);
const ready = useRef(false);
useLayoutEffect(() => {
const element = host.current;
if (!element || typeof ResizeObserver === "undefined") return;
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 } });
});
});
observer.observe(element);
return () => { cancelAnimationFrame(frame); observer.disconnect(); };
}, []);
return <div ref={host} style={{ height }}>
<ReactECharts ref={chart} option={option} opts={{ renderer: "svg" }} notMerge autoResize={false}
style={{ height: "100%" }} onChartReady={(instance) => {
ready.current = true;
instance.resize({ width: "auto", height: "auto", animation: { duration: 0 } });
}}
onEvents={{ click: (event: { componentType?: string; dataIndex?: number }) => {
if (event.componentType === "series" && typeof event.dataIndex === "number") onPointSelect(event.dataIndex);
} }} />
</div>;
}