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; parameterUnits?: Record; rotation: 0 | 90 | 180 | 270; mirrored: boolean; }; type ResultRenderedNodeData = ResultNodeData & { portConnectionStates: Record; }; 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; series: Record; 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; componentCount: number; connectionCount: number; sampleCount: number; componentParameters: Array<{ id: string; name: string; componentType: string; parameters: Record; parameterUnits: Record; }>; }; 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; 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; }; 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 = { 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( snapshot.project.nodes[0]?.id ?? null, ); const [chartWindows, setChartWindows] = useState(() => loadChartWindows(snapshot.id), ); const [paneLayout, setPaneLayout] = useState( loadResultsPaneLayout, ); const [activePaneResize, setActivePaneResize] = useState(null); const [csvDownloadPending, setCsvDownloadPending] = useState(false); const [activeMultiPickerWindowId, setActiveMultiPickerWindowId] = useState< string | null >(null); const [multiDropTargetWindowId, setMultiDropTargetWindowId] = useState< string | null >(null); const workbenchRef = useRef(null); const workspaceRef = useRef(null); const dragStateRef = useRef(null); const resizeStateRef = useRef(null); const paneResizeStateRef = useRef(null); const nextWindowIdRef = useRef(nextChartWindowNumber(chartWindows)); const topZIndexRef = useRef( Math.max(1, ...chartWindows.map((window) => window.zIndex)), ); const initialSystemViewportRef = useRef( 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(); 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[] >( () => 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( () => 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, 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) => { 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) => { 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, 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, 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, variableKey: string, ) => { event.dataTransfer.effectAllowed = "copy"; event.dataTransfer.setData(RESULT_VARIABLE_MIME, variableKey); }; const handleWorkspaceDrop = (event: DragEvent) => { 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, 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) => { 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) => { 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, 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) => { 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) => { 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 (
System Simulation Flow
仿真结果查看
void downloadResultsCsv()} onSnapshotLoad={onSnapshotLoad} snapshot={snapshot} />
{snapshot.project.name} {resultStatus !== "completed" ? ( {simulationResultStatusText(snapshot.result, resultStatus)} ) : null} {snapshot.result.diagnostics.sampleCount} 个采样点 ·{" "} {formatResultTimestamp(snapshot.createdAt)}
系统图
{snapshot.project.nodes.length} 个组件
setSelectedNodeId(node.id)} onPaneClick={() => setSelectedNodeId(null)} onMoveEnd={(_, viewport) => storeResultSystemViewport(snapshot.id, viewport) } panOnDrag selectionOnDrag={false} zoomOnDoubleClick={false} > 0} expectedNodeCount={readOnlyNodes.length} fitOnMount={initialSystemViewportRef.current === null} requestKey={snapshot.id} />
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="拖动调整上下区域,双击恢复默认" >
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="拖动调整左右区域,双击恢复默认" >
曲线窗口
{chartWindows.length} 个窗口 ·{" "} {chartWindows.reduce( (count, window) => count + window.variableKeys.length, 0, )}{" "} 条曲线
{ 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 (
{ 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, }} >
startWindowDrag(event, chartWindow) } onPointerMove={moveChartWindow} onPointerUp={stopWindowDrag} >
{pendingTitle} 待定 · 请拖入第一条曲线
{isMixed ? (
); } 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 (
{ 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, }} >
startWindowDrag(event, chartWindow) } onPointerMove={moveChartWindow} onPointerUp={stopWindowDrag} >
{isMixed ? "异单位上下对比" : isCombined ? "同单位多曲线对比" : label} {isCombined ? `${visibleCombinedVariables.length}/${combinedVariables.length} 条显示 · ${isMixed ? "上下同步图" : variable.unit || "无量纲"}` : componentName} {separatedEventSampleCount > 0 ? ` · 已隐藏 ${separatedEventSampleCount} 个孤立事件力尖峰` : ""}
{isCombined ? (
{ 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()} > {activeMultiPickerWindowId === chartWindow.id ? (
曲线管理 {isMixed ? "多单位" : variable.unit || "无量纲"}
{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 (
toggleChartCurveVisibility( chartWindow.id, item.key, ) } type="checkbox" /> {itemTitle} {resultVariableLabel(item)} ·{" "} {itemUnit.label || "无量纲"} {isMixed && itemUnitOptions.length > 1 ? ( ) : null}
); })}
) : null}
) : null} {!isMixed && unitOptions.length > 1 ? ( ) : null}
{isMixed ? ( updateChartWindowViewport(chartWindow.id, viewport) } onZoomPrevious={() => undoChartWindowZoom(chartWindow.id)} series={multiSeries} time={chartTime} viewport={chartWindow.viewport} zoomEnabled={chartWindow.zoomEnabled} /> ) : isCombined ? ( updateChartWindowViewport(chartWindow.id, viewport) } onZoomPrevious={() => undoChartWindowZoom(chartWindow.id)} series={multiSeries} time={chartTime} title="多曲线对比" unit={selectedUnit.label} viewport={chartWindow.viewport} zoomEnabled={chartWindow.zoomEnabled} /> ) : ( updateChartWindowViewport(chartWindow.id, viewport) } onZoomPrevious={() => undoChartWindowZoom(chartWindow.id)} time={chartTime} title={chartTitle} unit={selectedUnit.label} values={displayValues} viewport={chartWindow.viewport} zoomEnabled={chartWindow.zoomEnabled} /> )}
); })} {chartWindows.length === 0 ? (
暂无曲线
) : null}
); } export function EmptySimulationResultsView({ onViewChange, onSnapshotLoad, }: EmptySimulationResultsViewProps) { return (
System Simulation Flow
仿真结果查看
尚无结果
尚未生成仿真结果
); } type ResultsFileToolbarProps = { snapshot?: SimulationResultsSnapshot; csvDownloadPending?: boolean; onDownloadCsv?: () => void; onSnapshotLoad: (snapshot: SimulationResultsSnapshot) => void; }; function ResultsFileToolbar({ snapshot, csvDownloadPending = false, onDownloadCsv, onSnapshotLoad, }: ResultsFileToolbarProps) { const fileInputRef = useRef(null); const [resultFileLoadPending, setResultFileLoadPending] = useState(false); const loadResultFile = async (event: ReactChangeEvent) => { 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 (
{snapshot ? ( ) : null} {snapshot && onDownloadCsv ? ( ) : null} void loadResultFile(event)} ref={fileInputRef} type="file" />
); } type VariableGroupProps = { title: string; variables: ResultVariableMetadata[]; chartWindows: ChartWindow[]; onClick: (variableKey: string) => void; onDragStart: ( event: DragEvent, variableKey: string, ) => void; }; function VariableGroup({ title, variables, chartWindows, onClick, onDragStart, }: VariableGroupProps) { if (variables.length === 0) { return null; } return (
{title}
{variables.map((variable) => { const visible = chartWindows.some( (window) => window.variableKeys.includes(variable.key) && !window.hiddenVariableKeys.includes(variable.key), ); const label = resultVariableLabel(variable); return ( ); })}
); } 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(null); const panRef = useRef(null); const viewportRef = useRef(viewport); const [panning, setPanning] = useState(false); const [selection, setSelection] = useState(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) => clientPoint(event.currentTarget, event.clientX, event.clientY); const cancelSelection = (event?: ReactPointerEvent) => { 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) => { 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) => { 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) => { 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) => { 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) => { 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; 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; 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(null); const cursorPanelRef = useRef(null); const cursorPanelDragRef = useRef(null); const cursorLineDragRef = useRef(null); const clipPathId = `result-chart-clip-${useId().replaceAll(":", "")}`; const [chartSize, setChartSize] = useState({ width: 520, height: 270 }); const [cursorIndex, setCursorIndex] = useState(null); const [cursorPanelPosition, setCursorPanelPosition] = useState({ 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( () => 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 (
无可绘制数据
); } 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) => { if (!cursorEnabled) { return; } event.preventDefault(); event.stopPropagation(); cursorLineDragRef.current = { pointerId: event.pointerId, axis: "x", }; event.currentTarget.setPointerCapture(event.pointerId); }; const moveCursorLine = (event: ReactPointerEvent) => { 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) => { 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, ) => { 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) => { 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) => { 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 (
{ 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}`} > {title} {unit || "无量纲"} {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 ( {truncateLegendLabel( `Y${index + 1} ${curve.label}`, legendWidth - 20, )} ); })} {yTicks.map((tick, index) => { const y = yPosition(tick); return ( {formatChartNumber(tick)} ); })} {xTicks.map((tick, index) => { const x = xPosition(tick); const textAnchor = index === 0 ? "start" : index === xTicks.length - 1 ? "end" : "middle"; return ( {formatChartNumber(tick)} ); })} {plottedSeries.map((curve) => { const path = segmentedChartPath( time, curve.values, xPosition, yPosition, ); return ( ); })} {cursorEnabled && cursorSample ? ( <> {cursorReadings.map((reading) => ( ))} ) : null} {zoomEnabled ? ( ) : null} {zoomSelection ? ( ) : null} 时间 (s) {cursorEnabled && cursorSample ? (
event.stopPropagation()} ref={cursorPanelRef} style={{ left: cursorPanelPosition.x, top: cursorPanelPosition.y, }} >
X
{formatCursorNumber(cursorSample.x)} s
{cursorReadings.map((reading) => (
Y{reading.index + 1}
))}
) : null}
); } type StackedCurvePlotProps = { time: number[]; series: MultiCurveSeries[]; cursorEnabled: boolean; } & ChartZoomPlotProps; function StackedCurvePlot({ time, series, cursorEnabled, onViewportChange, onZoomPrevious, viewport, zoomEnabled, }: StackedCurvePlotProps) { const bodyRef = useRef(null); const cursorPanelRef = useRef(null); const cursorPanelDragRef = useRef(null); const cursorLineDragRef = useRef(null); const clipPathBaseId = `result-chart-clip-${useId().replaceAll(":", "")}`; const [chartSize, setChartSize] = useState({ width: 520, height: 270 }); const [cursorIndex, setCursorIndex] = useState(null); const [cursorPanelPosition, setCursorPanelPosition] = useState({ 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( () => 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 (
无可绘制数据
); } 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) => { if (!cursorEnabled) { return; } event.preventDefault(); event.stopPropagation(); cursorLineDragRef.current = { pointerId: event.pointerId, axis: "x", }; event.currentTarget.setPointerCapture(event.pointerId); }; const moveCursorLine = (event: ReactPointerEvent) => { 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) => { 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, ) => { 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) => { 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) => { 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 (
[ 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}`} > {plotBands.map((band) => ( ))} {plotBands.map((band) => { const isLast = band.index === plotBands.length - 1; const path = segmentedChartPath( time, band.curve.values, xPosition, band.yPosition, ); return ( {band.index > 0 ? ( ) : null} {truncateLegendLabel( `Y${band.index + 1} ${band.curve.label}`, plotWidth - 66, )} {band.curve.unit || "无量纲"} {band.yTicks.map((tick, tickIndex) => { const y = band.yPosition(tick); return ( {formatChartNumber(tick)} ); })} {xTicks.map((tick, tickIndex) => { const x = xPosition(tick); const textAnchor = tickIndex === 0 ? "start" : tickIndex === xTicks.length - 1 ? "end" : "middle"; return ( {isLast ? ( <> {formatChartNumber(tick)} ) : null} ); })} ); })} {cursorEnabled && cursorSample ? ( <> {plotBands.map((band) => { const reading = cursorReadings.find( (item) => item.index === band.index, ); return ( {reading ? ( ) : null} ); })} ) : null} {zoomEnabled ? ( {plotBands.map((band) => ( ))} ) : null} {zoomSelection ? ( ) : null} 时间 (s) {cursorEnabled && cursorSample ? (
event.stopPropagation()} ref={cursorPanelRef} style={{ left: cursorPanelPosition.x, top: cursorPanelPosition.y, }} >
X
{formatCursorNumber(cursorSample.x)} s
{cursorReadings.map((reading) => (
Y{reading.index + 1}
))}
) : null}
); } 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(null); const cursorPanelRef = useRef(null); const cursorPanelDragRef = useRef(null); const cursorLineDragRef = useRef(null); const clipPathId = `result-chart-clip-${useId().replaceAll(":", "")}`; const [chartSize, setChartSize] = useState({ width: 400, height: 190 }); const [cursorIndex, setCursorIndex] = useState(null); const [cursorPanelPosition, setCursorPanelPosition] = useState({ x: 238, y: 9 }); const samples = useMemo( () => 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 (
无可绘制数据
); } 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, 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) => { 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) => { 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, ) => { 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) => { 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) => { 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 (
{ 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}`} > {title} {unit || "无量纲"} {yTicks.map((tick, index) => { const y = yPosition(tick); return ( {formatChartNumber(tick)} ); })} {xTicks.map((tick, index) => { const x = xPosition(tick); const textAnchor = index === 0 ? "start" : index === xTicks.length - 1 ? "end" : "middle"; return ( {formatChartNumber(tick)} ); })} {cursorEnabled && cursorSample ? ( <> startCursorLineDrag(event, "x")} onPointerMove={moveCursorLine} onPointerUp={stopCursorLineDrag} x1={xPosition(cursorSample.x)} x2={xPosition(cursorSample.x)} y1={margin.top} y2={height - margin.bottom} /> startCursorLineDrag(event, "y")} onPointerMove={moveCursorLine} onPointerUp={stopCursorLineDrag} x1={margin.left} x2={width - margin.right} y1={yPosition(cursorSample.y)} y2={yPosition(cursorSample.y)} /> ) : null} {zoomEnabled ? ( ) : null} {zoomSelection ? ( ) : null} 时间 (s) {cursorEnabled && cursorSample ? (
event.stopPropagation()} ref={cursorPanelRef} style={{ left: cursorPanelPosition.x, top: cursorPanelPosition.y, }} >
X
{formatCursorNumber(cursorSample.x)} s
Y
{formatCursorNumber(cursorSample.y)} {unit}
) : null}
); } 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, 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((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) { 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 { 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; 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; 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; 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 { return result.status ?? (result.success ? "completed" : "failed"); } function simulationResultStatusText( result: ResultPayload, status: Exclude, "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, }); }