Files
SystemSimulationApp/frontend/src/App.tsx
T

9116 lines
285 KiB
TypeScript
Raw Blame History

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