import { ChangeEvent, DragEvent, memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { createPortal } from "react-dom"; import type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent, ReactNode, } from "react"; import { ChevronDown, ChevronUp, ClipboardPaste, Copy, Download, FileCode2, FileDown, FileUp, FlipHorizontal2, FolderOpen, Grid2X2, Grip, ListChecks, Magnet, Maximize2, Minimize2, Move, Play, Redo2, RotateCw, Save, Square, SquareTerminal, Trash2, Undo2, type LucideIcon, } from "lucide-react"; import { ConnectionLineType, ConnectionMode, Handle, Panel, PanOnScrollMode, Position, ReactFlow, ReactFlowProvider, ViewportPortal, SelectionMode, addEdge, applyNodeChanges, type Connection, type Edge, type EdgeChange, type EdgeMouseHandler, type Node as ReactFlowNode, type NodeChange, type NodeMouseHandler, type NodeProps, type NodeTypes, type OnConnectStart, type OnNodeDrag, type OnSelectionChangeFunc, type Viewport, useEdgesState, useNodesState, useReactFlow, useStore, useStoreApi, useUpdateNodeInternals, } from "@xyflow/react"; import { CANVAS_GRID_SIZE, CanvasGridBackground, type CanvasGridVisibility, } from "./CanvasGridBackground"; import { EmptySimulationResultsView, SimulationResultsView, type ResultVariableMetadata, type SimulationResultsSnapshot, } from "./SimulationResultsView"; import { AutoFitView } from "./AutoFitView"; import { DockedSimulationConsole } from "./DockedSimulationConsole"; import { ComponentSymbol, componentSymbolLayout, componentSymbolPresentation, componentSymbolVariantKey, hasComponentSymbol, } from "./ComponentSymbol"; import type { ComponentSymbolLayout, ComponentSymbolPortAnchor, ComponentSymbolSize, ComponentSymbolViewBox, } from "./componentSymbols/types"; import { LMECHN1_MAX_RIGHT_PORT_COUNT, lmechn1RightPortCount, } from "./componentSymbols/mechanical"; import { SIMULATION_ACTIVITY_STALL_TIMEOUT_MS, SIMULATION_ACTIVITY_STALL_TIMEOUT_SECONDS, SIMULATION_SOLVER_STALL_TIMEOUT_MINUTES, SIMULATION_SOLVER_STALL_TIMEOUT_MS, solverActivityStallTimeoutMessage, solverStallTimeoutMessage, } from "./simulationTimeout"; import { CONTACT_AWARE_EDGE_TYPE, contactAwareEdgeTypes, } from "./ContactAwareEdge"; import { buildOrthogonalRoutePoints, detectEdgeCrossingJumps, edgePointIsFinite, orthogonalEdgePoints, orthogonalPolylinePath, type EdgeRouteData, type EdgeRoutePoint, } from "./edgeRouting"; 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 PortConnectionDraft = { pointer: EdgeRoutePoint | null; waypoints: EdgeRoutePoint[]; }; type ContactSnapMatch = { connection: Connection; draggedNodeId: string; draggedPortName: string; targetNodeId: string; targetPortName: string; }; type ContactSnapCandidate = { matches: ContactSnapMatch[]; positionDelta: { x: number; y: number }; }; type ContactDragSession = { contactEdgeIds: Set; disconnected: boolean; draggedNodeIds: Set; effectiveEdges: SimulationEdge[]; lastCandidate: ContactSnapCandidate | null; lastEvaluatedPosition: { x: number; y: number } | null; lastSnapRadius: number; multipleNodes: boolean; nodeId: string; snapSearchContext: ContactSnapSearchContext; 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" | "error"; class ComponentCatalogLibraryError extends Error { readonly libraryName: string; constructor(libraryName: string, message: string) { super(message); this.name = "ComponentCatalogLibraryError"; this.libraryName = libraryName; } } type SimulationNodeData = { label: string; componentType: string; modelType: string; modelVersion?: string; modelContractIssues?: string[]; symbol?: string; ports: PortDefinition[]; parameters: Record; parameterUnits: Record; parameterScientificNotation: Record; rotation: NodeRotation; mirrored: boolean; portConnectionStates?: Record; hideConnectedPortIndexLabels?: boolean; }; type SimulationNode = ReactFlowNode; type SimulationEdge = Edge; type ComponentPortPresentationData = Pick< SimulationNodeData, "componentType" | "modelType" | "ports" | "parameters" >; function isLmechn1NodeData(data: ComponentPortPresentationData) { return ( data.componentType === "amesim_lmechn1" || data.modelType === "amesim_lmechn1" ); } /** * LMECHN1 的目录合同始终保留 21 个端口;工作区只展示当前 N 个右侧端口 * 和编号为 N+1 的左侧参考端口。 */ function displayedPortsForNodeData(data: ComponentPortPresentationData) { if (!isLmechn1NodeData(data)) { return data.ports; } const rightPortCount = lmechn1RightPortCount(data.parameters); const portsByName = new Map(data.ports.map((port) => [port.name, port])); const referencePort = portsByName.get(`port_${rightPortCount + 1}`); const rightPorts = Array.from({ length: rightPortCount }, (_, index) => portsByName.get(`port_${index + 1}`), ).filter((port): port is PortDefinition => Boolean(port)); return [ ...(referencePort ? [{ ...referencePort, side: "left" as const }] : []), ...rightPorts.map((port) => ({ ...port, side: "right" as const })), ]; } 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, "modelContractIssues" | "parameterUnits" | "parameterScientificNotation" > & { parameterUnits?: Record; parameterScientificNotation?: Record; }; }; type ProjectEdgePayload = { id: string; source: string; target: string; sourceHandle: string; targetHandle: string; data: { isContactEdge: boolean; routePoints?: EdgeRoutePoint[]; }; }; type ReactFlowProjectPayload = { projectSchemaVersion: 1; name: string; 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; signature: string; simulationConfig: SimulationConfig; }; type EditorClipboard = { nodes: SimulationNode[]; edges: SimulationEdge[]; }; type PendingPaste = { anchor: { x: number; y: number }; edges: SimulationEdge[]; nodes: SimulationNode[]; repairedReferenceCount: number; snapOffset: { x: number; y: number }; }; type PendingPasteMiddlePointer = { pointerId: number; startX: number; startY: number; moved: boolean; }; type RenderedNodeCacheEntry = { presentationContext: object; presentationSignature: string; renderedNode: SimulationNode; sourceNode: SimulationNode; }; type CachedNodePortGeometry = { mirrored: boolean; placementByPort: Map; positionX: number; positionY: number; rotation: number; sourceData: SimulationNode["data"]; }; type RenderedEdgeCacheEntry = { presentationSignature: string; renderedEdge: SimulationEdge; sourceEdge: 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 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_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 GRID_SIZE = CANVAS_GRID_SIZE; const MODELING_DEFAULT_EDGE_OPTIONS = { interactionWidth: 28, type: CONTACT_AWARE_EDGE_TYPE, }; const MODELING_DELETE_KEYS = ["Backspace", "Delete"]; const MODELING_PAN_ON_DRAG = [1]; const MODELING_PRO_OPTIONS = { hideAttribution: true }; const MODELING_SNAP_GRID: [number, number] = [GRID_SIZE, GRID_SIZE]; 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 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; // 接触吸附同时保留最小屏幕半径与最小画布半径: // 缩小时不会缩成难以命中的像素范围,放大时也不会因换算后半径过小而难以对接。 const CONTACT_SNAP_RADIUS_PX = 10; const CONTACT_SNAP_RADIUS_FLOW = 10; const CONTACT_DISCONNECT_DISTANCE_PX = 8; const CONTACT_GEOMETRY_TOLERANCE = 0.5; const CONTACT_DELETE_SEPARATION = 16; // 功能 4 审阅开关:关闭即可恢复旧浮动控制台,且不影响复制、粘贴与画布操作。 const USE_DOCKED_SIMULATION_CONSOLE = true; function contactSnapRadiusForZoom(zoom: number) { const safeZoom = Math.max(zoom, 0.1); return Math.max(CONTACT_SNAP_RADIUS_FLOW, CONTACT_SNAP_RADIUS_PX / safeZoom); } 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)), }; } const SYMBOL_ENVELOPE_CACHE_LIMIT = 512; const symbolEnvelopeCache = new Map(); function cacheSymbolEnvelope(key: string, envelope: SymbolEnvelope) { if (!symbolEnvelopeCache.has(key) && symbolEnvelopeCache.size >= SYMBOL_ENVELOPE_CACHE_LIMIT) { const oldestKey = symbolEnvelopeCache.keys().next().value; if (typeof oldestKey === "string") { symbolEnvelopeCache.delete(oldestKey); } } symbolEnvelopeCache.set(key, envelope); } 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 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 SimulationActivityTelemetry = { activitySequence?: number; activityKind?: string; currentTrialTime?: number | null; rhsCallCount?: number; acceptedStepSequence?: number; acceptedTime?: number | null; }; export type SimulationActivityWatchdogStatus = | "progressing" | "slow-active" | "slow-telemetry-unavailable" | "stalled"; export type SimulationActivityWatchdogState = { lastAcceptedProgressAt: number; lastActivityChangeAt: number | null; lastActivitySequence: number | null; lastAcceptedStepSequence: number | null; lastAcceptedTime: number | null; integratingPhaseActive: boolean; }; export type SimulationActivityWatchdogObservation = { status: SimulationActivityWatchdogStatus; acceptedProgressIdleMs: number; activityIdleMs: number | null; activityTelemetryAvailable: boolean; }; 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; } & SimulationActivityTelemetry) | { 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; }; export function createSimulationActivityWatchdog( now = Date.now(), ): SimulationActivityWatchdogState { return { lastAcceptedProgressAt: now, lastActivityChangeAt: null, lastActivitySequence: null, lastAcceptedStepSequence: null, lastAcceptedTime: null, integratingPhaseActive: false, }; } export function isSimulationSolverActivityPhase(phase: string): boolean { return phase === "integrating"; } export function observeSimulationActivityWatchdog( current: SimulationActivityWatchdogState, event: SimulationActivityTelemetry & { heartbeat?: boolean; phase?: string }, now = Date.now(), stallTimeoutMs = SIMULATION_ACTIVITY_STALL_TIMEOUT_MS, legacyStallTimeoutMs = SIMULATION_SOLVER_STALL_TIMEOUT_MS, ): { state: SimulationActivityWatchdogState; observation: SimulationActivityWatchdogObservation; } { const reportsPhase = typeof event.phase === "string"; if (reportsPhase && !isSimulationSolverActivityPhase(event.phase!)) { return { state: createSimulationActivityWatchdog(now), observation: { status: "progressing", acceptedProgressIdleMs: 0, activityIdleMs: null, activityTelemetryAvailable: false, }, }; } const baseline = reportsPhase && !current.integratingPhaseActive ? { ...createSimulationActivityWatchdog(now), integratingPhaseActive: true, } : current; let acceptedProgress = event.heartbeat !== true; let lastAcceptedStepSequence = baseline.lastAcceptedStepSequence; let lastAcceptedTime = baseline.lastAcceptedTime; if ( typeof event.acceptedStepSequence === "number" && Number.isFinite(event.acceptedStepSequence) ) { if ( lastAcceptedStepSequence !== null && event.acceptedStepSequence !== lastAcceptedStepSequence ) { acceptedProgress = true; } lastAcceptedStepSequence = event.acceptedStepSequence; } if (typeof event.acceptedTime === "number" && Number.isFinite(event.acceptedTime)) { if (lastAcceptedTime !== null && event.acceptedTime !== lastAcceptedTime) { acceptedProgress = true; } lastAcceptedTime = event.acceptedTime; } let lastActivityChangeAt = baseline.lastActivityChangeAt; let lastActivitySequence = baseline.lastActivitySequence; const reportsActivitySequence = typeof event.activitySequence === "number" && Number.isFinite(event.activitySequence); if (reportsActivitySequence) { if ( lastActivitySequence === null || event.activitySequence !== lastActivitySequence ) { lastActivityChangeAt = now; } lastActivitySequence = event.activitySequence!; } const lastAcceptedProgressAt = acceptedProgress ? now : baseline.lastAcceptedProgressAt; const acceptedProgressIdleMs = Math.max(0, now - lastAcceptedProgressAt); const activityIdleMs = lastActivityChangeAt === null ? null : Math.max(0, now - lastActivityChangeAt); const effectiveStallTimeoutMs = Math.max(0, stallTimeoutMs); const effectiveLegacyStallTimeoutMs = Math.max( effectiveStallTimeoutMs, legacyStallTimeoutMs, ); let status: SimulationActivityWatchdogStatus = "progressing"; if (acceptedProgressIdleMs >= effectiveStallTimeoutMs) { if (!reportsActivitySequence || activityIdleMs === null) { status = acceptedProgressIdleMs >= effectiveLegacyStallTimeoutMs ? "stalled" : "slow-telemetry-unavailable"; } else if (activityIdleMs >= effectiveStallTimeoutMs) { status = "stalled"; } else { status = "slow-active"; } } return { state: { lastAcceptedProgressAt, lastActivityChangeAt, lastActivitySequence, lastAcceptedStepSequence, lastAcceptedTime, integratingPhaseActive: reportsPhase || baseline.integratingPhaseActive, }, observation: { status, acceptedProgressIdleMs, activityIdleMs, activityTelemetryAvailable: reportsActivitySequence, }, }; } const SUPPORTED_SOLVER_METHODS = ["BDF", "Radau", "LSODA", "RK45", "RK23", "DOP853"]; const simulationConfigLabels: Record = { t_start: "起始时间", t_stop: "结束时间", step: "采样步长", max_step: "最大积分步长", }; 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, libraryIndex) => { const libraryName = typeof library?.label === "string" && library.label.trim() ? library.label.trim() : typeof library?.id === "string" && library.id.trim() ? library.id.trim() : `第 ${libraryIndex + 1} 个组件库`; if ( !library || typeof library.id !== "string" || typeof library.label !== "string" || typeof library.version !== "string" || !Array.isArray(library.components) ) { throw new ComponentCatalogLibraryError( libraryName, `组件库 ${libraryName} 声明不完整`, ); } const components = library.components.map((component) => { if ( !component || 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 ComponentCatalogLibraryError( libraryName, `组件库 ${libraryName} 包含无效元件`, ); } if (componentTypes.has(component.type)) { throw new ComponentCatalogLibraryError( libraryName, `组件库 ${libraryName} 包含重复组件类型:${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, modelVersion: definition.modelVersion, 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 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 ( node.data.modelContractIssues?.length || !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, }; } type SimulationComponentNodeProps = NodeProps; function SimulationComponentNodeImpl({ id, data, isConnectable, selected, }: SimulationComponentNodeProps) { const updateNodeInternals = useUpdateNodeInternals(); const viewportZoom = useStore((flowState) => flowState.transform[2]); 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 = useMemo( () => componentSymbolLayout(symbol, data.parameters), [data.parameters, symbol], ); const renderedSize = nodeFrameDimensions(layout, rotation); const layoutSignature = useMemo( () => `${layout.viewBox.x}:${layout.viewBox.y}:${layout.viewBox.width}:${layout.viewBox.height}:${layout.symbolSize.width}:${layout.symbolSize.height}:${layout.nodeSize.width}:${layout.nodeSize.height}:${JSON.stringify(layout.portAnchors ?? {})}`, [layout], ); const symbolVariantKey = useMemo( () => componentSymbolVariantKey(symbol, data.parameters), [data.parameters, symbol], ); const symbolEnvelopeCacheKey = `${symbol}|${symbolVariantKey}|${layoutSignature}`; const nodeInternalsGeometryKey = `${layoutSignature}|${rotation}|${mirrored ? 1 : 0}`; const previousNodeInternalsGeometryKeyRef = useRef(nodeInternalsGeometryKey); const [symbolEnvelope, setSymbolEnvelope] = useState( () => symbolEnvelopeCache.get(symbolEnvelopeCacheKey) ?? defaultSymbolEnvelope(layout), ); const displayedPorts = useMemo( () => displayedPortsForNodeData(data), [data.componentType, data.modelType, data.parameters, data.ports], ); const portPlacements = useMemo( () => transformedPortPlacements( displayedPorts, rotation, mirrored, layout, ), [displayedPorts, layout, mirrored, rotation], ); const visiblePortIndexPlacements = data.hideConnectedPortIndexLabels ? portPlacements.filter( ({ port }) => data.portConnectionStates?.[port.name] !== "connected", ) : portPlacements; const portIndexLabels = selected ? portIndexLabelPlacements( displayedPorts, visiblePortIndexPlacements, layout, rotation, ) : []; const inverseViewportScale = viewportZoom > 0 ? 1 / viewportZoom : 1; 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 cachedEnvelope = symbolEnvelopeCache.get(symbolEnvelopeCacheKey); if (cachedEnvelope) { setSymbolEnvelope(cachedEnvelope); return; } const svg = symbolContainerRef.current?.querySelector( ".component-symbol-svg", ); if (!svg) { return; } const nextEnvelope = measuredSymbolEnvelope(svg, layout); cacheSymbolEnvelope(symbolEnvelopeCacheKey, nextEnvelope); setSymbolEnvelope((currentEnvelope) => currentEnvelope.left === nextEnvelope.left && currentEnvelope.top === nextEnvelope.top && currentEnvelope.width === nextEnvelope.width && currentEnvelope.height === nextEnvelope.height ? currentEnvelope : nextEnvelope, ); }, [hasDedicatedSymbol, layout, symbolEnvelopeCacheKey]); useEffect(() => { if (previousNodeInternalsGeometryKeyRef.current === nodeInternalsGeometryKey) { return; } previousNodeInternalsGeometryKeyRef.current = nodeInternalsGeometryKey; updateNodeInternals(id); }, [id, nodeInternalsGeometryKey, updateNodeInternals]); return (
{portPlacements.map(({ port, position, x, y }) => { const connectionState = data.portConnectionStates?.[port.name] ?? "idle"; const isConnected = connectionState === "connected"; return ( ); })} {hasDedicatedSymbol ? ( ); } const SimulationComponentNode = memo( SimulationComponentNodeImpl, ( previous: SimulationComponentNodeProps, next: SimulationComponentNodeProps, ) => previous.id === next.id && previous.data === next.data && previous.isConnectable === next.isConnectable && previous.selected === next.selected, ); SimulationComponentNode.displayName = "SimulationComponentNode"; 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, node.data.parameters); 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( displayedPortsForNodeData(node.data), 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, node.data.parameters), ); } function nodePortFlowPlacement(node: SimulationNode, portName: string | null) { if (!portName) { return undefined; } return nodePortFlowPlacements(node).find( (placement) => placement.port.name === portName, ); } function resultNodePortFlowPlacement( node: SimulationResultsSnapshot["project"]["nodes"][number], portName: string | null, ) { return nodePortFlowPlacement(node as unknown as SimulationNode, portName); } 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; draggedNodeOrder: number; draggedPortOrder: number; targetNodeOrder: number; targetPortOrder: number; }; type ContactSnapPlacement = ReturnType[number]; type ContactSnapIndexedTarget = { key: string; placement: ContactSnapPlacement; targetNode: SimulationNode; targetNodeOrder: number; targetPortOrder: number; }; type ContactSnapSearchContext = { buckets: Map; cellSize: number; draggedNodeIdsKey: string; occupiedPorts: Set; targetByPortKey: Map; }; function contactSnapDraggedNodeIdsKey(nodeIds: ReadonlySet) { return [...nodeIds].sort().join("\u001f"); } function contactSnapCell(value: number, cellSize: number) { return Math.floor(value / cellSize); } function contactSnapBucketKey(cellX: number, cellY: number) { return `${cellX}:${cellY}`; } function contactSnapTargetsNear( context: ContactSnapSearchContext, flowX: number, flowY: number, radius: number, ) { const minCellX = contactSnapCell(flowX - radius, context.cellSize); const maxCellX = contactSnapCell(flowX + radius, context.cellSize); const minCellY = contactSnapCell(flowY - radius, context.cellSize); const maxCellY = contactSnapCell(flowY + radius, context.cellSize); const targets: ContactSnapIndexedTarget[] = []; for (let cellX = minCellX; cellX <= maxCellX; cellX += 1) { for (let cellY = minCellY; cellY <= maxCellY; cellY += 1) { targets.push( ...(context.buckets.get(contactSnapBucketKey(cellX, cellY)) ?? []), ); } } return targets; } function compareContactSnapPairs( first: ContactSnapPair, second: ContactSnapPair, ) { return ( first.distance - second.distance || first.draggedNodeOrder - second.draggedNodeOrder || first.draggedPortOrder - second.draggedPortOrder || first.targetNodeOrder - second.targetNodeOrder || first.targetPortOrder - second.targetPortOrder || first.draggedNodeId.localeCompare(second.draggedNodeId) || first.draggedPortName.localeCompare(second.draggedPortName) || first.targetNodeId.localeCompare(second.targetNodeId) || first.targetPortName.localeCompare(second.targetPortName) ); } function buildContactSnapSearchContext( draggedNodeIds: ReadonlySet, nodes: SimulationNode[], edges: SimulationEdge[], ): ContactSnapSearchContext { 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 cellSize = CONTACT_SNAP_RADIUS_FLOW; const buckets = new Map(); const targetByPortKey = new Map(); nodes.forEach((targetNode, targetNodeOrder) => { if (draggedNodeIds.has(targetNode.id)) { return; } const targetPortOrder = new Map( targetNode.data.ports.map((port, index) => [port.name, index] as const), ); nodePortFlowPlacements(targetNode).forEach((placement) => { const key = portConnectionKey(targetNode.id, placement.port.name); if (occupiedPorts.has(key)) { return; } const indexedTarget: ContactSnapIndexedTarget = { key, placement, targetNode, targetNodeOrder, targetPortOrder: targetPortOrder.get(placement.port.name) ?? Number.MAX_SAFE_INTEGER, }; const bucketKey = contactSnapBucketKey( contactSnapCell(placement.flowX, cellSize), contactSnapCell(placement.flowY, cellSize), ); const bucket = buckets.get(bucketKey); if (bucket) { bucket.push(indexedTarget); } else { buckets.set(bucketKey, [indexedTarget]); } targetByPortKey.set(key, indexedTarget); }); }); return { buckets, cellSize, draggedNodeIdsKey: contactSnapDraggedNodeIdsKey(draggedNodeIds), occupiedPorts, targetByPortKey, }; } function availableContactSnapPairs( draggedNodes: SimulationNode[], nodes: SimulationNode[], edges: SimulationEdge[], maximumDistance: number, searchContext?: ContactSnapSearchContext, ) { const draggedNodeIds = new Set(draggedNodes.map((node) => node.id)); const draggedNodeIdsKey = contactSnapDraggedNodeIdsKey(draggedNodeIds); const resolvedContext = searchContext?.draggedNodeIdsKey === draggedNodeIdsKey ? searchContext : buildContactSnapSearchContext(draggedNodeIds, nodes, edges); const { occupiedPorts } = resolvedContext; const pairs: ContactSnapPair[] = []; const maximumDistanceSquared = maximumDistance * maximumDistance; draggedNodes.forEach((draggedNode, draggedNodeOrder) => { const draggedPortOrder = new Map( draggedNode.data.ports.map((port, index) => [port.name, index] as const), ); const draggedPlacements = nodePortFlowPlacements(draggedNode).filter( (placement) => !occupiedPorts.has( portConnectionKey(draggedNode.id, placement.port.name), ), ); for (const draggedPlacement of draggedPlacements) { for (const indexedTarget of contactSnapTargetsNear( resolvedContext, draggedPlacement.flowX, draggedPlacement.flowY, maximumDistance, )) { const { placement: targetPlacement, targetNode } = indexedTarget; if (!arePortsCompatible(draggedPlacement.port, targetPlacement.port)) { continue; } const deltaX = targetPlacement.flowX - draggedPlacement.flowX; const deltaY = targetPlacement.flowY - draggedPlacement.flowY; const distanceSquared = deltaX * deltaX + deltaY * deltaY; if (distanceSquared > maximumDistanceSquared) { continue; } pairs.push({ connection: connectionForContactPorts( draggedNode, draggedPlacement.port, targetNode, targetPlacement.port, ), distance: Math.sqrt(distanceSquared), draggedNodeId: draggedNode.id, draggedNodeOrder, draggedPortName: draggedPlacement.port.name, draggedPortOrder: draggedPortOrder.get(draggedPlacement.port.name) ?? Number.MAX_SAFE_INTEGER, targetNodeId: targetNode.id, targetNodeOrder: indexedTarget.targetNodeOrder, targetPortOrder: indexedTarget.targetPortOrder, targetPortName: targetPlacement.port.name, }); } } }); return pairs.sort(compareContactSnapPairs); } function maximumContactSnapMatches(pairs: ContactSnapPair[]) { // 二分图最大匹配的两侧都使用完整 nodeId:portName,避免块内多个 // 组件拥有同名 port_1 时互相覆盖。 const pairsByDraggedPort = new Map(); pairs.forEach((pair) => { const key = portConnectionKey(pair.draggedNodeId, pair.draggedPortName); const current = pairsByDraggedPort.get(key) ?? []; current.push(pair); pairsByDraggedPort.set(key, current); }); const pairByTargetPort = new Map(); const assignDraggedPort = ( draggedPortKey: string, visitedTargetPorts: Set, ): boolean => { for (const pair of pairsByDraggedPort.get(draggedPortKey) ?? []) { const targetKey = portConnectionKey(pair.targetNodeId, pair.targetPortName); if (visitedTargetPorts.has(targetKey)) { continue; } visitedTargetPorts.add(targetKey); const currentPair = pairByTargetPort.get(targetKey); const currentDraggedKey = currentPair ? portConnectionKey( currentPair.draggedNodeId, currentPair.draggedPortName, ) : null; if ( currentDraggedKey && !assignDraggedPort(currentDraggedKey, visitedTargetPorts) ) { continue; } pairByTargetPort.set(targetKey, pair); return true; } return false; }; for (const draggedPortKey of pairsByDraggedPort.keys()) { assignDraggedPort(draggedPortKey, new Set()); } return [...pairByTargetPort.values()].sort(compareContactSnapPairs); } function findContactSnapCandidate( draggedNodes: SimulationNode[], nodes: SimulationNode[], edges: SimulationEdge[], maximumDistance: number, searchContext?: ContactSnapSearchContext, ) { if (draggedNodes.length === 0) { return null; } const draggedNodeIds = new Set(draggedNodes.map((node) => node.id)); const draggedNodeIdsKey = contactSnapDraggedNodeIdsKey(draggedNodeIds); const resolvedContext = searchContext?.draggedNodeIdsKey === draggedNodeIdsKey ? searchContext : buildContactSnapSearchContext(draggedNodeIds, nodes, edges); const nearbyPairs = availableContactSnapPairs( draggedNodes, nodes, edges, maximumDistance, resolvedContext, ); const anchor = nearbyPairs[0]; if (!anchor) { return null; } const draggedNode = draggedNodes.find( (node) => node.id === anchor.draggedNodeId, ); const draggedPlacement = draggedNode ? nodePortFlowPlacement(draggedNode, anchor.draggedPortName) : undefined; const targetPlacement = resolvedContext.targetByPortKey.get( portConnectionKey(anchor.targetNodeId, anchor.targetPortName), )?.placement; if (!draggedPlacement || !targetPlacement) { return null; } const positionDelta = { x: targetPlacement.flowX - draggedPlacement.flowX, y: targetPlacement.flowY - draggedPlacement.flowY, }; const snappedDraggedNodes = draggedNodes.map((node) => ({ ...node, position: { x: node.position.x + positionDelta.x, y: node.position.y + positionDelta.y, }, })); // 整个块只采用一个平移量;仅建立该统一落点下真正重合的端口。 const exactPairs = availableContactSnapPairs( snappedDraggedNodes, nodes, edges, CONTACT_GEOMETRY_TOLERANCE, resolvedContext, ); const matches = maximumContactSnapMatches(exactPairs).map( ({ connection, draggedNodeId, draggedPortName, targetNodeId, targetPortName, }) => ({ connection, draggedNodeId, draggedPortName, targetNodeId, targetPortName, }), ); return matches.length > 0 ? { matches, positionDelta } : null; } function sameContactSnapCandidate( first: ContactSnapCandidate | null, second: ContactSnapCandidate | null, ) { return ( first === second || (Boolean(first) && Boolean(second) && first?.positionDelta.x === second?.positionDelta.x && first?.positionDelta.y === second?.positionDelta.y && first?.matches.length === second?.matches.length && first?.matches.every((match, index) => { const other = second?.matches[index]; return Boolean( other && match.draggedNodeId === other.draggedNodeId && 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 routePointsForEdge(edge: SimulationEdge) { const routePoints = (edge.data as EdgeRouteData | undefined)?.routePoints; return Array.isArray(routePoints) && routePoints.every(edgePointIsFinite) ? routePoints : []; } function translateEdgeRoute( edge: SimulationEdge, deltaX: number, deltaY: number, ): SimulationEdge { const routePoints = routePointsForEdge(edge); if ( routePoints.length === 0 || (Math.abs(deltaX) <= 0.001 && Math.abs(deltaY) <= 0.001) ) { return edge; } return { ...edge, data: { ...edge.data, routePoints: routePoints.map((point) => ({ x: point.x + deltaX, y: point.y + deltaY, })), }, }; } function translateInternalEdgeRoutes( edges: SimulationEdge[], movedNodeIds: ReadonlySet, deltaX: number, deltaY: number, ) { let changed = false; const translated = edges.map((edge) => { if (!movedNodeIds.has(edge.source) || !movedNodeIds.has(edge.target)) { return edge; } const next = translateEdgeRoute(edge, deltaX, deltaY); changed ||= next !== edge; return next; }); return changed ? translated : edges; } type NodePortGeometryIndex = Map< string, Map >; function buildNodePortGeometryIndex(nodes: SimulationNode[]): NodePortGeometryIndex { return new Map( nodes.map((node) => [ node.id, new Map( nodePortFlowPlacements(node).map((placement) => [ placement.port.name, { flowX: placement.flowX, flowY: placement.flowY }, ] as const), ), ] as const), ); } function isContactEdgeGeometryInIndex( geometryIndex: NodePortGeometryIndex, edge: SimulationEdge, ) { const source = edge.sourceHandle ? geometryIndex.get(edge.source)?.get(edge.sourceHandle) : undefined; const target = edge.targetHandle ? geometryIndex.get(edge.target)?.get(edge.targetHandle) : undefined; 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, sourceNode.data.parameters, ), normalizeNodeRotation(sourceNode.data.rotation), ); const targetSize = nodeFrameDimensions( componentSymbolLayout( targetNode.data.symbol ?? targetNode.data.componentType, targetNode.data.parameters, ), 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 previousGeometry = buildNodePortGeometryIndex(previousNodes); const nextGeometry = buildNodePortGeometryIndex(nextNodes); const remainingEdges = edges.filter((edge) => { const affected = transformedNodeIds.has(edge.source) || transformedNodeIds.has(edge.target); const invalidated = affected && isContactEdgeGeometryInIndex(previousGeometry, edge) && !isContactEdgeGeometryInIndex(nextGeometry, 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, }; } return { percent: 0, simulatedTime: config.t_start, 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; } return undefined; } 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} /> ); } type FlowViewportWorldBounds = { minX: number; minY: number; maxX: number; maxY: number; }; const FLOW_VIEWPORT_DEFAULT_HALF_EXTENT = 2_000; const FLOW_VIEWPORT_NODE_MARGIN = 600; const FLOW_VIEWPORT_SCROLLBAR_CONTENT_LIMIT_PX = 8_000_000; function defaultFlowViewportWorldBounds(): FlowViewportWorldBounds { return { minX: -FLOW_VIEWPORT_DEFAULT_HALF_EXTENT, minY: -FLOW_VIEWPORT_DEFAULT_HALF_EXTENT, maxX: FLOW_VIEWPORT_DEFAULT_HALF_EXTENT, maxY: FLOW_VIEWPORT_DEFAULT_HALF_EXTENT, }; } function FlowViewportScrollbars({ deferBounds = false, nodes, }: { deferBounds?: boolean; nodes: SimulationNode[]; }) { const reactFlow = useReactFlow(); const transform = useStore((flowState) => flowState.transform); const canvasWidth = useStore((flowState) => flowState.width); const canvasHeight = useStore((flowState) => flowState.height); const horizontalRef = useRef(null); const verticalRef = useRef(null); const [scrollbarViewport, setScrollbarViewport] = useState({ horizontalWidth: 0, verticalHeight: 0, }); const [worldBounds, setWorldBounds] = useState( defaultFlowViewportWorldBounds, ); const nodeBoundsRef = useRef({ x: 0, y: 0, width: 0, height: 0 }); const nodeBounds = useMemo(() => { if (deferBounds) { return nodeBoundsRef.current; } return nodes.length > 0 ? reactFlow.getNodesBounds(nodes) : { x: 0, y: 0, width: 0, height: 0 }; }, [deferBounds, nodes, reactFlow]); useLayoutEffect(() => { if (!deferBounds) { nodeBoundsRef.current = nodeBounds; } }, [deferBounds, nodeBounds]); const zoom = Math.max(transform[2], 0.01); const visibleLeft = -transform[0] / zoom; const visibleTop = -transform[1] / zoom; const visibleWidth = canvasWidth / zoom; const visibleHeight = canvasHeight / zoom; useLayoutEffect(() => { if (nodes.length === 0) { setWorldBounds(defaultFlowViewportWorldBounds()); return; } setWorldBounds((current) => { const next = { minX: Math.min( current.minX, nodeBounds.x - FLOW_VIEWPORT_NODE_MARGIN, visibleLeft, ), minY: Math.min( current.minY, nodeBounds.y - FLOW_VIEWPORT_NODE_MARGIN, visibleTop, ), maxX: Math.max( current.maxX, nodeBounds.x + nodeBounds.width + FLOW_VIEWPORT_NODE_MARGIN, visibleLeft + visibleWidth, ), maxY: Math.max( current.maxY, nodeBounds.y + nodeBounds.height + FLOW_VIEWPORT_NODE_MARGIN, visibleTop + visibleHeight, ), }; return next.minX === current.minX && next.minY === current.minY && next.maxX === current.maxX && next.maxY === current.maxY ? current : next; }); }, [ nodeBounds.height, nodeBounds.width, nodeBounds.x, nodeBounds.y, nodes.length, visibleHeight, visibleLeft, visibleTop, visibleWidth, ]); useLayoutEffect(() => { const updateScrollbarViewport = () => { const next = { horizontalWidth: horizontalRef.current?.clientWidth ?? 0, verticalHeight: verticalRef.current?.clientHeight ?? 0, }; setScrollbarViewport((current) => current.horizontalWidth === next.horizontalWidth && current.verticalHeight === next.verticalHeight ? current : next, ); }; updateScrollbarViewport(); const resizeObserver = new ResizeObserver(updateScrollbarViewport); if (horizontalRef.current) { resizeObserver.observe(horizontalRef.current); } if (verticalRef.current) { resizeObserver.observe(verticalRef.current); } return () => resizeObserver.disconnect(); }, []); const horizontalScrollRange = Math.min( FLOW_VIEWPORT_SCROLLBAR_CONTENT_LIMIT_PX, Math.max(0, (worldBounds.maxX - worldBounds.minX) * zoom - canvasWidth), ); const verticalScrollRange = Math.min( FLOW_VIEWPORT_SCROLLBAR_CONTENT_LIMIT_PX, Math.max(0, (worldBounds.maxY - worldBounds.minY) * zoom - canvasHeight), ); const horizontalContentWidth = Math.max( scrollbarViewport.horizontalWidth, horizontalScrollRange + scrollbarViewport.horizontalWidth, ); const verticalContentHeight = Math.max( scrollbarViewport.verticalHeight, verticalScrollRange + scrollbarViewport.verticalHeight, ); const horizontalWorldRange = Math.max( 0, worldBounds.maxX - worldBounds.minX - visibleWidth, ); const verticalWorldRange = Math.max( 0, worldBounds.maxY - worldBounds.minY - visibleHeight, ); const expectedScrollLeft = Math.min( horizontalScrollRange, horizontalWorldRange > 0 ? ((visibleLeft - worldBounds.minX) / horizontalWorldRange) * horizontalScrollRange : 0, ); const expectedScrollTop = Math.min( verticalScrollRange, verticalWorldRange > 0 ? ((visibleTop - worldBounds.minY) / verticalWorldRange) * verticalScrollRange : 0, ); useLayoutEffect(() => { const horizontal = horizontalRef.current; const vertical = verticalRef.current; if (horizontal && Math.abs(horizontal.scrollLeft - expectedScrollLeft) > 0.5) { horizontal.scrollLeft = expectedScrollLeft; } if (vertical && Math.abs(vertical.scrollTop - expectedScrollTop) > 0.5) { vertical.scrollTop = expectedScrollTop; } }, [expectedScrollLeft, expectedScrollTop, horizontalContentWidth, verticalContentHeight]); const setHorizontalViewportFromScroll = (nextScrollLeft: number) => { const nextVisibleLeft = worldBounds.minX + (horizontalScrollRange > 0 ? (nextScrollLeft / horizontalScrollRange) * horizontalWorldRange : 0); void reactFlow.setViewport({ x: -nextVisibleLeft * zoom, y: transform[1], zoom, }); }; const setVerticalViewportFromScroll = (nextScrollTop: number) => { const nextVisibleTop = worldBounds.minY + (verticalScrollRange > 0 ? (nextScrollTop / verticalScrollRange) * verticalWorldRange : 0); void reactFlow.setViewport({ x: transform[0], y: -nextVisibleTop * zoom, zoom, }); }; return (
{ const nextScrollLeft = event.currentTarget.scrollLeft; if (Math.abs(nextScrollLeft - expectedScrollLeft) <= 0.5) { return; } setHorizontalViewportFromScroll(nextScrollLeft); }} onKeyDown={(event) => { const horizontal = event.currentTarget; const lineStep = 40; const pageStep = Math.max(40, horizontal.clientWidth * 0.8); const nextScrollLeft = event.key === "ArrowLeft" ? horizontal.scrollLeft - lineStep : event.key === "ArrowRight" ? horizontal.scrollLeft + lineStep : event.key === "PageUp" ? horizontal.scrollLeft - pageStep : event.key === "PageDown" ? horizontal.scrollLeft + pageStep : event.key === "Home" ? 0 : event.key === "End" ? horizontalScrollRange : null; if (nextScrollLeft === null) { return; } event.preventDefault(); horizontal.scrollLeft = Math.max( 0, Math.min(horizontalScrollRange, nextScrollLeft), ); }} ref={horizontalRef} role="scrollbar" tabIndex={0} >
{ const nextScrollTop = event.currentTarget.scrollTop; if (Math.abs(nextScrollTop - expectedScrollTop) <= 0.5) { return; } setVerticalViewportFromScroll(nextScrollTop); }} onKeyDown={(event) => { const vertical = event.currentTarget; const lineStep = 40; const pageStep = Math.max(40, vertical.clientHeight * 0.8); const nextScrollTop = event.key === "ArrowUp" ? vertical.scrollTop - lineStep : event.key === "ArrowDown" ? vertical.scrollTop + lineStep : event.key === "PageUp" ? vertical.scrollTop - pageStep : event.key === "PageDown" ? vertical.scrollTop + pageStep : event.key === "Home" ? 0 : event.key === "End" ? verticalScrollRange : null; if (nextScrollTop === null) { return; } event.preventDefault(); vertical.scrollTop = Math.max( 0, Math.min(verticalScrollRange, nextScrollTop), ); }} ref={verticalRef} role="scrollbar" tabIndex={0} >
); } function connectionDraftTargetPosition( source: EdgeRoutePoint, target: EdgeRoutePoint, ) { const deltaX = target.x - source.x; const deltaY = target.y - source.y; if (Math.abs(deltaX) >= Math.abs(deltaY)) { return deltaX >= 0 ? Position.Left : Position.Right; } return deltaY >= 0 ? Position.Top : Position.Bottom; } function ConnectionDraftOverlay({ activeConnection, draft, nodes, }: { activeConnection: ActivePortConnection | null; draft: PortConnectionDraft | null; nodes: SimulationNode[]; }) { if (!activeConnection || activeConnection.mode !== "click" || !draft?.pointer) { return null; } const source = nodes.find((node) => node.id === activeConnection.nodeId); const sourcePlacement = source ? nodePortFlowPlacement(source, activeConnection.portName) : undefined; if (!sourcePlacement) { return null; } const sourcePoint = { x: sourcePlacement.flowX, y: sourcePlacement.flowY, }; const routePoints = buildOrthogonalRoutePoints( sourcePoint, draft.pointer, sourcePlacement.position, connectionDraftTargetPosition(sourcePoint, draft.pointer), draft.waypoints, ); const points = [sourcePoint, ...routePoints, draft.pointer]; return ( ); } function PortGeometryRefresh({ nodeIds, requestKey, }: { nodeIds: string[]; requestKey: number; }) { const zoom = useStore((state) => state.transform[2]); const updateNodeInternals = useUpdateNodeInternals(); const lastRefreshKeyRef = useRef(""); useLayoutEffect(() => { const refreshKey = `${requestKey}:${zoom}:${nodeIds.join("\u001f")}`; if (lastRefreshKeyRef.current === refreshKey || nodeIds.length === 0) { return; } lastRefreshKeyRef.current = refreshKey; const frame = window.requestAnimationFrame(() => { updateNodeInternals(nodeIds); }); return () => window.cancelAnimationFrame(frame); }, [nodeIds, requestKey, updateNodeInternals, zoom]); return null; } function isVisibleComponentLibrary(library: ComponentLibraryDefinition) { return library.id !== "experimental"; } function FlowWorkbench() { const [componentLibraries, setComponentLibraries] = useState([]); const [componentCatalogStatus, setComponentCatalogStatus] = useState("loading"); const [componentCatalogMessage, setComponentCatalogMessage] = useState("正在加载后端组件目录"); const componentDefinitions = useMemo( () => componentLibraries.flatMap((library) => library.components), [componentLibraries], ); const defaultParametersByComponentType = useMemo( () => new Map( componentDefinitions.map((definition) => [ definition.type, defaultParameters(definition), ] as const), ), [componentDefinitions], ); const visibleComponentLibraries = useMemo( () => componentLibraries.filter(isVisibleComponentLibrary), [componentLibraries], ); const [nodes, setNodes] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); const [selectedNodeId, setSelectedNodeId] = useState(null); const [selectedEdgeId, setSelectedEdgeId] = useState(null); const [componentParameterDraftErrors, setComponentParameterDraftErrors] = useState>({}); const [activePortConnection, setActivePortConnection] = useState(null); const [portConnectionDraft, setPortConnectionDraft] = useState(null); const [contactSnapCandidate, setContactSnapCandidate] = useState(null); const [nodeDragActive, setNodeDragActive] = useState(false); 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 [pendingPaste, setPendingPaste] = useState(null); 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 [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 projectChangeSignatureRef = useRef(""); const validationSignatureRef = useRef(""); 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 = useMemo(() => { let selectedCount = 0; for (const node of nodes) { if (node.selected && (selectedCount += 1) > 1) { return true; } } return false; }, [nodes]); 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 pendingPasteRef = useRef(null); const portConnectionDraftRef = useRef(null); const modelingViewportRef = useRef({ x: 0, y: 0, zoom: 1 }); const lastFlowPointerRef = useRef<{ x: number; y: number } | null>(null); const pendingPasteMiddlePointerRef = useRef( null, ); const renderedNodeCacheRef = useRef>( new Map(), ); const nodePortGeometryCacheRef = useRef>( new Map(), ); const renderedEdgeCacheRef = useRef>( new Map(), ); const renderedEdgesArrayRef = useRef([]); const renderedEdgesSourceRef = useRef(null); const renderedEdgesSelectedIdRef = useRef(null); const amesimGasMediumOptionsCacheRef = useRef<{ definitions: ComponentDefinition[]; nodeDataById: Map; options: AmesimGasMediumOption[]; } | null>(null); const consoleEntryIdRef = useRef(0); const activeSimulationIdRef = useRef(null); 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; pendingPasteRef.current = pendingPaste; portConnectionDraftRef.current = portConnectionDraft; 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 }); portConnectionDraftRef.current = null; setPortConnectionDraft(null); setActivePortConnection(null); setNodeDragActive(false); 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 controlledSelectionNeedsCorrection = nodesRef.current.some( (node) => Boolean(node.selected) !== selectedNodeIds.has(node.id), ); const internalSelectionNeedsCorrection = Array.from( flowState.nodeLookup.values(), ).some( (node) => Boolean(node.selected) !== selectedNodeIds.has(node.id), ); if (internalSelectionNeedsCorrection) { // React Flow 的框选会在 onNodesChange 之前直接修改 nodeLookup;同步 // 覆盖该值,避免透明节点外框造成的错误虚线框先进入浏览器绘制。 for (const internalNode of flowState.nodeLookup.values()) { internalNode.selected = selectedNodeIds.has(internalNode.id); } } const nodesNeedCorrection = controlledSelectionNeedsCorrection || internalSelectionNeedsCorrection; 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 }); } tightSelectionRectRef.current = null; }); }, [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, }); tightSelectionRectRef.current = null; 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, }; // React Flow 会先按整个透明节点外框写入选择集,再发布当前框选矩形。 // 必须在同一事件周期按实际图标包络覆盖,避免误选状态先绘制一帧。 applyTightSelectionRect(tightSelectionRectRef.current); // 自动平移会先更新视口,再刷新 DOM transform;下一帧按最新矩形 // 复核一次,确保屏幕坐标包络与移动后的视图保持一致。 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(); 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], ); 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; } const visibleLibraries = libraries.filter(isVisibleComponentLibrary); if (visibleLibraries.length === 0) { throw new Error("后端目录中没有可显示的正式组件库"); } setComponentLibraries(libraries); setComponentCatalogStatus("ready"); setComponentCatalogMessage(`已加载 ${visibleLibraries.length} 个组件库`); } catch (error) { if (controller.signal.aborted) { return; } setComponentLibraries([]); setComponentCatalogStatus("error"); setComponentCatalogMessage( error instanceof ComponentCatalogLibraryError ? `加载失败的组件库:${error.libraryName}。详细原因:${error.message}` : `正式组件库目录加载失败(当前包括 AMESim 组件库);目录接口不可用,无法确认是否还有其他组件库受影响。详细原因:${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_COLLAPSED_SECTIONS_KEY, JSON.stringify([...collapsedPaletteSections]), ); } catch { // Section toggles remain available when browser storage is unavailable. } }, [collapsedPaletteSections]); useEffect(() => { saveParameterColumnLayout(parameterColumnLayout); }, [parameterColumnLayout]); 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 (pendingPasteRef.current) { appendConsoleEntry("info", "请先放置或按 Esc 取消待放置组件"); return; } 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); } }, [appendConsoleEntry, cancelPortConnection], ); const loadSimulationResultsSnapshot = useCallback( (snapshot: SimulationResultsSnapshot) => { setResultSnapshot(snapshot); storeResultSnapshot(snapshot); appendConsoleEntry( "success", `已加载结果文件:${snapshot.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 amesimGasMediumOptionsComputation = useMemo(() => { const cached = amesimGasMediumOptionsCacheRef.current; const canReuse = cached?.definitions === componentDefinitions && cached.nodeDataById.size === nodes.length && nodes.every((node) => cached.nodeDataById.get(node.id) === node.data); if (canReuse) { return cached; } return { definitions: componentDefinitions, nodeDataById: new Map( nodes.map((node) => [node.id, node.data] as const), ), options: buildAmesimGasMediumOptions(nodes, componentDefinitions), }; }, [componentDefinitions, nodes]); useLayoutEffect(() => { amesimGasMediumOptionsCacheRef.current = amesimGasMediumOptionsComputation; }, [amesimGasMediumOptionsComputation]); const amesimGasMediumOptions = amesimGasMediumOptionsComputation.options; const buildCurrentProject = useCallback( () => buildProjectPayload(projectName, nodes, edges, simulationConfig), [edges, nodes, projectName, simulationConfig], ); const projectChangeSignature = useMemo( () => nodeDragActive && projectChangeSignatureRef.current ? projectChangeSignatureRef.current : projectContentSignature( projectName, nodes, edges, simulationConfig, ), [edges, nodeDragActive, nodes, projectName, simulationConfig], ); const validationSignature = useMemo( () => nodeDragActive && validationSignatureRef.current ? validationSignatureRef.current : modelValidationSignature(nodes, edges, simulationConfig), [edges, nodeDragActive, nodes, simulationConfig], ); useLayoutEffect(() => { if (!nodeDragActive) { projectChangeSignatureRef.current = projectChangeSignature; validationSignatureRef.current = validationSignature; } }, [nodeDragActive, projectChangeSignature, validationSignature]); 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( match.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 renderedNodePresentationContext = useMemo( () => ({ activeConnectionPort, activePortConnection, connectedPortKeys, contactSnapOriginPortKeys, contactSnapTargetPortKeys, multipleNodesSelected, validationSeverityByNode, }), [ activeConnectionPort, activePortConnection, connectedPortKeys, contactSnapOriginPortKeys, contactSnapTargetPortKeys, multipleNodesSelected, validationSeverityByNode, ], ); const renderedNodeComputation = useMemo( () => { const previousCache = renderedNodeCacheRef.current; const nextCache = new Map(); const nextRenderedNodes = nodes.map((node) => { const cached = previousCache.get(node.id); if ( cached?.presentationContext === renderedNodePresentationContext && cached.sourceNode.data === node.data && cached.sourceNode.className === node.className ) { if (cached.sourceNode === node) { nextCache.set(node.id, cached); return cached.renderedNode; } const renderedNode: SimulationNode = { ...node, className: cached.renderedNode.className, data: cached.renderedNode.data, }; nextCache.set(node.id, { ...cached, renderedNode, sourceNode: node, }); return renderedNode; } const hasDedicatedSymbol = hasComponentSymbol( node.data.symbol ?? node.data.componentType, ); const portStateSignature: string[] = []; 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"; } portStateSignature.push(`${port.name}:${state}`); return [port.name, state]; }), ); const className = [ node.className, hasDedicatedSymbol ? "component-icon-node" : "", validationSeverityByNode.has(node.id) ? `validation-${validationSeverityByNode.get(node.id)}` : "", ] .filter(Boolean) .join(" "); const presentationSignature = [ className, multipleNodesSelected ? "hide-connected-labels" : "show-all-labels", ...portStateSignature, ].join("|"); const canReusePresentation = cached?.sourceNode.data === node.data && cached.presentationSignature === presentationSignature; if ( cached?.sourceNode === node && canReusePresentation ) { nextCache.set( node.id, cached.presentationContext === renderedNodePresentationContext ? cached : { ...cached, presentationContext: renderedNodePresentationContext, }, ); return cached.renderedNode; } const renderedData = canReusePresentation ? cached.renderedNode.data : { ...node.data, hideConnectedPortIndexLabels: multipleNodesSelected, portConnectionStates, }; const renderedNode: SimulationNode = { ...node, className: className || undefined, data: renderedData, }; nextCache.set(node.id, { presentationContext: renderedNodePresentationContext, presentationSignature, renderedNode, sourceNode: node, }); return renderedNode; }); return { cache: nextCache, nodes: nextRenderedNodes }; }, [ activeConnectionPort, activePortConnection, contactSnapOriginPortKeys, contactSnapTargetPortKeys, connectedPortKeys, multipleNodesSelected, nodes, renderedNodePresentationContext, validationSeverityByNode, ], ); useLayoutEffect(() => { renderedNodeCacheRef.current = renderedNodeComputation.cache; }, [renderedNodeComputation.cache]); const renderedNodes = renderedNodeComputation.nodes; function handleEdgeRouteEditStart() { recordHistory(); } function handleEdgeRoutePointsChange( edgeId: string, routePoints: EdgeRoutePoint[], ) { setEdges((currentEdges) => { let changed = false; const nextEdges = currentEdges.map((edge) => { if (edge.id !== edgeId) { return edge; } changed = true; return { ...edge, data: { ...edge.data, isContactEdge: false, routePoints: routePoints.map((point) => ({ ...point })), }, }; }); const resolvedEdges = changed ? nextEdges : currentEdges; edgesRef.current = resolvedEdges; return resolvedEdges; }); } const edgeIndexesByNodeId = useMemo(() => { const indexes = new Map(); edges.forEach((edge, edgeIndex) => { const sourceIndexes = indexes.get(edge.source) ?? []; sourceIndexes.push(edgeIndex); indexes.set(edge.source, sourceIndexes); if (edge.target !== edge.source) { const targetIndexes = indexes.get(edge.target) ?? []; targetIndexes.push(edgeIndex); indexes.set(edge.target, targetIndexes); } }); return indexes; }, [edges]); const renderedEdgeComputation = useMemo( () => { const nodeById = new Map(nodes.map((node) => [node.id, node] as const)); const previousGeometryCache = nodePortGeometryCacheRef.current; const geometryUpdates = new Map(); const geometryDeletes: string[] = []; const geometryMatchesNode = ( cached: CachedNodePortGeometry | undefined, node: SimulationNode, ) => Boolean( cached && cached.sourceData === node.data && cached.positionX === node.position.x && cached.positionY === node.position.y && cached.rotation === normalizeNodeRotation(node.data.rotation) && cached.mirrored === Boolean(node.data.mirrored), ); const geometryForNode = (node: SimulationNode): CachedNodePortGeometry => { const updated = geometryUpdates.get(node.id); if (updated) { return updated; } const rotation = normalizeNodeRotation(node.data.rotation); const mirrored = Boolean(node.data.mirrored); const cached = previousGeometryCache.get(node.id); if (cached && geometryMatchesNode(cached, node)) { geometryUpdates.set(node.id, cached); return cached; } const geometry: CachedNodePortGeometry = { mirrored, placementByPort: new Map( nodePortFlowPlacements(node).map((placement) => [ placement.port.name, { flowX: placement.flowX, flowY: placement.flowY }, ] as const), ), positionX: node.position.x, positionY: node.position.y, rotation, sourceData: node.data, }; geometryUpdates.set(node.id, geometry); return geometry; }; const previousEdgeCache = renderedEdgeCacheRef.current; const previousRenderedEdges = renderedEdgesArrayRef.current; const requiresFullRecompute = renderedEdgesSourceRef.current !== edges || previousRenderedEdges.length !== edges.length || renderedEdgesSelectedIdRef.current !== selectedEdgeId; const affectedEdgeIndexes = new Set(); if (requiresFullRecompute) { edges.forEach((_edge, edgeIndex) => affectedEdgeIndexes.add(edgeIndex)); } else { nodes.forEach((node) => { if (geometryMatchesNode(previousGeometryCache.get(node.id), node)) { return; } for (const edgeIndex of edgeIndexesByNodeId.get(node.id) ?? []) { affectedEdgeIndexes.add(edgeIndex); } }); for (const nodeId of previousGeometryCache.keys()) { if (nodeById.has(nodeId)) { continue; } geometryDeletes.push(nodeId); for (const edgeIndex of edgeIndexesByNodeId.get(nodeId) ?? []) { affectedEdgeIndexes.add(edgeIndex); } } } const edgeCacheUpdates = new Map(); let nextRenderedEdges = previousRenderedEdges; let renderedEdgesChanged = false; const updateRenderedEdge = (edgeIndex: number) => { const edge = edges[edgeIndex]; if (!edge) { return; } const selected = edge.selected || edge.id === selectedEdgeId; const sourceNode = nodeById.get(edge.source); const targetNode = nodeById.get(edge.target); const source = sourceNode && edge.sourceHandle ? geometryForNode(sourceNode).placementByPort.get(edge.sourceHandle) : undefined; const target = targetNode && edge.targetHandle ? geometryForNode(targetNode).placementByPort.get(edge.targetHandle) : undefined; const contact = Boolean( source && target && Math.hypot(target.flowX - source.flowX, target.flowY - source.flowY) <= CONTACT_GEOMETRY_TOLERANCE, ); const className = [ edge.className, contact ? "editor-edge-contact" : "", selected ? "editor-edge-selected" : "", ] .filter(Boolean) .join(" "); const presentationSignature = `${className}|${contact ? "contact" : "line"}|${selected ? "selected" : "idle"}`; const cached = previousEdgeCache.get(edge.id); if ( cached?.sourceEdge === edge && cached.presentationSignature === presentationSignature ) { if (requiresFullRecompute) { edgeCacheUpdates.set(edge.id, cached); } if ( requiresFullRecompute || previousRenderedEdges[edgeIndex] !== cached.renderedEdge ) { if (!renderedEdgesChanged) { nextRenderedEdges = previousRenderedEdges.slice(); renderedEdgesChanged = true; } nextRenderedEdges[edgeIndex] = cached.renderedEdge; } return; } const renderedEdge: SimulationEdge = { ...edge, className: className || undefined, data: { ...edge.data, editable: !contact, isContactEdge: contact, onRouteEditStart: handleEdgeRouteEditStart, onRoutePointsChange: handleEdgeRoutePointsChange, }, focusable: contact ? false : edge.focusable, selectable: contact ? false : edge.selectable, type: CONTACT_AWARE_EDGE_TYPE, }; edgeCacheUpdates.set(edge.id, { presentationSignature, renderedEdge, sourceEdge: edge, }); if (!renderedEdgesChanged) { nextRenderedEdges = previousRenderedEdges.slice(); renderedEdgesChanged = true; } nextRenderedEdges[edgeIndex] = renderedEdge; }; if (requiresFullRecompute) { nextRenderedEdges = new Array(edges.length); renderedEdgesChanged = true; } [...affectedEdgeIndexes] .sort((first, second) => first - second) .forEach(updateRenderedEdge); return { edgeCacheUpdates, edges: nextRenderedEdges, fullRecompute: requiresFullRecompute, geometryDeletes, geometryUpdates, selectedEdgeId, sourceEdges: edges, }; }, [edgeIndexesByNodeId, edges, nodes, selectedEdgeId], ); useLayoutEffect(() => { if (renderedEdgeComputation.fullRecompute) { nodePortGeometryCacheRef.current = renderedEdgeComputation.geometryUpdates; renderedEdgeCacheRef.current = renderedEdgeComputation.edgeCacheUpdates; } else { renderedEdgeComputation.geometryDeletes.forEach((nodeId) => { nodePortGeometryCacheRef.current.delete(nodeId); }); renderedEdgeComputation.geometryUpdates.forEach((geometry, nodeId) => { nodePortGeometryCacheRef.current.set(nodeId, geometry); }); renderedEdgeComputation.edgeCacheUpdates.forEach((entry, edgeId) => { renderedEdgeCacheRef.current.set(edgeId, entry); }); } renderedEdgesArrayRef.current = renderedEdgeComputation.edges; renderedEdgesSourceRef.current = renderedEdgeComputation.sourceEdges; renderedEdgesSelectedIdRef.current = renderedEdgeComputation.selectedEdgeId; }, [renderedEdgeComputation]); const renderedEdges = renderedEdgeComputation.edges; const crossingJumpsByEdgeId = useMemo(() => { const nodeById = new Map(nodes.map((node) => [node.id, node] as const)); return detectEdgeCrossingJumps( renderedEdges.flatMap((edge) => { if (edge.data?.isContactEdge === true) { return []; } const sourceNode = nodeById.get(edge.source); const targetNode = nodeById.get(edge.target); const source = sourceNode ? nodePortFlowPlacement(sourceNode, edge.sourceHandle ?? null) : undefined; const target = targetNode ? nodePortFlowPlacement(targetNode, edge.targetHandle ?? null) : undefined; if (!source || !target) { return []; } return [ { id: edge.id, nodeIds: [edge.source, edge.target] as const, points: orthogonalEdgePoints({ routePoints: routePointsForEdge(edge), sourcePosition: source.position, sourceX: source.flowX, sourceY: source.flowY, targetPosition: target.position, targetX: target.flowX, targetY: target.flowY, }), }, ]; }), ); }, [nodes, renderedEdges]); const routedRenderedEdges = useMemo( () => renderedEdges.map((edge) => { const crossingJumps = crossingJumpsByEdgeId.get(edge.id); return crossingJumps?.length ? { ...edge, data: { ...edge.data, crossingJumps, }, } : edge; }), [crossingJumpsByEdgeId, renderedEdges], ); const flowNodes = useMemo(() => { if (!pendingPaste) { return renderedNodes; } const pendingConnectedPortKeys = new Set(); pendingPaste.edges.forEach((edge) => { if (edge.sourceHandle) { pendingConnectedPortKeys.add( portConnectionKey(edge.source, edge.sourceHandle), ); } if (edge.targetHandle) { pendingConnectedPortKeys.add( portConnectionKey(edge.target, edge.targetHandle), ); } }); const previewNodes = pendingPaste.nodes.map((node) => ({ ...node, className: [ node.className, hasComponentSymbol(node.data.symbol ?? node.data.componentType) ? "component-icon-node" : "", "pending-paste-node", ] .filter(Boolean) .join(" "), connectable: false, deletable: false, draggable: false, focusable: false, selectable: false, selected: false, data: { ...node.data, hideConnectedPortIndexLabels: true, portConnectionStates: Object.fromEntries( node.data.ports.map((port) => { const key = portConnectionKey(node.id, port.name); return [ port.name, pendingConnectedPortKeys.has(key) ? "connected" : contactSnapOriginPortKeys.has(key) ? "origin" : "idle", ]; }), ) as Record, }, })); return [ ...renderedNodes.map((node) => ({ ...node, selected: false })), ...previewNodes, ]; }, [contactSnapOriginPortKeys, pendingPaste, renderedNodes]); const flowEdges = useMemo(() => { if (!pendingPaste) { return routedRenderedEdges; } const previewEdges = pendingPaste.edges.map((edge) => { const contact = isContactEdgeGeometry(pendingPaste.nodes, edge); return { ...edge, className: [edge.className, "pending-paste-edge"] .filter(Boolean) .join(" "), deletable: false, focusable: false, selectable: false, selected: false, data: { ...edge.data, isContactEdge: contact, }, type: CONTACT_AWARE_EDGE_TYPE, }; }); return [ ...routedRenderedEdges.map((edge) => ({ ...edge, className: edge.className ?.split(/\s+/) .filter((className) => className !== "editor-edge-selected") .join(" "), selected: false, })), ...previewEdges, ]; }, [pendingPaste, routedRenderedEdges]); const updateHistoryAvailability = useCallback(() => { setHistoryAvailability({ canUndo: historyRef.current.length > 0, canRedo: futureRef.current.length > 0, }); }, []); const captureSnapshot = useCallback( (): EditorSnapshot => { const signature = projectChangeSignatureRef.current || projectContentSignature( projectNameRef.current, nodesRef.current, edgesRef.current, simulationConfigRef.current, ); return { // 编辑器节点和边按不可变方式更新;历史记录保留结构共享,恢复时再深拷贝。 // 这样拖动开始时无需同步克隆整张大图。 nodes: nodesRef.current.slice(), edges: edgesRef.current.slice(), projectName: projectNameRef.current, signature, 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) { setNodes((currentNodes) => applyNodeChanges(changes, currentNodes)); return; } cancelPortConnection(); recordHistory(); const preview = repairAmesimGasReferences( applyNodeChanges(changes, nodesRef.current), componentDefinitions, ); setNodes((currentNodes) => repairAmesimGasReferences( applyNodeChanges(changes, currentNodes), componentDefinitions, ).nodes, ); if (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) => { setNodeDragActive(true); recordHistory(); clearContactSnapCandidate(); const movingNodes = draggedNodes.length > 0 ? draggedNodes : [draggedNode]; const draggedNodeIds = new Set(movingNodes.map((node) => node.id)); const draggedNodeById = new Map( movingNodes.map((node) => [node.id, node] as const), ); const effectiveNodes = nodesRef.current.map((node) => { const moved = draggedNodeById.get(node.id); return moved ? { ...node, position: { ...moved.position } } : node; }); const contactEdgeIds = new Set( edgesRef.current.flatMap((edge) => { const sourceMoves = draggedNodeIds.has(edge.source); const targetMoves = draggedNodeIds.has(edge.target); // Only a contact crossing the block boundary is disconnected. // Internal contact topology moves with the block and remains hidden. return sourceMoves !== targetMoves && isContactEdgeGeometry(effectiveNodes, edge) ? [edge.id] : []; }), ); contactDragSessionRef.current = { contactEdgeIds, disconnected: false, draggedNodeIds, effectiveEdges: edgesRef.current, lastCandidate: null, lastEvaluatedPosition: null, lastSnapRadius: 0, multipleNodes: draggedNodeIds.size > 1, nodeId: draggedNode.id, snapSearchContext: buildContactSnapSearchContext( draggedNodeIds, effectiveNodes, edgesRef.current, ), startPosition: { ...draggedNode.position }, }; }, [clearContactSnapCandidate, recordHistory], ); const handleNodeDrag = useCallback>( (_event, draggedNode, draggedNodes) => { const session = contactDragSessionRef.current; if (!session || session.nodeId !== draggedNode.id) { return; } const movingNodes = draggedNodes.length > 0 ? draggedNodes : [draggedNode]; const draggedNodeById = new Map( movingNodes.map((node) => [node.id, node] as const), ); const effectiveNodes = nodesRef.current.map((node) => { const moved = draggedNodeById.get(node.id); return moved ? { ...node, position: { ...moved.position } } : node; }); const effectiveDraggedNodes = effectiveNodes.filter((node) => session.draggedNodeIds.has(node.id), ); const effectivePrimary = effectiveDraggedNodes.find((node) => node.id === session.nodeId) ?? draggedNode; const screenDistance = Math.hypot( effectivePrimary.position.x - session.startPosition.x, effectivePrimary.position.y - session.startPosition.y, ) * reactFlow.getZoom(); const disconnectDistance = session.multipleNodes ? 0.5 : CONTACT_DISCONNECT_DISTANCE_PX; if ( !session.disconnected && session.contactEdgeIds.size > 0 && screenDistance >= disconnectDistance ) { const remainingEdges = session.effectiveEdges.filter( (edge) => !session.contactEdgeIds.has(edge.id), ); session.disconnected = true; session.effectiveEdges = remainingEdges; session.snapSearchContext = buildContactSnapSearchContext( session.draggedNodeIds, effectiveNodes, remainingEdges, ); edgesRef.current = remainingEdges; setEdges(remainingEdges); setSelectedEdgeId((current) => current && session.contactEdgeIds.has(current) ? null : current, ); } const snapRadius = contactSnapRadiusForZoom(reactFlow.getZoom()); const candidate = findContactSnapCandidate( effectiveDraggedNodes, effectiveNodes, session.effectiveEdges, snapRadius, session.snapSearchContext, ); session.lastCandidate = candidate; session.lastEvaluatedPosition = { ...effectivePrimary.position }; session.lastSnapRadius = snapRadius; setContactSnapCandidate((current) => sameContactSnapCandidate(current, candidate) ? current : candidate, ); const previewBlockDelta = { x: effectivePrimary.position.x - session.startPosition.x, y: effectivePrimary.position.y - session.startPosition.y, }; const previewEdges = translateInternalEdgeRoutes( session.effectiveEdges, session.draggedNodeIds, previewBlockDelta.x, previewBlockDelta.y, ); if (previewEdges !== session.effectiveEdges) { edgesRef.current = previewEdges; setEdges(previewEdges); } }, [reactFlow, setEdges], ); const handleNodeDragStop = useCallback>( (_event, draggedNode, draggedNodes) => { setNodeDragActive(false); const session = contactDragSessionRef.current; if (!session || session.nodeId !== draggedNode.id) { clearContactSnapCandidate(); return; } const movingNodes = draggedNodes.length > 0 ? draggedNodes : [draggedNode]; const draggedNodeById = new Map( movingNodes.map((node) => [node.id, node] as const), ); const effectiveNodes = nodesRef.current.map((node) => { const moved = draggedNodeById.get(node.id); return moved ? { ...node, position: { ...moved.position } } : node; }); const effectiveDraggedNodes = effectiveNodes.filter((node) => session.draggedNodeIds.has(node.id), ); const effectivePrimary = effectiveDraggedNodes.find((node) => node.id === session.nodeId) ?? draggedNode; const snapRadius = contactSnapRadiusForZoom(reactFlow.getZoom()); const canReuseLastCandidate = session.lastEvaluatedPosition?.x === effectivePrimary.position.x && session.lastEvaluatedPosition.y === effectivePrimary.position.y && session.lastSnapRadius === snapRadius; const candidate = canReuseLastCandidate ? session.lastCandidate : findContactSnapCandidate( effectiveDraggedNodes, effectiveNodes, session.effectiveEdges, snapRadius, session.snapSearchContext, ); contactDragSessionRef.current = null; clearContactSnapCandidate(); const finalNodes = candidate ? effectiveNodes.map((node) => session.draggedNodeIds.has(node.id) ? { ...node, position: { x: node.position.x + candidate.positionDelta.x, y: node.position.y + candidate.positionDelta.y, }, } : node, ) : effectiveNodes; const finalPrimary = finalNodes.find((node) => node.id === session.nodeId) ?? effectivePrimary; const blockDelta = { x: finalPrimary.position.x - session.startPosition.x, y: finalPrimary.position.y - session.startPosition.y, }; let connectedEdges = session.effectiveEdges.filter( (edge) => !session.contactEdgeIds.has(edge.id) || isContactEdgeGeometry(finalNodes, edge), ); connectedEdges = translateInternalEdgeRoutes( connectedEdges, session.draggedNodeIds, blockDelta.x, blockDelta.y, ); const timestamp = Date.now(); 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(finalNodes, connectedEdges, contactEdge) && isContactEdgeGeometry(finalNodes, contactEdge) ) { connectedEdges = addEdge(contactEdge, connectedEdges); } }); nodesRef.current = finalNodes; edgesRef.current = connectedEdges; setNodes(finalNodes); setEdges(connectedEdges); setSelectedEdgeId(null); }, [clearContactSnapCandidate, reactFlow, setEdges, setNodes], ); const beginPortConnection = useCallback( ( nodeId: string | null, portName: string | null, mode: ActivePortConnection["mode"], ) => { const port = nodeId && portName ? findNodePort(nodesRef.current, nodeId, portName) : undefined; if ( !nodeId || !portName || !port || !portAcceptsAnotherConnection(edgesRef.current, nodeId, port) ) { cancelPortConnection(); return; } suppressCancelledConnectionRef.current = false; if (mode === "click") { const placement = nodePortFlowPlacement( nodesRef.current.find((node) => node.id === nodeId) as SimulationNode, portName, ); const draft: PortConnectionDraft = { pointer: placement ? { x: placement.flowX, y: placement.flowY } : null, waypoints: [], }; portConnectionDraftRef.current = draft; setPortConnectionDraft(draft); } setActivePortConnection((current) => { if (mode === "drag" && current?.mode === "click") { return current; } return { nodeId, portName, mode }; }); }, [cancelPortConnection], ); const endDragPortConnection = useCallback(() => { suppressCancelledConnectionRef.current = false; setActivePortConnection((current) => current?.mode === "click" ? current : null, ); }, []); const endClickPortConnection = useCallback(() => { suppressCancelledConnectionRef.current = false; portConnectionDraftRef.current = null; setPortConnectionDraft(null); setActivePortConnection(null); }, []); const onConnect = useCallback( (connection: Connection) => { const clearConnectionUi = () => { portConnectionDraftRef.current = null; setPortConnectionDraft(null); setActivePortConnection(null); }; if (suppressCancelledConnectionRef.current) { clearConnectionUi(); return; } if (!canConnectPorts(nodesRef.current, edgesRef.current, connection)) { clearConnectionUi(); return; } let routePoints: EdgeRoutePoint[] | undefined; const draft = portConnectionDraftRef.current; if (activePortConnection?.mode === "click" && draft) { const sourceNode = nodesRef.current.find( (node) => node.id === connection.source, ); const targetNode = nodesRef.current.find( (node) => node.id === connection.target, ); const sourcePlacement = sourceNode ? nodePortFlowPlacement(sourceNode, connection.sourceHandle ?? null) : undefined; const targetPlacement = targetNode ? nodePortFlowPlacement(targetNode, connection.targetHandle ?? null) : undefined; const originIsSource = activePortConnection.nodeId === connection.source && activePortConnection.portName === connection.sourceHandle; const originIsTarget = activePortConnection.nodeId === connection.target && activePortConnection.portName === connection.targetHandle; if (sourcePlacement && targetPlacement && (originIsSource || originIsTarget)) { const orderedWaypoints = originIsSource ? draft.waypoints : [...draft.waypoints].reverse(); routePoints = buildOrthogonalRoutePoints( { x: sourcePlacement.flowX, y: sourcePlacement.flowY }, { x: targetPlacement.flowX, y: targetPlacement.flowY }, sourcePlacement.position, targetPlacement.position, orderedWaypoints, ); } } recordHistory(); setEdges((currentEdges) => { const nextEdges = addEdge( { ...connection, data: { isContactEdge: false, ...(routePoints && routePoints.length > 0 ? { routePoints } : {}), }, id: `edge-${connection.source}-${connection.sourceHandle}-${connection.target}-${connection.targetHandle}-${Date.now()}`, type: CONTACT_AWARE_EDGE_TYPE, }, currentEdges, ); edgesRef.current = nextEdges; return nextEdges; }); suppressCancelledConnectionRef.current = false; clearConnectionUi(); }, [activePortConnection, recordHistory, setEdges], ); const isValidConnection = useCallback( (connection: Connection | SimulationEdge) => canConnectPorts(nodesRef.current, edgesRef.current, connection), [], ); const onDragStart = (event: DragEvent, type: string) => { if (pendingPasteRef.current) { event.preventDefault(); return; } dismissPaletteIconPreview(); event.dataTransfer.setData("application/system-component", type); event.dataTransfer.effectAllowed = "move"; const definition = componentDefinitions.find((item) => item.type === type); const paletteSymbol = document.querySelector( `.palette-canvas-drag-template[data-component-type="${CSS.escape(type)}"] > .component-symbol`, ) ?? event.currentTarget.querySelector( ".palette-icon > .component-symbol", ); if (definition && paletteSymbol) { const layout = componentSymbolLayout( definition.symbol, defaultParameters(definition), ); 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(); if (pendingPasteRef.current) { return; } 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, new Set(nodesRef.current.map((node) => node.id)), ); const data = { ...buildNodeData(definition), label: id }; if (mediumIndex !== null) { data.parameters = { ...data.parameters, gi: mediumIndex, }; } const layout = componentSymbolLayout( data.symbol ?? data.componentType, data.parameters, ); 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, 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; let lmechn1PositionOffset: { x: number; y: number } | null = null; const draftErrorKey = selectedNodeId ? `${selectedNodeId}:${key}` : key; if ( selectedNode && isLmechn1NodeData(selectedNode.data) && key === "v1" ) { const nextRightPortCount = convertedBaseValue; const validationError = !hasFiniteNumericValue || !Number.isInteger(nextRightPortCount) ? "右侧端口数量必须为整数" : nextRightPortCount < 1 ? "右侧端口数量不能小于1" : nextRightPortCount > LMECHN1_MAX_RIGHT_PORT_COUNT ? "右侧端口数量不能超过20" : ""; if (validationError) { setComponentParameterDraftErrors((current) => ({ ...current, [draftErrorKey]: validationError, })); return; } const previousRightPortCount = lmechn1RightPortCount( selectedNode.data.parameters, ); if (nextRightPortCount < previousRightPortCount) { const portsThatWouldDisappear = new Set( Array.from( { length: previousRightPortCount - nextRightPortCount }, (_, index) => `port_${nextRightPortCount + 1 + index}`, ), ); const connectedDisappearingPort = edgesRef.current.some( (edge) => (edge.source === selectedNode.id && portsThatWouldDisappear.has(edge.sourceHandle ?? "")) || (edge.target === selectedNode.id && portsThatWouldDisappear.has(edge.targetHandle ?? "")), ); if (connectedDisappearingPort) { const message = "请先断开将被隐藏的右侧端口连接"; setComponentParameterDraftErrors((current) => ({ ...current, [draftErrorKey]: message, })); appendConsoleEntry("warning", message); return; } } setComponentParameterDraftErrors((current) => { if (!(draftErrorKey in current)) { return current; } const next = { ...current }; delete next[draftErrorKey]; return next; }); if (nextRightPortCount !== previousRightPortCount) { const symbol = selectedNode.data.symbol ?? selectedNode.data.componentType; const rotation = normalizeNodeRotation(selectedNode.data.rotation); const previousSize = nodeFrameDimensions( componentSymbolLayout(symbol, selectedNode.data.parameters), rotation, ); const nextSize = nodeFrameDimensions( componentSymbolLayout(symbol, { ...selectedNode.data.parameters, v1: nextRightPortCount, }), rotation, ); lmechn1PositionOffset = { x: (previousSize.width - nextSize.width) / 2, y: (previousSize.height - nextSize.height) / 2, }; const previousReferencePort = `port_${previousRightPortCount + 1}`; const nextReferencePort = `port_${nextRightPortCount + 1}`; setEdges((currentEdges) => { const nextEdges = currentEdges.map((edge) => ({ ...edge, ...(edge.source === selectedNode.id && edge.sourceHandle === previousReferencePort ? { sourceHandle: nextReferencePort } : {}), ...(edge.target === selectedNode.id && edge.targetHandle === previousReferencePort ? { targetHandle: nextReferencePort } : {}), })); edgesRef.current = nextEdges; return nextEdges; }); } } else { setComponentParameterDraftErrors((current) => { if (!(draftErrorKey in current)) { return current; } const next = { ...current }; delete next[draftErrorKey]; return next; }); } updateSelectedNode((node) => { const parameterScientificNotation = { ...node.data.parameterScientificNotation, }; if (scientificNotation) { parameterScientificNotation[key] = scientificNotation; } else { delete parameterScientificNotation[key]; } return { ...node, ...(lmechn1PositionOffset ? { position: { x: node.position.x + lmechn1PositionOffset.x, y: node.position.y + lmechn1PositionOffset.y, }, } : {}), 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), ), ), }; appendConsoleEntry("info", `已复制 ${selectedNodes.length} 个组件`); }, [appendConsoleEntry, selectedNodeId]); const flowPointForScreenPoint = useCallback( (screenPoint: { x: number; y: number }) => { const flowPoint = reactFlow.screenToFlowPosition(screenPoint); return snapEnabled ? { x: Math.round(flowPoint.x / GRID_SIZE) * GRID_SIZE, y: Math.round(flowPoint.y / GRID_SIZE) * GRID_SIZE, } : flowPoint; }, [reactFlow, snapEnabled], ); const positionPendingPasteAtScreenPoint = useCallback( (screenPoint: { x: number; y: number }) => { const current = pendingPasteRef.current; if (!current) { return; } const nextAnchor = flowPointForScreenPoint(screenPoint); // Positions currently include the previous snap offset. Remove it while // following the raw pointer, then derive a fresh block-wide snap. const rawDelta = { x: nextAnchor.x - current.anchor.x - current.snapOffset.x, y: nextAnchor.y - current.anchor.y - current.snapOffset.y, }; const pendingNodeIds = new Set(current.nodes.map((node) => node.id)); const rawNodes = current.nodes.map((node) => ({ ...node, position: { x: node.position.x + rawDelta.x, y: node.position.y + rawDelta.y, }, })); const rawEdges = translateInternalEdgeRoutes( current.edges, pendingNodeIds, rawDelta.x, rawDelta.y, ); const allNodes = [...nodesRef.current, ...rawNodes]; const allEdges = [...edgesRef.current, ...rawEdges]; const candidate = findContactSnapCandidate( rawNodes, allNodes, allEdges, contactSnapRadiusForZoom(reactFlow.getZoom()), ); const snapOffset = candidate?.positionDelta ?? { x: 0, y: 0 }; const nextNodes = rawNodes.map((node) => ({ ...node, position: { x: node.position.x + snapOffset.x, y: node.position.y + snapOffset.y, }, })); const nextEdges = translateInternalEdgeRoutes( rawEdges, pendingNodeIds, snapOffset.x, snapOffset.y, ); const next = { ...current, anchor: nextAnchor, edges: nextEdges, nodes: nextNodes, snapOffset, }; pendingPasteRef.current = next; setPendingPaste(next); setContactSnapCandidate((existing) => sameContactSnapCandidate(existing, candidate) ? existing : candidate, ); }, [flowPointForScreenPoint, reactFlow, setPendingPaste], ); const syncPendingPasteToPointer = useCallback(() => { const pointer = lastFlowPointerRef.current; if (pointer && pendingPasteRef.current) { positionPendingPasteAtScreenPoint(pointer); } }, [positionPendingPasteAtScreenPoint]); const rotatePendingPaste = useCallback(() => { setPendingPaste((current) => { if (!current) { return current; } const next = { ...current, // A rotated block gets a fresh automatic internal route. Absolute // manual bends from the previous orientation must not remain behind. edges: current.edges.map((edge) => ({ ...edge, data: { ...edge.data, routePoints: undefined, }, })), nodes: current.nodes.map(rotateNodeClockwise), }; pendingPasteRef.current = next; return next; }); clearContactSnapCandidate(); }, [clearContactSnapCandidate, setPendingPaste]); const cancelPendingPaste = useCallback(() => { if (!pendingPasteRef.current) { return; } pendingPasteRef.current = null; pendingPasteMiddlePointerRef.current = null; setPendingPaste(null); clearContactSnapCandidate(); appendConsoleEntry("info", "已取消粘贴放置"); }, [appendConsoleEntry, clearContactSnapCandidate]); const commitPendingPaste = useCallback(() => { const pending = pendingPasteRef.current; if (!pending) { return; } const occupiedNodeIds = new Set(nodesRef.current.map((node) => node.id)); const incomingNodeIds = new Set(); const conflictingNode = pending.nodes.find((node) => { const conflict = occupiedNodeIds.has(node.id) || incomingNodeIds.has(node.id); incomingNodeIds.add(node.id); return conflict; }); const occupiedEdgeIds = new Set(edgesRef.current.map((edge) => edge.id)); const incomingEdgeIds = new Set(); const conflictingEdge = pending.edges.find((edge) => { const conflict = occupiedEdgeIds.has(edge.id) || incomingEdgeIds.has(edge.id); incomingEdgeIds.add(edge.id); return conflict; }); if (conflictingNode || conflictingEdge) { pendingPasteRef.current = null; pendingPasteMiddlePointerRef.current = null; setPendingPaste(null); clearContactSnapCandidate(); appendConsoleEntry( "error", `粘贴已取消:检测到重复${conflictingNode ? `节点 ID ${conflictingNode.id}` : `连接 ID ${conflictingEdge?.id}`}`, ); return; } const pendingNodeIds = new Set(pending.nodes.map((node) => node.id)); const combinedNodes = [...nodesRef.current, ...pending.nodes]; const combinedEdges = [...edgesRef.current, ...pending.edges]; const candidate = findContactSnapCandidate( pending.nodes, combinedNodes, combinedEdges, contactSnapRadiusForZoom(reactFlow.getZoom()), ); const placementDelta = candidate?.positionDelta ?? { x: 0, y: 0 }; const placedNodes = pending.nodes.map((node) => ({ ...node, position: { x: node.position.x + placementDelta.x, y: node.position.y + placementDelta.y, }, selected: true, })); const placedInternalEdges = translateInternalEdgeRoutes( pending.edges, pendingNodeIds, placementDelta.x, placementDelta.y, ).map((edge) => ({ ...edge, selected: false })); const nextNodes = [ ...nodesRef.current.map((node) => ({ ...node, selected: false })), ...placedNodes, ]; let nextEdges: SimulationEdge[] = [ ...edgesRef.current.map((edge) => ({ ...edge, selected: false })), ...placedInternalEdges, ]; const timestamp = Date.now(); let attachedPortCount = 0; 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}-paste-${index}`, type: CONTACT_AWARE_EDGE_TYPE, }; if ( canConnectPorts(nextNodes, nextEdges, contactEdge) && isContactEdgeGeometry(nextNodes, contactEdge) ) { nextEdges = addEdge(contactEdge, nextEdges); attachedPortCount += 1; } }); recordHistory(); nodesRef.current = nextNodes; edgesRef.current = nextEdges; setNodes(nextNodes); setEdges(nextEdges); setSelectedNodeId(placedNodes.length === 1 ? placedNodes[0].id : null); setSelectedEdgeId(null); pendingPasteRef.current = null; pendingPasteMiddlePointerRef.current = null; setPendingPaste(null); clearContactSnapCandidate(); appendConsoleEntry( "info", `已粘贴 ${placedNodes.length} 个组件${attachedPortCount > 0 ? `,并吸附连接 ${attachedPortCount} 个端口` : ""}`, ); if (pending.repairedReferenceCount > 0) { appendConsoleEntry( "warning", `${pending.repairedReferenceCount} 个粘贴的介质物性引用没有对应介质定义,已切回 0 — 空气(理想气体,内置默认)`, ); } }, [ appendConsoleEntry, clearContactSnapCandidate, reactFlow, recordHistory, setEdges, setNodes, ]); 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); } }); const flowRoot = reactFlowStore.getState().domNode; const flowBounds = flowRoot?.getBoundingClientRect(); const screenAnchor = lastFlowPointerRef.current ?? { x: flowBounds ? flowBounds.left + flowBounds.width / 2 : window.innerWidth / 2, y: flowBounds ? flowBounds.top + flowBounds.height / 2 : window.innerHeight / 2, }; lastFlowPointerRef.current = screenAnchor; const anchor = flowPointForScreenPoint(screenAnchor); const copiedBounds = clipboard.nodes.reduce( (bounds, node) => { const size = nodeFrameDimensions( componentSymbolLayout( node.data.symbol ?? node.data.componentType, node.data.parameters, ), normalizeNodeRotation(node.data.rotation), ); return { minX: Math.min(bounds.minX, node.position.x), minY: Math.min(bounds.minY, node.position.y), maxX: Math.max(bounds.maxX, node.position.x + size.width), maxY: Math.max(bounds.maxY, node.position.y + size.height), }; }, { minX: Number.POSITIVE_INFINITY, minY: Number.POSITIVE_INFINITY, maxX: Number.NEGATIVE_INFINITY, maxY: Number.NEGATIVE_INFINITY, }, ); const copiedCenter = { x: (copiedBounds.minX + copiedBounds.maxX) / 2, y: (copiedBounds.minY + copiedBounds.maxY) / 2, }; const initialPasteDelta = { x: anchor.x - copiedCenter.x, y: anchor.y - copiedCenter.y, }; const idMap = new Map(); const reservedNodeIds = new Set(nodesRef.current.map((node) => node.id)); 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, reservedNodeIds); reservedNodeIds.add(id); 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 + initialPasteDelta.x, y: node.position.y + initialPasteDelta.y, }, selected: false, data: { ...cloneValue(node.data), label, parameters, }, } satisfies SimulationNode; }); const reservedEdgeIds = new Set(edgesRef.current.map((edge) => edge.id)); const pastedEdges = clipboard.edges.map((edge, index) => { const id = takeNextCopiedEdgeId(index, reservedEdgeIds); reservedEdgeIds.add(id); return translateEdgeRoute( { ...cloneValue(edge), id, source: idMap.get(edge.source) as string, target: idMap.get(edge.target) as string, selected: false, }, initialPasteDelta.x, initialPasteDelta.y, ); }); const nextPendingPaste = { anchor, edges: pastedEdges, nodes: pastedNodes, repairedReferenceCount: repairedPastedReferenceCount, snapOffset: { x: 0, y: 0 }, } satisfies PendingPaste; cancelPortConnection(); setSelectedNodeId(null); setSelectedEdgeId(null); pendingPasteRef.current = nextPendingPaste; setPendingPaste(nextPendingPaste); appendConsoleEntry( "info", `已准备 ${pastedNodes.length} 个组件:左键放置,中键旋转,Esc 取消`, ); }, [ appendConsoleEntry, cancelPortConnection, componentDefinitions, flowPointForScreenPoint, reactFlowStore, ]); const handleFlowPointerDownCapture = useCallback( (event: ReactPointerEvent) => { lastFlowPointerRef.current = { x: event.clientX, y: event.clientY }; rememberFlowPointer(event); if (pendingPasteRef.current && event.button === 1) { pendingPasteMiddlePointerRef.current = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, moved: false, }; } }, [rememberFlowPointer], ); const handleFlowPointerMoveCapture = useCallback( (event: ReactPointerEvent) => { lastFlowPointerRef.current = { x: event.clientX, y: event.clientY }; const middlePointer = pendingPasteMiddlePointerRef.current; if ( middlePointer && middlePointer.pointerId === event.pointerId && Math.hypot( event.clientX - middlePointer.startX, event.clientY - middlePointer.startY, ) > 4 ) { middlePointer.moved = true; } if (pendingPasteRef.current) { positionPendingPasteAtScreenPoint(lastFlowPointerRef.current); } const connectionDraft = portConnectionDraftRef.current; if (connectionDraft) { const pointer = reactFlow.screenToFlowPosition( { x: event.clientX, y: event.clientY }, { snapToGrid: false }, ); if ( connectionDraft.pointer?.x !== pointer.x || connectionDraft.pointer?.y !== pointer.y ) { const nextDraft = { ...connectionDraft, pointer }; portConnectionDraftRef.current = nextDraft; setPortConnectionDraft(nextDraft); } } }, [positionPendingPasteAtScreenPoint, reactFlow], ); const handleFlowPointerUpCapture = useCallback( (event: ReactPointerEvent) => { lastFlowPointerRef.current = { x: event.clientX, y: event.clientY }; const middlePointer = pendingPasteMiddlePointerRef.current; if (!middlePointer || middlePointer.pointerId !== event.pointerId) { return; } pendingPasteMiddlePointerRef.current = null; if (!middlePointer.moved && pendingPasteRef.current) { rotatePendingPaste(); } }, [rotatePendingPaste], ); const handleFlowClickCapture = useCallback( (event: ReactMouseEvent) => { if (!pendingPasteRef.current || event.button !== 0) { return; } const target = event.target instanceof Element ? event.target : null; if ( target?.closest( ".flow-viewport-scrollbars, .react-flow__panel, button, input, select, textarea, a, [role='button'], [role='scrollbar']", ) ) { return; } event.preventDefault(); event.stopPropagation(); commitPendingPaste(); }, [commitPendingPaste], ); 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, nodes); appendConsoleEntry( "success", `XML 已生成:${nodes.length} 个组件,${edges.length} 条连接`, ); appendConsoleEntry("info", xml, "xml"); } catch (error) { appendConsoleEntry("error", `XML 生成失败:${formatError(error)}`); } }; const downloadXml = () => { try { const xml = buildSystemXml(buildCurrentProject(), componentDefinitions, nodes); 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 legacyMigration = migrateLegacyAmesimComponents( project, componentDefinitions, ); const normalizedNodes = legacyMigration.project.nodes.map((node) => normalizeLoadedNode( node, componentDefinitions, ), ); const indexedMedia = assignMissingAmesimGasDefinitionIndices( normalizedNodes, componentDefinitions, ); const loadedNodes = indexedMedia.nodes; const edgeNormalization = loadProjectEdges( legacyMigration.project.edges, loadedNodes, ); const loadedEdges = edgeNormalization.edges; setNodes(loadedNodes); setEdges(loadedEdges); setProjectName(project.name || "untitled"); setSimulationConfig({ ...defaultSimulationConfig, ...project.simulation }); setSelectedNodeId(null); setSelectedEdgeId(null); setValidationIssues([]); setValidatedSignature(""); syncNextNodeNumbers(loadedNodes); appendConsoleEntry("success", message); if (legacyMigration.migratedLmechn1NodeCount > 0) { appendConsoleEntry( "info", `已升级 ${legacyMigration.migratedLmechn1NodeCount} 个 LMECHN1 元件的动态端口合同`, ); } if (legacyMigration.migratedForcNodeCount > 0) { appendConsoleEntry( "info", `已升级 ${legacyMigration.migratedForcNodeCount} 个 FORC 元件的显式力方向合同`, ); } loadedNodes .flatMap((node) => node.data.modelContractIssues ?? []) .forEach((issue) => appendConsoleEntry("error", `${issue};该组件仅可查看,不能仿真`)); edgeNormalization.droppedEdges.forEach(({ id, reason }) => appendConsoleEntry( "warning", `已丢弃失效连接 ${id}:${reason}`, ), ); if (indexedMedia.assignedDefinitionCount > 0) { appendConsoleEntry( "info", `已为 ${indexedMedia.assignedDefinitionCount} 个介质定义补充分配介质定义索引`, ); } 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 = () => { if (pendingPasteRef.current) { return; } 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 || pendingPasteRef.current) { return; } try { const fileText = await file.text(); if (pendingPasteRef.current) { return; } const project = parseProjectPayload(fileText); if (!project) { throw new Error("文件不是有效的 ReactFlow 工程 JSON"); } applyProject(project, `已导入工程:${file.name}`); } catch (error) { appendConsoleEntry("error", `导入失败:${formatError(error)}`); } }; const checkModel = useCallback(() => { const issues = validateModel( nodesRef.current, edgesRef.current, simulationConfigRef.current, componentDefinitions, ); const signature = modelValidationSignature( 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} 条警告`; issues.forEach((issue) => appendConsoleEntry(issue.severity === "error" ? "error" : "warning", issue.message), ); appendConsoleEntry( errorCount > 0 ? "error" : warningCount > 0 ? "warning" : "success", summary, ); return issues; }, [appendConsoleEntry, 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) { setSimulationProgress({ state: "error", percent: 0, message: "模型检查未通过", startTime: progressConfig.t_start, simulatedTime: progressConfig.t_start, totalTime: progressConfig.t_stop, }); return; } if (!simulationResolution.ok) { appendConsoleEntry("error", simulationResolution.message); return; } const project: ExecutableProjectPayload = { ...buildCurrentProject(), simulation: simulationResolution.value, }; 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, nodesRef.current); const simulationId = createSimulationId(); activeSimulationIdRef.current = simulationId; setSimulationStopPending(false); let lastLoggedPhase = ""; let lastLoggedBucket = -1; let lastWatchdogStatus: SimulationActivityWatchdogStatus = "progressing"; try { const result = await streamSystemSimulation( xml, (event, watchdog) => { if (event.event !== "progress") { return; } const normalizedProgress = normalizeSimulationProgressEvent( event, project.simulation, ); const reportsSimulationTime = ![ "queued", "validation", "compilation", "initialization", "initializing", ].includes(event.phase); const progressMessage = watchdog?.status === "slow-active" ? `正在处理耗时积分步,内部计算仍活跃${ event.activityKind ? `(${event.activityKind})` : "" },可手动停止` : watchdog?.status === "slow-telemetry-unavailable" ? "积分步耗时较长,后端未提供活动遥测;任务保持运行,可手动停止" : event.message; setSimulationProgress((current) => ({ ...current, state: "running", percent: normalizedProgress.percent, message: progressMessage, simulatedTime: normalizedProgress.simulatedTime, totalTime: normalizedProgress.totalTime, })); if (watchdog && watchdog.status !== lastWatchdogStatus) { if (watchdog.status === "slow-active") { appendConsoleEntry( "warning", `积分步超过 ${SIMULATION_ACTIVITY_STALL_TIMEOUT_SECONDS} 秒尚未接受,但后端内部活动仍在推进;继续等待,可随时手动停止`, ); } else if (watchdog.status === "slow-telemetry-unavailable") { appendConsoleEntry( "warning", `积分步超过 ${SIMULATION_ACTIVITY_STALL_TIMEOUT_SECONDS} 秒尚未接受,且后端未提供活动遥测;将按 ${SIMULATION_SOLVER_STALL_TIMEOUT_MINUTES} 分钟保守上限继续等待,可手动停止`, ); } else if (lastWatchdogStatus !== "progressing") { appendConsoleEntry("info", "求解器已接受新的积分步,仿真继续推进"); } lastWatchdogStatus = watchdog.status; } 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" ? error.message.includes("内部活动") ? solverActivityStallTimeoutMessage("recovering") : solverStallTimeoutMessage("recovering") : "超过 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 || pendingPasteRef.current) { 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); }, [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); }; const toggleGridLines = () => { const nextVisible = !gridVisibility.lines; setGridVisibility((current) => ({ ...current, lines: nextVisible })); }; const toggleGridDots = () => { const nextVisible = !gridVisibility.dots; setGridVisibility((current) => ({ ...current, dots: nextVisible })); }; const fitModelingView = () => { if (nodesRef.current.length === 0) { appendConsoleEntry("warning", "画布中没有可适配的组件"); return; } void reactFlow.fitView({ duration: 180, minZoom: 0.1, padding: 0.18 }); }; 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 || nodeDragActive || recoveryProject) { return; } setSaveState("有未保存更改"); const timer = window.setTimeout(() => { try { const project = buildProjectPayload( projectNameRef.current, nodesRef.current, edgesRef.current, simulationConfigRef.current, ); localStorage.setItem(AUTOSAVE_KEY, JSON.stringify(project)); setSaveState(`已自动保存 ${formatTime(new Date())}`); } catch (error) { setSaveState(`自动保存失败:${formatError(error)}`); } }, 600); return () => window.clearTimeout(timer); }, [autosaveReady, nodeDragActive, projectChangeSignature, recoveryProject]); useEffect(() => { const syncViewFromLocation = () => { if (pendingPasteRef.current) { if (window.location.hash !== "#/modeling") { window.history.replaceState( { workspaceView: "modeling" }, "", "#/modeling", ); } setActiveView("modeling"); return; } setActiveView(workspaceViewFromLocation()); }; window.addEventListener("hashchange", syncViewFromLocation); window.addEventListener("popstate", syncViewFromLocation); return () => { window.removeEventListener("hashchange", syncViewFromLocation); window.removeEventListener("popstate", syncViewFromLocation); }; }, []); useEffect(() => { if (activeView !== "modeling") { cancelPortConnection(); } }, [activeView, cancelPortConnection]); useEffect(() => { if (activeView === "results" && hasUnreadResults) { setHasUnreadResults(false); } }, [activeView, hasUnreadResults]); useEffect(() => { if (activeView !== "modeling") { return; } const handleKeyDown = (event: KeyboardEvent) => { const key = event.key.toLowerCase(); const commandKey = event.ctrlKey || event.metaKey; if (event.key === "Escape" && pendingPasteRef.current) { event.preventDefault(); cancelPendingPaste(); return; } if (event.key === "Escape" && activePortConnection) { event.preventDefault(); cancelPortConnection(); return; } if (shouldPreserveNativeConsoleShortcut(event, commandKey, key)) { return; } if (event.ctrlKey && !event.shiftKey && key === "r") { event.preventDefault(); pendingPasteRef.current ? rotatePendingPaste() : rotateSelection(); return; } if ( isEditableTarget(event.target) || (event.target instanceof Element && Boolean(event.target.closest("[data-editor-shortcuts-ignore='true']"))) ) { return; } if ( pendingPasteRef.current && ((event.shiftKey && !commandKey && !event.altKey && key === "h") || (commandKey && ["a", "c", "v", "y", "z"].includes(key))) ) { event.preventDefault(); return; } 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, cancelPendingPaste, cancelPortConnection, copySelection, mirrorSelection, pasteSelection, redo, rotatePendingPaste, rotateSelection, selectAllElements, undo, ]); const simulationConsole = USE_DOCKED_SIMULATION_CONSOLE ? ( setConsoleEntries([])} onModeChange={changeConsoleMode} onStop={() => void stopSimulation()} progress={simulationProgress} stopPending={simulationStopPending} /> ) : ( setConsoleEntries([])} onModeChange={changeConsoleMode} onStop={() => void stopSimulation()} progress={simulationProgress} stopPending={simulationStopPending} /> ); const handleDragConnectStart = useCallback( (_event, { nodeId, handleId }) => beginPortConnection(nodeId, handleId, "drag"), [beginPortConnection], ); const handleClickConnectStart = useCallback( (_event, { nodeId, handleId }) => beginPortConnection(nodeId, handleId, "click"), [beginPortConnection], ); const handleModelingEdgeClick = useCallback< EdgeMouseHandler >( (_event, edge) => { cancelPortConnection(); setSelectedEdgeId(edge.id); setSelectedNodeId(null); }, [cancelPortConnection], ); const handleModelingEdgesDelete = useCallback(() => { setSelectedEdgeId(null); }, []); const handleModelingNodeClick = useCallback< NodeMouseHandler >( (event, node) => { if ( !(event.target instanceof Element) || !event.target.closest(".port-handle") ) { cancelPortConnection(); } setSelectedNodeId(node.id); setSelectedEdgeId(null); }, [cancelPortConnection], ); const handleModelingNodesDelete = useCallback(() => { setSelectedNodeId(null); cancelPortConnection(); }, [cancelPortConnection]); const handleModelingPaneClick = useCallback( (event: ReactMouseEvent) => { setSelectedNodeId(null); setSelectedEdgeId(null); const connectionDraft = portConnectionDraftRef.current; if (activePortConnection?.mode === "click" && connectionDraft) { const waypoint = reactFlow.screenToFlowPosition( { x: event.clientX, y: event.clientY }, { snapToGrid: false }, ); const previousWaypoint = connectionDraft.waypoints.at(-1); if ( !previousWaypoint || Math.hypot( waypoint.x - previousWaypoint.x, waypoint.y - previousWaypoint.y, ) > 1 ) { const nextDraft = { pointer: waypoint, waypoints: [...connectionDraft.waypoints, waypoint], }; portConnectionDraftRef.current = nextDraft; setPortConnectionDraft(nextDraft); } return; } cancelPortConnection(); }, [activePortConnection, cancelPortConnection, reactFlow], ); const handleModelingSelectionChange = useCallback< OnSelectionChangeFunc >(({ nodes: selectedNodes, edges: selectedEdges }) => { setSelectedNodeId(selectedNodes.length === 1 ? selectedNodes[0].id : null); setSelectedEdgeId( selectedNodes.length === 0 && selectedEdges.length === 1 ? selectedEdges[0].id : null, ); }, []); 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 draftError = componentParameterDraftErrors[ `${selectedNode.id}:${key}` ]; const error = draftError || componentParameterValidationMessage( definition, baseValue, selectedUnit, gasReference ? amesimGasMediumOptions : undefined, selectedDefinition.type, key, ); 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, selectedDefinition.type, name, ), ); }); 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="拖动调整组件库宽度,双击恢复默认" >
{ if (pendingPasteRef.current) { event.dataTransfer.dropEffect = "none"; return; } event.preventDefault(); event.dataTransfer.dropEffect = "move"; }} onDrop={onDrop} onPointerCancelCapture={() => { pendingPasteMiddlePointerRef.current = null; setNodeDragActive(false); contactDragSessionRef.current = null; clearContactSnapCandidate(); handleTightSelectionPointerCancel(); }} onPointerDownCapture={handleFlowPointerDownCapture} onPointerMoveCapture={handleFlowPointerMoveCapture} onPointerUpCapture={handleFlowPointerUpCapture} > { modelingViewportRef.current = { ...viewport }; syncPendingPasteToPointer(); }} onPaneClick={handleModelingPaneClick} onSelectionChange={handleModelingSelectionChange} onSelectionEnd={finishTightSelection} panActivationKeyCode="Space" panOnDrag={MODELING_PAN_ON_DRAG} panOnScroll panOnScrollMode={PanOnScrollMode.Vertical} panOnScrollSpeed={1} preventScrolling proOptions={MODELING_PRO_OPTIONS} selectionMode={SelectionMode.Partial} selectionOnDrag={!pendingPaste} snapGrid={MODELING_SNAP_GRID} snapToGrid={snapEnabled} zoomActivationKeyCode="Control" zoomOnDoubleClick={false} zoomOnPinch zoomOnScroll={false} > 0} expectedNodeCount={renderedNodes.length} requestKey={modelingFitRequest} /> node.id)} requestKey={modelingFitRequest} /> {pendingPaste ? ( 待放置:左键放置 · 中键旋转 · Esc 取消 ) : null} {!pendingPaste && activePortConnection?.mode === "click" ? ( 连接模式:单击空白添加折点 · 单击兼容端口完成 · Esc 取消 ) : null} {recoveryProject ? ( 检测到上次自动保存的工程 ) : null}
{USE_DOCKED_SIMULATION_CONSOLE ? simulationConsole : 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="拖动调整参数面板宽度,双击恢复默认" >
{!USE_DOCKED_SIMULATION_CONSOLE ? simulationConsole : null} {paletteIconPreview && typeof document !== "undefined" ? createPortal( , document.body, ) : null} ); } function buildProjectPayload( name: string, nodes: SimulationNode[], edges: SimulationEdge[], simulation: SimulationConfig, calculateContactGeometry = true, ): ReactFlowProjectPayload { const contactGeometry = calculateContactGeometry ? buildNodePortGeometryIndex(nodes) : null; return { projectSchemaVersion: 1, name: name.trim() || "untitled", 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, modelVersion: node.data.modelVersion, 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) => { if ( typeof edge.sourceHandle !== "string" || typeof edge.targetHandle !== "string" ) { throw new Error(`连接 ${edge.id} 缺少明确的接口标识`); } const routePoints = routePointsForEdge(edge); return { id: edge.id, source: edge.source, target: edge.target, sourceHandle: edge.sourceHandle, targetHandle: edge.targetHandle, data: { isContactEdge: contactGeometry ? isContactEdgeGeometryInIndex(contactGeometry, edge) : Boolean(edge.data?.isContactEdge), ...(routePoints.length > 0 ? { routePoints: routePoints.map((point) => ({ ...point, })), } : {}), }, }; }), 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, ) { // 只有一个固定选项的参数不是用户可配置项,不在右侧参数表制造无效行。 if (definition.editor === "choice" && definition.options?.length === 1) { return false; } 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; } /** 升级本次合同变更前保存的 AMESim 机械元件,保留其可执行语义。 */ function migrateLegacyAmesimComponents( project: ReactFlowProjectPayload, componentDefinitions: ComponentDefinition[], ) { const lmechn1Definition = componentDefinitions.find( (definition) => definition.type === "amesim_lmechn1" && definition.modelType === "amesim_lmechn1", ); const forcDefinition = componentDefinitions.find( (definition) => definition.type === "amesim_forc" && definition.modelType === "amesim_forc", ); const referencePortByNodeId = new Map(); let migratedForcNodeCount = 0; const nodes = project.nodes.map((node) => { const isLegacyLmechn1 = node.data.componentType === "amesim_lmechn1" && node.data.modelType === "amesim_lmechn1" && node.data.modelVersion === "0.1.0"; if ( isLegacyLmechn1 && lmechn1Definition && lmechn1Definition.modelVersion !== "0.1.0" ) { const rightPortCount = Number(node.data.parameters.v1); if ( Number.isInteger(rightPortCount) && rightPortCount >= 1 && rightPortCount <= 8 ) { referencePortByNodeId.set(node.id, `port_${rightPortCount + 1}`); return { ...node, data: { ...node.data, modelVersion: lmechn1Definition.modelVersion, // 旧 sum 数值没有参与方程;新合同仅公开实际支持的标准节点模式。 parameters: { ...node.data.parameters, sum: 1 }, }, }; } } const isLegacyForc = node.data.componentType === "amesim_forc" && node.data.modelType === "amesim_forc" && node.data.modelVersion === "0.1.0"; if ( isLegacyForc && forcDefinition && forcDefinition.modelVersion !== "0.1.0" ) { migratedForcNodeCount += 1; return { ...node, data: { ...node.data, modelVersion: forcDefinition.modelVersion, parameters: { ...node.data.parameters, direction: node.data.parameters.direction ?? 1, }, }, }; } return node; }); const edges = project.edges.map((edge) => { const sourceReferencePort = referencePortByNodeId.get(edge.source); const targetReferencePort = referencePortByNodeId.get(edge.target); return { ...edge, sourceHandle: sourceReferencePort && edge.sourceHandle === "port_9" ? sourceReferencePort : edge.sourceHandle, targetHandle: targetReferencePort && edge.targetHandle === "port_9" ? targetReferencePort : edge.targetHandle, }; }); return { project: { ...project, nodes, edges }, migratedLmechn1NodeCount: referencePortByNodeId.size, migratedForcNodeCount, }; } function normalizeLoadedNode( node: ProjectNodePayload, componentDefinitions: ComponentDefinition[], ): SimulationNode { const definition = componentDefinitions.find((item) => item.type === node.data.componentType) ?? componentDefinitions.find((item) => item.modelType === node.data.modelType); const rawParameters = node.data.parameters; const modelContractCompatible = Boolean( definition && node.data.componentType === definition.type && node.data.modelType === definition.modelType && node.data.modelVersion === definition.modelVersion, ); const parameters = definition && modelContractCompatible ? { ...defaultParameters(definition), ...rawParameters } : { ...rawParameters }; if ( modelContractCompatible && isAmesimGasMediumDefinition(definition) && !Object.prototype.hasOwnProperty.call(rawParameters, "gi") ) { parameters.gi = ""; } const parameterUnits = definition && modelContractCompatible ? { ...defaultParameterUnits(definition), ...(node.data.parameterUnits ?? {}) } : { ...(node.data.parameterUnits ?? {}) }; const parameterScientificNotation = normalizeParameterScientificNotation( node.data.parameterScientificNotation, ); const modelContractIssues = loadedNodeModelContractIssues( node, definition, ); 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, modelVersion: node.data.modelVersion, ...(modelContractIssues.length > 0 ? { modelContractIssues } : {}), // 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: PortDefinition[], definition?: ComponentDefinition, ): PortDefinition[] { const loadedPorts = rawPorts.map((port) => ({ ...port })); if (!definition) { return loadedPorts; } // 工程端口只是历史快照。端口名称及 kind/domain/role/flow direction // 都属于当前模型版本的目录合同,不能被旧工程覆盖或凭位置猜测。 return definition.ports.map((port) => ({ ...port })); } function loadedNodeModelContractIssues( node: ProjectNodePayload, definition: ComponentDefinition | undefined, ) { const label = node.data.label || node.id; const issues: string[] = []; if (!definition) { issues.push(`组件“${label}”找不到当前组件目录定义`); return issues; } if ( node.data.componentType !== definition.type || node.data.modelType !== definition.modelType ) { issues.push( `组件“${label}”的工程类型与当前目录不一致(工程 ${node.data.componentType}/${node.data.modelType},目录 ${definition.type}/${definition.modelType})`, ); } if (!node.data.modelVersion) { issues.push(`组件“${label}”缺少 modelVersion,无法确认方程和参数含义`); } else if (node.data.modelVersion !== definition.modelVersion) { issues.push( `组件“${label}”模型版本不匹配(工程 ${node.data.modelVersion},当前 ${definition.modelVersion})`, ); } return issues; } function isPortNominalRole(value: unknown): value is PortNominalRole { return ["inlet", "outlet", "bidirectional", "input", "output"].includes( String(value), ); } function findNodePort( nodes: SimulationNode[], componentId: string | null, portName: string | null, ) { if (!componentId || !portName) { return undefined; } const node = nodes.find((candidate) => candidate.id === componentId); return node ? displayedPortsForNodeData(node.data).find((port) => port.name === portName) : undefined; } function portConnectionKey(nodeId: string, portName: string) { return `${nodeId}:${portName}`; } function isPortConnected( edges: SimulationEdge[], nodeId: string, portName: string, ) { return edges.some( (edge) => (edge.source === nodeId && edge.sourceHandle === portName) || (edge.target === nodeId && edge.targetHandle === portName), ); } function portAcceptsAnotherConnection( edges: SimulationEdge[], nodeId: string, port: PortDefinition, ) { return !isPortConnected(edges, nodeId, port.name); } 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; } return ( portAcceptsAnotherConnection(edges, connection.source, first) && portAcceptsAnotherConnection(edges, connection.target, second) ); } function connectionIdentity(edge: SimulationEdge, unordered: boolean) { const endpoints = [ `${edge.source}:${edge.sourceHandle ?? ""}`, `${edge.target}:${edge.targetHandle ?? ""}`, ]; if (unordered) { endpoints.sort(); } return endpoints.join(unordered ? "--" : "->"); } function loadProjectEdges(edges: ProjectEdgePayload[], nodes: SimulationNode[]) { const loadedEdges: SimulationEdge[] = []; const droppedEdges: Array<{ id: string; reason: string }> = []; const nodeById = new Map(nodes.map((node) => [node.id, node] as const)); const portByNodeId = new Map( nodes.map((node) => [ node.id, new Map( displayedPortsForNodeData(node.data).map((port) => [port.name, port] as const), ), ] as const), ); const occupiedPortKeys = new Set(); edges.forEach((edge) => { const routePoints = Array.isArray(edge.data.routePoints) ? edge.data.routePoints.map((point) => ({ ...point })) : undefined; const candidate: SimulationEdge = { ...edge, data: { ...edge.data, ...(routePoints && routePoints.length > 0 ? { routePoints } : {}), }, type: CONTACT_AWARE_EDGE_TYPE, }; const reason = loadedEdgeContractIssue( nodeById, portByNodeId, occupiedPortKeys, candidate, ); if (reason) { droppedEdges.push({ id: edge.id, reason }); return; } loadedEdges.push(candidate); occupiedPortKeys.add( portConnectionKey(candidate.source, candidate.sourceHandle ?? ""), ); occupiedPortKeys.add( portConnectionKey(candidate.target, candidate.targetHandle ?? ""), ); }); return { edges: loadedEdges, droppedEdges }; } function projectContentSignature( name: string, nodes: SimulationNode[], edges: SimulationEdge[], simulation: SimulationConfig, ) { // 选择、拖动和参数编辑需要快速判断“模型检查结果是否仍有效”。 // 接触边状态可由节点位置和接口信息推导,不应为生成签名再次执行整图几何计算。 return JSON.stringify( buildProjectPayload(name, nodes, edges, simulation, false), ); } function modelValidationSignature( nodes: SimulationNode[], edges: SimulationEdge[], simulation: SimulationConfig, ) { // 画布坐标、旋转、镜像和科学计数法展示格式不参与模型有效性判断。 // 将它们排除后,移动/选择元件不会让检查结果失效,也不会序列化无关展示状态。 return JSON.stringify({ nodes: nodes.map((node) => ({ id: node.id, label: node.data.label, componentType: node.data.componentType, modelType: node.data.modelType, modelVersion: node.data.modelVersion, modelContractIssues: node.data.modelContractIssues, ports: node.data.ports, parameters: node.data.parameters, parameterUnits: node.data.parameterUnits, })), edges: edges.map((edge) => ({ id: edge.id, source: edge.source, sourceHandle: edge.sourceHandle, target: edge.target, targetHandle: edge.targetHandle, })), simulation, }); } function loadedEdgeContractIssue( nodeById: ReadonlyMap, portByNodeId: ReadonlyMap>, occupiedPortKeys: ReadonlySet, edge: SimulationEdge, ) { if (edge.source === edge.target) { return "不允许连接同一组件"; } const sourceNode = nodeById.get(edge.source); const targetNode = nodeById.get(edge.target); if (!sourceNode || !targetNode) { return "引用了不存在的组件"; } const sourcePort = portByNodeId.get(sourceNode.id)?.get(edge.sourceHandle ?? ""); const targetPort = portByNodeId.get(targetNode.id)?.get(edge.targetHandle ?? ""); if (!sourcePort || !targetPort) { const endpoints = [ !sourcePort ? `${sourceNode.data.label}.${edge.sourceHandle ?? "?"}` : null, !targetPort ? `${targetNode.data.label}.${edge.targetHandle ?? "?"}` : null, ].filter(Boolean); return `端口 ${endpoints.join("、")} 不在当前组件目录中(可能已删除或改名)`; } if (!arePortsCompatible(sourcePort, targetPort)) { return "当前目录中的端口类型、物理域或信号方向不兼容"; } if ( occupiedPortKeys.has(portConnectionKey(sourceNode.id, sourcePort.name)) || occupiedPortKeys.has(portConnectionKey(targetNode.id, targetPort.name)) ) { return "当前目录合同不允许端口被多条连接占用"; } return null; } function takeNextNodeId( componentType: string, reservedNodeIds: ReadonlySet = new Set(), ) { let nextNumber = nextNodeNumberByType[componentType] ?? 1; let candidate = `${componentType}_${nextNumber}`; while (reservedNodeIds.has(candidate)) { nextNumber += 1; candidate = `${componentType}_${nextNumber}`; } nextNodeNumberByType[componentType] = nextNumber + 1; return candidate; } function takeNextCopiedEdgeId(index: number, reservedEdgeIds: ReadonlySet) { const baseId = `edge-copy-${Date.now()}-${index}`; let candidate = baseId; let suffix = 1; while (reservedEdgeIds.has(candidate)) { candidate = `${baseId}-${suffix}`; suffix += 1; } return candidate; } 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 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[], componentType?: string, parameterName?: string, ) { if (gasMediumOptions !== undefined) { const gasReferenceIndex = normalizeAmesimGasIndex(value); if ( gasReferenceIndex === null || !gasMediumOptions.some((option) => option.value === gasReferenceIndex) ) { return "引用的介质定义不存在"; } } if (componentType === "amesim_lmechn1" && parameterName === "v1") { const resolved = resolveParameterValue(value, definition, selectedUnit); if (resolved.ok) { if (resolved.value > LMECHN1_MAX_RIGHT_PORT_COUNT) { return "右侧端口数量不能超过20"; } if (resolved.value < 1) { return "右侧端口数量不能小于1"; } if (!Number.isInteger(resolved.value)) { 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; } const modelContractIssues = new Set(node.data.modelContractIssues ?? []); if ( node.data.componentType !== definition.type || node.data.modelType !== definition.modelType ) { modelContractIssues.add( `${node.data.label}:工程组件类型与当前目录定义不一致`, ); } if (!node.data.modelVersion) { modelContractIssues.add( `${node.data.label}:缺少 modelVersion,无法确认方程和参数含义`, ); } else if (node.data.modelVersion !== definition.modelVersion) { modelContractIssues.add( `${node.data.label}:模型版本不匹配(工程 ${node.data.modelVersion},当前 ${definition.modelVersion})`, ); } if (definition.modelType === "amesim_mecmas21") { (["useFriction", "strib"] as const).forEach((name) => { const value = numericChoiceValue(node.data.parameters[name]); if (value !== 1 && value !== 2) { modelContractIssues.add( `${node.data.label}:${name}=${String(node.data.parameters[name])} 不是统一的 AMESim 1/2 编码`, ); } }); } modelContractIssues.forEach((message) => addIssue("error", message, { nodeId: node.id }), ); Object.entries(definition.parameters).forEach(([key, parameter]) => { const selectedUnit = node.data.parameterUnits[key] ?? parameter.unit ?? ""; const message = componentParameterValidationMessage( parameter, node.data.parameters[key] ?? "", selectedUnit, undefined, definition.type, key, ); 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 }, ); } }); } displayedPortsForNodeData(node.data).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 = findNodePort(nodes, source.id, edge.sourceHandle ?? null); const targetPort = findNodePort(nodes, target.id, edge.targetHandle ?? null); 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 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 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) && snapshot.project?.projectSchemaVersion === 1 && typeof snapshot.project?.name === "string" && 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 ( snapshot.signature || projectContentSignature( 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 isProjectRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } function isParameterValueRecord(value: unknown) { return ( isProjectRecord(value) && Object.values(value).every( (entry) => typeof entry === "string" || typeof entry === "number", ) ); } function isStringRecord(value: unknown) { return ( isProjectRecord(value) && Object.values(value).every((entry) => typeof entry === "string") ); } function isProjectPortDefinitionValue(value: unknown): value is PortDefinition { if (!isProjectRecord(value)) { return false; } return ( typeof value.name === "string" && value.name.length > 0 && (value.kind === "physical" || value.kind === "signal") && typeof value.domain === "string" && value.domain.length > 0 && isPortNominalRole(value.nominalRole) && (value.positiveFlowDirection === undefined || value.positiveFlowDirection === "intoComponent") && (value.side === "left" || value.side === "right") ); } function isProjectNodePayloadValue(value: unknown): value is ProjectNodePayload { if ( !isProjectRecord(value) || !isProjectRecord(value.position) || !isProjectRecord(value.data) ) { return false; } const data = value.data; if ( typeof value.id !== "string" || value.id.length === 0 || value.type !== "simulationComponent" || !Number.isFinite(value.position.x) || !Number.isFinite(value.position.y) || typeof data.label !== "string" || typeof data.componentType !== "string" || data.componentType.length === 0 || typeof data.modelType !== "string" || data.modelType.length === 0 || (data.modelVersion !== undefined && typeof data.modelVersion !== "string") || !Array.isArray(data.ports) || !data.ports.every(isProjectPortDefinitionValue) || !isParameterValueRecord(data.parameters) || (data.parameterUnits !== undefined && !isStringRecord(data.parameterUnits)) || (data.parameterScientificNotation !== undefined && !isProjectRecord(data.parameterScientificNotation)) || ![0, 90, 180, 270].includes(Number(data.rotation)) || typeof data.mirrored !== "boolean" ) { return false; } const portNames = data.ports.map((port) => port.name); return new Set(portNames).size === portNames.length; } function isProjectEdgePayloadValue(value: unknown): value is ProjectEdgePayload { if (!( isProjectRecord(value) && typeof value.id === "string" && value.id.length > 0 && typeof value.source === "string" && value.source.length > 0 && typeof value.target === "string" && value.target.length > 0 && typeof value.sourceHandle === "string" && value.sourceHandle.length > 0 && typeof value.targetHandle === "string" && value.targetHandle.length > 0 && isProjectRecord(value.data) && typeof value.data.isContactEdge === "boolean" )) { return false; } const routePoints = value.data.routePoints; return ( routePoints === undefined || (Array.isArray(routePoints) && routePoints.every( (point) => isProjectRecord(point) && typeof point.x === "number" && Number.isFinite(point.x) && typeof point.y === "number" && Number.isFinite(point.y), )) ); } function isSimulationConfigValue(value: unknown): value is SimulationConfig { if (!isProjectRecord(value) || typeof value.method !== "string") { return false; } return [value.t_start, value.t_stop, value.step, value.max_step].every( (entry) => typeof entry === "number" || typeof entry === "string", ); } function parseProjectPayload(raw: string | null): ReactFlowProjectPayload | null { if (!raw) { return null; } const value = JSON.parse(raw) as unknown; if (!isProjectRecord(value)) { return null; } if ( value.projectSchemaVersion !== 1 || typeof value.name !== "string" || !Array.isArray(value.nodes) || !value.nodes.every(isProjectNodePayloadValue) || !Array.isArray(value.edges) || !value.edges.every(isProjectEdgePayloadValue) || !isSimulationConfigValue(value.simulation) ) { return null; } const project = value as ReactFlowProjectPayload; const nodeIds = project.nodes.map((node) => node.id); const edgeIds = project.edges.map((edge) => edge.id); if ( new Set(nodeIds).size !== nodeIds.length || new Set(edgeIds).size !== edgeIds.length ) { return null; } // 连接是否仍符合当前目录合同只能在目录加载后判断。解析阶段保留它们, // applyProject 会按当前端口定义逐条丢弃失效连接并向用户报告原因。 return project; } 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 nodeIsInsideElement(node: Node | null, element: Element) { if (!node) { return false; } return element.contains(node.nodeType === Node.TEXT_NODE ? node.parentElement : node); } function shouldPreserveNativeConsoleShortcut( event: KeyboardEvent, commandKey: boolean, key: string, ) { const targetInsideConsole = event.target instanceof Element && Boolean(event.target.closest("[data-editor-shortcuts-ignore='true']")); if (targetInsideConsole) { return true; } if (!commandKey || key !== "c") { return false; } const selection = window.getSelection(); if (!selection || selection.isCollapsed || selection.rangeCount === 0) { return false; } const consoleElement = document.querySelector( "[data-editor-shortcuts-ignore='true']", ); if (!consoleElement) { return false; } return ( nodeIsInsideElement(selection.anchorNode, consoleElement) || nodeIsInsideElement(selection.focusNode, consoleElement) || nodeIsInsideElement( selection.getRangeAt(0).commonAncestorContainer, consoleElement, ) ); } function formatTime(date: Date) { return date.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" }); } function projectExecutionContractIssues( project: ReactFlowProjectPayload, componentDefinitions: ComponentDefinition[], sourceNodes: SimulationNode[] = [], ) { const issues = new Set( sourceNodes.flatMap((node) => node.data.modelContractIssues ?? []), ); project.nodes.forEach((node) => { const label = node.data.label || node.id; const definition = componentDefinitions.find( (candidate) => candidate.type === node.data.componentType, ); if (!definition) { issues.add(`组件“${label}”找不到当前组件目录定义`); return; } if (node.data.modelType !== definition.modelType) { issues.add( `组件“${label}”模型类型不匹配(工程 ${node.data.modelType},当前 ${definition.modelType})`, ); } if (!node.data.modelVersion) { issues.add(`组件“${label}”缺少 modelVersion,无法确认方程和参数含义`); } else if (node.data.modelVersion !== definition.modelVersion) { issues.add( `组件“${label}”模型版本不匹配(工程 ${node.data.modelVersion},当前 ${definition.modelVersion})`, ); } if (definition.modelType !== "amesim_mecmas21") { return; } (["useFriction", "strib"] as const).forEach((name) => { const value = numericChoiceValue(node.data.parameters[name]); if (value !== 1 && value !== 2) { issues.add( `组件“${label}”的 ${name}=${String(node.data.parameters[name])} 不是统一的 AMESim 1/2 编码`, ); } }); }); return [...issues]; } function buildSystemXml( project: ReactFlowProjectPayload, componentDefinitions: ComponentDefinition[], sourceNodes: SimulationNode[] = [], ) { const contractIssues = projectExecutionContractIssues( project, componentDefinitions, sourceNodes, ); if (contractIssues.length > 0) { throw new Error( `工程包含不兼容的模型合同,不能生成执行 XML:\n- ${contractIssues.join("\n- ")}`, ); } 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; if (project.name.trim()) { system.setAttribute("name", project.name.trim()); } system.setAttribute("schemaVersion", "3"); 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("sampleStep", 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"); const registeredPortsByNodeId = new Map(); const exportedEdgeIds = new Set(); system.append(simulation, components, connections); project.nodes.forEach((node) => { if (registeredPortsByNodeId.has(node.id)) { throw new Error(`工程包含重复的组件 ID:${node.id}`); } const definitionByComponentType = componentDefinitions.find( (candidate) => candidate.type === node.data.componentType, ); const definitionByModelType = componentDefinitions.find( (candidate) => candidate.modelType === node.data.modelType, ); const definition = definitionByComponentType ?? definitionByModelType; if (!definition) { throw new Error( `组件“${node.data.label || node.id}”找不到目录定义,无法确定模型版本`, ); } if ( definition.type !== node.data.componentType || definition.modelType !== node.data.modelType ) { throw new Error( `组件“${node.data.label || node.id}”的工程类型与目录定义不一致:` + `componentType=${node.data.componentType},modelType=${node.data.modelType}`, ); } registeredPortsByNodeId.set( node.id, displayedPortsForNodeData({ componentType: definition.type, modelType: definition.modelType, ports: definition.ports, parameters: node.data.parameters, }), ); const unknownParameters = Object.keys(node.data.parameters).filter( (name) => !Object.prototype.hasOwnProperty.call(definition.parameters, name), ); if (unknownParameters.length > 0) { throw new Error( `组件“${node.data.label || node.id}”包含目录未定义的参数:${unknownParameters.join( "、", )}`, ); } const component = xmlDocument.createElement("Component"); component.setAttribute("id", node.id); component.setAttribute("type", definition.modelType); component.setAttribute("modelVersion", definition.modelVersion); Object.entries(definition.parameters).forEach(([name, parameterDefinition]) => { const value = node.data.parameters[name] ?? parameterDefinition.default; const selectedUnit = node.data.parameterUnits?.[name] ?? parameterDefinition?.unit ?? ""; const validationMessage = componentParameterValidationMessage( parameterDefinition, value, selectedUnit, undefined, definition.type, name, ); if (validationMessage) { throw new Error( `组件“${node.data.label || node.id}”的参数“${parameterDefinition.label}”:${validationMessage}`, ); } const resolved = resolveParameterValue(value, parameterDefinition, selectedUnit); if (!resolved.ok) { throw new Error( `组件“${node.data.label || node.id}”的参数“${parameterDefinition.label}”:${resolved.message}`, ); } const parameter = xmlDocument.createElement("Parameter"); parameter.setAttribute("name", name); // Number#toString emits the shortest decimal that round-trips to the // same IEEE-754 value. UI formatting intentionally uses fewer digits, // but the execution XML must not change the resolved physical input. parameter.setAttribute("value", String(resolved.value)); component.appendChild(parameter); }); components.appendChild(component); }); project.edges.forEach((edge) => { if (exportedEdgeIds.has(edge.id)) { throw new Error(`工程包含重复的连接 ID:${edge.id}`); } exportedEdgeIds.add(edge.id); const metadata = projectConnectionMetadata( registeredPortsByNodeId, edge, ); const connection = xmlDocument.createElement("Connection"); connection.setAttribute("id", edge.id); metadata.endpoints.forEach((endpoint) => { const endpointElement = xmlDocument.createElement("Endpoint"); endpointElement.setAttribute("component", endpoint.component); endpointElement.setAttribute("port", endpoint.port); connection.appendChild(endpointElement); }); connections.appendChild(connection); }); indentXmlElement(system); return `\n${new XMLSerializer().serializeToString(system)}`; } function projectConnectionMetadata( registeredPortsByNodeId: Map, edge: ProjectEdgePayload, ) { const first = findRegisteredPort( registeredPortsByNodeId, edge.source, edge.sourceHandle, ); const second = findRegisteredPort( registeredPortsByNodeId, 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 findRegisteredPort( registeredPortsByNodeId: Map, componentId: string, portName: string | null, ) { if (!portName) { return undefined; } return registeredPortsByNodeId .get(componentId) ?.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, watchdog?: SimulationActivityWatchdogObservation, ) => 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 activityWatchdog = createSimulationActivityWatchdog(); 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; let watchdogObservation: SimulationActivityWatchdogObservation | undefined; if (event.event === "progress") { const watched = observeSimulationActivityWatchdog( activityWatchdog, event, ); activityWatchdog = watched.state; if (isSimulationSolverActivityPhase(event.phase)) { watchdogObservation = watched.observation; if (watchdogObservation.status === "stalled") { throw new SimulationStreamError( watchdogObservation.activityTelemetryAvailable ? solverActivityStallTimeoutMessage("detected") : solverStallTimeoutMessage("detected"), [], undefined, "SOLVER_STALLED", ); } } } onEvent(event, watchdogObservation); 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 ( ); }