import { useEffect, useMemo, useRef, useState } from "react"; import type { ChangeEvent as ReactChangeEvent, DragEvent, KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent, } from "react"; import { ChartNoAxesCombined, ChartSpline, Crosshair, FileDown, FolderOpen, ImageDown, ListChecks, Move, Scaling, Sheet, Trash2, X, } from "lucide-react"; import { Background, ConnectionLineType, ConnectionMode, Controls, ReactFlow, type Edge, type Node, type NodeTypes, } from "@xyflow/react"; import { WorkspaceViewTabs, type WorkspaceView, } from "./WorkspaceViewTabs"; import { AutoFitView } from "./AutoFitView"; import { CONTACT_AWARE_EDGE_TYPE, contactAwareEdgeTypes, } from "./ContactAwareEdge"; type ResultPortDefinition = { name: string; kind: string; domain: string; nominalRole: string; positiveFlowDirection?: string; side: "left" | "right"; }; type ResultNodeData = { label: string; componentType: string; modelType: string; ports: ResultPortDefinition[]; parameters: Record; 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 | null; targetHandle: string | null; }; type ResultSimulationConfig = { t_start: number; t_stop: number; step: number; max_step: number; method: string; }; export type ResultVariableMetadata = { key: string; componentId: string; componentType: string; scope: "component" | "port"; portName: string | null; name: string; label: string; quantity: string; unit: string; category: string; order: number; }; type ResultPayload = { success: boolean; status?: "completed" | "stopped" | "stalled" | "failed"; partial?: boolean; message: string; simulatedUntil?: number; requestedStopTime?: number; variables: ResultVariableMetadata[]; final: Record; series: Record; diagnostics: { pressureFlow: { solveCount: number; maxScaledResidual: number; maxEvaluationsPerSolve: number; }; stream: { maxIterationsPerSolve: number; }; stateCount: number; sampleCount: number; }; }; export type SimulationResultsSnapshot = { id: string; createdAt: string; project: { name: string; presentationLayoutVersion?: 1 | null; nodes: ResultProjectNode[]; edges: ResultProjectEdge[]; simulation: ResultSimulationConfig; }; result: ResultPayload; }; type SimulationResultFile = { fileType: "system-simulation-result"; formatVersion: 1; exportedAt: string; metadata: { projectName: string; resultCreatedAt: string; simulation: ResultSimulationConfig & { simulatedUntil: number | null; }; resultStatus: NonNullable; componentCount: number; connectionCount: number; sampleCount: number; componentParameters: Array<{ id: string; name: string; componentType: string; parameters: Record; parameterUnits: Record; }>; }; snapshot: SimulationResultsSnapshot; }; type SimulationResultsViewProps = { snapshot: SimulationResultsSnapshot; nodeTypes: NodeTypes; onViewChange: (view: WorkspaceView) => void; onSnapshotLoad: (snapshot: SimulationResultsSnapshot) => void; }; type EmptySimulationResultsViewProps = { onViewChange: (view: WorkspaceView) => void; onSnapshotLoad: (snapshot: SimulationResultsSnapshot) => void; }; type ChartWindow = { id: string; kind: "single" | "multi" | "mixed"; variableKey: string; variableKeys: string[]; hiddenVariableKeys: string[]; variableUnits: Record; x: number; y: number; width: number; height: number; unit: string; cursorEnabled: boolean; zIndex: number; }; type ResultUnitOption = { value: string; label: string; fromBase: (value: number) => number; }; type WindowDragState = { windowId: string; pointerId: number; offsetX: number; offsetY: number; width: number; height: number; }; type WindowResizeState = { windowId: string; pointerId: number; startX: number; startY: number; startWidth: number; startHeight: number; windowX: number; windowY: number; }; type ResultsPaneLayout = { inspectorWidth: number; systemHeight: number; }; type PaneResizeKind = "inspector-width" | "system-height"; type PaneResizeState = { kind: PaneResizeKind; pointerId: number; startX: number; startY: number; startSize: number; }; const RESULT_VARIABLE_MIME = "application/x-system-simulation-variable"; const RESULT_FILE_TYPE = "system-simulation-result"; const RESULT_FILE_FORMAT_VERSION = 1; const RESULT_FILE_MIME = "application/vnd.system-simulation.result+json"; const MAX_RESULT_FILE_SIZE = 250 * 1024 * 1024; const RESULT_LAYOUT_KEY_PREFIX = "system-simulation-flow:result-layout:"; const RESULT_PANE_LAYOUT_KEY = "system-simulation-flow:result-pane-layout"; const CHART_WINDOW_WIDTH = 420; const CHART_WINDOW_HEIGHT = 260; const MULTI_CHART_WINDOW_WIDTH = 560; const MULTI_CHART_WINDOW_HEIGHT = 340; const MIN_CHART_WINDOW_WIDTH = 360; const MIN_CHART_WINDOW_HEIGHT = 240; const PANE_SPLITTER_SIZE = 7; const MIN_INSPECTOR_WIDTH = 320; const MIN_CHART_WIDTH = 440; const MIN_SYSTEM_HEIGHT = 180; const MIN_VARIABLE_HEIGHT = 180; const PANE_KEYBOARD_STEP = 20; const CHART_COLORS = [ "#1d6fb8", "#c2413b", "#2f7d55", "#b36b16", "#6b5ca5", "#087f8c", "#9a4f76", ]; const PRESSURE_RESULT_UNITS = [ scaledResultUnit("Pa", "Pa", 1), scaledResultUnit("kPa", "kPa", 1e3), scaledResultUnit("MPa", "MPa", 1e6), scaledResultUnit("bar", "bar", 1e5), ]; const SPECIFIC_ENERGY_RESULT_UNITS = [ scaledResultUnit("J/kg", "J/kg", 1), scaledResultUnit("kJ/kg", "kJ/kg", 1e3), scaledResultUnit("MJ/kg", "MJ/kg", 1e6), ]; const RESULT_UNIT_OPTIONS: Record = { area: [ scaledResultUnit("m2", "m²", 1), scaledResultUnit("cm2", "cm²", 1e-4), scaledResultUnit("mm2", "mm²", 1e-6), ], heat_transfer_coefficient: [ scaledResultUnit("W/(m2*K)", "W/(m²·K)", 1), ], pressure: PRESSURE_RESULT_UNITS, absolute_pressure: PRESSURE_RESULT_UNITS, temperature: [ scaledResultUnit("K", "K", 1), { value: "degC", label: "°C", fromBase: (value) => value - 273.15 }, ], mass: [ scaledResultUnit("kg", "kg", 1), scaledResultUnit("g", "g", 1e-3), scaledResultUnit("t", "t", 1e3), ], internal_energy: [ scaledResultUnit("J", "J", 1), scaledResultUnit("kJ", "kJ", 1e3), scaledResultUnit("MJ", "MJ", 1e6), ], energy: [ scaledResultUnit("J", "J", 1), scaledResultUnit("kJ", "kJ", 1e3), scaledResultUnit("MJ", "MJ", 1e6), ], specific_internal_energy: SPECIFIC_ENERGY_RESULT_UNITS, specific_enthalpy: SPECIFIC_ENERGY_RESULT_UNITS, density: [ scaledResultUnit("kg/m³", "kg/m³", 1), scaledResultUnit("g/cm³", "g/cm³", 1e3), ], mass_flow: [ scaledResultUnit("kg/s", "kg/s", 1), scaledResultUnit("g/s", "g/s", 1e-3), scaledResultUnit("kg/min", "kg/min", 1 / 60), scaledResultUnit("kg/h", "kg/h", 1 / 3600), ], volume: [ scaledResultUnit("m3", "m³", 1), scaledResultUnit("L", "L", 1e-3), scaledResultUnit("mL", "mL", 1e-6), ], length: [ scaledResultUnit("m", "m", 1), scaledResultUnit("cm", "cm", 1e-2), scaledResultUnit("mm", "mm", 1e-3), ], velocity: [ scaledResultUnit("m/s", "m/s", 1), ], }; export function SimulationResultsView({ snapshot, nodeTypes, onViewChange, onSnapshotLoad, }: SimulationResultsViewProps) { const resultStatus = simulationResultStatus(snapshot.result); const [selectedNodeId, setSelectedNodeId] = useState( 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(null); const [multiDropTargetWindowId, setMultiDropTargetWindowId] = useState(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)), ); useEffect(() => { storeChartWindows(snapshot.id, chartWindows); }, [chartWindows, snapshot.id]); useEffect(() => { storeResultsPaneLayout(paneLayout); }, [paneLayout]); useEffect(() => { const clampLayoutToWorkbench = () => { const bounds = workbenchRef.current?.getBoundingClientRect(); if (!bounds) { return; } setPaneLayout((current) => { const next = clampResultsPaneLayout(current, bounds.width, bounds.height); return sameResultsPaneLayout(current, next) ? current : next; }); }; clampLayoutToWorkbench(); window.addEventListener("resize", clampLayoutToWorkbench); return () => window.removeEventListener("resize", clampLayoutToWorkbench); }, []); useEffect(() => { const workspace = workspaceRef.current; if (!workspace || typeof ResizeObserver === "undefined") { return; } const observer = new ResizeObserver(([entry]) => { if (!entry) { return; } const { width, height } = entry.contentRect; setChartWindows((current) => { let changed = false; const next = current.map((chartWindow) => { const size = clampChartWindowDimensions( chartWindow.width, chartWindow.height, width, height, ); const x = clampWindowCoordinate( chartWindow.x, width, size.width, ); const y = clampWindowCoordinate( chartWindow.y, height, size.height, ); const windowChanged = x !== chartWindow.x || y !== chartWindow.y || size.width !== chartWindow.width || size.height !== chartWindow.height; changed ||= windowChanged; return windowChanged ? { ...chartWindow, ...size, x, y } : chartWindow; }); return changed ? next : current; }); }); observer.observe(workspace); return () => observer.disconnect(); }, []); const selectedNode = snapshot.project.nodes.find((node) => node.id === selectedNodeId) ?? null; const connectedPortKeys = useMemo(() => { const keys = new Set(); 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", connectable: false, draggable: false, focusable: true, selectable: true, selected: node.id === selectedNodeId, data: { ...node.data, portConnectionStates: Object.fromEntries( node.data.ports.map((port) => [ port.name, connectedPortKeys.has(`${node.id}:${port.name}`) ? "connected" : "idle", ]), ), }, })), [connectedPortKeys, selectedNodeId, snapshot.project.nodes], ); const readOnlyEdges = useMemo( () => snapshot.project.edges.map((edge) => ({ ...edge, focusable: false, selectable: false, type: CONTACT_AWARE_EDGE_TYPE, })), [snapshot.project.edges], ); const selectedVariables = useMemo( () => selectedNode ? resultVariablesForNode( snapshot.result.variables, snapshot.result.series, selectedNode.id, ) : [], [selectedNode, snapshot.result.series, snapshot.result.variables], ); const componentVariables = selectedVariables.filter( (variable) => variable.scope === "component", ); const portVariables = selectedVariables.filter( (variable) => variable.scope === "port", ); const plottableResultVariables = useMemo( () => snapshot.result.variables .filter( (variable) => (snapshot.result.series[variable.key]?.length ?? 0) > 0, ) .sort((first, second) => first.key.localeCompare(second.key, "zh-CN"), ), [snapshot.result.series, snapshot.result.variables], ); const setPaneSize = (kind: PaneResizeKind, requestedSize: number) => { const bounds = workbenchRef.current?.getBoundingClientRect(); if (!bounds) { return; } setPaneLayout((current) => clampResultsPaneLayout( { ...current, ...(kind === "inspector-width" ? { inspectorWidth: requestedSize } : { systemHeight: requestedSize }), }, bounds.width, bounds.height, ), ); }; const startPaneResize = ( event: ReactPointerEvent, 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, zIndex, }, ]); }; const addCombinedChartWindow = ( kind: "multi" | "mixed", requestedPosition?: { x: number; y: number }, ) => { const workspace = workspaceRef.current; if (!workspace) { return; } const bounds = workspace.getBoundingClientRect(); const size = clampChartWindowDimensions( MULTI_CHART_WINDOW_WIDTH, MULTI_CHART_WINDOW_HEIGHT, bounds.width, bounds.height, ); const index = chartWindows.length; const position = requestedPosition ?? { x: (kind === "mixed" ? 38 : 28) + (index % 3) * 28, y: (kind === "mixed" ? 38 : 28) + (index % 4) * 28, }; const id = `chart-${nextWindowIdRef.current++}`; const zIndex = ++topZIndexRef.current; setChartWindows((current) => [ ...current, { id, kind, variableKey: "", variableKeys: [], hiddenVariableKeys: [], variableUnits: {}, x: clampWindowCoordinate(position.x, bounds.width, size.width), y: clampWindowCoordinate(position.y, bounds.height, size.height), width: size.width, height: size.height, unit: "", cursorEnabled: false, zIndex, }, ]); setActiveMultiPickerWindowId(null); }; const toggleChartCurveVisibility = ( windowId: string, variableKey: string, ) => { setChartWindows((current) => current.map((window) => { if (window.id !== windowId || window.kind === "single") { return window; } const hiddenVariableKeys = window.hiddenVariableKeys.includes(variableKey) ? window.hiddenVariableKeys.filter((key) => key !== variableKey) : [...window.hiddenVariableKeys, variableKey]; return { ...window, hiddenVariableKeys, }; }), ); }; const removeChartCurve = (windowId: string, variableKey: string) => { const resetsWindow = chartWindows.some( (window) => window.id === windowId && window.kind !== "single" && window.variableKeys.length === 1, ); setChartWindows((current) => current.map((window) => { if (window.id !== windowId || window.kind === "single") { return window; } if (window.variableKeys.length === 1) { return { ...window, variableKey: "", variableKeys: [], hiddenVariableKeys: [], variableUnits: {}, unit: "", cursorEnabled: false, }; } const variableKeys = window.variableKeys.filter( (key) => key !== variableKey, ); return { ...window, variableKey: variableKeys[0], variableKeys, hiddenVariableKeys: window.hiddenVariableKeys.filter( (key) => key !== variableKey, ), variableUnits: Object.fromEntries( Object.entries(window.variableUnits).filter( ([key]) => key !== variableKey, ), ), }; }), ); if (resetsWindow) { setActiveMultiPickerWindowId(null); } }; const addDroppedVariableToMultiChart = ( event: DragEvent, chartWindow: ChartWindow, ) => { event.preventDefault(); event.stopPropagation(); setMultiDropTargetWindowId(null); const variableKey = event.dataTransfer.getData(RESULT_VARIABLE_MIME); const variable = snapshot.result.variables.find( (item) => item.key === variableKey, ); const primaryVariable = snapshot.result.variables.find( (item) => item.key === chartWindow.variableKey, ) ?? chartWindow.variableKeys .map((key) => snapshot.result.variables.find((item) => item.key === key), ) .find((item): item is ResultVariableMetadata => item !== undefined); if (!variableKey || !variable || chartWindow.kind === "single") { return; } if ( chartWindow.kind === "multi" && primaryVariable && variable.unit !== primaryVariable.unit ) { window.alert( `无法加入曲线:该窗口只接受 SI 单位为 ${primaryVariable.unit || "无量纲"} 的变量。`, ); return; } setChartWindows((current) => current.map((window) => { if (window.id !== chartWindow.id || window.kind === "single") { return window; } if (window.variableKeys.length === 0 || !primaryVariable) { return { ...window, variableKey, variableKeys: [variableKey], hiddenVariableKeys: [], variableUnits: { [variableKey]: variable.unit }, unit: variable.unit, }; } if (window.variableKeys.includes(variableKey)) { return { ...window, hiddenVariableKeys: window.hiddenVariableKeys.filter( (key) => key !== variableKey, ), }; } return { ...window, variableKeys: [...window.variableKeys, variableKey], hiddenVariableKeys: window.hiddenVariableKeys.filter( (key) => key !== variableKey, ), variableUnits: { ...window.variableUnits, [variableKey]: variable.unit, }, }; }), ); }; const handleVariableDragStart = ( event: DragEvent, 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 } : window, ), ); }; const downloadResultsCsv = async () => { if (csvDownloadPending) { return; } setCsvDownloadPending(true); try { const response = await fetch("/api/simulation-results/csv", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ projectName: snapshot.project.name, variables: snapshot.result.variables, series: snapshot.result.series, }), }); if (!response.ok) { throw new Error(await responseErrorMessage(response)); } const filename = responseDownloadFilename( response, `${safeFileStem(snapshot.project.name)}-results.csv`, ); downloadBlob(await response.blob(), filename); } catch (error) { window.alert(`CSV 下载失败:${errorMessage(error)}`); } finally { setCsvDownloadPending(false); } }; return (
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)} panOnDrag selectionOnDrag={false} zoomOnDoubleClick={false} > 0} expectedNodeCount={readOnlyNodes.length} fitOnMount 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 displayValues = ( snapshot.result.series[chartWindow.variableKey] ?? [] ).map(selectedUnit.fromBase); const combinedVariables = windowVariables.filter( (item) => isMixed || item.unit === variable.unit, ); const visibleCombinedVariables = combinedVariables.filter( (item) => !chartWindow.hiddenVariableKeys.includes(item.key), ); const multiSeries = visibleCombinedVariables.map((item) => { const itemComponent = snapshot.project.nodes.find( (node) => node.id === item.componentId, ); const itemUnit = isMixed ? resolveResultUnitOption( item, chartWindow.variableUnits[item.key] ?? item.unit, ) : selectedUnit; return { key: item.key, label: resultVariableTitle( item, itemComponent?.data.label ?? item.componentId, ), color: colorForVariable(item.key), unit: itemUnit.label, values: (snapshot.result.series[item.key] ?? []).map( itemUnit.fromBase, ), }; }); return (
{ 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}
{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 ? ( ) : isCombined ? ( ) : ( )}
); })} {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[]; }; type MultiCurvePlotProps = { time: number[]; series: MultiCurveSeries[]; title: string; unit: string; cursorEnabled: boolean; }; function MultiCurvePlot({ time, series, title, unit, cursorEnabled, }: MultiCurvePlotProps) { const bodyRef = useRef(null); const cursorPanelRef = useRef(null); const cursorPanelDragRef = useRef(null); const cursorLineDragRef = useRef(null); 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]); if (plottedSeries.length === 0) { return (
无可绘制数据
); } const { width, height } = chartSize; const maximumLegendRows = Math.max( 1, Math.floor(Math.min(70, height * 0.26) / 14), ); const legendColumns = Math.max( 1, Math.ceil(plottedSeries.length / maximumLegendRows), ); const legendRows = Math.ceil(plottedSeries.length / legendColumns); const allSamples = plottedSeries.flatMap((curve) => curve.samples); const rawYMin = Math.min(...allSamples.map((sample) => sample.y)); const rawYMax = Math.max(...allSamples.map((sample) => sample.y)); const sharedYPadding = rawYMin === rawYMax ? Math.max(Math.abs(rawYMin) * 0.05, 1) : (rawYMax - rawYMin) * 0.08; const sharedYScale = { minimum: rawYMin - sharedYPadding, maximum: rawYMax + sharedYPadding, }; const margin = { top: 25 + legendRows * 14, right: 18, bottom: 40, left: 68, }; const plotWidth = Math.max(40, width - margin.left - margin.right); const plotHeight = Math.max(30, height - margin.top - margin.bottom); const xMin = Math.min(...allSamples.map((sample) => sample.x)); const xMax = Math.max(...allSamples.map((sample) => sample.x)); const xRange = Math.max(xMax - xMin, 1e-12); const xPosition = (value: number) => margin.left + ((value - xMin) / xRange) * plotWidth; const yPosition = (value: number) => { const range = Math.max( sharedYScale.maximum - sharedYScale.minimum, 1e-12, ); return ( margin.top + (1 - (value - sharedYScale.minimum) / range) * plotHeight ); }; const xTicks = chartTicks(xMin, xMax, 5); const yTicks = chartTicks( sharedYScale.minimum, sharedYScale.maximum, 5, ); const legendWidth = plotWidth / legendColumns; const activeCursorIndex = cursorIndex === null ? Math.floor(cursorSamples.length / 2) : Math.min(cursorIndex, cursorSamples.length - 1); const cursorSample = cursorSamples[activeCursorIndex]; const cursorReadings = cursorSample ? plottedSeries .map((curve, index) => ({ color: curve.color, index, label: curve.label, unit: curve.unit, y: Number(curve.values[Math.round(cursorSample.y)]), })) .filter((reading) => Number.isFinite(reading.y)) : []; const updateCursorByX = (clientX: number, svg: SVGSVGElement) => { const bounds = svg.getBoundingClientRect(); if (bounds.width <= 0 || cursorSamples.length === 0) { return; } const svgX = ((clientX - bounds.left) / bounds.width) * width; const clampedX = Math.min( width - margin.right, Math.max(margin.left, svgX), ); const targetX = xMin + ((clampedX - margin.left) / plotWidth) * xRange; setCursorIndex(nearestSampleIndex(cursorSamples, targetX)); }; const startCursorLineDrag = ( event: ReactPointerEvent, ) => { 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; }; return (
{ if (cursorEnabled) { event.preventDefault(); updateCursorByX(event.clientX, event.currentTarget); } }} role="img" 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 = curve.samples .map( (sample, index) => `${index === 0 ? "M" : "L"} ${xPosition(sample.x).toFixed(2)} ${yPosition(sample.y).toFixed(2)}`, ) .join(" "); return ( ); })} {cursorEnabled && cursorSample ? ( <> {cursorReadings.map((reading) => ( ))} ) : 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; }; function StackedCurvePlot({ time, series, cursorEnabled, }: StackedCurvePlotProps) { const bodyRef = useRef(null); const cursorPanelRef = useRef(null); const cursorPanelDragRef = useRef(null); const cursorLineDragRef = useRef(null); 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]); if (plottedSeries.length === 0) { return (
无可绘制数据
); } const { width, height } = chartSize; const margin = { top: 4, right: 18, bottom: 28, left: 68, }; const plotWidth = Math.max(40, width - margin.left - margin.right); const availableHeight = Math.max( 80, height - margin.top - margin.bottom, ); const bandHeight = availableHeight / plottedSeries.length; const allSamples = plottedSeries.flatMap((curve) => curve.samples); const xMin = Math.min(...allSamples.map((sample) => sample.x)); const xMax = Math.max(...allSamples.map((sample) => sample.x)); const xRange = Math.max(xMax - xMin, 1e-12); const xPosition = (value: number) => margin.left + ((value - xMin) / xRange) * plotWidth; const xTicks = chartTicks(xMin, xMax, 5); const plotBands = plottedSeries.map((curve, index) => { const rawMinimum = Math.min(...curve.samples.map((sample) => sample.y)); const rawMaximum = Math.max(...curve.samples.map((sample) => sample.y)); const padding = rawMinimum === rawMaximum ? Math.max(Math.abs(rawMinimum) * 0.05, 1) : (rawMaximum - rawMinimum) * 0.08; const minimum = rawMinimum - padding; const maximum = rawMaximum + padding; const range = Math.max(maximum - minimum, 1e-12); const bandTop = margin.top + index * bandHeight; const plotTop = bandTop + 20; const plotBottom = Math.max(plotTop + 22, bandTop + bandHeight - 5); const plotHeight = plotBottom - plotTop; return { curve, index, bandTop, plotTop, plotBottom, yTicks: chartTicks(minimum, maximum, bandHeight < 105 ? 2 : 3), yPosition: (value: number) => plotTop + (1 - (value - minimum) / range) * plotHeight, }; }); const activeCursorIndex = cursorIndex === null ? Math.floor(cursorSamples.length / 2) : Math.min(cursorIndex, cursorSamples.length - 1); const cursorSample = cursorSamples[activeCursorIndex]; const cursorReadings = cursorSample ? plottedSeries .map((curve, index) => ({ color: curve.color, index, label: curve.label, unit: curve.unit, y: Number(curve.values[Math.round(cursorSample.y)]), })) .filter((reading) => Number.isFinite(reading.y)) : []; const cursorTop = plotBands[0].plotTop; const cursorBottom = plotBands[plotBands.length - 1].plotBottom; const updateCursorByX = (clientX: number, svg: SVGSVGElement) => { const bounds = svg.getBoundingClientRect(); if (bounds.width <= 0 || cursorSamples.length === 0) { return; } const svgX = ((clientX - bounds.left) / bounds.width) * width; const clampedX = Math.min( width - margin.right, Math.max(margin.left, svgX), ); const targetX = xMin + ((clampedX - margin.left) / plotWidth) * xRange; setCursorIndex(nearestSampleIndex(cursorSamples, targetX)); }; const startCursorLineDrag = ( event: ReactPointerEvent, ) => { 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; }; return (
{ if (cursorEnabled) { event.preventDefault(); updateCursorByX(event.clientX, event.currentTarget); } }} role="img" viewBox={`0 0 ${width} ${height}`} > {plotBands.map((band) => { const isLast = band.index === plotBands.length - 1; const path = band.curve.samples .map( (sample, sampleIndex) => `${sampleIndex === 0 ? "M" : "L"} ${xPosition(sample.x).toFixed(2)} ${band.yPosition(sample.y).toFixed(2)}`, ) .join(" "); return ( {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) => ( ))} {cursorReadings.map((reading) => { const band = plotBands[reading.index]; return band ? ( ) : null; })} ) : 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; }; type CurveSample = { x: number; y: number; }; type CursorPanelPosition = { x: number; y: number; }; type CursorPanelDragState = { pointerId: number; offsetX: number; offsetY: number; }; type CursorLineDragState = { pointerId: number; axis: "x" | "y"; }; function CurvePlot({ time, values, title, unit, color, cursorEnabled, }: CurvePlotProps) { const bodyRef = useRef(null); const cursorPanelRef = useRef(null); const cursorPanelDragRef = useRef(null); const cursorLineDragRef = useRef(null); 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]); if (samples.length < 2) { return (
无可绘制数据
); } const { width, height } = chartSize; const margin = { top: 24, right: 18, bottom: 40, left: 68 }; const plotWidth = width - margin.left - margin.right; const plotHeight = height - margin.top - margin.bottom; const xMin = Math.min(...samples.map((sample) => sample.x)); const xMax = Math.max(...samples.map((sample) => sample.x)); const rawYMin = Math.min(...samples.map((sample) => sample.y)); const rawYMax = Math.max(...samples.map((sample) => sample.y)); const yPadding = rawYMin === rawYMax ? Math.max(Math.abs(rawYMin) * 0.05, 1) : (rawYMax - rawYMin) * 0.08; const yMin = rawYMin - yPadding; const yMax = rawYMax + yPadding; const xRange = Math.max(xMax - xMin, 1e-12); const yRange = Math.max(yMax - yMin, 1e-12); const xPosition = (value: number) => margin.left + ((value - xMin) / xRange) * plotWidth; const yPosition = (value: number) => margin.top + (1 - (value - yMin) / yRange) * plotHeight; const xTicks = chartTicks(xMin, xMax, 5); const yTicks = chartTicks(yMin, yMax, 5); const path = samples .map( (sample, index) => `${index === 0 ? "M" : "L"} ${xPosition(sample.x).toFixed(2)} ${yPosition( sample.y, ).toFixed(2)}`, ) .join(" "); const activeCursorIndex = cursorIndex === null ? Math.floor(samples.length / 2) : Math.min(cursorIndex, samples.length - 1); const cursorSample = samples[activeCursorIndex]; const updateCursorByX = (clientX: number, svg: SVGSVGElement) => { const bounds = svg.getBoundingClientRect(); if (bounds.width <= 0) { return; } const svgX = ((clientX - bounds.left) / bounds.width) * width; const clampedX = Math.min( width - margin.right, Math.max(margin.left, svgX), ); const targetX = xMin + ((clampedX - margin.left) / plotWidth) * xRange; setCursorIndex(nearestSampleIndex(samples, targetX)); }; const updateCursorByY = (clientY: number, svg: SVGSVGElement) => { const bounds = svg.getBoundingClientRect(); if (bounds.height <= 0) { return; } const svgY = ((clientY - bounds.top) / bounds.height) * height; const clampedY = Math.min( height - margin.bottom, Math.max(margin.top, svgY), ); const targetY = yMax - ((clampedY - margin.top) / plotHeight) * yRange; setCursorIndex((current) => nearestSampleIndexByY( samples, targetY, current ?? activeCursorIndex, ), ); }; const startCursorLineDrag = ( event: ReactPointerEvent, 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; }; return (
{ if (cursorEnabled) { event.preventDefault(); updateCursorByX(event.clientX, event.currentTarget); } }} role="img" 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} 时间 (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 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) || typeof project.name !== "string" || (project.presentationLayoutVersion !== undefined && project.presentationLayoutVersion !== null && project.presentationLayoutVersion !== 1) || !Array.isArray(project.nodes) || !project.nodes.every(isResultProjectNode) || !Array.isArray(project.edges) || !project.edges.every(isResultProjectEdge) || !isResultSimulationConfig(project.simulation) || !isRecord(result) || typeof result.success !== "boolean" || typeof result.message !== "string" || !Array.isArray(result.variables) || !result.variables.every(isResultVariableMetadata) || !isRecord(result.final) || !isNumericRecord(result.final) || !isRecord(result.series) || !Object.values(result.series).every(isNumericArray) || !Array.isArray(result.series.time) || !isRecord(result.diagnostics) || typeof result.diagnostics.sampleCount !== "number" ) { return false; } return ( result.status === undefined || ["completed", "stopped", "stalled", "failed"].includes(String(result.status)) ); } function isResultProjectNode(value: unknown): value is ResultProjectNode { if (!isRecord(value) || !isRecord(value.position) || !isRecord(value.data)) { return false; } const data = value.data; return ( typeof value.id === "string" && value.type === "simulationComponent" && Number.isFinite(value.position.x) && Number.isFinite(value.position.y) && typeof data.label === "string" && typeof data.componentType === "string" && typeof data.modelType === "string" && Array.isArray(data.ports) && isRecord(data.parameters) && (data.parameterUnits === undefined || isRecord(data.parameterUnits)) && [0, 90, 180, 270].includes(Number(data.rotation)) && typeof data.mirrored === "boolean" ); } function isResultProjectEdge(value: unknown): value is ResultProjectEdge { return ( isRecord(value) && typeof value.id === "string" && typeof value.source === "string" && typeof value.target === "string" && (value.sourceHandle === null || typeof value.sourceHandle === "string") && (value.targetHandle === null || typeof value.targetHandle === "string") ); } function isResultSimulationConfig( value: unknown, ): value is ResultSimulationConfig { return ( isRecord(value) && Number.isFinite(value.t_start) && Number.isFinite(value.t_stop) && Number.isFinite(value.step) && Number.isFinite(value.max_step) && typeof value.method === "string" ); } function isResultVariableMetadata( value: unknown, ): value is ResultVariableMetadata { return ( isRecord(value) && typeof value.key === "string" && typeof value.componentId === "string" && typeof value.componentType === "string" && (value.scope === "component" || value.scope === "port") && (value.portName === null || typeof value.portName === "string") && typeof value.name === "string" && typeof value.label === "string" && typeof value.quantity === "string" && typeof value.unit === "string" && typeof value.category === "string" && Number.isFinite(value.order) ); } function isNumericRecord(value: Record) { 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 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" > >; function normalizeStoredChartWindow(window: StoredChartWindow): ChartWindow { const kind = window.kind === "multi" || window.kind === "mixed" ? window.kind : "single"; const rawVariableKeys = window.variableKeys; const hasStoredVariableKeys = Array.isArray(rawVariableKeys); const storedVariableKeys = hasStoredVariableKeys ? rawVariableKeys.filter( (key, index, keys) => typeof key === "string" && key.length > 0 && keys.indexOf(key) === index, ) : []; const variableKeys = kind === "single" ? window.variableKey ? [window.variableKey] : [] : hasStoredVariableKeys ? storedVariableKeys : window.variableKey ? [window.variableKey] : []; const hiddenVariableKeys = Array.isArray(window.hiddenVariableKeys) ? window.hiddenVariableKeys.filter( (key, index, keys) => typeof key === "string" && variableKeys.includes(key) && keys.indexOf(key) === index, ) : []; const variableUnits = window.variableUnits && isRecord(window.variableUnits) ? Object.fromEntries( Object.entries(window.variableUnits).filter( ([key, value]) => variableKeys.includes(key) && typeof value === "string", ), ) : {}; return { ...window, kind, variableKey: variableKeys[0] ?? "", variableKeys, hiddenVariableKeys, variableUnits, width: typeof window.width === "number" && Number.isFinite(window.width) ? window.width : CHART_WINDOW_WIDTH, height: typeof window.height === "number" && Number.isFinite(window.height) ? window.height : CHART_WINDOW_HEIGHT, unit: typeof window.unit === "string" ? window.unit : "", cursorEnabled: window.cursorEnabled === true, }; } function isStoredChartWindow(value: unknown): value is StoredChartWindow { if (!value || typeof value !== "object") { return false; } const window = value as Partial; 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, }); }