4423 lines
138 KiB
TypeScript
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,
|
|
});
|
|
}
|