import { ChangeEvent, DragEvent, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react"; import type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent, } from "react"; import { BellRing, ChevronDown, ChevronUp, ClipboardPaste, Copy, Download, FileCode2, FileDown, FileUp, FlipHorizontal2, FolderOpen, Grid2X2, Grip, ListChecks, Magnet, Maximize2, Minimize2, Move, Play, Redo2, RotateCw, Save, Square, SquareTerminal, Trash2, Undo2, type LucideIcon, } from "lucide-react"; import { Background, BackgroundVariant, ConnectionLineType, ConnectionMode, Controls, Handle, Panel, Position, ReactFlow, ReactFlowProvider, SelectionMode, addEdge, type Connection, type Edge, type EdgeChange, type Node, type NodeChange, type NodeProps, type NodeTypes, useEdgesState, useNodesState, useReactFlow, useUpdateNodeInternals, } from "@xyflow/react"; import { EmptySimulationResultsView, SimulationResultsView, type ResultVariableMetadata, type SimulationResultsSnapshot, } from "./SimulationResultsView"; import { WorkspaceViewTabs, type WorkspaceView, } from "./WorkspaceViewTabs"; type ParameterValue = number | string; type UnitQuantity = "pressure" | "volume" | "temperature" | "length"; type PortKind = "physical" | "signal"; type PortNominalRole = "inlet" | "outlet" | "bidirectional" | "input" | "output"; type PortSide = "left" | "right"; type NodeRotation = 0 | 90 | 180 | 270; type PortDefinition = { name: string; kind: PortKind; domain: string; nominalRole: PortNominalRole; positiveFlowDirection?: "intoComponent"; side: PortSide; }; type UnitOption = { value: string; label: string; toBase: (value: number) => number; fromBase: (value: number) => number; }; type ParameterDefinition = { label: string; unit?: string; quantity?: UnitQuantity; default: ParameterValue; min?: number; max?: number; minExclusive?: boolean; }; type ComponentDefinition = { type: string; label: string; modelType: string; ports: PortDefinition[]; parameters: Record; }; type SimulationNodeData = { label: string; componentType: string; modelType: string; ports: PortDefinition[]; parameters: Record; parameterUnits: Record; rotation: NodeRotation; mirrored: boolean; }; type SimulationNode = Node; type SimulationEdge = Edge; type SimulationConfig = { t_start: number; t_stop: number; step: number; max_step: number; method: string; }; type ProjectNodePayload = { id: string; type: "simulationComponent"; position: { x: number; y: number; }; data: Omit & { parameterUnits?: Record; }; }; type ProjectEdgePayload = { id: string; source: string; target: string; sourceHandle: string | null; targetHandle: string | null; }; type ReactFlowProjectPayload = { name: string; nodes: ProjectNodePayload[]; edges: ProjectEdgePayload[]; simulation: SimulationConfig; }; type SimulationResult = { 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; }; }; type ValidationIssue = { id: string; severity: "error" | "warning"; message: string; nodeId?: string; edgeId?: string; }; type EditorSnapshot = { nodes: SimulationNode[]; edges: SimulationEdge[]; projectName: string; simulationConfig: SimulationConfig; }; type EditorClipboard = { nodes: SimulationNode[]; edges: SimulationEdge[]; }; type ModelingPaneLayout = { paletteWidth: number; propertiesWidth: number; }; type ModelingPaneResizeKind = "palette-width" | "properties-width"; type ModelingPaneResizeState = { kind: ModelingPaneResizeKind; pointerId: number; startX: number; startWidth: number; }; type CanvasGridVisibility = { lines: boolean; dots: boolean; }; const AUTOSAVE_KEY = "system-simulation-flow:autosave"; const PROJECT_KEY_PREFIX = "system-simulation-flow:project:"; const RESULT_SNAPSHOT_KEY = "system-simulation-flow:latest-result"; const CONSOLE_PLACEMENT_KEY = "system-simulation-flow:console-positions"; const MODELING_PANE_LAYOUT_KEY = "system-simulation-flow:modeling-pane-layout"; const CANVAS_GRID_VISIBILITY_KEY = "system-simulation-flow:canvas-grid-visibility"; const CONSOLE_VIEWPORT_MARGIN = 8; const SIMULATION_STREAM_IDLE_TIMEOUT_MS = 30_000; const SIMULATION_SOLVER_STALL_TIMEOUT_MS = 60_000; const GRID_SIZE = 18; const MODELING_PANE_SPLITTER_SIZE = 7; const MIN_PALETTE_WIDTH = 180; const MAX_PALETTE_WIDTH = 420; const MIN_PROPERTIES_WIDTH = 280; const MAX_PROPERTIES_WIDTH = 560; const MIN_MODELING_CANVAS_WIDTH = 420; const MODELING_PANE_KEYBOARD_STEP = 20; const HISTORY_LIMIT = 50; const NODE_FRAME_WIDTH = 132; const NODE_FRAME_HEIGHT = 84; function defaultModelingPaneLayout(): ModelingPaneLayout { return { paletteWidth: 230, propertiesWidth: 330, }; } function loadModelingPaneLayout(): ModelingPaneLayout { const fallback = defaultModelingPaneLayout(); try { const parsed = JSON.parse( localStorage.getItem(MODELING_PANE_LAYOUT_KEY) ?? "null", ) as Partial | null; if ( !parsed || !Number.isFinite(parsed.paletteWidth) || !Number.isFinite(parsed.propertiesWidth) ) { return fallback; } return { paletteWidth: Math.min( MAX_PALETTE_WIDTH, Math.max(MIN_PALETTE_WIDTH, Number(parsed.paletteWidth)), ), propertiesWidth: Math.min( MAX_PROPERTIES_WIDTH, Math.max(MIN_PROPERTIES_WIDTH, Number(parsed.propertiesWidth)), ), }; } catch { return fallback; } } function clampModelingPaneLayout( layout: ModelingPaneLayout, workbenchWidth: number, ): ModelingPaneLayout { let paletteWidth = Math.min( MAX_PALETTE_WIDTH, Math.max(MIN_PALETTE_WIDTH, layout.paletteWidth), ); let propertiesWidth = Math.min( MAX_PROPERTIES_WIDTH, Math.max(MIN_PROPERTIES_WIDTH, layout.propertiesWidth), ); const availableSideWidth = Math.max( MIN_PALETTE_WIDTH + MIN_PROPERTIES_WIDTH, workbenchWidth - MIN_MODELING_CANVAS_WIDTH - MODELING_PANE_SPLITTER_SIZE * 2, ); let overflow = paletteWidth + propertiesWidth - availableSideWidth; if (overflow > 0) { const propertiesReduction = Math.min( overflow, propertiesWidth - MIN_PROPERTIES_WIDTH, ); propertiesWidth -= propertiesReduction; overflow -= propertiesReduction; } if (overflow > 0) { paletteWidth -= Math.min( overflow, paletteWidth - MIN_PALETTE_WIDTH, ); } return { paletteWidth, propertiesWidth }; } function loadCanvasGridVisibility(): CanvasGridVisibility { try { const parsed = JSON.parse( localStorage.getItem(CANVAS_GRID_VISIBILITY_KEY) ?? "null", ) as Partial | null; if ( parsed && typeof parsed.lines === "boolean" && typeof parsed.dots === "boolean" ) { return { lines: parsed.lines, dots: parsed.dots }; } } catch { // Use the default view when browser storage is unavailable or invalid. } return { lines: false, dots: true }; } const identityUnit = (value: number) => value; const scaleUnit = (factor: number) => ({ toBase: (value: number) => value * factor, fromBase: (value: number) => value / factor, }); const unitOptions: Record = { pressure: [ { value: "Pa", label: "Pa", toBase: identityUnit, fromBase: identityUnit }, { value: "kPa", label: "kPa", ...scaleUnit(1_000) }, { value: "MPa", label: "MPa", ...scaleUnit(1_000_000) }, { value: "bar", label: "bar", ...scaleUnit(100_000) }, ], volume: [ { value: "m3", label: "m³", toBase: identityUnit, fromBase: identityUnit }, { value: "L", label: "L", ...scaleUnit(0.001) }, { value: "mL", label: "mL", ...scaleUnit(0.000001) }, ], temperature: [ { value: "K", label: "K", toBase: identityUnit, fromBase: identityUnit }, { value: "degC", label: "°C", toBase: (value) => value + 273.15, fromBase: (value) => value - 273.15, }, ], length: [ { value: "m", label: "m", toBase: identityUnit, fromBase: identityUnit }, { value: "cm", label: "cm", ...scaleUnit(0.01) }, { value: "mm", label: "mm", ...scaleUnit(0.001) }, ], }; const defaultSimulationConfig: SimulationConfig = { t_start: 0, t_stop: 2, step: 0.1, max_step: 0.005, method: "BDF", }; type SimulationConsoleLevel = "info" | "success" | "warning" | "error"; type SimulationConsoleMode = "minimized" | "normal" | "maximized"; type ConsolePosition = { x: number; y: number; }; type ConsolePlacement = ConsolePosition & { horizontalAnchor: "left" | "right"; verticalAnchor: "top" | "bottom"; }; type ConsoleDragState = { pointerId: number; offsetX: number; offsetY: number; width: number; height: number; }; type SimulationConsoleEntry = { id: number; timestamp: string; level: SimulationConsoleLevel; message: string; }; type SimulationProgressState = { state: "idle" | "running" | "success" | "warning" | "error"; percent: number; message: string; startTime: number; simulatedTime: number; totalTime: number; }; type SimulationStreamIssue = { code?: string; message?: string; }; type SimulationCancelReason = "user" | "stalled"; type SimulationTaskResponse = { simulationId: string; status: "queued" | "running" | "cancelling" | "completed" | "stopped" | "stalled" | "failed"; cancelReason: SimulationCancelReason | null; result: SimulationResult | null; error: SimulationStreamEvent | null; }; type SimulationStreamEvent = | { event: "progress"; progress: number; phase: string; message: string; heartbeat?: boolean; simulatedTime?: number; totalTime?: number; } | { event: "result"; progress: number; phase: "complete"; message: string; simulatedTime?: number; totalTime?: number; result: SimulationResult; } | { event: "error"; progress: number; phase: "failed"; status: number; message: string; detail?: unknown; simulatedTime?: number; totalTime?: number; }; const SUPPORTED_SOLVER_METHODS = ["BDF", "Radau", "LSODA", "RK45", "RK23", "DOP853"]; function physicalPort( name: string, nominalRole: Extract, side: PortSide, ): PortDefinition { return { name, kind: "physical", domain: "pneumatic", nominalRole, positiveFlowDirection: "intoComponent", side, }; } const componentDefinitions: ComponentDefinition[] = [ { type: "cylinder", label: "气瓶", modelType: "cylinder", ports: [physicalPort("port_b", "outlet", "right")], parameters: { volume: { label: "容积", unit: "m3", quantity: "volume", default: 0.01, min: 0, minExclusive: true, }, p0: { label: "初始压力", unit: "Pa", quantity: "pressure", default: 35000000, min: 0 }, T0: { label: "初始温度", unit: "K", quantity: "temperature", default: 300, min: 0, minExclusive: true, }, }, }, { type: "tank", label: "贮箱", modelType: "tank", ports: [physicalPort("port_a", "inlet", "left")], parameters: { volume: { label: "容积", unit: "m3", quantity: "volume", default: 0.1, min: 0, minExclusive: true, }, p0: { label: "初始压力", unit: "Pa", quantity: "pressure", default: 100000, min: 0 }, T0: { label: "初始温度", unit: "K", quantity: "temperature", default: 300, min: 0, minExclusive: true, }, }, }, { type: "pipe", label: "管段", modelType: "pipe", ports: [ physicalPort("port_a", "inlet", "left"), physicalPort("port_b", "outlet", "right"), ], parameters: { length: { label: "长度", unit: "m", quantity: "length", default: 5, min: 0, minExclusive: true, }, diameter: { label: "直径", unit: "m", quantity: "length", default: 0.02, min: 0, minExclusive: true, }, lambda_darcy: { label: "摩阻系数", default: 0.02, min: 0 }, p0: { label: "初始压力", unit: "Pa", quantity: "pressure", default: 100000, min: 0 }, T0: { label: "初始温度", unit: "K", quantity: "temperature", default: 300, min: 0, minExclusive: true, }, }, }, { type: "orifice", label: "孔板/阀门", modelType: "orifice", ports: [ physicalPort("port_a", "inlet", "left"), physicalPort("port_b", "outlet", "right"), ], parameters: { K: { label: "流量系数", default: 0.00001, min: 0 }, opening: { label: "开度", default: 1, min: 0, max: 1 }, }, }, { type: "tee", label: "三通", modelType: "tee", ports: [ physicalPort("port_in", "bidirectional", "left"), physicalPort("port_out1", "bidirectional", "right"), physicalPort("port_out2", "bidirectional", "right"), ], parameters: {}, }, ]; const nextNodeNumberByType: Record = {}; function defaultParameters(definition: ComponentDefinition) { return Object.fromEntries( Object.entries(definition.parameters).map(([key, parameter]) => [ key, parameter.default, ]), ); } function defaultParameterUnits(definition: ComponentDefinition) { return Object.fromEntries( Object.entries(definition.parameters) .filter(([, parameter]) => parameter.unit) .map(([key, parameter]) => [key, parameter.unit as string]), ); } function buildNodeData(definition: ComponentDefinition): SimulationNodeData { return { label: definition.label, componentType: definition.type, modelType: definition.modelType, ports: definition.ports.map((port) => ({ ...port })), parameters: defaultParameters(definition), parameterUnits: defaultParameterUnits(definition), rotation: 0, mirrored: false, }; } function SimulationComponentNode({ id, data, selected }: NodeProps) { const updateNodeInternals = useUpdateNodeInternals(); const rotation = normalizeNodeRotation(data.rotation); const mirrored = Boolean(data.mirrored); const vertical = rotation === 90 || rotation === 270; const portPlacements = transformedPortPlacements(data.ports, rotation, mirrored); useEffect(() => { updateNodeInternals(id); }, [mirrored, rotation, id, updateNodeInternals]); return (
{portPlacements.map(({ port, position, x, y }) => ( ))}
{data.label}
); } function normalizeNodeRotation(value: unknown): NodeRotation { return value === 90 || value === 180 || value === 270 ? value : 0; } function nextNodeRotation(value: unknown): NodeRotation { return ((normalizeNodeRotation(value) + 90) % 360) as NodeRotation; } function nodeFrameDimensions(rotation: NodeRotation) { const vertical = rotation === 90 || rotation === 270; return vertical ? { width: NODE_FRAME_HEIGHT, height: NODE_FRAME_WIDTH } : { width: NODE_FRAME_WIDTH, height: NODE_FRAME_HEIGHT }; } function rotateNodeClockwise(node: SimulationNode): SimulationNode { const currentRotation = normalizeNodeRotation(node.data.rotation); const nextRotation = nextNodeRotation(currentRotation); const currentSize = nodeFrameDimensions(currentRotation); const nextSize = nodeFrameDimensions(nextRotation); return { ...node, position: { x: node.position.x + (currentSize.width - nextSize.width) / 2, y: node.position.y + (currentSize.height - nextSize.height) / 2, }, data: { ...node.data, rotation: nextRotation, }, }; } function portHandleType(port: PortDefinition) { if (port.kind === "signal") { return port.nominalRole === "output" ? ("source" as const) : ("target" as const); } return "source" as const; } function transformedPortPlacements( ports: PortDefinition[], rotation: NodeRotation, mirrored: boolean, ) { const renderedSize = nodeFrameDimensions(rotation); return (["left", "right"] as PortSide[]).flatMap((side) => { const sidePorts = ports.filter((port) => port.side === side); return sidePorts.map((port, index) => { let x = side === "left" ? -NODE_FRAME_WIDTH / 2 : NODE_FRAME_WIDTH / 2; let y = -NODE_FRAME_HEIGHT / 2 + ((index + 1) * NODE_FRAME_HEIGHT) / (sidePorts.length + 1); if (mirrored) { x = -x; } [x, y] = rotatePoint(x, y, rotation); const renderedX = renderedSize.width / 2 + x; const renderedY = renderedSize.height / 2 + y; return { port, position: portPosition(renderedX, renderedY, renderedSize), x: renderedX, y: renderedY, }; }); }); } function rotatePoint(x: number, y: number, rotation: NodeRotation): [number, number] { if (rotation === 90) { return [-y, x]; } if (rotation === 180) { return [-x, -y]; } if (rotation === 270) { return [y, -x]; } return [x, y]; } function portPosition( x: number, y: number, size: { width: number; height: number }, ) { if (Math.abs(x) < 0.01) { return Position.Left; } if (Math.abs(x - size.width) < 0.01) { return Position.Right; } if (Math.abs(y) < 0.01) { return Position.Top; } return Position.Bottom; } function symbolForComponent(type: string) { if (type === "cylinder") { return
; } if (type === "tank") { return
; } if (type === "pipe") { return
; } if (type === "orifice") { return
; } return
; } type ToolbarButtonProps = { label: string; icon: LucideIcon; onClick: () => void; disabled?: boolean; pressed?: boolean; accent?: boolean; primary?: boolean; }; function ToolbarButton({ label, icon: Icon, onClick, disabled = false, pressed, accent = false, primary = false, }: ToolbarButtonProps) { return ( ); } type SimulationConsoleProps = { entries: SimulationConsoleEntry[]; mode: SimulationConsoleMode; progress: SimulationProgressState; unreadCount: number; onClear: () => void; onModeChange: (mode: SimulationConsoleMode) => void; onStop: () => void; stopPending: boolean; }; const consoleLevelLabels: Record = { info: "信息", success: "完成", warning: "警告", error: "错误", }; function simulationProgressClockText(progress: SimulationProgressState) { const simulatedTime = Number.isFinite(progress.simulatedTime) ? progress.simulatedTime : progress.startTime; const totalTime = Number.isFinite(progress.totalTime) ? progress.totalTime : progress.startTime; return `${simulatedTime.toFixed(2)} s / ${totalTime.toFixed(2)} s`; } function simulationTimePercent( startTime: number, simulatedTime: number, totalTime: number, ) { const duration = totalTime - startTime; if (!Number.isFinite(duration) || duration <= 0) { return 0; } return Math.min( 100, Math.max(0, Math.round((100 * (simulatedTime - startTime)) / duration)), ); } type NormalizedSimulationProgress = { percent: number; simulatedTime: number; totalTime: number; }; function normalizeSimulationProgressEvent( event: Extract, config: SimulationConfig, ): NormalizedSimulationProgress { const totalTime = Number.isFinite(event.totalTime) ? Number(event.totalTime) : config.t_stop; const preparationPhase = [ "queued", "validation", "compilation", "initialization", "initializing", ].includes(event.phase); if (preparationPhase) { return { percent: 0, simulatedTime: config.t_start, totalTime, }; } if (Number.isFinite(event.simulatedTime)) { const simulatedTime = Math.min( totalTime, Math.max(config.t_start, Number(event.simulatedTime)), ); return { percent: simulationTimePercent( config.t_start, simulatedTime, totalTime, ), simulatedTime, totalTime, }; } // Older backends mapped solver progress into the 12%-98% range. const legacyModelProgress = (event.progress - 12) / 86; const legacyTimeFraction = Math.min( 1, Math.max(0, (legacyModelProgress - 0.05) / 0.75), ); const simulatedTime = config.t_start + legacyTimeFraction * (totalTime - config.t_start); return { percent: simulationTimePercent( config.t_start, simulatedTime, totalTime, ), simulatedTime, totalTime, }; } function isConsolePosition(value: unknown): value is ConsolePosition { if (!value || typeof value !== "object") { return false; } const position = value as Partial; return ( typeof position.x === "number" && Number.isFinite(position.x) && typeof position.y === "number" && Number.isFinite(position.y) ); } function isConsolePlacement(value: unknown): value is ConsolePlacement { if (!isConsolePosition(value)) { return false; } const placement = value as Partial; return ( (placement.horizontalAnchor === "left" || placement.horizontalAnchor === "right") && (placement.verticalAnchor === "top" || placement.verticalAnchor === "bottom") ); } function consolePlacementFromPosition( position: ConsolePosition, width: number, height: number, horizontalAnchor?: ConsolePlacement["horizontalAnchor"], verticalAnchor?: ConsolePlacement["verticalAnchor"], ): ConsolePlacement { const nextHorizontalAnchor = horizontalAnchor ?? (position.x + width / 2 <= window.innerWidth / 2 ? "left" : "right"); const nextVerticalAnchor = verticalAnchor ?? (position.y + height / 2 <= window.innerHeight / 2 ? "top" : "bottom"); return { x: Math.round(nextHorizontalAnchor === "left" ? position.x : position.x + width), y: Math.round(nextVerticalAnchor === "top" ? position.y : position.y + height), horizontalAnchor: nextHorizontalAnchor, verticalAnchor: nextVerticalAnchor, }; } function estimateConsoleSize(mode: "minimized" | "normal") { const mobile = window.innerWidth <= 640; const viewportPadding = mobile ? 16 : 28; return { width: Math.min(mode === "minimized" ? 480 : 760, window.innerWidth - viewportPadding), height: mode === "minimized" ? 40 : mobile ? Math.min(300, window.innerHeight - 16) : 270, }; } function loadConsolePlacement(): ConsolePlacement | undefined { try { const storedValue = localStorage.getItem(CONSOLE_PLACEMENT_KEY); if (!storedValue) { return undefined; } const parsedValue = JSON.parse(storedValue) as Record; if (isConsolePlacement(parsedValue)) { return parsedValue; } // 将旧版的分模式坐标迁移为一份共享锚点。 const legacyMode = isConsolePosition(parsedValue.minimized) ? "minimized" : isConsolePosition(parsedValue.normal) ? "normal" : null; if (!legacyMode) { return undefined; } const legacyPosition = parsedValue[legacyMode] as ConsolePosition; const size = estimateConsoleSize(legacyMode); return consolePlacementFromPosition( clampConsolePosition(legacyPosition, size.width, size.height), size.width, size.height, ); } catch { return undefined; } } function storeConsolePlacement(placement: ConsolePlacement | undefined) { try { if (placement) { localStorage.setItem(CONSOLE_PLACEMENT_KEY, JSON.stringify(placement)); } else { localStorage.removeItem(CONSOLE_PLACEMENT_KEY); } } catch { // The console remains movable when browser storage is unavailable. } } function clampConsolePosition( position: ConsolePosition, width: number, height: number, ): ConsolePosition { const maxX = Math.max( CONSOLE_VIEWPORT_MARGIN, window.innerWidth - width - CONSOLE_VIEWPORT_MARGIN, ); const maxY = Math.max( CONSOLE_VIEWPORT_MARGIN, window.innerHeight - height - CONSOLE_VIEWPORT_MARGIN, ); return { x: Math.round(Math.min(Math.max(CONSOLE_VIEWPORT_MARGIN, position.x), maxX)), y: Math.round(Math.min(Math.max(CONSOLE_VIEWPORT_MARGIN, position.y), maxY)), }; } function clampConsolePlacement( placement: ConsolePlacement, width: number, height: number, ): ConsolePlacement { const unclampedPosition = { x: placement.horizontalAnchor === "left" ? placement.x : placement.x - width, y: placement.verticalAnchor === "top" ? placement.y : placement.y - height, }; const position = clampConsolePosition(unclampedPosition, width, height); return consolePlacementFromPosition( position, width, height, placement.horizontalAnchor, placement.verticalAnchor, ); } function consolePlacementsEqual( first: ConsolePlacement, second: ConsolePlacement, ) { return ( first.x === second.x && first.y === second.y && first.horizontalAnchor === second.horizontalAnchor && first.verticalAnchor === second.verticalAnchor ); } function SimulationConsole({ entries, mode, progress, unreadCount, onClear, onModeChange, onStop, stopPending, }: SimulationConsoleProps) { const logRef = useRef(null); const consoleRef = useRef(null); const dragStateRef = useRef(null); const [placement, setPlacement] = useState( loadConsolePlacement, ); const [dragging, setDragging] = useState(false); useEffect(() => { if (mode !== "minimized" && logRef.current) { logRef.current.scrollTop = logRef.current.scrollHeight; } }, [entries, mode]); useEffect(() => { storeConsolePlacement(placement); }, [placement]); useEffect(() => { dragStateRef.current = null; setDragging(false); }, [mode]); useLayoutEffect(() => { if (mode === "maximized") { return; } const consoleElement = consoleRef.current; if (!consoleElement) { return; } const bounds = consoleElement.getBoundingClientRect(); setPlacement((currentPlacement) => { const nextPlacement = currentPlacement ? clampConsolePlacement(currentPlacement, bounds.width, bounds.height) : consolePlacementFromPosition( { x: bounds.left, y: bounds.top }, bounds.width, bounds.height, ); return currentPlacement && consolePlacementsEqual(currentPlacement, nextPlacement) ? currentPlacement : nextPlacement; }); }, [mode]); useEffect(() => { if (mode === "maximized") { return undefined; } const clampCurrentPlacement = () => { const consoleElement = consoleRef.current; if (!consoleElement) { return; } const bounds = consoleElement.getBoundingClientRect(); setPlacement((currentPlacement) => { if (!currentPlacement) { return currentPlacement; } const nextPlacement = clampConsolePlacement( currentPlacement, bounds.width, bounds.height, ); return consolePlacementsEqual(currentPlacement, nextPlacement) ? currentPlacement : nextPlacement; }); }; window.addEventListener("resize", clampCurrentPlacement); return () => window.removeEventListener("resize", clampCurrentPlacement); }, [mode]); const requestConsoleMode = (nextMode: SimulationConsoleMode) => { if (mode !== "maximized" && consoleRef.current) { const bounds = consoleRef.current.getBoundingClientRect(); setPlacement( consolePlacementFromPosition( { x: bounds.left, y: bounds.top }, bounds.width, bounds.height, ), ); } onModeChange(nextMode); }; const startConsoleDrag = (event: ReactPointerEvent) => { if (mode === "maximized" || event.button !== 0 || !consoleRef.current) { return; } event.preventDefault(); event.stopPropagation(); const bounds = consoleRef.current.getBoundingClientRect(); dragStateRef.current = { pointerId: event.pointerId, offsetX: event.clientX - bounds.left, offsetY: event.clientY - bounds.top, width: bounds.width, height: bounds.height, }; setPlacement( consolePlacementFromPosition( { x: bounds.left, y: bounds.top }, bounds.width, bounds.height, ), ); event.currentTarget.setPointerCapture(event.pointerId); setDragging(true); }; const moveConsole = (event: ReactPointerEvent) => { const dragState = dragStateRef.current; if (!dragState || dragState.pointerId !== event.pointerId) { return; } event.preventDefault(); const nextPosition = clampConsolePosition( { x: event.clientX - dragState.offsetX, y: event.clientY - dragState.offsetY, }, dragState.width, dragState.height, ); setPlacement( consolePlacementFromPosition( nextPosition, dragState.width, dragState.height, ), ); }; const stopConsoleDrag = (event: ReactPointerEvent) => { const dragState = dragStateRef.current; if (!dragState || dragState.pointerId !== event.pointerId) { return; } if (event.currentTarget.hasPointerCapture(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId); } dragStateRef.current = null; setDragging(false); }; const resetConsolePosition = () => { if (mode === "maximized") { return; } const consoleElement = consoleRef.current; if (!consoleElement) { return; } const bounds = consoleElement.getBoundingClientRect(); const viewportInset = window.innerWidth <= 640 ? 8 : 14; const defaultPosition = mode === "minimized" ? { x: window.innerWidth - bounds.width - viewportInset, y: window.innerHeight - bounds.height - viewportInset, } : { x: (window.innerWidth - bounds.width) / 2, y: window.innerHeight - bounds.height - viewportInset, }; setPlacement( consolePlacementFromPosition( clampConsolePosition(defaultPosition, bounds.width, bounds.height), bounds.width, bounds.height, ), ); }; const canStop = progress.state === "running" && !stopPending; const consoleStyle: CSSProperties | undefined = mode !== "maximized" && placement ? { bottom: placement.verticalAnchor === "bottom" ? Math.round(window.innerHeight - placement.y) : "auto", left: placement.horizontalAnchor === "left" ? placement.x : "auto", right: placement.horizontalAnchor === "right" ? Math.round(window.innerWidth - placement.x) : "auto", top: placement.verticalAnchor === "top" ? placement.y : "auto", transform: "none", } : undefined; if (mode === "minimized") { const latestMessage = entries.at(-1)?.message ?? "等待仿真任务"; return ( ); } return ( ); } type DeferredNumberInputProps = { ariaLabel?: string; invalid?: boolean; value: number | string; onCommit: (value: string) => void; onEditEnd?: () => void; onEditStart?: () => void; }; function DeferredNumberInput({ ariaLabel, invalid = false, value, onCommit, onEditEnd, onEditStart, }: DeferredNumberInputProps) { const committedText = String(value); const [draftText, setDraftText] = useState(committedText); const committedTextRef = useRef(committedText); const draftTextRef = useRef(committedText); const editingRef = useRef(false); const cancelCommitRef = useRef(false); useEffect(() => { committedTextRef.current = committedText; if (!editingRef.current) { draftTextRef.current = committedText; setDraftText(committedText); } }, [committedText]); return ( { editingRef.current = false; if (cancelCommitRef.current) { cancelCommitRef.current = false; } else { onCommit(draftTextRef.current); } window.setTimeout(() => onEditEnd?.(), 0); }} onChange={(event) => { draftTextRef.current = event.target.value; setDraftText(event.target.value); }} onFocus={() => { editingRef.current = true; cancelCommitRef.current = false; onEditStart?.(); }} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); event.currentTarget.blur(); } else if (event.key === "Escape") { event.preventDefault(); cancelCommitRef.current = true; draftTextRef.current = committedTextRef.current; setDraftText(committedTextRef.current); event.currentTarget.blur(); } }} spellCheck={false} type="text" value={draftText} /> ); } function FlowWorkbench() { const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); const [selectedNodeId, setSelectedNodeId] = useState(null); const [selectedEdgeId, setSelectedEdgeId] = useState(null); const [projectName, setProjectName] = useState("demo-system"); const [simulationConfig, setSimulationConfig] = useState( defaultSimulationConfig, ); const [xmlText, setXmlText] = useState(""); const [statusText, setStatusText] = useState("后端待调用"); const [simulationResult, setSimulationResult] = useState(null); const [simulationResultSignature, setSimulationResultSignature] = useState(""); const [resultSnapshot, setResultSnapshot] = useState(loadStoredResultSnapshot); const [hasUnreadResults, setHasUnreadResults] = useState(false); const [activeView, setActiveView] = useState( workspaceViewFromLocation, ); const [busy, setBusy] = useState(false); const [consoleEntries, setConsoleEntries] = useState([]); const [consoleMode, setConsoleMode] = useState("minimized"); const [consoleUnreadCount, setConsoleUnreadCount] = useState(0); const [simulationStopPending, setSimulationStopPending] = useState(false); const [simulationProgress, setSimulationProgress] = useState({ state: "idle", percent: 0, message: "等待仿真任务", startTime: defaultSimulationConfig.t_start, simulatedTime: defaultSimulationConfig.t_start, totalTime: defaultSimulationConfig.t_stop, }); const [snapEnabled, setSnapEnabled] = useState(true); const [gridVisibility, setGridVisibility] = useState( loadCanvasGridVisibility, ); const [modelingPaneLayout, setModelingPaneLayout] = useState(loadModelingPaneLayout); const [activeModelingPaneResize, setActiveModelingPaneResize] = useState(null); const [validationIssues, setValidationIssues] = useState([]); const [validatedSignature, setValidatedSignature] = useState(""); const [saveState, setSaveState] = useState("正在检查本地草稿..."); const [autosaveReady, setAutosaveReady] = useState(false); const [recoveryProject, setRecoveryProject] = useState(null); const [historyAvailability, setHistoryAvailability] = useState({ canUndo: false, canRedo: false, }); const reactFlow = useReactFlow(); const fileInputRef = useRef(null); const workbenchRef = useRef(null); const modelingPaneResizeRef = useRef(null); const nodesRef = useRef(nodes); const edgesRef = useRef(edges); const projectNameRef = useRef(projectName); const simulationConfigRef = useRef(simulationConfig); const historyRef = useRef([]); const futureRef = useRef([]); const clipboardRef = useRef(null); const pasteCountRef = useRef(0); const consoleModeRef = useRef("minimized"); const consoleEntryIdRef = useRef(0); const activeSimulationIdRef = useRef(null); nodesRef.current = nodes; edgesRef.current = edges; projectNameRef.current = projectName; simulationConfigRef.current = simulationConfig; consoleModeRef.current = consoleMode; useEffect(() => { try { localStorage.setItem( MODELING_PANE_LAYOUT_KEY, JSON.stringify(modelingPaneLayout), ); } catch { // Resizing remains available when browser storage is unavailable. } }, [modelingPaneLayout]); useEffect(() => { try { localStorage.setItem( CANVAS_GRID_VISIBILITY_KEY, JSON.stringify(gridVisibility), ); } catch { // Grid controls remain available when browser storage is unavailable. } }, [gridVisibility]); useLayoutEffect(() => { if (activeView !== "modeling") { return; } const workbench = workbenchRef.current; if (!workbench || typeof ResizeObserver === "undefined") { return; } const clampToWorkbench = (width: number) => { setModelingPaneLayout((current) => { const next = clampModelingPaneLayout(current, width); return next.paletteWidth === current.paletteWidth && next.propertiesWidth === current.propertiesWidth ? current : next; }); }; clampToWorkbench(workbench.clientWidth); const observer = new ResizeObserver(([entry]) => { if (entry) { clampToWorkbench(entry.contentRect.width); } }); observer.observe(workbench); return () => observer.disconnect(); }, [activeView]); const setModelingPaneSize = ( kind: ModelingPaneResizeKind, requestedWidth: number, ) => { const workbenchWidth = workbenchRef.current?.getBoundingClientRect().width; if (!workbenchWidth) { return; } setModelingPaneLayout((current) => { if (kind === "palette-width") { const maximum = Math.max( MIN_PALETTE_WIDTH, Math.min( MAX_PALETTE_WIDTH, workbenchWidth - current.propertiesWidth - MIN_MODELING_CANVAS_WIDTH - MODELING_PANE_SPLITTER_SIZE * 2, ), ); return { ...current, paletteWidth: Math.min( maximum, Math.max(MIN_PALETTE_WIDTH, requestedWidth), ), }; } const maximum = Math.max( MIN_PROPERTIES_WIDTH, Math.min( MAX_PROPERTIES_WIDTH, workbenchWidth - current.paletteWidth - MIN_MODELING_CANVAS_WIDTH - MODELING_PANE_SPLITTER_SIZE * 2, ), ); return { ...current, propertiesWidth: Math.min( maximum, Math.max(MIN_PROPERTIES_WIDTH, requestedWidth), ), }; }); }; const startModelingPaneResize = ( event: ReactPointerEvent, kind: ModelingPaneResizeKind, ) => { event.preventDefault(); modelingPaneResizeRef.current = { kind, pointerId: event.pointerId, startX: event.clientX, startWidth: kind === "palette-width" ? modelingPaneLayout.paletteWidth : modelingPaneLayout.propertiesWidth, }; event.currentTarget.setPointerCapture(event.pointerId); setActiveModelingPaneResize(kind); }; const moveModelingPaneResize = ( event: ReactPointerEvent, ) => { const resize = modelingPaneResizeRef.current; if (!resize || resize.pointerId !== event.pointerId) { return; } event.preventDefault(); const delta = event.clientX - resize.startX; setModelingPaneSize( resize.kind, resize.startWidth + (resize.kind === "palette-width" ? delta : -delta), ); }; const stopModelingPaneResize = ( event: ReactPointerEvent, ) => { if (modelingPaneResizeRef.current?.pointerId !== event.pointerId) { return; } if (event.currentTarget.hasPointerCapture(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId); } modelingPaneResizeRef.current = null; setActiveModelingPaneResize(null); }; const resizeModelingPaneWithKeyboard = ( event: ReactKeyboardEvent, kind: ModelingPaneResizeKind, ) => { if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") { return; } event.preventDefault(); const direction = event.key === "ArrowRight" ? 1 : -1; const currentWidth = kind === "palette-width" ? modelingPaneLayout.paletteWidth : modelingPaneLayout.propertiesWidth; setModelingPaneSize( kind, currentWidth + MODELING_PANE_KEYBOARD_STEP * (kind === "palette-width" ? direction : -direction), ); }; const resetModelingPaneSize = (kind: ModelingPaneResizeKind) => { const defaults = defaultModelingPaneLayout(); setModelingPaneSize( kind, kind === "palette-width" ? defaults.paletteWidth : defaults.propertiesWidth, ); }; const changeConsoleMode = useCallback((mode: SimulationConsoleMode) => { consoleModeRef.current = mode; setConsoleMode(mode); if (mode !== "minimized") { setConsoleUnreadCount(0); } }, []); const appendConsoleEntry = useCallback( (level: SimulationConsoleLevel, message: string) => { const entry: SimulationConsoleEntry = { id: ++consoleEntryIdRef.current, timestamp: new Date().toLocaleTimeString("zh-CN", { hour12: false }), level, message, }; setConsoleEntries((currentEntries) => [ ...currentEntries.slice(-399), entry, ]); if (consoleModeRef.current === "minimized") { setConsoleUnreadCount((count) => count + 1); } }, [], ); const changeWorkspaceView = useCallback((view: WorkspaceView) => { setActiveView(view); if (view === "results") { setHasUnreadResults(false); } const nextHash = view === "results" ? "#/results" : "#/modeling"; if (window.location.hash !== nextHash) { window.history.pushState({ workspaceView: view }, "", nextHash); } }, []); const loadSimulationResultsSnapshot = useCallback( (snapshot: SimulationResultsSnapshot) => { setResultSnapshot(snapshot); storeResultSnapshot(snapshot); setSimulationResult(null); setSimulationResultSignature(""); setStatusText(`已加载结果文件:${snapshot.project.name}`); changeWorkspaceView("results"); }, [changeWorkspaceView], ); const nodeTypes = useMemo( () => ({ simulationComponent: SimulationComponentNode }), [], ); const selectedNode = nodes.find((node) => node.id === selectedNodeId) ?? null; const selectedNodesForTools = nodes.filter( (node) => node.selected || node.id === selectedNodeId, ); const allSelectedNodesMirrored = selectedNodesForTools.length > 0 && selectedNodesForTools.every((node) => Boolean(node.data.mirrored)); const selectedDefinition = selectedNode ? componentDefinitions.find((definition) => definition.type === selectedNode.data.componentType) : null; const buildCurrentProject = useCallback( () => buildProjectPayload(projectName, nodes, edges, simulationConfig), [edges, nodes, projectName, simulationConfig], ); const validationSignature = useMemo( () => JSON.stringify(buildCurrentProject()), [buildCurrentProject], ); const validationCurrent = validatedSignature === validationSignature; const simulationResultCurrent = Boolean(simulationResult) && simulationResultSignature === validationSignature; const duplicateNames = useMemo(() => findDuplicateNames(nodes), [nodes]); const validationSeverityByNode = useMemo( () => { const severities = new Map(); if (!validationCurrent) { return severities; } validationIssues.forEach((issue) => { if ( issue.nodeId && (issue.severity === "error" || !severities.has(issue.nodeId)) ) { severities.set(issue.nodeId, issue.severity); } }); return severities; }, [validationCurrent, validationIssues], ); const renderedNodes = useMemo( () => nodes.map((node) => ({ ...node, className: validationSeverityByNode.has(node.id) ? `validation-${validationSeverityByNode.get(node.id)}` : node.className, })), [nodes, validationSeverityByNode], ); const renderedEdges = useMemo( () => edges.map((edge) => { const selected = edge.selected || edge.id === selectedEdgeId; const className = [edge.className, selected ? "editor-edge-selected" : ""] .filter(Boolean) .join(" "); return { ...edge, className: className || undefined, type: "step", }; }), [edges, selectedEdgeId], ); const updateHistoryAvailability = useCallback(() => { setHistoryAvailability({ canUndo: historyRef.current.length > 0, canRedo: futureRef.current.length > 0, }); }, []); const captureSnapshot = useCallback( (): EditorSnapshot => ({ nodes: cloneValue(nodesRef.current), edges: cloneValue(edgesRef.current), projectName: projectNameRef.current, simulationConfig: { ...simulationConfigRef.current }, }), [], ); const recordHistory = useCallback(() => { const snapshot = captureSnapshot(); const previous = historyRef.current.at(-1); if (!previous || snapshotSignature(previous) !== snapshotSignature(snapshot)) { historyRef.current.push(snapshot); if (historyRef.current.length > HISTORY_LIMIT) { historyRef.current.shift(); } } futureRef.current = []; updateHistoryAvailability(); }, [captureSnapshot, updateHistoryAvailability]); const finishHistoryEdit = useCallback(() => { const latest = historyRef.current.at(-1); if (latest && snapshotSignature(latest) === snapshotSignature(captureSnapshot())) { historyRef.current.pop(); updateHistoryAvailability(); } }, [captureSnapshot, updateHistoryAvailability]); const restoreSnapshot = useCallback( (snapshot: EditorSnapshot) => { const restoredNodes = cloneValue(snapshot.nodes).map((node) => ({ ...node, selected: false, })); setNodes(restoredNodes); setEdges(cloneValue(snapshot.edges).map((edge) => ({ ...edge, selected: false }))); setProjectName(snapshot.projectName); setSimulationConfig({ ...snapshot.simulationConfig }); setSelectedNodeId(null); setSelectedEdgeId(null); setSimulationResult(null); setSimulationResultSignature(""); setXmlText(""); syncNextNodeNumbers(restoredNodes); }, [setEdges, setNodes], ); const undo = useCallback(() => { const previous = historyRef.current.pop(); if (!previous) { return; } futureRef.current.push(captureSnapshot()); restoreSnapshot(previous); updateHistoryAvailability(); setStatusText("已撤销上一步操作"); }, [captureSnapshot, restoreSnapshot, updateHistoryAvailability]); const redo = useCallback(() => { const next = futureRef.current.pop(); if (!next) { return; } historyRef.current.push(captureSnapshot()); restoreSnapshot(next); updateHistoryAvailability(); setStatusText("已重做上一步操作"); }, [captureSnapshot, restoreSnapshot, updateHistoryAvailability]); const rotateSelection = useCallback(() => { const selectedIds = new Set( nodesRef.current .filter((node) => node.selected || node.id === selectedNodeId) .map((node) => node.id), ); if (selectedIds.size === 0) { setStatusText("请先选择需要旋转的组件"); return; } recordHistory(); setNodes((currentNodes) => currentNodes.map((node) => selectedIds.has(node.id) ? rotateNodeClockwise(node) : node, ), ); setStatusText(`已顺时针旋转 ${selectedIds.size} 个组件`); }, [recordHistory, selectedNodeId, setNodes]); const mirrorSelection = useCallback(() => { const selectedNodes = nodesRef.current.filter( (node) => node.selected || node.id === selectedNodeId, ); if (selectedNodes.length === 0) { setStatusText("请先选择需要镜像的组件"); return; } const selectedIds = new Set(selectedNodes.map((node) => node.id)); const nextMirrored = !selectedNodes.every((node) => Boolean(node.data.mirrored)); recordHistory(); setNodes((currentNodes) => currentNodes.map((node) => selectedIds.has(node.id) ? { ...node, data: { ...node.data, mirrored: nextMirrored, }, } : node, ), ); setStatusText(`${nextMirrored ? "已镜像" : "已取消镜像"} ${selectedIds.size} 个组件`); }, [recordHistory, selectedNodeId, setNodes]); const handleNodesChange = useCallback( (changes: NodeChange[]) => { if (changes.some((change) => change.type === "remove")) { recordHistory(); } onNodesChange(changes); }, [onNodesChange, recordHistory], ); const handleEdgesChange = useCallback( (changes: EdgeChange[]) => { if (changes.some((change) => change.type === "remove")) { recordHistory(); } onEdgesChange(changes); }, [onEdgesChange, recordHistory], ); const onConnect = useCallback( (connection: Connection) => { recordHistory(); setEdges((currentEdges) => addEdge( { ...connection, id: `edge-${connection.source}-${connection.sourceHandle}-${connection.target}-${connection.targetHandle}-${Date.now()}`, type: "step", }, currentEdges, ), ); }, [recordHistory, setEdges], ); const isValidConnection = useCallback( (connection: Connection | SimulationEdge) => canConnectPorts(nodes, connection), [nodes], ); const onDragStart = (event: DragEvent, type: string) => { event.dataTransfer.setData("application/system-component", type); event.dataTransfer.effectAllowed = "move"; const icon = event.currentTarget.querySelector(".palette-icon"); if (icon) { const preview = document.createElement("div"); const previewIcon = icon.cloneNode(true) as HTMLElement; const previewLabel = document.createElement("span"); preview.className = "component-drag-preview"; preview.setAttribute("aria-hidden", "true"); previewLabel.className = "component-drag-preview-label"; previewLabel.textContent = peekNextNodeId(type); preview.append(previewIcon, previewLabel); document.body.appendChild(preview); event.dataTransfer.setDragImage( preview, Math.round(preview.offsetWidth / 2), Math.round(preview.offsetHeight / 2), ); window.setTimeout(() => preview.remove(), 0); } }; const onDrop = useCallback( (event: DragEvent) => { event.preventDefault(); const type = event.dataTransfer.getData("application/system-component"); const definition = componentDefinitions.find((item) => item.type === type); if (!definition) { return; } const position = reactFlow.screenToFlowPosition({ x: event.clientX, y: event.clientY, }); const id = takeNextNodeId(definition.type); const node: SimulationNode = { id, type: "simulationComponent", position, data: { ...buildNodeData(definition), label: id }, }; recordHistory(); setNodes((currentNodes) => [...currentNodes, node]); setSelectedNodeId(id); }, [reactFlow, recordHistory, setNodes], ); const updateSelectedNode = (updater: (node: SimulationNode) => SimulationNode) => { if (!selectedNodeId) { return; } setNodes((currentNodes) => currentNodes.map((node) => (node.id === selectedNodeId ? updater(node) : node)), ); }; const updateSelectedParameter = ( key: string, definition: ParameterDefinition, displayValue: string, ) => { const selectedUnit = selectedNode?.data.parameterUnits[key] ?? definition.unit ?? ""; const unit = findUnitOption(definition, selectedUnit); const numericValue = Number(displayValue); const baseValue = displayValue.trim() === "" || !Number.isFinite(numericValue) ? displayValue : unit.toBase(numericValue); updateSelectedNode((node) => ({ ...node, data: { ...node.data, parameters: { ...node.data.parameters, [key]: baseValue, }, }, })); }; const updateSelectedParameterUnit = (key: string, unit: string) => { updateSelectedNode((node) => ({ ...node, data: { ...node.data, parameterUnits: { ...node.data.parameterUnits, [key]: unit, }, }, })); }; const copySelection = useCallback(() => { const selectedNodes = nodesRef.current.filter( (node) => node.selected || node.id === selectedNodeId, ); if (selectedNodes.length === 0) { setStatusText("请先选择需要复制的组件"); return; } const selectedIds = new Set(selectedNodes.map((node) => node.id)); clipboardRef.current = { nodes: cloneValue(selectedNodes), edges: cloneValue( edgesRef.current.filter( (edge) => selectedIds.has(edge.source) && selectedIds.has(edge.target), ), ), }; pasteCountRef.current = 0; setStatusText(`已复制 ${selectedNodes.length} 个组件`); }, [selectedNodeId]); const pasteSelection = useCallback(() => { const clipboard = clipboardRef.current; if (!clipboard || clipboard.nodes.length === 0) { setStatusText("剪贴板中没有可粘贴的组件"); return; } recordHistory(); pasteCountRef.current += 1; const offset = GRID_SIZE * 2 * pasteCountRef.current; const idMap = new Map(); const usedNames = new Set(nodesRef.current.map((node) => node.data.label.trim())); const pastedNodes = clipboard.nodes.map((node) => { const id = takeNextNodeId(node.data.componentType); idMap.set(node.id, id); const label = makeUniqueName(`${node.data.label}_副本`, usedNames); usedNames.add(label); return { ...cloneValue(node), id, position: { x: node.position.x + offset, y: node.position.y + offset, }, selected: true, data: { ...cloneValue(node.data), label, }, } satisfies SimulationNode; }); const pastedEdges = clipboard.edges.map((edge, index) => ({ ...cloneValue(edge), id: `edge-copy-${Date.now()}-${index}`, source: idMap.get(edge.source) as string, target: idMap.get(edge.target) as string, selected: false, })); setNodes((currentNodes) => [ ...currentNodes.map((node) => ({ ...node, selected: false })), ...pastedNodes, ]); setEdges((currentEdges) => [ ...currentEdges.map((edge) => ({ ...edge, selected: false })), ...pastedEdges, ]); setSelectedNodeId(pastedNodes.length === 1 ? pastedNodes[0].id : null); setSelectedEdgeId(null); setStatusText(`已粘贴 ${pastedNodes.length} 个组件`); }, [recordHistory, setEdges, setNodes]); const updateSimulationConfig = (key: keyof SimulationConfig, value: string) => { setSimulationConfig((current) => ({ ...current, [key]: key === "method" ? value : Number(value), })); }; const generateXml = () => { try { const xml = buildSystemXml(buildCurrentProject()); setXmlText(xml); setStatusText(`XML 已生成:${nodes.length} 个组件,${edges.length} 条连接`); } catch (error) { setStatusText(`XML 生成失败:${formatError(error)}`); } }; const downloadXml = () => { try { const xml = buildSystemXml(buildCurrentProject()); setXmlText(xml); downloadText(`${safeFilename(projectName)}.xml`, xml); setStatusText("XML 已下载"); } catch (error) { setStatusText(`XML 下载失败:${formatError(error)}`); } }; const applyProject = useCallback( (project: ReactFlowProjectPayload, message: string, addToHistory = true) => { if (addToHistory) { recordHistory(); } const loadedNodes = project.nodes.map(normalizeLoadedNode); setNodes(loadedNodes); setEdges(project.edges.map(normalizeLoadedEdge)); setProjectName(project.name || "untitled"); setSimulationConfig({ ...defaultSimulationConfig, ...project.simulation }); setSelectedNodeId(null); setSelectedEdgeId(null); setSimulationResult(null); setSimulationResultSignature(""); setValidationIssues([]); setValidatedSignature(""); setXmlText(""); syncNextNodeNumbers(loadedNodes); setStatusText(message); window.requestAnimationFrame(() => reactFlow.fitView({ padding: 0.18 })); }, [reactFlow, recordHistory, setEdges, setNodes], ); const saveProject = () => { try { const project = buildCurrentProject(); localStorage.setItem(localProjectKey(project.name), JSON.stringify(project)); localStorage.setItem(AUTOSAVE_KEY, JSON.stringify(project)); setSaveState(`已保存 ${formatTime(new Date())}`); setStatusText( `工程已保存到浏览器:${project.nodes.length} 个组件,${project.edges.length} 条连接`, ); } catch (error) { setStatusText(`保存失败:${formatError(error)}`); } }; const loadProject = () => { const name = projectName.trim() || "untitled"; try { const project = parseProjectPayload(localStorage.getItem(localProjectKey(name))); if (!project) { setStatusText(`没有找到本地工程:${name}`); return; } applyProject( project, `工程已加载:${project.nodes.length} 个组件,${project.edges.length} 条连接`, ); } catch (error) { setStatusText(`加载失败:${formatError(error)}`); } }; const exportProject = () => { const project = buildCurrentProject(); downloadFile( `${safeFilename(project.name)}.json`, JSON.stringify(project, null, 2), "application/json;charset=utf-8", ); setStatusText("工程 JSON 已导出"); }; const importProject = async (event: ChangeEvent) => { const file = event.target.files?.[0]; event.target.value = ""; if (!file) { return; } try { const project = parseProjectPayload(await file.text()); if (!project) { throw new Error("文件不是有效的 ReactFlow 工程 JSON"); } applyProject(project, `已导入工程:${file.name}`); } catch (error) { setStatusText(`导入失败:${formatError(error)}`); } }; const checkModel = useCallback(() => { const issues = validateModel( nodesRef.current, edgesRef.current, simulationConfigRef.current, ); const signature = JSON.stringify( buildProjectPayload( projectNameRef.current, nodesRef.current, edgesRef.current, simulationConfigRef.current, ), ); setValidationIssues(issues); setValidatedSignature(signature); const errorCount = issues.filter((issue) => issue.severity === "error").length; const warningCount = issues.length - errorCount; setStatusText( errorCount === 0 ? warningCount === 0 ? "模型检查通过" : `模型检查通过,存在 ${warningCount} 条警告` : `模型检查发现 ${errorCount} 个错误、${warningCount} 条警告`, ); return issues; }, []); const focusValidationIssue = (issue: ValidationIssue) => { if (!issue.nodeId) { return; } const node = nodesRef.current.find((item) => item.id === issue.nodeId); if (!node) { return; } setNodes((currentNodes) => currentNodes.map((item) => ({ ...item, selected: item.id === issue.nodeId })), ); setSelectedNodeId(issue.nodeId); setSelectedEdgeId(null); reactFlow.setCenter(node.position.x, node.position.y, { duration: 250, zoom: Math.max(reactFlow.getZoom(), 1), }); }; const publishSimulationResult = ( result: SimulationResult, project: ReactFlowProjectPayload, ) => { const resultStatus = result.status ?? (result.success ? "completed" : "failed"); const sampleCount = Math.min( result.diagnostics.sampleCount, result.series.time?.length ?? 0, ); const hasUsableSamples = sampleCount >= 2; const startTime = project.simulation.t_start; const finalSimulatedTime = Number.isFinite(result.simulatedUntil) ? result.simulatedUntil : startTime; const finalTotalTime = Number.isFinite(result.requestedStopTime) ? result.requestedStopTime : project.simulation.t_stop; const finalPercent = resultStatus === "completed" ? 100 : simulationTimePercent( startTime, finalSimulatedTime, finalTotalTime, ); const simulatedUntil = Number.isFinite(result.simulatedUntil) ? formatNumber(result.simulatedUntil) : "未知时间"; const statusMessages = { completed: `仿真完成,已生成新的结果,共 ${sampleCount} 个采样点`, stopped: `仿真已手动终止,已保留计算至 ${simulatedUntil} s 的 ${sampleCount} 个采样点`, stalled: `仿真因进度异常而终止,已保留计算至 ${simulatedUntil} s 的 ${sampleCount} 个采样点`, failed: `仿真异常终止,已保留计算至 ${simulatedUntil} s 的 ${sampleCount} 个采样点`, } as const; const statusMessage = statusMessages[resultStatus]; setStatusText(statusMessage); if (resultStatus === "completed") { setSimulationProgress({ state: "success", percent: 100, message: "仿真完成,新结果可查看", startTime, simulatedTime: finalTotalTime, totalTime: finalTotalTime, }); appendConsoleEntry( "success", `${statusMessage},可在“结果”页签查看;最大方程残差 ${formatNumber(result.diagnostics.pressureFlow.maxScaledResidual)}`, ); } else { setSimulationProgress((current) => ({ ...current, state: resultStatus === "stopped" ? "warning" : "error", percent: finalPercent, message: resultStatus === "stopped" ? "仿真已停止" : resultStatus === "stalled" ? "仿真因进度异常终止" : "仿真异常终止", startTime, simulatedTime: finalSimulatedTime, totalTime: finalTotalTime, })); appendConsoleEntry(resultStatus === "stopped" ? "warning" : "error", statusMessage); } if (!hasUsableSamples) { appendConsoleEntry("warning", "有效计算步不足,未生成部分结果页面"); return; } setSimulationResult(result); setSimulationResultSignature(JSON.stringify(project)); const nextSnapshot: SimulationResultsSnapshot = { id: `simulation-${Date.now()}`, createdAt: new Date().toISOString(), project: cloneValue(project), result: cloneValue(result), }; setResultSnapshot(nextSnapshot); storeResultSnapshot(nextSnapshot); setHasUnreadResults(true); }; const stopSimulation = async () => { const simulationId = activeSimulationIdRef.current; if (!simulationId || simulationStopPending || simulationProgress.state !== "running") { return; } setSimulationStopPending(true); setSimulationProgress((current) => ({ ...current, message: "正在停止仿真" })); appendConsoleEntry("warning", "已请求停止仿真,正在等待当前积分步结束并整理部分结果"); try { const response = await requestSimulationCancellation(simulationId, "user"); if (!response.accepted) { appendConsoleEntry("info", "仿真任务已经结束,正在读取最终状态"); } } catch (error) { setSimulationStopPending(false); setSimulationProgress((current) => ({ ...current, message: "停止请求失败,仿真可能仍在运行", })); appendConsoleEntry("error", `停止仿真失败:${formatError(error)}`); } }; const runSimulation = async () => { const issues = checkModel(); const modelErrors = issues.filter((issue) => issue.severity === "error"); if (modelErrors.length > 0) { changeConsoleMode("normal"); setSimulationProgress({ state: "error", percent: 0, message: "模型检查未通过", startTime: simulationConfigRef.current.t_start, simulatedTime: simulationConfigRef.current.t_start, totalTime: simulationConfigRef.current.t_stop, }); appendConsoleEntry("error", `模型检查未通过,共 ${modelErrors.length} 个错误`); modelErrors.forEach((issue) => appendConsoleEntry("error", issue.message)); return; } changeConsoleMode("normal"); setSimulationProgress({ state: "running", percent: 0, message: "正在提交仿真任务", startTime: simulationConfigRef.current.t_start, simulatedTime: simulationConfigRef.current.t_start, totalTime: simulationConfigRef.current.t_stop, }); appendConsoleEntry( "info", `开始仿真:${projectNameRef.current || "untitled"},时间范围 ${simulationConfigRef.current.t_start} - ${simulationConfigRef.current.t_stop} s`, ); issues .filter((issue) => issue.severity === "warning") .forEach((issue) => appendConsoleEntry("warning", issue.message)); await runBusyAction("正在编译模型并运行仿真...", async () => { const project = buildCurrentProject(); const xml = buildSystemXml(project); const simulationId = createSimulationId(); activeSimulationIdRef.current = simulationId; setSimulationStopPending(false); setXmlText(xml); let lastLoggedPhase = ""; let lastLoggedBucket = -1; try { const result = await streamSystemSimulation( xml, (event) => { if (event.event !== "progress") { return; } const normalizedProgress = normalizeSimulationProgressEvent( event, project.simulation, ); const reportsSimulationTime = ![ "queued", "validation", "compilation", "initialization", "initializing", ].includes(event.phase); setSimulationProgress((current) => ({ ...current, state: "running", percent: normalizedProgress.percent, message: event.message, simulatedTime: normalizedProgress.simulatedTime, totalTime: normalizedProgress.totalTime, })); const progressBucket = Math.floor(normalizedProgress.percent / 10); if (event.phase !== lastLoggedPhase || progressBucket !== lastLoggedBucket) { const progressDetail = reportsSimulationTime ? ` (${normalizedProgress.simulatedTime.toFixed(2)} s / ${normalizedProgress.totalTime.toFixed(2)} s,${normalizedProgress.percent}%)` : ""; appendConsoleEntry("info", `${event.message}${progressDetail}`); lastLoggedPhase = event.phase; lastLoggedBucket = progressBucket; } }, simulationId, ); publishSimulationResult(result, project); } catch (error) { let failure: unknown = error; if ( error instanceof SimulationStreamError && (error.code === "STREAM_IDLE_TIMEOUT" || error.code === "SOLVER_STALLED") ) { setSimulationStopPending(true); appendConsoleEntry( "error", error.code === "SOLVER_STALLED" ? "求解器连续 60 秒没有接受新积分步,正在终止任务并恢复部分结果" : "超过 30 秒未收到后端数据,正在终止任务并恢复部分结果", ); try { await requestSimulationCancellation(simulationId, "stalled"); const task = await waitForSimulationTaskCompletion(simulationId); if (task.result) { publishSimulationResult(task.result, project); return; } if (task.error?.event === "error") { failure = new SimulationStreamError( task.error.message, [], task.error.status, ); } else { failure = new SimulationStreamError("仿真异常终止且没有可恢复的部分结果"); } } catch (recoveryError) { failure = recoveryError; } } const message = formatError(failure); setSimulationProgress((current) => ({ ...current, state: "error", percent: current.percent, message: "仿真失败", })); appendConsoleEntry("error", `仿真失败:${message}`); if (failure instanceof SimulationStreamError) { failure.issues .filter((issue) => issue !== message) .forEach((issue) => appendConsoleEntry("error", issue)); } throw failure; } finally { activeSimulationIdRef.current = null; setSimulationStopPending(false); } }); }; async function runBusyAction(message: string, action: () => Promise) { setBusy(true); setStatusText(message); try { await action(); } catch (error) { setStatusText(`操作失败:${formatError(error)}`); } finally { setBusy(false); } } const restoreRecoveryProject = () => { if (!recoveryProject) { return; } applyProject(recoveryProject, "已恢复上次自动保存的工程", false); setRecoveryProject(null); setAutosaveReady(true); }; const discardRecoveryProject = () => { localStorage.removeItem(AUTOSAVE_KEY); setRecoveryProject(null); setAutosaveReady(true); setSaveState("尚未保存"); setStatusText("已忽略上次的自动保存草稿"); }; const selectAllElements = useCallback(() => { setNodes((currentNodes) => currentNodes.map((node) => ({ ...node, selected: true }))); setEdges((currentEdges) => currentEdges.map((edge) => ({ ...edge, selected: true }))); setSelectedNodeId(null); setSelectedEdgeId(null); }, [setEdges, setNodes]); const deleteSelection = useCallback(() => { const nodeIds = new Set( nodesRef.current .filter((node) => node.selected || node.id === selectedNodeId) .map((node) => node.id), ); const edgeIds = new Set( edgesRef.current .filter((edge) => edge.selected || edge.id === selectedEdgeId) .map((edge) => edge.id), ); if (nodeIds.size === 0 && edgeIds.size === 0) { setStatusText("请先选择需要删除的组件或连接"); return; } recordHistory(); setNodes((currentNodes) => currentNodes.filter((node) => !nodeIds.has(node.id))); setEdges((currentEdges) => currentEdges.filter( (edge) => !edgeIds.has(edge.id) && !nodeIds.has(edge.source) && !nodeIds.has(edge.target), ), ); setSelectedNodeId(null); setSelectedEdgeId(null); setStatusText(`已删除 ${nodeIds.size} 个组件和 ${edgeIds.size} 条连接`); }, [recordHistory, selectedEdgeId, selectedNodeId, setEdges, setNodes]); useEffect(() => { try { const project = parseProjectPayload(localStorage.getItem(AUTOSAVE_KEY)); if (project && (project.nodes.length > 0 || project.edges.length > 0)) { setRecoveryProject(project); setSaveState("发现可恢复的本地草稿"); } else { setAutosaveReady(true); setSaveState("尚未保存"); } } catch { localStorage.removeItem(AUTOSAVE_KEY); setAutosaveReady(true); setSaveState("尚未保存"); } }, []); useEffect(() => { if (!autosaveReady || recoveryProject) { return; } setSaveState("有未保存更改"); const timer = window.setTimeout(() => { try { localStorage.setItem(AUTOSAVE_KEY, JSON.stringify(buildCurrentProject())); setSaveState(`已自动保存 ${formatTime(new Date())}`); } catch (error) { setSaveState(`自动保存失败:${formatError(error)}`); } }, 600); return () => window.clearTimeout(timer); }, [autosaveReady, buildCurrentProject, recoveryProject]); useEffect(() => { const syncViewFromLocation = () => { setActiveView(workspaceViewFromLocation()); }; window.addEventListener("hashchange", syncViewFromLocation); window.addEventListener("popstate", syncViewFromLocation); return () => { window.removeEventListener("hashchange", syncViewFromLocation); window.removeEventListener("popstate", syncViewFromLocation); }; }, []); useEffect(() => { if (activeView === "results" && hasUnreadResults) { setHasUnreadResults(false); } }, [activeView, hasUnreadResults]); useEffect(() => { if (activeView !== "modeling") { return; } const handleKeyDown = (event: KeyboardEvent) => { const key = event.key.toLowerCase(); if (event.ctrlKey && !event.shiftKey && key === "r") { event.preventDefault(); rotateSelection(); return; } if (isEditableTarget(event.target)) { return; } const commandKey = event.ctrlKey || event.metaKey; if (event.shiftKey && !commandKey && !event.altKey && key === "h") { event.preventDefault(); mirrorSelection(); } else if (commandKey && key === "z") { event.preventDefault(); event.shiftKey ? redo() : undo(); } else if (commandKey && key === "y") { event.preventDefault(); redo(); } else if (commandKey && key === "c") { event.preventDefault(); copySelection(); } else if (commandKey && key === "v") { event.preventDefault(); pasteSelection(); } else if (commandKey && key === "a") { event.preventDefault(); selectAllElements(); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [ activeView, copySelection, mirrorSelection, pasteSelection, redo, rotateSelection, selectAllElements, undo, ]); const simulationConsole = ( setConsoleEntries([])} onModeChange={changeConsoleMode} onStop={() => void stopSimulation()} progress={simulationProgress} stopPending={simulationStopPending} unreadCount={consoleUnreadCount} /> ); if (activeView === "results") { return ( <> {resultSnapshot ? ( ) : ( )} {simulationConsole} ); } return ( <>
System Simulation Flow
React Flow 建模与仿真 MVP
{saveState}
resetModelingPaneSize("palette-width")} onKeyDown={(event) => resizeModelingPaneWithKeyboard(event, "palette-width") } onLostPointerCapture={stopModelingPaneResize} onPointerCancel={stopModelingPaneResize} onPointerDown={(event) => startModelingPaneResize(event, "palette-width") } onPointerMove={moveModelingPaneResize} onPointerUp={stopModelingPaneResize} role="separator" tabIndex={0} title="拖动调整组件库宽度,双击恢复默认" >
{ event.preventDefault(); event.dataTransfer.dropEffect = "move"; }} onDrop={onDrop} > { setSelectedEdgeId(edge.id); setSelectedNodeId(null); }} onEdgesChange={handleEdgesChange} onEdgesDelete={() => setSelectedEdgeId(null)} onNodeClick={(_, node) => { setSelectedNodeId(node.id); setSelectedEdgeId(null); }} onNodeDragStart={recordHistory} onNodesChange={handleNodesChange} onNodesDelete={() => setSelectedNodeId(null)} onPaneClick={() => { setSelectedNodeId(null); setSelectedEdgeId(null); }} onSelectionChange={({ nodes: selectedNodes, edges: selectedEdges }) => { setSelectedNodeId(selectedNodes.length === 1 ? selectedNodes[0].id : null); setSelectedEdgeId( selectedNodes.length === 0 && selectedEdges.length === 1 ? selectedEdges[0].id : null, ); }} panActivationKeyCode="Space" panOnDrag={[1]} selectionMode={SelectionMode.Partial} selectionOnDrag snapGrid={[GRID_SIZE, GRID_SIZE]} snapToGrid={snapEnabled} zoomOnDoubleClick={false} > {gridVisibility.lines ? ( ) : null} {gridVisibility.dots ? ( ) : null} {recoveryProject ? ( 检测到上次自动保存的工程 ) : null}
resetModelingPaneSize("properties-width")} onKeyDown={(event) => resizeModelingPaneWithKeyboard(event, "properties-width") } onLostPointerCapture={stopModelingPaneResize} onPointerCancel={stopModelingPaneResize} onPointerDown={(event) => startModelingPaneResize(event, "properties-width") } onPointerMove={moveModelingPaneResize} onPointerUp={stopModelingPaneResize} role="separator" tabIndex={0} title="拖动调整参数面板宽度,双击恢复默认" >