feat(workbench): expand SCADA monitoring and investigation presentation
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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[][] };
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
Reference in New Issue
Block a user