4419 lines
135 KiB
TypeScript
4419 lines
135 KiB
TypeScript
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>
|
||
);
|
||
}
|