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