import { ChangeEvent, DragEvent, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { createPortal } from "react-dom"; import type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent, ReactNode, } from "react"; import { ChevronDown, ChevronUp, ClipboardPaste, Copy, Download, FileCode2, FileDown, FileUp, FlipHorizontal2, FolderOpen, Grid2X2, Grip, List, 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, applyNodeChanges, type Connection, type Edge, type EdgeChange, type Node, type NodeChange, type NodeProps, type NodeTypes, type OnNodeDrag, useEdgesState, useNodesState, useReactFlow, useStore, useStoreApi, useUpdateNodeInternals, } from "@xyflow/react"; import { EmptySimulationResultsView, SimulationResultsView, type ResultVariableMetadata, type SimulationResultsSnapshot, } from "./SimulationResultsView"; import { AutoFitView } from "./AutoFitView"; import { ComponentSymbol, componentSymbolLayout, componentSymbolPresentation, componentSymbolVariantKey, hasComponentSymbol, } from "./ComponentSymbol"; import type { ComponentSymbolLayout, ComponentSymbolPortAnchor, ComponentSymbolSize, ComponentSymbolViewBox, } from "./componentSymbols/types"; import { CONTACT_AWARE_EDGE_TYPE, contactAwareEdgeTypes, } from "./ContactAwareEdge"; import { ParameterTable, loadParameterColumnLayout, saveParameterColumnLayout, type ParameterColumnLayout, type ParameterTableGroup, type ParameterTableRow, } from "./ParameterTable"; import { evaluateParameterExpression, isParameterExpressionValue, isScientificNotationValue, } from "./parameterExpression"; import { WorkspaceViewTabs, type WorkspaceView, } from "./WorkspaceViewTabs"; type ParameterValue = number | string; type UnitQuantity = string; type ParameterScientificNotation = { text: string; unit: string; }; 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 PortConnectionState = | "idle" | "connected" | "origin" | "compatible" | "incompatible"; type ActivePortConnection = { nodeId: string; portName: string; mode: "drag" | "click"; }; type ContactSnapMatch = { connection: Connection; draggedPortName: string; targetNodeId: string; targetPortName: string; }; type ContactSnapCandidate = { draggedNodeId: string; matches: ContactSnapMatch[]; snappedPosition: { x: number; y: number }; }; type ContactDragSession = { contactEdgeIds: Set; disconnected: boolean; effectiveEdges: SimulationEdge[]; multipleNodes: boolean; nodeId: string; startPosition: { x: number; y: number }; }; type UnitOption = { value: string; label: string; toBase: (value: number) => number; fromBase: (value: number) => number; }; type ParameterDefinition = { label: string; description?: string; unit?: string; quantity?: UnitQuantity; default: ParameterValue; min?: number; max?: number; minExclusive?: boolean; editor?: "choice" | "amesimGasReference" | "amesimGasPropertyModel"; options?: ParameterOption[]; visibleWhen?: ParameterVisibilityCondition[]; }; type ParameterOption = { value: number; label: string; }; type ParameterVisibilityCondition = { parameter: string; values: number[]; }; type ParameterGroupDefinition = { id: string; label: string; order: number; parameters: string[]; defaultExpanded: boolean; }; type ComponentDefinition = { type: string; label: string; modelType: string; modelVersion: string; symbol: string; order: number; category: { id: string; label: string; order: number; }; role?: "amesimGasMediumDefinition"; ports: PortDefinition[]; parameters: Record; parameterGroups?: ParameterGroupDefinition[]; }; type ComponentLibraryDefinition = { id: string; label: string; version: string; sourcePackage: string; temporary: boolean; order: number; components: ComponentDefinition[]; }; type ComponentCatalogParameterPayload = { name: string; label: string; description?: string; quantity: string; unit: string; default: number; minimum?: number; maximum?: number; minimumExclusive: boolean; editor?: "choice" | "amesimGasReference" | "amesimGasPropertyModel"; options?: ParameterOption[]; visibleWhen?: ParameterVisibilityCondition[]; }; type ComponentCatalogComponentPayload = { type: string; label: string; modelType: string; modelVersion: string; symbol: string; order: number; category: { id: string; label: string; order: number; }; role?: "amesimGasMediumDefinition"; ports: Array; parameters: ComponentCatalogParameterPayload[]; parameterGroups?: Array<{ id: string; label: string; order: number; parameters: string[]; defaultExpanded?: boolean; }>; }; type ComponentCatalogResponse = { schemaVersion: number; libraries: Array< Omit & { components: ComponentCatalogComponentPayload[]; } >; }; type ComponentCatalogStatus = "loading" | "ready" | "fallback"; type SimulationNodeData = { label: string; componentType: string; modelType: string; symbol?: string; ports: PortDefinition[]; parameters: Record; parameterUnits: Record; parameterScientificNotation: Record; rotation: NodeRotation; mirrored: boolean; portConnectionStates?: Record; hideConnectedPortIndexLabels?: boolean; }; type SimulationNode = Node; type SimulationEdge = Edge; type SimulationNumericKey = "t_start" | "t_stop" | "step" | "max_step"; type SimulationConfig = { t_start: ParameterValue; t_stop: ParameterValue; step: ParameterValue; max_step: ParameterValue; method: string; }; type ResolvedSimulationConfig = { 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< SimulationNodeData, "parameterUnits" | "parameterScientificNotation" > & { parameterUnits?: Record; parameterScientificNotation?: Record; }; }; type ProjectEdgePayload = { id: string; source: string; target: string; sourceHandle: string | null; targetHandle: string | null; }; type ReactFlowProjectPayload = { name: string; mediumReferenceVersion?: 1 | null; amesimParameterEncodingVersion?: 1 | null; presentationLayoutVersion?: 1 | null; nodes: ProjectNodePayload[]; edges: ProjectEdgePayload[]; simulation: SimulationConfig; }; type ExecutableProjectPayload = Omit & { simulation: ResolvedSimulationConfig; }; 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; componentParametersHeight: number; }; type ModelingPaneResizeKind = | "palette-width" | "properties-width" | "component-parameters-height"; type ModelingPaneResizeState = { kind: ModelingPaneResizeKind; pointerId: number; startX: number; startY: number; startSize: number; }; type CanvasGridVisibility = { lines: boolean; dots: boolean; }; type PaletteDisplayMode = "detailed" | "icons"; type PaletteIconPreview = { definition: ComponentDefinition; left: number; top: number; }; 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 PALETTE_DISPLAY_MODE_KEY = "system-simulation-flow:palette-display-mode"; const PALETTE_COLLAPSED_SECTIONS_KEY = "system-simulation-flow:palette-collapsed-sections"; const PALETTE_ICON_PREVIEW_DELAY_MS = 500; 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 PROPERTIES_PANE_SPLITTER_SIZE = 7; const MIN_COMPONENT_PARAMETERS_HEIGHT = 120; const MIN_SIMULATION_SETTINGS_HEIGHT = 120; const MIN_MODELING_CANVAS_WIDTH = 420; const MODELING_PANE_KEYBOARD_STEP = 20; const HISTORY_LIMIT = 50; const PRESENTATION_LAYOUT_VERSION = 1 as const; const LEGACY_NODE_FRAME_SIZE: ComponentSymbolSize = { width: 132, height: 84 }; const LEGACY_NODE_SYMBOL_SIZE: ComponentSymbolSize = { width: 112, height: 84 }; const SYMBOL_ENVELOPE_PADDING_PX = 2; const PORT_INDEX_OUTSET_PX = 18; const PORT_INDEX_TANGENT_OFFSET_PX = 12; const PORT_INDEX_LABEL_HEIGHT_PX = 20; const PORT_INDEX_LABEL_MIN_WIDTH_PX = 20; const PORT_INDEX_LABEL_CHARACTER_WIDTH_PX = 10; const PORT_INDEX_LABEL_GAP_PX = 1; const PORT_INDEX_LABEL_LANE_GAP_PX = 2; // 接触吸附以屏幕像素定义,调用处再按 React Flow 缩放换算为画布距离。 const CONTACT_SNAP_RADIUS_PX = 6; const CONTACT_DISCONNECT_DISTANCE_PX = 8; const CONTACT_GEOMETRY_TOLERANCE = 0.5; const CONTACT_DELETE_SEPARATION = 16; type SymbolEnvelope = { left: number; top: number; width: number; height: number; }; type CanvasSelectionRect = { x: number; y: number; width: number; height: number; }; type ScreenRectangle = { left: number; top: number; right: number; bottom: number; }; function screenRectanglesOverlap( selection: ScreenRectangle, target: ScreenRectangle, ): boolean { return ( selection.left < target.right && selection.right > target.left && selection.top < target.bottom && selection.bottom > target.top ); } function defaultSymbolEnvelope(layout: ComponentSymbolLayout): SymbolEnvelope { return { left: 0, top: 0, width: layout.symbolSize.width, height: layout.symbolSize.height, }; } type SymbolViewBoxTransform = { offsetX: number; offsetY: number; scale: number; }; /** 与 SVG 默认的 xMidYMid meet 规则保持一致,接口和图形共用同一换算。 */ function symbolViewBoxTransform( viewBox: ComponentSymbolViewBox, symbolSize: ComponentSymbolSize, ): SymbolViewBoxTransform { const scale = Math.min( symbolSize.width / viewBox.width, symbolSize.height / viewBox.height, ); return { scale, offsetX: (symbolSize.width - viewBox.width * scale) / 2, offsetY: (symbolSize.height - viewBox.height * scale) / 2, }; } function symbolPointToPixels( point: ComponentSymbolPortAnchor, layout: ComponentSymbolLayout, ) { const transform = symbolViewBoxTransform(layout.viewBox, layout.symbolSize); return { x: transform.offsetX + (point.x - layout.viewBox.x) * transform.scale, y: transform.offsetY + (point.y - layout.viewBox.y) * transform.scale, }; } function measuredSymbolEnvelope( svg: SVGSVGElement, layout: ComponentSymbolLayout, ): SymbolEnvelope { const viewBox = svg.viewBox.baseVal; const geometryBounds = svg.getBBox(); let bounds: Pick = geometryBounds; try { const paintBounds = svg.getBBox({ fill: true, stroke: true, markers: true, clipped: false, }); const includesPaintOutsideGeometry = paintBounds.x < geometryBounds.x || paintBounds.y < geometryBounds.y || paintBounds.x + paintBounds.width > geometryBounds.x + geometryBounds.width || paintBounds.y + paintBounds.height > geometryBounds.y + geometryBounds.height; if (includesPaintOutsideGeometry) { bounds = paintBounds; } } catch { // Older SVG implementations may not support the getBBox options object. } if (bounds === geometryBounds) { const maximumStrokeWidth = Array.from(svg.querySelectorAll("*")) .map((element) => getComputedStyle(element)) .filter((style) => style.stroke !== "none") .reduce( (maximum, style) => Math.max(maximum, Number.parseFloat(style.strokeWidth) || 0), 0, ); if (maximumStrokeWidth > 0) { const strokeScaling = svg.dataset.symbolStrokeScaling; const viewBoxScale = viewBox.width > 0 && viewBox.height > 0 ? symbolViewBoxTransform( { x: viewBox.x, y: viewBox.y, width: viewBox.width, height: viewBox.height, }, layout.symbolSize, ).scale : 1; // non-scaling-stroke 的 computed strokeWidth 是最终 CSS 像素; // 选择包络在 viewBox 坐标中计算,需要先换回 SVG 逻辑单位。 const strokeWidthInViewBox = strokeScaling === "independent" && viewBoxScale > 0 ? maximumStrokeWidth / viewBoxScale : maximumStrokeWidth; const strokeRadius = strokeWidthInViewBox / 2; bounds = { x: geometryBounds.x - strokeRadius, y: geometryBounds.y - strokeRadius, width: geometryBounds.width + strokeWidthInViewBox, height: geometryBounds.height + strokeWidthInViewBox, }; } } if ( viewBox.width <= 0 || viewBox.height <= 0 || bounds.width <= 0 || bounds.height <= 0 ) { return defaultSymbolEnvelope(layout); } const transform = symbolViewBoxTransform( { x: viewBox.x, y: viewBox.y, width: viewBox.width, height: viewBox.height }, layout.symbolSize, ); const left = transform.offsetX + (bounds.x - viewBox.x) * transform.scale - SYMBOL_ENVELOPE_PADDING_PX; const top = transform.offsetY + (bounds.y - viewBox.y) * transform.scale - SYMBOL_ENVELOPE_PADDING_PX; const right = transform.offsetX + (bounds.x + bounds.width - viewBox.x) * transform.scale + SYMBOL_ENVELOPE_PADDING_PX; const bottom = transform.offsetY + (bounds.y + bounds.height - viewBox.y) * transform.scale + SYMBOL_ENVELOPE_PADDING_PX; const roundPixel = (value: number) => Math.round(value * 100) / 100; return { left: roundPixel(left), top: roundPixel(top), width: roundPixel(Math.max(1, right - left)), height: roundPixel(Math.max(1, bottom - top)), }; } function defaultModelingPaneLayout(): ModelingPaneLayout { return { paletteWidth: 230, propertiesWidth: 330, componentParametersHeight: 340, }; } function loadModelingPaneLayout(): ModelingPaneLayout { const fallback = defaultModelingPaneLayout(); try { const parsed = JSON.parse( localStorage.getItem(MODELING_PANE_LAYOUT_KEY) ?? "null", ) as Partial | null; if (!parsed) { return fallback; } return { paletteWidth: Number.isFinite(parsed.paletteWidth) ? Math.min( MAX_PALETTE_WIDTH, Math.max(MIN_PALETTE_WIDTH, Number(parsed.paletteWidth)), ) : fallback.paletteWidth, propertiesWidth: Number.isFinite(parsed.propertiesWidth) ? Math.min( MAX_PROPERTIES_WIDTH, Math.max(MIN_PROPERTIES_WIDTH, Number(parsed.propertiesWidth)), ) : fallback.propertiesWidth, componentParametersHeight: Number.isFinite( parsed.componentParametersHeight, ) ? Math.max( MIN_COMPONENT_PARAMETERS_HEIGHT, Number(parsed.componentParametersHeight), ) : fallback.componentParametersHeight, }; } catch { return fallback; } } function clampModelingPaneLayout( layout: ModelingPaneLayout, workbenchWidth: number, workbenchHeight: 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, ); } const maximumComponentParametersHeight = Math.max( MIN_COMPONENT_PARAMETERS_HEIGHT, workbenchHeight - PROPERTIES_PANE_SPLITTER_SIZE - MIN_SIMULATION_SETTINGS_HEIGHT, ); const requestedComponentParametersHeight = Number.isFinite( layout.componentParametersHeight, ) ? layout.componentParametersHeight : defaultModelingPaneLayout().componentParametersHeight; const componentParametersHeight = Math.min( maximumComponentParametersHeight, Math.max( MIN_COMPONENT_PARAMETERS_HEIGHT, requestedComponentParametersHeight, ), ); return { paletteWidth, propertiesWidth, componentParametersHeight }; } 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 }; } function loadPaletteDisplayMode(): PaletteDisplayMode { try { const storedMode = localStorage.getItem(PALETTE_DISPLAY_MODE_KEY); return storedMode === "icons" || storedMode === "detailed" ? storedMode : "detailed"; } catch { return "detailed"; } } function loadPaletteCollapsedSections() { try { const parsed = JSON.parse( localStorage.getItem(PALETTE_COLLAPSED_SECTIONS_KEY) ?? "[]", ) as unknown; if (!Array.isArray(parsed)) { return new Set(); } return new Set( parsed.filter( (value): value is string => typeof value === "string" && (value.startsWith("library:") || value.startsWith("category:")), ), ); } catch { return new Set(); } } function paletteLibraryCollapseKey(libraryId: string) { return `library:${libraryId}`; } function paletteCategoryCollapseKey(libraryId: string, categoryId: string) { return `category:${libraryId}:${categoryId}`; } function paletteLibraryContentId(libraryId: string) { return `palette-library-content-${encodeURIComponent(libraryId)}`; } function paletteCategoryItemsId(libraryId: string, categoryId: string) { return `palette-category-items-${encodeURIComponent(libraryId)}-${encodeURIComponent(categoryId)}`; } const identityUnit = (value: number) => value; const scaleUnit = (factor: number) => ({ toBase: (value: number) => value * factor, fromBase: (value: number) => value / factor, }); const unitOptions: Record = { area: [ { value: "m2", label: "m²", toBase: identityUnit, fromBase: identityUnit }, { value: "cm2", label: "cm²", ...scaleUnit(1e-4) }, { value: "mm2", label: "mm²", ...scaleUnit(1e-6) }, ], heat_transfer_coefficient: [ { value: "W/(m2*K)", label: "W/(m²·K)", toBase: identityUnit, fromBase: identityUnit, }, ], 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: ResolvedSimulationConfig = { 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; format: "message" | "xml"; }; 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"]; const simulationConfigLabels: Record = { t_start: "起始时间", t_stop: "结束时间", step: "采样步长", max_step: "最大积分步长", }; function physicalPort( name: string, nominalRole: Extract, side: PortSide, ): PortDefinition { return { name, kind: "physical", domain: "pneumatic", nominalRole, positiveFlowDirection: "intoComponent", side, }; } const fallbackComponentDefinitions: ComponentDefinition[] = [ { type: "cylinder", label: "气瓶", modelType: "cylinder", modelVersion: "1.0.0", symbol: "cylinder", order: 10, category: { id: "storage", label: "储能元件", order: 10 }, 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, minExclusive: true, }, T0: { label: "初始温度", unit: "K", quantity: "temperature", default: 300, min: 0, minExclusive: true, }, }, }, { type: "tank", label: "贮箱", modelType: "tank", modelVersion: "1.0.0", symbol: "tank", order: 20, category: { id: "storage", label: "储能元件", order: 10 }, 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, minExclusive: true, }, T0: { label: "初始温度", unit: "K", quantity: "temperature", default: 300, min: 0, minExclusive: true, }, }, }, { type: "pipe", label: "管段", modelType: "pipe", modelVersion: "1.0.0", symbol: "pipe", order: 30, category: { id: "flow", label: "流动元件", order: 20 }, 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, minExclusive: true, }, T0: { label: "初始温度", unit: "K", quantity: "temperature", default: 300, min: 0, minExclusive: true, }, }, }, { type: "orifice", label: "孔板/阀门", modelType: "orifice", modelVersion: "1.0.0", symbol: "orifice", order: 40, category: { id: "flow", label: "流动元件", order: 20 }, ports: [ physicalPort("port_a", "inlet", "left"), physicalPort("port_b", "outlet", "right"), ], parameters: { K: { label: "流量系数", unit: "kg/(s*Pa^0.5)", quantity: "flow_coefficient", default: 0.00001, min: 0, }, opening: { label: "开度", default: 1, min: 0, max: 1 }, }, }, { type: "tee", label: "三通", modelType: "tee", modelVersion: "1.0.0", symbol: "tee", order: 50, category: { id: "junctions", label: "连接元件", order: 30 }, ports: [ physicalPort("port_in", "bidirectional", "left"), physicalPort("port_out1", "bidirectional", "right"), physicalPort("port_out2", "bidirectional", "right"), ], parameters: {}, }, ]; const fallbackComponentLibraries: ComponentLibraryDefinition[] = [ { id: "experimental", label: "临时测试组件库", version: "0.1.0", sourcePackage: "frontend-fallback", temporary: true, order: 100, components: fallbackComponentDefinitions, }, ]; function normalizeComponentCatalog(payload: unknown): ComponentLibraryDefinition[] { const catalog = payload as ComponentCatalogResponse | null; if ( !catalog || catalog.schemaVersion !== 1 || !Array.isArray(catalog.libraries) ) { throw new Error("组件目录格式不受支持"); } const componentTypes = new Set(); const libraries = catalog.libraries.map((library) => { if ( typeof library.id !== "string" || typeof library.label !== "string" || typeof library.version !== "string" || !Array.isArray(library.components) ) { throw new Error("组件库声明不完整"); } const components = library.components.map((component) => { if ( typeof component.type !== "string" || typeof component.modelType !== "string" || typeof component.modelVersion !== "string" || typeof component.label !== "string" || !Array.isArray(component.ports) || !Array.isArray(component.parameters) ) { throw new Error(`组件库 ${library.id} 包含无效元件`); } if (componentTypes.has(component.type)) { throw new Error(`组件类型重复:${component.type}`); } componentTypes.add(component.type); const parameters = Object.fromEntries( component.parameters.map((parameter) => { const visibleWhen = normalizeParameterVisibilityConditions( parameter.visibleWhen, ); return [ parameter.name, { label: parameter.label || parameter.name, ...(typeof parameter.description === "string" && parameter.description.trim() ? { description: parameter.description.trim() } : {}), ...(parameter.unit ? { unit: parameter.unit } : {}), ...(parameter.quantity && parameter.quantity !== "dimensionless" ? { quantity: parameter.quantity } : {}), default: parameter.default, ...(parameter.minimum !== undefined ? { min: parameter.minimum } : {}), ...(parameter.maximum !== undefined ? { max: parameter.maximum } : {}), ...(parameter.minimumExclusive ? { minExclusive: true } : {}), ...(parameter.editor === "choice" || parameter.editor === "amesimGasReference" || parameter.editor === "amesimGasPropertyModel" ? { editor: parameter.editor } : {}), ...(Array.isArray(parameter.options) ? { options: parameter.options.flatMap((option) => Number.isFinite(Number(option?.value)) && typeof option?.label === "string" ? [{ value: Number(option.value), label: option.label }] : [], ), } : {}), ...(visibleWhen ? { visibleWhen } : {}), }, ] as const; }), ); const parameterGroups = normalizeParameterGroups( component.parameterGroups, new Set(Object.keys(parameters)), ); return { type: component.type, label: component.label, modelType: component.modelType, modelVersion: component.modelVersion, symbol: component.symbol || component.type, order: Number(component.order) || 0, category: { id: component.category?.id || "uncategorized", label: component.category?.label || "其他元件", order: Number(component.category?.order) || 0, }, ...(component.role === "amesimGasMediumDefinition" ? { role: "amesimGasMediumDefinition" as const } : {}), ports: component.ports.map((port) => ({ name: port.name, kind: port.kind, domain: port.domain, nominalRole: port.nominalRole, ...(port.positiveFlowDirection === "intoComponent" ? { positiveFlowDirection: "intoComponent" as const } : {}), side: port.side, })), parameters, ...(parameterGroups.length > 0 ? { parameterGroups } : {}), } satisfies ComponentDefinition; }); return { id: library.id, label: library.label, version: library.version, sourcePackage: library.sourcePackage, temporary: Boolean(library.temporary), order: Number(library.order) || 0, components: components.sort( (first, second) => first.order - second.order || first.type.localeCompare(second.type), ), }; }); if (componentTypes.size === 0) { throw new Error("组件目录为空"); } return libraries.sort( (first, second) => first.order - second.order || first.id.localeCompare(second.id), ); } function groupLibraryComponents(library: ComponentLibraryDefinition) { const categories = new Map< string, { id: string; label: string; order: number; components: ComponentDefinition[]; } >(); library.components.forEach((component) => { const category = categories.get(component.category.id) ?? { ...component.category, components: [], }; category.components.push(component); categories.set(component.category.id, category); }); return [...categories.values()] .map((category) => ({ ...category, components: category.components.sort( (first, second) => first.order - second.order || first.type.localeCompare(second.type), ), })) .sort( (first, second) => first.order - second.order || first.id.localeCompare(second.id), ); } const nextNodeNumberByType: Record = {}; function defaultParameters(definition: ComponentDefinition) { return Object.fromEntries( Object.entries(definition.parameters).map(([key, parameter]) => [ key, parameter.editor === "amesimGasReference" ? 0 : 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 ungroupedParameterPriority( key: string, definition: ParameterDefinition, ) { if (key === "gi") { return 0; } return definition.editor ? 1 : 2; } function orderedParameterEntries( definition: ComponentDefinition, ): Array<[string, ParameterDefinition]> { const entries = Object.entries(definition.parameters); const entryByName = new Map(entries); const groupedNames = new Set( definition.parameterGroups?.flatMap((group) => group.parameters) ?? [], ); const ungroupedEntries = entries .map((entry, index) => ({ entry, index })) .filter(({ entry: [key] }) => !groupedNames.has(key)) .sort( (first, second) => ungroupedParameterPriority(first.entry[0], first.entry[1]) - ungroupedParameterPriority(second.entry[0], second.entry[1]) || first.index - second.index, ) .map(({ entry }) => entry); const groupedEntries = definition.parameterGroups?.flatMap((group) => group.parameters.flatMap((name) => { const parameter = entryByName.get(name); return parameter ? ([[name, parameter]] as Array<[string, ParameterDefinition]>) : []; }), ) ?? []; return [...ungroupedEntries, ...groupedEntries]; } function buildNodeData(definition: ComponentDefinition): SimulationNodeData { return { label: definition.label, componentType: definition.type, modelType: definition.modelType, symbol: definition.symbol, ports: definition.ports.map((port) => ({ ...port })), parameters: defaultParameters(definition), parameterUnits: defaultParameterUnits(definition), parameterScientificNotation: {}, rotation: 0, mirrored: false, }; } const AMESIM_BUILTIN_GAS_INDEX = 0; const AMESIM_MIN_DEFINED_GAS_INDEX = 1; const AMESIM_MAX_DEFINED_GAS_INDEX = 99; type AmesimGasMediumOption = { value: number; }; type AmesimGasReferenceRepair = { nodes: SimulationNode[]; repairedReferenceCount: number; }; function componentDefinitionForNode( node: SimulationNode, componentDefinitions: ComponentDefinition[], ) { return ( componentDefinitions.find( (definition) => definition.type === node.data.componentType, ) ?? componentDefinitions.find( (definition) => definition.modelType === node.data.modelType, ) ); } function isAmesimGasMediumDefinition( definition: ComponentDefinition | undefined, ) { return definition?.role === "amesimGasMediumDefinition"; } function normalizeAmesimGasIndex( value: ParameterValue | undefined, minimum = AMESIM_BUILTIN_GAS_INDEX, ) { if (typeof value === "string" && value.trim() === "") { return null; } const numericValue = Number(value); if ( !Number.isInteger(numericValue) || numericValue < minimum || numericValue > AMESIM_MAX_DEFINED_GAS_INDEX ) { return null; } return numericValue; } function definedAmesimGasIndices( nodes: SimulationNode[], componentDefinitions: ComponentDefinition[], ) { const indices = new Set(); nodes.forEach((node) => { const definition = componentDefinitionForNode(node, componentDefinitions); if (!isAmesimGasMediumDefinition(definition)) { return; } const index = normalizeAmesimGasIndex( node.data.parameters.gi, AMESIM_MIN_DEFINED_GAS_INDEX, ); if (index !== null) { indices.add(index); } }); return indices; } function nextAvailableAmesimGasIndex(usedIndices: ReadonlySet) { for ( let index = AMESIM_MIN_DEFINED_GAS_INDEX; index <= AMESIM_MAX_DEFINED_GAS_INDEX; index += 1 ) { if (!usedIndices.has(index)) { return index; } } return null; } function buildAmesimGasMediumOptions( nodes: SimulationNode[], componentDefinitions: ComponentDefinition[], ): AmesimGasMediumOption[] { const options: AmesimGasMediumOption[] = [ { value: AMESIM_BUILTIN_GAS_INDEX, }, ]; const seenIndices = new Set(); nodes.forEach((node) => { const definition = componentDefinitionForNode(node, componentDefinitions); if (!isAmesimGasMediumDefinition(definition)) { return; } const index = normalizeAmesimGasIndex( node.data.parameters.gi, AMESIM_MIN_DEFINED_GAS_INDEX, ); if (index === null || seenIndices.has(index)) { return; } seenIndices.add(index); options.push({ value: index, }); }); return [ options[0], ...options.slice(1).sort((first, second) => first.value - second.value), ]; } function repairAmesimGasReferences( nodes: SimulationNode[], componentDefinitions: ComponentDefinition[], ): AmesimGasReferenceRepair { const availableIndices = definedAmesimGasIndices(nodes, componentDefinitions); availableIndices.add(AMESIM_BUILTIN_GAS_INDEX); let repairedReferenceCount = 0; let changed = false; const repairedNodes = nodes.map((node) => { const definition = componentDefinitionForNode(node, componentDefinitions); if (!definition || isAmesimGasMediumDefinition(definition)) { return node; } const referenceKeys = Object.entries(definition.parameters) .filter(([, parameter]) => parameter.editor === "amesimGasReference") .map(([key]) => key); if (referenceKeys.length === 0) { return node; } let parameters = node.data.parameters; let nodeChanged = false; referenceKeys.forEach((key) => { const index = normalizeAmesimGasIndex(parameters[key]); if (index !== null && availableIndices.has(index)) { return; } if (!nodeChanged) { parameters = { ...parameters }; nodeChanged = true; } parameters[key] = AMESIM_BUILTIN_GAS_INDEX; repairedReferenceCount += 1; }); if (!nodeChanged) { return node; } changed = true; return { ...node, data: { ...node.data, parameters, }, }; }); return { nodes: changed ? repairedNodes : nodes, repairedReferenceCount, }; } function migrateLegacyDefaultAmesimGasReference( nodes: SimulationNode[], componentDefinitions: ComponentDefinition[], mediumReferenceVersion: 1 | null | undefined, ): AmesimGasReferenceRepair { const hasVersionMarker = mediumReferenceVersion === 1; const hasMediumDefinition = nodes.some((node) => isAmesimGasMediumDefinition( componentDefinitionForNode(node, componentDefinitions), ), ); if (hasVersionMarker || hasMediumDefinition) { return { nodes, repairedReferenceCount: 0 }; } let repairedReferenceCount = 0; let changed = false; const migratedNodes = nodes.map((node) => { const definition = componentDefinitionForNode(node, componentDefinitions); if (!definition) { return node; } let parameters = node.data.parameters; let nodeChanged = false; Object.entries(definition.parameters).forEach(([key, parameter]) => { if ( parameter.editor !== "amesimGasReference" || normalizeAmesimGasIndex(parameters[key]) !== 1 ) { return; } if (!nodeChanged) { parameters = { ...parameters }; nodeChanged = true; } parameters[key] = AMESIM_BUILTIN_GAS_INDEX; repairedReferenceCount += 1; }); if (!nodeChanged) { return node; } changed = true; return { ...node, data: { ...node.data, parameters, }, }; }); return { nodes: changed ? migratedNodes : nodes, repairedReferenceCount, }; } function assignMissingAmesimGasDefinitionIndices( nodes: SimulationNode[], componentDefinitions: ComponentDefinition[], ) { const usedIndices = definedAmesimGasIndices(nodes, componentDefinitions); let assignedDefinitionCount = 0; let exhaustedDefinitionCount = 0; const indexedNodes = nodes.map((node) => { const definition = componentDefinitionForNode(node, componentDefinitions); if (!isAmesimGasMediumDefinition(definition)) { return node; } const currentIndex = normalizeAmesimGasIndex( node.data.parameters.gi, AMESIM_MIN_DEFINED_GAS_INDEX, ); if (currentIndex !== null) { return node; } const nextIndex = nextAvailableAmesimGasIndex(usedIndices); if (nextIndex === null) { exhaustedDefinitionCount += 1; return node; } usedIndices.add(nextIndex); assignedDefinitionCount += 1; return { ...node, data: { ...node.data, parameters: { ...node.data.parameters, gi: nextIndex, }, }, }; }); return { nodes: indexedNodes, assignedDefinitionCount, exhaustedDefinitionCount, }; } function SimulationComponentNode({ id, data, selected }: NodeProps) { const updateNodeInternals = useUpdateNodeInternals(); const symbolContainerRef = useRef(null); const rotation = normalizeNodeRotation(data.rotation); const mirrored = Boolean(data.mirrored); const vertical = rotation === 90 || rotation === 270; const symbol = data.symbol ?? data.componentType; const hasDedicatedSymbol = hasComponentSymbol(symbol); const symbolPresentation = componentSymbolPresentation(symbol); const layout = componentSymbolLayout(symbol); const renderedSize = nodeFrameDimensions(layout, rotation); const layoutSignature = `${layout.viewBox.x}:${layout.viewBox.y}:${layout.viewBox.width}:${layout.viewBox.height}:${layout.symbolSize.width}:${layout.symbolSize.height}:${layout.nodeSize.width}:${layout.nodeSize.height}`; const [symbolEnvelope, setSymbolEnvelope] = useState(() => defaultSymbolEnvelope(layout), ); const portPlacements = transformedPortPlacements( data.ports, rotation, mirrored, layout, ); const visiblePortIndexPlacements = data.hideConnectedPortIndexLabels ? portPlacements.filter( ({ port }) => data.portConnectionStates?.[port.name] !== "connected", ) : portPlacements; const portIndexLabels = selected ? portIndexLabelPlacements( data.ports, visiblePortIndexPlacements, layout, rotation, ) : []; const symbolVariantKey = componentSymbolVariantKey(symbol, data.parameters); const nodeLayoutStyle = { width: renderedSize.width, height: renderedSize.height, "--node-frame-width": `${layout.nodeSize.width}px`, "--node-frame-height": `${layout.nodeSize.height}px`, "--node-symbol-width": `${layout.symbolSize.width}px`, "--node-symbol-height": `${layout.symbolSize.height}px`, } as CSSProperties; useLayoutEffect(() => { if (!hasDedicatedSymbol) { return; } const svg = symbolContainerRef.current?.querySelector( ".component-symbol-svg", ); if (!svg) { return; } const nextEnvelope = measuredSymbolEnvelope(svg, layout); setSymbolEnvelope((currentEnvelope) => currentEnvelope.left === nextEnvelope.left && currentEnvelope.top === nextEnvelope.top && currentEnvelope.width === nextEnvelope.width && currentEnvelope.height === nextEnvelope.height ? currentEnvelope : nextEnvelope, ); }, [hasDedicatedSymbol, layout, layoutSignature, symbol, symbolVariantKey]); useEffect(() => { updateNodeInternals(id); }, [id, layoutSignature, mirrored, rotation, updateNodeInternals]); return (
{portPlacements.map(({ port, position, x, y }) => { const connectionState = data.portConnectionStates?.[port.name] ?? "idle"; const isConnected = connectionState === "connected"; return ( ); })} {hasDedicatedSymbol ? ( ); } 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 rotatedDimensions(size: ComponentSymbolSize, rotation: NodeRotation) { const vertical = rotation === 90 || rotation === 270; return vertical ? { width: size.height, height: size.width } : { width: size.width, height: size.height }; } function nodeFrameDimensions( layout: ComponentSymbolLayout, rotation: NodeRotation, ) { return rotatedDimensions(layout.nodeSize, rotation); } function rotateNodeClockwise(node: SimulationNode): SimulationNode { const currentRotation = normalizeNodeRotation(node.data.rotation); const nextRotation = nextNodeRotation(currentRotation); const symbol = node.data.symbol ?? node.data.componentType; const layout = componentSymbolLayout(symbol); const currentSize = nodeFrameDimensions(layout, currentRotation); const nextSize = nodeFrameDimensions(layout, 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, layout: ComponentSymbolLayout, ) { const renderedSize = nodeFrameDimensions(layout, rotation); const portAnchors = layout.portAnchors; return (["left", "right"] as PortSide[]).flatMap((side) => { const sidePorts = ports.filter((port) => port.side === side); const sideAnchors = portAnchors?.[side]; const usesSymbolAnchors = sideAnchors?.length === sidePorts.length; return sidePorts.map((port, index) => { const anchor = usesSymbolAnchors ? sideAnchors[index] : undefined; const symbolPoint = anchor ? symbolPointToPixels(anchor, layout) : undefined; let x = symbolPoint ? symbolPoint.x - layout.symbolSize.width / 2 : side === "left" ? -layout.nodeSize.width / 2 : layout.nodeSize.width / 2; let y = symbolPoint ? symbolPoint.y - layout.symbolSize.height / 2 : -layout.nodeSize.height / 2 + ((index + 1) * layout.nodeSize.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: transformedPortPosition( side, rotation, mirrored, layout.viewBox, anchor, ), x: renderedX, y: renderedY, }; }); }); } type TransformedPortPlacement = ReturnType< typeof transformedPortPlacements >[number]; type PortIndexLabelPlacement = { order: number; portName: string; position: Position; text: string; x: number; y: number; }; function portIndexText(port: PortDefinition, index: number) { const numericSuffix = port.name.match(/^port_([1-9]\d*)$/)?.[1]; if (numericSuffix !== undefined) { const parsed = Number(numericSuffix); if (Number.isSafeInteger(parsed)) { return String(parsed); } } return String(index + 1); } function portIndexLabelWidth(text: string) { return Math.max( PORT_INDEX_LABEL_MIN_WIDTH_PX, text.length * PORT_INDEX_LABEL_CHARACTER_WIDTH_PX + 4, ); } function packPortIndexLabelLane( labels: PortIndexLabelPlacement[], position: Position, axisLength: number, ) { const verticalEdge = position === Position.Left || position === Position.Right; const coordinate = (label: PortIndexLabelPlacement) => verticalEdge ? label.y : label.x; const extent = (label: PortIndexLabelPlacement) => verticalEdge ? PORT_INDEX_LABEL_HEIGHT_PX : portIndexLabelWidth(label.text); const sorted = [...labels].sort( (first, second) => coordinate(first) - coordinate(second) || first.order - second.order, ); if (sorted.length < 2) { return sorted; } const packedCoordinates: number[] = []; for (const [index, label] of sorted.entries()) { const target = coordinate(label); if (index === 0) { packedCoordinates.push(target); continue; } const previous = sorted[index - 1]; const minimumCoordinate = packedCoordinates[index - 1] + extent(previous) / 2 + extent(label) / 2 + PORT_INDEX_LABEL_GAP_PX; packedCoordinates.push(Math.max(target, minimumCoordinate)); } const idealShift = sorted.reduce( (sum, label, index) => sum + coordinate(label) - packedCoordinates[index], 0, ) / sorted.length; const minimumShift = extent(sorted[0]) / 2 - packedCoordinates[0]; const maximumShift = axisLength - extent(sorted[sorted.length - 1]) / 2 - packedCoordinates[packedCoordinates.length - 1]; const shift = minimumShift <= maximumShift ? Math.min(Math.max(idealShift, minimumShift), maximumShift) : idealShift; return sorted.map((label, index) => { const adjustedCoordinate = packedCoordinates[index] + shift; return verticalEdge ? { ...label, y: adjustedCoordinate } : { ...label, x: adjustedCoordinate }; }); } function offsetPortIndexLabelOutward( label: PortIndexLabelPlacement, distance: number, ) { if (label.position === Position.Left) { return { ...label, x: label.x - distance }; } if (label.position === Position.Right) { return { ...label, x: label.x + distance }; } if (label.position === Position.Top) { return { ...label, y: label.y - distance }; } return { ...label, y: label.y + distance }; } function spreadPortIndexLabels( labels: PortIndexLabelPlacement[], position: Position, axisLength: number, ) { if (labels.length === 0) { return labels; } const verticalEdge = position === Position.Left || position === Position.Right; const coordinate = (label: PortIndexLabelPlacement) => verticalEdge ? label.y : label.x; const extent = (label: PortIndexLabelPlacement) => verticalEdge ? PORT_INDEX_LABEL_HEIGHT_PX : portIndexLabelWidth(label.text); const normalExtent = (label: PortIndexLabelPlacement) => verticalEdge ? portIndexLabelWidth(label.text) : PORT_INDEX_LABEL_HEIGHT_PX; const sorted = [...labels].sort( (first, second) => coordinate(first) - coordinate(second) || first.order - second.order, ); const requiredSpan = sorted.reduce((sum, label) => sum + extent(label), 0) + PORT_INDEX_LABEL_GAP_PX * Math.max(0, sorted.length - 1); const laneCount = Math.min( sorted.length, Math.max(1, Math.ceil(requiredSpan / axisLength)), ); const lanes = Array.from( { length: laneCount }, () => [] as PortIndexLabelPlacement[], ); sorted.forEach((label, index) => lanes[index % laneCount].push(label)); const laneStep = Math.max(...sorted.map(normalExtent)) + PORT_INDEX_LABEL_LANE_GAP_PX; return lanes.flatMap((lane, laneIndex) => packPortIndexLabelLane(lane, position, axisLength).map((label) => offsetPortIndexLabelOutward( label, PORT_INDEX_OUTSET_PX + laneIndex * laneStep, ), ), ); } function portIndexLabelPlacements( ports: PortDefinition[], placements: TransformedPortPlacement[], layout: ComponentSymbolLayout, rotation: NodeRotation, ) { const indexByPortName = new Map( ports.map((port, index) => [port.name, index] as const), ); const labels = placements.map(({ port, position, x, y }, order) => { const index = indexByPortName.get(port.name) ?? order; const label: PortIndexLabelPlacement = { order, portName: port.name, position, text: portIndexText(port, index), x, y, }; if (position === Position.Left || position === Position.Right) { label.y -= PORT_INDEX_TANGENT_OFFSET_PX; } else { label.x += PORT_INDEX_TANGENT_OFFSET_PX; } return label; }); const renderedSize = nodeFrameDimensions(layout, rotation); const spreadByOrder = new Map(); for (const position of [ Position.Left, Position.Right, Position.Top, Position.Bottom, ]) { const group = labels.filter((label) => label.position === position); const axisLength = position === Position.Left || position === Position.Right ? renderedSize.height : renderedSize.width; for (const label of spreadPortIndexLabels(group, position, axisLength)) { spreadByOrder.set(label.order, label); } } return labels.map((label) => spreadByOrder.get(label.order) ?? label); } 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 transformedPortPosition( side: PortSide, rotation: NodeRotation, mirrored: boolean, viewBox: ComponentSymbolViewBox, anchor?: ComponentSymbolPortAnchor, ) { let directionX = side === "left" ? -1 : 1; let directionY = 0; if (anchor) { const closestEdge = [ { distance: anchor.x - viewBox.x, x: -1, y: 0 }, { distance: viewBox.x + viewBox.width - anchor.x, x: 1, y: 0, }, { distance: anchor.y - viewBox.y, x: 0, y: -1 }, { distance: viewBox.y + viewBox.height - anchor.y, x: 0, y: 1, }, ].reduce((closest, candidate) => candidate.distance < closest.distance ? candidate : closest, ); directionX = closestEdge.x; directionY = closestEdge.y; } if (mirrored) { directionX = -directionX; } const [x, y] = rotatePoint(directionX, directionY, rotation); if (x < -0.01) { return Position.Left; } if (x > 0.01) { return Position.Right; } if (y < 0) { return Position.Top; } return Position.Bottom; } function nodePortFlowPlacementsWithLayout( node: SimulationNode, layout: ComponentSymbolLayout, ) { const rotation = normalizeNodeRotation(node.data.rotation); return transformedPortPlacements( node.data.ports, rotation, Boolean(node.data.mirrored), layout, ).map((placement) => ({ ...placement, flowX: node.position.x + placement.x, flowY: node.position.y + placement.y, })); } function nodePortFlowPlacements(node: SimulationNode) { const symbol = node.data.symbol ?? node.data.componentType; return nodePortFlowPlacementsWithLayout( node, componentSymbolLayout(symbol), ); } function nodePortFlowPlacement(node: SimulationNode, portName: string | null) { if (!portName) { return undefined; } return nodePortFlowPlacements(node).find( (placement) => placement.port.name === portName, ); } function legacyComponentSymbolLayout(symbol: string): ComponentSymbolLayout { const currentLayout = componentSymbolLayout(symbol); return { ...currentLayout, tier: "large", // 版本 1 之前所有建模区图标都使用这一套固定几何。 viewBox: { x: 0, y: 0, width: 64, height: 48 }, symbolSize: LEGACY_NODE_SYMBOL_SIZE, nodeSize: LEGACY_NODE_FRAME_SIZE, portAnchors: currentLayout.legacyPortAnchors ?? currentLayout.portAnchors, }; } type PresentationLayoutMigrationResult = { nodes: SimulationNode[]; migrated: boolean; preservedContactEdgeCount: number; conflictingContactConstraintCount: number; }; type ContactLayoutConstraint = { neighborId: string; ownPortName: string; neighborPortName: string; }; function nodePortPlacementWithLayout( node: SimulationNode, portName: string, layout: ComponentSymbolLayout, ) { return nodePortFlowPlacementsWithLayout(node, layout).find( (placement) => placement.port.name === portName, ); } /** * 将旧版固定 132×84 布局迁移到逐元件布局。 * 先用旧几何识别无连线接触边,再按新接口位置重排接触连通组。 */ function migrateLegacyPresentationLayout( nodes: SimulationNode[], edges: SimulationEdge[], version?: 1 | null, ): PresentationLayoutMigrationResult { if (version === PRESENTATION_LAYOUT_VERSION) { return { nodes, migrated: false, preservedContactEdgeCount: 0, conflictingContactConstraintCount: 0, }; } const originalById = new Map(nodes.map((node) => [node.id, node])); const preferredById = new Map( nodes.map((node) => { const rotation = normalizeNodeRotation(node.data.rotation); const symbol = node.data.symbol ?? node.data.componentType; const oldSize = rotatedDimensions(LEGACY_NODE_FRAME_SIZE, rotation); const newSize = nodeFrameDimensions(componentSymbolLayout(symbol), rotation); return [ node.id, { ...node, position: { x: node.position.x + (oldSize.width - newSize.width) / 2, y: node.position.y + (oldSize.height - newSize.height) / 2, }, } satisfies SimulationNode, ] as const; }), ); const constraints = new Map(); let preservedContactEdgeCount = 0; for (const edge of edges) { if (!edge.sourceHandle || !edge.targetHandle) { continue; } const sourceNode = originalById.get(edge.source); const targetNode = originalById.get(edge.target); if (!sourceNode || !targetNode) { continue; } const sourceSymbol = sourceNode.data.symbol ?? sourceNode.data.componentType; const targetSymbol = targetNode.data.symbol ?? targetNode.data.componentType; const sourcePort = nodePortPlacementWithLayout( sourceNode, edge.sourceHandle, legacyComponentSymbolLayout(sourceSymbol), ); const targetPort = nodePortPlacementWithLayout( targetNode, edge.targetHandle, legacyComponentSymbolLayout(targetSymbol), ); if ( !sourcePort || !targetPort || Math.hypot( targetPort.flowX - sourcePort.flowX, targetPort.flowY - sourcePort.flowY, ) > CONTACT_GEOMETRY_TOLERANCE ) { continue; } preservedContactEdgeCount += 1; const sourceConstraints = constraints.get(sourceNode.id) ?? []; sourceConstraints.push({ neighborId: targetNode.id, ownPortName: edge.sourceHandle, neighborPortName: edge.targetHandle, }); constraints.set(sourceNode.id, sourceConstraints); const targetConstraints = constraints.get(targetNode.id) ?? []; targetConstraints.push({ neighborId: sourceNode.id, ownPortName: edge.targetHandle, neighborPortName: edge.sourceHandle, }); constraints.set(targetNode.id, targetConstraints); } const migratedById = new Map(preferredById); const visited = new Set(); let conflictingContactConstraintCount = 0; for (const rootNode of nodes) { if (visited.has(rootNode.id) || !constraints.has(rootNode.id)) { continue; } const assignedPositions = new Map(); const componentNodeIds: string[] = []; const rootPreferred = preferredById.get(rootNode.id); if (!rootPreferred) { continue; } assignedPositions.set(rootNode.id, { ...rootPreferred.position }); const queue = [rootNode.id]; visited.add(rootNode.id); while (queue.length > 0) { const nodeId = queue.shift() as string; componentNodeIds.push(nodeId); const node = originalById.get(nodeId); const nodePosition = assignedPositions.get(nodeId); if (!node || !nodePosition) { continue; } const nodeLayout = componentSymbolLayout( node.data.symbol ?? node.data.componentType, ); for (const constraint of constraints.get(nodeId) ?? []) { const neighbor = originalById.get(constraint.neighborId); if (!neighbor) { continue; } const neighborLayout = componentSymbolLayout( neighbor.data.symbol ?? neighbor.data.componentType, ); const ownPort = nodePortPlacementWithLayout( node, constraint.ownPortName, nodeLayout, ); const neighborPort = nodePortPlacementWithLayout( neighbor, constraint.neighborPortName, neighborLayout, ); if (!ownPort || !neighborPort) { continue; } const proposedNeighborPosition = { x: nodePosition.x + ownPort.x - neighborPort.x, y: nodePosition.y + ownPort.y - neighborPort.y, }; const assignedNeighborPosition = assignedPositions.get(neighbor.id); if (assignedNeighborPosition) { if ( Math.hypot( assignedNeighborPosition.x - proposedNeighborPosition.x, assignedNeighborPosition.y - proposedNeighborPosition.y, ) > CONTACT_GEOMETRY_TOLERANCE ) { conflictingContactConstraintCount += 1; } continue; } assignedPositions.set(neighbor.id, proposedNeighborPosition); visited.add(neighbor.id); queue.push(neighbor.id); } } if (componentNodeIds.length === 0) { continue; } const preferredCenter = componentNodeIds.reduce( (sum, nodeId) => { const node = originalById.get(nodeId) as SimulationNode; const preferred = preferredById.get(nodeId) as SimulationNode; const size = nodeFrameDimensions( componentSymbolLayout(node.data.symbol ?? node.data.componentType), normalizeNodeRotation(node.data.rotation), ); return { x: sum.x + preferred.position.x + size.width / 2, y: sum.y + preferred.position.y + size.height / 2, }; }, { x: 0, y: 0 }, ); const assignedCenter = componentNodeIds.reduce( (sum, nodeId) => { const node = originalById.get(nodeId) as SimulationNode; const position = assignedPositions.get(nodeId) as { x: number; y: number }; const size = nodeFrameDimensions( componentSymbolLayout(node.data.symbol ?? node.data.componentType), normalizeNodeRotation(node.data.rotation), ); return { x: sum.x + position.x + size.width / 2, y: sum.y + position.y + size.height / 2, }; }, { x: 0, y: 0 }, ); const translation = { x: (preferredCenter.x - assignedCenter.x) / componentNodeIds.length, y: (preferredCenter.y - assignedCenter.y) / componentNodeIds.length, }; for (const nodeId of componentNodeIds) { const preferred = preferredById.get(nodeId) as SimulationNode; const position = assignedPositions.get(nodeId) as { x: number; y: number }; migratedById.set(nodeId, { ...preferred, position: { x: position.x + translation.x, y: position.y + translation.y, }, }); } } return { nodes: nodes.map((node) => migratedById.get(node.id) ?? node), migrated: true, preservedContactEdgeCount, conflictingContactConstraintCount, }; } function connectionForContactPorts( draggedNode: SimulationNode, draggedPort: PortDefinition, targetNode: SimulationNode, targetPort: PortDefinition, ): Connection { if (draggedPort.kind === "signal" && draggedPort.nominalRole === "input") { return { source: targetNode.id, sourceHandle: targetPort.name, target: draggedNode.id, targetHandle: draggedPort.name, }; } return { source: draggedNode.id, sourceHandle: draggedPort.name, target: targetNode.id, targetHandle: targetPort.name, }; } type ContactSnapPair = ContactSnapMatch & { distance: number; draggedPortOrder: number; targetNodeOrder: number; targetPortOrder: number; }; function compareContactSnapPairs( first: ContactSnapPair, second: ContactSnapPair, ) { return ( first.distance - second.distance || first.draggedPortOrder - second.draggedPortOrder || first.targetNodeOrder - second.targetNodeOrder || first.targetPortOrder - second.targetPortOrder || first.draggedPortName.localeCompare(second.draggedPortName) || first.targetNodeId.localeCompare(second.targetNodeId) || first.targetPortName.localeCompare(second.targetPortName) ); } function availableContactSnapPairs( draggedNode: SimulationNode, nodes: SimulationNode[], edges: SimulationEdge[], maximumDistance: number, ) { const occupiedPorts = new Set(); edges.forEach((edge) => { if (edge.sourceHandle) { occupiedPorts.add(portConnectionKey(edge.source, edge.sourceHandle)); } if (edge.targetHandle) { occupiedPorts.add(portConnectionKey(edge.target, edge.targetHandle)); } }); const pairs: ContactSnapPair[] = []; const draggedPortOrder = new Map( draggedNode.data.ports.map((port, index) => [port.name, index] as const), ); const draggedPlacements = nodePortFlowPlacements(draggedNode); for (const draggedPlacement of draggedPlacements) { if ( occupiedPorts.has( portConnectionKey(draggedNode.id, draggedPlacement.port.name), ) ) { continue; } for (const [targetNodeOrder, targetNode] of nodes.entries()) { if (targetNode.id === draggedNode.id) { continue; } const targetPortOrder = new Map( targetNode.data.ports.map((port, index) => [port.name, index] as const), ); for (const targetPlacement of nodePortFlowPlacements(targetNode)) { if ( occupiedPorts.has( portConnectionKey(targetNode.id, targetPlacement.port.name), ) || !arePortsCompatible(draggedPlacement.port, targetPlacement.port) ) { continue; } const distance = Math.hypot( targetPlacement.flowX - draggedPlacement.flowX, targetPlacement.flowY - draggedPlacement.flowY, ); if (distance > maximumDistance) { continue; } const connection = connectionForContactPorts( draggedNode, draggedPlacement.port, targetNode, targetPlacement.port, ); if (!canConnectPorts(nodes, edges, connection)) { continue; } pairs.push({ connection, distance, draggedPortOrder: draggedPortOrder.get(draggedPlacement.port.name) ?? Number.MAX_SAFE_INTEGER, draggedPortName: draggedPlacement.port.name, targetNodeId: targetNode.id, targetNodeOrder, targetPortOrder: targetPortOrder.get(targetPlacement.port.name) ?? Number.MAX_SAFE_INTEGER, targetPortName: targetPlacement.port.name, }); } } } return pairs.sort(compareContactSnapPairs); } function maximumContactSnapMatches(pairs: ContactSnapPair[]) { // 用二分图最大匹配保证同一批吸附中,每个拖动端口和目标端口都只占用一次。 const pairsByDraggedPort = new Map(); pairs.forEach((pair) => { const current = pairsByDraggedPort.get(pair.draggedPortName) ?? []; current.push(pair); pairsByDraggedPort.set(pair.draggedPortName, current); }); const pairByTargetPort = new Map(); const assignDraggedPort = ( draggedPortName: string, visitedTargetPorts: Set, ): boolean => { for (const pair of pairsByDraggedPort.get(draggedPortName) ?? []) { const targetKey = portConnectionKey(pair.targetNodeId, pair.targetPortName); if (visitedTargetPorts.has(targetKey)) { continue; } visitedTargetPorts.add(targetKey); const currentPair = pairByTargetPort.get(targetKey); if ( currentPair && !assignDraggedPort(currentPair.draggedPortName, visitedTargetPorts) ) { continue; } pairByTargetPort.set(targetKey, pair); return true; } return false; }; for (const draggedPortName of pairsByDraggedPort.keys()) { assignDraggedPort(draggedPortName, new Set()); } return [...pairByTargetPort.values()].sort(compareContactSnapPairs); } function findContactSnapCandidate( draggedNode: SimulationNode, nodes: SimulationNode[], edges: SimulationEdge[], maximumDistance: number, ) { const nearbyPairs = availableContactSnapPairs( draggedNode, nodes, edges, maximumDistance, ); const anchor = nearbyPairs[0]; if (!anchor) { return null; } const draggedPlacement = nodePortFlowPlacement( draggedNode, anchor.draggedPortName, ); const targetNode = nodes.find((node) => node.id === anchor.targetNodeId); const targetPlacement = targetNode ? nodePortFlowPlacement(targetNode, anchor.targetPortName) : undefined; if (!draggedPlacement || !targetPlacement) { return null; } const snappedPosition = { x: draggedNode.position.x + targetPlacement.flowX - draggedPlacement.flowX, y: draggedNode.position.y + targetPlacement.flowY - draggedPlacement.flowY, }; const snappedDraggedNode = { ...draggedNode, position: snappedPosition, }; const snappedNodes = nodes.map((node) => node.id === draggedNode.id ? snappedDraggedNode : node, ); // 多个接口必须共享同一个组件落点;只连接该落点下真正重合的接口, // 避免一个组件同时被互相冲突的接口位置“拉向”不同方向。 const exactPairs = availableContactSnapPairs( snappedDraggedNode, snappedNodes, edges, CONTACT_GEOMETRY_TOLERANCE, ); const matches = maximumContactSnapMatches(exactPairs).map( ({ connection, draggedPortName, targetNodeId, targetPortName, }) => ({ connection, draggedPortName, targetNodeId, targetPortName }), ); if (matches.length === 0) { return null; } return { draggedNodeId: draggedNode.id, matches, snappedPosition, }; } function sameContactSnapCandidate( first: ContactSnapCandidate | null, second: ContactSnapCandidate | null, ) { return ( first === second || (Boolean(first) && Boolean(second) && first?.draggedNodeId === second?.draggedNodeId && first?.snappedPosition.x === second?.snappedPosition.x && first?.snappedPosition.y === second?.snappedPosition.y && first?.matches.length === second?.matches.length && first?.matches.every((match, index) => { const other = second?.matches[index]; if (!other) { return false; } return ( match.draggedPortName === other.draggedPortName && match.targetNodeId === other.targetNodeId && match.targetPortName === other.targetPortName ); })) ); } function isContactEdgeGeometry(nodes: SimulationNode[], edge: SimulationEdge) { const sourceNode = nodes.find((node) => node.id === edge.source); const targetNode = nodes.find((node) => node.id === edge.target); if (!sourceNode || !targetNode) { return false; } const source = nodePortFlowPlacement(sourceNode, edge.sourceHandle ?? null); const target = nodePortFlowPlacement(targetNode, edge.targetHandle ?? null); return Boolean( source && target && Math.hypot(target.flowX - source.flowX, target.flowY - source.flowY) <= CONTACT_GEOMETRY_TOLERANCE, ); } function separateDeletedContactEdges( nodes: SimulationNode[], deletedEdges: SimulationEdge[], ) { const offsets = new Map(); deletedEdges.forEach((edge) => { if (!isContactEdgeGeometry(nodes, edge)) { return; } const sourceNode = nodes.find((node) => node.id === edge.source); const targetNode = nodes.find((node) => node.id === edge.target); if (!sourceNode || !targetNode) { return; } const sourceSize = nodeFrameDimensions( componentSymbolLayout( sourceNode.data.symbol ?? sourceNode.data.componentType, ), normalizeNodeRotation(sourceNode.data.rotation), ); const targetSize = nodeFrameDimensions( componentSymbolLayout( targetNode.data.symbol ?? targetNode.data.componentType, ), normalizeNodeRotation(targetNode.data.rotation), ); const centerDelta = { x: targetNode.position.x + targetSize.width / 2 - (sourceNode.position.x + sourceSize.width / 2), y: targetNode.position.y + targetSize.height / 2 - (sourceNode.position.y + sourceSize.height / 2), }; const magnitude = Math.hypot(centerDelta.x, centerDelta.y) || 1; const current = offsets.get(targetNode.id) ?? { x: 0, y: 0 }; offsets.set(targetNode.id, { x: current.x + (centerDelta.x / magnitude) * CONTACT_DELETE_SEPARATION, y: current.y + (centerDelta.y / magnitude) * CONTACT_DELETE_SEPARATION, }); }); if (offsets.size === 0) { return nodes; } return nodes.map((node) => { const offset = offsets.get(node.id); return offset ? { ...node, position: { x: node.position.x + offset.x, y: node.position.y + offset.y, }, } : node; }); } function disconnectInvalidatedContactEdges( previousNodes: SimulationNode[], nextNodes: SimulationNode[], edges: SimulationEdge[], transformedNodeIds: Set, ) { const removedEdges: SimulationEdge[] = []; const remainingEdges = edges.filter((edge) => { const affected = transformedNodeIds.has(edge.source) || transformedNodeIds.has(edge.target); const invalidated = affected && isContactEdgeGeometry(previousNodes, edge) && !isContactEdgeGeometry(nextNodes, edge); if (invalidated) { removedEdges.push(edge); } return !invalidated; }); return { remainingEdges, removedEdges }; } 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; 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: ResolvedSimulationConfig, ): 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, 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") { 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 [componentLibraries, setComponentLibraries] = useState(fallbackComponentLibraries); const [componentCatalogStatus, setComponentCatalogStatus] = useState("loading"); const [componentCatalogMessage, setComponentCatalogMessage] = useState("正在加载后端组件目录"); const componentDefinitions = useMemo( () => componentLibraries.flatMap((library) => library.components), [componentLibraries], ); const [nodes, setNodes] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); const [selectedNodeId, setSelectedNodeId] = useState(null); const [selectedEdgeId, setSelectedEdgeId] = useState(null); const [activePortConnection, setActivePortConnection] = useState(null); const [contactSnapCandidate, setContactSnapCandidate] = useState(null); const [projectName, setProjectName] = useState("demo-system"); const [simulationConfig, setSimulationConfig] = useState( defaultSimulationConfig, ); const [resultSnapshot, setResultSnapshot] = useState(loadStoredResultSnapshot); const [hasUnreadResults, setHasUnreadResults] = useState(false); const [activeView, setActiveView] = useState( workspaceViewFromLocation, ); const [modelingFitRequest, setModelingFitRequest] = useState(1); const [busy, setBusy] = useState(false); const [consoleEntries, setConsoleEntries] = useState([]); const [consoleMode, setConsoleMode] = useState("minimized"); 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 [paletteDisplayMode, setPaletteDisplayMode] = useState(loadPaletteDisplayMode); const [collapsedPaletteSections, setCollapsedPaletteSections] = useState>(loadPaletteCollapsedSections); const [paletteIconPreview, setPaletteIconPreview] = useState(null); const [parameterColumnLayout, setParameterColumnLayout] = useState(loadParameterColumnLayout); const [modelingPaneLayout, setModelingPaneLayout] = useState(loadModelingPaneLayout); const [activeModelingPaneResize, setActiveModelingPaneResize] = useState(null); const [modelingWorkbenchHeight, setModelingWorkbenchHeight] = useState(0); const effectiveComponentParametersHeight = Number.isFinite( modelingPaneLayout.componentParametersHeight, ) ? modelingPaneLayout.componentParametersHeight : defaultModelingPaneLayout().componentParametersHeight; 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 reactFlowStore = useStoreApi(); const multipleNodesSelected = useStore((flowState) => { let selectedCount = 0; for (const node of flowState.nodeLookup.values()) { if (node.selected && (selectedCount += 1) > 1) { return true; } } return false; }); 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 consoleEntryIdRef = useRef(0); const activeSimulationIdRef = useRef(null); const previousActiveViewRef = useRef(activeView); const paletteIconPreviewTimerRef = useRef(null); const suppressCancelledConnectionRef = useRef(false); const contactDragSessionRef = useRef(null); const tightSelectionRectRef = useRef(null); const tightSelectionFrameRef = useRef(null); const tightSelectionNodeIdsRef = useRef>(new Set()); const tightSelectionPointerIdRef = useRef(null); const tightSelectionPointerTargetRef = useRef(null); nodesRef.current = nodes; edgesRef.current = edges; projectNameRef.current = projectName; simulationConfigRef.current = simulationConfig; const clearContactSnapCandidate = useCallback(() => { setContactSnapCandidate(null); }, []); const cancelPortConnection = useCallback(() => { const flowState = reactFlowStore.getState(); if (flowState.connection.inProgress) { suppressCancelledConnectionRef.current = true; } flowState.cancelConnection(); // React Flow 12 的拖拽取消不会同时清理点击连线起点。 reactFlowStore.setState({ connectionClickStartHandle: null }); setActivePortConnection(null); contactDragSessionRef.current = null; clearContactSnapCandidate(); }, [clearContactSnapCandidate, reactFlowStore]); const applyTightSelectionRect = useCallback( (selectionRect: CanvasSelectionRect) => { const flowState = reactFlowStore.getState(); const flowRoot = flowState.domNode; if (!flowRoot) { return; } const selectedNodeIds = new Set(); if (selectionRect.width > 0 && selectionRect.height > 0) { const flowBounds = flowRoot.getBoundingClientRect(); const selectionBounds: ScreenRectangle = { left: flowBounds.left + selectionRect.x, top: flowBounds.top + selectionRect.y, right: flowBounds.left + selectionRect.x + selectionRect.width, bottom: flowBounds.top + selectionRect.y + selectionRect.height, }; const nodeElements = new Map( Array.from( flowRoot.querySelectorAll( ".react-flow__node[data-id]", ), ).flatMap((element) => element.dataset.id ? [[element.dataset.id, element] as const] : [], ), ); for (const internalNode of flowState.nodeLookup.values()) { if (internalNode.hidden || internalNode.selectable === false) { continue; } const nodeElement = nodeElements.get(internalNode.id); if (!nodeElement) { continue; } const selectionTarget = nodeElement.querySelector( '[data-symbol-envelope="true"]', ) ?? nodeElement; const targetBounds = selectionTarget.getBoundingClientRect(); if ( targetBounds.width > 0 && targetBounds.height > 0 && screenRectanglesOverlap(selectionBounds, targetBounds) ) { selectedNodeIds.add(internalNode.id); } } } tightSelectionNodeIdsRef.current = selectedNodeIds; // React Flow 内部状态和受控 nodes 的更新时序不同;两侧任一不一致都 // 强制写回,避免选中框、工具栏及接口编号各自看到不同的选择集合。 const nodesNeedCorrection = nodesRef.current.some( (node) => Boolean(node.selected) !== selectedNodeIds.has(node.id), ) || Array.from(flowState.nodeLookup.values()).some( (node) => Boolean(node.selected) !== selectedNodeIds.has(node.id), ); if (nodesNeedCorrection) { setNodes((currentNodes) => { let changed = false; const nextNodes = currentNodes.map((node) => { const selected = selectedNodeIds.has(node.id); const internalSelected = Boolean( flowState.nodeLookup.get(node.id)?.selected, ); if ( Boolean(node.selected) === selected && internalSelected === selected ) { return node; } changed = true; return { ...node, selected }; }); const resolvedNodes = changed ? nextNodes : currentNodes; nodesRef.current = resolvedNodes; return resolvedNodes; }); } const internallySelectedEdgeIds = new Set( Array.from(flowState.edgeLookup.values()) .filter((edge) => Boolean(edge.selected)) .map((edge) => edge.id), ); const edgesNeedCorrection = internallySelectedEdgeIds.size > 0 || edgesRef.current.some((edge) => Boolean(edge.selected)); if (edgesNeedCorrection) { setEdges((currentEdges) => { let changed = false; const nextEdges = currentEdges.map((edge) => { // 批量框选只选择组件,不额外高亮其连接线。 if (!edge.selected && !internallySelectedEdgeIds.has(edge.id)) { return edge; } changed = true; return { ...edge, selected: false }; }); const resolvedEdges = changed ? nextEdges : currentEdges; edgesRef.current = resolvedEdges; return resolvedEdges; }); } setSelectedNodeId( selectedNodeIds.size === 1 ? (selectedNodeIds.values().next().value ?? null) : null, ); setSelectedEdgeId(null); }, [reactFlowStore, setEdges, setNodes], ); const flushTightSelection = useCallback(() => { if (tightSelectionFrameRef.current !== null) { window.cancelAnimationFrame(tightSelectionFrameRef.current); tightSelectionFrameRef.current = null; } const selectionRect = tightSelectionRectRef.current; if (selectionRect) { applyTightSelectionRect(selectionRect); } }, [applyTightSelectionRect]); const finishTightSelection = useCallback(() => { flushTightSelection(); const hasSelectedNodes = tightSelectionNodeIdsRef.current.size > 0; tightSelectionPointerIdRef.current = null; tightSelectionPointerTargetRef.current = null; // React Flow 会在 onSelectionEnd 返回后再次写入原始节点框结果,因此延后一拍校正内部状态。 window.queueMicrotask(() => { const completedRect = tightSelectionRectRef.current; if (completedRect) { applyTightSelectionRect(completedRect); } const flowState = reactFlowStore.getState(); if ( !flowState.userSelectionActive && flowState.nodesSelectionActive !== hasSelectedNodes ) { reactFlowStore.setState({ nodesSelectionActive: hasSelectedNodes }); } }); }, [applyTightSelectionRect, flushTightSelection, reactFlowStore]); const cancelTightSelection = useCallback(() => { const flowState = reactFlowStore.getState(); if (!flowState.userSelectionActive && !flowState.userSelectionRect) { return; } flushTightSelection(); const hasSelectedNodes = tightSelectionRectRef.current ? tightSelectionNodeIdsRef.current.size > 0 : nodesRef.current.some((node) => Boolean(node.selected)); reactFlowStore.setState({ userSelectionActive: false, userSelectionRect: null, nodesSelectionActive: hasSelectedNodes, }); tightSelectionPointerIdRef.current = null; tightSelectionPointerTargetRef.current = null; }, [flushTightSelection, reactFlowStore]); const handleTightSelectionPointerCancel = useCallback(() => { const flowState = reactFlowStore.getState(); if (flowState.userSelectionActive) { // React Flow 12.11 的 pointercancel 不会重置其私有 selectionInProgress; // 在状态仍处于框选模式时补发一次被内部 capture 吞掉的 click,只用于清理该标记。 flowState.domNode ?.querySelector(".react-flow__pane") ?.click(); } cancelTightSelection(); }, [cancelTightSelection, reactFlowStore]); const rememberFlowPointer = useCallback( (event: ReactPointerEvent) => { if (event.button === 0 && event.isPrimary) { tightSelectionPointerIdRef.current = event.pointerId; tightSelectionPointerTargetRef.current = event.target instanceof Element ? event.target : null; } }, [], ); const cancelTightSelectionOnWindowBlur = useCallback(() => { const flowState = reactFlowStore.getState(); if (!flowState.userSelectionActive && !flowState.userSelectionRect) { return; } const pointerId = tightSelectionPointerIdRef.current; const pointerTarget = tightSelectionPointerTargetRef.current; if ( pointerTarget && pointerId !== null && pointerTarget.hasPointerCapture(pointerId) ) { try { // React Flow 会在实际 pointer target 上建立捕获;向同一元素发取消事件, // 事件冒泡到 pane 后可停止其私有自动平移循环。 pointerTarget.dispatchEvent( new PointerEvent("pointercancel", { bubbles: true, isPrimary: true, pointerId, pointerType: "mouse", }), ); } finally { const currentState = reactFlowStore.getState(); if (currentState.userSelectionActive || currentState.userSelectionRect) { cancelTightSelection(); } } return; } cancelTightSelection(); }, [cancelTightSelection, reactFlowStore]); useEffect(() => { const unsubscribe = reactFlowStore.subscribe((flowState, previousState) => { const selectionRect = flowState.userSelectionRect; if (selectionRect === previousState.userSelectionRect) { return; } if ( selectionRect && !flowState.userSelectionActive && selectionRect.width === 0 && selectionRect.height === 0 ) { if (tightSelectionFrameRef.current !== null) { window.cancelAnimationFrame(tightSelectionFrameRef.current); tightSelectionFrameRef.current = null; } tightSelectionRectRef.current = null; tightSelectionNodeIdsRef.current = new Set( nodesRef.current .filter((node) => Boolean(node.selected)) .map((node) => node.id), ); return; } if ( !selectionRect || !flowState.userSelectionActive ) { return; } tightSelectionRectRef.current = { x: selectionRect.x, y: selectionRect.y, width: selectionRect.width, height: selectionRect.height, }; if (tightSelectionFrameRef.current === null) { tightSelectionFrameRef.current = window.requestAnimationFrame(() => { tightSelectionFrameRef.current = null; const pendingRect = tightSelectionRectRef.current; if (pendingRect) { applyTightSelectionRect(pendingRect); } }); } }); return () => { unsubscribe(); if (tightSelectionFrameRef.current !== null) { window.cancelAnimationFrame(tightSelectionFrameRef.current); tightSelectionFrameRef.current = null; } }; }, [applyTightSelectionRect, reactFlowStore]); useEffect(() => { window.addEventListener("blur", cancelTightSelectionOnWindowBlur); return () => window.removeEventListener("blur", cancelTightSelectionOnWindowBlur); }, [cancelTightSelectionOnWindowBlur]); const dismissPaletteIconPreview = useCallback(() => { if (paletteIconPreviewTimerRef.current !== null) { window.clearTimeout(paletteIconPreviewTimerRef.current); paletteIconPreviewTimerRef.current = null; } setPaletteIconPreview(null); }, []); const schedulePaletteIconPreview = useCallback( (target: HTMLElement, definition: ComponentDefinition) => { dismissPaletteIconPreview(); if (paletteDisplayMode !== "icons") { return; } const bounds = target.getBoundingClientRect(); const estimatedPreviewWidth = 196; const estimatedPreviewHeight = 112; const left = Math.max( 8, Math.min(bounds.right + 12, window.innerWidth - estimatedPreviewWidth - 8), ); const top = Math.max( 8, Math.min(bounds.top, window.innerHeight - estimatedPreviewHeight - 8), ); paletteIconPreviewTimerRef.current = window.setTimeout(() => { paletteIconPreviewTimerRef.current = null; setPaletteIconPreview({ definition, left, top }); }, PALETTE_ICON_PREVIEW_DELAY_MS); }, [dismissPaletteIconPreview, paletteDisplayMode], ); const togglePaletteSection = useCallback( (key: string) => { dismissPaletteIconPreview(); setCollapsedPaletteSections((current) => { const next = new Set(current); if (next.has(key)) { next.delete(key); } else { next.add(key); } return next; }); }, [dismissPaletteIconPreview], ); useEffect(() => { const controller = new AbortController(); const loadComponentCatalog = async () => { try { const response = await fetch("/api/components/catalog", { cache: "no-store", signal: controller.signal, }); if (!response.ok) { throw new Error(`${response.status} ${response.statusText}`.trim()); } const libraries = normalizeComponentCatalog(await response.json()); if (controller.signal.aborted) { return; } setComponentLibraries(libraries); setComponentCatalogStatus("ready"); setComponentCatalogMessage( `已从后端加载 ${libraries.reduce( (count, library) => count + library.components.length, 0, )} 个组件`, ); } catch (error) { if (controller.signal.aborted) { return; } setComponentLibraries(fallbackComponentLibraries); setComponentCatalogStatus("fallback"); setComponentCatalogMessage(`后端组件目录不可用:${formatError(error)}`); } }; void loadComponentCatalog(); return () => controller.abort(); }, []); 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]); useEffect(() => { try { localStorage.setItem(PALETTE_DISPLAY_MODE_KEY, paletteDisplayMode); } catch { // The display switch remains available when browser storage is unavailable. } }, [paletteDisplayMode]); useEffect(() => { try { localStorage.setItem( PALETTE_COLLAPSED_SECTIONS_KEY, JSON.stringify([...collapsedPaletteSections]), ); } catch { // Section toggles remain available when browser storage is unavailable. } }, [collapsedPaletteSections]); useEffect(() => { saveParameterColumnLayout(parameterColumnLayout); }, [parameterColumnLayout]); useEffect(() => { if (paletteDisplayMode !== "icons") { dismissPaletteIconPreview(); } }, [dismissPaletteIconPreview, paletteDisplayMode]); useEffect(() => { const dismissOnViewportChange = () => dismissPaletteIconPreview(); window.addEventListener("scroll", dismissOnViewportChange, true); window.addEventListener("resize", dismissOnViewportChange); return () => { window.removeEventListener("scroll", dismissOnViewportChange, true); window.removeEventListener("resize", dismissOnViewportChange); if (paletteIconPreviewTimerRef.current !== null) { window.clearTimeout(paletteIconPreviewTimerRef.current); paletteIconPreviewTimerRef.current = null; } }; }, [dismissPaletteIconPreview]); useLayoutEffect(() => { if (activeView !== "modeling") { return; } const workbench = workbenchRef.current; if (!workbench || typeof ResizeObserver === "undefined") { return; } const clampToWorkbench = (width: number, height: number) => { if (width <= 0 || height <= 0) { return; } setModelingWorkbenchHeight((current) => current === height ? current : height, ); setModelingPaneLayout((current) => { const next = clampModelingPaneLayout(current, width, height); return next.paletteWidth === current.paletteWidth && next.propertiesWidth === current.propertiesWidth && next.componentParametersHeight === current.componentParametersHeight ? current : next; }); }; clampToWorkbench(workbench.clientWidth, workbench.clientHeight); const observer = new ResizeObserver(([entry]) => { if (entry) { clampToWorkbench(entry.contentRect.width, entry.contentRect.height); } }); observer.observe(workbench); return () => observer.disconnect(); }, [activeView]); const setModelingPaneSize = ( kind: ModelingPaneResizeKind, requestedSize: number, ) => { const workbenchBounds = workbenchRef.current?.getBoundingClientRect(); if (!workbenchBounds) { return; } setModelingPaneLayout((current) => { if (kind === "component-parameters-height") { const maximum = Math.max( MIN_COMPONENT_PARAMETERS_HEIGHT, workbenchBounds.height - PROPERTIES_PANE_SPLITTER_SIZE - MIN_SIMULATION_SETTINGS_HEIGHT, ); return { ...current, componentParametersHeight: Math.min( maximum, Math.max(MIN_COMPONENT_PARAMETERS_HEIGHT, requestedSize), ), }; } if (kind === "palette-width") { const maximum = Math.max( MIN_PALETTE_WIDTH, Math.min( MAX_PALETTE_WIDTH, workbenchBounds.width - current.propertiesWidth - MIN_MODELING_CANVAS_WIDTH - MODELING_PANE_SPLITTER_SIZE * 2, ), ); return { ...current, paletteWidth: Math.min( maximum, Math.max(MIN_PALETTE_WIDTH, requestedSize), ), }; } const maximum = Math.max( MIN_PROPERTIES_WIDTH, Math.min( MAX_PROPERTIES_WIDTH, workbenchBounds.width - current.paletteWidth - MIN_MODELING_CANVAS_WIDTH - MODELING_PANE_SPLITTER_SIZE * 2, ), ); return { ...current, propertiesWidth: Math.min( maximum, Math.max(MIN_PROPERTIES_WIDTH, requestedSize), ), }; }); }; const startModelingPaneResize = ( event: ReactPointerEvent, kind: ModelingPaneResizeKind, ) => { event.preventDefault(); modelingPaneResizeRef.current = { kind, pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, startSize: kind === "palette-width" ? modelingPaneLayout.paletteWidth : kind === "properties-width" ? modelingPaneLayout.propertiesWidth : effectiveComponentParametersHeight, }; 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 = resize.kind === "component-parameters-height" ? event.clientY - resize.startY : event.clientX - resize.startX; setModelingPaneSize( resize.kind, resize.startSize + (resize.kind === "properties-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, ) => { const decreaseKey = kind === "component-parameters-height" ? "ArrowUp" : "ArrowLeft"; const increaseKey = kind === "component-parameters-height" ? "ArrowDown" : "ArrowRight"; if (event.key !== decreaseKey && event.key !== increaseKey) { return; } event.preventDefault(); const direction = event.key === increaseKey ? 1 : -1; const currentSize = kind === "palette-width" ? modelingPaneLayout.paletteWidth : kind === "properties-width" ? modelingPaneLayout.propertiesWidth : effectiveComponentParametersHeight; setModelingPaneSize( kind, currentSize + MODELING_PANE_KEYBOARD_STEP * (kind === "properties-width" ? -direction : direction), ); }; const resetModelingPaneSize = (kind: ModelingPaneResizeKind) => { const defaults = defaultModelingPaneLayout(); setModelingPaneSize( kind, kind === "palette-width" ? defaults.paletteWidth : kind === "properties-width" ? defaults.propertiesWidth : defaults.componentParametersHeight, ); }; const changeConsoleMode = useCallback((mode: SimulationConsoleMode) => { setConsoleMode(mode); }, []); const appendConsoleEntry = useCallback( ( level: SimulationConsoleLevel, message: string, format: SimulationConsoleEntry["format"] = "message", ) => { const entry: SimulationConsoleEntry = { id: ++consoleEntryIdRef.current, timestamp: new Date().toLocaleTimeString("zh-CN", { hour12: false }), level, message, format, }; setConsoleEntries((currentEntries) => [ ...currentEntries.slice(-399), entry, ]); }, [], ); const requestModelingFitView = useCallback(() => { setModelingFitRequest((request) => request + 1); }, []); const changeWorkspaceView = useCallback( (view: WorkspaceView) => { if (view !== "modeling") { cancelPortConnection(); } setActiveView(view); if (view === "results") { setHasUnreadResults(false); } const nextHash = view === "results" ? "#/results" : "#/modeling"; if (window.location.hash !== nextHash) { window.history.pushState({ workspaceView: view }, "", nextHash); } }, [cancelPortConnection], ); const loadSimulationResultsSnapshot = useCallback( (snapshot: SimulationResultsSnapshot) => { const migratedSnapshot = migrateSimulationResultsSnapshot(snapshot); setResultSnapshot(migratedSnapshot); storeResultSnapshot(migratedSnapshot); appendConsoleEntry( "success", `已加载结果文件:${migratedSnapshot.project.name}`, ); changeWorkspaceView("results"); }, [appendConsoleEntry, 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 amesimGasMediumOptions = useMemo( () => buildAmesimGasMediumOptions(nodes, componentDefinitions), [componentDefinitions, nodes], ); const buildCurrentProject = useCallback( () => buildProjectPayload(projectName, nodes, edges, simulationConfig), [edges, nodes, projectName, simulationConfig], ); const validationSignature = useMemo( () => JSON.stringify(buildCurrentProject()), [buildCurrentProject], ); const validationCurrent = validatedSignature === 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 connectedPortKeys = useMemo(() => { const keys = new Set(); edges.forEach((edge) => { if (edge.sourceHandle) { keys.add(portConnectionKey(edge.source, edge.sourceHandle)); } if (edge.targetHandle) { keys.add(portConnectionKey(edge.target, edge.targetHandle)); } }); return keys; }, [edges]); const contactSnapOriginPortKeys = useMemo( () => new Set( contactSnapCandidate?.matches.map((match) => portConnectionKey( contactSnapCandidate.draggedNodeId, match.draggedPortName, ), ) ?? [], ), [contactSnapCandidate], ); const contactSnapTargetPortKeys = useMemo( () => new Set( contactSnapCandidate?.matches.map((match) => portConnectionKey(match.targetNodeId, match.targetPortName), ) ?? [], ), [contactSnapCandidate], ); const activeConnectionPort = useMemo( () => activePortConnection ? findNodePort( nodes, activePortConnection.nodeId, activePortConnection.portName, ) : undefined, [activePortConnection, nodes], ); const renderedNodes = useMemo( () => nodes.map((node) => { const hasDedicatedSymbol = hasComponentSymbol( node.data.symbol ?? node.data.componentType, ); const portConnectionStates = Object.fromEntries( node.data.ports.map((port) => { const key = portConnectionKey(node.id, port.name); let state: PortConnectionState = "idle"; if (connectedPortKeys.has(key)) { state = "connected"; } else if (contactSnapOriginPortKeys.has(key)) { state = "origin"; } else if (contactSnapTargetPortKeys.has(key)) { state = "compatible"; } else if ( activePortConnection?.nodeId === node.id && activePortConnection.portName === port.name ) { state = "origin"; } else if (activePortConnection && activeConnectionPort) { state = activePortConnection.nodeId !== node.id && arePortsCompatible(activeConnectionPort, port) ? "compatible" : "incompatible"; } return [port.name, state]; }), ); const className = [ node.className, hasDedicatedSymbol ? "component-icon-node" : "", validationSeverityByNode.has(node.id) ? `validation-${validationSeverityByNode.get(node.id)}` : "", ] .filter(Boolean) .join(" "); return { ...node, className: className || undefined, data: { ...node.data, hideConnectedPortIndexLabels: multipleNodesSelected, portConnectionStates, }, }; }), [ activeConnectionPort, activePortConnection, contactSnapOriginPortKeys, contactSnapTargetPortKeys, connectedPortKeys, multipleNodesSelected, nodes, validationSeverityByNode, ], ); const renderedEdges = useMemo( () => edges.map((edge) => { const selected = edge.selected || edge.id === selectedEdgeId; const contact = isContactEdgeGeometry(nodes, edge); const className = [ edge.className, contact ? "editor-edge-contact" : "", selected ? "editor-edge-selected" : "", ] .filter(Boolean) .join(" "); return { ...edge, className: className || undefined, type: CONTACT_AWARE_EDGE_TYPE, zIndex: contact ? 2000 : edge.zIndex, }; }), [edges, nodes, 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) => { cancelPortConnection(); 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); syncNextNodeNumbers(restoredNodes); }, [cancelPortConnection, setEdges, setNodes], ); const undo = useCallback(() => { const previous = historyRef.current.pop(); if (!previous) { return; } futureRef.current.push(captureSnapshot()); restoreSnapshot(previous); updateHistoryAvailability(); appendConsoleEntry("info", "已撤销上一步操作"); }, [appendConsoleEntry, captureSnapshot, restoreSnapshot, updateHistoryAvailability]); const redo = useCallback(() => { const next = futureRef.current.pop(); if (!next) { return; } historyRef.current.push(captureSnapshot()); restoreSnapshot(next); updateHistoryAvailability(); appendConsoleEntry("info", "已重做上一步操作"); }, [appendConsoleEntry, 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) { appendConsoleEntry("warning", "请先选择需要旋转的组件"); return; } recordHistory(); const previousNodes = nodesRef.current; const rotatedNodes = previousNodes.map((node) => selectedIds.has(node.id) ? rotateNodeClockwise(node) : node, ); const { remainingEdges, removedEdges } = disconnectInvalidatedContactEdges( previousNodes, rotatedNodes, edgesRef.current, selectedIds, ); nodesRef.current = rotatedNodes; edgesRef.current = remainingEdges; setNodes(rotatedNodes); if (removedEdges.length > 0) { setEdges(remainingEdges); appendConsoleEntry( "info", `旋转使接口分离,已断开 ${removedEdges.length} 个接触连接`, ); } appendConsoleEntry("info", `已顺时针旋转 ${selectedIds.size} 个组件`); }, [appendConsoleEntry, recordHistory, selectedNodeId, setEdges, setNodes]); const mirrorSelection = useCallback(() => { const selectedNodes = nodesRef.current.filter( (node) => node.selected || node.id === selectedNodeId, ); if (selectedNodes.length === 0) { appendConsoleEntry("warning", "请先选择需要镜像的组件"); return; } const selectedIds = new Set(selectedNodes.map((node) => node.id)); const nextMirrored = !selectedNodes.every((node) => Boolean(node.data.mirrored)); recordHistory(); const previousNodes = nodesRef.current; const mirroredNodes = previousNodes.map((node) => selectedIds.has(node.id) ? { ...node, data: { ...node.data, mirrored: nextMirrored, }, } : node, ); const { remainingEdges, removedEdges } = disconnectInvalidatedContactEdges( previousNodes, mirroredNodes, edgesRef.current, selectedIds, ); nodesRef.current = mirroredNodes; edgesRef.current = remainingEdges; setNodes(mirroredNodes); if (removedEdges.length > 0) { setEdges(remainingEdges); appendConsoleEntry( "info", `镜像使接口分离,已断开 ${removedEdges.length} 个接触连接`, ); } appendConsoleEntry( "info", `${nextMirrored ? "已镜像" : "已取消镜像"} ${selectedIds.size} 个组件`, ); }, [appendConsoleEntry, recordHistory, selectedNodeId, setEdges, setNodes]); const handleNodesChange = useCallback( (changes: NodeChange[]) => { const removesNodes = changes.some((change) => change.type === "remove"); if (removesNodes) { cancelPortConnection(); recordHistory(); } const preview = repairAmesimGasReferences( applyNodeChanges(changes, nodesRef.current), componentDefinitions, ); setNodes((currentNodes) => { const changedNodes = applyNodeChanges(changes, currentNodes); return removesNodes ? repairAmesimGasReferences(changedNodes, componentDefinitions).nodes : changedNodes; }); if (removesNodes && preview.repairedReferenceCount > 0) { appendConsoleEntry( "warning", `介质定义已删除,${preview.repairedReferenceCount} 个介质物性引用已切回 0 — 空气(理想气体,内置默认)`, ); } }, [ appendConsoleEntry, cancelPortConnection, componentDefinitions, recordHistory, setNodes, ], ); const handleEdgesChange = useCallback( (changes: EdgeChange[]) => { const removedEdgeIds = new Set( changes .filter((change) => change.type === "remove") .map((change) => change.id), ); if (removedEdgeIds.size > 0) { recordHistory(); const selectedNodeIds = new Set( nodesRef.current .filter((node) => node.selected || node.id === selectedNodeId) .map((node) => node.id), ); const deletedContactEdges = edgesRef.current.filter( (edge) => removedEdgeIds.has(edge.id) && !selectedNodeIds.has(edge.source) && !selectedNodeIds.has(edge.target), ); const separatedNodes = separateDeletedContactEdges( nodesRef.current, deletedContactEdges, ); if (separatedNodes !== nodesRef.current) { nodesRef.current = separatedNodes; setNodes(separatedNodes); } } onEdgesChange(changes); }, [onEdgesChange, recordHistory, selectedNodeId, setNodes], ); const handleNodeDragStart = useCallback>( (_event, draggedNode, draggedNodes) => { recordHistory(); clearContactSnapCandidate(); const effectiveNodes = nodesRef.current.map((node) => node.id === draggedNode.id ? { ...node, position: { ...draggedNode.position } } : node, ); const multipleNodes = draggedNodes.length > 1; const contactEdgeIds = new Set( multipleNodes ? [] : edgesRef.current .filter( (edge) => (edge.source === draggedNode.id || edge.target === draggedNode.id) && isContactEdgeGeometry(effectiveNodes, edge), ) .map((edge) => edge.id), ); contactDragSessionRef.current = { contactEdgeIds, disconnected: false, effectiveEdges: edgesRef.current, multipleNodes, nodeId: draggedNode.id, startPosition: { ...draggedNode.position }, }; }, [clearContactSnapCandidate, recordHistory], ); const handleNodeDrag = useCallback>( (_event, draggedNode) => { const session = contactDragSessionRef.current; if (!session || session.nodeId !== draggedNode.id) { return; } const screenDistance = Math.hypot( draggedNode.position.x - session.startPosition.x, draggedNode.position.y - session.startPosition.y, ) * reactFlow.getZoom(); if ( !session.multipleNodes && !session.disconnected && session.contactEdgeIds.size > 0 && screenDistance >= CONTACT_DISCONNECT_DISTANCE_PX ) { const remainingEdges = session.effectiveEdges.filter( (edge) => !session.contactEdgeIds.has(edge.id), ); session.disconnected = true; session.effectiveEdges = remainingEdges; edgesRef.current = remainingEdges; setEdges(remainingEdges); setSelectedEdgeId((current) => current && session.contactEdgeIds.has(current) ? null : current, ); appendConsoleEntry( "info", `已拖开并断开 ${session.contactEdgeIds.size} 个接触连接`, ); } if (session.multipleNodes) { clearContactSnapCandidate(); return; } const effectiveDraggedNode = { ...(nodesRef.current.find((node) => node.id === draggedNode.id) ?? draggedNode), position: { ...draggedNode.position }, }; const effectiveNodes = nodesRef.current.map((node) => node.id === draggedNode.id ? effectiveDraggedNode : node, ); const candidate = findContactSnapCandidate( effectiveDraggedNode, effectiveNodes, session.effectiveEdges, CONTACT_SNAP_RADIUS_PX / Math.max(reactFlow.getZoom(), 0.01), ); setContactSnapCandidate((current) => sameContactSnapCandidate(current, candidate) ? current : candidate, ); }, [appendConsoleEntry, clearContactSnapCandidate, reactFlow, setEdges], ); const handleNodeDragStop = useCallback>( (_event, draggedNode) => { const session = contactDragSessionRef.current; if (!session || session.nodeId !== draggedNode.id) { clearContactSnapCandidate(); return; } const effectiveDraggedNode = { ...(nodesRef.current.find((node) => node.id === draggedNode.id) ?? draggedNode), position: { ...draggedNode.position }, }; const effectiveNodes = nodesRef.current.map((node) => node.id === draggedNode.id ? effectiveDraggedNode : node, ); const candidate = session.multipleNodes ? null : findContactSnapCandidate( effectiveDraggedNode, effectiveNodes, session.effectiveEdges, CONTACT_SNAP_RADIUS_PX / Math.max(reactFlow.getZoom(), 0.01), ); contactDragSessionRef.current = null; clearContactSnapCandidate(); if (!candidate) { return; } const snappedNodes = effectiveNodes.map((node) => node.id === candidate.draggedNodeId ? { ...node, position: { ...candidate.snappedPosition }, } : node, ); const createdEdges: SimulationEdge[] = []; const timestamp = Date.now(); let connectedEdges = session.effectiveEdges; candidate.matches.forEach((match, index) => { const contactEdge: SimulationEdge = { ...match.connection, id: `edge-contact-${match.connection.source}-${match.connection.sourceHandle}-${match.connection.target}-${match.connection.targetHandle}-${timestamp}-${index}`, type: CONTACT_AWARE_EDGE_TYPE, }; if ( !canConnectPorts(snappedNodes, connectedEdges, contactEdge) || !isContactEdgeGeometry(snappedNodes, contactEdge) ) { return; } connectedEdges = addEdge(contactEdge, connectedEdges); createdEdges.push(contactEdge); }); if (createdEdges.length === 0) { return; } nodesRef.current = snappedNodes; edgesRef.current = connectedEdges; setNodes(snappedNodes); setEdges(connectedEdges); setSelectedEdgeId(null); appendConsoleEntry( "info", createdEdges.length === 1 ? `接口已吸附连接 ${createdEdges[0].source}.${createdEdges[0].sourceHandle ?? "port"} 与 ${createdEdges[0].target}.${createdEdges[0].targetHandle ?? "port"}` : `已吸附连接 ${createdEdges.length} 组接口`, ); }, [appendConsoleEntry, clearContactSnapCandidate, reactFlow, setEdges, setNodes], ); const beginPortConnection = useCallback( ( nodeId: string | null, portName: string | null, mode: ActivePortConnection["mode"], ) => { if (!nodeId || !portName || !findNodePort(nodesRef.current, nodeId, portName)) { return; } suppressCancelledConnectionRef.current = false; setActivePortConnection((current) => { if (mode === "drag" && current?.mode === "click") { return current; } return { nodeId, portName, mode }; }); }, [], ); const endDragPortConnection = useCallback(() => { suppressCancelledConnectionRef.current = false; setActivePortConnection((current) => current?.mode === "click" ? current : null, ); }, []); const endClickPortConnection = useCallback(() => { suppressCancelledConnectionRef.current = false; setActivePortConnection(null); }, []); const onConnect = useCallback( (connection: Connection) => { if (suppressCancelledConnectionRef.current) { setActivePortConnection(null); return; } if (!canConnectPorts(nodesRef.current, edgesRef.current, connection)) { setActivePortConnection(null); return; } recordHistory(); setEdges((currentEdges) => addEdge( { ...connection, id: `edge-${connection.source}-${connection.sourceHandle}-${connection.target}-${connection.targetHandle}-${Date.now()}`, type: CONTACT_AWARE_EDGE_TYPE, }, currentEdges, ), ); appendConsoleEntry( "info", `已连接 ${connection.source}.${connection.sourceHandle ?? "port"} 与 ${connection.target}.${connection.targetHandle ?? "port"}`, ); suppressCancelledConnectionRef.current = false; setActivePortConnection(null); }, [appendConsoleEntry, recordHistory, setEdges], ); const isValidConnection = useCallback( (connection: Connection | SimulationEdge) => canConnectPorts(nodes, edges, connection), [edges, nodes], ); const onDragStart = (event: DragEvent, type: string) => { dismissPaletteIconPreview(); event.dataTransfer.setData("application/system-component", type); event.dataTransfer.effectAllowed = "move"; const definition = componentDefinitions.find((item) => item.type === type); const paletteSymbol = event.currentTarget.querySelector( ".palette-icon > .component-symbol", ); if (definition && paletteSymbol) { const layout = componentSymbolLayout(definition.symbol); const viewportZoom = Math.max(reactFlow.getZoom(), 0.01); const canvasViewBox = `${layout.viewBox.x} ${layout.viewBox.y} ${layout.viewBox.width} ${layout.viewBox.height}`; const preview = document.createElement("div"); const previewSymbol = paletteSymbol.cloneNode(true) as HTMLElement; // 模型库图标有独立的缩略图占比;待拖入态切回工作区 viewBox 和当前缩放,避免落下时跳变。 preview.className = "component-drag-preview"; preview.setAttribute("aria-hidden", "true"); preview.dataset.componentType = type; preview.style.width = `${layout.nodeSize.width * viewportZoom}px`; preview.style.height = `${layout.nodeSize.height * viewportZoom}px`; previewSymbol.classList.remove("component-symbol-palette"); previewSymbol.classList.add("component-symbol-canvas"); previewSymbol.removeAttribute("data-palette-occupancy"); previewSymbol.dataset.symbolViewBox = canvasViewBox; previewSymbol.style.width = `${layout.symbolSize.width}px`; previewSymbol.style.height = `${layout.symbolSize.height}px`; previewSymbol.style.transform = `scale(${viewportZoom})`; previewSymbol.style.transformOrigin = "center"; previewSymbol .querySelector(".component-symbol-svg") ?.setAttribute("viewBox", canvasViewBox); preview.append(previewSymbol); 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 dropPoint = reactFlow.screenToFlowPosition({ x: event.clientX, y: event.clientY, }); const mediumIndex = isAmesimGasMediumDefinition(definition) ? nextAvailableAmesimGasIndex( definedAmesimGasIndices(nodesRef.current, componentDefinitions), ) : null; if (isAmesimGasMediumDefinition(definition) && mediumIndex === null) { appendConsoleEntry( "error", "无法添加介质定义:可用介质定义索引 1..99 已全部占用", ); return; } const id = takeNextNodeId(definition.type); const data = { ...buildNodeData(definition), label: id }; if (mediumIndex !== null) { data.parameters = { ...data.parameters, gi: mediumIndex, }; } const layout = componentSymbolLayout(data.symbol ?? data.componentType); const initialSize = nodeFrameDimensions( layout, normalizeNodeRotation(data.rotation), ); const centeredPosition = { x: dropPoint.x - initialSize.width / 2, y: dropPoint.y - initialSize.height / 2, }; const position = snapEnabled ? { x: Math.round(centeredPosition.x / GRID_SIZE) * GRID_SIZE, y: Math.round(centeredPosition.y / GRID_SIZE) * GRID_SIZE, } : centeredPosition; const node: SimulationNode = { id, type: "simulationComponent", position, selected: true, data, }; recordHistory(); setNodes((currentNodes) => [ ...currentNodes.map((currentNode) => ({ ...currentNode, selected: false, })), node, ]); setEdges((currentEdges) => currentEdges.map((edge) => ({ ...edge, selected: false })), ); setSelectedNodeId(id); setSelectedEdgeId(null); appendConsoleEntry("info", `已添加组件:${id}`); }, [ appendConsoleEntry, componentDefinitions, reactFlow, recordHistory, setEdges, setNodes, snapEnabled, ], ); 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 normalizedDisplayValue = displayValue.trim(); const numericValue = Number(normalizedDisplayValue); const hasFiniteNumericValue = normalizedDisplayValue !== "" && Number.isFinite(numericValue); const convertedBaseValue = hasFiniteNumericValue ? unit.toBase(numericValue) : numericValue; const baseValue = hasFiniteNumericValue ? convertedBaseValue : normalizedDisplayValue; const scientificNotation = hasFiniteNumericValue && Number.isFinite(convertedBaseValue) && isScientificNotationValue(normalizedDisplayValue) ? { text: normalizedDisplayValue, unit: selectedUnit } : null; updateSelectedNode((node) => { const parameterScientificNotation = { ...node.data.parameterScientificNotation, }; if (scientificNotation) { parameterScientificNotation[key] = scientificNotation; } else { delete parameterScientificNotation[key]; } return { ...node, data: { ...node.data, parameters: { ...node.data.parameters, [key]: baseValue, }, parameterScientificNotation, }, }; }); }; const updateSelectedChoice = (key: string, value: number) => { updateSelectedNode((node) => { const parameterScientificNotation = { ...node.data.parameterScientificNotation, }; delete parameterScientificNotation[key]; return { ...node, data: { ...node.data, parameters: { ...node.data.parameters, [key]: value, }, parameterScientificNotation, }, }; }); }; 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) { appendConsoleEntry("warning", "请先选择需要复制的组件"); 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; appendConsoleEntry("info", `已复制 ${selectedNodes.length} 个组件`); }, [appendConsoleEntry, selectedNodeId]); const pasteSelection = useCallback(() => { const clipboard = clipboardRef.current; if (!clipboard || clipboard.nodes.length === 0) { appendConsoleEntry("warning", "剪贴板中没有可粘贴的组件"); return; } const existingMediumIndices = definedAmesimGasIndices( nodesRef.current, componentDefinitions, ); const reservedMediumIndices = new Set(existingMediumIndices); const assignedIndexByCopiedNodeId = new Map(); const copiedMediumNodeIdsByOldIndex = new Map(); for (const node of clipboard.nodes) { const definition = componentDefinitionForNode(node, componentDefinitions); if (!isAmesimGasMediumDefinition(definition)) { continue; } const nextIndex = nextAvailableAmesimGasIndex(reservedMediumIndices); if (nextIndex === null) { appendConsoleEntry( "error", "无法粘贴介质定义:可用介质定义索引 1..99 不足", ); return; } reservedMediumIndices.add(nextIndex); assignedIndexByCopiedNodeId.set(node.id, nextIndex); const oldIndex = normalizeAmesimGasIndex( node.data.parameters.gi, AMESIM_MIN_DEFINED_GAS_INDEX, ); if (oldIndex !== null) { const nodeIds = copiedMediumNodeIdsByOldIndex.get(oldIndex) ?? []; nodeIds.push(node.id); copiedMediumNodeIdsByOldIndex.set(oldIndex, nodeIds); } } const copiedIndexRemap = new Map(); const ambiguousCopiedIndices = new Set(); copiedMediumNodeIdsByOldIndex.forEach((nodeIds, oldIndex) => { if (nodeIds.length !== 1) { ambiguousCopiedIndices.add(oldIndex); return; } const assignedIndex = assignedIndexByCopiedNodeId.get(nodeIds[0]); if (assignedIndex !== undefined) { copiedIndexRemap.set(oldIndex, assignedIndex); } }); recordHistory(); pasteCountRef.current += 1; const largestMinimumDimension = Math.max( ...clipboard.nodes.map((node) => { const layout = componentSymbolLayout( node.data.symbol ?? node.data.componentType, ); const size = nodeFrameDimensions( layout, normalizeNodeRotation(node.data.rotation), ); return Math.min(size.width, size.height); }), ); const offsetStep = Math.max( GRID_SIZE * 2, Math.ceil(largestMinimumDimension / 2 / GRID_SIZE) * GRID_SIZE, ); const offset = offsetStep * pasteCountRef.current; const idMap = new Map(); const usedNames = new Set(nodesRef.current.map((node) => node.data.label.trim())); let repairedPastedReferenceCount = 0; 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); const definition = componentDefinitionForNode(node, componentDefinitions); const parameters = { ...cloneValue(node.data.parameters) }; if (isAmesimGasMediumDefinition(definition)) { parameters.gi = assignedIndexByCopiedNodeId.get(node.id) as number; } else if (definition) { Object.entries(definition.parameters).forEach(([key, parameter]) => { if (parameter.editor !== "amesimGasReference") { return; } const oldIndex = normalizeAmesimGasIndex(parameters[key]); if (oldIndex === AMESIM_BUILTIN_GAS_INDEX) { parameters[key] = AMESIM_BUILTIN_GAS_INDEX; } else if (oldIndex !== null && copiedIndexRemap.has(oldIndex)) { parameters[key] = copiedIndexRemap.get(oldIndex) as number; } else if ( oldIndex !== null && !ambiguousCopiedIndices.has(oldIndex) && existingMediumIndices.has(oldIndex) ) { parameters[key] = oldIndex; } else { parameters[key] = AMESIM_BUILTIN_GAS_INDEX; repairedPastedReferenceCount += 1; } }); } return { ...cloneValue(node), id, position: { x: node.position.x + offset, y: node.position.y + offset, }, selected: true, data: { ...cloneValue(node.data), label, parameters, }, } 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); appendConsoleEntry("info", `已粘贴 ${pastedNodes.length} 个组件`); if (repairedPastedReferenceCount > 0) { appendConsoleEntry( "warning", `${repairedPastedReferenceCount} 个粘贴的介质物性引用没有对应介质定义,已切回 0 — 空气(理想气体,内置默认)`, ); } }, [ appendConsoleEntry, componentDefinitions, recordHistory, setEdges, setNodes, ]); const updateSimulationConfig = (key: keyof SimulationConfig, value: string) => { const normalizedValue = value.trim(); const numericValue = Number(normalizedValue); setSimulationConfig((current) => ({ ...current, [key]: key === "method" ? value : normalizedValue !== "" && Number.isFinite(numericValue) ? isScientificNotationValue(normalizedValue) ? normalizedValue : numericValue : normalizedValue, })); }; const generateXml = () => { try { const xml = buildSystemXml(buildCurrentProject(), componentDefinitions); changeConsoleMode("normal"); appendConsoleEntry( "success", `XML 已生成:${nodes.length} 个组件,${edges.length} 条连接`, ); appendConsoleEntry("info", xml, "xml"); } catch (error) { changeConsoleMode("normal"); appendConsoleEntry("error", `XML 生成失败:${formatError(error)}`); } }; const downloadXml = () => { try { const xml = buildSystemXml(buildCurrentProject(), componentDefinitions); downloadText(`${safeFilename(projectName)}.xml`, xml); appendConsoleEntry("success", `XML 已下载:${safeFilename(projectName)}.xml`); } catch (error) { appendConsoleEntry("error", `XML 下载失败:${formatError(error)}`); } }; const applyProject = useCallback( (project: ReactFlowProjectPayload, message: string, addToHistory = true) => { cancelPortConnection(); if (addToHistory) { recordHistory(); } const normalizedNodes = project.nodes.map((node) => normalizeLoadedNode( node, componentDefinitions, project.amesimParameterEncodingVersion, ), ); const indexedMedia = assignMissingAmesimGasDefinitionIndices( normalizedNodes, componentDefinitions, ); const legacyGasMigration = migrateLegacyDefaultAmesimGasReference( indexedMedia.nodes, componentDefinitions, project.mediumReferenceVersion, ); const edgeNormalization = normalizeLoadedEdges( legacyGasMigration.nodes, project.edges, ); const loadedEdges = edgeNormalization.edges; const presentationMigration = migrateLegacyPresentationLayout( legacyGasMigration.nodes, loadedEdges, project.presentationLayoutVersion, ); const loadedNodes = presentationMigration.nodes; setNodes(loadedNodes); setEdges(loadedEdges); setProjectName(project.name || "untitled"); setSimulationConfig({ ...defaultSimulationConfig, ...project.simulation }); setSelectedNodeId(null); setSelectedEdgeId(null); setValidationIssues([]); setValidatedSignature(""); syncNextNodeNumbers(loadedNodes); appendConsoleEntry("success", message); if (indexedMedia.assignedDefinitionCount > 0) { appendConsoleEntry( "info", `已为 ${indexedMedia.assignedDefinitionCount} 个介质定义补充分配介质定义索引`, ); } if (legacyGasMigration.repairedReferenceCount > 0) { appendConsoleEntry( "warning", `检测到无版本且没有介质定义的旧工程:已将 ${legacyGasMigration.repairedReferenceCount} 个历史默认介质物性引用 1 迁移到 0 — 空气(理想气体,内置默认)`, ); } if (edgeNormalization.repairedEdgeCount > 0) { appendConsoleEntry( "info", `已修复 ${edgeNormalization.repairedEdgeCount} 条旧连接的接口标识`, ); } if (edgeNormalization.droppedEdgeCount > 0) { appendConsoleEntry( "warning", `${edgeNormalization.droppedEdgeCount} 条连接无法唯一定位有效接口,已忽略`, ); } if (presentationMigration.migrated) { appendConsoleEntry( "info", `已将旧版固定尺寸布局迁移为逐元件布局,并保持 ${presentationMigration.preservedContactEdgeCount} 条接触连接`, ); } if (presentationMigration.conflictingContactConstraintCount > 0) { appendConsoleEntry( "warning", `${presentationMigration.conflictingContactConstraintCount} 个旧接触约束形成不一致闭环,已保留逻辑连接,请检查局部布局`, ); } if (indexedMedia.exhaustedDefinitionCount > 0) { appendConsoleEntry( "error", `${indexedMedia.exhaustedDefinitionCount} 个介质定义无法分配 1..99 索引,请删除多余定义`, ); } requestModelingFitView(); }, [ appendConsoleEntry, cancelPortConnection, componentDefinitions, recordHistory, requestModelingFitView, 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())}`); appendConsoleEntry( "success", `工程已保存到浏览器:${project.nodes.length} 个组件,${project.edges.length} 条连接`, ); } catch (error) { appendConsoleEntry("error", `保存失败:${formatError(error)}`); } }; const loadProject = () => { const name = projectName.trim() || "untitled"; try { const project = parseProjectPayload(localStorage.getItem(localProjectKey(name))); if (!project) { appendConsoleEntry("warning", `没有找到本地工程:${name}`); return; } applyProject( project, `工程已加载:${project.nodes.length} 个组件,${project.edges.length} 条连接`, ); } catch (error) { appendConsoleEntry("error", `加载失败:${formatError(error)}`); } }; const exportProject = () => { const project = buildCurrentProject(); downloadFile( `${safeFilename(project.name)}.json`, JSON.stringify(project, null, 2), "application/json;charset=utf-8", ); appendConsoleEntry("success", `工程 JSON 已导出:${safeFilename(project.name)}.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) { appendConsoleEntry("error", `导入失败:${formatError(error)}`); } }; const checkModel = useCallback(() => { changeConsoleMode("normal"); const issues = validateModel( nodesRef.current, edgesRef.current, simulationConfigRef.current, componentDefinitions, ); 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; const summary = errorCount === 0 ? warningCount === 0 ? "模型检查通过" : `模型检查通过,存在 ${warningCount} 条警告` : `模型检查发现 ${errorCount} 个错误、${warningCount} 条警告`; appendConsoleEntry( errorCount > 0 ? "error" : warningCount > 0 ? "warning" : "success", summary, ); issues.forEach((issue) => appendConsoleEntry(issue.severity === "error" ? "error" : "warning", issue.message), ); return issues; }, [appendConsoleEntry, changeConsoleMode, componentDefinitions]); const publishSimulationResult = ( result: SimulationResult, project: ExecutableProjectPayload, ) => { 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]; 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; } 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"); const simulationResolution = resolveSimulationConfig( simulationConfigRef.current, ); const progressConfig = simulationResolution.ok ? simulationResolution.value : defaultSimulationConfig; if (modelErrors.length > 0) { changeConsoleMode("normal"); setSimulationProgress({ state: "error", percent: 0, message: "模型检查未通过", startTime: progressConfig.t_start, simulatedTime: progressConfig.t_start, totalTime: progressConfig.t_stop, }); return; } if (!simulationResolution.ok) { changeConsoleMode("normal"); appendConsoleEntry("error", simulationResolution.message); return; } const project: ExecutableProjectPayload = { ...buildCurrentProject(), simulation: simulationResolution.value, }; changeConsoleMode("normal"); setSimulationProgress({ state: "running", percent: 0, message: "正在提交仿真任务", startTime: project.simulation.t_start, simulatedTime: project.simulation.t_start, totalTime: project.simulation.t_stop, }); appendConsoleEntry( "info", `开始仿真:${projectNameRef.current || "untitled"},时间范围 ${project.simulation.t_start} - ${project.simulation.t_stop} s`, ); await runBusyAction("正在编译模型并运行仿真...", async () => { const xml = buildSystemXml(project, componentDefinitions); const simulationId = createSimulationId(); activeSimulationIdRef.current = simulationId; setSimulationStopPending(false); 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)); } return; } finally { activeSimulationIdRef.current = null; setSimulationStopPending(false); } }); }; async function runBusyAction(message: string, action: () => Promise) { setBusy(true); appendConsoleEntry("info", message); try { await action(); } catch (error) { appendConsoleEntry("error", `操作失败:${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("尚未保存"); appendConsoleEntry("info", "已忽略上次的自动保存草稿"); }; const selectAllElements = useCallback(() => { setNodes((currentNodes) => currentNodes.map((node) => ({ ...node, selected: true }))); setEdges((currentEdges) => currentEdges.map((edge) => edge.selected ? { ...edge, selected: false } : edge, ), ); setSelectedNodeId(null); setSelectedEdgeId(null); appendConsoleEntry( "info", `已选择 ${nodesRef.current.length} 个组件`, ); }, [appendConsoleEntry, 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) { appendConsoleEntry("warning", "请先选择需要删除的组件或连接"); return; } cancelPortConnection(); recordHistory(); const remainingNodes = nodesRef.current.filter((node) => !nodeIds.has(node.id)); const repairedGasReferences = repairAmesimGasReferences( remainingNodes, componentDefinitions, ); const explicitlyDeletedEdges = edgesRef.current.filter( (edge) => edgeIds.has(edge.id) && !nodeIds.has(edge.source) && !nodeIds.has(edge.target), ); const separatedNodes = separateDeletedContactEdges( repairedGasReferences.nodes, explicitlyDeletedEdges, ); const remainingEdges = edgesRef.current.filter( (edge) => !edgeIds.has(edge.id) && !nodeIds.has(edge.source) && !nodeIds.has(edge.target), ); nodesRef.current = separatedNodes; edgesRef.current = remainingEdges; setNodes(separatedNodes); setEdges(remainingEdges); setSelectedNodeId(null); setSelectedEdgeId(null); appendConsoleEntry( "info", `已删除 ${nodeIds.size} 个组件和 ${edgeIds.size} 条连接`, ); if (repairedGasReferences.repairedReferenceCount > 0) { appendConsoleEntry( "warning", `介质定义已删除,${repairedGasReferences.repairedReferenceCount} 个介质物性引用已切回 0 — 空气(理想气体,内置默认)`, ); } }, [ appendConsoleEntry, cancelPortConnection, componentDefinitions, recordHistory, selectedEdgeId, selectedNodeId, setEdges, setNodes, ]); const toggleSnap = () => { const nextEnabled = !snapEnabled; setSnapEnabled(nextEnabled); appendConsoleEntry( "info", `网格吸附已${nextEnabled ? "开启" : "关闭"}`, ); }; const toggleGridLines = () => { const nextVisible = !gridVisibility.lines; setGridVisibility((current) => ({ ...current, lines: nextVisible })); appendConsoleEntry( "info", `主网格线已${nextVisible ? "显示" : "隐藏"}`, ); }; const toggleGridDots = () => { const nextVisible = !gridVisibility.dots; setGridVisibility((current) => ({ ...current, dots: nextVisible })); appendConsoleEntry( "info", `网格点已${nextVisible ? "显示" : "隐藏"}`, ); }; const fitModelingView = () => { if (nodesRef.current.length === 0) { appendConsoleEntry("warning", "画布中没有可适配的组件"); return; } void reactFlow.fitView({ duration: 180, minZoom: 0.1, padding: 0.18 }); appendConsoleEntry("info", "已适配建模画布"); }; 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(() => { const previousView = previousActiveViewRef.current; previousActiveViewRef.current = activeView; if (activeView !== "modeling") { cancelPortConnection(); return; } if (activeView === "modeling" && previousView !== "modeling") { requestModelingFitView(); } }, [activeView, cancelPortConnection, requestModelingFitView]); 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.key === "Escape" && activePortConnection) { event.preventDefault(); cancelPortConnection(); return; } 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); }, [ activePortConnection, activeView, cancelPortConnection, copySelection, mirrorSelection, pasteSelection, redo, rotateSelection, selectAllElements, undo, ]); const simulationConsole = ( setConsoleEntries([])} onModeChange={changeConsoleMode} onStop={() => void stopSimulation()} progress={simulationProgress} stopPending={simulationStopPending} /> ); if (activeView === "results") { return ( <> {resultSnapshot ? ( ) : ( )} ); } const componentNameError = selectedNode ? !selectedNode.data.label.trim() ? "组件名称不能为空" : duplicateNames.has(selectedNode.data.label.trim()) ? "组件名称不能重复" : "" : ""; const componentParameterRows: ParameterTableRow[] = selectedNode && selectedDefinition ? [ { id: "component-name", name: "组件名称", value: ( updateSelectedNode((node) => ({ ...node, data: { ...node.data, label: event.target.value }, })) } onFocus={recordHistory} value={selectedNode.data.label} /> ), error: componentNameError || undefined, description: "画布中用于识别该组件的名称,必须非空且不能与其他组件重复。", }, { id: "component-id", name: "节点 ID", value: ( {selectedNode.id} ), readOnly: true, description: "系统为画布节点分配的稳定标识,用于工程数据和连接关系。", }, ...orderedParameterEntries(selectedDefinition) .filter(([, definition]) => parameterIsVisible(definition, selectedNode.data.parameters), ) .map(([key, definition]): ParameterTableRow => { const selectedUnit = selectedNode.data.parameterUnits[key] ?? definition.unit ?? ""; const units = parameterUnitOptions(definition); const baseValue = selectedNode.data.parameters[key] ?? ""; const displayValue = displayParameterValue( baseValue, definition, selectedUnit, selectedNode.data.parameterScientificNotation[key], ); const mediumDefinitionIndex = isAmesimGasMediumDefinition(selectedDefinition) && key === "gi"; const gasReference = !mediumDefinitionIndex && definition.editor === "amesimGasReference"; const gasPropertyModel = definition.editor === "amesimGasPropertyModel"; const genericChoice = definition.editor === "choice"; const parameterLabel = mediumDefinitionIndex ? "介质定义索引" : gasReference ? "介质物性索引" : definition.label; const gasReferenceIndex = gasReference ? normalizeAmesimGasIndex(baseValue) : null; const gasReferenceAvailable = gasReferenceIndex !== null && amesimGasMediumOptions.some( (option) => option.value === gasReferenceIndex, ); const optionValue = numericChoiceValue(baseValue); const optionAvailable = (gasPropertyModel || genericChoice) && optionValue !== null && (definition.options ?? []).some( (option) => option.value === optionValue, ); const error = componentParameterValidationMessage( definition, baseValue, selectedUnit, gasReference ? amesimGasMediumOptions : undefined, ); let valueControl: ReactNode; if (mediumDefinitionIndex) { valueControl = ( ); } else if (gasReference) { valueControl = ( ); } else if (gasPropertyModel || genericChoice) { valueControl = ( ); } else { valueControl = ( updateSelectedParameter(key, definition, value) } onEditEnd={finishHistoryEdit} onEditStart={recordHistory} value={displayValue} /> ); } const unitControl = units.length > 0 ? ( ) : undefined; return { id: `component-parameter-${key}`, name: parameterLabel, value: valueControl, unit: unitControl, error: error || undefined, readOnly: mediumDefinitionIndex, help: mediumDefinitionIndex ? "索引由画布自动维护;0 保留给内置空气。" : undefined, description: definition.description, defaultValue: parameterDefaultMetadata( definition, selectedUnit, ), range: parameterRangeMetadata(definition, selectedUnit), currentUnit: parameterCurrentUnitMetadata( definition, selectedUnit, ), calculatedValue: parameterCalculatedValueMetadata( baseValue, definition, selectedUnit, ), }; }), ] : []; const componentParameterGroups: ParameterTableGroup[] = selectedNode && selectedDefinition ? (selectedDefinition.parameterGroups ?? []).flatMap((group) => { const rowIds = group.parameters.flatMap((name) => { const definition = selectedDefinition.parameters[name]; return definition && parameterIsVisible(definition, selectedNode.data.parameters) ? [`component-parameter-${name}`] : []; }); const invalidParameters = group.parameters.filter((name) => { const definition = selectedDefinition.parameters[name]; if (!definition) { return false; } const selectedUnit = selectedNode.data.parameterUnits[name] ?? definition.unit ?? ""; const mediumDefinitionIndex = isAmesimGasMediumDefinition(selectedDefinition) && name === "gi"; const gasReference = !mediumDefinitionIndex && definition.editor === "amesimGasReference"; return Boolean( componentParameterValidationMessage( definition, selectedNode.data.parameters[name] ?? "", selectedUnit, gasReference ? amesimGasMediumOptions : undefined, ), ); }); const hiddenErrorCount = invalidParameters.filter((name) => { const definition = selectedDefinition.parameters[name]; return ( definition && !parameterIsVisible(definition, selectedNode.data.parameters) ); }).length; return rowIds.length > 0 || invalidParameters.length > 0 ? [ { id: `${selectedNode.id}:${group.id}`, label: group.label, rowIds, defaultExpanded: group.defaultExpanded, errorCount: invalidParameters.length, hiddenErrorCount, }, ] : []; }) : []; const simulationSettingErrors = simulationConfigValidationMessages( simulationConfig, ); const simulationSettingRows: ParameterTableRow[] = [ { id: "simulation-t-start", name: "起始时间", value: ( updateSimulationConfig("t_start", value)} onEditEnd={finishHistoryEdit} onEditStart={recordHistory} /> ), unit: s, description: "仿真时间区间的起点。", defaultValue: `${defaultSimulationConfig.t_start} s`, range: "有限数值", currentUnit: "s", error: simulationSettingErrors.t_start, calculatedValue: simulationCalculatedValueMetadata( simulationConfig.t_start, "s", ), }, { id: "simulation-t-stop", name: "结束时间", value: ( updateSimulationConfig("t_stop", value)} onEditEnd={finishHistoryEdit} onEditStart={recordHistory} /> ), unit: s, description: "仿真时间区间的终点,必须大于起始时间。", defaultValue: `${defaultSimulationConfig.t_stop} s`, range: "> 起始时间", currentUnit: "s", error: simulationSettingErrors.t_stop, calculatedValue: simulationCalculatedValueMetadata( simulationConfig.t_stop, "s", ), }, { id: "simulation-step", name: "采样步长", value: ( updateSimulationConfig("step", value)} onEditEnd={finishHistoryEdit} onEditStart={recordHistory} /> ), unit: s, description: "仿真结果输出与采样的时间间隔,必须大于 0。", defaultValue: `${defaultSimulationConfig.step} s`, range: "> 0", currentUnit: "s", error: simulationSettingErrors.step, calculatedValue: simulationCalculatedValueMetadata( simulationConfig.step, "s", ), }, { id: "simulation-max-step", name: "最大积分步长", value: ( updateSimulationConfig("max_step", value)} onEditEnd={finishHistoryEdit} onEditStart={recordHistory} /> ), unit: s, description: "积分器允许采用的最大内部时间步长,必须大于 0。", defaultValue: `${defaultSimulationConfig.max_step} s`, range: "> 0", currentUnit: "s", error: simulationSettingErrors.max_step, calculatedValue: simulationCalculatedValueMetadata( simulationConfig.max_step, "s", ), }, { id: "simulation-method", name: "求解器", value: ( ), description: "用于求解模型微分方程的数值积分方法。", defaultValue: defaultSimulationConfig.method, range: SUPPORTED_SOLVER_METHODS.join(" / "), }, ]; const parameterTableAvailableWidth = Math.max( 0, modelingPaneLayout.propertiesWidth, ); 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} onPointerCancelCapture={handleTightSelectionPointerCancel} onPointerDownCapture={rememberFlowPointer} > beginPortConnection(nodeId, handleId, "drag") } onClickConnectEnd={endClickPortConnection} onClickConnectStart={(_, { nodeId, handleId }) => beginPortConnection(nodeId, handleId, "click") } isValidConnection={isValidConnection} minZoom={0.1} onEdgeClick={(_, edge) => { setSelectedEdgeId(edge.id); setSelectedNodeId(null); }} onEdgesChange={handleEdgesChange} onEdgesDelete={(deletedEdges) => { setSelectedEdgeId(null); if (deletedEdges.length > 0) { appendConsoleEntry("info", `已删除 ${deletedEdges.length} 条连接`); } }} onNodeClick={(_, node) => { setSelectedNodeId(node.id); setSelectedEdgeId(null); }} onNodeDrag={handleNodeDrag} onNodeDragStart={handleNodeDragStart} onNodeDragStop={handleNodeDragStop} onNodesChange={handleNodesChange} onNodesDelete={(deletedNodes) => { setSelectedNodeId(null); cancelPortConnection(); if (deletedNodes.length > 0) { appendConsoleEntry("info", `已删除 ${deletedNodes.length} 个组件`); } }} onPaneClick={() => { setSelectedNodeId(null); setSelectedEdgeId(null); cancelPortConnection(); }} onSelectionChange={({ nodes: selectedNodes, edges: selectedEdges }) => { setSelectedNodeId(selectedNodes.length === 1 ? selectedNodes[0].id : null); setSelectedEdgeId( selectedNodes.length === 0 && selectedEdges.length === 1 ? selectedEdges[0].id : null, ); }} onSelectionEnd={finishTightSelection} panActivationKeyCode="Space" panOnDrag={[1]} selectionMode={SelectionMode.Partial} selectionOnDrag snapGrid={[GRID_SIZE, GRID_SIZE]} snapToGrid={snapEnabled} zoomOnDoubleClick={false} > 0} expectedNodeCount={renderedNodes.length} requestKey={modelingFitRequest} /> {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="拖动调整参数面板宽度,双击恢复默认" >
{simulationConsole} {paletteIconPreview && typeof document !== "undefined" ? createPortal( , document.body, ) : null} ); } function buildProjectPayload( name: string, nodes: SimulationNode[], edges: SimulationEdge[], simulation: SimulationConfig, ): ReactFlowProjectPayload { return { name: name.trim() || "untitled", mediumReferenceVersion: 1, amesimParameterEncodingVersion: 1, presentationLayoutVersion: PRESENTATION_LAYOUT_VERSION, nodes: nodes.map((node) => ({ id: node.id, type: "simulationComponent", position: { x: node.position.x, y: node.position.y, }, data: { label: node.data.label, componentType: node.data.componentType, modelType: node.data.modelType, ports: node.data.ports.map((port) => ({ ...port })), parameters: { ...node.data.parameters }, parameterUnits: { ...node.data.parameterUnits }, parameterScientificNotation: { ...node.data.parameterScientificNotation, }, rotation: normalizeNodeRotation(node.data.rotation), mirrored: Boolean(node.data.mirrored), }, })), edges: edges.map((edge) => ({ id: edge.id, source: edge.source, target: edge.target, sourceHandle: edge.sourceHandle ?? null, targetHandle: edge.targetHandle ?? null, })), simulation, }; } function numericChoiceValue(value: ParameterValue | undefined) { if (typeof value === "string" && value.trim() === "") { return null; } const numericValue = Number(value); return Number.isFinite(numericValue) ? numericValue : null; } function parameterIsVisible( definition: ParameterDefinition, parameters: Record, ) { return ( !definition.visibleWhen?.length || definition.visibleWhen.every((condition) => { const currentValue = numericChoiceValue(parameters[condition.parameter]); return currentValue !== null && condition.values.includes(currentValue); }) ); } function normalizeParameterGroups( value: unknown, parameterNames: ReadonlySet, ): ParameterGroupDefinition[] { if (!Array.isArray(value)) { return []; } const groupIds = new Set(); const groupedParameters = new Set(); const groups = value.flatMap((entry) => { if (!entry || typeof entry !== "object" || Array.isArray(entry)) { return []; } const candidate = entry as Record; const id = typeof candidate.id === "string" ? candidate.id.trim() : ""; const label = typeof candidate.label === "string" ? candidate.label.trim() : ""; if (!id || !label || groupIds.has(id) || !Array.isArray(candidate.parameters)) { return []; } const parameters = candidate.parameters.flatMap((parameter) => { if ( typeof parameter !== "string" || !parameterNames.has(parameter) || groupedParameters.has(parameter) ) { return []; } groupedParameters.add(parameter); return [parameter]; }); if (parameters.length === 0) { return []; } groupIds.add(id); const numericOrder = Number(candidate.order); return [ { id, label, order: Number.isInteger(numericOrder) ? numericOrder : 0, parameters, defaultExpanded: candidate.defaultExpanded === true, }, ]; }); return groups.sort( (first, second) => first.order - second.order || first.id.localeCompare(second.id), ); } function normalizeParameterVisibilityConditions( value: unknown, ): ParameterVisibilityCondition[] | undefined { if (!Array.isArray(value)) { return undefined; } const conditions = value.flatMap((entry) => { if (!entry || typeof entry !== "object" || Array.isArray(entry)) { return []; } const candidate = entry as Record; if (typeof candidate.parameter !== "string" || !candidate.parameter.trim()) { return []; } if (!Array.isArray(candidate.values)) { return []; } const values = [ ...new Set( candidate.values.flatMap((item) => { const numericValue = Number(item); return Number.isFinite(numericValue) ? [numericValue] : []; }), ), ]; return values.length > 0 ? [{ parameter: candidate.parameter.trim(), values }] : []; }); return conditions.length > 0 ? conditions : undefined; } function normalizeParameterScientificNotation( value: unknown, ): Record { if (!value || typeof value !== "object" || Array.isArray(value)) { return {}; } const normalized: Record = {}; Object.entries(value as Record).forEach(([key, entry]) => { if (!entry || typeof entry !== "object" || Array.isArray(entry)) { return; } const candidate = entry as Record; if ( typeof candidate.text !== "string" || typeof candidate.unit !== "string" || !isScientificNotationValue(candidate.text) ) { return; } normalized[key] = { text: candidate.text.trim(), unit: candidate.unit, }; }); return normalized; } function normalizeLoadedNode( node: ProjectNodePayload, componentDefinitions: ComponentDefinition[], amesimParameterEncodingVersion?: 1 | null, ): SimulationNode { const definition = componentDefinitions.find((item) => item.type === node.data.componentType) ?? componentDefinitions.find((item) => item.modelType === node.data.modelType); const effectiveModelType = definition?.modelType || node.data.modelType || node.data.componentType; const rawParameters = node.data.parameters ?? {}; const mergedParameters = definition ? { ...defaultParameters(definition), ...rawParameters } : { ...rawParameters }; const parameters = migrateLegacyAmesimParameterEncoding( effectiveModelType, mergedParameters, amesimParameterEncodingVersion, ); if ( isAmesimGasMediumDefinition(definition) && !Object.prototype.hasOwnProperty.call(rawParameters, "gi") ) { parameters.gi = ""; } const parameterUnits = definition ? { ...defaultParameterUnits(definition), ...(node.data.parameterUnits ?? {}) } : { ...(node.data.parameterUnits ?? {}) }; const parameterScientificNotation = normalizeParameterScientificNotation( node.data.parameterScientificNotation, ); return { id: node.id, type: "simulationComponent", position: { x: node.position?.x ?? 0, y: node.position?.y ?? 0, }, data: { label: node.data.label || definition?.label || node.id, componentType: node.data.componentType || definition?.type || "component", modelType: node.data.modelType || definition?.modelType || node.data.componentType, // Catalog symbols are versioned presentation metadata. Prefer the current // definition so projects saved with an old generic icon are upgraded on load. symbol: definition?.symbol || node.data.symbol || node.data.componentType, ports: normalizeLoadedPorts(node.data.ports, definition), parameters, parameterUnits, parameterScientificNotation, rotation: normalizeNodeRotation(node.data.rotation), mirrored: Boolean(node.data.mirrored), }, }; } function normalizeLoadedPorts( rawPorts: unknown, definition?: ComponentDefinition, ): PortDefinition[] { if (!Array.isArray(rawPorts) || rawPorts.length === 0) { return definition?.ports.map((port) => ({ ...port })) ?? []; } const loadedPorts = rawPorts.flatMap((rawPort, index) => { const record = rawPort !== null && typeof rawPort === "object" ? (rawPort as Record) : undefined; const rawName = typeof rawPort === "string" ? rawPort : record?.name; if (typeof rawName !== "string" || !rawName.trim()) { return []; } const name = rawName.trim(); const registered = definition?.ports.find((port) => port.name === name); const kind: PortKind = record?.kind === "signal" || record?.kind === "physical" ? record.kind : registered?.kind ?? "physical"; const nominalRole = isPortNominalRole(record?.nominalRole) ? record.nominalRole : registered?.nominalRole ?? legacyPortRole(name); const side: PortSide = record?.side === "left" || record?.side === "right" ? record.side : registered?.side ?? (index === 0 ? "left" : "right"); return [ { name, kind, domain: typeof record?.domain === "string" && record.domain.trim() ? record.domain.trim() : registered?.domain ?? (kind === "physical" ? "pneumatic" : "signal"), nominalRole, ...(kind === "physical" ? { positiveFlowDirection: "intoComponent" as const } : {}), side, }, ]; }); if (!definition) { return [...new Map(loadedPorts.map((port) => [port.name, port])).values()]; } const loadedByName = new Map(loadedPorts.map((port) => [port.name, port])); const registeredNames = new Set(definition.ports.map((port) => port.name)); return [ ...definition.ports.map((registered) => ({ ...(loadedByName.get(registered.name) ?? registered), // Port placement is versioned catalog metadata, just like the symbol. // Prefer it so projects saved with an incorrect display order are upgraded. side: registered.side, })), ...loadedPorts.filter((port) => !registeredNames.has(port.name)), ]; } function isPortNominalRole(value: unknown): value is PortNominalRole { return ["inlet", "outlet", "bidirectional", "input", "output"].includes( String(value), ); } function legacyPortRole(name: string): PortNominalRole { if (name.includes("out") || name === "port_b") { return "outlet"; } if (name.includes("in") || name === "port_a") { return "inlet"; } return "bidirectional"; } function findNodePort( nodes: SimulationNode[], componentId: string | null, portName: string | null, ) { if (!componentId || !portName) { return undefined; } return nodes .find((node) => node.id === componentId) ?.data.ports.find((port) => port.name === portName); } function portConnectionKey(nodeId: string, portName: string) { return `${nodeId}:${portName}`; } function arePortsCompatible(first: PortDefinition, second: PortDefinition) { if (first.kind !== second.kind || first.domain !== second.domain) { return false; } if (first.kind === "physical") { return true; } return new Set([first.nominalRole, second.nominalRole]).size === 2 && [first.nominalRole, second.nominalRole].every((role) => role === "input" || role === "output", ); } function canConnectPorts( nodes: SimulationNode[], edges: SimulationEdge[], connection: Connection | SimulationEdge, ) { if ( !connection.source || !connection.target || connection.source === connection.target ) { return false; } const first = findNodePort(nodes, connection.source, connection.sourceHandle ?? null); const second = findNodePort(nodes, connection.target, connection.targetHandle ?? null); if (!first || !second || !arePortsCompatible(first, second)) { return false; } const firstKey = portConnectionKey(connection.source, connection.sourceHandle!); const secondKey = portConnectionKey(connection.target, connection.targetHandle!); return !edges.some( (edge) => (typeof edge.sourceHandle === "string" && portConnectionKey(edge.source, edge.sourceHandle) === firstKey) || (typeof edge.targetHandle === "string" && portConnectionKey(edge.target, edge.targetHandle) === firstKey) || (typeof edge.sourceHandle === "string" && portConnectionKey(edge.source, edge.sourceHandle) === secondKey) || (typeof edge.targetHandle === "string" && portConnectionKey(edge.target, edge.targetHandle) === secondKey), ); } function connectionIdentity(edge: SimulationEdge, unordered: boolean) { const endpoints = [ `${edge.source}:${edge.sourceHandle ?? ""}`, `${edge.target}:${edge.targetHandle ?? ""}`, ]; if (unordered) { endpoints.sort(); } return endpoints.join(unordered ? "--" : "->"); } type LoadedEdgeNormalizationResult = { edges: SimulationEdge[]; repairedEdgeCount: number; droppedEdgeCount: number; }; /** * 旧工程可能包含空白、缺失或已经失效的 Handle 名称。只有接口配对唯一时才 * 自动补全;存在歧义的边直接忽略,避免画面有连线但端口仍被当作未连接。 */ function normalizeLoadedEdges( nodes: SimulationNode[], rawEdges: unknown[], ): LoadedEdgeNormalizationResult { const nodeById = new Map(nodes.map((node) => [node.id, node] as const)); const occupiedPortKeys = new Set(); const usedEdgeIds = new Set(); const edges: SimulationEdge[] = []; let repairedEdgeCount = 0; let droppedEdgeCount = 0; rawEdges.forEach((rawEdge, index) => { const record = rawEdge !== null && typeof rawEdge === "object" ? (rawEdge as Record) : undefined; const source = typeof record?.source === "string" ? record.source.trim() : ""; const target = typeof record?.target === "string" ? record.target.trim() : ""; const sourceNode = nodeById.get(source); const targetNode = nodeById.get(target); if (!sourceNode || !targetNode || source === target) { droppedEdgeCount += 1; return; } const rawSourceHandle = typeof record?.sourceHandle === "string" ? record.sourceHandle.trim() : ""; const rawTargetHandle = typeof record?.targetHandle === "string" ? record.targetHandle.trim() : ""; const explicitSourcePort = sourceNode.data.ports.find( (port) => port.name === rawSourceHandle, ); const explicitTargetPort = targetNode.data.ports.find( (port) => port.name === rawTargetHandle, ); const sourceCandidates = explicitSourcePort ? [explicitSourcePort] : sourceNode.data.ports; const targetCandidates = explicitTargetPort ? [explicitTargetPort] : targetNode.data.ports; const compatiblePairs = sourceCandidates.flatMap((sourcePort) => targetCandidates.flatMap((targetPort) => { const sourceKey = portConnectionKey(source, sourcePort.name); const targetKey = portConnectionKey(target, targetPort.name); return arePortsCompatible(sourcePort, targetPort) && !occupiedPortKeys.has(sourceKey) && !occupiedPortKeys.has(targetKey) ? [{ sourceKey, sourcePort, targetKey, targetPort }] : []; }), ); if (compatiblePairs.length !== 1) { droppedEdgeCount += 1; return; } const pair = compatiblePairs[0]; let id = typeof record?.id === "string" && record.id.trim() ? record.id.trim() : `edge-loaded-${index + 1}`; if (usedEdgeIds.has(id)) { const baseId = id; let suffix = 2; while (usedEdgeIds.has(`${baseId}-${suffix}`)) { suffix += 1; } id = `${baseId}-${suffix}`; } usedEdgeIds.add(id); occupiedPortKeys.add(pair.sourceKey); occupiedPortKeys.add(pair.targetKey); edges.push({ id, source, sourceHandle: pair.sourcePort.name, target, targetHandle: pair.targetPort.name, type: "step", }); if ( id !== record?.id || source !== record?.source || target !== record?.target || pair.sourcePort.name !== record?.sourceHandle || pair.targetPort.name !== record?.targetHandle ) { repairedEdgeCount += 1; } }); return { edges, repairedEdgeCount, droppedEdgeCount }; } function takeNextNodeId(componentType: string) { const nextNumber = nextNodeNumberByType[componentType] ?? 1; nextNodeNumberByType[componentType] = nextNumber + 1; return `${componentType}_${nextNumber}`; } function syncNextNodeNumbers(nodes: SimulationNode[]) { Object.keys(nextNodeNumberByType).forEach((type) => { delete nextNodeNumberByType[type]; }); nodes.forEach((node) => { const componentType = node.data.componentType; const idPrefix = `${componentType}_`; if (!node.id.startsWith(idPrefix)) { return; } const nodeNumber = Number(node.id.slice(idPrefix.length)); if (!Number.isInteger(nodeNumber) || nodeNumber < 1) { return; } nextNodeNumberByType[componentType] = Math.max( nextNodeNumberByType[componentType] ?? 1, nodeNumber + 1, ); }); } function findUnitOption(definition: ParameterDefinition, unitValue: string) { const options = parameterUnitOptions(definition); if (options.length === 0) { return { value: "", label: "", toBase: identityUnit, fromBase: identityUnit }; } return ( options.find((unit) => unit.value === unitValue) ?? options[0] ); } function parameterUnitOptions(definition: ParameterDefinition): UnitOption[] { if (!definition.unit) { return []; } const configured = definition.quantity ? unitOptions[definition.quantity] : undefined; if (configured?.length) { return configured; } return [ { value: definition.unit, label: definition.unit, toBase: identityUnit, fromBase: identityUnit, }, ]; } function displayParameterValue( baseValue: ParameterValue, definition: ParameterDefinition, selectedUnit: string, scientificNotation?: ParameterScientificNotation, ) { if (typeof baseValue === "string" && baseValue.trim() === "") { return ""; } const numericValue = Number(baseValue); if (!Number.isFinite(numericValue)) { return String(baseValue); } const displayValue = findUnitOption(definition, selectedUnit).fromBase(numericValue); if ( scientificNotation && isScientificNotationValue(scientificNotation.text) ) { return scientificNotation.unit === selectedUnit ? scientificNotation.text.trim() : formatScientificEditableNumber(displayValue, scientificNotation.text); } return formatEditableNumber(displayValue); } function migrateLegacyAmesimParameterEncoding( modelType: string, parameters: Record, version?: 1 | null, ) { if (version === 1 || modelType !== "amesim_mecmas21") { return { ...parameters }; } const migrated = { ...parameters }; (["useFriction", "strib"] as const).forEach((name) => { const value = numericChoiceValue(migrated[name]); if (value === 0) { migrated[name] = 1; } else if (value === 1) { migrated[name] = 2; } }); return migrated; } function formatEditableNumber(value: number) { if (!Number.isFinite(value)) { return ""; } return Number(value.toPrecision(12)).toString(); } const SCIENTIFIC_NOTATION_STYLE_PATTERN = /^[+-]?(?:\d+(?:\.(\d*))?|\.(\d+))([eE])([+-]?)(\d+)$/; function formatScientificEditableNumber(value: number, notationHint: string) { if (!Number.isFinite(value)) { return ""; } const style = SCIENTIFIC_NOTATION_STYLE_PATTERN.exec(notationHint.trim()); if (!style) { return Number(value.toPrecision(12)).toExponential(); } const minimumFractionDigits = (style[1] ?? style[2] ?? "").length; const exponentMarker = style[3]; const showPositiveExponentSign = style[4] === "+"; const roundedDisplayValue = Number(value.toPrecision(15)); const [rawMantissa, rawExponent] = roundedDisplayValue.toExponential().split("e"); const [wholePart, rawFraction = ""] = rawMantissa.split("."); let fraction = rawFraction; while (fraction.endsWith("0") && fraction.length > minimumFractionDigits) { fraction = fraction.slice(0, -1); } if (fraction.length < minimumFractionDigits) { fraction = fraction.padEnd(minimumFractionDigits, "0"); } const exponent = Number(rawExponent); const exponentSign = exponent < 0 ? "-" : showPositiveExponentSign ? "+" : ""; const exponentDigits = String(Math.abs(exponent)); return `${wholePart}${fraction ? `.${fraction}` : ""}${exponentMarker}${exponentSign}${exponentDigits}`; } function parameterMetadataValue( value: ParameterValue, definition: ParameterDefinition, selectedUnit: string, ) { const displayValue = displayParameterValue(value, definition, selectedUnit); const option = definition.options?.find( (candidate) => candidate.value === Number(value), ); return option ? `${displayValue}(${option.label})` : displayValue; } function parameterDefaultMetadata( definition: ParameterDefinition, selectedUnit: string, ) { const defaultValue = definition.editor === "amesimGasReference" ? 0 : definition.default; return parameterMetadataValue(defaultValue, definition, selectedUnit); } function parameterRangeMetadata( definition: ParameterDefinition, selectedUnit: string, ) { const limits: string[] = []; if (definition.min !== undefined) { limits.push( `${definition.minExclusive ? ">" : "≥"} ${parameterMetadataValue( definition.min, definition, selectedUnit, )}`, ); } if (definition.max !== undefined) { limits.push( `≤ ${parameterMetadataValue(definition.max, definition, selectedUnit)}`, ); } if (limits.length > 0) { return limits.join(","); } if (definition.options?.length) { return definition.options .map((option) => `${option.value}(${option.label})`) .join(" / "); } return undefined; } function parameterCurrentUnitMetadata( definition: ParameterDefinition, selectedUnit: string, ) { return parameterUnitOptions(definition).length > 0 ? findUnitOption(definition, selectedUnit).label : undefined; } type NumericInputResolution = | { ok: true; value: number; expression: boolean } | { ok: false; message: string; expression: boolean }; function resolveNumericInput(value: ParameterValue): NumericInputResolution { if (typeof value === "number") { return Number.isFinite(value) ? { ok: true, value, expression: false } : { ok: false, message: "请输入有限数值", expression: false }; } const normalizedValue = value.trim(); if (!normalizedValue) { return { ok: false, message: "参数不能为空", expression: false }; } const numericValue = Number(normalizedValue); if (Number.isFinite(numericValue)) { return { ok: true, value: numericValue, expression: false }; } const evaluated = evaluateParameterExpression(normalizedValue); return evaluated.ok ? { ok: true, value: evaluated.value, expression: true } : { ok: false, message: `等式无法计算:${evaluated.message}`, expression: true, }; } function resolveParameterValue( value: ParameterValue, definition: ParameterDefinition, selectedUnit: string, ): NumericInputResolution { const resolved = resolveNumericInput(value); if (!resolved.ok) { return resolved; } if (resolved.expression && definition.editor) { return { ok: false, message: "该离散参数只能通过下拉栏选择", expression: true, }; } if (!resolved.expression) { return resolved; } const baseValue = findUnitOption(definition, selectedUnit).toBase( resolved.value, ); return Number.isFinite(baseValue) ? { ...resolved, value: baseValue } : { ok: false, message: "等式换算到基础单位后不是有限数值", expression: true, }; } function parameterCalculatedValueMetadata( value: ParameterValue, definition: ParameterDefinition, selectedUnit: string, ) { if (!isParameterExpressionValue(value)) { return undefined; } const resolved = resolveParameterValue(value, definition, selectedUnit); if (!resolved.ok || !resolved.expression) { return undefined; } const unit = findUnitOption(definition, selectedUnit); const displayValue = unit.fromBase(resolved.value); return `${formatEditableNumber(displayValue)}${unit.label ? ` ${unit.label}` : ""}`; } function parameterValidationMessage( definition: ParameterDefinition, value: ParameterValue, selectedUnit: string, ) { const resolved = resolveParameterValue(value, definition, selectedUnit); if (!resolved.ok) { return resolved.message; } const numericValue = resolved.value; if ( definition.options && !definition.options.some((option) => option.value === numericValue) ) { return "不在支持的可选项中"; } if ( definition.min !== undefined && (definition.minExclusive ? numericValue <= definition.min : numericValue < definition.min) ) { return definition.minExclusive ? `必须大于 ${definition.min}` : `不能小于 ${definition.min}`; } if (definition.max !== undefined && numericValue > definition.max) { return `不能大于 ${definition.max}`; } return ""; } function componentParameterValidationMessage( definition: ParameterDefinition, value: ParameterValue, selectedUnit: string, gasMediumOptions?: readonly AmesimGasMediumOption[], ) { if (gasMediumOptions !== undefined) { const gasReferenceIndex = normalizeAmesimGasIndex(value); if ( gasReferenceIndex === null || !gasMediumOptions.some((option) => option.value === gasReferenceIndex) ) { return "引用的介质定义不存在"; } } return parameterValidationMessage(definition, value, selectedUnit); } function simulationCalculatedValueMetadata( value: ParameterValue, unit: string, ) { if (!isParameterExpressionValue(value)) { return undefined; } const resolved = resolveNumericInput(value); if (!resolved.ok || !resolved.expression) { return undefined; } return `${formatEditableNumber(resolved.value)}${unit ? ` ${unit}` : ""}`; } function simulationConfigValidationMessages( simulation: SimulationConfig, ): Partial> { const resolutions = { t_start: resolveNumericInput(simulation.t_start), t_stop: resolveNumericInput(simulation.t_stop), step: resolveNumericInput(simulation.step), max_step: resolveNumericInput(simulation.max_step), } satisfies Record; const messages: Partial> = {}; (Object.keys(resolutions) as SimulationNumericKey[]).forEach((key) => { const resolved = resolutions[key]; if (!resolved.ok) { messages[key] = resolved.message; } }); if ( resolutions.t_start.ok && resolutions.t_stop.ok && resolutions.t_stop.value <= resolutions.t_start.value ) { messages.t_stop = "必须大于起始时间"; } if (resolutions.step.ok && resolutions.step.value <= 0) { messages.step = "必须大于 0"; } if (resolutions.max_step.ok && resolutions.max_step.value <= 0) { messages.max_step = "必须大于 0"; } return messages; } type SimulationConfigResolution = | { ok: true; value: ResolvedSimulationConfig } | { ok: false; message: string }; function resolveSimulationConfig( simulation: SimulationConfig, ): SimulationConfigResolution { const messages = simulationConfigValidationMessages(simulation); const firstInvalidKey = (Object.keys(messages) as SimulationNumericKey[])[0]; if (firstInvalidKey) { return { ok: false, message: `仿真设置“${simulationConfigLabels[firstInvalidKey]}”:${messages[firstInvalidKey]}`, }; } return { ok: true, value: { t_start: (resolveNumericInput(simulation.t_start) as { ok: true; value: number }) .value, t_stop: (resolveNumericInput(simulation.t_stop) as { ok: true; value: number }) .value, step: (resolveNumericInput(simulation.step) as { ok: true; value: number }) .value, max_step: ( resolveNumericInput(simulation.max_step) as { ok: true; value: number } ).value, method: simulation.method, }, }; } function findDuplicateNames(nodes: SimulationNode[]) { const counts = new Map(); nodes.forEach((node) => { const name = node.data.label.trim(); if (name) { counts.set(name, (counts.get(name) ?? 0) + 1); } }); return new Set( [...counts.entries()].filter(([, count]) => count > 1).map(([name]) => name), ); } function validateModel( nodes: SimulationNode[], edges: SimulationEdge[], simulation: SimulationConfig, componentDefinitions: ComponentDefinition[], ) { const issues: ValidationIssue[] = []; let issueNumber = 0; const addIssue = ( severity: ValidationIssue["severity"], message: string, location: Pick = {}, ) => { issueNumber += 1; issues.push({ id: `validation-${issueNumber}`, severity, message, ...location }); }; if (nodes.length === 0) { addIssue("error", "画布中没有组件"); } const nodeMap = new Map(nodes.map((node) => [node.id, node])); const duplicateNames = findDuplicateNames(nodes); const gasMediumNodesByIndex = new Map(); nodes.forEach((node) => { const definition = componentDefinitionForNode(node, componentDefinitions); if (!isAmesimGasMediumDefinition(definition)) { return; } const index = normalizeAmesimGasIndex( node.data.parameters.gi, AMESIM_MIN_DEFINED_GAS_INDEX, ); if (index === null) { addIssue( "error", `${node.data.label}:介质定义索引必须是 1..99 的整数`, { nodeId: node.id }, ); return; } const definitionsAtIndex = gasMediumNodesByIndex.get(index) ?? []; definitionsAtIndex.push(node); gasMediumNodesByIndex.set(index, definitionsAtIndex); }); gasMediumNodesByIndex.forEach((mediumNodes, index) => { if (mediumNodes.length <= 1) { return; } mediumNodes.forEach((node) => { addIssue( "error", `${node.data.label}:介质定义索引 ${index} 重复`, { nodeId: node.id }, ); }); }); nodes.forEach((node) => { const definition = componentDefinitionForNode(node, componentDefinitions); if (!node.data.label.trim()) { addIssue("error", `${node.id} 的组件名称为空`, { nodeId: node.id }); } else if (duplicateNames.has(node.data.label.trim())) { addIssue("error", `组件名称“${node.data.label.trim()}”重复`, { nodeId: node.id }); } if (!definition) { addIssue("error", `${node.id} 的组件类型不受支持`, { nodeId: node.id }); return; } Object.entries(definition.parameters).forEach(([key, parameter]) => { const selectedUnit = node.data.parameterUnits[key] ?? parameter.unit ?? ""; const message = parameterValidationMessage( parameter, node.data.parameters[key] ?? "", selectedUnit, ); if (message) { const messageSeparator = message.startsWith("等式") ? ":" : ""; addIssue("error", `${node.data.label}:${parameter.label}${messageSeparator}${message}`, { nodeId: node.id, }); } }); if (!isAmesimGasMediumDefinition(definition)) { Object.entries(definition.parameters).forEach(([key, parameter]) => { if (parameter.editor !== "amesimGasReference") { return; } const index = normalizeAmesimGasIndex(node.data.parameters[key]); if (index === null) { addIssue( "error", `${node.data.label}:介质物性索引必须是 0..99 的整数`, { nodeId: node.id }, ); return; } if (index === AMESIM_BUILTIN_GAS_INDEX) { return; } const providers = gasMediumNodesByIndex.get(index) ?? []; if (providers.length === 0) { addIssue( "error", `${node.data.label}:介质物性索引 ${index} 没有对应的介质定义`, { nodeId: node.id }, ); } else if (providers.length > 1) { addIssue( "error", `${node.data.label}:介质物性索引 ${index} 对应多个介质定义`, { nodeId: node.id }, ); } }); } definition.ports.forEach((port) => { const connectionCount = edges.filter( (edge) => (edge.source === node.id && edge.sourceHandle === port.name) || (edge.target === node.id && edge.targetHandle === port.name), ).length; if (connectionCount === 0) { addIssue("error", `${node.data.label} 的端口 ${port.name} 未连接`, { nodeId: node.id }); } else if (connectionCount > 1) { addIssue("error", `${node.data.label} 的端口 ${port.name} 存在多条连接`, { nodeId: node.id, }); } }); }); const connectionKeys = new Set(); edges.forEach((edge) => { const source = nodeMap.get(edge.source); const target = nodeMap.get(edge.target); if (!source || !target) { addIssue("error", `连接 ${edge.id} 引用了不存在的组件`, { edgeId: edge.id }); return; } const sourcePort = source.data.ports.find((port) => port.name === edge.sourceHandle); const targetPort = target.data.ports.find((port) => port.name === edge.targetHandle); if (!sourcePort) { addIssue("error", `连接 ${edge.id} 的源端口无效`, { edgeId: edge.id, nodeId: source.id, }); } if (!targetPort) { addIssue("error", `连接 ${edge.id} 的目标端口无效`, { edgeId: edge.id, nodeId: target.id, }); } if (sourcePort && targetPort && !arePortsCompatible(sourcePort, targetPort)) { addIssue("error", `连接 ${edge.id} 的端口类型或物理域不兼容`, { edgeId: edge.id }); } const key = connectionIdentity(edge, sourcePort?.kind === "physical"); if (connectionKeys.has(key)) { addIssue("warning", "检测到重复连接", { edgeId: edge.id }); } connectionKeys.add(key); }); const pneumaticAdjacency = new Map>(); nodes.forEach((node) => { if ( node.data.ports.some( (port) => port.kind === "physical" && port.domain === "pneumatic", ) ) { pneumaticAdjacency.set(node.id, new Set()); } }); edges.forEach((edge) => { const source = nodeMap.get(edge.source); const target = nodeMap.get(edge.target); const sourcePort = source?.data.ports.find( (port) => port.name === edge.sourceHandle, ); const targetPort = target?.data.ports.find( (port) => port.name === edge.targetHandle, ); if ( !source || !target || sourcePort?.kind !== "physical" || targetPort?.kind !== "physical" || sourcePort.domain !== "pneumatic" || targetPort.domain !== "pneumatic" ) { return; } pneumaticAdjacency.get(source.id)?.add(target.id); pneumaticAdjacency.get(target.id)?.add(source.id); }); const visitedPneumaticNodes = new Set(); pneumaticAdjacency.forEach((_, startNodeId) => { if (visitedPneumaticNodes.has(startNodeId)) { return; } const pendingNodeIds = [startNodeId]; const connectedNodeIds: string[] = []; while (pendingNodeIds.length > 0) { const nodeId = pendingNodeIds.pop() as string; if (visitedPneumaticNodes.has(nodeId)) { continue; } visitedPneumaticNodes.add(nodeId); connectedNodeIds.push(nodeId); pneumaticAdjacency.get(nodeId)?.forEach((neighborId) => { if (!visitedPneumaticNodes.has(neighborId)) { pendingNodeIds.push(neighborId); } }); } const referencedIndices = new Set(); const referencingNodes = new Map(); connectedNodeIds.forEach((nodeId) => { const node = nodeMap.get(nodeId); if (!node) { return; } const definition = componentDefinitionForNode(node, componentDefinitions); if (!definition || isAmesimGasMediumDefinition(definition)) { return; } Object.entries(definition.parameters).forEach(([key, parameter]) => { if (parameter.editor !== "amesimGasReference") { return; } const index = normalizeAmesimGasIndex(node.data.parameters[key]); if (index === null) { return; } referencedIndices.add(index); referencingNodes.set(node.id, node); }); }); if (referencedIndices.size <= 1) { return; } const indexList = [...referencedIndices] .sort((first, second) => first - second) .join("、"); referencingNodes.forEach((node) => { addIssue( "error", `${node.data.label}:同一气路引用了不同的介质物性索引(${indexList})`, { nodeId: node.id }, ); }); }); const simulationErrors = simulationConfigValidationMessages(simulation); (Object.keys(simulationErrors) as SimulationNumericKey[]).forEach((key) => { const message = simulationErrors[key]; if (message) { addIssue("error", `仿真设置:${simulationConfigLabels[key]}:${message}`); } }); if (!SUPPORTED_SOLVER_METHODS.includes(simulation.method)) { addIssue("error", "求解器不受支持"); } return issues; } function workspaceViewFromLocation(): WorkspaceView { return window.location.hash === "#/results" ? "results" : "modeling"; } function migrateSimulationResultsSnapshot( snapshot: SimulationResultsSnapshot, ): SimulationResultsSnapshot { if (snapshot.project.presentationLayoutVersion === PRESENTATION_LAYOUT_VERSION) { return snapshot; } const migrationNodes: SimulationNode[] = snapshot.project.nodes.map((node) => ({ id: node.id, type: "simulationComponent", position: { ...node.position }, data: { label: node.data.label, componentType: node.data.componentType, modelType: node.data.modelType, symbol: node.data.componentType, ports: normalizeLoadedPorts(node.data.ports), parameters: { ...node.data.parameters }, parameterUnits: { ...(node.data.parameterUnits ?? {}) }, parameterScientificNotation: {}, rotation: normalizeNodeRotation(node.data.rotation), mirrored: Boolean(node.data.mirrored), }, })); const migrationEdges = normalizeLoadedEdges( migrationNodes, snapshot.project.edges, ).edges; const migration = migrateLegacyPresentationLayout( migrationNodes, migrationEdges, snapshot.project.presentationLayoutVersion, ); const positionByNodeId = new Map( migration.nodes.map((node) => [node.id, node.position] as const), ); return { ...snapshot, project: { ...snapshot.project, presentationLayoutVersion: PRESENTATION_LAYOUT_VERSION, nodes: snapshot.project.nodes.map((node) => ({ ...node, position: { ...(positionByNodeId.get(node.id) ?? node.position) }, })), }, }; } function loadStoredResultSnapshot(): SimulationResultsSnapshot | null { try { const raw = sessionStorage.getItem(RESULT_SNAPSHOT_KEY); if (!raw) { return null; } const value = JSON.parse(raw) as unknown; if (!isSimulationResultsSnapshot(value)) { sessionStorage.removeItem(RESULT_SNAPSHOT_KEY); return null; } return migrateSimulationResultsSnapshot(value); } catch { return null; } } function storeResultSnapshot(snapshot: SimulationResultsSnapshot) { try { sessionStorage.setItem(RESULT_SNAPSHOT_KEY, JSON.stringify(snapshot)); } catch { // Large result sets remain available in memory for the current page. } } function isSimulationResultsSnapshot( value: unknown, ): value is SimulationResultsSnapshot { if (!value || typeof value !== "object") { return false; } const snapshot = value as Partial; return ( typeof snapshot.id === "string" && typeof snapshot.createdAt === "string" && Boolean(snapshot.project) && typeof snapshot.project?.name === "string" && (snapshot.project?.presentationLayoutVersion === undefined || snapshot.project.presentationLayoutVersion === null || snapshot.project.presentationLayoutVersion === PRESENTATION_LAYOUT_VERSION) && Array.isArray(snapshot.project?.nodes) && Array.isArray(snapshot.project?.edges) && Boolean(snapshot.result) && Array.isArray(snapshot.result?.variables) && Boolean(snapshot.result?.series) && Array.isArray(snapshot.result?.series?.time) ); } function cloneValue(value: T): T { return structuredClone(value); } function snapshotSignature(snapshot: EditorSnapshot) { return JSON.stringify( buildProjectPayload( snapshot.projectName, snapshot.nodes, snapshot.edges, snapshot.simulationConfig, ), ); } function makeUniqueName(baseName: string, usedNames: Set) { if (!usedNames.has(baseName)) { return baseName; } let suffix = 2; while (usedNames.has(`${baseName}_${suffix}`)) { suffix += 1; } return `${baseName}_${suffix}`; } function parseProjectPayload(raw: string | null): ReactFlowProjectPayload | null { if (!raw) { return null; } const value = JSON.parse(raw) as unknown; if (!value || typeof value !== "object") { return null; } const project = value as Partial; if ( typeof project.name !== "string" || !Array.isArray(project.nodes) || !Array.isArray(project.edges) || !project.simulation || typeof project.simulation !== "object" || (project.mediumReferenceVersion !== undefined && project.mediumReferenceVersion !== null && project.mediumReferenceVersion !== 1) || (project.amesimParameterEncodingVersion !== undefined && project.amesimParameterEncodingVersion !== null && project.amesimParameterEncodingVersion !== 1) || (project.presentationLayoutVersion !== undefined && project.presentationLayoutVersion !== null && project.presentationLayoutVersion !== PRESENTATION_LAYOUT_VERSION) ) { return null; } return project as ReactFlowProjectPayload; } function localProjectKey(name: string) { return `${PROJECT_KEY_PREFIX}${encodeURIComponent(name.trim() || "untitled")}`; } function isEditableTarget(target: EventTarget | null) { return ( target instanceof HTMLElement && (target.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName)) ); } function formatTime(date: Date) { return date.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" }); } function buildSystemXml( project: ReactFlowProjectPayload, componentDefinitions: ComponentDefinition[], ) { const simulationResolution = resolveSimulationConfig(project.simulation); if (!simulationResolution.ok) { throw new Error(simulationResolution.message); } const resolvedSimulation = simulationResolution.value; const xmlDocument = document.implementation.createDocument("", "System", null); const system = xmlDocument.documentElement; system.setAttribute("name", project.name); system.setAttribute("schemaVersion", "2"); system.setAttribute("mediumReferenceVersion", "1"); system.setAttribute("amesimParameterEncodingVersion", "1"); system.setAttribute("unitSystem", "SI"); const simulation = xmlDocument.createElement("Simulation"); simulation.setAttribute("tStart", String(resolvedSimulation.t_start)); simulation.setAttribute("tStop", String(resolvedSimulation.t_stop)); simulation.setAttribute("step", String(resolvedSimulation.step)); simulation.setAttribute("maxStep", String(resolvedSimulation.max_step)); simulation.setAttribute("method", resolvedSimulation.method); const components = xmlDocument.createElement("Components"); const connections = xmlDocument.createElement("Connections"); system.append(simulation, components, connections); project.nodes.forEach((node) => { const definition = componentDefinitions.find( (candidate) => candidate.type === node.data.componentType, ); if (!definition) { throw new Error(`组件 ${node.data.label || node.id} 的类型不受支持`); } const component = xmlDocument.createElement("Component"); component.setAttribute("id", node.id); component.setAttribute("name", node.data.label); component.setAttribute("type", node.data.modelType); component.setAttribute("componentType", node.data.componentType); component.setAttribute("x", formatEditableNumber(node.position.x)); component.setAttribute("y", formatEditableNumber(node.position.y)); component.setAttribute("rotation", String(normalizeNodeRotation(node.data.rotation))); component.setAttribute("mirrored", String(Boolean(node.data.mirrored))); node.data.ports.forEach((portDefinition) => { const port = xmlDocument.createElement("Port"); port.setAttribute("name", portDefinition.name); port.setAttribute("kind", portDefinition.kind); port.setAttribute("domain", portDefinition.domain); port.setAttribute("nominalRole", portDefinition.nominalRole); port.setAttribute("side", portDefinition.side); if (portDefinition.positiveFlowDirection) { port.setAttribute("positiveFlowDirection", portDefinition.positiveFlowDirection); } component.appendChild(port); }); Object.entries(node.data.parameters).forEach(([name, value]) => { const parameterDefinition = definition.parameters[name]; const selectedUnit = node.data.parameterUnits?.[name] ?? parameterDefinition?.unit ?? ""; const resolved = parameterDefinition ? resolveParameterValue(value, parameterDefinition, selectedUnit) : resolveNumericInput(value); if (!resolved.ok) { throw new Error( `组件“${node.data.label || node.id}”的参数“${parameterDefinition?.label ?? name}”:${resolved.message}`, ); } const parameter = xmlDocument.createElement("Parameter"); parameter.setAttribute("name", name); parameter.setAttribute("value", formatEditableNumber(resolved.value)); component.appendChild(parameter); }); components.appendChild(component); }); project.edges.forEach((edge) => { const metadata = projectConnectionMetadata(project, edge); const connection = xmlDocument.createElement("Connection"); connection.setAttribute("id", edge.id); connection.setAttribute("kind", metadata.kind); connection.setAttribute("domain", metadata.domain); metadata.endpoints.forEach((endpoint) => { const endpointElement = xmlDocument.createElement("Endpoint"); endpointElement.setAttribute("component", endpoint.component); endpointElement.setAttribute("port", endpoint.port); if (endpoint.role) { endpointElement.setAttribute("role", endpoint.role); } connection.appendChild(endpointElement); }); connections.appendChild(connection); }); indentXmlElement(system); return `\n${new XMLSerializer().serializeToString(system)}`; } function projectConnectionMetadata( project: ReactFlowProjectPayload, edge: ProjectEdgePayload, ) { const first = findProjectPort(project, edge.source, edge.sourceHandle); const second = findProjectPort(project, edge.target, edge.targetHandle); if (!first || !second) { throw new Error(`连接 ${edge.id} 引用了不存在的组件端口`); } if (!arePortsCompatible(first, second)) { throw new Error(`连接 ${edge.id} 的端口类型或物理域不兼容`); } const endpoints: Array<{ component: string; port: string; role?: "source" | "target"; }> = [ { component: edge.source, port: first.name }, { component: edge.target, port: second.name }, ]; if (first.kind === "signal") { if (first.nominalRole === "input") { endpoints.reverse(); } endpoints[0].role = "source"; endpoints[1].role = "target"; } return { kind: first.kind, domain: first.domain, endpoints, }; } function findProjectPort( project: ReactFlowProjectPayload, componentId: string, portName: string | null, ) { if (!portName) { return undefined; } return project.nodes .find((node) => node.id === componentId) ?.data.ports.find((port) => port.name === portName); } function indentXmlElement(element: Element, depth = 0) { const children = Array.from(element.children); if (children.length === 0) { return; } children.forEach((child) => { element.insertBefore( element.ownerDocument.createTextNode(`\n${" ".repeat(depth + 1)}`), child, ); indentXmlElement(child, depth + 1); }); element.appendChild( element.ownerDocument.createTextNode(`\n${" ".repeat(depth)}`), ); } class SimulationStreamError extends Error { readonly issues: string[]; readonly status?: number; readonly code?: "STREAM_IDLE_TIMEOUT" | "SOLVER_STALLED"; constructor( message: string, issues: string[] = [], status?: number, code?: "STREAM_IDLE_TIMEOUT" | "SOLVER_STALLED", ) { super(message); this.name = "SimulationStreamError"; this.issues = issues; this.status = status; this.code = code; } } async function readSimulationStreamChunk( reader: ReadableStreamDefaultReader, ): Promise> { let timeoutId: number | undefined; const inactivityTimeout = new Promise((_, reject) => { timeoutId = window.setTimeout(() => { reject( new SimulationStreamError( "超过 30 秒未收到仿真进度,进度连接可能已经中断,请刷新页面后重试", [], undefined, "STREAM_IDLE_TIMEOUT", ), ); }, SIMULATION_STREAM_IDLE_TIMEOUT_MS); }); try { return await Promise.race([reader.read(), inactivityTimeout]); } finally { if (timeoutId !== undefined) { window.clearTimeout(timeoutId); } } } async function streamSystemSimulation( xml: string, onEvent: (event: SimulationStreamEvent) => void, simulationId: string, ): Promise { const abortController = new AbortController(); const response = await fetch("/api/system-xml/simulate-stream", { method: "POST", headers: { "Content-Type": "application/xml", "X-Simulation-Id": simulationId, }, body: xml, signal: abortController.signal, }); if (!response.ok) { if (response.status === 404) { throw new SimulationStreamError( "当前 FastAPI 未加载流式仿真接口,请重启后端服务", [], response.status, ); } if (response.status === 502 || response.status === 503) { throw new SimulationStreamError( "无法连接 FastAPI 仿真服务,请确认 8000 端口已启动", [], response.status, ); } throw new SimulationStreamError(await readApiError(response), [], response.status); } if (!response.body) { throw new SimulationStreamError("浏览器未收到仿真进度数据流"); } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; let result: SimulationResult | null = null; let lastSolverProgressAt = Date.now(); const consumeLine = (line: string) => { const trimmed = line.trim(); if (!trimmed) { return; } const parsed = JSON.parse(trimmed) as Partial; if ( parsed.event !== "progress" && parsed.event !== "result" && parsed.event !== "error" ) { throw new SimulationStreamError("仿真服务返回了无法识别的进度消息"); } const event = parsed as SimulationStreamEvent; if (event.event === "progress") { if ( event.heartbeat === true && Date.now() - lastSolverProgressAt >= SIMULATION_SOLVER_STALL_TIMEOUT_MS ) { throw new SimulationStreamError( "求解器连续 60 秒没有接受新的积分步,任务可能已经卡死", [], undefined, "SOLVER_STALLED", ); } if (event.heartbeat !== true) { lastSolverProgressAt = Date.now(); } } onEvent(event); if (event.event === "result") { result = event.result; return; } if (event.event === "error") { const detail = event.detail; const issues = detail && typeof detail === "object" && Array.isArray((detail as { issues?: unknown }).issues) ? ((detail as { issues: SimulationStreamIssue[] }).issues ?? []) .map((issue) => { if (typeof issue.message !== "string") { return ""; } return typeof issue.code === "string" ? `${issue.code}:${issue.message}` : issue.message; }) .filter(Boolean) : typeof detail === "string" && detail !== event.message ? [detail] : []; throw new SimulationStreamError(event.message, issues, event.status); } }; try { while (true) { const { done, value } = await readSimulationStreamChunk(reader); buffer += decoder.decode(value, { stream: !done }); let newlineIndex = buffer.indexOf("\n"); while (newlineIndex >= 0) { consumeLine(buffer.slice(0, newlineIndex)); buffer = buffer.slice(newlineIndex + 1); newlineIndex = buffer.indexOf("\n"); } if (done) { consumeLine(buffer); break; } } } finally { abortController.abort(); try { await reader.cancel(); } catch { // The stream may already be closed or aborted. } reader.releaseLock(); } if (!result) { throw new SimulationStreamError("仿真进度流已结束,但没有返回结果"); } return result; } function createSimulationId() { if (typeof crypto.randomUUID === "function") { return crypto.randomUUID(); } return `simulation-${Date.now()}-${Math.random().toString(16).slice(2)}`; } async function requestSimulationCancellation( simulationId: string, reason: SimulationCancelReason, ) { const response = await fetch( `/api/system-xml/simulations/${encodeURIComponent(simulationId)}/cancel`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ reason }), }, ); if (!response.ok) { throw new SimulationStreamError(await readApiError(response), [], response.status); } return (await response.json()) as { simulationId: string; accepted: boolean; status: SimulationTaskResponse["status"]; }; } async function readSimulationTask(simulationId: string) { const response = await fetch( `/api/system-xml/simulations/${encodeURIComponent(simulationId)}`, ); if (!response.ok) { throw new SimulationStreamError(await readApiError(response), [], response.status); } return (await response.json()) as SimulationTaskResponse; } async function waitForSimulationTaskCompletion( simulationId: string, timeoutMs = 30_000, ) { const deadline = Date.now() + timeoutMs; while (true) { const task = await readSimulationTask(simulationId); if (task.result || task.error || ["completed", "stopped", "stalled", "failed"].includes(task.status)) { return task; } if (Date.now() >= deadline) { throw new SimulationStreamError( "停止请求已发送,但后端未能在 30 秒内整理出部分结果", ); } await new Promise((resolve) => window.setTimeout(resolve, 500)); } } async function postXml(url: string, xml: string): Promise { const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/xml" }, body: xml, }); if (!response.ok) { throw new Error(await readApiError(response)); } return (await response.json()) as TResponse; } async function readApiError(response: Response) { const text = await response.text(); if (!text) { return `${response.status} ${response.statusText}`; } try { const parsed = JSON.parse(text) as { detail?: unknown }; if (typeof parsed.detail === "string") { return parsed.detail; } if (parsed.detail && typeof parsed.detail === "object") { const detail = parsed.detail as { message?: unknown; issues?: Array<{ code?: unknown; message?: unknown }>; }; const messages = Array.isArray(detail.issues) ? detail.issues .map((issue) => typeof issue.message === "string" ? `${typeof issue.code === "string" ? `${issue.code}:` : ""}${issue.message}` : "", ) .filter(Boolean) : []; if (messages.length > 0) { return messages.join(";"); } if (typeof detail.message === "string") { return detail.message; } } } catch { return text; } return text; } function downloadText(filename: string, content: string) { downloadFile(filename, content, "application/xml;charset=utf-8"); } function downloadFile(filename: string, content: string, mimeType: string) { const blob = new Blob([content], { type: mimeType }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = filename; document.body.appendChild(link); link.click(); link.remove(); URL.revokeObjectURL(url); } function safeFilename(value: string) { return (value.trim() || "system").replace(/[\\/:*?"<>|]+/g, "_"); } function formatError(error: unknown) { if (error instanceof Error) { return error.message; } return String(error); } function formatNumber(value: number) { if (!Number.isFinite(value)) { return "-"; } if (Math.abs(value) >= 100000 || (Math.abs(value) > 0 && Math.abs(value) < 0.001)) { return value.toExponential(4); } return value.toFixed(4).replace(/\.?0+$/, ""); } export default function App() { return ( ); }