feat(workbench): expand SCADA monitoring and investigation presentation
This commit is contained in:
@@ -1,9 +1,9 @@
|
||||
import { Activity, BarChart3, Database, History, LineChart, MonitorCog } from "lucide-react";
|
||||
import { AnimatePresence, MotionConfig, motion } from "motion/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { memo, type ReactNode } from "react";
|
||||
import { StatusIcon } from "@/shared/ui/status";
|
||||
import { normalizeSafeChartData, parseChartSpec, type SafeChartData, type SafeChartSeries, type SafeChartSpec } from "../chart-data";
|
||||
import { StandaloneInteractiveChart } from "../answer-ui/interactive-chart";
|
||||
import { normalizeSafeChartData, parseChartSpec } from "../chart-data";
|
||||
import type { AgentUiResult } from "../types";
|
||||
import type { UIEnvelope } from "../ui-envelope";
|
||||
import type { parseFrontendChart } from "../frontend-action/chart";
|
||||
@@ -71,11 +71,13 @@ function AgentUiResultCard({ result }: { result: AgentUiResult }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentChartView({ chart, embedded = false }: { chart: NonNullable<ReturnType<typeof parseFrontendChart>>; embedded?: boolean }) {
|
||||
return <SafeChartEnvelope embedded={embedded} envelope={{ kind: "chart", schemaVersion: "agent-ui@1", grammar: "echarts-safe-subset", surface: "side_panel", spec: { title: chart.title, chart_type: chart.chart_type }, data: { x_data: chart.x_data, series: chart.series } }} />;
|
||||
}
|
||||
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,
|
||||
...(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 }: { envelope: Extract<UIEnvelope, { kind: "chart" }>; embedded?: boolean }) {
|
||||
function SafeChartEnvelope({ envelope, embedded = false, expanded = false }: { envelope: Extract<UIEnvelope, { kind: "chart" }>; embedded?: boolean; expanded?: boolean }) {
|
||||
const spec = parseChartSpec(envelope.spec);
|
||||
const data = normalizeSafeChartData(envelope.data, 100);
|
||||
|
||||
@@ -83,173 +85,17 @@ function SafeChartEnvelope({ envelope, embedded = false }: { envelope: Extract<U
|
||||
return <FallbackText text={envelope.fallbackText ?? "图表数据未通过前端安全子集校验。"} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={embedded ? "min-w-0" : "agent-panel-nested rounded-xl p-3"}>
|
||||
<div className={cn("mb-3 flex gap-3", embedded ? "min-w-0 flex-col" : "items-start justify-between")}>
|
||||
{!embedded ? <div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-slate-950" title={spec.title}>
|
||||
{spec.title ?? "Agent 图表"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-[var(--ink-muted)]">
|
||||
{spec.chartType === "bar" ? "柱状图" : "折线图"}
|
||||
{spec.yAxisName ? ` · ${spec.yAxisName}` : ""}
|
||||
</p>
|
||||
</div> : null}
|
||||
<div className={cn("flex flex-wrap gap-1.5", embedded ? "min-w-0 justify-start" : "shrink-0 justify-end")}>
|
||||
{data.series.slice(0, 3).map((series, index) => (
|
||||
<span key={series.name} className={cn("flex items-center gap-1 text-xs", embedded ? "min-w-0 text-muted-foreground" : "text-slate-600")}>
|
||||
<span className={cn("h-2 w-2 shrink-0 rounded-full", chartColorClass(index))} />
|
||||
{embedded ? <span className="min-w-0 break-words [overflow-wrap:anywhere]">{series.name}</span> : series.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<SafeChartSvg spec={spec} data={data} />
|
||||
{spec.xAxisName ? <p className="mt-2 text-center text-xs text-[var(--ink-muted)]">{spec.xAxisName}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SafeChartSvg({ spec, data }: { spec: SafeChartSpec; data: SafeChartData }) {
|
||||
const width = 360;
|
||||
const height = 190;
|
||||
const padding = { top: 16, right: 18, bottom: 32, left: 38 };
|
||||
const plotWidth = width - padding.left - padding.right;
|
||||
const plotHeight = height - padding.top - padding.bottom;
|
||||
const values = data.series.flatMap((series) => series.data);
|
||||
const min = Math.min(0, ...values);
|
||||
const max = Math.max(...values);
|
||||
const range = max - min || 1;
|
||||
const xFor = (index: number) =>
|
||||
padding.left + (data.xData.length === 1 ? plotWidth / 2 : (index / (data.xData.length - 1)) * plotWidth);
|
||||
const yFor = (value: number) => padding.top + plotHeight - ((value - min) / range) * plotHeight;
|
||||
const ticks = createTicks(min, max);
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${width} ${height}`} role="img" aria-label={spec.title ?? "Agent 图表"} className="h-48 w-full">
|
||||
<rect x="0" y="0" width={width} height={height} rx="12" className="fill-white/70" />
|
||||
{ticks.map((tick) => {
|
||||
const y = yFor(tick);
|
||||
return (
|
||||
<g key={tick}>
|
||||
<line x1={padding.left} x2={width - padding.right} y1={y} y2={y} className="stroke-slate-200" />
|
||||
<text x={padding.left - 8} y={y + 4} textAnchor="end" className="fill-slate-400 text-xs">
|
||||
{formatChartNumber(tick)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
<line x1={padding.left} x2={padding.left} y1={padding.top} y2={height - padding.bottom} className="stroke-slate-300" />
|
||||
<line x1={padding.left} x2={width - padding.right} y1={height - padding.bottom} y2={height - padding.bottom} className="stroke-slate-300" />
|
||||
{data.xData.map((label, index) => (
|
||||
<text
|
||||
key={`${label}-${index}`}
|
||||
x={xFor(index)}
|
||||
y={height - 12}
|
||||
textAnchor="middle"
|
||||
className="fill-slate-500 text-xs"
|
||||
>
|
||||
{shortLabel(label)}
|
||||
</text>
|
||||
))}
|
||||
{data.series.slice(0, 4).map((series, seriesIndex) =>
|
||||
(series.type ?? spec.chartType) === "bar" ? (
|
||||
<BarSeries
|
||||
key={series.name}
|
||||
series={series}
|
||||
seriesIndex={seriesIndex}
|
||||
seriesCount={Math.min(data.series.length, 4)}
|
||||
xFor={xFor}
|
||||
yFor={yFor}
|
||||
baselineY={yFor(0)}
|
||||
/>
|
||||
) : (
|
||||
<LineSeries key={series.name} series={series} seriesIndex={seriesIndex} xFor={xFor} yFor={yFor} />
|
||||
)
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function LineSeries({
|
||||
series,
|
||||
seriesIndex,
|
||||
xFor,
|
||||
yFor
|
||||
}: {
|
||||
series: SafeChartSeries;
|
||||
seriesIndex: number;
|
||||
xFor: (index: number) => number;
|
||||
yFor: (value: number) => number;
|
||||
}) {
|
||||
const points = series.data.map((value, index) => `${xFor(index)},${yFor(value)}`).join(" ");
|
||||
const stroke = chartStroke(seriesIndex);
|
||||
|
||||
return (
|
||||
<g>
|
||||
<polyline
|
||||
points={points}
|
||||
fill="none"
|
||||
stroke={stroke}
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="agent-chart-line"
|
||||
/>
|
||||
{series.data.map((value, index) => (
|
||||
<circle
|
||||
key={`${series.name}-${index}`}
|
||||
cx={xFor(index)}
|
||||
cy={yFor(value)}
|
||||
r="2.8"
|
||||
fill={stroke}
|
||||
className="agent-chart-point"
|
||||
style={{ animationDelay: `${80 + index * 18}ms` }}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function BarSeries({
|
||||
series,
|
||||
seriesIndex,
|
||||
seriesCount,
|
||||
xFor,
|
||||
yFor,
|
||||
baselineY
|
||||
}: {
|
||||
series: SafeChartSeries;
|
||||
seriesIndex: number;
|
||||
seriesCount: number;
|
||||
xFor: (index: number) => number;
|
||||
yFor: (value: number) => number;
|
||||
baselineY: number;
|
||||
}) {
|
||||
const fill = chartStroke(seriesIndex);
|
||||
const barWidth = Math.max(5, 22 / seriesCount);
|
||||
const offset = (seriesIndex - (seriesCount - 1) / 2) * (barWidth + 2);
|
||||
|
||||
return (
|
||||
<g>
|
||||
{series.data.map((value, index) => {
|
||||
const y = yFor(value);
|
||||
return (
|
||||
<rect
|
||||
key={`${series.name}-${index}`}
|
||||
x={xFor(index) - barWidth / 2 + offset}
|
||||
y={Math.min(y, baselineY)}
|
||||
width={barWidth}
|
||||
height={Math.max(1, Math.abs(baselineY - y))}
|
||||
rx="2"
|
||||
fill={fill}
|
||||
className="agent-chart-bar"
|
||||
style={{ animationDelay: `${seriesIndex * 28 + index * 16}ms` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
);
|
||||
return <StandaloneInteractiveChart
|
||||
title={spec.title ?? "Agent 图表"}
|
||||
chart_type={spec.chartType}
|
||||
x_data={data.xData}
|
||||
series={data.series.slice(0, 8)}
|
||||
source="来源见关联依据。"
|
||||
embedded={embedded}
|
||||
expanded={expanded}
|
||||
xAxisName={spec.xAxisName}
|
||||
yAxisName={spec.yAxisName}
|
||||
/>;
|
||||
}
|
||||
|
||||
function RegisteredComponentEnvelope({
|
||||
@@ -377,35 +223,6 @@ function getSurfaceLabel(surface: UIEnvelope["surface"]) {
|
||||
return "地图叠加";
|
||||
}
|
||||
|
||||
function createTicks(min: number, max: number) {
|
||||
const range = max - min || 1;
|
||||
return [max, min + range * 0.5, min];
|
||||
}
|
||||
|
||||
function chartStroke(index: number) {
|
||||
return ["#2563eb", "#0aa6a6", "#ff7a45", "#0477bf"][index % 4] ?? "#2563eb";
|
||||
}
|
||||
|
||||
function chartColorClass(index: number) {
|
||||
return ["bg-[var(--action-blue)]", "bg-teal-600", "bg-orange-500", "bg-sky-700"][index % 4] ?? "bg-[var(--action-blue)]";
|
||||
}
|
||||
|
||||
function formatChartNumber(value: number) {
|
||||
if (Math.abs(value) >= 100) {
|
||||
return value.toFixed(0);
|
||||
}
|
||||
|
||||
if (Math.abs(value) >= 10) {
|
||||
return value.toFixed(1);
|
||||
}
|
||||
|
||||
return value.toFixed(2);
|
||||
}
|
||||
|
||||
function shortLabel(value: string) {
|
||||
return value.length > 8 ? `${value.slice(0, 7)}...` : value;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user