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