Files
SystemSimulationApp/frontend/src/App.tsx
T
ljz 11093613c5 完善 PNVO001 与 PNRP17 前端图标及端口交互
按 AMESim 参考样式重绘孔板与气动活塞图标,调整端口锚点和旋转后的连接方向,并补充组件图标及接触连接回归测试。
2026-08-03 09:49:55 +08:00

7179 lines
222 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,
useStoreApi,
useUpdateNodeInternals,
} from "@xyflow/react";
import {
EmptySimulationResultsView,
SimulationResultsView,
type ResultVariableMetadata,
type SimulationResultsSnapshot,
} from "./SimulationResultsView";
import { AutoFitView } from "./AutoFitView";
import {
ComponentSymbol,
componentSymbolPortAnchors,
componentSymbolPresentation,
hasComponentSymbol,
} from "./ComponentSymbol";
import type { ComponentSymbolPortAnchors } from "./componentSymbols/types";
import {
CONTACT_AWARE_EDGE_TYPE,
contactAwareEdgeTypes,
} from "./ContactAwareEdge";
import {
ParameterTable,
loadParameterColumnLayout,
saveParameterColumnLayout,
type ParameterColumnLayout,
type ParameterTableRow,
} from "./ParameterTable";
import {
evaluateParameterExpression,
isParameterExpressionValue,
} from "./parameterExpression";
import {
WorkspaceViewTabs,
type WorkspaceView,
} from "./WorkspaceViewTabs";
type ParameterValue = number | string;
type UnitQuantity = 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 ContactSnapCandidate = {
connection: Connection;
draggedNodeId: string;
draggedPortName: string;
snappedPosition: { x: number; y: number };
targetNodeId: string;
targetPortName: string;
};
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?: "amesimGasReference" | "amesimGasPropertyModel";
options?: ParameterOption[];
};
type ParameterOption = {
value: number;
label: string;
};
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>;
};
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?: "amesimGasReference" | "amesimGasPropertyModel";
options?: ParameterOption[];
};
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[];
};
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>;
rotation: NodeRotation;
mirrored: boolean;
portConnectionStates?: Record<string, PortConnectionState>;
};
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"> & {
parameterUnits?: Record<string, string>;
};
};
type ProjectEdgePayload = {
id: string;
source: string;
target: string;
sourceHandle: string | null;
targetHandle: string | null;
};
type ReactFlowProjectPayload = {
name: string;
mediumReferenceVersion?: 1;
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 NODE_FRAME_WIDTH = 132;
const NODE_FRAME_HEIGHT = 84;
const NODE_SYMBOL_WIDTH = 112;
const NODE_SYMBOL_HEIGHT = 84;
const SYMBOL_VIEWBOX_WIDTH = 64;
const SYMBOL_VIEWBOX_HEIGHT = 48;
const CONTACT_SNAP_RADIUS_PX = 24;
const CONTACT_DISCONNECT_DISTANCE_PX = 8;
const CONTACT_GEOMETRY_TOLERANCE = 0.5;
const CONTACT_DELETE_SEPARATION = 16;
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);
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: Object.fromEntries(
component.parameters.map((parameter) => [
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 === "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 }]
: [],
),
}
: {}),
},
]),
),
} 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 orderedParameterEntries(definition: ComponentDefinition) {
return Object.entries(definition.parameters).sort(
([firstKey], [secondKey]) =>
Number(secondKey === "gi") - Number(firstKey === "gi"),
);
}
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),
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 | undefined,
): AmesimGasReferenceRepair {
const hasVersionMarker = mediumReferenceVersion !== undefined;
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 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 portAnchors = hasDedicatedSymbol
? componentSymbolPortAnchors(symbol)
: undefined;
const portPlacements = transformedPortPlacements(
data.ports,
rotation,
mirrored,
portAnchors,
);
useEffect(() => {
updateNodeInternals(id);
}, [mirrored, rotation, id, 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" : ""}`}
>
<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"
style={{
transform: `rotate(${rotation}deg) scaleX(${mirrored ? -1 : 1})`,
}}
>
<ComponentSymbol symbol={symbol} surface="canvas" />
</div>
</div>
) : null}
<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 nodeFrameDimensions(rotation: NodeRotation) {
const vertical = rotation === 90 || rotation === 270;
return vertical
? { width: NODE_FRAME_HEIGHT, height: NODE_FRAME_WIDTH }
: { width: NODE_FRAME_WIDTH, height: NODE_FRAME_HEIGHT };
}
function rotateNodeClockwise(node: SimulationNode): SimulationNode {
const currentRotation = normalizeNodeRotation(node.data.rotation);
const nextRotation = nextNodeRotation(currentRotation);
const currentSize = nodeFrameDimensions(currentRotation);
const nextSize = nodeFrameDimensions(nextRotation);
return {
...node,
position: {
x: node.position.x + (currentSize.width - nextSize.width) / 2,
y: node.position.y + (currentSize.height - nextSize.height) / 2,
},
data: {
...node.data,
rotation: nextRotation,
},
};
}
function portHandleType(port: PortDefinition) {
if (port.kind === "signal") {
return port.nominalRole === "output" ? ("source" as const) : ("target" as const);
}
return "source" as const;
}
function transformedPortPlacements(
ports: PortDefinition[],
rotation: NodeRotation,
mirrored: boolean,
portAnchors?: ComponentSymbolPortAnchors,
) {
const renderedSize = nodeFrameDimensions(rotation);
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;
let x = anchor
? (anchor.x / SYMBOL_VIEWBOX_WIDTH - 0.5) * NODE_SYMBOL_WIDTH
: side === "left"
? -NODE_FRAME_WIDTH / 2
: NODE_FRAME_WIDTH / 2;
let y = anchor
? (anchor.y / SYMBOL_VIEWBOX_HEIGHT - 0.5) * NODE_SYMBOL_HEIGHT
: -NODE_FRAME_HEIGHT / 2 +
((index + 1) * NODE_FRAME_HEIGHT) / (sidePorts.length + 1);
if (mirrored) {
x = -x;
}
[x, y] = rotatePoint(x, y, rotation);
const renderedX = renderedSize.width / 2 + x;
const renderedY = renderedSize.height / 2 + y;
return {
port,
position: transformedPortPosition(side, rotation, mirrored, anchor),
x: renderedX,
y: renderedY,
};
});
});
}
function rotatePoint(x: number, y: number, rotation: NodeRotation): [number, number] {
if (rotation === 90) {
return [-y, x];
}
if (rotation === 180) {
return [-x, -y];
}
if (rotation === 270) {
return [y, -x];
}
return [x, y];
}
function transformedPortPosition(
side: PortSide,
rotation: NodeRotation,
mirrored: boolean,
anchor?: { x: number; y: number },
) {
let directionX = side === "left" ? -1 : 1;
let directionY = 0;
if (anchor) {
const closestEdge = [
{ distance: anchor.x, x: -1, y: 0 },
{ distance: SYMBOL_VIEWBOX_WIDTH - anchor.x, x: 1, y: 0 },
{ distance: anchor.y, x: 0, y: -1 },
{ distance: SYMBOL_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 nodePortFlowPlacements(node: SimulationNode) {
const rotation = normalizeNodeRotation(node.data.rotation);
const symbol = node.data.symbol ?? node.data.componentType;
const anchors = hasComponentSymbol(symbol)
? componentSymbolPortAnchors(symbol)
: undefined;
return transformedPortPlacements(
node.data.ports,
rotation,
Boolean(node.data.mirrored),
anchors,
).map((placement) => ({
...placement,
flowX: node.position.x + placement.x,
flowY: node.position.y + placement.y,
}));
}
function nodePortFlowPlacement(node: SimulationNode, portName: string | null) {
if (!portName) {
return undefined;
}
return nodePortFlowPlacements(node).find(
(placement) => placement.port.name === portName,
);
}
function connectionForContactPorts(
draggedNode: SimulationNode,
draggedPort: PortDefinition,
targetNode: SimulationNode,
targetPort: PortDefinition,
): Connection {
if (draggedPort.kind === "signal" && draggedPort.nominalRole === "input") {
return {
source: targetNode.id,
sourceHandle: targetPort.name,
target: draggedNode.id,
targetHandle: draggedPort.name,
};
}
return {
source: draggedNode.id,
sourceHandle: draggedPort.name,
target: targetNode.id,
targetHandle: targetPort.name,
};
}
function findContactSnapCandidate(
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));
}
});
let nearest: (ContactSnapCandidate & { distance: number }) | null = null;
const draggedPlacements = nodePortFlowPlacements(draggedNode);
for (const draggedPlacement of draggedPlacements) {
if (
occupiedPorts.has(
portConnectionKey(draggedNode.id, draggedPlacement.port.name),
)
) {
continue;
}
for (const targetNode of nodes) {
if (targetNode.id === draggedNode.id) {
continue;
}
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 || (nearest && distance >= nearest.distance)) {
continue;
}
const connection = connectionForContactPorts(
draggedNode,
draggedPlacement.port,
targetNode,
targetPlacement.port,
);
if (!canConnectPorts(nodes, edges, connection)) {
continue;
}
nearest = {
connection,
distance,
draggedNodeId: draggedNode.id,
draggedPortName: draggedPlacement.port.name,
snappedPosition: {
x:
draggedNode.position.x +
targetPlacement.flowX -
draggedPlacement.flowX,
y:
draggedNode.position.y +
targetPlacement.flowY -
draggedPlacement.flowY,
},
targetNodeId: targetNode.id,
targetPortName: targetPlacement.port.name,
};
}
}
}
if (!nearest) {
return null;
}
const { distance: _distance, ...candidate } = nearest;
return candidate;
}
function sameContactSnapCandidate(
first: ContactSnapCandidate | null,
second: ContactSnapCandidate | null,
) {
return (
first === second ||
(Boolean(first) &&
Boolean(second) &&
first?.draggedNodeId === second?.draggedNodeId &&
first?.draggedPortName === second?.draggedPortName &&
first?.targetNodeId === second?.targetNodeId &&
first?.targetPortName === second?.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(
normalizeNodeRotation(sourceNode.data.rotation),
);
const targetSize = nodeFrameDimensions(
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}
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 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);
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 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) => {
setResultSnapshot(snapshot);
storeResultSnapshot(snapshot);
appendConsoleEntry("success", `已加载结果文件:${snapshot.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 activeConnectionPort = useMemo(
() =>
activePortConnection
? findNodePort(
nodes,
activePortConnection.nodeId,
activePortConnection.portName,
)
: undefined,
[activePortConnection, nodes],
);
const renderedNodes = useMemo(
() =>
nodes.map((node) => {
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 (
contactSnapCandidate?.draggedNodeId === node.id &&
contactSnapCandidate.draggedPortName === port.name
) {
state = "origin";
} else if (
contactSnapCandidate?.targetNodeId === node.id &&
contactSnapCandidate.targetPortName === port.name
) {
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];
}),
);
return {
...node,
className: validationSeverityByNode.has(node.id)
? `validation-${validationSeverityByNode.get(node.id)}`
: node.className,
data: {
...node.data,
portConnectionStates,
},
};
}),
[
activeConnectionPort,
activePortConnection,
contactSnapCandidate,
connectedPortKeys,
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,
);
if (!canConnectPorts(snappedNodes, session.effectiveEdges, candidate.connection)) {
return;
}
const contactEdge: SimulationEdge = {
...candidate.connection,
id: `edge-contact-${candidate.connection.source}-${candidate.connection.sourceHandle}-${candidate.connection.target}-${candidate.connection.targetHandle}-${Date.now()}`,
type: CONTACT_AWARE_EDGE_TYPE,
};
const connectedEdges = addEdge(contactEdge, session.effectiveEdges);
nodesRef.current = snappedNodes;
edgesRef.current = connectedEdges;
setNodes(snappedNodes);
setEdges(connectedEdges);
setSelectedEdgeId(null);
appendConsoleEntry(
"info",
`接口已吸附连接 ${candidate.connection.source}.${candidate.connection.sourceHandle ?? "port"} 与 ${candidate.connection.target}.${candidate.connection.targetHandle ?? "port"}`,
);
},
[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 icon = event.currentTarget.querySelector<HTMLElement>(".palette-icon");
if (icon) {
const preview = document.createElement("div");
const previewIcon = icon.cloneNode(true) as HTMLElement;
const previewLabel = document.createElement("span");
preview.className = "component-drag-preview";
preview.setAttribute("aria-hidden", "true");
previewLabel.className = "component-drag-preview-label";
previewLabel.textContent = peekNextNodeId(type);
preview.append(previewIcon, previewLabel);
document.body.appendChild(preview);
event.dataTransfer.setDragImage(
preview,
Math.round(preview.offsetWidth / 2),
Math.round(preview.offsetHeight / 2),
);
window.setTimeout(() => preview.remove(), 0);
}
};
const onDrop = useCallback(
(event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
const type = event.dataTransfer.getData("application/system-component");
const definition = componentDefinitions.find((item) => item.type === type);
if (!definition) {
return;
}
const position = reactFlow.screenToFlowPosition({
x: event.clientX,
y: event.clientY,
});
const 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 node: SimulationNode = {
id,
type: "simulationComponent",
position,
data,
};
recordHistory();
setNodes((currentNodes) => [...currentNodes, node]);
setSelectedNodeId(id);
appendConsoleEntry("info", `已添加组件:${id}`);
},
[appendConsoleEntry, componentDefinitions, reactFlow, recordHistory, setNodes],
);
const updateSelectedNode = (updater: (node: SimulationNode) => SimulationNode) => {
if (!selectedNodeId) {
return;
}
setNodes((currentNodes) =>
currentNodes.map((node) => (node.id === selectedNodeId ? updater(node) : node)),
);
};
const updateSelectedParameter = (
key: string,
definition: ParameterDefinition,
displayValue: string,
) => {
const selectedUnit =
selectedNode?.data.parameterUnits[key] ?? definition.unit ?? "";
const unit = findUnitOption(definition, selectedUnit);
const normalizedDisplayValue = displayValue.trim();
const numericValue = Number(normalizedDisplayValue);
const baseValue =
normalizedDisplayValue === "" || !Number.isFinite(numericValue)
? normalizedDisplayValue
: unit.toBase(numericValue);
updateSelectedNode((node) => ({
...node,
data: {
...node.data,
parameters: {
...node.data.parameters,
[key]: baseValue,
},
},
}));
};
const updateSelectedChoice = (key: string, value: number) => {
updateSelectedNode((node) => ({
...node,
data: {
...node.data,
parameters: {
...node.data.parameters,
[key]: value,
},
},
}));
};
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 offset = GRID_SIZE * 2 * 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)
? 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),
);
const indexedMedia = assignMissingAmesimGasDefinitionIndices(
normalizedNodes,
componentDefinitions,
);
const legacyGasMigration = migrateLegacyDefaultAmesimGasReference(
indexedMedia.nodes,
componentDefinitions,
project.mediumReferenceVersion,
);
const loadedNodes = legacyGasMigration.nodes;
setNodes(loadedNodes);
setEdges(project.edges.map(normalizeLoadedEdge));
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 (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: true })));
setSelectedNodeId(null);
setSelectedEdgeId(null);
appendConsoleEntry(
"info",
`已选择 ${nodesRef.current.length} 个组件和 ${edgesRef.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).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,
);
const mediumDefinitionIndex =
isAmesimGasMediumDefinition(selectedDefinition) && key === "gi";
const gasReference =
!mediumDefinitionIndex &&
definition.editor === "amesimGasReference";
const gasPropertyModel =
definition.editor === "amesimGasPropertyModel";
const parameterLabel = mediumDefinitionIndex
? "介质定义索引"
: gasReference
? "介质物性索引"
: definition.label;
const gasReferenceIndex = gasReference
? normalizeAmesimGasIndex(baseValue)
: null;
const gasReferenceAvailable =
gasReferenceIndex !== null &&
amesimGasMediumOptions.some(
(option) => option.value === gasReferenceIndex,
);
const propertyModelValue = Number(baseValue);
const propertyModelAvailable =
gasPropertyModel &&
Number.isFinite(propertyModelValue) &&
(definition.options ?? []).some(
(option) => option.value === propertyModelValue,
);
const error =
gasReference && !gasReferenceAvailable
? "引用的介质定义不存在"
: parameterValidationMessage(
definition,
baseValue,
selectedUnit,
);
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) {
valueControl = (
<select
aria-invalid={Boolean(error)}
aria-label={parameterLabel}
onBlur={finishHistoryEdit}
onChange={(event) =>
updateSelectedChoice(key, Number(event.target.value))
}
onFocus={recordHistory}
value={
propertyModelAvailable ? String(propertyModelValue) : ""
}
>
{!propertyModelAvailable ? (
<option disabled value="">
当前模型无效,请重新选择
</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 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 - 24,
);
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
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}
>
<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,
);
}}
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}
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
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,
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 },
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 normalizeLoadedNode(
node: ProjectNodePayload,
componentDefinitions: ComponentDefinition[],
): SimulationNode {
const definition =
componentDefinitions.find((item) => item.type === node.data.componentType) ??
componentDefinitions.find((item) => item.modelType === node.data.modelType);
const rawParameters = node.data.parameters ?? {};
const parameters = definition
? { ...defaultParameters(definition), ...rawParameters }
: { ...rawParameters };
if (
isAmesimGasMediumDefinition(definition) &&
!Object.prototype.hasOwnProperty.call(rawParameters, "gi")
) {
parameters.gi = "";
}
const parameterUnits = definition
? { ...defaultParameterUnits(definition), ...(node.data.parameterUnits ?? {}) }
: { ...(node.data.parameterUnits ?? {}) };
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,
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 })) ?? [];
}
return rawPorts.flatMap((rawPort, index) => {
const record =
rawPort !== null && typeof rawPort === "object"
? (rawPort as Record<string, unknown>)
: undefined;
const name = typeof rawPort === "string" ? rawPort : record?.name;
if (typeof name !== "string" || !name.trim()) {
return [];
}
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
: registered?.domain ?? (kind === "physical" ? "pneumatic" : "signal"),
nominalRole,
...(kind === "physical" ? { positiveFlowDirection: "intoComponent" as const } : {}),
side,
},
];
});
}
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 ? "--" : "->");
}
function normalizeLoadedEdge(edge: ProjectEdgePayload): SimulationEdge {
return {
id: edge.id,
source: edge.source,
target: edge.target,
sourceHandle: edge.sourceHandle,
targetHandle: edge.targetHandle,
type: "step",
};
}
function takeNextNodeId(componentType: string) {
const nextNumber = nextNodeNumberByType[componentType] ?? 1;
nextNodeNumberByType[componentType] = nextNumber + 1;
return `${componentType}_${nextNumber}`;
}
function peekNextNodeId(componentType: string) {
return `${componentType}_${nextNodeNumberByType[componentType] ?? 1}`;
}
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,
) {
if (typeof baseValue === "string" && baseValue.trim() === "") {
return "";
}
const numericValue = Number(baseValue);
if (!Number.isFinite(numericValue)) {
return String(baseValue);
}
return formatEditableNumber(findUnitOption(definition, selectedUnit).fromBase(numericValue));
}
function formatEditableNumber(value: number) {
if (!Number.isFinite(value)) {
return "";
}
return Number(value.toPrecision(12)).toString();
}
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 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 loadStoredResultSnapshot(): SimulationResultsSnapshot | null {
try {
const raw = sessionStorage.getItem(RESULT_SNAPSHOT_KEY);
if (!raw) {
return null;
}
const value = JSON.parse(raw) as unknown;
if (!isSimulationResultsSnapshot(value)) {
sessionStorage.removeItem(RESULT_SNAPSHOT_KEY);
return null;
}
return value;
} catch {
return null;
}
}
function storeResultSnapshot(snapshot: SimulationResultsSnapshot) {
try {
sessionStorage.setItem(RESULT_SNAPSHOT_KEY, JSON.stringify(snapshot));
} catch {
// Large result sets remain available in memory for the current page.
}
}
function isSimulationResultsSnapshot(
value: unknown,
): value is SimulationResultsSnapshot {
if (!value || typeof value !== "object") {
return false;
}
const snapshot = value as Partial<SimulationResultsSnapshot>;
return (
typeof snapshot.id === "string" &&
typeof snapshot.createdAt === "string" &&
Boolean(snapshot.project) &&
typeof snapshot.project?.name === "string" &&
Array.isArray(snapshot.project?.nodes) &&
Array.isArray(snapshot.project?.edges) &&
Boolean(snapshot.result) &&
Array.isArray(snapshot.result?.variables) &&
Boolean(snapshot.result?.series) &&
Array.isArray(snapshot.result?.series?.time)
);
}
function cloneValue<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 !== 1)
) {
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("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>
);
}