feat(charts): manage investigation charts in a scrolling workspace
This commit is contained in:
@@ -7,10 +7,10 @@ export type AnswerUiMapTarget = {
|
||||
};
|
||||
export type AnswerUiInteractions = {
|
||||
disabled: boolean;
|
||||
chartPresentation?: "tile";
|
||||
onLocate?: (target: AnswerUiMapTarget) => Promise<unknown> | void;
|
||||
onSubmit?: (text: string) => Promise<void> | void;
|
||||
evidenceFilter?: string;
|
||||
submitMode?: "send" | "draft";
|
||||
chartWindowKey?: string;
|
||||
};
|
||||
export const AnswerInteractionContext = createContext<AnswerUiInteractions>({ disabled: true });
|
||||
|
||||
@@ -189,6 +189,30 @@ describe("local interactive answer renderer", () => {
|
||||
expect(screen.getByRole("tab", { name: /^柱状$/ })).toHaveAttribute("aria-selected", "true");
|
||||
expect(screen.queryByRole("table")).not.toBeInTheDocument();
|
||||
});
|
||||
it("updates chart titles and readings after the same spec is changed in place", async () => {
|
||||
const user = userEvent.setup();
|
||||
const fixture = block("Chart", {
|
||||
title: "原始压力图", chart_type: "line", source: "监测数据",
|
||||
x_data: ["08:00", "09:00"], series: [{ name: "原始压力", data: [0.3, 0.2] }]
|
||||
});
|
||||
const { rerender } = render(<AnswerUiView block={fixture} />);
|
||||
await user.click(screen.getByRole("tab", { name: /^数据$/ }));
|
||||
expect(screen.getByRole("table")).toHaveTextContent("0.2");
|
||||
|
||||
const props = fixture.spec.elements.node.props;
|
||||
props.title = "更新压力图";
|
||||
const series = props.series as { name: string; data: number[] }[];
|
||||
series[0].name = "更新后压力";
|
||||
series[0].data[1] = 0.17;
|
||||
rerender(<AnswerUiView block={fixture} />);
|
||||
|
||||
expect(screen.getByRole("group", { name: "更新压力图" })).toBeVisible();
|
||||
expect(screen.queryByRole("group", { name: "原始压力图" })).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.getByRole("columnheader", { name: "更新后压力" })).toBeVisible());
|
||||
expect(screen.getByRole("table")).toHaveTextContent("0.17");
|
||||
expect(screen.getByRole("table")).not.toHaveTextContent("0.2");
|
||||
expect(screen.getByRole("tab", { name: /^数据$/ })).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
it("runs map location only after a user click and reports missing IDs", async () => {
|
||||
const locate = vi
|
||||
.fn()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { LocateButton, FollowUpForm } from "./answer-action-components";
|
||||
import { Renderer, defineRegistry } from "@json-render/react";
|
||||
import { useContext, useRef, useState, type ReactNode } from "react";
|
||||
import { useContext, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import {
|
||||
Info,
|
||||
TriangleAlert,
|
||||
@@ -103,17 +103,19 @@ const registry = withAnswerNodeIdentity(baseRegistry);
|
||||
|
||||
type AnswerUiViewProps = { block: AnswerUiBlock; viewState?: AnswerViewState; onViewStateChange?: AnswerViewStateChange } & Partial<AnswerUiInteractions>;
|
||||
export function AnswerUiView({ viewState, onViewStateChange, ...props }: AnswerUiViewProps) {
|
||||
const serializedSpec = JSON.stringify(props.block.spec);
|
||||
const displayedSpec = useMemo(() => withAnswerNodeIds(withAnswerDisplayTimes(JSON.parse(serializedSpec) as AnswerUiBlock["spec"])), [serializedSpec]);
|
||||
return <AnswerViewStateProvider key={props.block.block_id} spec={props.block.spec} registry={registry} viewState={viewState} onViewStateChange={onViewStateChange}>
|
||||
<AnswerUiBody {...props} block={{ ...props.block, spec: withAnswerNodeIds(withAnswerDisplayTimes(props.block.spec)) }} />
|
||||
<AnswerUiBody {...props} block={{ ...props.block, spec: displayedSpec }} />
|
||||
</AnswerViewStateProvider>;
|
||||
}
|
||||
|
||||
function AnswerUiBody({
|
||||
block,
|
||||
disabled = false,
|
||||
chartPresentation,
|
||||
onLocate,
|
||||
onSubmit,
|
||||
chartWindowKey,
|
||||
submitMode = "send"
|
||||
}: { block: AnswerUiBlock } & Partial<AnswerUiInteractions>) {
|
||||
const [{ evidenceFilter: filter }, setFilter] = useAnswerBlockState();
|
||||
@@ -132,10 +134,10 @@ function AnswerUiBody({
|
||||
const isGroup = (ids: string[]) => hasTaskLayout && ids.length > 1 && !ids.every((id) => block.spec.elements[id].type === "Section");
|
||||
const renderNodes = (ids: string[]) => ids.map((id) => <Renderer key={id} spec={{ ...block.spec, root: id }} registry={registry} />);
|
||||
return (
|
||||
<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}>
|
||||
<InteractionContext.Provider value={{ disabled, onLocate, onSubmit, submitMode, evidenceFilter: filter, chartPresentation }}>
|
||||
<AnswerMotionScope className={chartPresentation === "tile" ? "answer-ui-root flex h-full min-h-0 flex-col" : "answer-ui-root"} data-answer-ui={block.block_id} data-answer-phase={block.phase} data-answer-intent={intent} data-answer-density={density}>
|
||||
{chartPresentation !== "tile" ? <AnswerContentNav block={block} contentRef={contentRef} /> : null}
|
||||
<div ref={contentRef} className={chartPresentation === "tile" ? "flex h-full min-h-0 flex-1 flex-col" : undefined}>
|
||||
<>
|
||||
{taskLayout ? <div className={hasTaskLayout ? "answer-task-layout" : "answer-task-layout-simple"} data-answer-task-layout={hasTaskLayout || undefined}>
|
||||
{taskLayout.overview.length ? <div className="answer-task-overview grid gap-3" aria-label="回答概览">{renderNodes(taskLayout.overview)}</div> : null}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { chartPointTooltip, chartSampleTime, chartValueBounds, chartValueLabel } from "./chart-presentation";
|
||||
import { chartPointTooltip, chartSampleTime, chartUsesIntegerTicks, chartValueBounds, chartValueLabel } from "./chart-presentation";
|
||||
|
||||
describe("monitoring chart comparison", () => {
|
||||
it("displays UTC samples in UTC8 and keeps explicit UTC8 instants stable across midnight", () => {
|
||||
@@ -17,6 +17,39 @@ describe("monitoring chart comparison", () => {
|
||||
expect(chartValueBounds([{ name: "零流量", data: [0, 0] }], 0, 1)).toEqual({ min: -0.08, max: 0.08 });
|
||||
});
|
||||
|
||||
it("uses aligned integer boundaries and steps without rounding monitoring readings", () => {
|
||||
const series = [{ name: "流量", data: [2494.274, 4742.033] }];
|
||||
const original = structuredClone(series);
|
||||
expect(chartValueBounds(series, 0, 1, true)).toEqual({ min: 2000, max: 5000, interval: 1000 });
|
||||
expect(series).toEqual(original);
|
||||
});
|
||||
|
||||
it("keeps a narrow pressure range near its readings instead of forcing a broad zero-based axis", () => {
|
||||
expect(chartValueBounds([{ name: "压力", data: [28.125, 28.875] }], 0, 1, true))
|
||||
.toEqual({ min: 28, max: 29, interval: 1 });
|
||||
expect(chartValueBounds([{ name: "压力", data: [9999, 28.14, 29.03, -9999] }], 1, 2, true))
|
||||
.toEqual({ min: 28, max: 30, interval: 1 });
|
||||
});
|
||||
|
||||
it("preserves negative flow and provides distinct integer ticks for zero flow", () => {
|
||||
expect(chartValueBounds([{ name: "反向流量", data: [-108.8, 12.125] }], 0, 1, true))
|
||||
.toEqual({ min: -150, max: 50, interval: 50 });
|
||||
expect(chartValueBounds([{ name: "零流量", data: [0, 0] }], 0, 1, true))
|
||||
.toEqual({ min: -1, max: 1, interval: 1 });
|
||||
});
|
||||
|
||||
it("limits integer ticks to confirmed monitoring names and units while preserving generic small ranges", () => {
|
||||
for (const name of ["压力(m)", "压力 ( m )", "压力水头(m)", "水头(m)", "流量(m³/h)", "流量 ( m3 / h )", "管流(m³/h)"])
|
||||
expect(chartUsesIntegerTicks(name)).toBe(true);
|
||||
for (const name of [undefined, "压力(MPa)", "流量(L/s)", "比率", ""])
|
||||
expect(chartUsesIntegerTicks(name)).toBe(false);
|
||||
const series = [{ name: "压力(MPa)", data: [0.2131, 0.2149] }];
|
||||
const bounds = chartValueBounds(series, 0, 1, chartUsesIntegerTicks("压力(MPa)"));
|
||||
expect(bounds.min).toBeGreaterThan(0.21);
|
||||
expect(bounds.max).toBeLessThan(0.22);
|
||||
expect(bounds).not.toHaveProperty("interval");
|
||||
});
|
||||
|
||||
it("preserves the original historical date in tooltip and marks missing values explicitly", () => {
|
||||
const series = [{ name: "当前", data: [28], sample_times: ["2026-06-14T12:00:00+08:00"] },
|
||||
{ name: "前一日", type: "scatter" as const, data: [37], sample_times: ["2026-06-13T12:00:00+08:00"] },
|
||||
|
||||
@@ -9,13 +9,24 @@ export function chartSampleTime(time: string | null | undefined): string {
|
||||
return time ? formatDisplayTimestamp(time) : "";
|
||||
}
|
||||
|
||||
export function chartValueBounds(series: SafeChartSeries[], start: number, end: number) {
|
||||
/** Only confirmed monitoring units opt into integer ticks; small generic units retain their scale. */
|
||||
export function chartUsesIntegerTicks(axisName: string | undefined): boolean {
|
||||
const normalized = axisName?.replace(/\s+/g, "").replace(/(/g, "(").replace(/)/g, ")").replace(/³/g, "3").toLowerCase();
|
||||
return /^(?:(?:压力|压力水头|水头)\(m\)|(?:流量|管流)\(m3\/h\))$/.test(normalized ?? "");
|
||||
}
|
||||
|
||||
export function chartValueBounds(series: SafeChartSeries[], start: number, end: number, integerTicks = false) {
|
||||
const values = series.flatMap((item) => item.data.slice(start, end + 1))
|
||||
.filter((value): value is number => value !== null && Number.isFinite(value));
|
||||
if (!values.length) return {};
|
||||
const min = Math.min(...values), max = Math.max(...values);
|
||||
const padding = (max - min || Math.abs(min) * 0.1 || 1) * 0.08;
|
||||
return { min: min - padding, max: max + padding };
|
||||
const bounds = { min: min - padding, max: max + padding };
|
||||
if (!integerTicks) return bounds;
|
||||
const target = Math.max(1, (bounds.max - bounds.min) / 4);
|
||||
const magnitude = 10 ** Math.floor(Math.log10(target));
|
||||
const interval = ([1, 2, 5, 10].find((step) => step * magnitude >= target) ?? 10) * magnitude;
|
||||
return { min: Math.floor(bounds.min / interval) * interval, max: Math.ceil(bounds.max / interval) * interval, interval };
|
||||
}
|
||||
|
||||
/** The axis label is a projected comparison time; the sample timestamp remains authoritative. */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { memo, useContext, useLayoutEffect, useRef, useState } from "react";
|
||||
import { memo, useContext, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { motion, useAnimationControls, useReducedMotion } from "motion/react";
|
||||
import { BarChart3, X, Database, SlidersHorizontal, Table2, TrendingUp } from "lucide-react";
|
||||
import { BarChart3, X, Database, FileText, SlidersHorizontal, Table2, TrendingUp } from "lucide-react";
|
||||
import { FloatingWindow } from "@/shared/ui/floating-window";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs";
|
||||
@@ -11,8 +11,9 @@ import { InteractiveDataTable } from "./interactive-data-table";
|
||||
import { ResponsiveChart } from "./responsive-chart";
|
||||
import { agentEase } from "../components/agent-motion";
|
||||
import type { SafeChartSeries } from "../chart-data";
|
||||
import { chartPointTooltip, chartSampleTime, chartValueBounds, chartValueLabel } from "./chart-presentation";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
|
||||
import { AnswerInteractionContext } from "./answer-ui-interaction-context";
|
||||
import { chartPointTooltip, chartSampleTime, chartUsesIntegerTicks, chartValueBounds, chartValueLabel } from "./chart-presentation";
|
||||
|
||||
export type AnswerChartProps = {
|
||||
title: string;
|
||||
@@ -35,7 +36,7 @@ function PointSelect({ label, value, labels, onChange }: {
|
||||
<SelectContent>{labels.map((text, index) => <SelectItem key={index} value={String(index)}>{text}</SelectItem>)}</SelectContent>
|
||||
</Select>;
|
||||
}
|
||||
type ChartPresentation = { expanded?: boolean; plotHeight?: number; embedded?: boolean; xAxisName?: string; yAxisName?: string };
|
||||
type ChartPresentation = { tile?: boolean; expanded?: boolean; plotHeight?: number; embedded?: boolean; xAxisName?: string; yAxisName?: string };
|
||||
const standaloneRegistry = {};
|
||||
|
||||
/** Legacy chart envelopes use the same controls and chart engine as structured answers. */
|
||||
@@ -47,7 +48,9 @@ export function StandaloneInteractiveChart(props: AnswerChartProps & ChartPresen
|
||||
}
|
||||
|
||||
export const InteractiveChart = memo(function InteractiveChartView(props: AnswerChartProps & ChartPresentation) {
|
||||
const { chartWindowKey } = useContext(AnswerInteractionContext);
|
||||
const { chartPresentation } = useContext(AnswerInteractionContext);
|
||||
const tile = props.tile ?? chartPresentation === "tile";
|
||||
const fitted = Boolean(props.expanded || tile);
|
||||
const [{ view, hidden, range, point, inspecting, table }, patch, migrationNotice] = useAnswerNodeState<AnswerChartState>("Chart", props);
|
||||
const reducedMotion = useReducedMotion();
|
||||
const pointer = useRef(false);
|
||||
@@ -56,13 +59,13 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer
|
||||
const [plotWidth, setPlotWidth] = useState(360);
|
||||
useLayoutEffect(() => {
|
||||
const element = plot.current;
|
||||
if (props.expanded || !element || typeof ResizeObserver === "undefined") return;
|
||||
if (fitted || !element || typeof ResizeObserver === "undefined") return;
|
||||
const resize = () => setPlotWidth(element.clientWidth);
|
||||
resize();
|
||||
const observer = new ResizeObserver(resize);
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, [view, props.expanded]);
|
||||
}, [view, fitted]);
|
||||
const contentMotion = useAnimationControls();
|
||||
const pendingTransition = useRef(false);
|
||||
useLayoutEffect(() => {
|
||||
@@ -75,7 +78,7 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer
|
||||
}, [contentMotion, view]);
|
||||
const setView = (next: string) => {
|
||||
if (next === view) return;
|
||||
const animate = pointer.current && !reducedMotion;
|
||||
const animate = pointer.current && !reducedMotion && !tile;
|
||||
setAnimateInteraction(animate);
|
||||
contentMotion.stop();
|
||||
contentMotion.set(animate ? { opacity: 0, y: 4 } : { opacity: 1, y: 0 });
|
||||
@@ -85,8 +88,11 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer
|
||||
const setRange = (range: [number, number]) => patch({ range, table: { query: "", sort: null, ...table, selection: [] } }, ["range"]);
|
||||
const setPoint = (point: number) => patch({ point });
|
||||
const setInspecting = (inspecting: boolean) => patch({ inspecting });
|
||||
const visible = props.series.filter((_, index) => !hidden.includes(index));
|
||||
const labels = props.x_data.slice(range[0], range[1] + 1);
|
||||
const hiddenKey = hidden.join(",");
|
||||
const hiddenSeries = useMemo(() => new Set(hiddenKey.split(",").filter(Boolean).map(Number)), [hiddenKey]);
|
||||
const visible = useMemo(() => props.series.filter((_, index) => !hiddenSeries.has(index)), [props.series, hiddenSeries]);
|
||||
const [rangeStart, rangeEnd] = range;
|
||||
const labels = useMemo(() => props.x_data.slice(rangeStart, rangeEnd + 1), [props.x_data, rangeStart, rangeEnd]);
|
||||
const pointIndex = Math.min(range[1], Math.max(range[0], point));
|
||||
const fullRange = range[0] === 0 && range[1] === props.x_data.length - 1;
|
||||
const summaries = visible.map((series) => {
|
||||
@@ -98,61 +104,63 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer
|
||||
const categoryColors = visible.length === 1;
|
||||
const horizontalBars = props.x_data.some((label) => label.length > 10);
|
||||
const numberLabel = chartValueLabel;
|
||||
const option = (type: "line" | "bar") => {
|
||||
const horizontal = type === "bar" && horizontalBars;
|
||||
const seriesType = (series: SafeChartSeries) => view === (props.chart_type === "bar" ? "bar" : "line") ? series.type ?? type : type;
|
||||
const includesBars = visible.some((series) => seriesType(series) === "bar");
|
||||
const categoryAxis = { type: "category", data: labels, boundaryGap: type === "bar",
|
||||
axisLabel: { fontSize: 11, color: "#64748b", hideOverlap: !horizontal, interval: horizontal ? 0 : "auto",
|
||||
...(horizontal ? { width: props.expanded ? 150 : Math.min(210, Math.max(96, plotWidth * 0.28)), overflow: "break", lineHeight: 16 } : {}) },
|
||||
axisTick: { alignWithLabel: true }, axisLine: { lineStyle: { color: "#cbd5e1" } } };
|
||||
const valueAxis = { type: "value", scale: !includesBars,
|
||||
...(!includesBars ? chartValueBounds(visible, range[0], range[1]) : {}),
|
||||
axisLabel: { fontSize: 11, color: "#64748b", formatter: numberLabel },
|
||||
splitLine: { lineStyle: { color: "#e2e8f0" } } };
|
||||
return {
|
||||
animation: animateInteraction && !reducedMotion, animationDuration: 180, animationDurationUpdate: 180,
|
||||
textStyle: { fontFamily: '"Noto Sans SC Variable", "PingFang SC", "Microsoft YaHei", sans-serif' },
|
||||
color: colors.filter((_, index) => !hidden.includes(index)), aria: { enabled: true },
|
||||
tooltip: { trigger: composite ? "item" : "axis", renderMode: composite ? "html" : "richText", confine: true,
|
||||
textStyle: { fontFamily: '"Noto Sans SC Variable", "PingFang SC", "Microsoft YaHei", sans-serif', fontSize: 12 },
|
||||
...(composite ? { formatter: (params: unknown) => {
|
||||
const first = (Array.isArray(params) ? params[0] : params) as { dataIndex?: number; seriesIndex?: number } | undefined;
|
||||
const focused = composite && typeof first?.seriesIndex === "number" ? visible.slice(first.seriesIndex, first.seriesIndex + 1) : visible;
|
||||
const text = typeof first?.dataIndex === "number"
|
||||
? chartPointTooltip(labels[first.dataIndex], focused, range[0] + first.dataIndex) : "";
|
||||
// ECharts richText tooltips omit the font family. Plain DOM text keeps CJK
|
||||
// readable and never interprets model-provided labels as HTML.
|
||||
const content = document.createElement("div");
|
||||
content.textContent = text;
|
||||
content.style.whiteSpace = "pre-line";
|
||||
return content;
|
||||
} } : {}), axisPointer: { type: type === "bar" ? "shadow" : "line" } },
|
||||
grid: { top: 28, right: 24, bottom: (props.xAxisName ?? props.x_axis_name) ? 48 : 28, left: 12, containLabel: true },
|
||||
xAxis: { ...(horizontal ? valueAxis : categoryAxis), name: horizontal ? props.yAxisName ?? props.y_axis_name : props.xAxisName ?? props.x_axis_name,
|
||||
nameLocation: "middle", nameGap: 30 },
|
||||
yAxis: horizontal ? { ...categoryAxis, inverse: true, name: props.xAxisName ?? props.x_axis_name } : { ...valueAxis, name: props.yAxisName ?? props.y_axis_name },
|
||||
series: visible.map((series) => ({ name: series.name, type: seriesType(series),
|
||||
data: series.data.slice(range[0], range[1] + 1).map((value, index) => type === "bar" && categoryColors
|
||||
? { value, itemStyle: { color: colors[(range[0] + index) % colors.length] } } : value),
|
||||
barMaxWidth: 32, barCategoryGap: "35%", connectNulls: false,
|
||||
showSymbol: labels.length <= 24, symbolSize: seriesType(series) === "scatter" ? 8 : 6, emphasis: { focus: "series" } }))
|
||||
const xAxisName = props.xAxisName ?? props.x_axis_name;
|
||||
const yAxisName = props.yAxisName ?? props.y_axis_name;
|
||||
// Keep tooltip functions and options stable across sizing, focus and table-only updates.
|
||||
const options = useMemo(() => {
|
||||
const option = (type: "line" | "bar") => {
|
||||
const horizontal = type === "bar" && horizontalBars;
|
||||
const horizontalAxisName = horizontal ? yAxisName : xAxisName;
|
||||
const verticalAxisName = horizontal ? xAxisName : yAxisName;
|
||||
const seriesType = (series: SafeChartSeries) => view === (props.chart_type === "bar" ? "bar" : "line") ? series.type ?? type : type;
|
||||
const includesBars = visible.some((series) => seriesType(series) === "bar");
|
||||
const categoryAxis = { type: "category", data: labels, boundaryGap: type === "bar",
|
||||
axisLabel: { fontSize: tile ? 10 : 11, color: "#64748b", hideOverlap: !horizontal, interval: horizontal ? 0 : "auto",
|
||||
...(tile ? { margin: 4, lineHeight: 12 } : {}),
|
||||
...(horizontal ? { width: fitted && !tile ? 150 : Math.min(210, Math.max(96, plotWidth * 0.28)), overflow: "break", lineHeight: tile ? 12 : 16 } : {}) },
|
||||
axisTick: { alignWithLabel: true }, axisLine: { lineStyle: { color: "#cbd5e1" } } };
|
||||
const valueAxis = { type: "value", scale: !includesBars,
|
||||
...(!includesBars ? chartValueBounds(visible, rangeStart, rangeEnd, chartUsesIntegerTicks(yAxisName)) : {}),
|
||||
...(chartUsesIntegerTicks(yAxisName) ? { minInterval: 1 } : {}),
|
||||
axisLabel: { fontSize: tile ? 10 : 11, color: "#64748b", formatter: numberLabel,
|
||||
...(tile ? { margin: 4, lineHeight: 12 } : {}) },
|
||||
splitLine: { lineStyle: { color: "#e2e8f0" } } };
|
||||
return {
|
||||
animation: animateInteraction && !reducedMotion, animationDuration: 180, animationDurationUpdate: 180,
|
||||
textStyle: { fontFamily: '"Noto Sans SC Variable", "PingFang SC", "Microsoft YaHei", sans-serif' },
|
||||
color: colors.filter((_, index) => !hiddenSeries.has(index)), aria: { enabled: true },
|
||||
tooltip: { trigger: composite ? "item" : "axis", renderMode: composite ? "html" : "richText", confine: true,
|
||||
textStyle: { fontFamily: '"Noto Sans SC Variable", "PingFang SC", "Microsoft YaHei", sans-serif', fontSize: 12 },
|
||||
...(composite ? { formatter: (params: unknown) => {
|
||||
const first = (Array.isArray(params) ? params[0] : params) as { dataIndex?: number; seriesIndex?: number } | undefined;
|
||||
const focused = composite && typeof first?.seriesIndex === "number" ? visible.slice(first.seriesIndex, first.seriesIndex + 1) : visible;
|
||||
const text = typeof first?.dataIndex === "number"
|
||||
? chartPointTooltip(labels[first.dataIndex], focused, rangeStart + first.dataIndex) : "";
|
||||
// ECharts richText tooltips omit the font family. Plain DOM text keeps CJK
|
||||
// readable and never interprets model-provided labels as HTML.
|
||||
const content = document.createElement("div");
|
||||
content.textContent = text;
|
||||
content.style.whiteSpace = "pre-line";
|
||||
return content;
|
||||
} } : {}), axisPointer: { type: type === "bar" ? "shadow" : "line" } },
|
||||
grid: { top: tile ? verticalAxisName ? 16 : 10 : 28, right: 24,
|
||||
bottom: tile ? horizontalAxisName ? 24 : 16 : xAxisName ? 48 : 28, left: 12, containLabel: true },
|
||||
xAxis: { ...(horizontal ? valueAxis : categoryAxis), name: horizontalAxisName,
|
||||
nameLocation: "middle", nameGap: tile ? 16 : 30,
|
||||
...(tile ? { nameTextStyle: { fontSize: 10, lineHeight: 12 } } : {}) },
|
||||
yAxis: { ...(horizontal ? { ...categoryAxis, inverse: true } : valueAxis), name: verticalAxisName,
|
||||
...(tile ? { nameGap: 4, nameTextStyle: { fontSize: 10, lineHeight: 12, align: "left" } } : {}) },
|
||||
series: visible.map((series) => ({ name: series.name, type: seriesType(series),
|
||||
data: series.data.slice(rangeStart, rangeEnd + 1).map((value, index) => type === "bar" && categoryColors
|
||||
? { value, itemStyle: { color: colors[(rangeStart + index) % colors.length] } } : value),
|
||||
barMaxWidth: 32, barCategoryGap: "35%", connectNulls: false,
|
||||
showSymbol: labels.length <= 24, symbolSize: seriesType(series) === "scatter" ? 8 : 6, emphasis: { focus: "series" } }))
|
||||
};
|
||||
};
|
||||
};
|
||||
const metadata = <>
|
||||
{!props.embedded && view !== "table" ? <div className="rounded-lg bg-muted/40 p-2.5" role="group" aria-label="当前区间摘要">
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-1">
|
||||
<span className="text-[11px] font-medium">区间摘要 <span className="font-normal text-muted-foreground">· 本地统计</span></span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{summaries.map((item) => <div key={item.name} className="answer-chart-range-summary text-[11px] leading-5">
|
||||
<span className="min-w-0 break-words text-muted-foreground">{item.name}</span>
|
||||
<span className="tabular-nums">{numberLabel(item.first)} → {numberLabel(item.last)}</span>
|
||||
<span className="text-muted-foreground">最小 {numberLabel(item.min)} · 最大 {numberLabel(item.max)}</span>
|
||||
</div>)}
|
||||
</div>
|
||||
</div> : null}
|
||||
{view !== "table" && inspecting ? <motion.section aria-label="数据点详情" className="rounded-lg border border-border p-2.5"
|
||||
return { line: option("line"), bar: option("bar") };
|
||||
}, [animateInteraction, categoryColors, composite, fitted, hiddenSeries, horizontalBars, labels, numberLabel, plotWidth,
|
||||
props.chart_type, rangeStart, rangeEnd, reducedMotion, tile, view, visible, xAxisName, yAxisName]);
|
||||
const pointDetails = view !== "table" && inspecting ? <motion.section aria-label="数据点详情" className="rounded-lg border border-border p-2.5"
|
||||
initial={pointer.current && !reducedMotion ? { opacity: 0, y: 4 } : false}
|
||||
animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.18, ease: agentEase }}>
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
@@ -170,69 +178,26 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer
|
||||
</dd>
|
||||
</div>)}
|
||||
</dl>
|
||||
</motion.section> : null}
|
||||
</motion.section> : null;
|
||||
const metadata = <>
|
||||
{!props.embedded && !tile && view !== "table" ? <div className="rounded-lg bg-muted/40 p-2.5" role="group" aria-label="当前区间摘要">
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-1">
|
||||
<span className="text-[11px] font-medium">区间摘要 <span className="font-normal text-muted-foreground">· 本地统计</span></span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{summaries.map((item) => <div key={item.name} className="answer-chart-range-summary text-[11px] leading-5">
|
||||
<span className="min-w-0 break-words text-muted-foreground">{item.name}</span>
|
||||
<span className="tabular-nums">{numberLabel(item.first)} → {numberLabel(item.last)}</span>
|
||||
<span className="text-muted-foreground">最小 {numberLabel(item.min)} · 最大 {numberLabel(item.max)}</span>
|
||||
</div>)}
|
||||
</div>
|
||||
</div> : null}
|
||||
{pointDetails}
|
||||
<p className="flex items-start gap-1.5 border-t pt-2.5 text-[11px] leading-5 text-muted-foreground">
|
||||
<Database size={12} className="mt-1 shrink-0" /><span className="min-w-0 break-words [overflow-wrap:anywhere]">{fullRange ? labels.length : `${labels.length} / ${props.x_data.length}`} 个数据点 · {visible.length} 组序列<br />来源:{props.source}</span>
|
||||
<Database size={12} className="mt-1 shrink-0" /><span className="min-w-0 break-words [overflow-wrap:anywhere]">{fullRange ? labels.length : `${labels.length} / ${props.x_data.length}`} 个数据点 · {visible.length} 组序列{props.embedded ? "" : <><br />来源:{props.source}</>}</span>
|
||||
</p>
|
||||
</>;
|
||||
return <Collapsible asChild><Card className={`answer-ui-chart overflow-hidden ${props.embedded || props.expanded ? "border-0 shadow-none" : ""} ${props.expanded ? "@container flex h-full min-h-0 min-w-0 flex-1 flex-col" : ""}`} role={props.embedded ? undefined : "group"} aria-label={props.embedded ? undefined : props.title}
|
||||
onPointerDownCapture={() => { pointer.current = true; }}
|
||||
onKeyDownCapture={() => {
|
||||
pointer.current = false;
|
||||
setAnimateInteraction(false);
|
||||
contentMotion.stop();
|
||||
contentMotion.set({ opacity: 1, y: 0 });
|
||||
}}>
|
||||
<CardHeader className="shrink-0 flex-row items-center justify-between gap-2 space-y-0 px-3.5 py-2.5">
|
||||
<CardTitle className="flex min-w-0 items-center gap-2 text-xs leading-5 tracking-normal"><TrendingUp size={15} className="shrink-0" aria-hidden />{props.embedded || props.expanded ? "图形与数据" : props.title}</CardTitle>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{!props.embedded && !props.expanded ? <FloatingWindow title={props.title} fillContent
|
||||
openRequestKey={chartWindowKey === undefined ? undefined : JSON.stringify([chartWindowKey, props])}>
|
||||
{() => <InteractiveChart {...props} expanded />}
|
||||
</FloatingWindow> : null}
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button type="button" variant="ghost" size="icon-sm" aria-label="筛选数据范围" title="筛选数据范围"
|
||||
className="shrink-0 text-muted-foreground">
|
||||
<SlidersHorizontal size={14} aria-hidden />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className={`${props.expanded ? "flex min-h-0 flex-1 flex-col" : "space-y-3"} px-3.5 pb-3.5 pt-0`}>
|
||||
{migrationNotice ? <p role="status" className="text-xs leading-5 text-muted-foreground">{migrationNotice}</p> : null}
|
||||
<Tabs value={view} onValueChange={setView} className={props.expanded ? "grid min-h-0 flex-1 grid-cols-1 grid-rows-[auto_auto_minmax(120px,1fr)_minmax(0,auto)] gap-x-3 gap-y-2 @min-[560px]:grid-cols-[minmax(0,1fr)_176px] @min-[560px]:grid-rows-[auto_auto_minmax(120px,1fr)]" : undefined}>
|
||||
<TabsList aria-label={`${props.title}显示方式`} className={`${props.expanded ? "col-start-1 row-start-1 min-w-0" : "mb-3"} relative grid w-full grid-cols-3 [&>[role=tab]]:relative [&>[role=tab]]:z-10 [&>[role=tab][data-state=active]]:bg-transparent [&>[role=tab][data-state=active]]:shadow-none`}>
|
||||
<motion.span aria-hidden className="pointer-events-none absolute inset-y-1 left-1 rounded-md bg-card shadow-xs"
|
||||
style={{ width: "calc((100% - 0.5rem) / 3)" }}
|
||||
initial={false} animate={{ x: view === "line" ? "0%" : view === "bar" ? "100%" : "200%" }}
|
||||
transition={{ duration: animateInteraction && !reducedMotion ? 0.18 : 0, ease: agentEase }} />
|
||||
<TabsTrigger value="line"><TrendingUp size={13} />{composite ? "复合" : "折线"}</TabsTrigger>
|
||||
<TabsTrigger value="bar"><BarChart3 size={13} />柱状</TabsTrigger>
|
||||
<TabsTrigger value="table"><Table2 size={13} />数据</TabsTrigger>
|
||||
</TabsList>
|
||||
<aside className={props.expanded ? "col-start-1 row-start-4 max-h-32 min-h-0 min-w-0 space-y-2 overflow-y-auto border-t border-border pt-2 @min-[560px]:col-start-2 @min-[560px]:row-start-1 @min-[560px]:row-span-3 @min-[560px]:max-h-none @min-[560px]:border-t-0 @min-[560px]:border-l @min-[560px]:pt-0 @min-[560px]:pl-3" : undefined}>
|
||||
<div className={`answer-ui-chart-series mb-2 flex flex-wrap gap-2 ${props.expanded ? "@min-[560px]:flex-col @min-[560px]:flex-nowrap @min-[560px]:items-stretch" : ""}`} aria-label="显示的数据序列">
|
||||
{props.series.map((series, index) => <Button key={index} size="sm" variant="outline"
|
||||
className={`h-auto min-h-8 min-w-0 max-w-full gap-1.5 whitespace-normal text-left text-[11px] after:inset-0 ${props.expanded ? "justify-start" : ""}`} aria-pressed={!hidden.includes(index)}
|
||||
disabled={visible.length === 1 && !hidden.includes(index)}
|
||||
onClick={() => { setAnimateInteraction(pointer.current && !reducedMotion); patch({ hidden: hidden.includes(index) ? hidden.filter((item) => item !== index) : [...hidden, index],
|
||||
table: { query: "", ...table, sort: null, selection: [] } }, ["hidden", "table/sort"]); }}>
|
||||
<span className="size-2 shrink-0 rounded-full" style={{ background: hidden.includes(index) ? "#cbd5e1" : colors[index] }} /><span className="min-w-0 break-words">{series.name}</span>
|
||||
</Button>)}
|
||||
</div>
|
||||
{hidden.length ? <Button type="button" variant="ghost" size="sm" onClick={() => patch({ hidden: [] }, ["hidden"])}>显示全部序列</Button> : null}
|
||||
{view === "bar" && categoryColors ? <div aria-label="类别图例" className={`mb-3 grid gap-1.5 rounded-md bg-muted/30 p-2 ${props.expanded ? "" : "max-h-40 overflow-y-auto"}`} style={{ gridTemplateColumns: `repeat(${props.expanded ? 1 : plotWidth >= 700 ? 3 : plotWidth >= 420 ? 2 : 1},minmax(0,1fr))` }}>
|
||||
{labels.map((label, index) => <div key={index} className="flex min-w-0 items-start gap-2 text-[11px] leading-5">
|
||||
<span className="mt-1.5 size-2 shrink-0 rounded-sm" style={{ background: colors[(range[0] + index) % colors.length] }} />
|
||||
<span className="min-w-0 break-words">{label}</span>
|
||||
<span className="ml-auto shrink-0 tabular-nums">{numberLabel(visible[0].data[range[0] + index])}</span>
|
||||
</div>)}
|
||||
</div> : null}
|
||||
{props.expanded ? metadata : null}
|
||||
</aside>
|
||||
<div className={props.expanded ? "col-start-1 row-start-2 min-w-0" : undefined}>
|
||||
{!fullRange ? <p className="mb-2 text-[11px] text-muted-foreground">{props.x_data[range[0]]} 至 {props.x_data[range[1]]}</p> : null}
|
||||
<CollapsibleContent className="mb-2 space-y-2 rounded-lg border bg-muted/30 p-2.5">
|
||||
const rangeControls = <div className="space-y-2">
|
||||
<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])])} />
|
||||
@@ -242,12 +207,63 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="sm" disabled={fullRange}
|
||||
onClick={() => setRange([0, props.x_data.length - 1])}>重置范围</Button>
|
||||
</CollapsibleContent>
|
||||
</div>;
|
||||
return <Collapsible asChild><Card className={`answer-ui-chart overflow-hidden ${props.embedded || props.expanded ? "border-0 shadow-none" : ""} ${fitted ? "@container flex h-full min-h-0 min-w-0 flex-1 flex-col" : ""}`} data-chart-tile={tile || undefined} role={props.embedded ? undefined : "group"} aria-label={props.embedded ? undefined : props.title}
|
||||
onPointerDownCapture={() => { pointer.current = true; }}
|
||||
onKeyDownCapture={() => {
|
||||
pointer.current = false;
|
||||
setAnimateInteraction(false);
|
||||
contentMotion.stop();
|
||||
contentMotion.set({ opacity: 1, y: 0 });
|
||||
}}>
|
||||
{!props.embedded ? <CardHeader className={`shrink-0 flex-row items-center justify-between gap-2 space-y-0 ${tile ? "px-2 py-1.5" : "px-3.5 py-2.5"}`}>
|
||||
<CardTitle className="flex min-w-0 items-center gap-2 text-xs leading-5 tracking-normal"><TrendingUp size={15} className="shrink-0" aria-hidden />{props.expanded ? "图形与数据" : props.title}</CardTitle>
|
||||
<div className={`flex shrink-0 items-center gap-1 ${tile ? "[&>button]:size-6" : ""}`}>
|
||||
{!props.embedded && !props.expanded ? <FloatingWindow title={props.title} fillContent>
|
||||
{() => <InteractiveChart {...props} expanded tile={false} />}
|
||||
</FloatingWindow> : null}
|
||||
{tile ? <Popover><PopoverTrigger asChild>
|
||||
<Button type="button" variant="ghost" size="icon-sm" aria-label="查看图表来源" title="查看图表来源"><FileText size={14} aria-hidden /></Button>
|
||||
</PopoverTrigger><PopoverContent align="end" className="workbench-shadcn-surface z-[90] max-h-72 overflow-auto text-xs leading-5">{props.source}</PopoverContent></Popover> : <CollapsibleTrigger asChild>
|
||||
<Button type="button" variant="ghost" size="icon-sm" aria-label="筛选数据范围" title="筛选数据范围" className="shrink-0 text-muted-foreground"><SlidersHorizontal size={14} aria-hidden /></Button>
|
||||
</CollapsibleTrigger>}
|
||||
</div>
|
||||
</CardHeader> : null}
|
||||
<CardContent className={`${fitted ? "flex min-h-0 flex-1 flex-col" : "space-y-3"} ${props.embedded ? "px-0 pb-0" : tile ? "px-2 pb-2" : "px-3.5 pb-3.5"} pt-0`}>
|
||||
{migrationNotice ? <p role="status" className="text-xs leading-5 text-muted-foreground">{migrationNotice}</p> : null}
|
||||
<Tabs value={view} onValueChange={setView} className={fitted ? `grid min-h-0 flex-1 grid-cols-1 ${tile ? "grid-rows-[auto_minmax(100px,1fr)_minmax(32px,auto)] gap-y-1" : "grid-rows-[auto_auto_minmax(180px,1fr)_auto] gap-y-2"}` : undefined}>
|
||||
<div className={`flex min-w-0 items-center gap-2 ${fitted ? "col-start-1 row-start-1" : "mb-3"}`}>
|
||||
<TabsList aria-label={`${props.title}显示方式`} className={`relative grid min-w-0 flex-1 grid-cols-3 ${tile ? "min-h-7 p-0.5" : ""} [&>[role=tab]]:relative [&>[role=tab]]:z-10 [&>[role=tab][data-state=active]]:bg-transparent [&>[role=tab][data-state=active]]:shadow-none`}>
|
||||
<motion.span aria-hidden className="pointer-events-none absolute inset-y-1 left-1 rounded-md bg-card shadow-xs"
|
||||
style={{ width: "calc((100% - 0.5rem) / 3)" }}
|
||||
initial={false} animate={{ x: view === "line" ? "0%" : view === "bar" ? "100%" : "200%" }}
|
||||
transition={{ duration: animateInteraction && !reducedMotion ? 0.18 : 0, ease: agentEase }} />
|
||||
<TabsTrigger value="line" className={tile ? "min-h-6 px-1.5 py-0.5 text-[11px]" : undefined}><TrendingUp size={13} />{composite ? "复合" : "折线"}</TabsTrigger>
|
||||
<TabsTrigger value="bar" className={tile ? "min-h-6 px-1.5 py-0.5 text-[11px]" : undefined}><BarChart3 size={13} />柱状</TabsTrigger>
|
||||
<TabsTrigger value="table" className={tile ? "min-h-6 px-1.5 py-0.5 text-[11px]" : undefined}><Table2 size={13} />数据</TabsTrigger>
|
||||
</TabsList>
|
||||
{tile ? <>
|
||||
<Popover><PopoverTrigger asChild><Button type="button" variant="ghost" size="icon-sm" aria-label="筛选数据范围" title="筛选数据范围" className={`h-7 shrink-0 ${fullRange ? "w-7" : "w-auto gap-1 px-1.5"}`}><SlidersHorizontal size={14} aria-hidden />
|
||||
{!fullRange ? <span className="max-w-20 truncate text-[10px]" title={`${props.x_data[range[0]]} 至 ${props.x_data[range[1]]}`}>{props.x_data[range[0]]} 至 {props.x_data[range[1]]}</span> : null}
|
||||
</Button></PopoverTrigger>
|
||||
<PopoverContent align="end" className="workbench-shadcn-surface z-[90] p-3">{rangeControls}</PopoverContent>
|
||||
</Popover>
|
||||
{inspecting && view !== "table" ? <Popover open={inspecting} onOpenChange={setInspecting}><PopoverTrigger asChild><Button type="button" variant="ghost" size="icon-sm" aria-label="数据点详情" title="数据点详情" className="size-7 shrink-0"><Database size={14} aria-hidden /></Button></PopoverTrigger>
|
||||
<PopoverContent align="end" className="workbench-shadcn-surface z-[90] max-h-[60dvh] overflow-auto p-2" onOpenAutoFocus={(event) => event.preventDefault()}>{pointDetails}</PopoverContent>
|
||||
</Popover> : null}
|
||||
</> : props.embedded ? <CollapsibleTrigger asChild>
|
||||
<Button type="button" variant="ghost" size="icon-sm" aria-label="筛选数据范围" title="筛选数据范围"><SlidersHorizontal size={14} aria-hidden /></Button>
|
||||
</CollapsibleTrigger> : null}
|
||||
</div>
|
||||
<motion.div className={props.expanded ? "col-start-1 row-start-3 min-h-0 min-w-0" : undefined} data-chart-view={view} initial={false} animate={contentMotion}>
|
||||
{(["line", "bar"] as const).map((type) => <TabsContent key={type} value={type} className={props.expanded ? "h-full min-h-0" : undefined}>
|
||||
|
||||
{!tile ? <div className={fitted ? "col-start-1 row-start-2 min-w-0" : undefined}>
|
||||
{!fullRange ? <p className="mb-2 text-[11px] text-muted-foreground">{props.x_data[range[0]]} 至 {props.x_data[range[1]]}</p> : null}
|
||||
{!tile ? <CollapsibleContent className="mb-2 space-y-2 rounded-lg border bg-muted/30 p-2.5">{rangeControls}</CollapsibleContent> : null}
|
||||
</div> : null}
|
||||
<motion.div className={fitted ? `col-start-1 ${tile ? "row-start-2" : "row-start-3"} min-h-0 min-w-0` : undefined} data-chart-view={view} initial={false} animate={contentMotion}>
|
||||
{(["line", "bar"] as const).map((type) => <TabsContent key={type} value={type} className={fitted ? "h-full min-h-0" : undefined}>
|
||||
<div ref={view === type ? plot : undefined} role="group" aria-label={`${props.title},按回车查看数据点,方向键切换数据点`} tabIndex={0}
|
||||
className={`${props.expanded ? (type === "bar" && horizontalBars ? "h-full min-h-0 overflow-x-hidden overflow-y-auto" : "h-full min-h-0 overflow-hidden") : ""} rounded-md focus-visible:outline-2 focus-visible:outline-ring`}
|
||||
className={`${fitted ? (type === "bar" && horizontalBars ? "h-full min-h-0 overflow-x-hidden overflow-y-auto" : "h-full min-h-0 overflow-hidden") : ""} rounded-md focus-visible:outline-2 focus-visible:outline-ring`}
|
||||
onKeyDown={(event) => {
|
||||
if (event.target !== event.currentTarget) return;
|
||||
if (["Enter", "ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) {
|
||||
@@ -259,13 +275,13 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer
|
||||
if (event.key === "Escape") setInspecting(false);
|
||||
}}>
|
||||
<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 role="img" aria-label={props.title} style={{ height: fitted ? "100%" : props.plotHeight ?? 220,
|
||||
minHeight: fitted && type === "bar" && horizontalBars ? labels.length * (tile ? 28 : 36) + 48 : undefined }}>
|
||||
<ResponsiveChart option={options[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" }}>
|
||||
<TabsContent value="table" className={fitted ? "h-full overflow-auto" : undefined} forceMount style={view === "table" ? undefined : { display: "none" }}>
|
||||
<InteractiveDataTable title={props.title} columns={["时间或类别", ...visible.map((series) => series.name)]}
|
||||
rows={labels.map((label, index) => [label, ...visible.map((series) => {
|
||||
const offset = range[0] + index, time = chartSampleTime(series.sample_times?.[offset]);
|
||||
@@ -274,7 +290,27 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer
|
||||
state={table} onStateChange={(updates) => patch({ table: { query: "", sort: null, selection: [], ...table, ...updates } }, Object.keys(updates).map((key) => `table/${key}`))} />
|
||||
</TabsContent>
|
||||
</motion.div>
|
||||
{!props.expanded ? metadata : null}
|
||||
<div aria-label="图表图例" className={fitted ? `col-start-1 ${tile ? "row-start-3 min-h-8 max-h-14" : `row-start-4 min-h-0 ${props.embedded ? "max-h-56" : "max-h-40"}`} min-w-0 overflow-y-auto border-t border-border ${tile ? "pt-1" : "pt-2"}` : "border-t border-border pt-3"}>
|
||||
<div className={`answer-ui-chart-series flex flex-wrap ${tile ? "gap-1" : "mb-2 gap-2"}`} aria-label="显示的数据序列">
|
||||
{props.series.map((series, index) => <Button key={index} size="sm" variant="outline"
|
||||
className={`h-auto ${tile ? "min-h-6 px-2 py-0.5" : "min-h-8"} min-w-0 max-w-full justify-start gap-1.5 whitespace-normal text-left text-[11px] after:inset-0`} aria-pressed={!hidden.includes(index)}
|
||||
disabled={visible.length === 1 && !hidden.includes(index)}
|
||||
onClick={() => { setAnimateInteraction(pointer.current && !reducedMotion && !tile); patch({ hidden: hidden.includes(index) ? hidden.filter((item) => item !== index) : [...hidden, index],
|
||||
table: { query: "", ...table, sort: null, selection: [] } }, ["hidden", "table/sort"]); }}>
|
||||
<span className="size-2 shrink-0 rounded-full" style={{ background: hidden.includes(index) ? "#cbd5e1" : colors[index] }} /><span className="min-w-0 break-words">{series.name}</span>
|
||||
</Button>)}
|
||||
</div>
|
||||
{hidden.length ? <Button type="button" variant="ghost" size="sm" onClick={() => patch({ hidden: [] }, ["hidden"])}>显示全部序列</Button> : null}
|
||||
{view === "bar" && categoryColors ? <div aria-label="类别图例" className={`mb-3 grid gap-1.5 rounded-md bg-muted/30 p-2 ${fitted ? "" : "max-h-40 overflow-y-auto"}`} style={{ gridTemplateColumns: `repeat(${fitted ? 1 : plotWidth >= 700 ? 3 : plotWidth >= 420 ? 2 : 1},minmax(0,1fr))` }}>
|
||||
{labels.map((label, index) => <div key={index} className="flex min-w-0 items-start gap-2 text-[11px] leading-5">
|
||||
<span className="mt-1.5 size-2 shrink-0 rounded-sm" style={{ background: colors[(range[0] + index) % colors.length] }} />
|
||||
<span className="min-w-0 break-words">{label}</span>
|
||||
<span className="ml-auto shrink-0 tabular-nums">{numberLabel(visible[0].data[range[0] + index])}</span>
|
||||
</div>)}
|
||||
</div> : null}
|
||||
{props.expanded && !tile ? metadata : null}
|
||||
</div>
|
||||
{!fitted ? metadata : null}
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card></Collapsible>;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react";
|
||||
import { ArrowDown, ArrowUp } from "lucide-react";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { FloatingWindow } from "@/shared/ui/floating-window";
|
||||
@@ -45,13 +45,13 @@ export function InteractiveDataTable({ title, columns, rows, state: controlledSt
|
||||
<Table>
|
||||
<TableCaption className="caption-top font-medium">{title}</TableCaption>
|
||||
<TableHeader className="bg-muted/50"><TableRow>
|
||||
{columns.map((column, index) => <TableHead key={index} scope="col" className="whitespace-nowrap"
|
||||
{columns.map((column, index) => <TableHead key={index} scope="col" className="whitespace-nowrap p-0"
|
||||
aria-sort={sort?.column === index ? sort.descending ? "descending" : "ascending" : "none"}>
|
||||
<Button size="sm" variant="ghost" className="h-9 gap-1 px-1 text-xs" aria-label={`按${column}排序`}
|
||||
<Button variant="ghost" className="h-10 w-full justify-start gap-1 rounded-none px-4 text-xs focus-visible:ring-inset focus-visible:ring-offset-0" aria-label={`按${column}排序`}
|
||||
onClick={() => patch({ sort: sort?.column === index
|
||||
? sort.descending ? null : { column: index, descending: true }
|
||||
: { column: index, descending: false } })}>
|
||||
{column}{sort?.column === index ? sort.descending ? <ArrowDown size={12} /> : <ArrowUp size={12} /> : <ArrowUpDown size={12} />}
|
||||
{column}{sort?.column === index ? sort.descending ? <ArrowDown size={12} aria-hidden /> : <ArrowUp size={12} aria-hidden /> : null}
|
||||
</Button>
|
||||
</TableHead>)}
|
||||
</TableRow></TableHeader>
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { act, cleanup, render } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { ResponsiveChart } from "./responsive-chart";
|
||||
|
||||
const engine = vi.hoisted(() => ({ resize: vi.fn() }));
|
||||
vi.mock("echarts-for-react", async () => {
|
||||
const React = await import("react");
|
||||
return { default: React.forwardRef(function MockChart({ onChartReady }: { onChartReady?: (instance: typeof engine) => void }, ref) {
|
||||
React.useImperativeHandle(ref, () => ({ getEchartsInstance: () => engine }), []);
|
||||
React.useLayoutEffect(() => { onChartReady?.(engine); }, [onChartReady]);
|
||||
return <div data-testid="chart-engine" />;
|
||||
}) };
|
||||
});
|
||||
|
||||
const observers: ResizeObserverCallback[] = [];
|
||||
const frames = new Map<number, FrameRequestCallback>();
|
||||
let frameId = 0;
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "performance"] });
|
||||
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { frames.set(++frameId, callback); return frameId; });
|
||||
vi.stubGlobal("cancelAnimationFrame", (id: number) => { frames.delete(id); });
|
||||
vi.stubGlobal("ResizeObserver", class {
|
||||
constructor(callback: ResizeObserverCallback) { observers.push(callback); }
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
engine.resize.mockReset();
|
||||
observers.length = 0;
|
||||
frames.clear();
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function fixture(resizing: boolean, count = 1) {
|
||||
const observerStart = observers.length;
|
||||
const view = render(<div data-floating-window="" data-floating-window-resizing={resizing ? "true" : undefined}>
|
||||
{Array.from({ length: count }, (_, index) => <ResponsiveChart key={index} option={{}} height="100%" onPointSelect={() => undefined} />)}
|
||||
</div>);
|
||||
const shell = view.container.firstElementChild as HTMLElement;
|
||||
const size = { width: 320, height: 200 };
|
||||
for (const host of shell.children) Object.defineProperties(host, { clientWidth: { get: () => size.width }, clientHeight: { get: () => size.height } });
|
||||
const notify = (index = 0) => observers[observerStart + index]([], {} as ResizeObserver);
|
||||
const paint = () => { const pending = [...frames.values()]; frames.clear(); pending.forEach((callback) => callback(performance.now())); };
|
||||
engine.resize.mockClear();
|
||||
return { view, shell, size, notify, paint };
|
||||
}
|
||||
|
||||
it("coalesces active resizes to the latest actual dimensions with a 64ms interval", () => {
|
||||
const chart = fixture(true);
|
||||
const sizes: number[] = [];
|
||||
engine.resize.mockImplementation(() => { sizes.push(chart.size.width); });
|
||||
chart.notify();
|
||||
chart.notify();
|
||||
expect(frames.size).toBe(1);
|
||||
chart.paint();
|
||||
act(() => vi.advanceTimersByTime(16));
|
||||
chart.size.width = 400;
|
||||
chart.notify();
|
||||
chart.paint();
|
||||
expect(engine.resize).toHaveBeenCalledTimes(1);
|
||||
chart.size.width = 520;
|
||||
chart.notify();
|
||||
act(() => vi.advanceTimersByTime(48));
|
||||
chart.paint();
|
||||
expect(sizes).toEqual([320, 520]);
|
||||
expect(engine.resize).toHaveBeenLastCalledWith({ width: "auto", height: "auto", animation: { duration: 0 } });
|
||||
});
|
||||
|
||||
it("spaces charts in the same window from the last completed draw", () => {
|
||||
const chart = fixture(true, 2);
|
||||
engine.resize.mockImplementation(() => { vi.advanceTimersByTime(70); });
|
||||
chart.notify(0);
|
||||
chart.notify(1);
|
||||
chart.paint();
|
||||
expect(engine.resize).toHaveBeenCalledTimes(1);
|
||||
act(() => vi.advanceTimersByTime(64));
|
||||
chart.paint();
|
||||
expect(engine.resize).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not delay a different window behind another window's draw", () => {
|
||||
const first = fixture(true);
|
||||
const second = fixture(true);
|
||||
first.notify();
|
||||
second.notify();
|
||||
first.paint();
|
||||
expect(engine.resize).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("flushes the final geometry on release without waiting for the remaining interval", async () => {
|
||||
const chart = fixture(true);
|
||||
chart.notify();
|
||||
chart.paint();
|
||||
act(() => vi.advanceTimersByTime(16));
|
||||
chart.notify();
|
||||
chart.paint();
|
||||
expect(vi.getTimerCount()).toBe(1);
|
||||
await act(async () => { delete chart.shell.dataset.floatingWindowResizing; await Promise.resolve(); });
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
chart.paint();
|
||||
expect(engine.resize).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("keeps normal layout changes immediate instead of applying pointer throttling", () => {
|
||||
const chart = fixture(false);
|
||||
chart.notify();
|
||||
chart.paint();
|
||||
act(() => vi.advanceTimersByTime(16));
|
||||
chart.notify();
|
||||
chart.paint();
|
||||
expect(engine.resize).toHaveBeenCalledTimes(2);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("cancels scheduled drawing on unmount", () => {
|
||||
const chart = fixture(true);
|
||||
chart.notify();
|
||||
chart.paint();
|
||||
act(() => vi.advanceTimersByTime(16));
|
||||
chart.notify();
|
||||
chart.paint();
|
||||
chart.view.unmount();
|
||||
act(() => vi.advanceTimersByTime(100));
|
||||
chart.paint();
|
||||
expect(engine.resize).toHaveBeenCalledTimes(1);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useLayoutEffect, useRef, type ComponentProps } from "react";
|
||||
import ReactECharts from "echarts-for-react";
|
||||
|
||||
/** Resize the existing drawing at most once per frame without replacing its options. */
|
||||
const resizedWindows = new WeakMap<HTMLElement, number>();
|
||||
|
||||
/** Reflow the existing drawing, coalescing rapid pointer resizes without scaling it. */
|
||||
export function ResponsiveChart({ option, height, onPointSelect }: {
|
||||
option: ComponentProps<typeof ReactECharts>["option"];
|
||||
height: number | "100%";
|
||||
@@ -13,16 +15,32 @@ export function ResponsiveChart({ option, height, onPointSelect }: {
|
||||
useLayoutEffect(() => {
|
||||
const element = host.current;
|
||||
if (!element || typeof ResizeObserver === "undefined") return;
|
||||
const shell = element.closest<HTMLElement>("[data-floating-window]");
|
||||
let frame = 0;
|
||||
const observer = new ResizeObserver(() => {
|
||||
cancelAnimationFrame(frame);
|
||||
frame = requestAnimationFrame(() => {
|
||||
if (!ready.current || !element.clientWidth || !element.clientHeight) return;
|
||||
chart.current?.getEchartsInstance().resize({ width: "auto", height: "auto", animation: { duration: 0 } });
|
||||
});
|
||||
let timer = 0;
|
||||
const resize = () => {
|
||||
frame = 0;
|
||||
if (!ready.current || !element.clientWidth || !element.clientHeight) return;
|
||||
// Space expensive drawings in one window instead of redrawing every chart together.
|
||||
const remaining = 64 - (performance.now() - (shell ? resizedWindows.get(shell) ?? -Infinity : -Infinity));
|
||||
if (shell?.dataset.floatingWindowResizing === "true" && remaining > 0) {
|
||||
timer = window.setTimeout(() => { timer = 0; frame = requestAnimationFrame(resize); }, remaining);
|
||||
return;
|
||||
}
|
||||
chart.current?.getEchartsInstance().resize({ width: "auto", height: "auto", animation: { duration: 0 } });
|
||||
if (shell) resizedWindows.set(shell, performance.now());
|
||||
};
|
||||
const schedule = () => { if (!frame && !timer) frame = requestAnimationFrame(resize); };
|
||||
const observer = new ResizeObserver(schedule);
|
||||
const settled = new MutationObserver(() => {
|
||||
if (shell?.dataset.floatingWindowResizing === "true") return;
|
||||
clearTimeout(timer);
|
||||
timer = 0;
|
||||
schedule();
|
||||
});
|
||||
observer.observe(element);
|
||||
return () => { cancelAnimationFrame(frame); observer.disconnect(); };
|
||||
if (shell) settled.observe(shell, { attributes: true, attributeFilter: ["data-floating-window-resizing"] });
|
||||
return () => { clearTimeout(timer); cancelAnimationFrame(frame); observer.disconnect(); settled.disconnect(); };
|
||||
}, []);
|
||||
return <div ref={host} style={{ height }}>
|
||||
<ReactECharts ref={chart} option={option} opts={{ renderer: "svg" }} notMerge autoResize={false}
|
||||
|
||||
@@ -72,13 +72,13 @@ function AgentUiResultCard({ result }: { result: AgentUiResult }) {
|
||||
);
|
||||
}
|
||||
|
||||
export const AgentChartView = memo(function AgentChartView({ chart, embedded = false, expanded = false }: { chart: NonNullable<ReturnType<typeof parseFrontendChart>>; embedded?: boolean; expanded?: boolean }) {
|
||||
return <SafeChartEnvelope embedded={embedded} expanded={expanded} envelope={{ kind: "chart", schemaVersion: "agent-ui@1", grammar: "echarts-safe-subset", surface: "side_panel", spec: { title: chart.title, chart_type: chart.chart_type,
|
||||
export const AgentChartView = memo(function AgentChartView({ chart, embedded = false, expanded = false, plotHeight, tile = false }: { chart: NonNullable<ReturnType<typeof parseFrontendChart>>; embedded?: boolean; expanded?: boolean; plotHeight?: number; tile?: boolean }) {
|
||||
return <SafeChartEnvelope embedded={embedded} expanded={expanded} plotHeight={plotHeight} tile={tile} envelope={{ kind: "chart", schemaVersion: "agent-ui@1", grammar: "echarts-safe-subset", surface: "side_panel", spec: { title: chart.title, chart_type: chart.chart_type,
|
||||
...(chart.x_axis_name ? { x_axis_name: chart.x_axis_name } : {}),
|
||||
...(chart.y_axis_name ? { y_axis_name: chart.y_axis_name } : {}) }, data: { x_data: chart.x_data, series: chart.series } }} />;
|
||||
});
|
||||
|
||||
function SafeChartEnvelope({ envelope, embedded = false, expanded = false }: { envelope: Extract<UIEnvelope, { kind: "chart" }>; embedded?: boolean; expanded?: boolean }) {
|
||||
function SafeChartEnvelope({ envelope, embedded = false, expanded = false, plotHeight, tile = false }: { envelope: Extract<UIEnvelope, { kind: "chart" }>; embedded?: boolean; expanded?: boolean; plotHeight?: number; tile?: boolean }) {
|
||||
const parsed = parseChartSpec(envelope.spec);
|
||||
const raw = normalizeSafeChartData(envelope.data, 100);
|
||||
const displayed = withChartDisplayTimes({ title: parsed.title ?? "Agent 图表", x_axis_name: parsed.xAxisName,
|
||||
@@ -97,7 +97,7 @@ function SafeChartEnvelope({ envelope, embedded = false, expanded = false }: { e
|
||||
series={data.series.slice(0, 8)}
|
||||
source="来源见关联依据。"
|
||||
embedded={embedded}
|
||||
expanded={expanded}
|
||||
expanded={expanded} plotHeight={plotHeight} tile={tile}
|
||||
xAxisName={spec.xAxisName}
|
||||
yAxisName={spec.yAxisName}
|
||||
/>;
|
||||
|
||||
@@ -116,3 +116,4 @@ export { readInteractionReply } from "./api/interaction-requests";
|
||||
export type { InteractionRequestsController } from "./hooks/use-interaction-requests";
|
||||
|
||||
export { presentationCommandSchema, presentationOverlayDataSchema, PRESENTATION_ACTIONS, type PresentationCommand } from "./presentation/command";
|
||||
export { chartPublicationError, safeChartPublicationInput } from "./presentation/command-schema";
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
import cases from "../../../../tests/fixtures/chart-publication-cases.json";
|
||||
import examples from "../../../../tests/fixtures/unified-presentation.jsonl?raw";
|
||||
import { createViewSchemas, safeChartInput, safeChartPublicationInput } from "./command-schema";
|
||||
|
||||
describe("shared chart publication conformance", () => {
|
||||
it.each(cases)("$name", ({ chart, accepted }) => {
|
||||
expect(safeChartPublicationInput.safeParse(chart).success).toBe(accepted);
|
||||
for (const publication of [false, true]) {
|
||||
const schema = createViewSchemas(z, publication, true).command;
|
||||
expect(schema.safeParse({ action: "open_chart", chart_id: "comparison", evidence_refs: ["dev:synthetic"], chart }).success).toBe(accepted);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps historical mixed charts structurally readable", () => {
|
||||
expect(safeChartInput.safeParse(cases.find((item) => item.name === "old mixed charts are read only")!.chart).success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts every exported command example through the publication schema", () => {
|
||||
const schema = createViewSchemas(z, true).command;
|
||||
for (const record of examples.trim().split("\n").map((line) => JSON.parse(line)))
|
||||
if (record.record_type === "presentation") expect(schema.safeParse(record.command).success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -7,8 +7,63 @@ export function createChartSampleTimeSchema(zod: typeof z = z) {
|
||||
);
|
||||
}
|
||||
|
||||
type PublicationChart = {
|
||||
chart_type: string;
|
||||
x_data: string[];
|
||||
series: Array<{ type?: string; data: Array<number | null>; sample_times?: Array<string | null> }>;
|
||||
};
|
||||
|
||||
/** Mirrors Server time_comparison_chart.py for new publications, never historical reads. */
|
||||
export function chartPublicationError(chart: PublicationChart): string | null {
|
||||
const kinds = new Set(chart.series.map((series) => series.type ?? (chart.chart_type === "time_comparison" ? "line" : chart.chart_type)));
|
||||
if (chart.chart_type !== "time_comparison") return kinds.has("line") && kinds.has("scatter")
|
||||
? "曲线与历史散点复合图须使用 chart_type=time_comparison" : null;
|
||||
if (kinds.size !== 2 || !kinds.has("line") || !kinds.has("scatter"))
|
||||
return "time_comparison 必须包含当前 line 和历史 scatter,不能混入 bar";
|
||||
const labels = chart.x_data;
|
||||
if (!labels.every((label) => /^(?:[01][0-9]|2[0-3]):[0-5][0-9](?::[0-5][0-9])?$/.test(label)) || new Set(labels.map((label) => label.length)).size !== 1)
|
||||
return "time_comparison.x_data 必须使用一致精度的 HH:mm 或 HH:mm:ss 共享时刻槽";
|
||||
const seconds = labels.map((label) => label.split(":").reduce((value, part, index) => value + Number(part) * [3600, 60, 1][index], 0));
|
||||
const offsets = [0];
|
||||
for (let index = 1; index < seconds.length; index++) {
|
||||
const step = (seconds[index] - seconds[index - 1] + 86400) % 86400;
|
||||
if (!step) return "x_data 不能重复时刻,历史点应复用当前槽位";
|
||||
offsets.push(offsets[index - 1] + step);
|
||||
}
|
||||
if (offsets.at(-1)! >= 86400) return "x_data 必须按时间递增且小于一天";
|
||||
const current = new Set<number>();
|
||||
const historical: number[] = [];
|
||||
for (const series of chart.series) {
|
||||
const times = series.sample_times;
|
||||
if (!times || times.length !== labels.length) return "sample_times 必须提供并与 x_data 等长";
|
||||
const bases = new Set<number>();
|
||||
for (let index = 0; index < times.length; index++) {
|
||||
const at = times[index];
|
||||
if ((series.data[index] === null) !== (at === null)) return "有效值必须有真实采样时间,缺测值和时间均填 null";
|
||||
if (at === null) continue;
|
||||
const local = new Date(Date.parse(at) + 8 * 3600_000);
|
||||
const base = local.getTime() - offsets[index] * 1000;
|
||||
const year = local.getUTCFullYear(), baseYear = new Date(base).getUTCFullYear();
|
||||
if (!Number.isFinite(base) || year < 1 || year > 9999 || baseYear < 1 || baseYear > 9999)
|
||||
return "sample_times 超出同期对比可表达的日期范围";
|
||||
const microseconds = /\.(\d+)/.exec(at)?.[1].slice(0, 6) ?? "0";
|
||||
if (local.toISOString().slice(11, labels[index].length === 8 ? 19 : 16) !== labels[index] ||
|
||||
Number(microseconds) !== 0 || labels[index].length === 5 && local.getUTCSeconds() !== 0)
|
||||
return "sample_times 换算为 UTC+8 后须与 x_data 对应时刻对齐";
|
||||
bases.add(base);
|
||||
}
|
||||
if (bases.size > 1) return "同一序列的 sample_times 必须按同一日期窗口对齐";
|
||||
if ((series.type ?? "line") === "line") for (const base of bases) current.add(base);
|
||||
else historical.push(...bases);
|
||||
}
|
||||
if (current.size !== 1) return "当前 line 必须包含真实采样时间,且所有当前曲线使用同一窗口";
|
||||
const start = [...current][0];
|
||||
return historical.some((base) => start <= base || (start - base) % 86400_000 !== 0)
|
||||
? "历史 scatter 必须来自当前窗口之前的对应日期" : null;
|
||||
}
|
||||
|
||||
/** Shared host/native schema factory. Native OpenCode supplies its own Zod instance. */
|
||||
export function createViewSchemas(zod: typeof z = z, publication = false) {
|
||||
export function createViewSchemas(zod: typeof z = z, publication = false, validateCharts = publication) {
|
||||
const z = zod;
|
||||
|
||||
const presentationIdentifierSchema = z.string().trim().min(1).max(128);
|
||||
@@ -39,7 +94,11 @@ export function createViewSchemas(zod: typeof z = z, publication = false) {
|
||||
(!series.sample_times || series.sample_times.length === value.x_data.length &&
|
||||
series.sample_times.every((time, index) => time === null || series.data[index] !== null))),
|
||||
"series must align with x_data"
|
||||
);
|
||||
).superRefine((value, ctx) => {
|
||||
if (!validateCharts) return;
|
||||
const message = chartPublicationError(value);
|
||||
if (message) ctx.addIssue({ code: "custom", message });
|
||||
});
|
||||
const position = z
|
||||
.tuple([z.number().finite().min(-180).max(180), z.number().finite().min(-90).max(90)])
|
||||
.rest(z.number().finite())
|
||||
@@ -213,8 +272,9 @@ export const {
|
||||
overlayDataSchema,
|
||||
variants: viewCommandVariants
|
||||
} = createViewSchemas();
|
||||
export const createViewCommandSchema = (options: { publication: boolean }) =>
|
||||
createViewSchemas(z, options.publication).command;
|
||||
export const safeChartPublicationInput = createViewSchemas(z, true).safeChartInput;
|
||||
export const createViewCommandSchema = (options: { publication: boolean; validateCharts?: boolean }) =>
|
||||
createViewSchemas(z, options.publication, options.validateCharts ?? options.publication).command;
|
||||
type Command = z.infer<ReturnType<typeof createViewCommandSchema>>;
|
||||
export type ViewCommand =
|
||||
| Exclude<Command, { action: "set_overlay" }>
|
||||
|
||||
Reference in New Issue
Block a user