Files
SystemSimulationApp/frontend/src/App.tsx
T

4419 lines
135 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 type {
CSSProperties,
KeyboardEvent as ReactKeyboardEvent,
PointerEvent as ReactPointerEvent,
} from "react";
import {
BellRing,
ChevronDown,
ChevronUp,
ClipboardPaste,
Copy,
Download,
FileCode2,
FileDown,
FileUp,
FlipHorizontal2,
FolderOpen,
Grid2X2,
Grip,
ListChecks,
Magnet,
Maximize2,
Minimize2,
Move,
Play,
Redo2,
RotateCw,
Save,
Square,
SquareTerminal,
Trash2,
Undo2,
type LucideIcon,
} from "lucide-react";
import {
Background,
BackgroundVariant,
ConnectionLineType,
ConnectionMode,
Controls,
Handle,
Panel,
Position,
ReactFlow,
ReactFlowProvider,
SelectionMode,
addEdge,
type Connection,
type Edge,
type EdgeChange,
type Node,
type NodeChange,
type NodeProps,
type NodeTypes,
useEdgesState,
useNodesState,
useReactFlow,
useUpdateNodeInternals,
} from "@xyflow/react";
import {
EmptySimulationResultsView,
SimulationResultsView,
type ResultVariableMetadata,
type SimulationResultsSnapshot,
} from "./SimulationResultsView";
import {
WorkspaceViewTabs,
type WorkspaceView,
} from "./WorkspaceViewTabs";
type ParameterValue = number | string;
type UnitQuantity = "pressure" | "volume" | "temperature" | "length";
type PortKind = "physical" | "signal";
type PortNominalRole = "inlet" | "outlet" | "bidirectional" | "input" | "output";
type PortSide = "left" | "right";
type NodeRotation = 0 | 90 | 180 | 270;
type PortDefinition = {
name: string;
kind: PortKind;
domain: string;
nominalRole: PortNominalRole;
positiveFlowDirection?: "intoComponent";
side: PortSide;
};
type UnitOption = {
value: string;
label: string;
toBase: (value: number) => number;
fromBase: (value: number) => number;
};
type ParameterDefinition = {
label: string;
unit?: string;
quantity?: UnitQuantity;
default: ParameterValue;
min?: number;
max?: number;
minExclusive?: boolean;
};
type ComponentDefinition = {
type: string;
label: string;
modelType: string;
ports: PortDefinition[];
parameters: Record<string, ParameterDefinition>;
};
type SimulationNodeData = {
label: string;
componentType: string;
modelType: string;
ports: PortDefinition[];
parameters: Record<string, ParameterValue>;
parameterUnits: Record<string, string>;
rotation: NodeRotation;
mirrored: boolean;
};
type SimulationNode = Node<SimulationNodeData, "simulationComponent">;
type SimulationEdge = Edge;
type SimulationConfig = {
t_start: number;
t_stop: number;
step: number;
max_step: number;
method: string;
};
type ProjectNodePayload = {
id: string;
type: "simulationComponent";
position: {
x: number;
y: number;
};
data: Omit<SimulationNodeData, "parameterUnits"> & {
parameterUnits?: Record<string, string>;
};
};
type ProjectEdgePayload = {
id: string;
source: string;
target: string;
sourceHandle: string | null;
targetHandle: string | null;
};
type ReactFlowProjectPayload = {
name: string;
nodes: ProjectNodePayload[];
edges: ProjectEdgePayload[];
simulation: SimulationConfig;
};
type SimulationResult = {
success: boolean;
status: "completed" | "stopped" | "stalled" | "failed";
partial: boolean;
message: string;
simulatedUntil: number;
requestedStopTime: number;
variables: ResultVariableMetadata[];
final: Record<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;
};
type ModelingPaneResizeKind = "palette-width" | "properties-width";
type ModelingPaneResizeState = {
kind: ModelingPaneResizeKind;
pointerId: number;
startX: number;
startWidth: number;
};
type CanvasGridVisibility = {
lines: boolean;
dots: boolean;
};
const AUTOSAVE_KEY = "system-simulation-flow:autosave";
const PROJECT_KEY_PREFIX = "system-simulation-flow:project:";
const RESULT_SNAPSHOT_KEY = "system-simulation-flow:latest-result";
const CONSOLE_PLACEMENT_KEY = "system-simulation-flow:console-positions";
const MODELING_PANE_LAYOUT_KEY = "system-simulation-flow:modeling-pane-layout";
const CANVAS_GRID_VISIBILITY_KEY = "system-simulation-flow:canvas-grid-visibility";
const CONSOLE_VIEWPORT_MARGIN = 8;
const SIMULATION_STREAM_IDLE_TIMEOUT_MS = 30_000;
const SIMULATION_SOLVER_STALL_TIMEOUT_MS = 60_000;
const GRID_SIZE = 18;
const MODELING_PANE_SPLITTER_SIZE = 7;
const MIN_PALETTE_WIDTH = 180;
const MAX_PALETTE_WIDTH = 420;
const MIN_PROPERTIES_WIDTH = 280;
const MAX_PROPERTIES_WIDTH = 560;
const MIN_MODELING_CANVAS_WIDTH = 420;
const MODELING_PANE_KEYBOARD_STEP = 20;
const HISTORY_LIMIT = 50;
const NODE_FRAME_WIDTH = 132;
const NODE_FRAME_HEIGHT = 84;
function defaultModelingPaneLayout(): ModelingPaneLayout {
return {
paletteWidth: 230,
propertiesWidth: 330,
};
}
function loadModelingPaneLayout(): ModelingPaneLayout {
const fallback = defaultModelingPaneLayout();
try {
const parsed = JSON.parse(
localStorage.getItem(MODELING_PANE_LAYOUT_KEY) ?? "null",
) as Partial<ModelingPaneLayout> | null;
if (
!parsed ||
!Number.isFinite(parsed.paletteWidth) ||
!Number.isFinite(parsed.propertiesWidth)
) {
return fallback;
}
return {
paletteWidth: Math.min(
MAX_PALETTE_WIDTH,
Math.max(MIN_PALETTE_WIDTH, Number(parsed.paletteWidth)),
),
propertiesWidth: Math.min(
MAX_PROPERTIES_WIDTH,
Math.max(MIN_PROPERTIES_WIDTH, Number(parsed.propertiesWidth)),
),
};
} catch {
return fallback;
}
}
function clampModelingPaneLayout(
layout: ModelingPaneLayout,
workbenchWidth: number,
): ModelingPaneLayout {
let paletteWidth = Math.min(
MAX_PALETTE_WIDTH,
Math.max(MIN_PALETTE_WIDTH, layout.paletteWidth),
);
let propertiesWidth = Math.min(
MAX_PROPERTIES_WIDTH,
Math.max(MIN_PROPERTIES_WIDTH, layout.propertiesWidth),
);
const availableSideWidth = Math.max(
MIN_PALETTE_WIDTH + MIN_PROPERTIES_WIDTH,
workbenchWidth -
MIN_MODELING_CANVAS_WIDTH -
MODELING_PANE_SPLITTER_SIZE * 2,
);
let overflow = paletteWidth + propertiesWidth - availableSideWidth;
if (overflow > 0) {
const propertiesReduction = Math.min(
overflow,
propertiesWidth - MIN_PROPERTIES_WIDTH,
);
propertiesWidth -= propertiesReduction;
overflow -= propertiesReduction;
}
if (overflow > 0) {
paletteWidth -= Math.min(
overflow,
paletteWidth - MIN_PALETTE_WIDTH,
);
}
return { paletteWidth, propertiesWidth };
}
function loadCanvasGridVisibility(): CanvasGridVisibility {
try {
const parsed = JSON.parse(
localStorage.getItem(CANVAS_GRID_VISIBILITY_KEY) ?? "null",
) as Partial<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 };
}
const identityUnit = (value: number) => value;
const scaleUnit = (factor: number) => ({
toBase: (value: number) => value * factor,
fromBase: (value: number) => value / factor,
});
const unitOptions: Record<UnitQuantity, UnitOption[]> = {
pressure: [
{ value: "Pa", label: "Pa", toBase: identityUnit, fromBase: identityUnit },
{ value: "kPa", label: "kPa", ...scaleUnit(1_000) },
{ value: "MPa", label: "MPa", ...scaleUnit(1_000_000) },
{ value: "bar", label: "bar", ...scaleUnit(100_000) },
],
volume: [
{ value: "m3", label: "m³", toBase: identityUnit, fromBase: identityUnit },
{ value: "L", label: "L", ...scaleUnit(0.001) },
{ value: "mL", label: "mL", ...scaleUnit(0.000001) },
],
temperature: [
{ value: "K", label: "K", toBase: identityUnit, fromBase: identityUnit },
{
value: "degC",
label: "°C",
toBase: (value) => value + 273.15,
fromBase: (value) => value - 273.15,
},
],
length: [
{ value: "m", label: "m", toBase: identityUnit, fromBase: identityUnit },
{ value: "cm", label: "cm", ...scaleUnit(0.01) },
{ value: "mm", label: "mm", ...scaleUnit(0.001) },
],
};
const defaultSimulationConfig: SimulationConfig = {
t_start: 0,
t_stop: 2,
step: 0.1,
max_step: 0.005,
method: "BDF",
};
type SimulationConsoleLevel = "info" | "success" | "warning" | "error";
type SimulationConsoleMode = "minimized" | "normal" | "maximized";
type ConsolePosition = {
x: number;
y: number;
};
type ConsolePlacement = ConsolePosition & {
horizontalAnchor: "left" | "right";
verticalAnchor: "top" | "bottom";
};
type ConsoleDragState = {
pointerId: number;
offsetX: number;
offsetY: number;
width: number;
height: number;
};
type SimulationConsoleEntry = {
id: number;
timestamp: string;
level: SimulationConsoleLevel;
message: string;
};
type SimulationProgressState = {
state: "idle" | "running" | "success" | "warning" | "error";
percent: number;
message: string;
startTime: number;
simulatedTime: number;
totalTime: number;
};
type SimulationStreamIssue = {
code?: string;
message?: string;
};
type SimulationCancelReason = "user" | "stalled";
type SimulationTaskResponse = {
simulationId: string;
status: "queued" | "running" | "cancelling" | "completed" | "stopped" | "stalled" | "failed";
cancelReason: SimulationCancelReason | null;
result: SimulationResult | null;
error: SimulationStreamEvent | null;
};
type SimulationStreamEvent =
| {
event: "progress";
progress: number;
phase: string;
message: string;
heartbeat?: boolean;
simulatedTime?: number;
totalTime?: number;
}
| {
event: "result";
progress: number;
phase: "complete";
message: string;
simulatedTime?: number;
totalTime?: number;
result: SimulationResult;
}
| {
event: "error";
progress: number;
phase: "failed";
status: number;
message: string;
detail?: unknown;
simulatedTime?: number;
totalTime?: number;
};
const SUPPORTED_SOLVER_METHODS = ["BDF", "Radau", "LSODA", "RK45", "RK23", "DOP853"];
function physicalPort(
name: string,
nominalRole: Extract<PortNominalRole, "inlet" | "outlet" | "bidirectional">,
side: PortSide,
): PortDefinition {
return {
name,
kind: "physical",
domain: "pneumatic",
nominalRole,
positiveFlowDirection: "intoComponent",
side,
};
}
const componentDefinitions: ComponentDefinition[] = [
{
type: "cylinder",
label: "气瓶",
modelType: "cylinder",
ports: [physicalPort("port_b", "outlet", "right")],
parameters: {
volume: {
label: "容积",
unit: "m3",
quantity: "volume",
default: 0.01,
min: 0,
minExclusive: true,
},
p0: { label: "初始压力", unit: "Pa", quantity: "pressure", default: 35000000, min: 0 },
T0: {
label: "初始温度",
unit: "K",
quantity: "temperature",
default: 300,
min: 0,
minExclusive: true,
},
},
},
{
type: "tank",
label: "贮箱",
modelType: "tank",
ports: [physicalPort("port_a", "inlet", "left")],
parameters: {
volume: {
label: "容积",
unit: "m3",
quantity: "volume",
default: 0.1,
min: 0,
minExclusive: true,
},
p0: { label: "初始压力", unit: "Pa", quantity: "pressure", default: 100000, min: 0 },
T0: {
label: "初始温度",
unit: "K",
quantity: "temperature",
default: 300,
min: 0,
minExclusive: true,
},
},
},
{
type: "pipe",
label: "管段",
modelType: "pipe",
ports: [
physicalPort("port_a", "inlet", "left"),
physicalPort("port_b", "outlet", "right"),
],
parameters: {
length: {
label: "长度",
unit: "m",
quantity: "length",
default: 5,
min: 0,
minExclusive: true,
},
diameter: {
label: "直径",
unit: "m",
quantity: "length",
default: 0.02,
min: 0,
minExclusive: true,
},
lambda_darcy: { label: "摩阻系数", default: 0.02, min: 0 },
p0: { label: "初始压力", unit: "Pa", quantity: "pressure", default: 100000, min: 0 },
T0: {
label: "初始温度",
unit: "K",
quantity: "temperature",
default: 300,
min: 0,
minExclusive: true,
},
},
},
{
type: "orifice",
label: "孔板/阀门",
modelType: "orifice",
ports: [
physicalPort("port_a", "inlet", "left"),
physicalPort("port_b", "outlet", "right"),
],
parameters: {
K: { label: "流量系数", default: 0.00001, min: 0 },
opening: { label: "开度", default: 1, min: 0, max: 1 },
},
},
{
type: "tee",
label: "三通",
modelType: "tee",
ports: [
physicalPort("port_in", "bidirectional", "left"),
physicalPort("port_out1", "bidirectional", "right"),
physicalPort("port_out2", "bidirectional", "right"),
],
parameters: {},
},
];
const nextNodeNumberByType: Record<string, number> = {};
function defaultParameters(definition: ComponentDefinition) {
return Object.fromEntries(
Object.entries(definition.parameters).map(([key, parameter]) => [
key,
parameter.default,
]),
);
}
function defaultParameterUnits(definition: ComponentDefinition) {
return Object.fromEntries(
Object.entries(definition.parameters)
.filter(([, parameter]) => parameter.unit)
.map(([key, parameter]) => [key, parameter.unit as string]),
);
}
function buildNodeData(definition: ComponentDefinition): SimulationNodeData {
return {
label: definition.label,
componentType: definition.type,
modelType: definition.modelType,
ports: definition.ports.map((port) => ({ ...port })),
parameters: defaultParameters(definition),
parameterUnits: defaultParameterUnits(definition),
rotation: 0,
mirrored: false,
};
}
function SimulationComponentNode({ id, data, selected }: NodeProps<SimulationNode>) {
const updateNodeInternals = useUpdateNodeInternals();
const rotation = normalizeNodeRotation(data.rotation);
const mirrored = Boolean(data.mirrored);
const vertical = rotation === 90 || rotation === 270;
const portPlacements = transformedPortPlacements(data.ports, rotation, mirrored);
useEffect(() => {
updateNodeInternals(id);
}, [mirrored, rotation, id, updateNodeInternals]);
return (
<div
className={`sim-node sim-node-${data.componentType} ${vertical ? "orientation-vertical" : ""} ${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 }) => (
<Handle
className={`port-handle port-handle-${port.kind}`}
id={port.name}
key={port.name}
position={position}
style={{ left: x, top: y, transform: "translate(-50%, -50%)" }}
title={`${port.name} (${port.domain})`}
type={portHandleType(port)}
/>
))}
<div className="node-symbol-anchor" aria-hidden="true">
<div
className="node-symbol"
style={{
transform: `rotate(${rotation}deg) scaleX(${mirrored ? -1 : 1})`,
}}
>
{symbolForComponent(data.componentType)}
</div>
</div>
<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,
) {
const renderedSize = nodeFrameDimensions(rotation);
return (["left", "right"] as PortSide[]).flatMap((side) => {
const sidePorts = ports.filter((port) => port.side === side);
return sidePorts.map((port, index) => {
let x = side === "left" ? -NODE_FRAME_WIDTH / 2 : NODE_FRAME_WIDTH / 2;
let y =
-NODE_FRAME_HEIGHT / 2 +
((index + 1) * NODE_FRAME_HEIGHT) / (sidePorts.length + 1);
if (mirrored) {
x = -x;
}
[x, y] = rotatePoint(x, y, rotation);
const renderedX = renderedSize.width / 2 + x;
const renderedY = renderedSize.height / 2 + y;
return {
port,
position: portPosition(renderedX, renderedY, renderedSize),
x: renderedX,
y: renderedY,
};
});
});
}
function rotatePoint(x: number, y: number, rotation: NodeRotation): [number, number] {
if (rotation === 90) {
return [-y, x];
}
if (rotation === 180) {
return [-x, -y];
}
if (rotation === 270) {
return [y, -x];
}
return [x, y];
}
function portPosition(
x: number,
y: number,
size: { width: number; height: number },
) {
if (Math.abs(x) < 0.01) {
return Position.Left;
}
if (Math.abs(x - size.width) < 0.01) {
return Position.Right;
}
if (Math.abs(y) < 0.01) {
return Position.Top;
}
return Position.Bottom;
}
function symbolForComponent(type: string) {
if (type === "cylinder") {
return <div className="symbol-cylinder" />;
}
if (type === "tank") {
return <div className="symbol-tank" />;
}
if (type === "pipe") {
return <div className="symbol-pipe" />;
}
if (type === "orifice") {
return <div className="symbol-orifice" />;
}
return <div className="symbol-tee" />;
}
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;
unreadCount: number;
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: SimulationConfig,
): NormalizedSimulationProgress {
const totalTime = Number.isFinite(event.totalTime)
? Number(event.totalTime)
: config.t_stop;
const preparationPhase = [
"queued",
"validation",
"compilation",
"initialization",
"initializing",
].includes(event.phase);
if (preparationPhase) {
return {
percent: 0,
simulatedTime: config.t_start,
totalTime,
};
}
if (Number.isFinite(event.simulatedTime)) {
const simulatedTime = Math.min(
totalTime,
Math.max(config.t_start, Number(event.simulatedTime)),
);
return {
percent: simulationTimePercent(
config.t_start,
simulatedTime,
totalTime,
),
simulatedTime,
totalTime,
};
}
// Older backends mapped solver progress into the 12%-98% range.
const legacyModelProgress = (event.progress - 12) / 86;
const legacyTimeFraction = Math.min(
1,
Math.max(0, (legacyModelProgress - 0.05) / 0.75),
);
const simulatedTime =
config.t_start + legacyTimeFraction * (totalTime - config.t_start);
return {
percent: simulationTimePercent(
config.t_start,
simulatedTime,
totalTime,
),
simulatedTime,
totalTime,
};
}
function isConsolePosition(value: unknown): value is ConsolePosition {
if (!value || typeof value !== "object") {
return false;
}
const position = value as Partial<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,
unreadCount,
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") {
const latestMessage = entries.at(-1)?.message ?? "等待仿真任务";
return (
<aside
aria-label="仿真控制台"
className={`simulation-console minimized ${unreadCount > 0 ? "has-unread" : ""} ${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 className="simulation-console-collapsed-message">{latestMessage}</span>
)}
{unreadCount > 0 ? (
<span className="simulation-console-unread" title={`${unreadCount} 条新消息`}>
<BellRing aria-hidden="true" size={13} />
{unreadCount > 99 ? "99+" : unreadCount}
</span>
) : null}
<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-live="polite"
className="simulation-console-log"
ref={logRef}
role="log"
>
{entries.length === 0 ? (
<div className="simulation-console-empty">暂无仿真消息</div>
) : (
entries.map((entry) => (
<div className={`simulation-console-entry ${entry.level}`} key={entry.id}>
<time>{entry.timestamp}</time>
<span className="simulation-console-entry-level">
{consoleLevelLabels[entry.level]}
</span>
<span>{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="decimal"
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 [nodes, setNodes, onNodesChange] = useNodesState<SimulationNode>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<SimulationEdge>([]);
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
const [projectName, setProjectName] = useState("demo-system");
const [simulationConfig, setSimulationConfig] = useState<SimulationConfig>(
defaultSimulationConfig,
);
const [xmlText, setXmlText] = useState("");
const [statusText, setStatusText] = useState("后端待调用");
const [simulationResult, setSimulationResult] = useState<SimulationResult | null>(null);
const [simulationResultSignature, setSimulationResultSignature] = useState("");
const [resultSnapshot, setResultSnapshot] =
useState<SimulationResultsSnapshot | null>(loadStoredResultSnapshot);
const [hasUnreadResults, setHasUnreadResults] = useState(false);
const [activeView, setActiveView] = useState<WorkspaceView>(
workspaceViewFromLocation,
);
const [busy, setBusy] = useState(false);
const [consoleEntries, setConsoleEntries] = useState<SimulationConsoleEntry[]>([]);
const [consoleMode, setConsoleMode] = useState<SimulationConsoleMode>("minimized");
const [consoleUnreadCount, setConsoleUnreadCount] = useState(0);
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 [modelingPaneLayout, setModelingPaneLayout] =
useState<ModelingPaneLayout>(loadModelingPaneLayout);
const [activeModelingPaneResize, setActiveModelingPaneResize] =
useState<ModelingPaneResizeKind | null>(null);
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 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 consoleModeRef = useRef<SimulationConsoleMode>("minimized");
const consoleEntryIdRef = useRef(0);
const activeSimulationIdRef = useRef<string | null>(null);
nodesRef.current = nodes;
edgesRef.current = edges;
projectNameRef.current = projectName;
simulationConfigRef.current = simulationConfig;
consoleModeRef.current = consoleMode;
useEffect(() => {
try {
localStorage.setItem(
MODELING_PANE_LAYOUT_KEY,
JSON.stringify(modelingPaneLayout),
);
} catch {
// Resizing remains available when browser storage is unavailable.
}
}, [modelingPaneLayout]);
useEffect(() => {
try {
localStorage.setItem(
CANVAS_GRID_VISIBILITY_KEY,
JSON.stringify(gridVisibility),
);
} catch {
// Grid controls remain available when browser storage is unavailable.
}
}, [gridVisibility]);
useLayoutEffect(() => {
if (activeView !== "modeling") {
return;
}
const workbench = workbenchRef.current;
if (!workbench || typeof ResizeObserver === "undefined") {
return;
}
const clampToWorkbench = (width: number) => {
setModelingPaneLayout((current) => {
const next = clampModelingPaneLayout(current, width);
return next.paletteWidth === current.paletteWidth &&
next.propertiesWidth === current.propertiesWidth
? current
: next;
});
};
clampToWorkbench(workbench.clientWidth);
const observer = new ResizeObserver(([entry]) => {
if (entry) {
clampToWorkbench(entry.contentRect.width);
}
});
observer.observe(workbench);
return () => observer.disconnect();
}, [activeView]);
const setModelingPaneSize = (
kind: ModelingPaneResizeKind,
requestedWidth: number,
) => {
const workbenchWidth = workbenchRef.current?.getBoundingClientRect().width;
if (!workbenchWidth) {
return;
}
setModelingPaneLayout((current) => {
if (kind === "palette-width") {
const maximum = Math.max(
MIN_PALETTE_WIDTH,
Math.min(
MAX_PALETTE_WIDTH,
workbenchWidth -
current.propertiesWidth -
MIN_MODELING_CANVAS_WIDTH -
MODELING_PANE_SPLITTER_SIZE * 2,
),
);
return {
...current,
paletteWidth: Math.min(
maximum,
Math.max(MIN_PALETTE_WIDTH, requestedWidth),
),
};
}
const maximum = Math.max(
MIN_PROPERTIES_WIDTH,
Math.min(
MAX_PROPERTIES_WIDTH,
workbenchWidth -
current.paletteWidth -
MIN_MODELING_CANVAS_WIDTH -
MODELING_PANE_SPLITTER_SIZE * 2,
),
);
return {
...current,
propertiesWidth: Math.min(
maximum,
Math.max(MIN_PROPERTIES_WIDTH, requestedWidth),
),
};
});
};
const startModelingPaneResize = (
event: ReactPointerEvent<HTMLDivElement>,
kind: ModelingPaneResizeKind,
) => {
event.preventDefault();
modelingPaneResizeRef.current = {
kind,
pointerId: event.pointerId,
startX: event.clientX,
startWidth:
kind === "palette-width"
? modelingPaneLayout.paletteWidth
: modelingPaneLayout.propertiesWidth,
};
event.currentTarget.setPointerCapture(event.pointerId);
setActiveModelingPaneResize(kind);
};
const moveModelingPaneResize = (
event: ReactPointerEvent<HTMLDivElement>,
) => {
const resize = modelingPaneResizeRef.current;
if (!resize || resize.pointerId !== event.pointerId) {
return;
}
event.preventDefault();
const delta = event.clientX - resize.startX;
setModelingPaneSize(
resize.kind,
resize.startWidth +
(resize.kind === "palette-width" ? delta : -delta),
);
};
const stopModelingPaneResize = (
event: ReactPointerEvent<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,
) => {
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") {
return;
}
event.preventDefault();
const direction = event.key === "ArrowRight" ? 1 : -1;
const currentWidth =
kind === "palette-width"
? modelingPaneLayout.paletteWidth
: modelingPaneLayout.propertiesWidth;
setModelingPaneSize(
kind,
currentWidth +
MODELING_PANE_KEYBOARD_STEP *
(kind === "palette-width" ? direction : -direction),
);
};
const resetModelingPaneSize = (kind: ModelingPaneResizeKind) => {
const defaults = defaultModelingPaneLayout();
setModelingPaneSize(
kind,
kind === "palette-width"
? defaults.paletteWidth
: defaults.propertiesWidth,
);
};
const changeConsoleMode = useCallback((mode: SimulationConsoleMode) => {
consoleModeRef.current = mode;
setConsoleMode(mode);
if (mode !== "minimized") {
setConsoleUnreadCount(0);
}
}, []);
const appendConsoleEntry = useCallback(
(level: SimulationConsoleLevel, message: string) => {
const entry: SimulationConsoleEntry = {
id: ++consoleEntryIdRef.current,
timestamp: new Date().toLocaleTimeString("zh-CN", { hour12: false }),
level,
message,
};
setConsoleEntries((currentEntries) => [
...currentEntries.slice(-399),
entry,
]);
if (consoleModeRef.current === "minimized") {
setConsoleUnreadCount((count) => count + 1);
}
},
[],
);
const changeWorkspaceView = useCallback((view: WorkspaceView) => {
setActiveView(view);
if (view === "results") {
setHasUnreadResults(false);
}
const nextHash = view === "results" ? "#/results" : "#/modeling";
if (window.location.hash !== nextHash) {
window.history.pushState({ workspaceView: view }, "", nextHash);
}
}, []);
const loadSimulationResultsSnapshot = useCallback(
(snapshot: SimulationResultsSnapshot) => {
setResultSnapshot(snapshot);
storeResultSnapshot(snapshot);
setSimulationResult(null);
setSimulationResultSignature("");
setStatusText(`已加载结果文件:${snapshot.project.name}`);
changeWorkspaceView("results");
},
[changeWorkspaceView],
);
const nodeTypes = useMemo<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 buildCurrentProject = useCallback(
() => buildProjectPayload(projectName, nodes, edges, simulationConfig),
[edges, nodes, projectName, simulationConfig],
);
const validationSignature = useMemo(
() => JSON.stringify(buildCurrentProject()),
[buildCurrentProject],
);
const validationCurrent = validatedSignature === validationSignature;
const simulationResultCurrent =
Boolean(simulationResult) && simulationResultSignature === validationSignature;
const duplicateNames = useMemo(() => findDuplicateNames(nodes), [nodes]);
const validationSeverityByNode = useMemo(
() => {
const severities = new Map<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 renderedNodes = useMemo(
() =>
nodes.map((node) => ({
...node,
className: validationSeverityByNode.has(node.id)
? `validation-${validationSeverityByNode.get(node.id)}`
: node.className,
})),
[nodes, validationSeverityByNode],
);
const renderedEdges = useMemo(
() =>
edges.map((edge) => {
const selected = edge.selected || edge.id === selectedEdgeId;
const className = [edge.className, selected ? "editor-edge-selected" : ""]
.filter(Boolean)
.join(" ");
return {
...edge,
className: className || undefined,
type: "step",
};
}),
[edges, selectedEdgeId],
);
const updateHistoryAvailability = useCallback(() => {
setHistoryAvailability({
canUndo: historyRef.current.length > 0,
canRedo: futureRef.current.length > 0,
});
}, []);
const captureSnapshot = useCallback(
(): EditorSnapshot => ({
nodes: cloneValue(nodesRef.current),
edges: cloneValue(edgesRef.current),
projectName: projectNameRef.current,
simulationConfig: { ...simulationConfigRef.current },
}),
[],
);
const recordHistory = useCallback(() => {
const snapshot = captureSnapshot();
const previous = historyRef.current.at(-1);
if (!previous || snapshotSignature(previous) !== snapshotSignature(snapshot)) {
historyRef.current.push(snapshot);
if (historyRef.current.length > HISTORY_LIMIT) {
historyRef.current.shift();
}
}
futureRef.current = [];
updateHistoryAvailability();
}, [captureSnapshot, updateHistoryAvailability]);
const finishHistoryEdit = useCallback(() => {
const latest = historyRef.current.at(-1);
if (latest && snapshotSignature(latest) === snapshotSignature(captureSnapshot())) {
historyRef.current.pop();
updateHistoryAvailability();
}
}, [captureSnapshot, updateHistoryAvailability]);
const restoreSnapshot = useCallback(
(snapshot: EditorSnapshot) => {
const restoredNodes = cloneValue(snapshot.nodes).map((node) => ({
...node,
selected: false,
}));
setNodes(restoredNodes);
setEdges(cloneValue(snapshot.edges).map((edge) => ({ ...edge, selected: false })));
setProjectName(snapshot.projectName);
setSimulationConfig({ ...snapshot.simulationConfig });
setSelectedNodeId(null);
setSelectedEdgeId(null);
setSimulationResult(null);
setSimulationResultSignature("");
setXmlText("");
syncNextNodeNumbers(restoredNodes);
},
[setEdges, setNodes],
);
const undo = useCallback(() => {
const previous = historyRef.current.pop();
if (!previous) {
return;
}
futureRef.current.push(captureSnapshot());
restoreSnapshot(previous);
updateHistoryAvailability();
setStatusText("已撤销上一步操作");
}, [captureSnapshot, restoreSnapshot, updateHistoryAvailability]);
const redo = useCallback(() => {
const next = futureRef.current.pop();
if (!next) {
return;
}
historyRef.current.push(captureSnapshot());
restoreSnapshot(next);
updateHistoryAvailability();
setStatusText("已重做上一步操作");
}, [captureSnapshot, restoreSnapshot, updateHistoryAvailability]);
const rotateSelection = useCallback(() => {
const selectedIds = new Set(
nodesRef.current
.filter((node) => node.selected || node.id === selectedNodeId)
.map((node) => node.id),
);
if (selectedIds.size === 0) {
setStatusText("请先选择需要旋转的组件");
return;
}
recordHistory();
setNodes((currentNodes) =>
currentNodes.map((node) =>
selectedIds.has(node.id) ? rotateNodeClockwise(node) : node,
),
);
setStatusText(`已顺时针旋转 ${selectedIds.size} 个组件`);
}, [recordHistory, selectedNodeId, setNodes]);
const mirrorSelection = useCallback(() => {
const selectedNodes = nodesRef.current.filter(
(node) => node.selected || node.id === selectedNodeId,
);
if (selectedNodes.length === 0) {
setStatusText("请先选择需要镜像的组件");
return;
}
const selectedIds = new Set(selectedNodes.map((node) => node.id));
const nextMirrored = !selectedNodes.every((node) => Boolean(node.data.mirrored));
recordHistory();
setNodes((currentNodes) =>
currentNodes.map((node) =>
selectedIds.has(node.id)
? {
...node,
data: {
...node.data,
mirrored: nextMirrored,
},
}
: node,
),
);
setStatusText(`${nextMirrored ? "已镜像" : "已取消镜像"} ${selectedIds.size} 个组件`);
}, [recordHistory, selectedNodeId, setNodes]);
const handleNodesChange = useCallback(
(changes: NodeChange<SimulationNode>[]) => {
if (changes.some((change) => change.type === "remove")) {
recordHistory();
}
onNodesChange(changes);
},
[onNodesChange, recordHistory],
);
const handleEdgesChange = useCallback(
(changes: EdgeChange<SimulationEdge>[]) => {
if (changes.some((change) => change.type === "remove")) {
recordHistory();
}
onEdgesChange(changes);
},
[onEdgesChange, recordHistory],
);
const onConnect = useCallback(
(connection: Connection) => {
recordHistory();
setEdges((currentEdges) =>
addEdge(
{
...connection,
id: `edge-${connection.source}-${connection.sourceHandle}-${connection.target}-${connection.targetHandle}-${Date.now()}`,
type: "step",
},
currentEdges,
),
);
},
[recordHistory, setEdges],
);
const isValidConnection = useCallback(
(connection: Connection | SimulationEdge) => canConnectPorts(nodes, connection),
[nodes],
);
const onDragStart = (event: DragEvent<HTMLButtonElement>, type: string) => {
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 id = takeNextNodeId(definition.type);
const node: SimulationNode = {
id,
type: "simulationComponent",
position,
data: { ...buildNodeData(definition), label: id },
};
recordHistory();
setNodes((currentNodes) => [...currentNodes, node]);
setSelectedNodeId(id);
},
[reactFlow, recordHistory, setNodes],
);
const updateSelectedNode = (updater: (node: SimulationNode) => SimulationNode) => {
if (!selectedNodeId) {
return;
}
setNodes((currentNodes) =>
currentNodes.map((node) => (node.id === selectedNodeId ? updater(node) : node)),
);
};
const updateSelectedParameter = (
key: string,
definition: ParameterDefinition,
displayValue: string,
) => {
const selectedUnit =
selectedNode?.data.parameterUnits[key] ?? definition.unit ?? "";
const unit = findUnitOption(definition, selectedUnit);
const numericValue = Number(displayValue);
const baseValue =
displayValue.trim() === "" || !Number.isFinite(numericValue)
? displayValue
: unit.toBase(numericValue);
updateSelectedNode((node) => ({
...node,
data: {
...node.data,
parameters: {
...node.data.parameters,
[key]: baseValue,
},
},
}));
};
const updateSelectedParameterUnit = (key: string, unit: string) => {
updateSelectedNode((node) => ({
...node,
data: {
...node.data,
parameterUnits: {
...node.data.parameterUnits,
[key]: unit,
},
},
}));
};
const copySelection = useCallback(() => {
const selectedNodes = nodesRef.current.filter(
(node) => node.selected || node.id === selectedNodeId,
);
if (selectedNodes.length === 0) {
setStatusText("请先选择需要复制的组件");
return;
}
const selectedIds = new Set(selectedNodes.map((node) => node.id));
clipboardRef.current = {
nodes: cloneValue(selectedNodes),
edges: cloneValue(
edgesRef.current.filter(
(edge) => selectedIds.has(edge.source) && selectedIds.has(edge.target),
),
),
};
pasteCountRef.current = 0;
setStatusText(`已复制 ${selectedNodes.length} 个组件`);
}, [selectedNodeId]);
const pasteSelection = useCallback(() => {
const clipboard = clipboardRef.current;
if (!clipboard || clipboard.nodes.length === 0) {
setStatusText("剪贴板中没有可粘贴的组件");
return;
}
recordHistory();
pasteCountRef.current += 1;
const offset = GRID_SIZE * 2 * pasteCountRef.current;
const idMap = new Map<string, string>();
const usedNames = new Set(nodesRef.current.map((node) => node.data.label.trim()));
const pastedNodes = clipboard.nodes.map((node) => {
const id = takeNextNodeId(node.data.componentType);
idMap.set(node.id, id);
const label = makeUniqueName(`${node.data.label}_副本`, usedNames);
usedNames.add(label);
return {
...cloneValue(node),
id,
position: {
x: node.position.x + offset,
y: node.position.y + offset,
},
selected: true,
data: {
...cloneValue(node.data),
label,
},
} satisfies SimulationNode;
});
const pastedEdges = clipboard.edges.map((edge, index) => ({
...cloneValue(edge),
id: `edge-copy-${Date.now()}-${index}`,
source: idMap.get(edge.source) as string,
target: idMap.get(edge.target) as string,
selected: false,
}));
setNodes((currentNodes) => [
...currentNodes.map((node) => ({ ...node, selected: false })),
...pastedNodes,
]);
setEdges((currentEdges) => [
...currentEdges.map((edge) => ({ ...edge, selected: false })),
...pastedEdges,
]);
setSelectedNodeId(pastedNodes.length === 1 ? pastedNodes[0].id : null);
setSelectedEdgeId(null);
setStatusText(`已粘贴 ${pastedNodes.length} 个组件`);
}, [recordHistory, setEdges, setNodes]);
const updateSimulationConfig = (key: keyof SimulationConfig, value: string) => {
setSimulationConfig((current) => ({
...current,
[key]: key === "method" ? value : Number(value),
}));
};
const generateXml = () => {
try {
const xml = buildSystemXml(buildCurrentProject());
setXmlText(xml);
setStatusText(`XML 已生成:${nodes.length} 个组件,${edges.length} 条连接`);
} catch (error) {
setStatusText(`XML 生成失败:${formatError(error)}`);
}
};
const downloadXml = () => {
try {
const xml = buildSystemXml(buildCurrentProject());
setXmlText(xml);
downloadText(`${safeFilename(projectName)}.xml`, xml);
setStatusText("XML 已下载");
} catch (error) {
setStatusText(`XML 下载失败:${formatError(error)}`);
}
};
const applyProject = useCallback(
(project: ReactFlowProjectPayload, message: string, addToHistory = true) => {
if (addToHistory) {
recordHistory();
}
const loadedNodes = project.nodes.map(normalizeLoadedNode);
setNodes(loadedNodes);
setEdges(project.edges.map(normalizeLoadedEdge));
setProjectName(project.name || "untitled");
setSimulationConfig({ ...defaultSimulationConfig, ...project.simulation });
setSelectedNodeId(null);
setSelectedEdgeId(null);
setSimulationResult(null);
setSimulationResultSignature("");
setValidationIssues([]);
setValidatedSignature("");
setXmlText("");
syncNextNodeNumbers(loadedNodes);
setStatusText(message);
window.requestAnimationFrame(() => reactFlow.fitView({ padding: 0.18 }));
},
[reactFlow, recordHistory, setEdges, setNodes],
);
const saveProject = () => {
try {
const project = buildCurrentProject();
localStorage.setItem(localProjectKey(project.name), JSON.stringify(project));
localStorage.setItem(AUTOSAVE_KEY, JSON.stringify(project));
setSaveState(`已保存 ${formatTime(new Date())}`);
setStatusText(
`工程已保存到浏览器:${project.nodes.length} 个组件,${project.edges.length} 条连接`,
);
} catch (error) {
setStatusText(`保存失败:${formatError(error)}`);
}
};
const loadProject = () => {
const name = projectName.trim() || "untitled";
try {
const project = parseProjectPayload(localStorage.getItem(localProjectKey(name)));
if (!project) {
setStatusText(`没有找到本地工程:${name}`);
return;
}
applyProject(
project,
`工程已加载:${project.nodes.length} 个组件,${project.edges.length} 条连接`,
);
} catch (error) {
setStatusText(`加载失败:${formatError(error)}`);
}
};
const exportProject = () => {
const project = buildCurrentProject();
downloadFile(
`${safeFilename(project.name)}.json`,
JSON.stringify(project, null, 2),
"application/json;charset=utf-8",
);
setStatusText("工程 JSON 已导出");
};
const importProject = async (event: ChangeEvent<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) {
setStatusText(`导入失败:${formatError(error)}`);
}
};
const checkModel = useCallback(() => {
const issues = validateModel(
nodesRef.current,
edgesRef.current,
simulationConfigRef.current,
);
const signature = JSON.stringify(
buildProjectPayload(
projectNameRef.current,
nodesRef.current,
edgesRef.current,
simulationConfigRef.current,
),
);
setValidationIssues(issues);
setValidatedSignature(signature);
const errorCount = issues.filter((issue) => issue.severity === "error").length;
const warningCount = issues.length - errorCount;
setStatusText(
errorCount === 0
? warningCount === 0
? "模型检查通过"
: `模型检查通过,存在 ${warningCount} 条警告`
: `模型检查发现 ${errorCount} 个错误、${warningCount} 条警告`,
);
return issues;
}, []);
const focusValidationIssue = (issue: ValidationIssue) => {
if (!issue.nodeId) {
return;
}
const node = nodesRef.current.find((item) => item.id === issue.nodeId);
if (!node) {
return;
}
setNodes((currentNodes) =>
currentNodes.map((item) => ({ ...item, selected: item.id === issue.nodeId })),
);
setSelectedNodeId(issue.nodeId);
setSelectedEdgeId(null);
reactFlow.setCenter(node.position.x, node.position.y, {
duration: 250,
zoom: Math.max(reactFlow.getZoom(), 1),
});
};
const publishSimulationResult = (
result: SimulationResult,
project: ReactFlowProjectPayload,
) => {
const resultStatus = result.status ?? (result.success ? "completed" : "failed");
const sampleCount = Math.min(
result.diagnostics.sampleCount,
result.series.time?.length ?? 0,
);
const hasUsableSamples = sampleCount >= 2;
const startTime = project.simulation.t_start;
const finalSimulatedTime = Number.isFinite(result.simulatedUntil)
? result.simulatedUntil
: startTime;
const finalTotalTime = Number.isFinite(result.requestedStopTime)
? result.requestedStopTime
: project.simulation.t_stop;
const finalPercent =
resultStatus === "completed"
? 100
: simulationTimePercent(
startTime,
finalSimulatedTime,
finalTotalTime,
);
const simulatedUntil = Number.isFinite(result.simulatedUntil)
? formatNumber(result.simulatedUntil)
: "未知时间";
const statusMessages = {
completed: `仿真完成,已生成新的结果,共 ${sampleCount} 个采样点`,
stopped: `仿真已手动终止,已保留计算至 ${simulatedUntil} s 的 ${sampleCount} 个采样点`,
stalled: `仿真因进度异常而终止,已保留计算至 ${simulatedUntil} s 的 ${sampleCount} 个采样点`,
failed: `仿真异常终止,已保留计算至 ${simulatedUntil} s 的 ${sampleCount} 个采样点`,
} as const;
const statusMessage = statusMessages[resultStatus];
setStatusText(statusMessage);
if (resultStatus === "completed") {
setSimulationProgress({
state: "success",
percent: 100,
message: "仿真完成,新结果可查看",
startTime,
simulatedTime: finalTotalTime,
totalTime: finalTotalTime,
});
appendConsoleEntry(
"success",
`${statusMessage},可在“结果”页签查看;最大方程残差 ${formatNumber(result.diagnostics.pressureFlow.maxScaledResidual)}`,
);
} else {
setSimulationProgress((current) => ({
...current,
state: resultStatus === "stopped" ? "warning" : "error",
percent: finalPercent,
message:
resultStatus === "stopped"
? "仿真已停止"
: resultStatus === "stalled"
? "仿真因进度异常终止"
: "仿真异常终止",
startTime,
simulatedTime: finalSimulatedTime,
totalTime: finalTotalTime,
}));
appendConsoleEntry(resultStatus === "stopped" ? "warning" : "error", statusMessage);
}
if (!hasUsableSamples) {
appendConsoleEntry("warning", "有效计算步不足,未生成部分结果页面");
return;
}
setSimulationResult(result);
setSimulationResultSignature(JSON.stringify(project));
const nextSnapshot: SimulationResultsSnapshot = {
id: `simulation-${Date.now()}`,
createdAt: new Date().toISOString(),
project: cloneValue(project),
result: cloneValue(result),
};
setResultSnapshot(nextSnapshot);
storeResultSnapshot(nextSnapshot);
setHasUnreadResults(true);
};
const stopSimulation = async () => {
const simulationId = activeSimulationIdRef.current;
if (!simulationId || simulationStopPending || simulationProgress.state !== "running") {
return;
}
setSimulationStopPending(true);
setSimulationProgress((current) => ({ ...current, message: "正在停止仿真" }));
appendConsoleEntry("warning", "已请求停止仿真,正在等待当前积分步结束并整理部分结果");
try {
const response = await requestSimulationCancellation(simulationId, "user");
if (!response.accepted) {
appendConsoleEntry("info", "仿真任务已经结束,正在读取最终状态");
}
} catch (error) {
setSimulationStopPending(false);
setSimulationProgress((current) => ({
...current,
message: "停止请求失败,仿真可能仍在运行",
}));
appendConsoleEntry("error", `停止仿真失败:${formatError(error)}`);
}
};
const runSimulation = async () => {
const issues = checkModel();
const modelErrors = issues.filter((issue) => issue.severity === "error");
if (modelErrors.length > 0) {
changeConsoleMode("normal");
setSimulationProgress({
state: "error",
percent: 0,
message: "模型检查未通过",
startTime: simulationConfigRef.current.t_start,
simulatedTime: simulationConfigRef.current.t_start,
totalTime: simulationConfigRef.current.t_stop,
});
appendConsoleEntry("error", `模型检查未通过,共 ${modelErrors.length} 个错误`);
modelErrors.forEach((issue) => appendConsoleEntry("error", issue.message));
return;
}
changeConsoleMode("normal");
setSimulationProgress({
state: "running",
percent: 0,
message: "正在提交仿真任务",
startTime: simulationConfigRef.current.t_start,
simulatedTime: simulationConfigRef.current.t_start,
totalTime: simulationConfigRef.current.t_stop,
});
appendConsoleEntry(
"info",
`开始仿真:${projectNameRef.current || "untitled"},时间范围 ${simulationConfigRef.current.t_start} - ${simulationConfigRef.current.t_stop} s`,
);
issues
.filter((issue) => issue.severity === "warning")
.forEach((issue) => appendConsoleEntry("warning", issue.message));
await runBusyAction("正在编译模型并运行仿真...", async () => {
const project = buildCurrentProject();
const xml = buildSystemXml(project);
const simulationId = createSimulationId();
activeSimulationIdRef.current = simulationId;
setSimulationStopPending(false);
setXmlText(xml);
let lastLoggedPhase = "";
let lastLoggedBucket = -1;
try {
const result = await streamSystemSimulation(
xml,
(event) => {
if (event.event !== "progress") {
return;
}
const normalizedProgress = normalizeSimulationProgressEvent(
event,
project.simulation,
);
const reportsSimulationTime = ![
"queued",
"validation",
"compilation",
"initialization",
"initializing",
].includes(event.phase);
setSimulationProgress((current) => ({
...current,
state: "running",
percent: normalizedProgress.percent,
message: event.message,
simulatedTime: normalizedProgress.simulatedTime,
totalTime: normalizedProgress.totalTime,
}));
const progressBucket = Math.floor(normalizedProgress.percent / 10);
if (event.phase !== lastLoggedPhase || progressBucket !== lastLoggedBucket) {
const progressDetail = reportsSimulationTime
? ` (${normalizedProgress.simulatedTime.toFixed(2)} s / ${normalizedProgress.totalTime.toFixed(2)} s,${normalizedProgress.percent}%)`
: "";
appendConsoleEntry("info", `${event.message}${progressDetail}`);
lastLoggedPhase = event.phase;
lastLoggedBucket = progressBucket;
}
},
simulationId,
);
publishSimulationResult(result, project);
} catch (error) {
let failure: unknown = error;
if (
error instanceof SimulationStreamError &&
(error.code === "STREAM_IDLE_TIMEOUT" || error.code === "SOLVER_STALLED")
) {
setSimulationStopPending(true);
appendConsoleEntry(
"error",
error.code === "SOLVER_STALLED"
? "求解器连续 60 秒没有接受新积分步,正在终止任务并恢复部分结果"
: "超过 30 秒未收到后端数据,正在终止任务并恢复部分结果",
);
try {
await requestSimulationCancellation(simulationId, "stalled");
const task = await waitForSimulationTaskCompletion(simulationId);
if (task.result) {
publishSimulationResult(task.result, project);
return;
}
if (task.error?.event === "error") {
failure = new SimulationStreamError(
task.error.message,
[],
task.error.status,
);
} else {
failure = new SimulationStreamError("仿真异常终止且没有可恢复的部分结果");
}
} catch (recoveryError) {
failure = recoveryError;
}
}
const message = formatError(failure);
setSimulationProgress((current) => ({
...current,
state: "error",
percent: current.percent,
message: "仿真失败",
}));
appendConsoleEntry("error", `仿真失败:${message}`);
if (failure instanceof SimulationStreamError) {
failure.issues
.filter((issue) => issue !== message)
.forEach((issue) => appendConsoleEntry("error", issue));
}
throw failure;
} finally {
activeSimulationIdRef.current = null;
setSimulationStopPending(false);
}
});
};
async function runBusyAction(message: string, action: () => Promise<void>) {
setBusy(true);
setStatusText(message);
try {
await action();
} catch (error) {
setStatusText(`操作失败:${formatError(error)}`);
} finally {
setBusy(false);
}
}
const restoreRecoveryProject = () => {
if (!recoveryProject) {
return;
}
applyProject(recoveryProject, "已恢复上次自动保存的工程", false);
setRecoveryProject(null);
setAutosaveReady(true);
};
const discardRecoveryProject = () => {
localStorage.removeItem(AUTOSAVE_KEY);
setRecoveryProject(null);
setAutosaveReady(true);
setSaveState("尚未保存");
setStatusText("已忽略上次的自动保存草稿");
};
const selectAllElements = useCallback(() => {
setNodes((currentNodes) => currentNodes.map((node) => ({ ...node, selected: true })));
setEdges((currentEdges) => currentEdges.map((edge) => ({ ...edge, selected: true })));
setSelectedNodeId(null);
setSelectedEdgeId(null);
}, [setEdges, setNodes]);
const deleteSelection = useCallback(() => {
const nodeIds = new Set(
nodesRef.current
.filter((node) => node.selected || node.id === selectedNodeId)
.map((node) => node.id),
);
const edgeIds = new Set(
edgesRef.current
.filter((edge) => edge.selected || edge.id === selectedEdgeId)
.map((edge) => edge.id),
);
if (nodeIds.size === 0 && edgeIds.size === 0) {
setStatusText("请先选择需要删除的组件或连接");
return;
}
recordHistory();
setNodes((currentNodes) => currentNodes.filter((node) => !nodeIds.has(node.id)));
setEdges((currentEdges) =>
currentEdges.filter(
(edge) =>
!edgeIds.has(edge.id) &&
!nodeIds.has(edge.source) &&
!nodeIds.has(edge.target),
),
);
setSelectedNodeId(null);
setSelectedEdgeId(null);
setStatusText(`已删除 ${nodeIds.size} 个组件和 ${edgeIds.size} 条连接`);
}, [recordHistory, selectedEdgeId, selectedNodeId, setEdges, setNodes]);
useEffect(() => {
try {
const project = parseProjectPayload(localStorage.getItem(AUTOSAVE_KEY));
if (project && (project.nodes.length > 0 || project.edges.length > 0)) {
setRecoveryProject(project);
setSaveState("发现可恢复的本地草稿");
} else {
setAutosaveReady(true);
setSaveState("尚未保存");
}
} catch {
localStorage.removeItem(AUTOSAVE_KEY);
setAutosaveReady(true);
setSaveState("尚未保存");
}
}, []);
useEffect(() => {
if (!autosaveReady || recoveryProject) {
return;
}
setSaveState("有未保存更改");
const timer = window.setTimeout(() => {
try {
localStorage.setItem(AUTOSAVE_KEY, JSON.stringify(buildCurrentProject()));
setSaveState(`已自动保存 ${formatTime(new Date())}`);
} catch (error) {
setSaveState(`自动保存失败:${formatError(error)}`);
}
}, 600);
return () => window.clearTimeout(timer);
}, [autosaveReady, buildCurrentProject, recoveryProject]);
useEffect(() => {
const syncViewFromLocation = () => {
setActiveView(workspaceViewFromLocation());
};
window.addEventListener("hashchange", syncViewFromLocation);
window.addEventListener("popstate", syncViewFromLocation);
return () => {
window.removeEventListener("hashchange", syncViewFromLocation);
window.removeEventListener("popstate", syncViewFromLocation);
};
}, []);
useEffect(() => {
if (activeView === "results" && hasUnreadResults) {
setHasUnreadResults(false);
}
}, [activeView, hasUnreadResults]);
useEffect(() => {
if (activeView !== "modeling") {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
const key = event.key.toLowerCase();
if (event.ctrlKey && !event.shiftKey && key === "r") {
event.preventDefault();
rotateSelection();
return;
}
if (isEditableTarget(event.target)) {
return;
}
const commandKey = event.ctrlKey || event.metaKey;
if (event.shiftKey && !commandKey && !event.altKey && key === "h") {
event.preventDefault();
mirrorSelection();
} else if (commandKey && key === "z") {
event.preventDefault();
event.shiftKey ? redo() : undo();
} else if (commandKey && key === "y") {
event.preventDefault();
redo();
} else if (commandKey && key === "c") {
event.preventDefault();
copySelection();
} else if (commandKey && key === "v") {
event.preventDefault();
pasteSelection();
} else if (commandKey && key === "a") {
event.preventDefault();
selectAllElements();
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [
activeView,
copySelection,
mirrorSelection,
pasteSelection,
redo,
rotateSelection,
selectAllElements,
undo,
]);
const simulationConsole = (
<SimulationConsole
entries={consoleEntries}
mode={consoleMode}
onClear={() => setConsoleEntries([])}
onModeChange={changeConsoleMode}
onStop={() => void stopSimulation()}
progress={simulationProgress}
stopPending={simulationStopPending}
unreadCount={consoleUnreadCount}
/>
);
if (activeView === "results") {
return (
<>
{resultSnapshot ? (
<SimulationResultsView
key={resultSnapshot.id}
nodeTypes={nodeTypes}
onSnapshotLoad={loadSimulationResultsSnapshot}
onViewChange={changeWorkspaceView}
snapshot={resultSnapshot}
/>
) : (
<EmptySimulationResultsView
onSnapshotLoad={loadSimulationResultsSnapshot}
onViewChange={changeWorkspaceView}
/>
)}
{simulationConsole}
</>
);
}
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={() => setSnapEnabled((enabled) => !enabled)}
pressed={snapEnabled}
/>
<ToolbarButton
icon={Grid2X2}
label={gridVisibility.lines ? "隐藏主网格线" : "显示主网格线"}
onClick={() =>
setGridVisibility((current) => ({
...current,
lines: !current.lines,
}))
}
pressed={gridVisibility.lines}
/>
<ToolbarButton
icon={Grip}
label={gridVisibility.dots ? "隐藏网格点" : "显示网格点"}
onClick={() =>
setGridVisibility((current) => ({
...current,
dots: !current.dots,
}))
}
pressed={gridVisibility.dots}
/>
<ToolbarButton
icon={Maximize2}
label="适配视图"
onClick={() => reactFlow.fitView({ padding: 0.18 })}
/>
</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`,
} as CSSProperties
}
>
<aside className="palette">
<div className="panel-title">组件库</div>
<div className="palette-list">
{componentDefinitions.map((definition) => (
<button
className="palette-item"
draggable
key={definition.type}
onDragStart={(event) => onDragStart(event, definition.type)}
type="button"
>
<span className={`palette-icon icon-${definition.type}`} />
<span>
<strong>{definition.label}</strong>
<small>{definition.ports.map((port) => port.name).join(" / ") || "无端口"}</small>
</span>
</button>
))}
</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}
defaultEdgeOptions={{ interactionWidth: 28, type: "step" }}
edges={renderedEdges}
fitView
nodeTypes={nodeTypes}
nodes={renderedNodes}
deleteKeyCode={["Backspace", "Delete"]}
onConnect={onConnect}
isValidConnection={isValidConnection}
onEdgeClick={(_, edge) => {
setSelectedEdgeId(edge.id);
setSelectedNodeId(null);
}}
onEdgesChange={handleEdgesChange}
onEdgesDelete={() => setSelectedEdgeId(null)}
onNodeClick={(_, node) => {
setSelectedNodeId(node.id);
setSelectedEdgeId(null);
}}
onNodeDragStart={recordHistory}
onNodesChange={handleNodesChange}
onNodesDelete={() => setSelectedNodeId(null)}
onPaneClick={() => {
setSelectedNodeId(null);
setSelectedEdgeId(null);
}}
onSelectionChange={({ nodes: selectedNodes, edges: selectedEdges }) => {
setSelectedNodeId(selectedNodes.length === 1 ? selectedNodes[0].id : null);
setSelectedEdgeId(
selectedNodes.length === 0 && selectedEdges.length === 1
? selectedEdges[0].id
: null,
);
}}
panActivationKeyCode="Space"
panOnDrag={[1]}
selectionMode={SelectionMode.Partial}
selectionOnDrag
snapGrid={[GRID_SIZE, GRID_SIZE]}
snapToGrid={snapEnabled}
zoomOnDoubleClick={false}
>
{gridVisibility.lines ? (
<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 />
{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">
<div className="status-strip">{statusText}</div>
{validationIssues.length > 0 ? (
<div className="validation-section">
<div className="validation-heading">
<strong>模型检查</strong>
{!validationCurrent ? <span>结果已过期</span> : null}
</div>
<div className="validation-list">
{validationIssues.map((issue) => (
<button
className={`validation-item ${issue.severity}`}
disabled={!issue.nodeId}
key={issue.id}
onClick={() => focusValidationIssue(issue)}
type="button"
>
<span>{issue.severity === "error" ? "错误" : "警告"}</span>
<strong>{issue.message}</strong>
</button>
))}
</div>
</div>
) : null}
<div className="panel-title">组件参数</div>
{selectedNode && selectedDefinition ? (
<div className="parameter-form">
<label
className={`field ${
!selectedNode.data.label.trim() ||
duplicateNames.has(selectedNode.data.label.trim())
? "invalid"
: ""
}`}
>
<span>组件名称</span>
<input
aria-invalid={
!selectedNode.data.label.trim() ||
duplicateNames.has(selectedNode.data.label.trim())
}
value={selectedNode.data.label}
onChange={(event) =>
updateSelectedNode((node) => ({
...node,
data: { ...node.data, label: event.target.value },
}))
}
onBlur={finishHistoryEdit}
onFocus={recordHistory}
/>
{!selectedNode.data.label.trim() ? (
<small className="field-error">组件名称不能为空</small>
) : duplicateNames.has(selectedNode.data.label.trim()) ? (
<small className="field-error">组件名称不能重复</small>
) : null}
</label>
<div className="node-id">{selectedNode.id}</div>
{Object.entries(selectedDefinition.parameters).map(([key, definition]) => {
const selectedUnit =
selectedNode.data.parameterUnits[key] ?? definition.unit ?? "";
const units = definition.quantity ? unitOptions[definition.quantity] : [];
const baseValue = selectedNode.data.parameters[key] ?? "";
const displayValue = displayParameterValue(baseValue, definition, selectedUnit);
const error = parameterValidationMessage(definition, baseValue);
return (
<div className={`field ${error ? "invalid" : ""}`} key={key}>
<span>{definition.label}</span>
<div className={`parameter-control ${units.length === 0 ? "unitless" : ""}`}>
<DeferredNumberInput
ariaLabel={definition.label}
invalid={Boolean(error)}
onCommit={(value) =>
updateSelectedParameter(key, definition, value)
}
onEditEnd={finishHistoryEdit}
onEditStart={recordHistory}
value={displayValue}
/>
{units.length > 0 ? (
<select
aria-label={`${definition.label}单位`}
onChange={(event) =>
updateSelectedParameterUnit(key, event.target.value)
}
onBlur={finishHistoryEdit}
onFocus={recordHistory}
value={selectedUnit}
>
{units.map((unit) => (
<option key={unit.value} value={unit.value}>
{unit.label}
</option>
))}
</select>
) : null}
</div>
{error ? <small className="field-error">{error}</small> : null}
</div>
);
})}
</div>
) : (
<div className="empty-state">选择画布上的组件后编辑参数</div>
)}
<div className="panel-title">仿真设置</div>
<div className="parameter-form compact-form">
<label className="field">
<span>起始时间</span>
<DeferredNumberInput
value={simulationConfig.t_start}
onCommit={(value) => updateSimulationConfig("t_start", value)}
onEditEnd={finishHistoryEdit}
onEditStart={recordHistory}
/>
</label>
<label className="field">
<span>结束时间</span>
<DeferredNumberInput
value={simulationConfig.t_stop}
onCommit={(value) => updateSimulationConfig("t_stop", value)}
onEditEnd={finishHistoryEdit}
onEditStart={recordHistory}
/>
</label>
<label className="field">
<span>采样步长</span>
<DeferredNumberInput
value={simulationConfig.step}
onCommit={(value) => updateSimulationConfig("step", value)}
onEditEnd={finishHistoryEdit}
onEditStart={recordHistory}
/>
</label>
<label className="field">
<span>最大积分步长</span>
<DeferredNumberInput
value={simulationConfig.max_step}
onCommit={(value) => updateSimulationConfig("max_step", value)}
onEditEnd={finishHistoryEdit}
onEditStart={recordHistory}
/>
</label>
<label className="field">
<span>求解器</span>
<select
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>
</label>
</div>
{simulationResult ? (
<>
<div className="panel-title result-title">
<span>仿真结果</span>
{!simulationResultCurrent ? <small>结果已过期</small> : null}
</div>
<div className="result-section">
<div className="result-grid">
<span>结束时间</span>
<strong>{formatNumber(simulationResult.series.time.at(-1) ?? Number.NaN)} s</strong>
<span>采样点</span>
<strong>{simulationResult.diagnostics.sampleCount}</strong>
<span>状态数</span>
<strong>{simulationResult.diagnostics.stateCount}</strong>
<span>最大方程残差</span>
<strong>
{formatNumber(
simulationResult.diagnostics.pressureFlow.maxScaledResidual,
)}
</strong>
</div>
{nodes
.filter((node) => ["cylinder", "tank"].includes(node.data.modelType))
.map((node) => (
<div className="component-result" key={node.id}>
<strong>{node.data.label}</strong>
<div className="result-grid">
<span>压力</span>
<strong>
{formatNumber(simulationResult.final[`${node.id}.p`])} Pa
</strong>
<span>温度</span>
<strong>
{formatNumber(simulationResult.final[`${node.id}.T`])} K
</strong>
<span>质量</span>
<strong>
{formatNumber(simulationResult.final[`${node.id}.m`])} kg
</strong>
</div>
</div>
))}
<div className="run-message">{simulationResult.message}</div>
</div>
</>
) : null}
{xmlText ? (
<>
<div className="panel-title">系统 XML</div>
<textarea className="xml-output" readOnly value={xmlText} />
</>
) : null}
</aside>
</main>
</div>
{simulationConsole}
</>
);
}
function buildProjectPayload(
name: string,
nodes: SimulationNode[],
edges: SimulationEdge[],
simulation: SimulationConfig,
): ReactFlowProjectPayload {
return {
name: name.trim() || "untitled",
nodes: nodes.map((node) => ({
id: node.id,
type: "simulationComponent",
position: {
x: node.position.x,
y: node.position.y,
},
data: {
label: node.data.label,
componentType: node.data.componentType,
modelType: node.data.modelType,
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): SimulationNode {
const definition =
componentDefinitions.find((item) => item.type === node.data.componentType) ??
componentDefinitions.find((item) => item.modelType === node.data.modelType);
const parameters = definition
? { ...defaultParameters(definition), ...node.data.parameters }
: { ...node.data.parameters };
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,
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 arePortsCompatible(first: PortDefinition, second: PortDefinition) {
if (first.kind !== second.kind) {
return false;
}
if (first.kind === "physical") {
return first.domain === second.domain;
}
return new Set([first.nominalRole, second.nominalRole]).size === 2 &&
[first.nominalRole, second.nominalRole].every((role) =>
role === "input" || role === "output",
);
}
function canConnectPorts(
nodes: SimulationNode[],
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);
return Boolean(first && second && arePortsCompatible(first, second));
}
function connectionIdentity(edge: SimulationEdge, unordered: boolean) {
const endpoints = [
`${edge.source}:${edge.sourceHandle ?? ""}`,
`${edge.target}:${edge.targetHandle ?? ""}`,
];
if (unordered) {
endpoints.sort();
}
return endpoints.join(unordered ? "--" : "->");
}
function 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) {
if (!definition.quantity) {
return { value: "", label: "", toBase: identityUnit, fromBase: identityUnit };
}
return (
unitOptions[definition.quantity].find((unit) => unit.value === unitValue) ??
unitOptions[definition.quantity][0]
);
}
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 parameterValidationMessage(
definition: ParameterDefinition,
value: ParameterValue,
) {
if (typeof value === "string" && value.trim() === "") {
return "参数不能为空";
}
const numericValue = Number(value);
if (!Number.isFinite(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 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,
) {
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);
nodes.forEach((node) => {
const definition = componentDefinitions.find(
(item) => item.type === node.data.componentType,
);
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 message = parameterValidationMessage(parameter, node.data.parameters[key] ?? "");
if (message) {
addIssue("error", `${node.data.label}:${parameter.label}${message}`, {
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);
});
if (!Number.isFinite(simulation.t_start) || !Number.isFinite(simulation.t_stop)) {
addIssue("error", "仿真起止时间必须是有效数字");
} else if (simulation.t_stop <= simulation.t_start) {
addIssue("error", "仿真结束时间必须大于起始时间");
}
if (!Number.isFinite(simulation.step) || simulation.step <= 0) {
addIssue("error", "采样步长必须大于 0");
}
if (!Number.isFinite(simulation.max_step) || simulation.max_step <= 0) {
addIssue("error", "最大积分步长必须大于 0");
}
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"
) {
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) {
const xmlDocument = document.implementation.createDocument("", "System", null);
const system = xmlDocument.documentElement;
system.setAttribute("name", project.name);
system.setAttribute("schemaVersion", "2");
system.setAttribute("unitSystem", "SI");
const simulation = xmlDocument.createElement("Simulation");
simulation.setAttribute("tStart", String(project.simulation.t_start));
simulation.setAttribute("tStop", String(project.simulation.t_stop));
simulation.setAttribute("step", String(project.simulation.step));
simulation.setAttribute("maxStep", String(project.simulation.max_step));
simulation.setAttribute("method", project.simulation.method);
const components = xmlDocument.createElement("Components");
const connections = xmlDocument.createElement("Connections");
system.append(simulation, components, connections);
project.nodes.forEach((node) => {
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 parameter = xmlDocument.createElement("Parameter");
parameter.setAttribute("name", name);
parameter.setAttribute("value", String(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>
);
}