Files
SystemSimulationApp/frontend/src/SimulationResultsView.tsx
T

5997 lines
185 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useId, useMemo, useRef, useState } from "react";
import type {
ChangeEvent as ReactChangeEvent,
DragEvent,
KeyboardEvent as ReactKeyboardEvent,
PointerEvent as ReactPointerEvent,
WheelEvent as ReactWheelEvent,
} from "react";
import {
ChartNoAxesCombined,
ChartSpline,
Crosshair,
FileDown,
FolderOpen,
ImageDown,
ListChecks,
Maximize2,
Move,
Scaling,
Sheet,
Trash2,
X,
ZoomIn,
} from "lucide-react";
import {
ConnectionLineType,
ConnectionMode,
ReactFlow,
useReactFlow,
type Edge,
type Node,
type NodeTypes,
type Position,
type Viewport,
} from "@xyflow/react";
import {
CanvasGridBackground,
type CanvasGridVisibility,
} from "./CanvasGridBackground";
import { hasComponentSymbol } from "./ComponentSymbol";
import { WorkspaceViewTabs, type WorkspaceView } from "./WorkspaceViewTabs";
import { AutoFitView } from "./AutoFitView";
import {
CONTACT_AWARE_EDGE_TYPE,
contactAwareEdgeTypes,
} from "./ContactAwareEdge";
import {
detectEdgeCrossingJumps,
edgePointIsFinite,
orthogonalEdgePoints,
type EdgeRouteData,
} from "./edgeRouting";
import {
continuousMechanicalForceChartValues,
resultStateTransitionSampleIndices,
segmentedChartPath,
} from "./resultEventSeries";
type ResultPortDefinition = {
name: string;
kind: string;
domain: string;
nominalRole: string;
positiveFlowDirection?: string;
side: "left" | "right";
};
type ResultNodeData = {
label: string;
componentType: string;
modelType: string;
symbol?: string;
ports: ResultPortDefinition[];
parameters: Record<string, number | string>;
parameterUnits?: Record<string, string>;
rotation: 0 | 90 | 180 | 270;
mirrored: boolean;
};
type ResultRenderedNodeData = ResultNodeData & {
portConnectionStates: Record<string, "idle" | "connected">;
};
type ResultProjectNode = {
id: string;
type: "simulationComponent";
position: {
x: number;
y: number;
};
data: ResultNodeData;
};
type ResultProjectEdge = {
id: string;
source: string;
target: string;
sourceHandle: string;
targetHandle: string;
data: EdgeRouteData & {
isContactEdge: boolean;
};
};
type ResultPortFlowPlacement = {
flowX: number;
flowY: number;
position: Position;
};
type ResultSimulationConfig = {
t_start: number;
t_stop: number;
step: number;
max_step: number;
method: string;
};
export type ResultVariableMetadata = {
key: string;
componentId: string;
componentType: string;
scope: "component" | "port";
portName: string | null;
name: string;
label: string;
quantity: string;
unit: string;
category: string;
order: number;
};
type ResultPayload = {
success: boolean;
status?: "completed" | "stopped" | "stalled" | "failed";
partial?: boolean;
message: string;
simulatedUntil?: number;
requestedStopTime?: number;
variables: ResultVariableMetadata[];
final: Record<string, number>;
series: Record<string, number[]>;
diagnostics: {
integration?: {
segments?: Array<{
stateTransitionTimes?: number[];
}>;
};
pressureFlow?: {
solveCount: number;
maxScaledResidual: number;
maxEvaluationsPerSolve: number;
};
stream?: {
maxIterationsPerSolve: number;
};
stateCount: number;
sampleCount: number;
};
};
export type SimulationResultsSnapshot = {
id: string;
createdAt: string;
project: {
projectSchemaVersion: 1;
name: string;
nodes: ResultProjectNode[];
edges: ResultProjectEdge[];
simulation: ResultSimulationConfig;
};
result: ResultPayload;
};
type SimulationResultFile = {
fileType: "system-simulation-result";
formatVersion: 1;
exportedAt: string;
metadata: {
projectName: string;
resultCreatedAt: string;
simulation: ResultSimulationConfig & {
simulatedUntil: number | null;
};
resultStatus: NonNullable<ResultPayload["status"]>;
componentCount: number;
connectionCount: number;
sampleCount: number;
componentParameters: Array<{
id: string;
name: string;
componentType: string;
parameters: Record<string, number | string>;
parameterUnits: Record<string, string>;
}>;
};
snapshot: SimulationResultsSnapshot;
};
type SimulationResultsViewProps = {
snapshot: SimulationResultsSnapshot;
gridVisibility: CanvasGridVisibility;
nodeTypes: NodeTypes;
onViewChange: (view: WorkspaceView) => void;
onSnapshotLoad: (snapshot: SimulationResultsSnapshot) => void;
resolvePortPlacement: (
node: ResultProjectNode,
portName: string | null,
) => ResultPortFlowPlacement | undefined;
};
type EmptySimulationResultsViewProps = {
onViewChange: (view: WorkspaceView) => void;
onSnapshotLoad: (snapshot: SimulationResultsSnapshot) => void;
};
type ChartWindow = {
id: string;
kind: "single" | "multi" | "mixed";
variableKey: string;
variableKeys: string[];
hiddenVariableKeys: string[];
variableUnits: Record<string, string>;
x: number;
y: number;
width: number;
height: number;
unit: string;
cursorEnabled: boolean;
zoomEnabled: boolean;
viewport: ChartViewport;
zoomHistory: ChartViewport[];
zIndex: number;
};
type ChartViewportRange = {
start: number;
end: number;
};
type ChartViewport = {
x: ChartViewportRange;
y: ChartViewportRange;
stackedY: Record<string, ChartViewportRange>;
};
type ResultUnitOption = {
value: string;
label: string;
fromBase: (value: number) => number;
};
type WindowDragState = {
windowId: string;
pointerId: number;
offsetX: number;
offsetY: number;
width: number;
height: number;
};
type WindowResizeState = {
windowId: string;
pointerId: number;
startX: number;
startY: number;
startWidth: number;
startHeight: number;
windowX: number;
windowY: number;
};
type ResultsPaneLayout = {
inspectorWidth: number;
systemHeight: number;
};
type PaneResizeKind = "inspector-width" | "system-height";
type PaneResizeState = {
kind: PaneResizeKind;
pointerId: number;
startX: number;
startY: number;
startSize: number;
};
const RESULT_VARIABLE_MIME = "application/x-system-simulation-variable";
const RESULT_FILE_TYPE = "system-simulation-result";
const RESULT_FILE_FORMAT_VERSION = 1;
const RESULT_FILE_MIME = "application/vnd.system-simulation.result+json";
const MAX_RESULT_FILE_SIZE = 250 * 1024 * 1024;
const RESULT_LAYOUT_KEY_PREFIX = "system-simulation-flow:result-layout:";
const RESULT_PANE_LAYOUT_KEY = "system-simulation-flow:result-pane-layout";
const RESULT_SYSTEM_VIEWPORT_KEY_PREFIX =
"system-simulation-flow:result-system-viewport:";
const CHART_WINDOW_WIDTH = 420;
const CHART_WINDOW_HEIGHT = 260;
const MULTI_CHART_WINDOW_WIDTH = 560;
const MULTI_CHART_WINDOW_HEIGHT = 340;
const MIN_CHART_WINDOW_WIDTH = 360;
const MIN_CHART_WINDOW_HEIGHT = 240;
const PANE_SPLITTER_SIZE = 7;
const MIN_INSPECTOR_WIDTH = 320;
const MIN_CHART_WIDTH = 440;
const MIN_SYSTEM_HEIGHT = 180;
const MIN_VARIABLE_HEIGHT = 180;
const PANE_KEYBOARD_STEP = 20;
const MAX_CHART_ZOOM_HISTORY = 24;
const CHART_ZOOM_DRAG_THRESHOLD = 6;
const MIN_CHART_VIEWPORT_SPAN = 1e-6;
const MAX_CHART_VIEWPORT_SPAN = 1e6;
const CHART_COLORS = [
"#1d6fb8",
"#c2413b",
"#2f7d55",
"#b36b16",
"#6b5ca5",
"#087f8c",
"#9a4f76",
];
const PRESSURE_RESULT_UNITS = [
scaledResultUnit("Pa", "Pa", 1),
scaledResultUnit("kPa", "kPa", 1e3),
scaledResultUnit("MPa", "MPa", 1e6),
scaledResultUnit("bar", "bar", 1e5),
];
const SPECIFIC_ENERGY_RESULT_UNITS = [
scaledResultUnit("J/kg", "J/kg", 1),
scaledResultUnit("kJ/kg", "kJ/kg", 1e3),
scaledResultUnit("MJ/kg", "MJ/kg", 1e6),
];
const RESULT_UNIT_OPTIONS: Record<string, ResultUnitOption[]> = {
area: [
scaledResultUnit("m2", "m²", 1),
scaledResultUnit("cm2", "cm²", 1e-4),
scaledResultUnit("mm2", "mm²", 1e-6),
],
heat_transfer_coefficient: [scaledResultUnit("W/(m2*K)", "W/(m²·K)", 1)],
pressure: PRESSURE_RESULT_UNITS,
absolute_pressure: PRESSURE_RESULT_UNITS,
temperature: [
scaledResultUnit("K", "K", 1),
{ value: "degC", label: "°C", fromBase: (value) => value - 273.15 },
],
mass: [
scaledResultUnit("kg", "kg", 1),
scaledResultUnit("g", "g", 1e-3),
scaledResultUnit("t", "t", 1e3),
],
internal_energy: [
scaledResultUnit("J", "J", 1),
scaledResultUnit("kJ", "kJ", 1e3),
scaledResultUnit("MJ", "MJ", 1e6),
],
energy: [
scaledResultUnit("J", "J", 1),
scaledResultUnit("kJ", "kJ", 1e3),
scaledResultUnit("MJ", "MJ", 1e6),
],
specific_internal_energy: SPECIFIC_ENERGY_RESULT_UNITS,
specific_enthalpy: SPECIFIC_ENERGY_RESULT_UNITS,
density: [
scaledResultUnit("kg/m³", "kg/m³", 1),
scaledResultUnit("g/cm³", "g/cm³", 1e3),
],
mass_flow: [
scaledResultUnit("kg/s", "kg/s", 1),
scaledResultUnit("g/s", "g/s", 1e-3),
scaledResultUnit("kg/min", "kg/min", 1 / 60),
scaledResultUnit("kg/h", "kg/h", 1 / 3600),
],
volume: [
scaledResultUnit("m3", "m³", 1),
scaledResultUnit("L", "L", 1e-3),
scaledResultUnit("mL", "mL", 1e-6),
],
length: [
scaledResultUnit("m", "m", 1),
scaledResultUnit("cm", "cm", 1e-2),
scaledResultUnit("mm", "mm", 1e-3),
],
velocity: [scaledResultUnit("m/s", "m/s", 1)],
};
export function SimulationResultsView({
snapshot,
gridVisibility,
nodeTypes,
onViewChange,
onSnapshotLoad,
resolvePortPlacement,
}: SimulationResultsViewProps) {
const reactFlow = useReactFlow();
const resultStatus = simulationResultStatus(snapshot.result);
const stateTransitionSampleIndices = useMemo(
() =>
resultStateTransitionSampleIndices(
snapshot.result.series.time ?? [],
snapshot.result.diagnostics,
),
[snapshot.result.diagnostics, snapshot.result.series.time],
);
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(
snapshot.project.nodes[0]?.id ?? null,
);
const [chartWindows, setChartWindows] = useState<ChartWindow[]>(() =>
loadChartWindows(snapshot.id),
);
const [paneLayout, setPaneLayout] = useState<ResultsPaneLayout>(
loadResultsPaneLayout,
);
const [activePaneResize, setActivePaneResize] =
useState<PaneResizeKind | null>(null);
const [csvDownloadPending, setCsvDownloadPending] = useState(false);
const [activeMultiPickerWindowId, setActiveMultiPickerWindowId] = useState<
string | null
>(null);
const [multiDropTargetWindowId, setMultiDropTargetWindowId] = useState<
string | null
>(null);
const workbenchRef = useRef<HTMLElement>(null);
const workspaceRef = useRef<HTMLDivElement>(null);
const dragStateRef = useRef<WindowDragState | null>(null);
const resizeStateRef = useRef<WindowResizeState | null>(null);
const paneResizeStateRef = useRef<PaneResizeState | null>(null);
const nextWindowIdRef = useRef(nextChartWindowNumber(chartWindows));
const topZIndexRef = useRef(
Math.max(1, ...chartWindows.map((window) => window.zIndex)),
);
const initialSystemViewportRef = useRef<Viewport | null>(
loadResultSystemViewport(snapshot.id),
);
useEffect(() => {
storeChartWindows(snapshot.id, chartWindows);
}, [chartWindows, snapshot.id]);
useEffect(() => {
storeResultsPaneLayout(paneLayout);
}, [paneLayout]);
useEffect(() => {
const clampLayoutToWorkbench = () => {
const bounds = workbenchRef.current?.getBoundingClientRect();
if (!bounds) {
return;
}
setPaneLayout((current) => {
const next = clampResultsPaneLayout(
current,
bounds.width,
bounds.height,
);
return sameResultsPaneLayout(current, next) ? current : next;
});
};
clampLayoutToWorkbench();
window.addEventListener("resize", clampLayoutToWorkbench);
const workbench = workbenchRef.current;
const observer =
workbench && typeof ResizeObserver !== "undefined"
? new ResizeObserver(clampLayoutToWorkbench)
: null;
if (workbench) {
observer?.observe(workbench);
}
return () => {
window.removeEventListener("resize", clampLayoutToWorkbench);
observer?.disconnect();
};
}, []);
useEffect(() => {
const workspace = workspaceRef.current;
if (!workspace || typeof ResizeObserver === "undefined") {
return;
}
const observer = new ResizeObserver(([entry]) => {
if (!entry) {
return;
}
const { width, height } = entry.contentRect;
setChartWindows((current) => {
let changed = false;
const next = current.map((chartWindow) => {
const size = clampChartWindowDimensions(
chartWindow.width,
chartWindow.height,
width,
height,
);
const x = clampWindowCoordinate(chartWindow.x, width, size.width);
const y = clampWindowCoordinate(chartWindow.y, height, size.height);
const windowChanged =
x !== chartWindow.x ||
y !== chartWindow.y ||
size.width !== chartWindow.width ||
size.height !== chartWindow.height;
changed ||= windowChanged;
return windowChanged
? { ...chartWindow, ...size, x, y }
: chartWindow;
});
return changed ? next : current;
});
});
observer.observe(workspace);
return () => observer.disconnect();
}, []);
const selectedNode =
snapshot.project.nodes.find((node) => node.id === selectedNodeId) ?? null;
const connectedPortKeys = useMemo(() => {
const keys = new Set<string>();
snapshot.project.edges.forEach((edge) => {
if (edge.sourceHandle) {
keys.add(`${edge.source}:${edge.sourceHandle}`);
}
if (edge.targetHandle) {
keys.add(`${edge.target}:${edge.targetHandle}`);
}
});
return keys;
}, [snapshot.project.edges]);
const readOnlyNodes = useMemo<
Node<ResultRenderedNodeData, "simulationComponent">[]
>(
() =>
snapshot.project.nodes.map((node) => ({
...node,
className: [
"results-readonly-node",
hasComponentSymbol(node.data.symbol ?? node.data.componentType) ||
hasComponentSymbol(node.data.modelType)
? "component-icon-node"
: "",
]
.filter(Boolean)
.join(" "),
connectable: false,
draggable: false,
focusable: true,
selectable: true,
selected: node.id === selectedNodeId,
data: {
...node.data,
portConnectionStates: Object.fromEntries(
node.data.ports.map((port) => [
port.name,
connectedPortKeys.has(`${node.id}:${port.name}`)
? "connected"
: "idle",
]),
),
},
})),
[connectedPortKeys, selectedNodeId, snapshot.project.nodes],
);
const resultCrossingJumpsByEdgeId = useMemo(() => {
const nodesById = new Map(
snapshot.project.nodes.map((node) => [node.id, node] as const),
);
return detectEdgeCrossingJumps(
snapshot.project.edges.flatMap((edge) => {
if (edge.data.isContactEdge) {
return [];
}
const sourceNode = nodesById.get(edge.source);
const targetNode = nodesById.get(edge.target);
const source = sourceNode
? resolvePortPlacement(sourceNode, edge.sourceHandle)
: undefined;
const target = targetNode
? resolvePortPlacement(targetNode, edge.targetHandle)
: undefined;
if (!source || !target) {
return [];
}
return [
{
id: edge.id,
nodeIds: [edge.source, edge.target] as const,
points: orthogonalEdgePoints({
routePoints: edge.data.routePoints,
sourcePosition: source.position,
sourceX: source.flowX,
sourceY: source.flowY,
targetPosition: target.position,
targetX: target.flowX,
targetY: target.flowY,
}),
},
];
}),
);
}, [resolvePortPlacement, snapshot.project.edges, snapshot.project.nodes]);
const readOnlyEdges = useMemo<Edge[]>(
() =>
snapshot.project.edges.map((edge) => ({
...edge,
data: {
...edge.data,
crossingJumps: resultCrossingJumpsByEdgeId.get(edge.id),
},
focusable: false,
selectable: false,
type: CONTACT_AWARE_EDGE_TYPE,
})),
[resultCrossingJumpsByEdgeId, snapshot.project.edges],
);
const selectedVariables = useMemo(
() =>
selectedNode
? resultVariablesForNode(
snapshot.result.variables,
snapshot.result.series,
selectedNode.id,
)
: [],
[selectedNode, snapshot.result.series, snapshot.result.variables],
);
const componentVariables = selectedVariables.filter(
(variable) => variable.scope === "component",
);
const portVariables = selectedVariables.filter(
(variable) => variable.scope === "port",
);
const plottableResultVariables = useMemo(
() =>
snapshot.result.variables
.filter(
(variable) => (snapshot.result.series[variable.key]?.length ?? 0) > 0,
)
.sort((first, second) => first.key.localeCompare(second.key, "zh-CN")),
[snapshot.result.series, snapshot.result.variables],
);
const setPaneSize = (kind: PaneResizeKind, requestedSize: number) => {
const bounds = workbenchRef.current?.getBoundingClientRect();
if (!bounds) {
return;
}
setPaneLayout((current) =>
clampResultsPaneLayout(
{
...current,
...(kind === "inspector-width"
? { inspectorWidth: requestedSize }
: { systemHeight: requestedSize }),
},
bounds.width,
bounds.height,
),
);
};
const startPaneResize = (
event: ReactPointerEvent<HTMLDivElement>,
kind: PaneResizeKind,
) => {
event.preventDefault();
paneResizeStateRef.current = {
kind,
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
startSize:
kind === "inspector-width"
? paneLayout.inspectorWidth
: paneLayout.systemHeight,
};
event.currentTarget.setPointerCapture(event.pointerId);
setActivePaneResize(kind);
};
const movePaneResize = (event: ReactPointerEvent<HTMLDivElement>) => {
const resize = paneResizeStateRef.current;
if (!resize || resize.pointerId !== event.pointerId) {
return;
}
const delta =
resize.kind === "inspector-width"
? event.clientX - resize.startX
: event.clientY - resize.startY;
setPaneSize(resize.kind, resize.startSize + delta);
};
const stopPaneResize = (event: ReactPointerEvent<HTMLDivElement>) => {
if (paneResizeStateRef.current?.pointerId !== event.pointerId) {
return;
}
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
paneResizeStateRef.current = null;
setActivePaneResize(null);
};
const resizePaneWithKeyboard = (
event: ReactKeyboardEvent<HTMLDivElement>,
kind: PaneResizeKind,
) => {
const decreaseKey = kind === "inspector-width" ? "ArrowLeft" : "ArrowUp";
const increaseKey = kind === "inspector-width" ? "ArrowRight" : "ArrowDown";
if (event.key !== decreaseKey && event.key !== increaseKey) {
return;
}
event.preventDefault();
const currentSize =
kind === "inspector-width"
? paneLayout.inspectorWidth
: paneLayout.systemHeight;
setPaneSize(
kind,
currentSize +
(event.key === increaseKey ? PANE_KEYBOARD_STEP : -PANE_KEYBOARD_STEP),
);
};
const resetPaneSize = (kind: PaneResizeKind) => {
const defaults = defaultResultsPaneLayout();
setPaneSize(
kind,
kind === "inspector-width"
? defaults.inspectorWidth
: defaults.systemHeight,
);
};
const focusChartWindow = (windowId: string) => {
const zIndex = ++topZIndexRef.current;
setChartWindows((current) =>
current.map((window) =>
window.id === windowId ? { ...window, zIndex } : window,
),
);
};
const addChartWindow = (
variableKey: string,
requestedPosition?: { x: number; y: number },
) => {
const workspace = workspaceRef.current;
if (!workspace) {
return;
}
const existing = chartWindows.find(
(window) =>
window.kind === "single" && window.variableKey === variableKey,
);
if (existing) {
focusChartWindow(existing.id);
return;
}
const bounds = workspace.getBoundingClientRect();
const size = clampChartWindowDimensions(
CHART_WINDOW_WIDTH,
CHART_WINDOW_HEIGHT,
bounds.width,
bounds.height,
);
const variable = snapshot.result.variables.find(
(item) => item.key === variableKey,
);
const index = chartWindows.length;
const fallback = {
x: 18 + (index % 3) * 26,
y: 18 + (index % 4) * 26,
};
const position = requestedPosition ?? fallback;
const zIndex = ++topZIndexRef.current;
setChartWindows((current) => [
...current,
{
id: `chart-${nextWindowIdRef.current++}`,
kind: "single",
variableKey,
variableKeys: [variableKey],
hiddenVariableKeys: [],
variableUnits: { [variableKey]: variable?.unit ?? "" },
x: clampWindowCoordinate(position.x, bounds.width, size.width),
y: clampWindowCoordinate(position.y, bounds.height, size.height),
width: size.width,
height: size.height,
unit: variable?.unit ?? "",
cursorEnabled: false,
zoomEnabled: false,
viewport: defaultChartViewport(),
zoomHistory: [],
zIndex,
},
]);
};
const addCombinedChartWindow = (
kind: "multi" | "mixed",
requestedPosition?: { x: number; y: number },
) => {
const workspace = workspaceRef.current;
if (!workspace) {
return;
}
const bounds = workspace.getBoundingClientRect();
const size = clampChartWindowDimensions(
MULTI_CHART_WINDOW_WIDTH,
MULTI_CHART_WINDOW_HEIGHT,
bounds.width,
bounds.height,
);
const index = chartWindows.length;
const position = requestedPosition ?? {
x: (kind === "mixed" ? 38 : 28) + (index % 3) * 28,
y: (kind === "mixed" ? 38 : 28) + (index % 4) * 28,
};
const id = `chart-${nextWindowIdRef.current++}`;
const zIndex = ++topZIndexRef.current;
setChartWindows((current) => [
...current,
{
id,
kind,
variableKey: "",
variableKeys: [],
hiddenVariableKeys: [],
variableUnits: {},
x: clampWindowCoordinate(position.x, bounds.width, size.width),
y: clampWindowCoordinate(position.y, bounds.height, size.height),
width: size.width,
height: size.height,
unit: "",
cursorEnabled: false,
zoomEnabled: false,
viewport: defaultChartViewport(),
zoomHistory: [],
zIndex,
},
]);
setActiveMultiPickerWindowId(null);
};
const toggleChartCurveVisibility = (
windowId: string,
variableKey: string,
) => {
setChartWindows((current) =>
current.map((window) => {
if (window.id !== windowId || window.kind === "single") {
return window;
}
const hiddenVariableKeys = window.hiddenVariableKeys.includes(
variableKey,
)
? window.hiddenVariableKeys.filter((key) => key !== variableKey)
: [...window.hiddenVariableKeys, variableKey];
return {
...window,
cursorEnabled:
hiddenVariableKeys.length >= window.variableKeys.length
? false
: window.cursorEnabled,
hiddenVariableKeys,
};
}),
);
};
const removeChartCurve = (windowId: string, variableKey: string) => {
const resetsWindow = chartWindows.some(
(window) =>
window.id === windowId &&
window.kind !== "single" &&
window.variableKeys.length === 1,
);
setChartWindows((current) =>
current.map((window) => {
if (window.id !== windowId || window.kind === "single") {
return window;
}
if (window.variableKeys.length === 1) {
return {
...window,
variableKey: "",
variableKeys: [],
hiddenVariableKeys: [],
variableUnits: {},
unit: "",
cursorEnabled: false,
zoomEnabled: false,
viewport: defaultChartViewport(),
zoomHistory: [],
};
}
const variableKeys = window.variableKeys.filter(
(key) => key !== variableKey,
);
return {
...window,
variableKey: variableKeys[0],
variableKeys,
hiddenVariableKeys: window.hiddenVariableKeys.filter(
(key) => key !== variableKey,
),
variableUnits: Object.fromEntries(
Object.entries(window.variableUnits).filter(
([key]) => key !== variableKey,
),
),
viewport: chartViewportWithoutStackedY(window.viewport, variableKey),
zoomHistory: window.zoomHistory.map((viewport) =>
chartViewportWithoutStackedY(viewport, variableKey),
),
};
}),
);
if (resetsWindow) {
setActiveMultiPickerWindowId(null);
}
};
const addDroppedVariableToMultiChart = (
event: DragEvent<HTMLElement>,
chartWindow: ChartWindow,
) => {
event.preventDefault();
event.stopPropagation();
setMultiDropTargetWindowId(null);
const variableKey = event.dataTransfer.getData(RESULT_VARIABLE_MIME);
const variable = snapshot.result.variables.find(
(item) => item.key === variableKey,
);
const primaryVariable =
snapshot.result.variables.find(
(item) => item.key === chartWindow.variableKey,
) ??
chartWindow.variableKeys
.map((key) =>
snapshot.result.variables.find((item) => item.key === key),
)
.find((item): item is ResultVariableMetadata => item !== undefined);
if (!variableKey || !variable || chartWindow.kind === "single") {
return;
}
if (
chartWindow.kind === "multi" &&
primaryVariable &&
variable.unit !== primaryVariable.unit
) {
window.alert(
`无法加入曲线:该窗口只接受 SI 单位为 ${primaryVariable.unit || "无量纲"} 的变量。`,
);
return;
}
setChartWindows((current) =>
current.map((window) => {
if (window.id !== chartWindow.id || window.kind === "single") {
return window;
}
if (window.variableKeys.length === 0 || !primaryVariable) {
return {
...window,
variableKey,
variableKeys: [variableKey],
hiddenVariableKeys: [],
variableUnits: { [variableKey]: variable.unit },
unit: variable.unit,
viewport: defaultChartViewport(),
zoomHistory: [],
};
}
if (window.variableKeys.includes(variableKey)) {
return {
...window,
hiddenVariableKeys: window.hiddenVariableKeys.filter(
(key) => key !== variableKey,
),
};
}
return {
...window,
variableKeys: [...window.variableKeys, variableKey],
hiddenVariableKeys: window.hiddenVariableKeys.filter(
(key) => key !== variableKey,
),
variableUnits: {
...window.variableUnits,
[variableKey]: variable.unit,
},
};
}),
);
};
const handleVariableDragStart = (
event: DragEvent<HTMLButtonElement>,
variableKey: string,
) => {
event.dataTransfer.effectAllowed = "copy";
event.dataTransfer.setData(RESULT_VARIABLE_MIME, variableKey);
};
const handleWorkspaceDrop = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
const variableKey = event.dataTransfer.getData(RESULT_VARIABLE_MIME);
const workspace = workspaceRef.current;
if (!variableKey || !workspace) {
return;
}
const bounds = workspace.getBoundingClientRect();
addChartWindow(variableKey, {
x: event.clientX - bounds.left - CHART_WINDOW_WIDTH / 2,
y: event.clientY - bounds.top - 34,
});
};
const startWindowDrag = (
event: ReactPointerEvent<HTMLDivElement>,
chartWindow: ChartWindow,
) => {
const windowElement = event.currentTarget.closest(".result-chart-window");
if (!(windowElement instanceof HTMLElement)) {
return;
}
const bounds = windowElement.getBoundingClientRect();
dragStateRef.current = {
windowId: chartWindow.id,
pointerId: event.pointerId,
offsetX: event.clientX - bounds.left,
offsetY: event.clientY - bounds.top,
width: chartWindow.width,
height: chartWindow.height,
};
event.currentTarget.setPointerCapture(event.pointerId);
focusChartWindow(chartWindow.id);
};
const moveChartWindow = (event: ReactPointerEvent<HTMLDivElement>) => {
const dragState = dragStateRef.current;
const workspace = workspaceRef.current;
if (!dragState || dragState.pointerId !== event.pointerId || !workspace) {
return;
}
const bounds = workspace.getBoundingClientRect();
const x = clampWindowCoordinate(
event.clientX - bounds.left - dragState.offsetX,
bounds.width,
dragState.width,
);
const y = clampWindowCoordinate(
event.clientY - bounds.top - dragState.offsetY,
bounds.height,
dragState.height,
);
setChartWindows((current) =>
current.map((window) =>
window.id === dragState.windowId ? { ...window, x, y } : window,
),
);
};
const stopWindowDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
if (dragStateRef.current?.pointerId !== event.pointerId) {
return;
}
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
dragStateRef.current = null;
};
const startWindowResize = (
event: ReactPointerEvent<HTMLButtonElement>,
chartWindow: ChartWindow,
) => {
event.preventDefault();
event.stopPropagation();
resizeStateRef.current = {
windowId: chartWindow.id,
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
startWidth: chartWindow.width,
startHeight: chartWindow.height,
windowX: chartWindow.x,
windowY: chartWindow.y,
};
event.currentTarget.setPointerCapture(event.pointerId);
focusChartWindow(chartWindow.id);
};
const resizeChartWindow = (event: ReactPointerEvent<HTMLButtonElement>) => {
const resize = resizeStateRef.current;
const workspace = workspaceRef.current;
if (!resize || resize.pointerId !== event.pointerId || !workspace) {
return;
}
event.preventDefault();
const bounds = workspace.getBoundingClientRect();
const width = clampChartWindowDimension(
resize.startWidth + event.clientX - resize.startX,
bounds.width - resize.windowX - 12,
MIN_CHART_WINDOW_WIDTH,
);
const height = clampChartWindowDimension(
resize.startHeight + event.clientY - resize.startY,
bounds.height - resize.windowY - 12,
MIN_CHART_WINDOW_HEIGHT,
);
setChartWindows((current) =>
current.map((window) =>
window.id === resize.windowId ? { ...window, width, height } : window,
),
);
};
const stopWindowResize = (event: ReactPointerEvent<HTMLButtonElement>) => {
if (resizeStateRef.current?.pointerId !== event.pointerId) {
return;
}
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
resizeStateRef.current = null;
};
const removeChartWindow = (windowId: string) => {
setChartWindows((current) =>
current.filter((window) => window.id !== windowId),
);
setActiveMultiPickerWindowId((current) =>
current === windowId ? null : current,
);
};
const updateChartWindowUnit = (windowId: string, unit: string) => {
setChartWindows((current) =>
current.map((window) =>
window.id === windowId ? { ...window, unit } : window,
),
);
};
const updateChartCurveUnit = (
windowId: string,
variableKey: string,
unit: string,
) => {
setChartWindows((current) =>
current.map((window) =>
window.id === windowId
? {
...window,
variableUnits: {
...window.variableUnits,
[variableKey]: unit,
},
}
: window,
),
);
};
const toggleChartWindowCursor = (windowId: string) => {
setChartWindows((current) =>
current.map((window) =>
window.id === windowId
? {
...window,
cursorEnabled: !window.cursorEnabled,
zoomEnabled: false,
}
: window,
),
);
};
const toggleChartWindowZoom = (windowId: string) => {
setChartWindows((current) =>
current.map((window) =>
window.id === windowId
? {
...window,
cursorEnabled: false,
zoomEnabled: !window.zoomEnabled,
}
: window,
),
);
};
const updateChartWindowViewport = (
windowId: string,
requestedViewport: ChartViewport,
) => {
const viewport = normalizeChartViewport(requestedViewport);
setChartWindows((current) =>
current.map((window) => {
if (
window.id !== windowId ||
sameChartViewport(window.viewport, viewport)
) {
return window;
}
return {
...window,
cursorEnabled: false,
viewport,
zoomHistory: [],
};
}),
);
};
const undoChartWindowZoom = (windowId: string) => {
setChartWindows((current) =>
current.map((window) => {
if (window.id !== windowId || isDefaultChartViewport(window.viewport)) {
return window;
}
return {
...window,
viewport: defaultChartViewport(),
zoomHistory: [],
};
}),
);
};
const downloadResultsCsv = async () => {
if (csvDownloadPending) {
return;
}
setCsvDownloadPending(true);
try {
const response = await fetch("/api/simulation-results/csv", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
projectName: snapshot.project.name,
variables: snapshot.result.variables,
series: snapshot.result.series,
}),
});
if (!response.ok) {
throw new Error(await responseErrorMessage(response));
}
const filename = responseDownloadFilename(
response,
`${safeFileStem(snapshot.project.name)}-results.csv`,
);
downloadBlob(await response.blob(), filename);
} catch (error) {
window.alert(`CSV 下载失败:${errorMessage(error)}`);
} finally {
setCsvDownloadPending(false);
}
};
return (
<div className="results-shell">
<header className="results-topbar">
<div>
<div className="product-name">System Simulation Flow</div>
<div className="product-meta">仿真结果查看</div>
</div>
<WorkspaceViewTabs
activeView="results"
hasResults
onViewChange={onViewChange}
/>
<div className="results-run-actions">
<ResultsFileToolbar
csvDownloadPending={csvDownloadPending}
onDownloadCsv={() => void downloadResultsCsv()}
onSnapshotLoad={onSnapshotLoad}
snapshot={snapshot}
/>
<div className="results-run-meta">
<strong>{snapshot.project.name}</strong>
{resultStatus !== "completed" ? (
<span className={`results-run-status ${resultStatus}`}>
{simulationResultStatusText(snapshot.result, resultStatus)}
</span>
) : null}
<span>
{snapshot.result.diagnostics.sampleCount} 个采样点 ·{" "}
{formatResultTimestamp(snapshot.createdAt)}
</span>
</div>
</div>
</header>
<main
className={`results-workbench ${
activePaneResize ? `resizing-${activePaneResize}` : ""
}`}
ref={workbenchRef}
style={{
gridTemplateColumns: `${paneLayout.inspectorWidth}px ${PANE_SPLITTER_SIZE}px minmax(${MIN_CHART_WIDTH}px, 1fr)`,
}}
>
<div
className="results-inspector-pane"
style={{
gridTemplateRows: `${paneLayout.systemHeight}px ${PANE_SPLITTER_SIZE}px minmax(${MIN_VARIABLE_HEIGHT}px, 1fr)`,
}}
>
<section className="results-system-panel">
<div className="results-section-heading">
<strong>系统图</strong>
<div className="results-system-heading-tools">
<span>{snapshot.project.nodes.length} 个组件</span>
<button
aria-label="适应系统图窗口"
onClick={() => {
void reactFlow.fitView({
duration: 180,
minZoom: 0.1,
padding: 0.18,
});
}}
title="适应窗口"
type="button"
>
<Scaling aria-hidden="true" size={14} strokeWidth={1.8} />
</button>
</div>
</div>
<div className="results-system-canvas">
<ReactFlow
colorMode="light"
connectionLineType={ConnectionLineType.Step}
connectionMode={ConnectionMode.Loose}
defaultViewport={initialSystemViewportRef.current ?? undefined}
deleteKeyCode={null}
edges={readOnlyEdges}
edgeTypes={contactAwareEdgeTypes}
edgesFocusable={false}
edgesReconnectable={false}
elementsSelectable
minZoom={0.1}
nodeTypes={nodeTypes}
nodes={readOnlyNodes}
nodesConnectable={false}
nodesDraggable={false}
proOptions={{ hideAttribution: true }}
nodesFocusable
onNodeClick={(_, node) => setSelectedNodeId(node.id)}
onPaneClick={() => setSelectedNodeId(null)}
onMoveEnd={(_, viewport) =>
storeResultSystemViewport(snapshot.id, viewport)
}
panOnDrag
selectionOnDrag={false}
zoomOnDoubleClick={false}
>
<AutoFitView
enabled={readOnlyNodes.length > 0}
expectedNodeCount={readOnlyNodes.length}
fitOnMount={initialSystemViewportRef.current === null}
requestKey={snapshot.id}
/>
<CanvasGridBackground visibility={gridVisibility} />
</ReactFlow>
</div>
</section>
<div
aria-label="调整系统图和结果变量区域高度"
aria-orientation="horizontal"
aria-valuemin={MIN_SYSTEM_HEIGHT}
aria-valuenow={Math.round(paneLayout.systemHeight)}
className="results-pane-splitter horizontal"
onDoubleClick={() => resetPaneSize("system-height")}
onKeyDown={(event) =>
resizePaneWithKeyboard(event, "system-height")
}
onPointerCancel={stopPaneResize}
onPointerDown={(event) => startPaneResize(event, "system-height")}
onPointerMove={movePaneResize}
onPointerUp={stopPaneResize}
role="separator"
tabIndex={0}
title="拖动调整上下区域,双击恢复默认"
>
<span aria-hidden="true" />
</div>
<aside className="results-variable-panel">
<div className="results-section-heading">
<strong>结果变量</strong>
<span>{selectedVariables.length}</span>
</div>
<div className="results-variable-content">
{selectedNode ? (
<>
<div className="results-selected-component">
<strong>{selectedNode.data.label}</strong>
<span>{selectedNode.data.modelType}</span>
</div>
<VariableGroup
chartWindows={chartWindows}
onClick={addChartWindow}
onDragStart={handleVariableDragStart}
title="组件变量"
variables={componentVariables}
/>
<VariableGroup
chartWindows={chartWindows}
onClick={addChartWindow}
onDragStart={handleVariableDragStart}
title="端口变量"
variables={portVariables}
/>
</>
) : (
<div className="results-variable-empty">未选择组件</div>
)}
</div>
</aside>
</div>
<div
aria-label="调整系统与曲线区域宽度"
aria-orientation="vertical"
aria-valuemin={MIN_INSPECTOR_WIDTH}
aria-valuenow={Math.round(paneLayout.inspectorWidth)}
className="results-pane-splitter vertical"
onDoubleClick={() => resetPaneSize("inspector-width")}
onKeyDown={(event) =>
resizePaneWithKeyboard(event, "inspector-width")
}
onPointerCancel={stopPaneResize}
onPointerDown={(event) => startPaneResize(event, "inspector-width")}
onPointerMove={movePaneResize}
onPointerUp={stopPaneResize}
role="separator"
tabIndex={0}
title="拖动调整左右区域,双击恢复默认"
>
<span aria-hidden="true" />
</div>
<section className="results-chart-panel">
<div className="results-section-heading results-chart-heading">
<strong>曲线窗口</strong>
<div className="results-chart-heading-tools">
<span>
{chartWindows.length} 个窗口 ·{" "}
{chartWindows.reduce(
(count, window) => count + window.variableKeys.length,
0,
)}{" "}
条曲线
</span>
<div className="results-multi-create">
<button
aria-label="新建同单位多曲线对比窗口"
disabled={plottableResultVariables.length === 0}
onClick={() => addCombinedChartWindow("multi")}
title="新建同单位多曲线对比窗口"
type="button"
>
<ChartNoAxesCombined
aria-hidden="true"
size={17}
strokeWidth={1.9}
/>
</button>
</div>
<div className="results-multi-create">
<button
aria-label="新建异单位上下对比窗口"
disabled={plottableResultVariables.length === 0}
onClick={() => addCombinedChartWindow("mixed")}
title="新建异单位上下对比窗口"
type="button"
>
<ChartSpline aria-hidden="true" size={17} strokeWidth={1.9} />
</button>
</div>
</div>
</div>
<div
className={`results-chart-workspace ${
chartWindows.length === 0 ? "empty" : ""
}`}
onDragOver={(event) => {
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
}}
onDrop={handleWorkspaceDrop}
ref={workspaceRef}
>
{chartWindows.map((chartWindow) => {
const isCombined = chartWindow.kind !== "single";
const isMixed = chartWindow.kind === "mixed";
const windowVariables = chartWindow.variableKeys
.map((key) =>
snapshot.result.variables.find((item) => item.key === key),
)
.filter(
(item): item is ResultVariableMetadata => item !== undefined,
);
const variable =
windowVariables.find(
(item) => item.key === chartWindow.variableKey,
) ?? windowVariables[0];
if (!variable) {
if (!isCombined) {
return null;
}
const pendingTitle = isMixed
? "异单位上下对比"
: "同单位多曲线对比";
return (
<article
aria-label={`${pendingTitle}窗口(待定)`}
className={`result-chart-window ${chartWindow.kind} pending ${
multiDropTargetWindowId === chartWindow.id
? "drop-target"
: ""
}`}
data-chart-kind={chartWindow.kind}
data-pending="true"
key={chartWindow.id}
onDragLeave={(event) => {
if (
event.relatedTarget instanceof Element &&
event.currentTarget.contains(event.relatedTarget)
) {
return;
}
if (multiDropTargetWindowId === chartWindow.id) {
setMultiDropTargetWindowId(null);
}
}}
onDragOver={(event) => {
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = "copy";
setMultiDropTargetWindowId(chartWindow.id);
}}
onDrop={(event) =>
addDroppedVariableToMultiChart(event, chartWindow)
}
onPointerDown={() => focusChartWindow(chartWindow.id)}
style={{
height: chartWindow.height,
left: chartWindow.x,
top: chartWindow.y,
width: chartWindow.width,
zIndex: chartWindow.zIndex,
}}
>
<div
className="result-chart-window-header"
onPointerCancel={stopWindowDrag}
onPointerDown={(event) =>
startWindowDrag(event, chartWindow)
}
onPointerMove={moveChartWindow}
onPointerUp={stopWindowDrag}
>
<div className="result-chart-window-title">
<strong>{pendingTitle}</strong>
<span>待定 · 请拖入第一条曲线</span>
</div>
<div className="result-chart-window-actions">
<button
aria-label={`关闭${pendingTitle}窗口`}
className="close"
onClick={(event) => {
event.stopPropagation();
removeChartWindow(chartWindow.id);
}}
onPointerDown={(event) => event.stopPropagation()}
title="关闭曲线窗口"
type="button"
>
<X aria-hidden="true" size={16} strokeWidth={1.9} />
</button>
</div>
</div>
<div className="result-chart-pending">
{isMixed ? (
<ChartSpline
aria-hidden="true"
size={34}
strokeWidth={1.5}
/>
) : (
<ChartNoAxesCombined
aria-hidden="true"
size={34}
strokeWidth={1.5}
/>
)}
<strong>等待第一条曲线</strong>
<span>
拖入变量后,将以第一条曲线初始化单位和显示设置
</span>
</div>
<button
aria-label={`调整${pendingTitle}窗口大小`}
className="result-chart-resize-handle"
onLostPointerCapture={stopWindowResize}
onPointerCancel={stopWindowResize}
onPointerDown={(event) =>
startWindowResize(event, chartWindow)
}
onPointerMove={resizeChartWindow}
onPointerUp={stopWindowResize}
title="调整窗口大小"
type="button"
>
<Scaling aria-hidden="true" size={13} strokeWidth={1.8} />
</button>
</article>
);
}
const label = resultVariableLabel(variable);
const component = snapshot.project.nodes.find(
(node) => node.id === variable.componentId,
);
const componentName =
component?.data.label ?? variable.componentId;
const chartTitle = resultVariableTitle(variable, componentName);
const unitOptions = resultUnitOptions(variable);
const selectedUnit = resolveResultUnitOption(
variable,
chartWindow.unit,
);
const chartTime = snapshot.result.series.time ?? [];
const primaryChartValues = continuousMechanicalForceChartValues(
variable,
component,
snapshot.result.series[chartWindow.variableKey] ?? [],
stateTransitionSampleIndices,
);
const displayValues = primaryChartValues.values.map(
selectedUnit.fromBase,
);
const combinedVariables = windowVariables.filter(
(item) => isMixed || item.unit === variable.unit,
);
const visibleCombinedVariables = combinedVariables.filter(
(item) => !chartWindow.hiddenVariableKeys.includes(item.key),
);
const multiSeries = visibleCombinedVariables.map((item) => {
const itemComponent = snapshot.project.nodes.find(
(node) => node.id === item.componentId,
);
const itemUnit = isMixed
? resolveResultUnitOption(
item,
chartWindow.variableUnits[item.key] ?? item.unit,
)
: selectedUnit;
const itemChartValues =
continuousMechanicalForceChartValues(
item,
itemComponent,
snapshot.result.series[item.key] ?? [],
stateTransitionSampleIndices,
);
return {
key: item.key,
label: resultVariableTitle(
item,
itemComponent?.data.label ?? item.componentId,
),
color: colorForVariable(item.key),
unit: itemUnit.label,
values: itemChartValues.values.map(itemUnit.fromBase),
separatedEventSampleCount:
itemChartValues.separatedEventSampleCount,
};
});
const separatedEventSampleCount = isCombined
? multiSeries.reduce(
(count, item) =>
count + item.separatedEventSampleCount,
0,
)
: primaryChartValues.separatedEventSampleCount;
const visibilitySeries = isCombined
? multiSeries
: [
{
key: chartWindow.variableKey,
values: displayValues,
},
];
const hasVisibleChartCurve = chartSeriesHasVisibleSegments(
chartTime,
visibilitySeries,
chartWindow.viewport,
isMixed,
);
const chartCursorEnabled =
chartWindow.cursorEnabled && hasVisibleChartCurve;
return (
<article
className={`result-chart-window ${isCombined ? chartWindow.kind : "single"} ${
multiDropTargetWindowId === chartWindow.id
? "drop-target"
: ""
}`}
data-chart-kind={chartWindow.kind}
data-pending="false"
data-separated-event-samples={separatedEventSampleCount}
key={chartWindow.id}
onDragLeave={(event) => {
if (
event.relatedTarget instanceof Element &&
event.currentTarget.contains(event.relatedTarget)
) {
return;
}
if (multiDropTargetWindowId === chartWindow.id) {
setMultiDropTargetWindowId(null);
}
}}
onDragOver={(event) => {
if (!isCombined) {
return;
}
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = "copy";
setMultiDropTargetWindowId(chartWindow.id);
}}
onDrop={(event) => {
if (isCombined) {
addDroppedVariableToMultiChart(event, chartWindow);
}
}}
onPointerDown={() => focusChartWindow(chartWindow.id)}
style={{
height: chartWindow.height,
left: chartWindow.x,
top: chartWindow.y,
width: chartWindow.width,
zIndex: chartWindow.zIndex,
}}
>
<div
className="result-chart-window-header"
onPointerCancel={stopWindowDrag}
onPointerDown={(event) =>
startWindowDrag(event, chartWindow)
}
onPointerMove={moveChartWindow}
onPointerUp={stopWindowDrag}
>
<div className="result-chart-window-title">
<strong>
{isMixed
? "异单位上下对比"
: isCombined
? "同单位多曲线对比"
: label}
</strong>
<span>
{isCombined
? `${visibleCombinedVariables.length}/${combinedVariables.length} 条显示 · ${isMixed ? "上下同步图" : variable.unit || "无量纲"}`
: componentName}
{separatedEventSampleCount > 0
? ` · 已隐藏 ${separatedEventSampleCount} 个孤立事件力尖峰`
: ""}
</span>
</div>
<div className="result-chart-window-actions">
<button
aria-label={`${chartCursorEnabled ? "关闭" : "打开"} ${isCombined ? "多曲线" : label} 曲线游标`}
aria-pressed={chartCursorEnabled}
className={`cursor ${chartCursorEnabled ? "active" : ""}`}
disabled={!hasVisibleChartCurve}
onClick={(event) => {
event.stopPropagation();
toggleChartWindowCursor(chartWindow.id);
}}
onPointerDown={(event) => event.stopPropagation()}
title={
!hasVisibleChartCurve
? "当前视口内没有可用曲线"
: chartCursorEnabled
? "关闭游标"
: "打开游标"
}
type="button"
>
<Crosshair
aria-hidden="true"
size={16}
strokeWidth={1.9}
/>
</button>
<button
aria-label={`${chartWindow.zoomEnabled ? "关闭" : "打开"} ${isCombined ? "多曲线" : label} 曲线缩放`}
aria-pressed={chartWindow.zoomEnabled}
className={`zoom ${chartWindow.zoomEnabled ? "active" : ""}`}
onClick={(event) => {
event.stopPropagation();
toggleChartWindowZoom(chartWindow.id);
}}
onPointerDown={(event) => event.stopPropagation()}
title={
chartWindow.zoomEnabled
? "关闭曲线缩放"
: "曲线区滚轮缩放双轴,坐标轴滚轮只缩放对应轴,拖拽框选局部,按住中键拖动平移"
}
type="button"
>
<ZoomIn
aria-hidden="true"
size={16}
strokeWidth={1.9}
/>
</button>
<button
aria-label={`恢复 ${isCombined ? "多曲线" : label} 原始尺寸`}
disabled={isDefaultChartViewport(chartWindow.viewport)}
onClick={(event) => {
event.stopPropagation();
undoChartWindowZoom(chartWindow.id);
}}
onPointerDown={(event) => event.stopPropagation()}
title="恢复原始尺寸(Backspace)"
type="button"
>
<Maximize2
aria-hidden="true"
size={16}
strokeWidth={1.9}
/>
</button>
{isCombined ? (
<div
className="result-chart-multi-picker"
onBlur={(event) => {
if (
!(event.relatedTarget instanceof HTMLElement) ||
!event.currentTarget.contains(event.relatedTarget)
) {
setActiveMultiPickerWindowId(null);
}
}}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.stopPropagation();
setActiveMultiPickerWindowId(null);
}
}}
onPointerDown={(event) => event.stopPropagation()}
>
<button
aria-expanded={
activeMultiPickerWindowId === chartWindow.id
}
aria-label="管理多曲线窗口中的曲线"
className={
activeMultiPickerWindowId === chartWindow.id
? "active"
: ""
}
onClick={(event) => {
event.stopPropagation();
setActiveMultiPickerWindowId((current) =>
current === chartWindow.id
? null
: chartWindow.id,
);
}}
title="显示、隐藏或删除曲线"
type="button"
>
<ListChecks
aria-hidden="true"
size={17}
strokeWidth={1.9}
/>
</button>
{activeMultiPickerWindowId === chartWindow.id ? (
<div
aria-label="管理已加入多曲线窗口的曲线"
className="result-chart-multi-menu"
role="group"
>
<div className="result-chart-multi-menu-title">
<span>曲线管理</span>
<strong>
{isMixed
? "多单位"
: variable.unit || "无量纲"}
</strong>
</div>
<div className="result-chart-multi-menu-list">
{combinedVariables.map((item) => {
const visible =
!chartWindow.hiddenVariableKeys.includes(
item.key,
);
const itemComponent =
snapshot.project.nodes.find(
(node) => node.id === item.componentId,
);
const itemTitle = resultVariableTitle(
item,
itemComponent?.data.label ??
item.componentId,
);
const itemUnitOptions =
resultUnitOptions(item);
const itemUnit = resolveResultUnitOption(
item,
chartWindow.variableUnits[item.key] ??
item.unit,
);
return (
<div
className="result-chart-multi-menu-row"
key={item.key}
title={itemTitle}
>
<input
aria-label={`${visible ? "隐藏" : "显示"} ${itemTitle}`}
checked={visible}
onChange={() =>
toggleChartCurveVisibility(
chartWindow.id,
item.key,
)
}
type="checkbox"
/>
<span
className="result-variable-swatch"
style={{
backgroundColor: colorForVariable(
item.key,
),
}}
/>
<span className="result-chart-multi-menu-label">
<b>{itemTitle}</b>
<small>
{resultVariableLabel(item)} ·{" "}
{itemUnit.label || "无量纲"}
</small>
</span>
{isMixed && itemUnitOptions.length > 1 ? (
<select
aria-label={`${itemTitle}纵轴单位`}
onChange={(event) =>
updateChartCurveUnit(
chartWindow.id,
item.key,
event.target.value,
)
}
title="该曲线纵轴单位"
value={itemUnit.value}
>
{itemUnitOptions.map((option) => (
<option
key={option.value}
value={option.value}
>
{option.label}
</option>
))}
</select>
) : null}
<button
aria-label={`删除 ${itemTitle}`}
className="remove"
onClick={() =>
removeChartCurve(
chartWindow.id,
item.key,
)
}
title="从窗口中删除曲线"
type="button"
>
<Trash2
aria-hidden="true"
size={14}
strokeWidth={1.8}
/>
</button>
</div>
);
})}
</div>
</div>
) : null}
</div>
) : null}
{!isMixed && unitOptions.length > 1 ? (
<select
aria-label={`${isCombined ? "多曲线" : label}纵轴单位`}
onChange={(event) =>
updateChartWindowUnit(
chartWindow.id,
event.target.value,
)
}
onPointerDown={(event) => event.stopPropagation()}
title="纵轴单位"
value={selectedUnit.value}
>
{unitOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
) : null}
<button
aria-label={`下载 ${isCombined ? "多曲线" : label} 曲线图片`}
className="download"
onClick={(event) => {
event.stopPropagation();
void downloadChartImage(
event.currentTarget,
isCombined
? `${snapshot.project.name}-${isMixed ? "异单位" : "同单位"}多曲线对比`
: chartTitle,
).catch((error) => {
window.alert(
`图片下载失败:${errorMessage(error)}`,
);
});
}}
onPointerDown={(event) => event.stopPropagation()}
title="下载 PNG"
type="button"
>
<ImageDown
aria-hidden="true"
size={16}
strokeWidth={1.9}
/>
</button>
<button
aria-label={`关闭 ${isCombined ? "多曲线" : label} 曲线`}
className="close"
onClick={(event) => {
event.stopPropagation();
removeChartWindow(chartWindow.id);
}}
onPointerDown={(event) => event.stopPropagation()}
title="关闭曲线"
type="button"
>
<X aria-hidden="true" size={16} strokeWidth={1.9} />
</button>
</div>
</div>
{isMixed ? (
<StackedCurvePlot
cursorEnabled={chartCursorEnabled}
onViewportChange={(viewport) =>
updateChartWindowViewport(chartWindow.id, viewport)
}
onZoomPrevious={() => undoChartWindowZoom(chartWindow.id)}
series={multiSeries}
time={chartTime}
viewport={chartWindow.viewport}
zoomEnabled={chartWindow.zoomEnabled}
/>
) : isCombined ? (
<MultiCurvePlot
cursorEnabled={chartCursorEnabled}
onViewportChange={(viewport) =>
updateChartWindowViewport(chartWindow.id, viewport)
}
onZoomPrevious={() => undoChartWindowZoom(chartWindow.id)}
series={multiSeries}
time={chartTime}
title="多曲线对比"
unit={selectedUnit.label}
viewport={chartWindow.viewport}
zoomEnabled={chartWindow.zoomEnabled}
/>
) : (
<CurvePlot
color={colorForVariable(chartWindow.variableKey)}
cursorEnabled={chartCursorEnabled}
onViewportChange={(viewport) =>
updateChartWindowViewport(chartWindow.id, viewport)
}
onZoomPrevious={() => undoChartWindowZoom(chartWindow.id)}
time={chartTime}
title={chartTitle}
unit={selectedUnit.label}
values={displayValues}
viewport={chartWindow.viewport}
zoomEnabled={chartWindow.zoomEnabled}
/>
)}
<button
aria-label={`调整 ${isCombined ? "多曲线" : label} 曲线窗口大小`}
className="result-chart-resize-handle"
onLostPointerCapture={stopWindowResize}
onPointerCancel={stopWindowResize}
onPointerDown={(event) =>
startWindowResize(event, chartWindow)
}
onPointerMove={resizeChartWindow}
onPointerUp={stopWindowResize}
title="调整窗口大小"
type="button"
>
<Scaling aria-hidden="true" size={13} strokeWidth={1.8} />
</button>
</article>
);
})}
{chartWindows.length === 0 ? (
<div className="results-workspace-empty">暂无曲线</div>
) : null}
</div>
</section>
</main>
</div>
);
}
export function EmptySimulationResultsView({
onViewChange,
onSnapshotLoad,
}: EmptySimulationResultsViewProps) {
return (
<div className="results-shell">
<header className="results-topbar">
<div>
<div className="product-name">System Simulation Flow</div>
<div className="product-meta">仿真结果查看</div>
</div>
<WorkspaceViewTabs
activeView="results"
hasResults={false}
onViewChange={onViewChange}
/>
<div className="results-run-actions">
<ResultsFileToolbar onSnapshotLoad={onSnapshotLoad} />
<div className="results-run-meta empty">
<span>尚无结果</span>
</div>
</div>
</header>
<main className="results-empty-view">
<strong>尚未生成仿真结果</strong>
<button onClick={() => onViewChange("modeling")} type="button">
进入建模
</button>
</main>
</div>
);
}
type ResultsFileToolbarProps = {
snapshot?: SimulationResultsSnapshot;
csvDownloadPending?: boolean;
onDownloadCsv?: () => void;
onSnapshotLoad: (snapshot: SimulationResultsSnapshot) => void;
};
function ResultsFileToolbar({
snapshot,
csvDownloadPending = false,
onDownloadCsv,
onSnapshotLoad,
}: ResultsFileToolbarProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const [resultFileLoadPending, setResultFileLoadPending] = useState(false);
const loadResultFile = async (event: ReactChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file || resultFileLoadPending) {
return;
}
setResultFileLoadPending(true);
try {
if (file.size > MAX_RESULT_FILE_SIZE) {
throw new Error("结果文件超过 250 MB,无法在浏览器中安全加载。");
}
onSnapshotLoad(parseSimulationResultFile(await file.text()));
} catch (error) {
window.alert(`结果文件加载失败:${errorMessage(error)}`);
} finally {
setResultFileLoadPending(false);
}
};
return (
<div
aria-label="结果文件工具"
className="results-file-toolbar"
role="toolbar"
>
{snapshot ? (
<button
aria-label="下载结果文件"
onClick={() => downloadSimulationResultFile(snapshot)}
title="下载结果文件"
type="button"
>
<FileDown aria-hidden="true" size={17} strokeWidth={1.9} />
</button>
) : null}
<button
aria-busy={resultFileLoadPending}
aria-label="加载结果文件"
disabled={resultFileLoadPending}
onClick={() => fileInputRef.current?.click()}
title="加载结果文件"
type="button"
>
<FolderOpen aria-hidden="true" size={17} strokeWidth={1.9} />
</button>
{snapshot && onDownloadCsv ? (
<button
aria-busy={csvDownloadPending}
aria-label="下载结果 CSV"
disabled={csvDownloadPending}
onClick={onDownloadCsv}
title="下载结果 CSV"
type="button"
>
<Sheet aria-hidden="true" size={17} strokeWidth={1.9} />
</button>
) : null}
<input
accept={`.simresult,${RESULT_FILE_MIME}`}
className="results-file-input"
onChange={(event) => void loadResultFile(event)}
ref={fileInputRef}
type="file"
/>
</div>
);
}
type VariableGroupProps = {
title: string;
variables: ResultVariableMetadata[];
chartWindows: ChartWindow[];
onClick: (variableKey: string) => void;
onDragStart: (
event: DragEvent<HTMLButtonElement>,
variableKey: string,
) => void;
};
function VariableGroup({
title,
variables,
chartWindows,
onClick,
onDragStart,
}: VariableGroupProps) {
if (variables.length === 0) {
return null;
}
return (
<section className="results-variable-group">
<div className="results-variable-group-title">{title}</div>
<div className="results-variable-list">
{variables.map((variable) => {
const visible = chartWindows.some(
(window) =>
window.variableKeys.includes(variable.key) &&
!window.hiddenVariableKeys.includes(variable.key),
);
const label = resultVariableLabel(variable);
return (
<button
className={visible ? "visible" : ""}
draggable
key={variable.key}
onClick={() => onClick(variable.key)}
onDragStart={(event) => onDragStart(event, variable.key)}
type="button"
>
<span
className="result-variable-swatch"
style={{ backgroundColor: colorForVariable(variable.key) }}
/>
<span>
<strong>{label}</strong>
<small>{variable.unit || "无量纲"}</small>
</span>
</button>
);
})}
</div>
</section>
);
}
type MultiCurveSeries = {
key: string;
label: string;
color: string;
unit: string;
values: number[];
separatedEventSampleCount: number;
};
type ChartVisibilitySeries = {
key: string;
values: number[];
};
type ChartZoomPlotProps = {
viewport: ChartViewport;
zoomEnabled: boolean;
onViewportChange: (viewport: ChartViewport) => void;
onZoomPrevious: () => void;
};
type ChartPlotRect = {
left: number;
top: number;
right: number;
bottom: number;
};
type ChartZoomTarget = {
rect: ChartPlotRect;
yKey?: string;
};
type ChartWheelZoomTarget = ChartZoomTarget & {
axes: "x" | "y" | "both";
};
type ChartPanDragState = {
pointerId: number;
target: ChartWheelZoomTarget;
lastX: number;
lastY: number;
};
type ChartZoomDragState = {
pointerId: number;
target: ChartZoomTarget;
startX: number;
startY: number;
currentX: number;
currentY: number;
};
type ChartZoomSelection = Pick<
ChartZoomDragState,
"target" | "startX" | "startY" | "currentX" | "currentY"
>;
type ChartPoint = {
x: number;
y: number;
};
function useChartBoxZoom({
height,
onViewportChange,
onZoomPrevious,
resolveTarget,
resolveWheelTarget,
viewport,
width,
zoomEnabled,
}: ChartZoomPlotProps & {
width: number;
height: number;
resolveTarget: (point: ChartPoint) => ChartZoomTarget | null;
resolveWheelTarget: (point: ChartPoint) => ChartWheelZoomTarget | null;
}) {
const dragRef = useRef<ChartZoomDragState | null>(null);
const panRef = useRef<ChartPanDragState | null>(null);
const viewportRef = useRef(viewport);
const [panning, setPanning] = useState(false);
const [selection, setSelection] = useState<ChartZoomSelection | null>(null);
viewportRef.current = viewport;
useEffect(() => {
if (!zoomEnabled) {
dragRef.current = null;
panRef.current = null;
setPanning(false);
setSelection(null);
}
}, [zoomEnabled]);
const clientPoint = (
svg: SVGSVGElement,
clientX: number,
clientY: number,
): ChartPoint | null => {
const bounds = svg.getBoundingClientRect();
if (bounds.width <= 0 || bounds.height <= 0) {
return null;
}
return {
x: ((clientX - bounds.left) / bounds.width) * width,
y: ((clientY - bounds.top) / bounds.height) * height,
};
};
const eventPoint = (event: ReactPointerEvent<SVGSVGElement>) =>
clientPoint(event.currentTarget, event.clientX, event.clientY);
const cancelSelection = (event?: ReactPointerEvent<SVGSVGElement>) => {
const drag = dragRef.current;
const pan = panRef.current;
const pointerId = drag?.pointerId ?? pan?.pointerId;
if (
event &&
pointerId !== undefined &&
event.currentTarget.hasPointerCapture(pointerId)
) {
event.currentTarget.releasePointerCapture(pointerId);
}
dragRef.current = null;
panRef.current = null;
setPanning(false);
setSelection(null);
};
const onPointerDown = (event: ReactPointerEvent<SVGSVGElement>) => {
if (!zoomEnabled) {
return;
}
const point = eventPoint(event);
if (event.button === 1) {
const panTarget = point ? resolveWheelTarget(point) : null;
if (!point || !panTarget) {
return;
}
event.preventDefault();
event.stopPropagation();
event.currentTarget.focus({ preventScroll: true });
panRef.current = {
pointerId: event.pointerId,
target: panTarget,
lastX: point.x,
lastY: point.y,
};
setPanning(true);
event.currentTarget.setPointerCapture(event.pointerId);
return;
}
if (event.button !== 0 || !point) {
return;
}
const target = point ? resolveTarget(point) : null;
if (!point || !target || !pointInsideChartRect(point, target.rect)) {
return;
}
event.preventDefault();
event.currentTarget.focus({ preventScroll: true });
const clamped = clampPointToChartRect(point, target.rect);
const drag: ChartZoomDragState = {
pointerId: event.pointerId,
target,
startX: clamped.x,
startY: clamped.y,
currentX: clamped.x,
currentY: clamped.y,
};
dragRef.current = drag;
setSelection(drag);
event.currentTarget.setPointerCapture(event.pointerId);
};
const onPointerMove = (event: ReactPointerEvent<SVGSVGElement>) => {
const pan = panRef.current;
if (pan && pan.pointerId === event.pointerId) {
const point = eventPoint(event);
if (!point) {
return;
}
event.preventDefault();
const deltaX = point.x - pan.lastX;
const deltaY = point.y - pan.lastY;
pan.lastX = point.x;
pan.lastY = point.y;
const nextViewport = chartViewportAfterPan(
viewportRef.current,
pan.target,
deltaX,
deltaY,
);
if (!sameChartViewport(viewportRef.current, nextViewport)) {
viewportRef.current = nextViewport;
onViewportChange(nextViewport);
}
return;
}
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) {
return;
}
const point = eventPoint(event);
if (!point) {
return;
}
event.preventDefault();
const clamped = clampPointToChartRect(point, drag.target.rect);
drag.currentX = clamped.x;
drag.currentY = clamped.y;
setSelection({ ...drag });
};
const finishSelection = (event: ReactPointerEvent<SVGSVGElement>) => {
const pan = panRef.current;
if (pan?.pointerId === event.pointerId) {
event.preventDefault();
cancelSelection(event);
return;
}
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) {
return;
}
const point = eventPoint(event);
if (point) {
const clamped = clampPointToChartRect(point, drag.target.rect);
drag.currentX = clamped.x;
drag.currentY = clamped.y;
}
event.preventDefault();
const nextViewport = chartViewportAfterSelection(viewportRef.current, drag);
cancelSelection(event);
if (nextViewport) {
viewportRef.current = nextViewport;
onViewportChange(nextViewport);
}
};
const onWheel = (event: ReactWheelEvent<SVGSVGElement>) => {
if (!zoomEnabled) {
return;
}
const point = clientPoint(
event.currentTarget,
event.clientX,
event.clientY,
);
const target = point ? resolveWheelTarget(point) : null;
if (!point || !target) {
return;
}
const rawDelta = event.deltaY !== 0 ? event.deltaY : event.deltaX;
if (rawDelta === 0) {
return;
}
event.preventDefault();
event.stopPropagation();
event.currentTarget.focus({ preventScroll: true });
const deltaMultiplier =
event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? 160 : 1;
const normalizedDelta = Math.min(
300,
Math.max(-300, rawDelta * deltaMultiplier),
);
const scaleFactor = Math.exp(normalizedDelta * 0.0018);
const nextViewport = chartViewportAfterWheel(
viewportRef.current,
target,
point,
scaleFactor,
);
if (sameChartViewport(viewportRef.current, nextViewport)) {
return;
}
viewportRef.current = nextViewport;
onViewportChange(nextViewport);
};
const onKeyDown = (event: ReactKeyboardEvent<SVGSVGElement>) => {
const activePointerId =
dragRef.current?.pointerId ?? panRef.current?.pointerId;
if (event.key === "Escape" && activePointerId !== undefined) {
event.preventDefault();
if (event.currentTarget.hasPointerCapture(activePointerId)) {
event.currentTarget.releasePointerCapture(activePointerId);
}
cancelSelection();
return;
}
if (event.key === "Backspace") {
event.preventDefault();
onZoomPrevious();
}
};
return {
onKeyDown,
onLostPointerCapture: cancelSelection,
onPointerCancel: cancelSelection,
onPointerDown,
onPointerMove,
onPointerUp: finishSelection,
onWheel,
panning,
selection,
};
}
function pointInsideChartRect(point: ChartPoint, rect: ChartPlotRect) {
return (
point.x >= rect.left &&
point.x <= rect.right &&
point.y >= rect.top &&
point.y <= rect.bottom
);
}
function resolveCartesianWheelZoomTarget(
point: ChartPoint,
rect: ChartPlotRect,
chartHeight: number,
): ChartWheelZoomTarget | null {
if (pointInsideChartRect(point, rect)) {
return { axes: "both", rect };
}
if (
point.x >= rect.left &&
point.x <= rect.right &&
point.y >= rect.bottom &&
point.y <= chartHeight
) {
return { axes: "x", rect };
}
if (
point.x >= 0 &&
point.x <= rect.left &&
point.y >= rect.top &&
point.y <= rect.bottom
) {
return { axes: "y", rect };
}
return null;
}
function resolveStackedWheelZoomTarget(
point: ChartPoint,
bands: Array<{ key: string; rect: ChartPlotRect }>,
chartHeight: number,
): ChartWheelZoomTarget | null {
const plotBand = bands.find((band) => pointInsideChartRect(point, band.rect));
if (plotBand) {
return { axes: "both", rect: plotBand.rect, yKey: plotBand.key };
}
const yAxisBand = bands.find(
(band) =>
point.x >= 0 &&
point.x <= band.rect.left &&
point.y >= band.rect.top &&
point.y <= band.rect.bottom,
);
if (yAxisBand) {
return { axes: "y", rect: yAxisBand.rect, yKey: yAxisBand.key };
}
const xAxisBand = bands[bands.length - 1];
if (
xAxisBand &&
point.x >= xAxisBand.rect.left &&
point.x <= xAxisBand.rect.right &&
point.y >= xAxisBand.rect.bottom &&
point.y <= chartHeight
) {
return { axes: "x", rect: xAxisBand.rect };
}
return null;
}
function clampPointToChartRect(
point: ChartPoint,
rect: ChartPlotRect,
): ChartPoint {
return {
x: Math.min(rect.right, Math.max(rect.left, point.x)),
y: Math.min(rect.bottom, Math.max(rect.top, point.y)),
};
}
function chartViewportAfterSelection(
viewport: ChartViewport,
selection: ChartZoomSelection,
): ChartViewport | null {
const { rect } = selection.target;
const selectionWidth = Math.abs(selection.currentX - selection.startX);
const selectionHeight = Math.abs(selection.currentY - selection.startY);
const zoomX = selectionWidth >= CHART_ZOOM_DRAG_THRESHOLD;
const zoomY = selectionHeight >= CHART_ZOOM_DRAG_THRESHOLD;
if (!zoomX && !zoomY) {
return null;
}
const left = Math.min(selection.startX, selection.currentX);
const right = Math.max(selection.startX, selection.currentX);
const top = Math.min(selection.startY, selection.currentY);
const bottom = Math.max(selection.startY, selection.currentY);
const xStart = (left - rect.left) / Math.max(rect.right - rect.left, 1);
const xEnd = (right - rect.left) / Math.max(rect.right - rect.left, 1);
const yStart = 1 - (bottom - rect.top) / Math.max(rect.bottom - rect.top, 1);
const yEnd = 1 - (top - rect.top) / Math.max(rect.bottom - rect.top, 1);
const next = {
...viewport,
x: zoomX ? composeChartViewportRange(viewport.x, xStart, xEnd) : viewport.x,
};
if (!zoomY) {
return normalizeChartViewport(next);
}
const yKey = selection.target.yKey;
if (yKey) {
const currentY = viewport.stackedY[yKey] ?? fullChartViewportRange();
return normalizeChartViewport({
...next,
stackedY: {
...viewport.stackedY,
[yKey]: composeChartViewportRange(currentY, yStart, yEnd),
},
});
}
return normalizeChartViewport({
...next,
y: composeChartViewportRange(viewport.y, yStart, yEnd),
});
}
function chartViewportAfterWheel(
viewport: ChartViewport,
target: ChartWheelZoomTarget,
point: ChartPoint,
scaleFactor: number,
): ChartViewport {
const { rect } = target;
const xFraction = (point.x - rect.left) / Math.max(rect.right - rect.left, 1);
const yFraction =
1 - (point.y - rect.top) / Math.max(rect.bottom - rect.top, 1);
const zoomX = target.axes === "x" || target.axes === "both";
const zoomY = target.axes === "y" || target.axes === "both";
const next: ChartViewport = {
...viewport,
x: zoomX
? zoomChartViewportRange(viewport.x, xFraction, scaleFactor)
: viewport.x,
};
if (!zoomY) {
return normalizeChartViewport(next);
}
const yKey = target.yKey;
if (yKey) {
const currentY = viewport.stackedY[yKey] ?? fullChartViewportRange();
return normalizeChartViewport({
...next,
stackedY: {
...viewport.stackedY,
[yKey]: zoomChartViewportRange(currentY, yFraction, scaleFactor),
},
});
}
return normalizeChartViewport({
...next,
y: zoomChartViewportRange(viewport.y, yFraction, scaleFactor),
});
}
function chartViewportAfterPan(
viewport: ChartViewport,
target: ChartWheelZoomTarget,
deltaX: number,
deltaY: number,
): ChartViewport {
const { rect } = target;
const panX = target.axes === "x" || target.axes === "both";
const panY = target.axes === "y" || target.axes === "both";
const xFraction = -deltaX / Math.max(rect.right - rect.left, 1);
const yFraction = deltaY / Math.max(rect.bottom - rect.top, 1);
const next: ChartViewport = {
...viewport,
x: panX ? shiftChartViewportRange(viewport.x, xFraction) : viewport.x,
};
if (!panY) {
return normalizeChartViewport(next);
}
const yKey = target.yKey;
if (yKey) {
const currentY = viewport.stackedY[yKey] ?? fullChartViewportRange();
return normalizeChartViewport({
...next,
stackedY: {
...viewport.stackedY,
[yKey]: shiftChartViewportRange(currentY, yFraction),
},
});
}
return normalizeChartViewport({
...next,
y: shiftChartViewportRange(viewport.y, yFraction),
});
}
function shiftChartViewportRange(
current: ChartViewportRange,
fraction: number,
): ChartViewportRange {
const span = current.end - current.start;
return normalizeChartViewportRange({
start: current.start + span * fraction,
end: current.end + span * fraction,
});
}
function zoomChartViewportRange(
current: ChartViewportRange,
anchorFraction: number,
scaleFactor: number,
): ChartViewportRange {
const currentSpan = current.end - current.start;
const nextSpan = Math.min(
MAX_CHART_VIEWPORT_SPAN,
Math.max(MIN_CHART_VIEWPORT_SPAN, currentSpan * scaleFactor),
);
const clampedAnchor = Math.min(1, Math.max(0, anchorFraction));
const anchor = current.start + currentSpan * clampedAnchor;
const start = anchor - nextSpan * clampedAnchor;
const end = start + nextSpan;
return normalizeChartViewportRange({
start,
end,
});
}
function composeChartViewportRange(
current: ChartViewportRange,
startFraction: number,
endFraction: number,
): ChartViewportRange {
const span = current.end - current.start;
return normalizeChartViewportRange({
start: current.start + span * startFraction,
end: current.start + span * endFraction,
});
}
function chartSelectionRect(selection: ChartZoomSelection) {
return {
x: Math.min(selection.startX, selection.currentX),
y: Math.min(selection.startY, selection.currentY),
width: Math.abs(selection.currentX - selection.startX),
height: Math.abs(selection.currentY - selection.startY),
};
}
function fullChartViewportRange(): ChartViewportRange {
return { start: 0, end: 1 };
}
function defaultChartViewport(): ChartViewport {
return {
x: fullChartViewportRange(),
y: fullChartViewportRange(),
stackedY: {},
};
}
function normalizeChartViewportRange(value: unknown): ChartViewportRange {
if (!value || typeof value !== "object") {
return fullChartViewportRange();
}
const candidate = value as Partial<ChartViewportRange>;
if (
typeof candidate.start !== "number" ||
!Number.isFinite(candidate.start) ||
typeof candidate.end !== "number" ||
!Number.isFinite(candidate.end)
) {
return fullChartViewportRange();
}
let start = candidate.start;
let end = candidate.end;
if (start > end) {
[start, end] = [end, start];
}
const span = end - start;
if (!Number.isFinite(span)) {
return fullChartViewportRange();
}
if (span < MIN_CHART_VIEWPORT_SPAN || span > MAX_CHART_VIEWPORT_SPAN) {
const center = start / 2 + end / 2;
const normalizedSpan = Math.min(
MAX_CHART_VIEWPORT_SPAN,
Math.max(MIN_CHART_VIEWPORT_SPAN, span),
);
return {
start: center - normalizedSpan / 2,
end: center + normalizedSpan / 2,
};
}
return { start, end };
}
function normalizeChartViewport(value: unknown): ChartViewport {
if (!value || typeof value !== "object") {
return defaultChartViewport();
}
const candidate = value as Partial<ChartViewport>;
const stackedY =
candidate.stackedY && isRecord(candidate.stackedY)
? Object.fromEntries(
Object.entries(candidate.stackedY)
.filter(([, range]) => range && typeof range === "object")
.map(([key, range]) => [key, normalizeChartViewportRange(range)])
.filter(
([, range]) =>
!sameChartViewportRange(
range as ChartViewportRange,
fullChartViewportRange(),
),
),
)
: {};
return {
x: normalizeChartViewportRange(candidate.x),
y: normalizeChartViewportRange(candidate.y),
stackedY,
};
}
function sameChartViewportRange(
first: ChartViewportRange,
second: ChartViewportRange,
) {
return first.start === second.start && first.end === second.end;
}
function sameChartViewport(first: ChartViewport, second: ChartViewport) {
if (
!sameChartViewportRange(first.x, second.x) ||
!sameChartViewportRange(first.y, second.y)
) {
return false;
}
const firstKeys = Object.keys(first.stackedY).sort();
const secondKeys = Object.keys(second.stackedY).sort();
return (
firstKeys.length === secondKeys.length &&
firstKeys.every(
(key, index) =>
key === secondKeys[index] &&
sameChartViewportRange(first.stackedY[key], second.stackedY[key]),
)
);
}
function isDefaultChartViewport(viewport: ChartViewport) {
return sameChartViewport(viewport, defaultChartViewport());
}
function chartViewportWithoutStackedY(
viewport: ChartViewport,
variableKey: string,
): ChartViewport {
if (!(variableKey in viewport.stackedY)) {
return viewport;
}
return {
...viewport,
stackedY: Object.fromEntries(
Object.entries(viewport.stackedY).filter(([key]) => key !== variableKey),
),
};
}
function chartDomainForViewport(
minimum: number,
maximum: number,
viewport: ChartViewportRange,
): [number, number] {
const span = maximum - minimum;
return [minimum + span * viewport.start, minimum + span * viewport.end];
}
function paddedChartDomain(values: number[]): [number, number] {
const rawMinimum = Math.min(...values);
const rawMaximum = Math.max(...values);
const padding =
rawMinimum === rawMaximum
? Math.max(Math.abs(rawMinimum) * 0.05, 1)
: (rawMaximum - rawMinimum) * 0.08;
return [rawMinimum - padding, rawMaximum + padding];
}
function chartSegmentIntersectsDomain(
first: { x: number; y: number },
second: { x: number; y: number },
xMinimum: number,
xMaximum: number,
yMinimum: number,
yMaximum: number,
) {
const deltaX = second.x - first.x;
const deltaY = second.y - first.y;
const boundaries: Array<[number, number]> = [
[-deltaX, first.x - xMinimum],
[deltaX, xMaximum - first.x],
[-deltaY, first.y - yMinimum],
[deltaY, yMaximum - first.y],
];
let lower = 0;
let upper = 1;
for (const [coefficient, distance] of boundaries) {
if (Math.abs(coefficient) < 1e-12) {
if (distance < 0) {
return false;
}
continue;
}
const ratio = distance / coefficient;
if (coefficient < 0) {
lower = Math.max(lower, ratio);
} else {
upper = Math.min(upper, ratio);
}
if (lower > upper) {
return false;
}
}
return true;
}
function chartSamplesHaveVisibleSegment(
samples: Array<{ x: number; y: number }>,
xMinimum: number,
xMaximum: number,
yMinimum: number,
yMaximum: number,
) {
return samples.some((sample, index) => {
const previous = samples[index - 1];
if (!previous) {
return (
sample.x >= xMinimum &&
sample.x <= xMaximum &&
sample.y >= yMinimum &&
sample.y <= yMaximum
);
}
return chartSegmentIntersectsDomain(
previous,
sample,
xMinimum,
xMaximum,
yMinimum,
yMaximum,
);
});
}
function chartSeriesHasVisibleSegments(
time: number[],
series: ChartVisibilitySeries[],
viewport: ChartViewport,
stacked: boolean,
) {
const prepared = series
.map((curve) => ({
...curve,
samples: time
.map((timeValue, index) => ({
x: Number(timeValue),
y: Number(curve.values[index]),
}))
.filter(
(sample) => Number.isFinite(sample.x) && Number.isFinite(sample.y),
),
}))
.filter((curve) => curve.samples.length >= 2);
if (prepared.length === 0) {
return false;
}
const allSamples = prepared.flatMap((curve) => curve.samples);
const fullXMinimum = Math.min(...allSamples.map((sample) => sample.x));
const fullXMaximum = Math.max(...allSamples.map((sample) => sample.x));
const [xMinimum, xMaximum] = chartDomainForViewport(
fullXMinimum,
fullXMaximum,
viewport.x,
);
if (stacked) {
return prepared.some((curve) => {
const [fullYMinimum, fullYMaximum] = paddedChartDomain(
curve.samples.map((sample) => sample.y),
);
const [yMinimum, yMaximum] = chartDomainForViewport(
fullYMinimum,
fullYMaximum,
viewport.stackedY[curve.key] ?? fullChartViewportRange(),
);
return chartSamplesHaveVisibleSegment(
curve.samples,
xMinimum,
xMaximum,
yMinimum,
yMaximum,
);
});
}
const [fullYMinimum, fullYMaximum] = paddedChartDomain(
allSamples.map((sample) => sample.y),
);
const [yMinimum, yMaximum] = chartDomainForViewport(
fullYMinimum,
fullYMaximum,
viewport.y,
);
return prepared.some((curve) =>
chartSamplesHaveVisibleSegment(
curve.samples,
xMinimum,
xMaximum,
yMinimum,
yMaximum,
),
);
}
type MultiCurvePlotProps = {
time: number[];
series: MultiCurveSeries[];
title: string;
unit: string;
cursorEnabled: boolean;
} & ChartZoomPlotProps;
function MultiCurvePlot({
time,
series,
title,
unit,
cursorEnabled,
onViewportChange,
onZoomPrevious,
viewport,
zoomEnabled,
}: MultiCurvePlotProps) {
const bodyRef = useRef<HTMLDivElement>(null);
const cursorPanelRef = useRef<HTMLDivElement>(null);
const cursorPanelDragRef = useRef<CursorPanelDragState | null>(null);
const cursorLineDragRef = useRef<CursorLineDragState | null>(null);
const clipPathId = `result-chart-clip-${useId().replaceAll(":", "")}`;
const [chartSize, setChartSize] = useState({ width: 520, height: 270 });
const [cursorIndex, setCursorIndex] = useState<number | null>(null);
const [cursorPanelPosition, setCursorPanelPosition] =
useState<CursorPanelPosition>({ x: 330, y: 9 });
const plottedSeries = useMemo(
() =>
series
.map((curve) => ({
...curve,
samples: time
.map((timeValue, index) => ({
x: Number(timeValue),
y: Number(curve.values[index]),
dataIndex: index,
}))
.filter(
(sample) =>
Number.isFinite(sample.x) && Number.isFinite(sample.y),
),
}))
.filter((curve) => curve.samples.length >= 2),
[series, time],
);
const cursorSamples = useMemo<CurveSample[]>(
() =>
time
.map((timeValue, index) => ({
x: Number(timeValue),
y: index,
}))
.filter((sample) => Number.isFinite(sample.x)),
[time],
);
useEffect(() => {
const body = bodyRef.current;
if (!body || typeof ResizeObserver === "undefined") {
return;
}
const updateSize = (width: number, height: number) => {
const nextSize = {
width: Math.max(280, Math.round(width)),
height: Math.max(150, Math.round(height)),
};
setChartSize((current) =>
current.width === nextSize.width && current.height === nextSize.height
? current
: nextSize,
);
};
updateSize(body.clientWidth, body.clientHeight);
const observer = new ResizeObserver(([entry]) => {
if (entry) {
updateSize(entry.contentRect.width, entry.contentRect.height);
}
});
observer.observe(body);
return () => observer.disconnect();
}, []);
useEffect(() => {
if (!cursorEnabled || cursorSamples.length === 0) {
return;
}
setCursorIndex((current) =>
current !== null && current < cursorSamples.length
? current
: Math.floor(cursorSamples.length / 2),
);
}, [cursorEnabled, cursorSamples.length]);
useEffect(() => {
const body = bodyRef.current;
if (!cursorEnabled || !body || typeof ResizeObserver === "undefined") {
return;
}
const clampPanel = () => {
const panel = cursorPanelRef.current;
if (!panel) {
return;
}
setCursorPanelPosition((current) =>
clampCursorPanelPosition(
current,
body.clientWidth,
body.clientHeight,
panel.offsetWidth,
panel.offsetHeight,
),
);
};
const animationFrame = window.requestAnimationFrame(clampPanel);
const observer = new ResizeObserver(clampPanel);
observer.observe(body);
return () => {
window.cancelAnimationFrame(animationFrame);
observer.disconnect();
};
}, [cursorEnabled, plottedSeries.length]);
const { width, height } = chartSize;
const maximumLegendRows = Math.max(
1,
Math.floor(Math.min(70, height * 0.26) / 14),
);
const legendColumns = Math.max(
1,
Math.ceil(plottedSeries.length / maximumLegendRows),
);
const legendRows = Math.ceil(plottedSeries.length / legendColumns);
const margin = {
top: 25 + legendRows * 14,
right: 18,
bottom: 40,
left: 68,
};
const plotWidth = Math.max(40, width - margin.left - margin.right);
const plotHeight = Math.max(30, height - margin.top - margin.bottom);
const plotRect: ChartPlotRect = {
left: margin.left,
top: margin.top,
right: margin.left + plotWidth,
bottom: margin.top + plotHeight,
};
const boxZoom = useChartBoxZoom({
height,
onViewportChange,
onZoomPrevious,
resolveTarget: (point) =>
pointInsideChartRect(point, plotRect) ? { rect: plotRect } : null,
resolveWheelTarget: (point) =>
resolveCartesianWheelZoomTarget(point, plotRect, height),
viewport,
width,
zoomEnabled,
});
if (plottedSeries.length === 0) {
return (
<div className="result-chart-body" ref={bodyRef}>
<div className="result-chart-empty">无可绘制数据</div>
</div>
);
}
const allSamples = plottedSeries.flatMap((curve) => curve.samples);
const rawYMin = Math.min(...allSamples.map((sample) => sample.y));
const rawYMax = Math.max(...allSamples.map((sample) => sample.y));
const sharedYPadding =
rawYMin === rawYMax
? Math.max(Math.abs(rawYMin) * 0.05, 1)
: (rawYMax - rawYMin) * 0.08;
const fullYScale = {
minimum: rawYMin - sharedYPadding,
maximum: rawYMax + sharedYPadding,
};
const [yMin, yMax] = chartDomainForViewport(
fullYScale.minimum,
fullYScale.maximum,
viewport.y,
);
const sharedYScale = { minimum: yMin, maximum: yMax };
const fullXMin = Math.min(...allSamples.map((sample) => sample.x));
const fullXMax = Math.max(...allSamples.map((sample) => sample.x));
const [xMin, xMax] = chartDomainForViewport(fullXMin, fullXMax, viewport.x);
const xRange = Math.max(xMax - xMin, 1e-12);
const xPosition = (value: number) =>
margin.left + ((value - xMin) / xRange) * plotWidth;
const yPosition = (value: number) => {
const range = Math.max(sharedYScale.maximum - sharedYScale.minimum, 1e-12);
return (
margin.top + (1 - (value - sharedYScale.minimum) / range) * plotHeight
);
};
const xTicks = chartTicks(xMin, xMax, 5);
const yTicks = chartTicks(sharedYScale.minimum, sharedYScale.maximum, 5);
const legendWidth = plotWidth / legendColumns;
const requestedCursorIndex =
cursorIndex === null
? Math.floor(cursorSamples.length / 2)
: Math.min(cursorIndex, cursorSamples.length - 1);
const activeCursorIndex = clampCursorIndexToVisibleX(
cursorSamples,
requestedCursorIndex,
xMin,
xMax,
);
const cursorSample = cursorSamples[activeCursorIndex];
const cursorReadings = cursorSample
? plottedSeries
.map((curve, index) => ({
color: curve.color,
index,
label: curve.label,
unit: curve.unit,
y: Number(curve.values[Math.round(cursorSample.y)]),
}))
.filter((reading) => Number.isFinite(reading.y))
: [];
const updateCursorByX = (clientX: number, svg: SVGSVGElement) => {
const bounds = svg.getBoundingClientRect();
if (bounds.width <= 0 || cursorSamples.length === 0) {
return;
}
const svgX = ((clientX - bounds.left) / bounds.width) * width;
const clampedX = Math.min(
width - margin.right,
Math.max(margin.left, svgX),
);
const targetX = xMin + ((clampedX - margin.left) / plotWidth) * xRange;
setCursorIndex(nearestSampleIndex(cursorSamples, targetX));
};
const startCursorLineDrag = (event: ReactPointerEvent<SVGLineElement>) => {
if (!cursorEnabled) {
return;
}
event.preventDefault();
event.stopPropagation();
cursorLineDragRef.current = {
pointerId: event.pointerId,
axis: "x",
};
event.currentTarget.setPointerCapture(event.pointerId);
};
const moveCursorLine = (event: ReactPointerEvent<SVGLineElement>) => {
const drag = cursorLineDragRef.current;
const svg = event.currentTarget.ownerSVGElement;
if (!drag || drag.pointerId !== event.pointerId || !svg) {
return;
}
event.preventDefault();
event.stopPropagation();
updateCursorByX(event.clientX, svg);
};
const stopCursorLineDrag = (event: ReactPointerEvent<SVGLineElement>) => {
if (cursorLineDragRef.current?.pointerId !== event.pointerId) {
return;
}
event.stopPropagation();
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
cursorLineDragRef.current = null;
};
const startCursorPanelDrag = (
event: ReactPointerEvent<HTMLButtonElement>,
) => {
const body = bodyRef.current;
const panel = cursorPanelRef.current;
if (!body || !panel) {
return;
}
event.preventDefault();
event.stopPropagation();
const panelBounds = panel.getBoundingClientRect();
const bodyBounds = body.getBoundingClientRect();
cursorPanelDragRef.current = {
pointerId: event.pointerId,
offsetX: event.clientX - panelBounds.left,
offsetY: event.clientY - panelBounds.top,
};
setCursorPanelPosition({
x: panelBounds.left - bodyBounds.left,
y: panelBounds.top - bodyBounds.top,
});
event.currentTarget.setPointerCapture(event.pointerId);
};
const moveCursorPanel = (event: ReactPointerEvent<HTMLButtonElement>) => {
const drag = cursorPanelDragRef.current;
const body = bodyRef.current;
const panel = cursorPanelRef.current;
if (!drag || drag.pointerId !== event.pointerId || !body || !panel) {
return;
}
event.preventDefault();
event.stopPropagation();
const bodyBounds = body.getBoundingClientRect();
setCursorPanelPosition(
clampCursorPanelPosition(
{
x: event.clientX - bodyBounds.left - drag.offsetX,
y: event.clientY - bodyBounds.top - drag.offsetY,
},
body.clientWidth,
body.clientHeight,
panel.offsetWidth,
panel.offsetHeight,
),
);
};
const stopCursorPanelDrag = (event: ReactPointerEvent<HTMLButtonElement>) => {
if (cursorPanelDragRef.current?.pointerId !== event.pointerId) {
return;
}
event.stopPropagation();
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
cursorPanelDragRef.current = null;
};
const zoomSelection = boxZoom.selection
? chartSelectionRect(boxZoom.selection)
: null;
return (
<div className="result-chart-body" ref={bodyRef}>
<svg
aria-label={`${title},包含 ${plottedSeries.length} 条曲线`}
className={
[
cursorEnabled ? "cursor-enabled" : "",
zoomEnabled ? "zoom-enabled" : "",
]
.filter(Boolean)
.join(" ") || undefined
}
data-result-chart="true"
data-panning={boxZoom.panning}
data-view-x-max={xMax}
data-view-x-min={xMin}
data-view-y-max={sharedYScale.maximum}
data-view-y-min={sharedYScale.minimum}
data-zoomed={!isDefaultChartViewport(viewport)}
onKeyDown={boxZoom.onKeyDown}
onLostPointerCapture={boxZoom.onLostPointerCapture}
onPointerCancel={boxZoom.onPointerCancel}
onPointerDown={(event) => {
if (zoomEnabled) {
boxZoom.onPointerDown(event);
return;
}
if (cursorEnabled) {
event.preventDefault();
updateCursorByX(event.clientX, event.currentTarget);
}
}}
onPointerMove={boxZoom.onPointerMove}
onPointerUp={boxZoom.onPointerUp}
onWheel={boxZoom.onWheel}
role="img"
tabIndex={0}
viewBox={`0 0 ${width} ${height}`}
>
<defs>
<clipPath id={clipPathId}>
<rect
height={plotHeight}
width={plotWidth}
x={margin.left}
y={margin.top}
/>
</clipPath>
</defs>
<text
className="result-chart-title"
textAnchor="start"
x={margin.left}
y={13}
>
{title}
</text>
<text
className="result-chart-unit"
textAnchor="end"
x={width - margin.right}
y={13}
>
{unit || "无量纲"}
</text>
<g className="result-chart-legend">
{plottedSeries.map((curve, index) => {
const column = Math.floor(index / legendRows);
const row = index % legendRows;
const x = margin.left + column * legendWidth;
const y = 27 + row * 14;
return (
<g key={curve.key}>
<line
stroke={curve.color}
strokeWidth="2"
vectorEffect="non-scaling-stroke"
x1={x}
x2={x + 12}
y1={y - 3}
y2={y - 3}
/>
<text x={x + 16} y={y}>
{truncateLegendLabel(
`Y${index + 1} ${curve.label}`,
legendWidth - 20,
)}
</text>
</g>
);
})}
</g>
{yTicks.map((tick, index) => {
const y = yPosition(tick);
return (
<g key={`y-${index}`}>
<line
className="result-chart-grid-line"
x1={margin.left}
x2={margin.left + plotWidth}
y1={y}
y2={y}
/>
<line
className="result-chart-tick"
x1={margin.left - 4}
x2={margin.left}
y1={y}
y2={y}
/>
<text
className="result-chart-label"
textAnchor="end"
x={margin.left - 7}
y={y + 3}
>
{formatChartNumber(tick)}
</text>
</g>
);
})}
{xTicks.map((tick, index) => {
const x = xPosition(tick);
const textAnchor =
index === 0
? "start"
: index === xTicks.length - 1
? "end"
: "middle";
return (
<g key={`x-${index}`}>
<line
className="result-chart-grid-line"
x1={x}
x2={x}
y1={margin.top}
y2={height - margin.bottom}
/>
<line
className="result-chart-tick"
x1={x}
x2={x}
y1={height - margin.bottom}
y2={height - margin.bottom + 4}
/>
<text
className="result-chart-label"
textAnchor={textAnchor}
x={x}
y={height - margin.bottom + 15}
>
{formatChartNumber(tick)}
</text>
</g>
);
})}
<line
className="result-chart-axis"
x1={margin.left}
x2={margin.left}
y1={margin.top}
y2={height - margin.bottom}
/>
<line
className="result-chart-axis"
x1={margin.left}
x2={margin.left + plotWidth}
y1={height - margin.bottom}
y2={height - margin.bottom}
/>
<g clipPath={`url(#${clipPathId})`} data-chart-clipped-series="true">
{plottedSeries.map((curve) => {
const path = segmentedChartPath(
time,
curve.values,
xPosition,
yPosition,
);
return (
<path
d={path}
fill="none"
key={curve.key}
stroke={curve.color}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
vectorEffect="non-scaling-stroke"
/>
);
})}
</g>
{cursorEnabled && cursorSample ? (
<>
<g
className="result-chart-cursor"
clipPath={`url(#${clipPathId})`}
pointerEvents="none"
>
<line
x1={xPosition(cursorSample.x)}
x2={xPosition(cursorSample.x)}
y1={margin.top}
y2={height - margin.bottom}
/>
{cursorReadings.map((reading) => (
<circle
cx={xPosition(cursorSample.x)}
cy={yPosition(reading.y)}
fill={reading.color}
key={`${reading.index}-${reading.label}`}
r="3.5"
/>
))}
</g>
<line
className="result-chart-cursor-hit x"
onLostPointerCapture={stopCursorLineDrag}
onPointerCancel={stopCursorLineDrag}
onPointerDown={startCursorLineDrag}
onPointerMove={moveCursorLine}
onPointerUp={stopCursorLineDrag}
x1={xPosition(cursorSample.x)}
x2={xPosition(cursorSample.x)}
y1={margin.top}
y2={height - margin.bottom}
/>
</>
) : null}
{zoomEnabled ? (
<g className="result-chart-zoom-axis-hits">
<rect
aria-label="纵轴滚轮缩放区"
className="result-chart-zoom-axis-hit y"
data-chart-zoom-axis="y"
fill="transparent"
height={plotHeight}
width={margin.left}
x={0}
y={margin.top}
/>
<rect
aria-label="横轴滚轮缩放区"
className="result-chart-zoom-axis-hit x"
data-chart-zoom-axis="x"
fill="transparent"
height={Math.max(0, height - plotRect.bottom)}
width={plotWidth}
x={margin.left}
y={plotRect.bottom}
/>
</g>
) : null}
{zoomSelection ? (
<rect
className="result-chart-zoom-selection"
height={zoomSelection.height}
pointerEvents="none"
width={zoomSelection.width}
x={zoomSelection.x}
y={zoomSelection.y}
/>
) : null}
<text
className="result-chart-axis-title"
textAnchor="middle"
x={margin.left + plotWidth / 2}
y={height - 5}
>
时间 (s)
</text>
</svg>
{cursorEnabled && cursorSample ? (
<div
className="result-chart-cursor-panel multi"
onPointerDown={(event) => event.stopPropagation()}
ref={cursorPanelRef}
style={{
left: cursorPanelPosition.x,
top: cursorPanelPosition.y,
}}
>
<button
aria-label="移动多曲线游标读数窗口"
className="result-chart-cursor-panel-header"
onLostPointerCapture={stopCursorPanelDrag}
onPointerCancel={stopCursorPanelDrag}
onPointerDown={startCursorPanelDrag}
onPointerMove={moveCursorPanel}
onPointerUp={stopCursorPanelDrag}
title="移动游标读数"
type="button"
>
<Move aria-hidden="true" size={13} strokeWidth={1.8} />
<span>游标</span>
</button>
<dl>
<div>
<dt>X</dt>
<dd>{formatCursorNumber(cursorSample.x)} s</dd>
</div>
{cursorReadings.map((reading) => (
<div
key={`${reading.index}-${reading.label}`}
title={`Y${reading.index + 1} · ${reading.label}`}
>
<dt>Y{reading.index + 1}</dt>
<dd>
<span
aria-hidden="true"
className="result-chart-cursor-value-swatch"
style={{ backgroundColor: reading.color }}
/>
<span>
{formatCursorNumber(reading.y)} {reading.unit}
</span>
</dd>
</div>
))}
</dl>
</div>
) : null}
</div>
);
}
type StackedCurvePlotProps = {
time: number[];
series: MultiCurveSeries[];
cursorEnabled: boolean;
} & ChartZoomPlotProps;
function StackedCurvePlot({
time,
series,
cursorEnabled,
onViewportChange,
onZoomPrevious,
viewport,
zoomEnabled,
}: StackedCurvePlotProps) {
const bodyRef = useRef<HTMLDivElement>(null);
const cursorPanelRef = useRef<HTMLDivElement>(null);
const cursorPanelDragRef = useRef<CursorPanelDragState | null>(null);
const cursorLineDragRef = useRef<CursorLineDragState | null>(null);
const clipPathBaseId = `result-chart-clip-${useId().replaceAll(":", "")}`;
const [chartSize, setChartSize] = useState({ width: 520, height: 270 });
const [cursorIndex, setCursorIndex] = useState<number | null>(null);
const [cursorPanelPosition, setCursorPanelPosition] =
useState<CursorPanelPosition>({ x: 330, y: 9 });
const plottedSeries = useMemo(
() =>
series
.map((curve) => ({
...curve,
samples: time
.map((timeValue, index) => ({
x: Number(timeValue),
y: Number(curve.values[index]),
dataIndex: index,
}))
.filter(
(sample) =>
Number.isFinite(sample.x) && Number.isFinite(sample.y),
),
}))
.filter((curve) => curve.samples.length >= 2),
[series, time],
);
const cursorSamples = useMemo<CurveSample[]>(
() =>
time
.map((timeValue, index) => ({
x: Number(timeValue),
y: index,
}))
.filter((sample) => Number.isFinite(sample.x)),
[time],
);
useEffect(() => {
const body = bodyRef.current;
if (!body || typeof ResizeObserver === "undefined") {
return;
}
const updateSize = (width: number, height: number) => {
const nextSize = {
width: Math.max(280, Math.round(width)),
height: Math.max(180, Math.round(height)),
};
setChartSize((current) =>
current.width === nextSize.width && current.height === nextSize.height
? current
: nextSize,
);
};
updateSize(body.clientWidth, body.clientHeight);
const observer = new ResizeObserver(([entry]) => {
if (entry) {
updateSize(entry.contentRect.width, entry.contentRect.height);
}
});
observer.observe(body);
return () => observer.disconnect();
}, []);
useEffect(() => {
if (!cursorEnabled || cursorSamples.length === 0) {
return;
}
setCursorIndex((current) =>
current !== null && current < cursorSamples.length
? current
: Math.floor(cursorSamples.length / 2),
);
}, [cursorEnabled, cursorSamples.length]);
useEffect(() => {
const body = bodyRef.current;
if (!cursorEnabled || !body || typeof ResizeObserver === "undefined") {
return;
}
const clampPanel = () => {
const panel = cursorPanelRef.current;
if (!panel) {
return;
}
setCursorPanelPosition((current) =>
clampCursorPanelPosition(
current,
body.clientWidth,
body.clientHeight,
panel.offsetWidth,
panel.offsetHeight,
),
);
};
const animationFrame = window.requestAnimationFrame(clampPanel);
const observer = new ResizeObserver(clampPanel);
observer.observe(body);
return () => {
window.cancelAnimationFrame(animationFrame);
observer.disconnect();
};
}, [cursorEnabled, plottedSeries.length]);
const { width, height } = chartSize;
const margin = {
top: 4,
right: 18,
bottom: 28,
left: 68,
};
const plotWidth = Math.max(40, width - margin.left - margin.right);
const availableHeight = Math.max(80, height - margin.top - margin.bottom);
const bandHeight = availableHeight / Math.max(plottedSeries.length, 1);
const zoomBands = plottedSeries.map((curve, index) => {
const bandTop = margin.top + index * bandHeight;
const plotTop = bandTop + 20;
const plotBottom = Math.max(plotTop + 22, bandTop + bandHeight - 5);
return {
key: curve.key,
rect: {
left: margin.left,
top: plotTop,
right: margin.left + plotWidth,
bottom: plotBottom,
},
};
});
const boxZoom = useChartBoxZoom({
height,
onViewportChange,
onZoomPrevious,
resolveTarget: (point) => {
const band = zoomBands.find((item) =>
pointInsideChartRect(point, item.rect),
);
return band ? { rect: band.rect, yKey: band.key } : null;
},
resolveWheelTarget: (point) =>
resolveStackedWheelZoomTarget(point, zoomBands, height),
viewport,
width,
zoomEnabled,
});
if (plottedSeries.length === 0) {
return (
<div className="result-chart-body" ref={bodyRef}>
<div className="result-chart-empty">无可绘制数据</div>
</div>
);
}
const allSamples = plottedSeries.flatMap((curve) => curve.samples);
const fullXMin = Math.min(...allSamples.map((sample) => sample.x));
const fullXMax = Math.max(...allSamples.map((sample) => sample.x));
const [xMin, xMax] = chartDomainForViewport(fullXMin, fullXMax, viewport.x);
const xRange = Math.max(xMax - xMin, 1e-12);
const xPosition = (value: number) =>
margin.left + ((value - xMin) / xRange) * plotWidth;
const xTicks = chartTicks(xMin, xMax, 5);
const plotBands = plottedSeries.map((curve, index) => {
const rawMinimum = Math.min(...curve.samples.map((sample) => sample.y));
const rawMaximum = Math.max(...curve.samples.map((sample) => sample.y));
const padding =
rawMinimum === rawMaximum
? Math.max(Math.abs(rawMinimum) * 0.05, 1)
: (rawMaximum - rawMinimum) * 0.08;
const fullMinimum = rawMinimum - padding;
const fullMaximum = rawMaximum + padding;
const [minimum, maximum] = chartDomainForViewport(
fullMinimum,
fullMaximum,
viewport.stackedY[curve.key] ?? fullChartViewportRange(),
);
const range = Math.max(maximum - minimum, 1e-12);
const bandTop = margin.top + index * bandHeight;
const plotTop = bandTop + 20;
const plotBottom = Math.max(plotTop + 22, bandTop + bandHeight - 5);
const plotHeight = plotBottom - plotTop;
return {
curve,
index,
bandTop,
clipPathId: `${clipPathBaseId}-${index}`,
minimum,
maximum,
plotTop,
plotBottom,
yTicks: chartTicks(minimum, maximum, bandHeight < 105 ? 2 : 3),
yPosition: (value: number) =>
plotTop + (1 - (value - minimum) / range) * plotHeight,
};
});
const requestedCursorIndex =
cursorIndex === null
? Math.floor(cursorSamples.length / 2)
: Math.min(cursorIndex, cursorSamples.length - 1);
const activeCursorIndex = clampCursorIndexToVisibleX(
cursorSamples,
requestedCursorIndex,
xMin,
xMax,
);
const cursorSample = cursorSamples[activeCursorIndex];
const cursorReadings = cursorSample
? plottedSeries
.map((curve, index) => ({
color: curve.color,
index,
label: curve.label,
unit: curve.unit,
y: Number(curve.values[Math.round(cursorSample.y)]),
}))
.filter((reading) => Number.isFinite(reading.y))
: [];
const cursorTop = plotBands[0].plotTop;
const cursorBottom = plotBands[plotBands.length - 1].plotBottom;
const updateCursorByX = (clientX: number, svg: SVGSVGElement) => {
const bounds = svg.getBoundingClientRect();
if (bounds.width <= 0 || cursorSamples.length === 0) {
return;
}
const svgX = ((clientX - bounds.left) / bounds.width) * width;
const clampedX = Math.min(
width - margin.right,
Math.max(margin.left, svgX),
);
const targetX = xMin + ((clampedX - margin.left) / plotWidth) * xRange;
setCursorIndex(nearestSampleIndex(cursorSamples, targetX));
};
const startCursorLineDrag = (event: ReactPointerEvent<SVGLineElement>) => {
if (!cursorEnabled) {
return;
}
event.preventDefault();
event.stopPropagation();
cursorLineDragRef.current = {
pointerId: event.pointerId,
axis: "x",
};
event.currentTarget.setPointerCapture(event.pointerId);
};
const moveCursorLine = (event: ReactPointerEvent<SVGLineElement>) => {
const drag = cursorLineDragRef.current;
const svg = event.currentTarget.ownerSVGElement;
if (!drag || drag.pointerId !== event.pointerId || !svg) {
return;
}
event.preventDefault();
event.stopPropagation();
updateCursorByX(event.clientX, svg);
};
const stopCursorLineDrag = (event: ReactPointerEvent<SVGLineElement>) => {
if (cursorLineDragRef.current?.pointerId !== event.pointerId) {
return;
}
event.stopPropagation();
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
cursorLineDragRef.current = null;
};
const startCursorPanelDrag = (
event: ReactPointerEvent<HTMLButtonElement>,
) => {
const body = bodyRef.current;
const panel = cursorPanelRef.current;
if (!body || !panel) {
return;
}
event.preventDefault();
event.stopPropagation();
const panelBounds = panel.getBoundingClientRect();
const bodyBounds = body.getBoundingClientRect();
cursorPanelDragRef.current = {
pointerId: event.pointerId,
offsetX: event.clientX - panelBounds.left,
offsetY: event.clientY - panelBounds.top,
};
setCursorPanelPosition({
x: panelBounds.left - bodyBounds.left,
y: panelBounds.top - bodyBounds.top,
});
event.currentTarget.setPointerCapture(event.pointerId);
};
const moveCursorPanel = (event: ReactPointerEvent<HTMLButtonElement>) => {
const drag = cursorPanelDragRef.current;
const body = bodyRef.current;
const panel = cursorPanelRef.current;
if (!drag || drag.pointerId !== event.pointerId || !body || !panel) {
return;
}
event.preventDefault();
event.stopPropagation();
const bodyBounds = body.getBoundingClientRect();
setCursorPanelPosition(
clampCursorPanelPosition(
{
x: event.clientX - bodyBounds.left - drag.offsetX,
y: event.clientY - bodyBounds.top - drag.offsetY,
},
body.clientWidth,
body.clientHeight,
panel.offsetWidth,
panel.offsetHeight,
),
);
};
const stopCursorPanelDrag = (event: ReactPointerEvent<HTMLButtonElement>) => {
if (cursorPanelDragRef.current?.pointerId !== event.pointerId) {
return;
}
event.stopPropagation();
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
cursorPanelDragRef.current = null;
};
const zoomSelection = boxZoom.selection
? chartSelectionRect(boxZoom.selection)
: null;
return (
<div className="result-chart-body stacked" ref={bodyRef}>
<svg
aria-label={`上下曲线对比,包含 ${plottedSeries.length} 个同步子图`}
className={
[
cursorEnabled ? "cursor-enabled" : "",
zoomEnabled ? "zoom-enabled" : "",
]
.filter(Boolean)
.join(" ") || undefined
}
data-result-chart="true"
data-panning={boxZoom.panning}
data-view-x-max={xMax}
data-view-x-min={xMin}
data-view-y-ranges={JSON.stringify(
Object.fromEntries(
plotBands.map((band) => [
band.curve.key,
[band.minimum, band.maximum],
]),
),
)}
data-zoomed={!isDefaultChartViewport(viewport)}
onKeyDown={boxZoom.onKeyDown}
onLostPointerCapture={boxZoom.onLostPointerCapture}
onPointerCancel={boxZoom.onPointerCancel}
onPointerDown={(event) => {
if (zoomEnabled) {
boxZoom.onPointerDown(event);
return;
}
if (cursorEnabled) {
event.preventDefault();
updateCursorByX(event.clientX, event.currentTarget);
}
}}
onPointerMove={boxZoom.onPointerMove}
onPointerUp={boxZoom.onPointerUp}
onWheel={boxZoom.onWheel}
role="img"
tabIndex={0}
viewBox={`0 0 ${width} ${height}`}
>
<defs>
{plotBands.map((band) => (
<clipPath id={band.clipPathId} key={band.clipPathId}>
<rect
height={band.plotBottom - band.plotTop}
width={plotWidth}
x={margin.left}
y={band.plotTop}
/>
</clipPath>
))}
</defs>
{plotBands.map((band) => {
const isLast = band.index === plotBands.length - 1;
const path = segmentedChartPath(
time,
band.curve.values,
xPosition,
band.yPosition,
);
return (
<g className="result-chart-stack-band" key={band.curve.key}>
{band.index > 0 ? (
<line
className="result-chart-stack-divider"
x1={8}
x2={width - 8}
y1={band.bandTop}
y2={band.bandTop}
/>
) : null}
<text
className="result-chart-title"
textAnchor="start"
x={margin.left}
y={band.bandTop + 12}
>
{truncateLegendLabel(
`Y${band.index + 1} ${band.curve.label}`,
plotWidth - 66,
)}
</text>
<text
className="result-chart-unit"
textAnchor="end"
x={width - margin.right}
y={band.bandTop + 12}
>
{band.curve.unit || "无量纲"}
</text>
{band.yTicks.map((tick, tickIndex) => {
const y = band.yPosition(tick);
return (
<g key={`${band.curve.key}-y-${tickIndex}`}>
<line
className="result-chart-grid-line"
x1={margin.left}
x2={margin.left + plotWidth}
y1={y}
y2={y}
/>
<line
className="result-chart-tick"
x1={margin.left - 4}
x2={margin.left}
y1={y}
y2={y}
/>
<text
className="result-chart-label"
textAnchor="end"
x={margin.left - 7}
y={y + 3}
>
{formatChartNumber(tick)}
</text>
</g>
);
})}
{xTicks.map((tick, tickIndex) => {
const x = xPosition(tick);
const textAnchor =
tickIndex === 0
? "start"
: tickIndex === xTicks.length - 1
? "end"
: "middle";
return (
<g key={`${band.curve.key}-x-${tickIndex}`}>
<line
className="result-chart-grid-line"
x1={x}
x2={x}
y1={band.plotTop}
y2={band.plotBottom}
/>
{isLast ? (
<>
<line
className="result-chart-tick"
x1={x}
x2={x}
y1={band.plotBottom}
y2={band.plotBottom + 4}
/>
<text
className="result-chart-label"
textAnchor={textAnchor}
x={x}
y={band.plotBottom + 14}
>
{formatChartNumber(tick)}
</text>
</>
) : null}
</g>
);
})}
<line
className="result-chart-axis"
x1={margin.left}
x2={margin.left}
y1={band.plotTop}
y2={band.plotBottom}
/>
<line
className="result-chart-axis"
x1={margin.left}
x2={margin.left + plotWidth}
y1={band.plotBottom}
y2={band.plotBottom}
/>
<path
clipPath={`url(#${band.clipPathId})`}
d={path}
data-chart-clipped-series="true"
fill="none"
stroke={band.curve.color}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
vectorEffect="non-scaling-stroke"
/>
</g>
);
})}
{cursorEnabled && cursorSample ? (
<>
<g className="result-chart-cursor" pointerEvents="none">
{plotBands.map((band) => {
const reading = cursorReadings.find(
(item) => item.index === band.index,
);
return (
<g
clipPath={`url(#${band.clipPathId})`}
key={`${band.curve.key}-cursor`}
>
<line
x1={xPosition(cursorSample.x)}
x2={xPosition(cursorSample.x)}
y1={band.plotTop}
y2={band.plotBottom}
/>
{reading ? (
<circle
cx={xPosition(cursorSample.x)}
cy={band.yPosition(reading.y)}
fill={reading.color}
r="3.5"
/>
) : null}
</g>
);
})}
</g>
<line
className="result-chart-cursor-hit x"
onLostPointerCapture={stopCursorLineDrag}
onPointerCancel={stopCursorLineDrag}
onPointerDown={startCursorLineDrag}
onPointerMove={moveCursorLine}
onPointerUp={stopCursorLineDrag}
x1={xPosition(cursorSample.x)}
x2={xPosition(cursorSample.x)}
y1={cursorTop}
y2={cursorBottom}
/>
</>
) : null}
{zoomEnabled ? (
<g className="result-chart-zoom-axis-hits">
{plotBands.map((band) => (
<rect
aria-label={`${band.curve.label}纵轴滚轮缩放区`}
className="result-chart-zoom-axis-hit y"
data-chart-zoom-axis="y"
data-y-key={band.curve.key}
fill="transparent"
height={band.plotBottom - band.plotTop}
key={band.curve.key}
width={margin.left}
x={0}
y={band.plotTop}
/>
))}
<rect
aria-label="横轴滚轮缩放区"
className="result-chart-zoom-axis-hit x"
data-chart-zoom-axis="x"
fill="transparent"
height={Math.max(0, height - cursorBottom)}
width={plotWidth}
x={margin.left}
y={cursorBottom}
/>
</g>
) : null}
{zoomSelection ? (
<rect
className="result-chart-zoom-selection"
height={zoomSelection.height}
pointerEvents="none"
width={zoomSelection.width}
x={zoomSelection.x}
y={zoomSelection.y}
/>
) : null}
<text
className="result-chart-axis-title"
textAnchor="middle"
x={margin.left + plotWidth / 2}
y={height - 4}
>
时间 (s)
</text>
</svg>
{cursorEnabled && cursorSample ? (
<div
className="result-chart-cursor-panel multi"
onPointerDown={(event) => event.stopPropagation()}
ref={cursorPanelRef}
style={{
left: cursorPanelPosition.x,
top: cursorPanelPosition.y,
}}
>
<button
aria-label="移动上下曲线游标读数窗口"
className="result-chart-cursor-panel-header"
onLostPointerCapture={stopCursorPanelDrag}
onPointerCancel={stopCursorPanelDrag}
onPointerDown={startCursorPanelDrag}
onPointerMove={moveCursorPanel}
onPointerUp={stopCursorPanelDrag}
title="移动游标读数"
type="button"
>
<Move aria-hidden="true" size={13} strokeWidth={1.8} />
<span>同步游标</span>
</button>
<dl>
<div>
<dt>X</dt>
<dd>{formatCursorNumber(cursorSample.x)} s</dd>
</div>
{cursorReadings.map((reading) => (
<div key={`${reading.index}-${reading.label}`}>
<dt>Y{reading.index + 1}</dt>
<dd>
<span
aria-hidden="true"
className="result-chart-cursor-value-swatch"
style={{ backgroundColor: reading.color }}
/>
<span>
{formatCursorNumber(reading.y)} {reading.unit}
</span>
</dd>
</div>
))}
</dl>
</div>
) : null}
</div>
);
}
type CurvePlotProps = {
time: number[];
values: number[];
title: string;
unit: string;
color: string;
cursorEnabled: boolean;
} & ChartZoomPlotProps;
type CurveSample = {
x: number;
y: number;
};
type CursorPanelPosition = {
x: number;
y: number;
};
type CursorPanelDragState = {
pointerId: number;
offsetX: number;
offsetY: number;
};
type CursorLineDragState = {
pointerId: number;
axis: "x" | "y";
};
function CurvePlot({
time,
values,
title,
unit,
color,
cursorEnabled,
onViewportChange,
onZoomPrevious,
viewport,
zoomEnabled,
}: CurvePlotProps) {
const bodyRef = useRef<HTMLDivElement>(null);
const cursorPanelRef = useRef<HTMLDivElement>(null);
const cursorPanelDragRef = useRef<CursorPanelDragState | null>(null);
const cursorLineDragRef = useRef<CursorLineDragState | null>(null);
const clipPathId = `result-chart-clip-${useId().replaceAll(":", "")}`;
const [chartSize, setChartSize] = useState({ width: 400, height: 190 });
const [cursorIndex, setCursorIndex] = useState<number | null>(null);
const [cursorPanelPosition, setCursorPanelPosition] =
useState<CursorPanelPosition>({ x: 238, y: 9 });
const samples = useMemo<CurveSample[]>(
() =>
time
.map((timeValue, index) => ({
x: Number(timeValue),
y: Number(values[index]),
}))
.filter(
(sample) => Number.isFinite(sample.x) && Number.isFinite(sample.y),
),
[time, values],
);
useEffect(() => {
const body = bodyRef.current;
if (!body || typeof ResizeObserver === "undefined") {
return;
}
const updateSize = (width: number, height: number) => {
const nextSize = {
width: Math.max(280, Math.round(width)),
height: Math.max(150, Math.round(height)),
};
setChartSize((current) =>
current.width === nextSize.width && current.height === nextSize.height
? current
: nextSize,
);
};
updateSize(body.clientWidth, body.clientHeight);
const observer = new ResizeObserver(([entry]) => {
if (entry) {
updateSize(entry.contentRect.width, entry.contentRect.height);
}
});
observer.observe(body);
return () => observer.disconnect();
}, []);
useEffect(() => {
if (!cursorEnabled || samples.length === 0) {
return;
}
setCursorIndex((current) =>
current !== null && current < samples.length
? current
: Math.floor(samples.length / 2),
);
}, [cursorEnabled, samples.length]);
useEffect(() => {
const body = bodyRef.current;
if (!cursorEnabled || !body || typeof ResizeObserver === "undefined") {
return;
}
const clampPanel = () => {
const panel = cursorPanelRef.current;
if (!panel) {
return;
}
setCursorPanelPosition((current) =>
clampCursorPanelPosition(
current,
body.clientWidth,
body.clientHeight,
panel.offsetWidth,
panel.offsetHeight,
),
);
};
const animationFrame = window.requestAnimationFrame(clampPanel);
const observer = new ResizeObserver(clampPanel);
observer.observe(body);
return () => {
window.cancelAnimationFrame(animationFrame);
observer.disconnect();
};
}, [cursorEnabled]);
const { width, height } = chartSize;
const margin = { top: 24, right: 18, bottom: 40, left: 68 };
const plotWidth = Math.max(40, width - margin.left - margin.right);
const plotHeight = Math.max(30, height - margin.top - margin.bottom);
const plotRect: ChartPlotRect = {
left: margin.left,
top: margin.top,
right: margin.left + plotWidth,
bottom: margin.top + plotHeight,
};
const boxZoom = useChartBoxZoom({
height,
onViewportChange,
onZoomPrevious,
resolveTarget: (point) =>
pointInsideChartRect(point, plotRect) ? { rect: plotRect } : null,
resolveWheelTarget: (point) =>
resolveCartesianWheelZoomTarget(point, plotRect, height),
viewport,
width,
zoomEnabled,
});
if (samples.length < 2) {
return (
<div className="result-chart-body" ref={bodyRef}>
<div className="result-chart-empty">无可绘制数据</div>
</div>
);
}
const fullXMin = Math.min(...samples.map((sample) => sample.x));
const fullXMax = Math.max(...samples.map((sample) => sample.x));
const [xMin, xMax] = chartDomainForViewport(fullXMin, fullXMax, viewport.x);
const rawYMin = Math.min(...samples.map((sample) => sample.y));
const rawYMax = Math.max(...samples.map((sample) => sample.y));
const yPadding =
rawYMin === rawYMax
? Math.max(Math.abs(rawYMin) * 0.05, 1)
: (rawYMax - rawYMin) * 0.08;
const [yMin, yMax] = chartDomainForViewport(
rawYMin - yPadding,
rawYMax + yPadding,
viewport.y,
);
const xRange = Math.max(xMax - xMin, 1e-12);
const yRange = Math.max(yMax - yMin, 1e-12);
const xPosition = (value: number) =>
margin.left + ((value - xMin) / xRange) * plotWidth;
const yPosition = (value: number) =>
margin.top + (1 - (value - yMin) / yRange) * plotHeight;
const xTicks = chartTicks(xMin, xMax, 5);
const yTicks = chartTicks(yMin, yMax, 5);
const path = segmentedChartPath(
time,
values,
xPosition,
yPosition,
);
const requestedCursorIndex =
cursorIndex === null
? Math.floor(samples.length / 2)
: Math.min(cursorIndex, samples.length - 1);
const activeCursorIndex = clampCursorIndexToVisibleX(
samples,
requestedCursorIndex,
xMin,
xMax,
);
const cursorSample = samples[activeCursorIndex];
const updateCursorByX = (clientX: number, svg: SVGSVGElement) => {
const bounds = svg.getBoundingClientRect();
if (bounds.width <= 0) {
return;
}
const svgX = ((clientX - bounds.left) / bounds.width) * width;
const clampedX = Math.min(
width - margin.right,
Math.max(margin.left, svgX),
);
const targetX = xMin + ((clampedX - margin.left) / plotWidth) * xRange;
setCursorIndex(nearestSampleIndex(samples, targetX));
};
const updateCursorByY = (clientY: number, svg: SVGSVGElement) => {
const bounds = svg.getBoundingClientRect();
if (bounds.height <= 0) {
return;
}
const svgY = ((clientY - bounds.top) / bounds.height) * height;
const clampedY = Math.min(
height - margin.bottom,
Math.max(margin.top, svgY),
);
const targetY = yMax - ((clampedY - margin.top) / plotHeight) * yRange;
setCursorIndex((current) =>
nearestSampleIndexByY(samples, targetY, current ?? activeCursorIndex),
);
};
const startCursorLineDrag = (
event: ReactPointerEvent<SVGLineElement>,
axis: CursorLineDragState["axis"],
) => {
if (!cursorEnabled) {
return;
}
event.preventDefault();
event.stopPropagation();
cursorLineDragRef.current = {
pointerId: event.pointerId,
axis,
};
event.currentTarget.setPointerCapture(event.pointerId);
};
const moveCursorLine = (event: ReactPointerEvent<SVGLineElement>) => {
const drag = cursorLineDragRef.current;
const svg = event.currentTarget.ownerSVGElement;
if (!drag || drag.pointerId !== event.pointerId || !svg) {
return;
}
event.preventDefault();
event.stopPropagation();
if (drag.axis === "x") {
updateCursorByX(event.clientX, svg);
} else {
updateCursorByY(event.clientY, svg);
}
};
const stopCursorLineDrag = (event: ReactPointerEvent<SVGLineElement>) => {
if (cursorLineDragRef.current?.pointerId !== event.pointerId) {
return;
}
event.stopPropagation();
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
cursorLineDragRef.current = null;
};
const startCursorPanelDrag = (
event: ReactPointerEvent<HTMLButtonElement>,
) => {
const body = bodyRef.current;
const panel = cursorPanelRef.current;
if (!body || !panel) {
return;
}
event.preventDefault();
event.stopPropagation();
const panelBounds = panel.getBoundingClientRect();
const bodyBounds = body.getBoundingClientRect();
cursorPanelDragRef.current = {
pointerId: event.pointerId,
offsetX: event.clientX - panelBounds.left,
offsetY: event.clientY - panelBounds.top,
};
setCursorPanelPosition({
x: panelBounds.left - bodyBounds.left,
y: panelBounds.top - bodyBounds.top,
});
event.currentTarget.setPointerCapture(event.pointerId);
};
const moveCursorPanel = (event: ReactPointerEvent<HTMLButtonElement>) => {
const drag = cursorPanelDragRef.current;
const body = bodyRef.current;
const panel = cursorPanelRef.current;
if (!drag || drag.pointerId !== event.pointerId || !body || !panel) {
return;
}
event.preventDefault();
event.stopPropagation();
const bodyBounds = body.getBoundingClientRect();
setCursorPanelPosition(
clampCursorPanelPosition(
{
x: event.clientX - bodyBounds.left - drag.offsetX,
y: event.clientY - bodyBounds.top - drag.offsetY,
},
body.clientWidth,
body.clientHeight,
panel.offsetWidth,
panel.offsetHeight,
),
);
};
const stopCursorPanelDrag = (event: ReactPointerEvent<HTMLButtonElement>) => {
if (cursorPanelDragRef.current?.pointerId !== event.pointerId) {
return;
}
event.stopPropagation();
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
cursorPanelDragRef.current = null;
};
const zoomSelection = boxZoom.selection
? chartSelectionRect(boxZoom.selection)
: null;
return (
<div className="result-chart-body" ref={bodyRef}>
<svg
aria-label={`${title} 随时间变化曲线`}
className={
[
cursorEnabled ? "cursor-enabled" : "",
zoomEnabled ? "zoom-enabled" : "",
]
.filter(Boolean)
.join(" ") || undefined
}
data-result-chart="true"
data-panning={boxZoom.panning}
data-view-x-max={xMax}
data-view-x-min={xMin}
data-view-y-max={yMax}
data-view-y-min={yMin}
data-zoomed={!isDefaultChartViewport(viewport)}
onKeyDown={boxZoom.onKeyDown}
onLostPointerCapture={boxZoom.onLostPointerCapture}
onPointerCancel={boxZoom.onPointerCancel}
onPointerDown={(event) => {
if (zoomEnabled) {
boxZoom.onPointerDown(event);
return;
}
if (cursorEnabled) {
event.preventDefault();
updateCursorByX(event.clientX, event.currentTarget);
}
}}
onPointerMove={boxZoom.onPointerMove}
onPointerUp={boxZoom.onPointerUp}
onWheel={boxZoom.onWheel}
role="img"
tabIndex={0}
viewBox={`0 0 ${width} ${height}`}
>
<defs>
<clipPath id={clipPathId}>
<rect
height={plotHeight}
width={plotWidth}
x={margin.left}
y={margin.top}
/>
</clipPath>
</defs>
<text
className="result-chart-title"
textAnchor="start"
x={margin.left}
y={13}
>
{title}
</text>
<text
className="result-chart-unit"
textAnchor="end"
x={width - margin.right}
y={13}
>
{unit || "无量纲"}
</text>
{yTicks.map((tick, index) => {
const y = yPosition(tick);
return (
<g key={`y-${index}`}>
<line
className="result-chart-grid-line"
x1={margin.left}
x2={width - margin.right}
y1={y}
y2={y}
/>
<line
className="result-chart-tick"
x1={margin.left - 4}
x2={margin.left}
y1={y}
y2={y}
/>
<text
className="result-chart-label"
textAnchor="end"
x={margin.left - 7}
y={y + 3}
>
{formatChartNumber(tick)}
</text>
</g>
);
})}
{xTicks.map((tick, index) => {
const x = xPosition(tick);
const textAnchor =
index === 0
? "start"
: index === xTicks.length - 1
? "end"
: "middle";
return (
<g key={`x-${index}`}>
<line
className="result-chart-grid-line"
x1={x}
x2={x}
y1={margin.top}
y2={height - margin.bottom}
/>
<line
className="result-chart-tick"
x1={x}
x2={x}
y1={height - margin.bottom}
y2={height - margin.bottom + 4}
/>
<text
className="result-chart-label"
textAnchor={textAnchor}
x={x}
y={height - margin.bottom + 15}
>
{formatChartNumber(tick)}
</text>
</g>
);
})}
<line
className="result-chart-axis"
x1={margin.left}
x2={margin.left}
y1={margin.top}
y2={height - margin.bottom}
/>
<line
className="result-chart-axis"
x1={margin.left}
x2={width - margin.right}
y1={height - margin.bottom}
y2={height - margin.bottom}
/>
<path
clipPath={`url(#${clipPathId})`}
d={path}
data-chart-clipped-series="true"
fill="none"
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
vectorEffect="non-scaling-stroke"
/>
{cursorEnabled && cursorSample ? (
<>
<g
className="result-chart-cursor"
clipPath={`url(#${clipPathId})`}
pointerEvents="none"
>
<line
x1={xPosition(cursorSample.x)}
x2={xPosition(cursorSample.x)}
y1={margin.top}
y2={height - margin.bottom}
/>
<line
x1={margin.left}
x2={width - margin.right}
y1={yPosition(cursorSample.y)}
y2={yPosition(cursorSample.y)}
/>
<circle
cx={xPosition(cursorSample.x)}
cy={yPosition(cursorSample.y)}
fill={color}
r="3.5"
/>
</g>
<line
className="result-chart-cursor-hit x"
onLostPointerCapture={stopCursorLineDrag}
onPointerCancel={stopCursorLineDrag}
onPointerDown={(event) => startCursorLineDrag(event, "x")}
onPointerMove={moveCursorLine}
onPointerUp={stopCursorLineDrag}
x1={xPosition(cursorSample.x)}
x2={xPosition(cursorSample.x)}
y1={margin.top}
y2={height - margin.bottom}
/>
<line
className="result-chart-cursor-hit y"
onLostPointerCapture={stopCursorLineDrag}
onPointerCancel={stopCursorLineDrag}
onPointerDown={(event) => startCursorLineDrag(event, "y")}
onPointerMove={moveCursorLine}
onPointerUp={stopCursorLineDrag}
x1={margin.left}
x2={width - margin.right}
y1={yPosition(cursorSample.y)}
y2={yPosition(cursorSample.y)}
/>
</>
) : null}
{zoomEnabled ? (
<g className="result-chart-zoom-axis-hits">
<rect
aria-label="纵轴滚轮缩放区"
className="result-chart-zoom-axis-hit y"
data-chart-zoom-axis="y"
fill="transparent"
height={plotHeight}
width={margin.left}
x={0}
y={margin.top}
/>
<rect
aria-label="横轴滚轮缩放区"
className="result-chart-zoom-axis-hit x"
data-chart-zoom-axis="x"
fill="transparent"
height={Math.max(0, height - plotRect.bottom)}
width={plotWidth}
x={margin.left}
y={plotRect.bottom}
/>
</g>
) : null}
{zoomSelection ? (
<rect
className="result-chart-zoom-selection"
height={zoomSelection.height}
pointerEvents="none"
width={zoomSelection.width}
x={zoomSelection.x}
y={zoomSelection.y}
/>
) : null}
<text
className="result-chart-axis-title"
textAnchor="middle"
x={margin.left + plotWidth / 2}
y={height - 5}
>
时间 (s)
</text>
</svg>
{cursorEnabled && cursorSample ? (
<div
className="result-chart-cursor-panel"
onPointerDown={(event) => event.stopPropagation()}
ref={cursorPanelRef}
style={{
left: cursorPanelPosition.x,
top: cursorPanelPosition.y,
}}
>
<button
aria-label="移动游标读数窗口"
className="result-chart-cursor-panel-header"
onLostPointerCapture={stopCursorPanelDrag}
onPointerCancel={stopCursorPanelDrag}
onPointerDown={startCursorPanelDrag}
onPointerMove={moveCursorPanel}
onPointerUp={stopCursorPanelDrag}
title="移动游标读数"
type="button"
>
<Move aria-hidden="true" size={13} strokeWidth={1.8} />
<span>游标</span>
</button>
<dl>
<div>
<dt>X</dt>
<dd>{formatCursorNumber(cursorSample.x)} s</dd>
</div>
<div>
<dt>Y</dt>
<dd>
{formatCursorNumber(cursorSample.y)} {unit}
</dd>
</div>
</dl>
</div>
) : null}
</div>
);
}
function nearestSampleIndex(samples: CurveSample[], targetX: number) {
let low = 0;
let high = samples.length - 1;
while (low < high) {
const middle = Math.floor((low + high) / 2);
if (samples[middle].x < targetX) {
low = middle + 1;
} else {
high = middle;
}
}
if (low === 0) {
return 0;
}
return Math.abs(samples[low].x - targetX) <
Math.abs(samples[low - 1].x - targetX)
? low
: low - 1;
}
function clampCursorIndexToVisibleX(
samples: CurveSample[],
requestedIndex: number,
xMinimum: number,
xMaximum: number,
) {
if (samples.length === 0) {
return 0;
}
const first = nearestSampleIndex(samples, xMinimum);
const last = nearestSampleIndex(samples, xMaximum);
return Math.min(
Math.max(first, last),
Math.max(Math.min(first, last), requestedIndex),
);
}
function nearestSampleIndexByY(
samples: CurveSample[],
targetY: number,
currentIndex: number,
) {
let nearestIndex = 0;
let nearestDistance = Number.POSITIVE_INFINITY;
let nearestIndexDistance = Number.POSITIVE_INFINITY;
samples.forEach((sample, index) => {
const distance = Math.abs(sample.y - targetY);
const indexDistance = Math.abs(index - currentIndex);
if (
distance < nearestDistance ||
(distance === nearestDistance && indexDistance < nearestIndexDistance)
) {
nearestIndex = index;
nearestDistance = distance;
nearestIndexDistance = indexDistance;
}
});
return nearestIndex;
}
function clampCursorPanelPosition(
position: CursorPanelPosition,
containerWidth: number,
containerHeight: number,
panelWidth: number,
panelHeight: number,
) {
const margin = 6;
return {
x: Math.round(
Math.min(
Math.max(margin, position.x),
Math.max(margin, containerWidth - panelWidth - margin),
),
),
y: Math.round(
Math.min(
Math.max(margin, position.y),
Math.max(margin, containerHeight - panelHeight - margin),
),
),
};
}
function resultVariablesForNode(
variables: ResultVariableMetadata[],
series: Record<string, number[]>,
nodeId: string,
) {
return variables
.filter((variable) => {
const values = series[variable.key];
return (
variable.componentId === nodeId &&
Array.isArray(values) &&
values.length > 0
);
})
.sort((first, second) => {
if (first.scope !== second.scope) {
return first.scope === "component" ? -1 : 1;
}
const portOrder = (first.portName ?? "").localeCompare(
second.portName ?? "",
"zh-CN",
);
if (portOrder !== 0) {
return portOrder;
}
return (
first.order - second.order ||
first.label.localeCompare(second.label, "zh-CN")
);
});
}
function resultVariableLabel(variable: ResultVariableMetadata) {
return variable.scope === "port" && variable.portName
? `${variable.portName} · ${variable.label}`
: variable.label;
}
function resultVariableTitle(
variable: ResultVariableMetadata,
componentName: string,
) {
const variablePath = variable.portName
? `${variable.portName}.${variable.name}`
: variable.name;
return `${componentName}.${variablePath}`;
}
function scaledResultUnit(
value: string,
label: string,
baseUnitsPerDisplayUnit: number,
): ResultUnitOption {
return {
value,
label,
fromBase: (baseValue) => baseValue / baseUnitsPerDisplayUnit,
};
}
function resultUnitOptions(variable: ResultVariableMetadata) {
return (
RESULT_UNIT_OPTIONS[variable.quantity] ?? [
scaledResultUnit(variable.unit, variable.unit || "无量纲", 1),
]
);
}
function resolveResultUnitOption(
variable: ResultVariableMetadata,
requestedUnit: string,
) {
const options = resultUnitOptions(variable);
return (
options.find((option) => option.value === requestedUnit) ??
options.find((option) => option.value === variable.unit) ??
options[0]
);
}
function chartTicks(minimum: number, maximum: number, intervalCount: number) {
const range = maximum - minimum;
return Array.from(
{ length: intervalCount + 1 },
(_, index) => minimum + (range * index) / intervalCount,
);
}
function truncateLegendLabel(label: string, availableWidth: number) {
const maximumCharacters = Math.max(7, Math.floor(availableWidth / 5.8));
return label.length <= maximumCharacters
? label
: `${label.slice(0, maximumCharacters - 1)}…`;
}
async function downloadChartImage(
button: HTMLButtonElement,
filenameStem: string,
) {
const chartWindow = button.closest(".result-chart-window");
if (!(chartWindow instanceof HTMLElement)) {
throw new Error("没有找到可导出的曲线窗口。");
}
const chartSurface = chartWindow.querySelector(".result-chart-body");
if (!(chartSurface instanceof HTMLElement)) {
throw new Error("没有找到可导出的绘图区。");
}
downloadBlob(
await chartSurfaceToPngBlob(chartSurface),
`${safeFileStem(filenameStem)}.png`,
);
}
async function chartSurfaceToPngBlob(chartSurface: HTMLElement) {
const { default: html2canvas } = await import("html2canvas");
const canvas = await html2canvas(chartSurface, {
backgroundColor: "#ffffff",
imageTimeout: 0,
logging: false,
removeContainer: true,
scale: Math.min(Math.max(window.devicePixelRatio || 1, 2), 3),
useCORS: true,
});
return await canvasBlob(canvas, "image/png");
}
function canvasBlob(canvas: HTMLCanvasElement, type: string) {
return new Promise<Blob>((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) {
resolve(blob);
} else {
reject(new Error("曲线图片生成失败。"));
}
}, type);
});
}
function createSimulationResultFile(
snapshot: SimulationResultsSnapshot,
): SimulationResultFile {
const lastSeriesTime = snapshot.result.series.time?.at(-1);
const simulatedUntil = Number.isFinite(snapshot.result.simulatedUntil)
? Number(snapshot.result.simulatedUntil)
: Number.isFinite(lastSeriesTime)
? Number(lastSeriesTime)
: null;
return {
fileType: RESULT_FILE_TYPE,
formatVersion: RESULT_FILE_FORMAT_VERSION,
exportedAt: new Date().toISOString(),
metadata: {
projectName: snapshot.project.name,
resultCreatedAt: snapshot.createdAt,
simulation: {
...snapshot.project.simulation,
simulatedUntil,
},
resultStatus: simulationResultStatus(snapshot.result),
componentCount: snapshot.project.nodes.length,
connectionCount: snapshot.project.edges.length,
sampleCount: snapshot.result.diagnostics.sampleCount,
componentParameters: snapshot.project.nodes.map((node) => ({
id: node.id,
name: node.data.label,
componentType: node.data.componentType,
parameters: { ...node.data.parameters },
parameterUnits: { ...(node.data.parameterUnits ?? {}) },
})),
},
snapshot,
};
}
function downloadSimulationResultFile(snapshot: SimulationResultsSnapshot) {
const payload = createSimulationResultFile(snapshot);
const timestamp = compactFileTimestamp(payload.exportedAt);
downloadBlob(
new Blob([JSON.stringify(payload)], { type: RESULT_FILE_MIME }),
`${safeFileStem(snapshot.project.name)}-${timestamp}.simresult`,
);
}
function parseSimulationResultFile(raw: string): SimulationResultsSnapshot {
let value: unknown;
try {
value = JSON.parse(raw);
} catch {
throw new Error("文件不是有效的 System Simulation 结果文件。");
}
if (!isRecord(value) || value.fileType !== RESULT_FILE_TYPE) {
throw new Error("文件类型不正确,请选择 .simresult 文件。");
}
if (value.formatVersion !== RESULT_FILE_FORMAT_VERSION) {
throw new Error(`不支持的结果文件版本:${String(value.formatVersion)}`);
}
if (
!isRecord(value.metadata) ||
!isSimulationResultsSnapshotValue(value.snapshot)
) {
throw new Error("结果文件内容不完整或已经损坏。");
}
return {
...value.snapshot,
id: `imported-${Date.now()}-${Math.random().toString(16).slice(2)}`,
};
}
function isSimulationResultsSnapshotValue(
value: unknown,
): value is SimulationResultsSnapshot {
if (
!isRecord(value) ||
typeof value.id !== "string" ||
typeof value.createdAt !== "string"
) {
return false;
}
const project = value.project;
const result = value.result;
if (
!isRecord(project) ||
project.projectSchemaVersion !== 1 ||
typeof project.name !== "string" ||
!Array.isArray(project.nodes) ||
!project.nodes.every(isResultProjectNode) ||
!Array.isArray(project.edges) ||
!project.edges.every(isResultProjectEdge) ||
!isResultSimulationConfig(project.simulation) ||
!isRecord(result) ||
typeof result.success !== "boolean" ||
typeof result.message !== "string" ||
!Array.isArray(result.variables) ||
!result.variables.every(isResultVariableMetadata) ||
!isRecord(result.final) ||
!isNumericRecord(result.final) ||
!isRecord(result.series) ||
!Object.values(result.series).every(isNumericArray) ||
!Array.isArray(result.series.time) ||
!isRecord(result.diagnostics) ||
typeof result.diagnostics.sampleCount !== "number"
) {
return false;
}
return (
result.status === undefined ||
["completed", "stopped", "stalled", "failed"].includes(
String(result.status),
)
);
}
function isResultProjectNode(value: unknown): value is ResultProjectNode {
if (!isRecord(value) || !isRecord(value.position) || !isRecord(value.data)) {
return false;
}
const data = value.data;
return (
typeof value.id === "string" &&
value.type === "simulationComponent" &&
Number.isFinite(value.position.x) &&
Number.isFinite(value.position.y) &&
typeof data.label === "string" &&
typeof data.componentType === "string" &&
typeof data.modelType === "string" &&
Array.isArray(data.ports) &&
data.ports.every(isResultPortDefinition) &&
isRecord(data.parameters) &&
(data.parameterUnits === undefined || isRecord(data.parameterUnits)) &&
[0, 90, 180, 270].includes(Number(data.rotation)) &&
typeof data.mirrored === "boolean"
);
}
function isResultPortDefinition(value: unknown): value is ResultPortDefinition {
return (
isRecord(value) &&
typeof value.name === "string" &&
value.name.length > 0 &&
typeof value.kind === "string" &&
typeof value.domain === "string" &&
typeof value.nominalRole === "string" &&
(value.positiveFlowDirection === undefined ||
typeof value.positiveFlowDirection === "string") &&
(value.side === "left" || value.side === "right")
);
}
function isResultProjectEdge(value: unknown): value is ResultProjectEdge {
return (
isRecord(value) &&
typeof value.id === "string" &&
typeof value.source === "string" &&
typeof value.target === "string" &&
typeof value.sourceHandle === "string" &&
value.sourceHandle.length > 0 &&
typeof value.targetHandle === "string" &&
value.targetHandle.length > 0 &&
isRecord(value.data) &&
typeof value.data.isContactEdge === "boolean" &&
(value.data.routePoints === undefined ||
(Array.isArray(value.data.routePoints) &&
value.data.routePoints.every(edgePointIsFinite)))
);
}
function isResultSimulationConfig(
value: unknown,
): value is ResultSimulationConfig {
return (
isRecord(value) &&
Number.isFinite(value.t_start) &&
Number.isFinite(value.t_stop) &&
Number.isFinite(value.step) &&
Number.isFinite(value.max_step) &&
typeof value.method === "string"
);
}
function isResultVariableMetadata(
value: unknown,
): value is ResultVariableMetadata {
return (
isRecord(value) &&
typeof value.key === "string" &&
typeof value.componentId === "string" &&
typeof value.componentType === "string" &&
(value.scope === "component" || value.scope === "port") &&
(value.portName === null || typeof value.portName === "string") &&
typeof value.name === "string" &&
typeof value.label === "string" &&
typeof value.quantity === "string" &&
typeof value.unit === "string" &&
typeof value.category === "string" &&
Number.isFinite(value.order)
);
}
function isNumericRecord(value: Record<string, unknown>) {
return Object.values(value).every(
(item) => typeof item === "number" && Number.isFinite(item),
);
}
function isNumericArray(value: unknown): value is number[] {
return (
Array.isArray(value) &&
value.every((item) => typeof item === "number" && Number.isFinite(item))
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function compactFileTimestamp(value: string) {
const timestamp = new Date(value);
if (Number.isNaN(timestamp.getTime())) {
return "result";
}
const pad = (part: number) => String(part).padStart(2, "0");
return `${timestamp.getFullYear()}${pad(timestamp.getMonth() + 1)}${pad(
timestamp.getDate(),
)}-${pad(timestamp.getHours())}${pad(timestamp.getMinutes())}${pad(
timestamp.getSeconds(),
)}`;
}
function responseDownloadFilename(response: Response, fallback: string) {
const disposition = response.headers.get("Content-Disposition") ?? "";
const encoded = /filename\*=UTF-8''([^;]+)/i.exec(disposition)?.[1];
if (encoded) {
try {
return decodeURIComponent(encoded);
} catch {
return fallback;
}
}
return /filename="([^"]+)"/i.exec(disposition)?.[1] ?? fallback;
}
async function responseErrorMessage(response: Response) {
try {
const payload = (await response.json()) as { detail?: unknown };
if (typeof payload.detail === "string") {
return payload.detail;
}
} catch {
// Use the HTTP status when the server did not return a JSON error body.
}
return `${response.status} ${response.statusText}`.trim();
}
function errorMessage(error: unknown) {
return error instanceof Error ? error.message : String(error);
}
function safeFileStem(value: string) {
const cleaned = value
.trim()
.replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_")
.replace(/[. ]+$/g, "");
return cleaned.slice(0, 80) || "simulation";
}
function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.style.display = "none";
document.body.append(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function colorForVariable(key: string) {
let hash = 0;
for (const character of key) {
hash = (hash * 31 + character.charCodeAt(0)) >>> 0;
}
return CHART_COLORS[hash % CHART_COLORS.length];
}
function defaultResultsPaneLayout(): ResultsPaneLayout {
const viewportWidth =
typeof window === "undefined" ? 1440 : window.innerWidth;
const viewportHeight =
typeof window === "undefined" ? 900 : window.innerHeight;
return {
inspectorWidth: Math.round(
Math.min(560, Math.max(380, viewportWidth * 0.31)),
),
systemHeight: Math.round(
Math.min(580, Math.max(260, (viewportHeight - 62) * 0.54)),
),
};
}
function loadResultSystemViewport(snapshotId: string): Viewport | null {
try {
const raw = sessionStorage.getItem(
`${RESULT_SYSTEM_VIEWPORT_KEY_PREFIX}${snapshotId}`,
);
if (!raw) {
return null;
}
const value = JSON.parse(raw) as Partial<Viewport>;
if (
typeof value.x !== "number" ||
!Number.isFinite(value.x) ||
typeof value.y !== "number" ||
!Number.isFinite(value.y) ||
typeof value.zoom !== "number" ||
!Number.isFinite(value.zoom) ||
value.zoom <= 0
) {
return null;
}
return { x: value.x, y: value.y, zoom: value.zoom };
} catch {
return null;
}
}
function storeResultSystemViewport(snapshotId: string, viewport: Viewport) {
try {
sessionStorage.setItem(
`${RESULT_SYSTEM_VIEWPORT_KEY_PREFIX}${snapshotId}`,
JSON.stringify(viewport),
);
} catch {
// The active result canvas still keeps its viewport in React Flow state.
}
}
function loadResultsPaneLayout(): ResultsPaneLayout {
const defaults = defaultResultsPaneLayout();
try {
const raw = localStorage.getItem(RESULT_PANE_LAYOUT_KEY);
if (!raw) {
return defaults;
}
const value = JSON.parse(raw) as Partial<ResultsPaneLayout>;
return {
inspectorWidth:
typeof value.inspectorWidth === "number" &&
Number.isFinite(value.inspectorWidth)
? value.inspectorWidth
: defaults.inspectorWidth,
systemHeight:
typeof value.systemHeight === "number" &&
Number.isFinite(value.systemHeight)
? value.systemHeight
: defaults.systemHeight,
};
} catch {
return defaults;
}
}
function storeResultsPaneLayout(layout: ResultsPaneLayout) {
try {
localStorage.setItem(RESULT_PANE_LAYOUT_KEY, JSON.stringify(layout));
} catch {
// Keep the active layout in memory when browser storage is unavailable.
}
}
function clampResultsPaneLayout(
layout: ResultsPaneLayout,
workbenchWidth: number,
workbenchHeight: number,
): ResultsPaneLayout {
const maximumInspectorWidth = Math.max(
MIN_INSPECTOR_WIDTH,
workbenchWidth - PANE_SPLITTER_SIZE - MIN_CHART_WIDTH,
);
const maximumSystemHeight = Math.max(
MIN_SYSTEM_HEIGHT,
workbenchHeight - PANE_SPLITTER_SIZE - MIN_VARIABLE_HEIGHT,
);
return {
inspectorWidth: Math.round(
Math.min(
maximumInspectorWidth,
Math.max(MIN_INSPECTOR_WIDTH, layout.inspectorWidth),
),
),
systemHeight: Math.round(
Math.min(
maximumSystemHeight,
Math.max(MIN_SYSTEM_HEIGHT, layout.systemHeight),
),
),
};
}
function sameResultsPaneLayout(
first: ResultsPaneLayout,
second: ResultsPaneLayout,
) {
return (
first.inspectorWidth === second.inspectorWidth &&
first.systemHeight === second.systemHeight
);
}
function clampWindowCoordinate(
value: number,
containerSize: number,
windowSize: number,
) {
return Math.min(
Math.max(12, value),
Math.max(12, containerSize - windowSize - 12),
);
}
function clampChartWindowDimension(
requestedSize: number,
availableSize: number,
minimumSize: number,
) {
const maximumSize = Math.max(1, availableSize);
const effectiveMinimum = Math.min(minimumSize, maximumSize);
return Math.round(
Math.min(maximumSize, Math.max(effectiveMinimum, requestedSize)),
);
}
function clampChartWindowDimensions(
requestedWidth: number,
requestedHeight: number,
workspaceWidth: number,
workspaceHeight: number,
) {
return {
width: clampChartWindowDimension(
requestedWidth,
workspaceWidth - 24,
MIN_CHART_WINDOW_WIDTH,
),
height: clampChartWindowDimension(
requestedHeight,
workspaceHeight - 24,
MIN_CHART_WINDOW_HEIGHT,
),
};
}
function loadChartWindows(snapshotId: string): ChartWindow[] {
try {
const raw = sessionStorage.getItem(
`${RESULT_LAYOUT_KEY_PREFIX}${snapshotId}`,
);
if (!raw) {
return [];
}
const value = JSON.parse(raw) as unknown;
if (!Array.isArray(value)) {
return [];
}
return value.filter(isStoredChartWindow).map(normalizeStoredChartWindow);
} catch {
return [];
}
}
function nextChartWindowNumber(windows: ChartWindow[]) {
return (
Math.max(
0,
...windows.map((window) => {
const match = /^chart-(\d+)$/.exec(window.id);
return match ? Number(match[1]) : 0;
}),
) + 1
);
}
function storeChartWindows(snapshotId: string, windows: ChartWindow[]) {
try {
sessionStorage.setItem(
`${RESULT_LAYOUT_KEY_PREFIX}${snapshotId}`,
JSON.stringify(windows),
);
} catch {
// The current mounted result view still retains its layout in memory.
}
}
type StoredChartWindow = Pick<
ChartWindow,
"id" | "variableKey" | "x" | "y" | "zIndex"
> &
Partial<
Pick<
ChartWindow,
| "kind"
| "variableKeys"
| "hiddenVariableKeys"
| "variableUnits"
| "width"
| "height"
| "unit"
| "cursorEnabled"
| "zoomEnabled"
| "viewport"
| "zoomHistory"
>
>;
function normalizeStoredChartWindow(window: StoredChartWindow): ChartWindow {
const kind =
window.kind === "multi" || window.kind === "mixed" ? window.kind : "single";
const rawVariableKeys = window.variableKeys;
const hasStoredVariableKeys = Array.isArray(rawVariableKeys);
const storedVariableKeys = hasStoredVariableKeys
? rawVariableKeys.filter(
(key, index, keys) =>
typeof key === "string" &&
key.length > 0 &&
keys.indexOf(key) === index,
)
: [];
const variableKeys =
kind === "single"
? window.variableKey
? [window.variableKey]
: []
: hasStoredVariableKeys
? storedVariableKeys
: window.variableKey
? [window.variableKey]
: [];
const hiddenVariableKeys = Array.isArray(window.hiddenVariableKeys)
? window.hiddenVariableKeys.filter(
(key, index, keys) =>
typeof key === "string" &&
variableKeys.includes(key) &&
keys.indexOf(key) === index,
)
: [];
const variableUnits =
window.variableUnits && isRecord(window.variableUnits)
? Object.fromEntries(
Object.entries(window.variableUnits).filter(
([key, value]) =>
variableKeys.includes(key) && typeof value === "string",
),
)
: {};
const viewport = normalizeChartViewport(window.viewport);
const zoomHistory = Array.isArray(window.zoomHistory)
? window.zoomHistory
.filter((item) => item && typeof item === "object")
.map(normalizeChartViewport)
.slice(-MAX_CHART_ZOOM_HISTORY)
: [];
const zoomEnabled = window.zoomEnabled === true;
return {
...window,
kind,
variableKey: variableKeys[0] ?? "",
variableKeys,
hiddenVariableKeys,
variableUnits,
width:
typeof window.width === "number" && Number.isFinite(window.width)
? window.width
: CHART_WINDOW_WIDTH,
height:
typeof window.height === "number" && Number.isFinite(window.height)
? window.height
: CHART_WINDOW_HEIGHT,
unit: typeof window.unit === "string" ? window.unit : "",
cursorEnabled: !zoomEnabled && window.cursorEnabled === true,
zoomEnabled,
viewport,
zoomHistory,
};
}
function isStoredChartWindow(value: unknown): value is StoredChartWindow {
if (!value || typeof value !== "object") {
return false;
}
const window = value as Partial<ChartWindow>;
return (
typeof window.id === "string" &&
typeof window.variableKey === "string" &&
typeof window.x === "number" &&
Number.isFinite(window.x) &&
typeof window.y === "number" &&
Number.isFinite(window.y) &&
typeof window.zIndex === "number" &&
Number.isFinite(window.zIndex)
);
}
function simulationResultStatus(
result: ResultPayload,
): NonNullable<ResultPayload["status"]> {
return result.status ?? (result.success ? "completed" : "failed");
}
function simulationResultStatusText(
result: ResultPayload,
status: Exclude<NonNullable<ResultPayload["status"]>, "completed">,
) {
const lastSeriesTime = result.series.time?.at(-1);
const simulatedUntil = Number.isFinite(result.simulatedUntil)
? Number(result.simulatedUntil)
: Number.isFinite(lastSeriesTime)
? Number(lastSeriesTime)
: null;
const timeText =
simulatedUntil === null
? ""
: ` · 计算至 t=${simulatedUntil.toLocaleString("zh-CN", { maximumFractionDigits: 6 })} s`;
const labels = {
stopped: "部分结果:仿真已手动终止",
stalled: "部分结果:仿真因进度异常终止",
failed: "部分结果:仿真因求解异常终止",
} as const;
return `${labels[status]}${timeText}`;
}
function formatResultTimestamp(value: string) {
const parsed = new Date(value);
return Number.isNaN(parsed.getTime())
? value
: parsed.toLocaleString("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
function formatChartNumber(value: number) {
if (!Number.isFinite(value)) {
return "-";
}
if (
Math.abs(value) >= 100000 ||
(Math.abs(value) > 0 && Math.abs(value) < 0.001)
) {
return value.toExponential(2);
}
return value.toFixed(3).replace(/\.?0+$/, "");
}
function formatCursorNumber(value: number) {
if (!Number.isFinite(value)) {
return "-";
}
if (
Math.abs(value) >= 1e7 ||
(Math.abs(value) > 0 && Math.abs(value) < 1e-5)
) {
return value.toExponential(6);
}
return value.toLocaleString("zh-CN", {
maximumSignificantDigits: 8,
useGrouping: false,
});
}