Files
SystemSimulationApp/frontend/src/SimulationResultsView.tsx
T

4423 lines
138 KiB
TypeScript

import { useEffect, useMemo, useRef, useState } from "react";
import type {
ChangeEvent as ReactChangeEvent,
DragEvent,
KeyboardEvent as ReactKeyboardEvent,
PointerEvent as ReactPointerEvent,
} from "react";
import {
ChartNoAxesCombined,
ChartSpline,
Crosshair,
FileDown,
FolderOpen,
ImageDown,
ListChecks,
Move,
Scaling,
Sheet,
Trash2,
X,
} from "lucide-react";
import {
Background,
ConnectionLineType,
ConnectionMode,
Controls,
ReactFlow,
type Edge,
type Node,
type NodeTypes,
} from "@xyflow/react";
import {
WorkspaceViewTabs,
type WorkspaceView,
} from "./WorkspaceViewTabs";
import { AutoFitView } from "./AutoFitView";
import {
CONTACT_AWARE_EDGE_TYPE,
contactAwareEdgeTypes,
} from "./ContactAwareEdge";
type ResultPortDefinition = {
name: string;
kind: string;
domain: string;
nominalRole: string;
positiveFlowDirection?: string;
side: "left" | "right";
};
type ResultNodeData = {
label: string;
componentType: string;
modelType: 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 | null;
targetHandle: string | null;
};
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: {
pressureFlow: {
solveCount: number;
maxScaledResidual: number;
maxEvaluationsPerSolve: number;
};
stream: {
maxIterationsPerSolve: number;
};
stateCount: number;
sampleCount: number;
};
};
export type SimulationResultsSnapshot = {
id: string;
createdAt: string;
project: {
name: string;
presentationLayoutVersion?: 1 | null;
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;
nodeTypes: NodeTypes;
onViewChange: (view: WorkspaceView) => void;
onSnapshotLoad: (snapshot: SimulationResultsSnapshot) => void;
};
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;
zIndex: number;
};
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 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 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,
nodeTypes,
onViewChange,
onSnapshotLoad,
}: SimulationResultsViewProps) {
const resultStatus = simulationResultStatus(snapshot.result);
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)),
);
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);
return () => window.removeEventListener("resize", clampLayoutToWorkbench);
}, []);
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",
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 readOnlyEdges = useMemo<Edge[]>(
() =>
snapshot.project.edges.map((edge) => ({
...edge,
focusable: false,
selectable: false,
type: CONTACT_AWARE_EDGE_TYPE,
})),
[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,
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,
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,
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,
};
}
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,
),
),
};
}),
);
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,
};
}
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 }
: window,
),
);
};
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>
<span>{snapshot.project.nodes.length} 个组件</span>
</div>
<div className="results-system-canvas">
<ReactFlow
colorMode="light"
connectionLineType={ConnectionLineType.Step}
connectionMode={ConnectionMode.Loose}
deleteKeyCode={null}
edges={readOnlyEdges}
edgeTypes={contactAwareEdgeTypes}
edgesFocusable={false}
edgesReconnectable={false}
elementsSelectable
minZoom={0.1}
nodeTypes={nodeTypes}
nodes={readOnlyNodes}
nodesConnectable={false}
nodesDraggable={false}
nodesFocusable
onNodeClick={(_, node) => setSelectedNodeId(node.id)}
onPaneClick={() => setSelectedNodeId(null)}
panOnDrag
selectionOnDrag={false}
zoomOnDoubleClick={false}
>
<AutoFitView
enabled={readOnlyNodes.length > 0}
expectedNodeCount={readOnlyNodes.length}
fitOnMount
requestKey={snapshot.id}
/>
<Background gap={18} />
<Controls
fitViewOptions={{ duration: 180, minZoom: 0.1, padding: 0.18 }}
/>
</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 displayValues = (
snapshot.result.series[chartWindow.variableKey] ?? []
).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;
return {
key: item.key,
label: resultVariableTitle(
item,
itemComponent?.data.label ?? item.componentId,
),
color: colorForVariable(item.key),
unit: itemUnit.label,
values: (snapshot.result.series[item.key] ?? []).map(
itemUnit.fromBase,
),
};
});
return (
<article
className={`result-chart-window ${isCombined ? chartWindow.kind : "single"} ${
multiDropTargetWindowId === chartWindow.id
? "drop-target"
: ""
}`}
data-chart-kind={chartWindow.kind}
data-pending="false"
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}
</span>
</div>
<div className="result-chart-window-actions">
<button
aria-label={`${chartWindow.cursorEnabled ? "关闭" : "打开"} ${isCombined ? "多曲线" : label} 曲线游标`}
aria-pressed={chartWindow.cursorEnabled}
className={`cursor ${chartWindow.cursorEnabled ? "active" : ""}`}
onClick={(event) => {
event.stopPropagation();
toggleChartWindowCursor(chartWindow.id);
}}
onPointerDown={(event) => event.stopPropagation()}
title={chartWindow.cursorEnabled ? "关闭游标" : "打开游标"}
type="button"
>
<Crosshair
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={chartWindow.cursorEnabled}
series={multiSeries}
time={snapshot.result.series.time ?? []}
/>
) : isCombined ? (
<MultiCurvePlot
cursorEnabled={chartWindow.cursorEnabled}
series={multiSeries}
time={snapshot.result.series.time ?? []}
title="多曲线对比"
unit={selectedUnit.label}
/>
) : (
<CurvePlot
color={colorForVariable(chartWindow.variableKey)}
cursorEnabled={chartWindow.cursorEnabled}
time={snapshot.result.series.time ?? []}
title={chartTitle}
unit={selectedUnit.label}
values={displayValues}
/>
)}
<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[];
};
type MultiCurvePlotProps = {
time: number[];
series: MultiCurveSeries[];
title: string;
unit: string;
cursorEnabled: boolean;
};
function MultiCurvePlot({
time,
series,
title,
unit,
cursorEnabled,
}: MultiCurvePlotProps) {
const bodyRef = useRef<HTMLDivElement>(null);
const cursorPanelRef = useRef<HTMLDivElement>(null);
const cursorPanelDragRef = useRef<CursorPanelDragState | null>(null);
const cursorLineDragRef = useRef<CursorLineDragState | null>(null);
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]);
if (plottedSeries.length === 0) {
return (
<div className="result-chart-body" ref={bodyRef}>
<div className="result-chart-empty">无可绘制数据</div>
</div>
);
}
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 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 sharedYScale = {
minimum: rawYMin - sharedYPadding,
maximum: rawYMax + sharedYPadding,
};
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 xMin = Math.min(...allSamples.map((sample) => sample.x));
const xMax = Math.max(...allSamples.map((sample) => sample.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 activeCursorIndex =
cursorIndex === null
? Math.floor(cursorSamples.length / 2)
: Math.min(cursorIndex, cursorSamples.length - 1);
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;
};
return (
<div className="result-chart-body" ref={bodyRef}>
<svg
aria-label={`${title},包含 ${plottedSeries.length} 条曲线`}
className={cursorEnabled ? "cursor-enabled" : undefined}
data-result-chart="true"
onPointerDown={(event) => {
if (cursorEnabled) {
event.preventDefault();
updateCursorByX(event.clientX, event.currentTarget);
}
}}
role="img"
viewBox={`0 0 ${width} ${height}`}
>
<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}
/>
{plottedSeries.map((curve) => {
const path = curve.samples
.map(
(sample, index) =>
`${index === 0 ? "M" : "L"} ${xPosition(sample.x).toFixed(2)} ${yPosition(sample.y).toFixed(2)}`,
)
.join(" ");
return (
<path
d={path}
fill="none"
key={curve.key}
stroke={curve.color}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
vectorEffect="non-scaling-stroke"
/>
);
})}
{cursorEnabled && cursorSample ? (
<>
<g className="result-chart-cursor" 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}
<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;
};
function StackedCurvePlot({
time,
series,
cursorEnabled,
}: StackedCurvePlotProps) {
const bodyRef = useRef<HTMLDivElement>(null);
const cursorPanelRef = useRef<HTMLDivElement>(null);
const cursorPanelDragRef = useRef<CursorPanelDragState | null>(null);
const cursorLineDragRef = useRef<CursorLineDragState | null>(null);
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]);
if (plottedSeries.length === 0) {
return (
<div className="result-chart-body" ref={bodyRef}>
<div className="result-chart-empty">无可绘制数据</div>
</div>
);
}
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 / plottedSeries.length;
const allSamples = plottedSeries.flatMap((curve) => curve.samples);
const xMin = Math.min(...allSamples.map((sample) => sample.x));
const xMax = Math.max(...allSamples.map((sample) => sample.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 minimum = rawMinimum - padding;
const maximum = rawMaximum + padding;
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,
plotTop,
plotBottom,
yTicks: chartTicks(minimum, maximum, bandHeight < 105 ? 2 : 3),
yPosition: (value: number) =>
plotTop + (1 - (value - minimum) / range) * plotHeight,
};
});
const activeCursorIndex =
cursorIndex === null
? Math.floor(cursorSamples.length / 2)
: Math.min(cursorIndex, cursorSamples.length - 1);
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;
};
return (
<div className="result-chart-body stacked" ref={bodyRef}>
<svg
aria-label={`上下曲线对比,包含 ${plottedSeries.length} 个同步子图`}
className={cursorEnabled ? "cursor-enabled" : undefined}
data-result-chart="true"
onPointerDown={(event) => {
if (cursorEnabled) {
event.preventDefault();
updateCursorByX(event.clientX, event.currentTarget);
}
}}
role="img"
viewBox={`0 0 ${width} ${height}`}
>
{plotBands.map((band) => {
const isLast = band.index === plotBands.length - 1;
const path = band.curve.samples
.map(
(sample, sampleIndex) =>
`${sampleIndex === 0 ? "M" : "L"} ${xPosition(sample.x).toFixed(2)} ${band.yPosition(sample.y).toFixed(2)}`,
)
.join(" ");
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
d={path}
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) => (
<line
key={`${band.curve.key}-cursor-line`}
x1={xPosition(cursorSample.x)}
x2={xPosition(cursorSample.x)}
y1={band.plotTop}
y2={band.plotBottom}
/>
))}
{cursorReadings.map((reading) => {
const band = plotBands[reading.index];
return band ? (
<circle
cx={xPosition(cursorSample.x)}
cy={band.yPosition(reading.y)}
fill={reading.color}
key={`${reading.index}-${reading.label}`}
r="3.5"
/>
) : null;
})}
</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}
<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;
};
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,
}: CurvePlotProps) {
const bodyRef = useRef<HTMLDivElement>(null);
const cursorPanelRef = useRef<HTMLDivElement>(null);
const cursorPanelDragRef = useRef<CursorPanelDragState | null>(null);
const cursorLineDragRef = useRef<CursorLineDragState | null>(null);
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]);
if (samples.length < 2) {
return (
<div className="result-chart-body" ref={bodyRef}>
<div className="result-chart-empty">无可绘制数据</div>
</div>
);
}
const { width, height } = chartSize;
const margin = { top: 24, right: 18, bottom: 40, left: 68 };
const plotWidth = width - margin.left - margin.right;
const plotHeight = height - margin.top - margin.bottom;
const xMin = Math.min(...samples.map((sample) => sample.x));
const xMax = Math.max(...samples.map((sample) => sample.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 = rawYMin - yPadding;
const yMax = rawYMax + yPadding;
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 = samples
.map(
(sample, index) =>
`${index === 0 ? "M" : "L"} ${xPosition(sample.x).toFixed(2)} ${yPosition(
sample.y,
).toFixed(2)}`,
)
.join(" ");
const activeCursorIndex =
cursorIndex === null
? Math.floor(samples.length / 2)
: Math.min(cursorIndex, samples.length - 1);
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;
};
return (
<div className="result-chart-body" ref={bodyRef}>
<svg
aria-label={`${title} 随时间变化曲线`}
className={cursorEnabled ? "cursor-enabled" : undefined}
data-result-chart="true"
onPointerDown={(event) => {
if (cursorEnabled) {
event.preventDefault();
updateCursorByX(event.clientX, event.currentTarget);
}
}}
role="img"
viewBox={`0 0 ${width} ${height}`}
>
<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
d={path}
fill="none"
stroke={color}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
vectorEffect="non-scaling-stroke"
/>
{cursorEnabled && cursorSample ? (
<>
<g className="result-chart-cursor" 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}
<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 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) ||
typeof project.name !== "string" ||
(project.presentationLayoutVersion !== undefined &&
project.presentationLayoutVersion !== null &&
project.presentationLayoutVersion !== 1) ||
!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) &&
isRecord(data.parameters) &&
(data.parameterUnits === undefined || isRecord(data.parameterUnits)) &&
[0, 90, 180, 270].includes(Number(data.rotation)) &&
typeof data.mirrored === "boolean"
);
}
function isResultProjectEdge(value: unknown): value is ResultProjectEdge {
return (
isRecord(value) &&
typeof value.id === "string" &&
typeof value.source === "string" &&
typeof value.target === "string" &&
(value.sourceHandle === null || typeof value.sourceHandle === "string") &&
(value.targetHandle === null || typeof value.targetHandle === "string")
);
}
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 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"
>
>;
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",
),
)
: {};
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: window.cursorEnabled === true,
};
}
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,
});
}