同步远端 PNL0003 诊断和大采样网格能力,语义合并活动感知的 60 秒真停滞判定与旧后端 15 分钟兼容兜底。 纳管热路径优化、15 单元运行证据、浏览器与 API 报告,并补充北京时间更新日志和遗留问题。
11364 lines
361 KiB
TypeScript
11364 lines
361 KiB
TypeScript
import {
|
||
ChangeEvent,
|
||
DragEvent,
|
||
memo,
|
||
useCallback,
|
||
useEffect,
|
||
useLayoutEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
} from "react";
|
||
import { createPortal } from "react-dom";
|
||
import type {
|
||
CSSProperties,
|
||
KeyboardEvent as ReactKeyboardEvent,
|
||
MouseEvent as ReactMouseEvent,
|
||
PointerEvent as ReactPointerEvent,
|
||
ReactNode,
|
||
} from "react";
|
||
import {
|
||
ChevronDown,
|
||
ChevronUp,
|
||
ClipboardPaste,
|
||
Copy,
|
||
Download,
|
||
FileCode2,
|
||
FileDown,
|
||
FileUp,
|
||
FlipHorizontal2,
|
||
FolderOpen,
|
||
Grid2X2,
|
||
Grip,
|
||
ListChecks,
|
||
Magnet,
|
||
Maximize2,
|
||
Minimize2,
|
||
Move,
|
||
Play,
|
||
Redo2,
|
||
RotateCw,
|
||
Save,
|
||
Square,
|
||
SquareTerminal,
|
||
Trash2,
|
||
Undo2,
|
||
type LucideIcon,
|
||
} from "lucide-react";
|
||
import {
|
||
ConnectionLineType,
|
||
ConnectionMode,
|
||
Handle,
|
||
Panel,
|
||
PanOnScrollMode,
|
||
Position,
|
||
ReactFlow,
|
||
ReactFlowProvider,
|
||
ViewportPortal,
|
||
SelectionMode,
|
||
addEdge,
|
||
applyNodeChanges,
|
||
type Connection,
|
||
type Edge,
|
||
type EdgeChange,
|
||
type EdgeMouseHandler,
|
||
type Node as ReactFlowNode,
|
||
type NodeChange,
|
||
type NodeMouseHandler,
|
||
type NodeProps,
|
||
type NodeTypes,
|
||
type OnConnectStart,
|
||
type OnNodeDrag,
|
||
type OnSelectionChangeFunc,
|
||
type Viewport,
|
||
useEdgesState,
|
||
useNodesState,
|
||
useReactFlow,
|
||
useStore,
|
||
useStoreApi,
|
||
useUpdateNodeInternals,
|
||
} from "@xyflow/react";
|
||
import {
|
||
CANVAS_GRID_SIZE,
|
||
CanvasGridBackground,
|
||
type CanvasGridVisibility,
|
||
} from "./CanvasGridBackground";
|
||
import {
|
||
EmptySimulationResultsView,
|
||
SimulationResultsView,
|
||
type ResultVariableMetadata,
|
||
type SimulationResultsSnapshot,
|
||
} from "./SimulationResultsView";
|
||
import { AutoFitView } from "./AutoFitView";
|
||
import { DockedSimulationConsole } from "./DockedSimulationConsole";
|
||
import {
|
||
ComponentSymbol,
|
||
componentSymbolLayout,
|
||
componentSymbolPresentation,
|
||
componentSymbolVariantKey,
|
||
hasComponentSymbol,
|
||
} from "./ComponentSymbol";
|
||
import type {
|
||
ComponentSymbolLayout,
|
||
ComponentSymbolPortAnchor,
|
||
ComponentSymbolSize,
|
||
ComponentSymbolViewBox,
|
||
} from "./componentSymbols/types";
|
||
import {
|
||
LMECHN1_MAX_RIGHT_PORT_COUNT,
|
||
lmechn1RightPortCount,
|
||
} from "./componentSymbols/mechanical";
|
||
import {
|
||
SIMULATION_ACTIVITY_STALL_TIMEOUT_MS,
|
||
SIMULATION_ACTIVITY_STALL_TIMEOUT_SECONDS,
|
||
SIMULATION_SOLVER_STALL_TIMEOUT_MINUTES,
|
||
SIMULATION_SOLVER_STALL_TIMEOUT_MS,
|
||
solverActivityStallTimeoutMessage,
|
||
solverStallTimeoutMessage,
|
||
} from "./simulationTimeout";
|
||
import {
|
||
CONTACT_AWARE_EDGE_TYPE,
|
||
contactAwareEdgeTypes,
|
||
} from "./ContactAwareEdge";
|
||
import {
|
||
buildOrthogonalRoutePoints,
|
||
detectEdgeCrossingJumps,
|
||
edgePointIsFinite,
|
||
orthogonalEdgePoints,
|
||
orthogonalPolylinePath,
|
||
type EdgeRouteData,
|
||
type EdgeRoutePoint,
|
||
} from "./edgeRouting";
|
||
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 PortConnectionDraft = {
|
||
pointer: EdgeRoutePoint | null;
|
||
waypoints: EdgeRoutePoint[];
|
||
};
|
||
|
||
type ContactSnapMatch = {
|
||
connection: Connection;
|
||
draggedNodeId: string;
|
||
draggedPortName: string;
|
||
targetNodeId: string;
|
||
targetPortName: string;
|
||
};
|
||
|
||
type ContactSnapCandidate = {
|
||
matches: ContactSnapMatch[];
|
||
positionDelta: { x: number; y: number };
|
||
};
|
||
|
||
type ContactDragSession = {
|
||
contactEdgeIds: Set<string>;
|
||
disconnected: boolean;
|
||
draggedNodeIds: Set<string>;
|
||
effectiveEdges: SimulationEdge[];
|
||
lastCandidate: ContactSnapCandidate | null;
|
||
lastEvaluatedPosition: { x: number; y: number } | null;
|
||
lastSnapRadius: number;
|
||
multipleNodes: boolean;
|
||
nodeId: string;
|
||
snapSearchContext: ContactSnapSearchContext;
|
||
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" | "error";
|
||
|
||
class ComponentCatalogLibraryError extends Error {
|
||
readonly libraryName: string;
|
||
|
||
constructor(libraryName: string, message: string) {
|
||
super(message);
|
||
this.name = "ComponentCatalogLibraryError";
|
||
this.libraryName = libraryName;
|
||
}
|
||
}
|
||
|
||
type SimulationNodeData = {
|
||
label: string;
|
||
componentType: string;
|
||
modelType: string;
|
||
modelVersion?: string;
|
||
modelContractIssues?: 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 = ReactFlowNode<SimulationNodeData, "simulationComponent">;
|
||
type SimulationEdge = Edge;
|
||
|
||
type ComponentPortPresentationData = Pick<
|
||
SimulationNodeData,
|
||
"componentType" | "modelType" | "ports" | "parameters"
|
||
>;
|
||
|
||
function isLmechn1NodeData(data: ComponentPortPresentationData) {
|
||
return (
|
||
data.componentType === "amesim_lmechn1" ||
|
||
data.modelType === "amesim_lmechn1"
|
||
);
|
||
}
|
||
|
||
/**
|
||
* LMECHN1 的目录合同始终保留 21 个端口;工作区只展示当前 N 个右侧端口
|
||
* 和编号为 N+1 的左侧参考端口。
|
||
*/
|
||
function displayedPortsForNodeData(data: ComponentPortPresentationData) {
|
||
if (!isLmechn1NodeData(data)) {
|
||
return data.ports;
|
||
}
|
||
const rightPortCount = lmechn1RightPortCount(data.parameters);
|
||
const portsByName = new Map(data.ports.map((port) => [port.name, port]));
|
||
const referencePort = portsByName.get(`port_${rightPortCount + 1}`);
|
||
const rightPorts = Array.from({ length: rightPortCount }, (_, index) =>
|
||
portsByName.get(`port_${index + 1}`),
|
||
).filter((port): port is PortDefinition => Boolean(port));
|
||
return [
|
||
...(referencePort ? [{ ...referencePort, side: "left" as const }] : []),
|
||
...rightPorts.map((port) => ({ ...port, side: "right" as const })),
|
||
];
|
||
}
|
||
|
||
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,
|
||
"modelContractIssues" | "parameterUnits" | "parameterScientificNotation"
|
||
> & {
|
||
parameterUnits?: Record<string, string>;
|
||
parameterScientificNotation?: Record<string, ParameterScientificNotation>;
|
||
};
|
||
};
|
||
|
||
type ProjectEdgePayload = {
|
||
id: string;
|
||
source: string;
|
||
target: string;
|
||
sourceHandle: string;
|
||
targetHandle: string;
|
||
data: {
|
||
isContactEdge: boolean;
|
||
routePoints?: EdgeRoutePoint[];
|
||
};
|
||
};
|
||
|
||
type ReactFlowProjectPayload = {
|
||
projectSchemaVersion: 1;
|
||
name: string;
|
||
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;
|
||
signature: string;
|
||
simulationConfig: SimulationConfig;
|
||
};
|
||
|
||
type EditorClipboard = {
|
||
nodes: SimulationNode[];
|
||
edges: SimulationEdge[];
|
||
};
|
||
|
||
type PendingPaste = {
|
||
anchor: { x: number; y: number };
|
||
edges: SimulationEdge[];
|
||
nodes: SimulationNode[];
|
||
repairedReferenceCount: number;
|
||
snapOffset: { x: number; y: number };
|
||
};
|
||
|
||
type PendingPasteMiddlePointer = {
|
||
pointerId: number;
|
||
startX: number;
|
||
startY: number;
|
||
moved: boolean;
|
||
};
|
||
|
||
type RenderedNodeCacheEntry = {
|
||
presentationContext: object;
|
||
presentationSignature: string;
|
||
renderedNode: SimulationNode;
|
||
sourceNode: SimulationNode;
|
||
};
|
||
|
||
type CachedNodePortGeometry = {
|
||
mirrored: boolean;
|
||
placementByPort: Map<string, { flowX: number; flowY: number }>;
|
||
positionX: number;
|
||
positionY: number;
|
||
rotation: number;
|
||
sourceData: SimulationNode["data"];
|
||
};
|
||
|
||
type RenderedEdgeCacheEntry = {
|
||
presentationSignature: string;
|
||
renderedEdge: SimulationEdge;
|
||
sourceEdge: 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 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_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 GRID_SIZE = CANVAS_GRID_SIZE;
|
||
const MODELING_DEFAULT_EDGE_OPTIONS = {
|
||
interactionWidth: 28,
|
||
type: CONTACT_AWARE_EDGE_TYPE,
|
||
};
|
||
const MODELING_DELETE_KEYS = ["Backspace", "Delete"];
|
||
const MODELING_PAN_ON_DRAG = [1];
|
||
const MODELING_PRO_OPTIONS = { hideAttribution: true };
|
||
const MODELING_SNAP_GRID: [number, number] = [GRID_SIZE, GRID_SIZE];
|
||
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 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;
|
||
// 接触吸附同时保留最小屏幕半径与最小画布半径:
|
||
// 缩小时不会缩成难以命中的像素范围,放大时也不会因换算后半径过小而难以对接。
|
||
const CONTACT_SNAP_RADIUS_PX = 10;
|
||
const CONTACT_SNAP_RADIUS_FLOW = 10;
|
||
const CONTACT_DISCONNECT_DISTANCE_PX = 8;
|
||
const CONTACT_GEOMETRY_TOLERANCE = 0.5;
|
||
const CONTACT_DELETE_SEPARATION = 16;
|
||
// 功能 4 审阅开关:关闭即可恢复旧浮动控制台,且不影响复制、粘贴与画布操作。
|
||
const USE_DOCKED_SIMULATION_CONSOLE = true;
|
||
|
||
function contactSnapRadiusForZoom(zoom: number) {
|
||
const safeZoom = Math.max(zoom, 0.1);
|
||
return Math.max(CONTACT_SNAP_RADIUS_FLOW, CONTACT_SNAP_RADIUS_PX / safeZoom);
|
||
}
|
||
|
||
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)),
|
||
};
|
||
}
|
||
|
||
const SYMBOL_ENVELOPE_CACHE_LIMIT = 512;
|
||
const symbolEnvelopeCache = new Map<string, SymbolEnvelope>();
|
||
|
||
function cacheSymbolEnvelope(key: string, envelope: SymbolEnvelope) {
|
||
if (!symbolEnvelopeCache.has(key) && symbolEnvelopeCache.size >= SYMBOL_ENVELOPE_CACHE_LIMIT) {
|
||
const oldestKey = symbolEnvelopeCache.keys().next().value;
|
||
if (typeof oldestKey === "string") {
|
||
symbolEnvelopeCache.delete(oldestKey);
|
||
}
|
||
}
|
||
symbolEnvelopeCache.set(key, envelope);
|
||
}
|
||
|
||
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 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 SimulationActivityTelemetry = {
|
||
activitySequence?: number;
|
||
activityKind?: string;
|
||
currentTrialTime?: number | null;
|
||
rhsCallCount?: number;
|
||
acceptedStepSequence?: number;
|
||
acceptedTime?: number | null;
|
||
};
|
||
|
||
export type SimulationActivityWatchdogStatus =
|
||
| "progressing"
|
||
| "slow-active"
|
||
| "slow-telemetry-unavailable"
|
||
| "stalled";
|
||
|
||
export type SimulationActivityWatchdogState = {
|
||
lastAcceptedProgressAt: number;
|
||
lastActivityChangeAt: number | null;
|
||
lastActivitySequence: number | null;
|
||
lastAcceptedStepSequence: number | null;
|
||
lastAcceptedTime: number | null;
|
||
integratingPhaseActive: boolean;
|
||
};
|
||
|
||
export type SimulationActivityWatchdogObservation = {
|
||
status: SimulationActivityWatchdogStatus;
|
||
acceptedProgressIdleMs: number;
|
||
activityIdleMs: number | null;
|
||
activityTelemetryAvailable: boolean;
|
||
};
|
||
|
||
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;
|
||
} & SimulationActivityTelemetry)
|
||
| {
|
||
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;
|
||
};
|
||
|
||
export function createSimulationActivityWatchdog(
|
||
now = Date.now(),
|
||
): SimulationActivityWatchdogState {
|
||
return {
|
||
lastAcceptedProgressAt: now,
|
||
lastActivityChangeAt: null,
|
||
lastActivitySequence: null,
|
||
lastAcceptedStepSequence: null,
|
||
lastAcceptedTime: null,
|
||
integratingPhaseActive: false,
|
||
};
|
||
}
|
||
|
||
export function isSimulationSolverActivityPhase(phase: string): boolean {
|
||
return phase === "integrating";
|
||
}
|
||
|
||
export function observeSimulationActivityWatchdog(
|
||
current: SimulationActivityWatchdogState,
|
||
event: SimulationActivityTelemetry & { heartbeat?: boolean; phase?: string },
|
||
now = Date.now(),
|
||
stallTimeoutMs = SIMULATION_ACTIVITY_STALL_TIMEOUT_MS,
|
||
legacyStallTimeoutMs = SIMULATION_SOLVER_STALL_TIMEOUT_MS,
|
||
): {
|
||
state: SimulationActivityWatchdogState;
|
||
observation: SimulationActivityWatchdogObservation;
|
||
} {
|
||
const reportsPhase = typeof event.phase === "string";
|
||
if (reportsPhase && !isSimulationSolverActivityPhase(event.phase!)) {
|
||
return {
|
||
state: createSimulationActivityWatchdog(now),
|
||
observation: {
|
||
status: "progressing",
|
||
acceptedProgressIdleMs: 0,
|
||
activityIdleMs: null,
|
||
activityTelemetryAvailable: false,
|
||
},
|
||
};
|
||
}
|
||
|
||
const baseline =
|
||
reportsPhase && !current.integratingPhaseActive
|
||
? {
|
||
...createSimulationActivityWatchdog(now),
|
||
integratingPhaseActive: true,
|
||
}
|
||
: current;
|
||
let acceptedProgress = event.heartbeat !== true;
|
||
let lastAcceptedStepSequence = baseline.lastAcceptedStepSequence;
|
||
let lastAcceptedTime = baseline.lastAcceptedTime;
|
||
|
||
if (
|
||
typeof event.acceptedStepSequence === "number" &&
|
||
Number.isFinite(event.acceptedStepSequence)
|
||
) {
|
||
if (
|
||
lastAcceptedStepSequence !== null &&
|
||
event.acceptedStepSequence !== lastAcceptedStepSequence
|
||
) {
|
||
acceptedProgress = true;
|
||
}
|
||
lastAcceptedStepSequence = event.acceptedStepSequence;
|
||
}
|
||
if (typeof event.acceptedTime === "number" && Number.isFinite(event.acceptedTime)) {
|
||
if (lastAcceptedTime !== null && event.acceptedTime !== lastAcceptedTime) {
|
||
acceptedProgress = true;
|
||
}
|
||
lastAcceptedTime = event.acceptedTime;
|
||
}
|
||
|
||
let lastActivityChangeAt = baseline.lastActivityChangeAt;
|
||
let lastActivitySequence = baseline.lastActivitySequence;
|
||
const reportsActivitySequence =
|
||
typeof event.activitySequence === "number" &&
|
||
Number.isFinite(event.activitySequence);
|
||
if (reportsActivitySequence) {
|
||
if (
|
||
lastActivitySequence === null ||
|
||
event.activitySequence !== lastActivitySequence
|
||
) {
|
||
lastActivityChangeAt = now;
|
||
}
|
||
lastActivitySequence = event.activitySequence!;
|
||
}
|
||
|
||
const lastAcceptedProgressAt = acceptedProgress
|
||
? now
|
||
: baseline.lastAcceptedProgressAt;
|
||
const acceptedProgressIdleMs = Math.max(0, now - lastAcceptedProgressAt);
|
||
const activityIdleMs =
|
||
lastActivityChangeAt === null
|
||
? null
|
||
: Math.max(0, now - lastActivityChangeAt);
|
||
const effectiveStallTimeoutMs = Math.max(0, stallTimeoutMs);
|
||
const effectiveLegacyStallTimeoutMs = Math.max(
|
||
effectiveStallTimeoutMs,
|
||
legacyStallTimeoutMs,
|
||
);
|
||
let status: SimulationActivityWatchdogStatus = "progressing";
|
||
if (acceptedProgressIdleMs >= effectiveStallTimeoutMs) {
|
||
if (!reportsActivitySequence || activityIdleMs === null) {
|
||
status =
|
||
acceptedProgressIdleMs >= effectiveLegacyStallTimeoutMs
|
||
? "stalled"
|
||
: "slow-telemetry-unavailable";
|
||
} else if (activityIdleMs >= effectiveStallTimeoutMs) {
|
||
status = "stalled";
|
||
} else {
|
||
status = "slow-active";
|
||
}
|
||
}
|
||
|
||
return {
|
||
state: {
|
||
lastAcceptedProgressAt,
|
||
lastActivityChangeAt,
|
||
lastActivitySequence,
|
||
lastAcceptedStepSequence,
|
||
lastAcceptedTime,
|
||
integratingPhaseActive:
|
||
reportsPhase || baseline.integratingPhaseActive,
|
||
},
|
||
observation: {
|
||
status,
|
||
acceptedProgressIdleMs,
|
||
activityIdleMs,
|
||
activityTelemetryAvailable: reportsActivitySequence,
|
||
},
|
||
};
|
||
}
|
||
const SUPPORTED_SOLVER_METHODS = ["BDF", "Radau", "LSODA", "RK45", "RK23", "DOP853"];
|
||
const simulationConfigLabels: Record<SimulationNumericKey, string> = {
|
||
t_start: "起始时间",
|
||
t_stop: "结束时间",
|
||
step: "采样步长",
|
||
max_step: "最大积分步长",
|
||
};
|
||
|
||
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, libraryIndex) => {
|
||
const libraryName =
|
||
typeof library?.label === "string" && library.label.trim()
|
||
? library.label.trim()
|
||
: typeof library?.id === "string" && library.id.trim()
|
||
? library.id.trim()
|
||
: `第 ${libraryIndex + 1} 个组件库`;
|
||
if (
|
||
!library ||
|
||
typeof library.id !== "string" ||
|
||
typeof library.label !== "string" ||
|
||
typeof library.version !== "string" ||
|
||
!Array.isArray(library.components)
|
||
) {
|
||
throw new ComponentCatalogLibraryError(
|
||
libraryName,
|
||
`组件库 ${libraryName} 声明不完整`,
|
||
);
|
||
}
|
||
const components = library.components.map((component) => {
|
||
if (
|
||
!component ||
|
||
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 ComponentCatalogLibraryError(
|
||
libraryName,
|
||
`组件库 ${libraryName} 包含无效元件`,
|
||
);
|
||
}
|
||
if (componentTypes.has(component.type)) {
|
||
throw new ComponentCatalogLibraryError(
|
||
libraryName,
|
||
`组件库 ${libraryName} 包含重复组件类型:${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,
|
||
modelVersion: definition.modelVersion,
|
||
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 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 (
|
||
node.data.modelContractIssues?.length ||
|
||
!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,
|
||
};
|
||
}
|
||
|
||
type SimulationComponentNodeProps = NodeProps<SimulationNode>;
|
||
|
||
function SimulationComponentNodeImpl({
|
||
id,
|
||
data,
|
||
isConnectable,
|
||
selected,
|
||
}: SimulationComponentNodeProps) {
|
||
const updateNodeInternals = useUpdateNodeInternals();
|
||
const viewportZoom = useStore((flowState) => flowState.transform[2]);
|
||
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 = useMemo(
|
||
() => componentSymbolLayout(symbol, data.parameters),
|
||
[data.parameters, symbol],
|
||
);
|
||
const renderedSize = nodeFrameDimensions(layout, rotation);
|
||
const layoutSignature = useMemo(
|
||
() =>
|
||
`${layout.viewBox.x}:${layout.viewBox.y}:${layout.viewBox.width}:${layout.viewBox.height}:${layout.symbolSize.width}:${layout.symbolSize.height}:${layout.nodeSize.width}:${layout.nodeSize.height}:${JSON.stringify(layout.portAnchors ?? {})}`,
|
||
[layout],
|
||
);
|
||
const symbolVariantKey = useMemo(
|
||
() => componentSymbolVariantKey(symbol, data.parameters),
|
||
[data.parameters, symbol],
|
||
);
|
||
const symbolEnvelopeCacheKey = `${symbol}|${symbolVariantKey}|${layoutSignature}`;
|
||
const nodeInternalsGeometryKey = `${layoutSignature}|${rotation}|${mirrored ? 1 : 0}`;
|
||
const previousNodeInternalsGeometryKeyRef = useRef(nodeInternalsGeometryKey);
|
||
const [symbolEnvelope, setSymbolEnvelope] = useState<SymbolEnvelope>(
|
||
() =>
|
||
symbolEnvelopeCache.get(symbolEnvelopeCacheKey) ??
|
||
defaultSymbolEnvelope(layout),
|
||
);
|
||
const displayedPorts = useMemo(
|
||
() => displayedPortsForNodeData(data),
|
||
[data.componentType, data.modelType, data.parameters, data.ports],
|
||
);
|
||
const portPlacements = useMemo(
|
||
() =>
|
||
transformedPortPlacements(
|
||
displayedPorts,
|
||
rotation,
|
||
mirrored,
|
||
layout,
|
||
),
|
||
[displayedPorts, layout, mirrored, rotation],
|
||
);
|
||
const visiblePortIndexPlacements = data.hideConnectedPortIndexLabels
|
||
? portPlacements.filter(
|
||
({ port }) => data.portConnectionStates?.[port.name] !== "connected",
|
||
)
|
||
: portPlacements;
|
||
const portIndexLabels = selected
|
||
? portIndexLabelPlacements(
|
||
displayedPorts,
|
||
visiblePortIndexPlacements,
|
||
layout,
|
||
rotation,
|
||
)
|
||
: [];
|
||
const inverseViewportScale = viewportZoom > 0 ? 1 / viewportZoom : 1;
|
||
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 cachedEnvelope = symbolEnvelopeCache.get(symbolEnvelopeCacheKey);
|
||
if (cachedEnvelope) {
|
||
setSymbolEnvelope(cachedEnvelope);
|
||
return;
|
||
}
|
||
const svg = symbolContainerRef.current?.querySelector<SVGSVGElement>(
|
||
".component-symbol-svg",
|
||
);
|
||
if (!svg) {
|
||
return;
|
||
}
|
||
const nextEnvelope = measuredSymbolEnvelope(svg, layout);
|
||
cacheSymbolEnvelope(symbolEnvelopeCacheKey, nextEnvelope);
|
||
setSymbolEnvelope((currentEnvelope) =>
|
||
currentEnvelope.left === nextEnvelope.left &&
|
||
currentEnvelope.top === nextEnvelope.top &&
|
||
currentEnvelope.width === nextEnvelope.width &&
|
||
currentEnvelope.height === nextEnvelope.height
|
||
? currentEnvelope
|
||
: nextEnvelope,
|
||
);
|
||
}, [hasDedicatedSymbol, layout, symbolEnvelopeCacheKey]);
|
||
|
||
useEffect(() => {
|
||
if (previousNodeInternalsGeometryKeyRef.current === nodeInternalsGeometryKey) {
|
||
return;
|
||
}
|
||
previousNodeInternalsGeometryKeyRef.current = nodeInternalsGeometryKey;
|
||
updateNodeInternals(id);
|
||
}, [id, nodeInternalsGeometryKey, 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.modelContractIssues?.length ? "model-contract-incompatible" : ""}`}
|
||
data-model-contract-compatible={data.modelContractIssues?.length ? "false" : "true"}
|
||
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={isConnectable && !isConnected}
|
||
isConnectableEnd={
|
||
isConnectable &&
|
||
(connectionState === "idle" || connectionState === "compatible")
|
||
}
|
||
isConnectableStart={
|
||
isConnectable &&
|
||
(connectionState === "idle" || connectionState === "origin")
|
||
}
|
||
key={port.name}
|
||
position={position}
|
||
style={{
|
||
left: x,
|
||
top: y,
|
||
transform: `scale(${inverseViewportScale}) translate(-50%, -50%)`,
|
||
transformOrigin: "0 0",
|
||
}}
|
||
title={`${port.name} (${port.domain})`}
|
||
type={portHandleType(port)}
|
||
>
|
||
<svg
|
||
aria-hidden="true"
|
||
className="port-handle-glyph"
|
||
preserveAspectRatio="xMidYMid meet"
|
||
shapeRendering="geometricPrecision"
|
||
style={{
|
||
transform: `scale(${viewportZoom})`,
|
||
transformOrigin: "center",
|
||
}}
|
||
viewBox="0 0 6 6"
|
||
>
|
||
{port.kind === "physical" ? (
|
||
<circle
|
||
className="port-handle-glyph-shape"
|
||
cx="3"
|
||
cy="3"
|
||
data-port-glyph-shape="circle"
|
||
r="2.5"
|
||
/>
|
||
) : (
|
||
<rect
|
||
className="port-handle-glyph-shape"
|
||
data-port-glyph-shape="signal"
|
||
height="5"
|
||
rx="1.25"
|
||
ry="1.25"
|
||
width="5"
|
||
x="0.5"
|
||
y="0.5"
|
||
/>
|
||
)}
|
||
</svg>
|
||
</Handle>
|
||
);
|
||
})}
|
||
{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>
|
||
))}
|
||
{data.modelContractIssues?.length ? (
|
||
<span
|
||
aria-label={`${data.label} 模型合同不兼容`}
|
||
className="model-contract-warning"
|
||
role="img"
|
||
title={data.modelContractIssues.join("\n")}
|
||
>
|
||
!
|
||
</span>
|
||
) : null}
|
||
<div className="node-label">{data.label}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const SimulationComponentNode = memo(
|
||
SimulationComponentNodeImpl,
|
||
(
|
||
previous: SimulationComponentNodeProps,
|
||
next: SimulationComponentNodeProps,
|
||
) =>
|
||
previous.id === next.id &&
|
||
previous.data === next.data &&
|
||
previous.isConnectable === next.isConnectable &&
|
||
previous.selected === next.selected,
|
||
);
|
||
SimulationComponentNode.displayName = "SimulationComponentNode";
|
||
|
||
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, node.data.parameters);
|
||
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(
|
||
displayedPortsForNodeData(node.data),
|
||
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, node.data.parameters),
|
||
);
|
||
}
|
||
|
||
function nodePortFlowPlacement(node: SimulationNode, portName: string | null) {
|
||
if (!portName) {
|
||
return undefined;
|
||
}
|
||
return nodePortFlowPlacements(node).find(
|
||
(placement) => placement.port.name === portName,
|
||
);
|
||
}
|
||
|
||
function resultNodePortFlowPlacement(
|
||
node: SimulationResultsSnapshot["project"]["nodes"][number],
|
||
portName: string | null,
|
||
) {
|
||
return nodePortFlowPlacement(node as unknown as SimulationNode, portName);
|
||
}
|
||
|
||
function connectionForContactPorts(
|
||
draggedNode: SimulationNode,
|
||
draggedPort: PortDefinition,
|
||
targetNode: SimulationNode,
|
||
targetPort: PortDefinition,
|
||
): Connection {
|
||
if (draggedPort.kind === "signal" && draggedPort.nominalRole === "input") {
|
||
return {
|
||
source: targetNode.id,
|
||
sourceHandle: targetPort.name,
|
||
target: draggedNode.id,
|
||
targetHandle: draggedPort.name,
|
||
};
|
||
}
|
||
return {
|
||
source: draggedNode.id,
|
||
sourceHandle: draggedPort.name,
|
||
target: targetNode.id,
|
||
targetHandle: targetPort.name,
|
||
};
|
||
}
|
||
|
||
type ContactSnapPair = ContactSnapMatch & {
|
||
distance: number;
|
||
draggedNodeOrder: number;
|
||
draggedPortOrder: number;
|
||
targetNodeOrder: number;
|
||
targetPortOrder: number;
|
||
};
|
||
|
||
type ContactSnapPlacement = ReturnType<typeof nodePortFlowPlacements>[number];
|
||
|
||
type ContactSnapIndexedTarget = {
|
||
key: string;
|
||
placement: ContactSnapPlacement;
|
||
targetNode: SimulationNode;
|
||
targetNodeOrder: number;
|
||
targetPortOrder: number;
|
||
};
|
||
|
||
type ContactSnapSearchContext = {
|
||
buckets: Map<string, ContactSnapIndexedTarget[]>;
|
||
cellSize: number;
|
||
draggedNodeIdsKey: string;
|
||
occupiedPorts: Set<string>;
|
||
targetByPortKey: Map<string, ContactSnapIndexedTarget>;
|
||
};
|
||
|
||
function contactSnapDraggedNodeIdsKey(nodeIds: ReadonlySet<string>) {
|
||
return [...nodeIds].sort().join("\u001f");
|
||
}
|
||
|
||
function contactSnapCell(value: number, cellSize: number) {
|
||
return Math.floor(value / cellSize);
|
||
}
|
||
|
||
function contactSnapBucketKey(cellX: number, cellY: number) {
|
||
return `${cellX}:${cellY}`;
|
||
}
|
||
|
||
function contactSnapTargetsNear(
|
||
context: ContactSnapSearchContext,
|
||
flowX: number,
|
||
flowY: number,
|
||
radius: number,
|
||
) {
|
||
const minCellX = contactSnapCell(flowX - radius, context.cellSize);
|
||
const maxCellX = contactSnapCell(flowX + radius, context.cellSize);
|
||
const minCellY = contactSnapCell(flowY - radius, context.cellSize);
|
||
const maxCellY = contactSnapCell(flowY + radius, context.cellSize);
|
||
const targets: ContactSnapIndexedTarget[] = [];
|
||
for (let cellX = minCellX; cellX <= maxCellX; cellX += 1) {
|
||
for (let cellY = minCellY; cellY <= maxCellY; cellY += 1) {
|
||
targets.push(
|
||
...(context.buckets.get(contactSnapBucketKey(cellX, cellY)) ?? []),
|
||
);
|
||
}
|
||
}
|
||
return targets;
|
||
}
|
||
|
||
function compareContactSnapPairs(
|
||
first: ContactSnapPair,
|
||
second: ContactSnapPair,
|
||
) {
|
||
return (
|
||
first.distance - second.distance ||
|
||
first.draggedNodeOrder - second.draggedNodeOrder ||
|
||
first.draggedPortOrder - second.draggedPortOrder ||
|
||
first.targetNodeOrder - second.targetNodeOrder ||
|
||
first.targetPortOrder - second.targetPortOrder ||
|
||
first.draggedNodeId.localeCompare(second.draggedNodeId) ||
|
||
first.draggedPortName.localeCompare(second.draggedPortName) ||
|
||
first.targetNodeId.localeCompare(second.targetNodeId) ||
|
||
first.targetPortName.localeCompare(second.targetPortName)
|
||
);
|
||
}
|
||
|
||
function buildContactSnapSearchContext(
|
||
draggedNodeIds: ReadonlySet<string>,
|
||
nodes: SimulationNode[],
|
||
edges: SimulationEdge[],
|
||
): ContactSnapSearchContext {
|
||
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 cellSize = CONTACT_SNAP_RADIUS_FLOW;
|
||
const buckets = new Map<string, ContactSnapIndexedTarget[]>();
|
||
const targetByPortKey = new Map<string, ContactSnapIndexedTarget>();
|
||
nodes.forEach((targetNode, targetNodeOrder) => {
|
||
if (draggedNodeIds.has(targetNode.id)) {
|
||
return;
|
||
}
|
||
const targetPortOrder = new Map(
|
||
targetNode.data.ports.map((port, index) => [port.name, index] as const),
|
||
);
|
||
nodePortFlowPlacements(targetNode).forEach((placement) => {
|
||
const key = portConnectionKey(targetNode.id, placement.port.name);
|
||
if (occupiedPorts.has(key)) {
|
||
return;
|
||
}
|
||
const indexedTarget: ContactSnapIndexedTarget = {
|
||
key,
|
||
placement,
|
||
targetNode,
|
||
targetNodeOrder,
|
||
targetPortOrder:
|
||
targetPortOrder.get(placement.port.name) ?? Number.MAX_SAFE_INTEGER,
|
||
};
|
||
const bucketKey = contactSnapBucketKey(
|
||
contactSnapCell(placement.flowX, cellSize),
|
||
contactSnapCell(placement.flowY, cellSize),
|
||
);
|
||
const bucket = buckets.get(bucketKey);
|
||
if (bucket) {
|
||
bucket.push(indexedTarget);
|
||
} else {
|
||
buckets.set(bucketKey, [indexedTarget]);
|
||
}
|
||
targetByPortKey.set(key, indexedTarget);
|
||
});
|
||
});
|
||
return {
|
||
buckets,
|
||
cellSize,
|
||
draggedNodeIdsKey: contactSnapDraggedNodeIdsKey(draggedNodeIds),
|
||
occupiedPorts,
|
||
targetByPortKey,
|
||
};
|
||
}
|
||
|
||
function availableContactSnapPairs(
|
||
draggedNodes: SimulationNode[],
|
||
nodes: SimulationNode[],
|
||
edges: SimulationEdge[],
|
||
maximumDistance: number,
|
||
searchContext?: ContactSnapSearchContext,
|
||
) {
|
||
const draggedNodeIds = new Set(draggedNodes.map((node) => node.id));
|
||
const draggedNodeIdsKey = contactSnapDraggedNodeIdsKey(draggedNodeIds);
|
||
const resolvedContext =
|
||
searchContext?.draggedNodeIdsKey === draggedNodeIdsKey
|
||
? searchContext
|
||
: buildContactSnapSearchContext(draggedNodeIds, nodes, edges);
|
||
const { occupiedPorts } = resolvedContext;
|
||
const pairs: ContactSnapPair[] = [];
|
||
const maximumDistanceSquared = maximumDistance * maximumDistance;
|
||
|
||
draggedNodes.forEach((draggedNode, draggedNodeOrder) => {
|
||
const draggedPortOrder = new Map(
|
||
draggedNode.data.ports.map((port, index) => [port.name, index] as const),
|
||
);
|
||
const draggedPlacements = nodePortFlowPlacements(draggedNode).filter(
|
||
(placement) =>
|
||
!occupiedPorts.has(
|
||
portConnectionKey(draggedNode.id, placement.port.name),
|
||
),
|
||
);
|
||
for (const draggedPlacement of draggedPlacements) {
|
||
for (const indexedTarget of contactSnapTargetsNear(
|
||
resolvedContext,
|
||
draggedPlacement.flowX,
|
||
draggedPlacement.flowY,
|
||
maximumDistance,
|
||
)) {
|
||
const { placement: targetPlacement, targetNode } = indexedTarget;
|
||
if (!arePortsCompatible(draggedPlacement.port, targetPlacement.port)) {
|
||
continue;
|
||
}
|
||
const deltaX = targetPlacement.flowX - draggedPlacement.flowX;
|
||
const deltaY = targetPlacement.flowY - draggedPlacement.flowY;
|
||
const distanceSquared = deltaX * deltaX + deltaY * deltaY;
|
||
if (distanceSquared > maximumDistanceSquared) {
|
||
continue;
|
||
}
|
||
pairs.push({
|
||
connection: connectionForContactPorts(
|
||
draggedNode,
|
||
draggedPlacement.port,
|
||
targetNode,
|
||
targetPlacement.port,
|
||
),
|
||
distance: Math.sqrt(distanceSquared),
|
||
draggedNodeId: draggedNode.id,
|
||
draggedNodeOrder,
|
||
draggedPortName: draggedPlacement.port.name,
|
||
draggedPortOrder:
|
||
draggedPortOrder.get(draggedPlacement.port.name) ??
|
||
Number.MAX_SAFE_INTEGER,
|
||
targetNodeId: targetNode.id,
|
||
targetNodeOrder: indexedTarget.targetNodeOrder,
|
||
targetPortOrder: indexedTarget.targetPortOrder,
|
||
targetPortName: targetPlacement.port.name,
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
return pairs.sort(compareContactSnapPairs);
|
||
}
|
||
|
||
function maximumContactSnapMatches(pairs: ContactSnapPair[]) {
|
||
// 二分图最大匹配的两侧都使用完整 nodeId:portName,避免块内多个
|
||
// 组件拥有同名 port_1 时互相覆盖。
|
||
const pairsByDraggedPort = new Map<string, ContactSnapPair[]>();
|
||
pairs.forEach((pair) => {
|
||
const key = portConnectionKey(pair.draggedNodeId, pair.draggedPortName);
|
||
const current = pairsByDraggedPort.get(key) ?? [];
|
||
current.push(pair);
|
||
pairsByDraggedPort.set(key, current);
|
||
});
|
||
const pairByTargetPort = new Map<string, ContactSnapPair>();
|
||
|
||
const assignDraggedPort = (
|
||
draggedPortKey: string,
|
||
visitedTargetPorts: Set<string>,
|
||
): boolean => {
|
||
for (const pair of pairsByDraggedPort.get(draggedPortKey) ?? []) {
|
||
const targetKey = portConnectionKey(pair.targetNodeId, pair.targetPortName);
|
||
if (visitedTargetPorts.has(targetKey)) {
|
||
continue;
|
||
}
|
||
visitedTargetPorts.add(targetKey);
|
||
const currentPair = pairByTargetPort.get(targetKey);
|
||
const currentDraggedKey = currentPair
|
||
? portConnectionKey(
|
||
currentPair.draggedNodeId,
|
||
currentPair.draggedPortName,
|
||
)
|
||
: null;
|
||
if (
|
||
currentDraggedKey &&
|
||
!assignDraggedPort(currentDraggedKey, visitedTargetPorts)
|
||
) {
|
||
continue;
|
||
}
|
||
pairByTargetPort.set(targetKey, pair);
|
||
return true;
|
||
}
|
||
return false;
|
||
};
|
||
|
||
for (const draggedPortKey of pairsByDraggedPort.keys()) {
|
||
assignDraggedPort(draggedPortKey, new Set());
|
||
}
|
||
|
||
return [...pairByTargetPort.values()].sort(compareContactSnapPairs);
|
||
}
|
||
|
||
function findContactSnapCandidate(
|
||
draggedNodes: SimulationNode[],
|
||
nodes: SimulationNode[],
|
||
edges: SimulationEdge[],
|
||
maximumDistance: number,
|
||
searchContext?: ContactSnapSearchContext,
|
||
) {
|
||
if (draggedNodes.length === 0) {
|
||
return null;
|
||
}
|
||
const draggedNodeIds = new Set(draggedNodes.map((node) => node.id));
|
||
const draggedNodeIdsKey = contactSnapDraggedNodeIdsKey(draggedNodeIds);
|
||
const resolvedContext =
|
||
searchContext?.draggedNodeIdsKey === draggedNodeIdsKey
|
||
? searchContext
|
||
: buildContactSnapSearchContext(draggedNodeIds, nodes, edges);
|
||
const nearbyPairs = availableContactSnapPairs(
|
||
draggedNodes,
|
||
nodes,
|
||
edges,
|
||
maximumDistance,
|
||
resolvedContext,
|
||
);
|
||
const anchor = nearbyPairs[0];
|
||
if (!anchor) {
|
||
return null;
|
||
}
|
||
const draggedNode = draggedNodes.find(
|
||
(node) => node.id === anchor.draggedNodeId,
|
||
);
|
||
const draggedPlacement = draggedNode
|
||
? nodePortFlowPlacement(draggedNode, anchor.draggedPortName)
|
||
: undefined;
|
||
const targetPlacement = resolvedContext.targetByPortKey.get(
|
||
portConnectionKey(anchor.targetNodeId, anchor.targetPortName),
|
||
)?.placement;
|
||
if (!draggedPlacement || !targetPlacement) {
|
||
return null;
|
||
}
|
||
const positionDelta = {
|
||
x: targetPlacement.flowX - draggedPlacement.flowX,
|
||
y: targetPlacement.flowY - draggedPlacement.flowY,
|
||
};
|
||
const snappedDraggedNodes = draggedNodes.map((node) => ({
|
||
...node,
|
||
position: {
|
||
x: node.position.x + positionDelta.x,
|
||
y: node.position.y + positionDelta.y,
|
||
},
|
||
}));
|
||
// 整个块只采用一个平移量;仅建立该统一落点下真正重合的端口。
|
||
const exactPairs = availableContactSnapPairs(
|
||
snappedDraggedNodes,
|
||
nodes,
|
||
edges,
|
||
CONTACT_GEOMETRY_TOLERANCE,
|
||
resolvedContext,
|
||
);
|
||
const matches = maximumContactSnapMatches(exactPairs).map(
|
||
({
|
||
connection,
|
||
draggedNodeId,
|
||
draggedPortName,
|
||
targetNodeId,
|
||
targetPortName,
|
||
}) => ({
|
||
connection,
|
||
draggedNodeId,
|
||
draggedPortName,
|
||
targetNodeId,
|
||
targetPortName,
|
||
}),
|
||
);
|
||
return matches.length > 0 ? { matches, positionDelta } : null;
|
||
}
|
||
|
||
function sameContactSnapCandidate(
|
||
first: ContactSnapCandidate | null,
|
||
second: ContactSnapCandidate | null,
|
||
) {
|
||
return (
|
||
first === second ||
|
||
(Boolean(first) &&
|
||
Boolean(second) &&
|
||
first?.positionDelta.x === second?.positionDelta.x &&
|
||
first?.positionDelta.y === second?.positionDelta.y &&
|
||
first?.matches.length === second?.matches.length &&
|
||
first?.matches.every((match, index) => {
|
||
const other = second?.matches[index];
|
||
return Boolean(
|
||
other &&
|
||
match.draggedNodeId === other.draggedNodeId &&
|
||
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 routePointsForEdge(edge: SimulationEdge) {
|
||
const routePoints = (edge.data as EdgeRouteData | undefined)?.routePoints;
|
||
return Array.isArray(routePoints) && routePoints.every(edgePointIsFinite)
|
||
? routePoints
|
||
: [];
|
||
}
|
||
|
||
function translateEdgeRoute(
|
||
edge: SimulationEdge,
|
||
deltaX: number,
|
||
deltaY: number,
|
||
): SimulationEdge {
|
||
const routePoints = routePointsForEdge(edge);
|
||
if (
|
||
routePoints.length === 0 ||
|
||
(Math.abs(deltaX) <= 0.001 && Math.abs(deltaY) <= 0.001)
|
||
) {
|
||
return edge;
|
||
}
|
||
return {
|
||
...edge,
|
||
data: {
|
||
...edge.data,
|
||
routePoints: routePoints.map((point) => ({
|
||
x: point.x + deltaX,
|
||
y: point.y + deltaY,
|
||
})),
|
||
},
|
||
};
|
||
}
|
||
|
||
function translateInternalEdgeRoutes(
|
||
edges: SimulationEdge[],
|
||
movedNodeIds: ReadonlySet<string>,
|
||
deltaX: number,
|
||
deltaY: number,
|
||
) {
|
||
let changed = false;
|
||
const translated = edges.map((edge) => {
|
||
if (!movedNodeIds.has(edge.source) || !movedNodeIds.has(edge.target)) {
|
||
return edge;
|
||
}
|
||
const next = translateEdgeRoute(edge, deltaX, deltaY);
|
||
changed ||= next !== edge;
|
||
return next;
|
||
});
|
||
return changed ? translated : edges;
|
||
}
|
||
|
||
type NodePortGeometryIndex = Map<
|
||
string,
|
||
Map<string, { flowX: number; flowY: number }>
|
||
>;
|
||
|
||
function buildNodePortGeometryIndex(nodes: SimulationNode[]): NodePortGeometryIndex {
|
||
return new Map(
|
||
nodes.map((node) => [
|
||
node.id,
|
||
new Map(
|
||
nodePortFlowPlacements(node).map((placement) => [
|
||
placement.port.name,
|
||
{ flowX: placement.flowX, flowY: placement.flowY },
|
||
] as const),
|
||
),
|
||
] as const),
|
||
);
|
||
}
|
||
|
||
function isContactEdgeGeometryInIndex(
|
||
geometryIndex: NodePortGeometryIndex,
|
||
edge: SimulationEdge,
|
||
) {
|
||
const source = edge.sourceHandle
|
||
? geometryIndex.get(edge.source)?.get(edge.sourceHandle)
|
||
: undefined;
|
||
const target = edge.targetHandle
|
||
? geometryIndex.get(edge.target)?.get(edge.targetHandle)
|
||
: undefined;
|
||
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,
|
||
sourceNode.data.parameters,
|
||
),
|
||
normalizeNodeRotation(sourceNode.data.rotation),
|
||
);
|
||
const targetSize = nodeFrameDimensions(
|
||
componentSymbolLayout(
|
||
targetNode.data.symbol ?? targetNode.data.componentType,
|
||
targetNode.data.parameters,
|
||
),
|
||
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 previousGeometry = buildNodePortGeometryIndex(previousNodes);
|
||
const nextGeometry = buildNodePortGeometryIndex(nextNodes);
|
||
const remainingEdges = edges.filter((edge) => {
|
||
const affected =
|
||
transformedNodeIds.has(edge.source) || transformedNodeIds.has(edge.target);
|
||
const invalidated =
|
||
affected &&
|
||
isContactEdgeGeometryInIndex(previousGeometry, edge) &&
|
||
!isContactEdgeGeometryInIndex(nextGeometry, 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,
|
||
};
|
||
}
|
||
|
||
return {
|
||
percent: 0,
|
||
simulatedTime: config.t_start,
|
||
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;
|
||
}
|
||
return undefined;
|
||
} 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" : ""}`}
|
||
data-editor-shortcuts-ignore="true"
|
||
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" : ""}`}
|
||
data-editor-shortcuts-ignore="true"
|
||
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}
|
||
/>
|
||
);
|
||
}
|
||
|
||
type FlowViewportWorldBounds = {
|
||
minX: number;
|
||
minY: number;
|
||
maxX: number;
|
||
maxY: number;
|
||
};
|
||
|
||
const FLOW_VIEWPORT_DEFAULT_HALF_EXTENT = 2_000;
|
||
const FLOW_VIEWPORT_NODE_MARGIN = 600;
|
||
const FLOW_VIEWPORT_SCROLLBAR_CONTENT_LIMIT_PX = 8_000_000;
|
||
|
||
function defaultFlowViewportWorldBounds(): FlowViewportWorldBounds {
|
||
return {
|
||
minX: -FLOW_VIEWPORT_DEFAULT_HALF_EXTENT,
|
||
minY: -FLOW_VIEWPORT_DEFAULT_HALF_EXTENT,
|
||
maxX: FLOW_VIEWPORT_DEFAULT_HALF_EXTENT,
|
||
maxY: FLOW_VIEWPORT_DEFAULT_HALF_EXTENT,
|
||
};
|
||
}
|
||
|
||
function FlowViewportScrollbars({
|
||
deferBounds = false,
|
||
nodes,
|
||
}: {
|
||
deferBounds?: boolean;
|
||
nodes: SimulationNode[];
|
||
}) {
|
||
const reactFlow = useReactFlow<SimulationNode, SimulationEdge>();
|
||
const transform = useStore((flowState) => flowState.transform);
|
||
const canvasWidth = useStore((flowState) => flowState.width);
|
||
const canvasHeight = useStore((flowState) => flowState.height);
|
||
const horizontalRef = useRef<HTMLDivElement>(null);
|
||
const verticalRef = useRef<HTMLDivElement>(null);
|
||
const [scrollbarViewport, setScrollbarViewport] = useState({
|
||
horizontalWidth: 0,
|
||
verticalHeight: 0,
|
||
});
|
||
const [worldBounds, setWorldBounds] = useState<FlowViewportWorldBounds>(
|
||
defaultFlowViewportWorldBounds,
|
||
);
|
||
const nodeBoundsRef = useRef({ x: 0, y: 0, width: 0, height: 0 });
|
||
const nodeBounds = useMemo(() => {
|
||
if (deferBounds) {
|
||
return nodeBoundsRef.current;
|
||
}
|
||
return nodes.length > 0
|
||
? reactFlow.getNodesBounds(nodes)
|
||
: { x: 0, y: 0, width: 0, height: 0 };
|
||
}, [deferBounds, nodes, reactFlow]);
|
||
useLayoutEffect(() => {
|
||
if (!deferBounds) {
|
||
nodeBoundsRef.current = nodeBounds;
|
||
}
|
||
}, [deferBounds, nodeBounds]);
|
||
const zoom = Math.max(transform[2], 0.01);
|
||
const visibleLeft = -transform[0] / zoom;
|
||
const visibleTop = -transform[1] / zoom;
|
||
const visibleWidth = canvasWidth / zoom;
|
||
const visibleHeight = canvasHeight / zoom;
|
||
|
||
useLayoutEffect(() => {
|
||
if (nodes.length === 0) {
|
||
setWorldBounds(defaultFlowViewportWorldBounds());
|
||
return;
|
||
}
|
||
setWorldBounds((current) => {
|
||
const next = {
|
||
minX: Math.min(
|
||
current.minX,
|
||
nodeBounds.x - FLOW_VIEWPORT_NODE_MARGIN,
|
||
visibleLeft,
|
||
),
|
||
minY: Math.min(
|
||
current.minY,
|
||
nodeBounds.y - FLOW_VIEWPORT_NODE_MARGIN,
|
||
visibleTop,
|
||
),
|
||
maxX: Math.max(
|
||
current.maxX,
|
||
nodeBounds.x + nodeBounds.width + FLOW_VIEWPORT_NODE_MARGIN,
|
||
visibleLeft + visibleWidth,
|
||
),
|
||
maxY: Math.max(
|
||
current.maxY,
|
||
nodeBounds.y + nodeBounds.height + FLOW_VIEWPORT_NODE_MARGIN,
|
||
visibleTop + visibleHeight,
|
||
),
|
||
};
|
||
return next.minX === current.minX &&
|
||
next.minY === current.minY &&
|
||
next.maxX === current.maxX &&
|
||
next.maxY === current.maxY
|
||
? current
|
||
: next;
|
||
});
|
||
}, [
|
||
nodeBounds.height,
|
||
nodeBounds.width,
|
||
nodeBounds.x,
|
||
nodeBounds.y,
|
||
nodes.length,
|
||
visibleHeight,
|
||
visibleLeft,
|
||
visibleTop,
|
||
visibleWidth,
|
||
]);
|
||
|
||
useLayoutEffect(() => {
|
||
const updateScrollbarViewport = () => {
|
||
const next = {
|
||
horizontalWidth: horizontalRef.current?.clientWidth ?? 0,
|
||
verticalHeight: verticalRef.current?.clientHeight ?? 0,
|
||
};
|
||
setScrollbarViewport((current) =>
|
||
current.horizontalWidth === next.horizontalWidth &&
|
||
current.verticalHeight === next.verticalHeight
|
||
? current
|
||
: next,
|
||
);
|
||
};
|
||
updateScrollbarViewport();
|
||
const resizeObserver = new ResizeObserver(updateScrollbarViewport);
|
||
if (horizontalRef.current) {
|
||
resizeObserver.observe(horizontalRef.current);
|
||
}
|
||
if (verticalRef.current) {
|
||
resizeObserver.observe(verticalRef.current);
|
||
}
|
||
return () => resizeObserver.disconnect();
|
||
}, []);
|
||
|
||
const horizontalScrollRange = Math.min(
|
||
FLOW_VIEWPORT_SCROLLBAR_CONTENT_LIMIT_PX,
|
||
Math.max(0, (worldBounds.maxX - worldBounds.minX) * zoom - canvasWidth),
|
||
);
|
||
const verticalScrollRange = Math.min(
|
||
FLOW_VIEWPORT_SCROLLBAR_CONTENT_LIMIT_PX,
|
||
Math.max(0, (worldBounds.maxY - worldBounds.minY) * zoom - canvasHeight),
|
||
);
|
||
const horizontalContentWidth = Math.max(
|
||
scrollbarViewport.horizontalWidth,
|
||
horizontalScrollRange + scrollbarViewport.horizontalWidth,
|
||
);
|
||
const verticalContentHeight = Math.max(
|
||
scrollbarViewport.verticalHeight,
|
||
verticalScrollRange + scrollbarViewport.verticalHeight,
|
||
);
|
||
const horizontalWorldRange = Math.max(
|
||
0,
|
||
worldBounds.maxX - worldBounds.minX - visibleWidth,
|
||
);
|
||
const verticalWorldRange = Math.max(
|
||
0,
|
||
worldBounds.maxY - worldBounds.minY - visibleHeight,
|
||
);
|
||
const expectedScrollLeft = Math.min(
|
||
horizontalScrollRange,
|
||
horizontalWorldRange > 0
|
||
? ((visibleLeft - worldBounds.minX) / horizontalWorldRange) *
|
||
horizontalScrollRange
|
||
: 0,
|
||
);
|
||
const expectedScrollTop = Math.min(
|
||
verticalScrollRange,
|
||
verticalWorldRange > 0
|
||
? ((visibleTop - worldBounds.minY) / verticalWorldRange) *
|
||
verticalScrollRange
|
||
: 0,
|
||
);
|
||
|
||
useLayoutEffect(() => {
|
||
const horizontal = horizontalRef.current;
|
||
const vertical = verticalRef.current;
|
||
if (horizontal && Math.abs(horizontal.scrollLeft - expectedScrollLeft) > 0.5) {
|
||
horizontal.scrollLeft = expectedScrollLeft;
|
||
}
|
||
if (vertical && Math.abs(vertical.scrollTop - expectedScrollTop) > 0.5) {
|
||
vertical.scrollTop = expectedScrollTop;
|
||
}
|
||
}, [expectedScrollLeft, expectedScrollTop, horizontalContentWidth, verticalContentHeight]);
|
||
|
||
const setHorizontalViewportFromScroll = (nextScrollLeft: number) => {
|
||
const nextVisibleLeft =
|
||
worldBounds.minX +
|
||
(horizontalScrollRange > 0
|
||
? (nextScrollLeft / horizontalScrollRange) * horizontalWorldRange
|
||
: 0);
|
||
void reactFlow.setViewport({
|
||
x: -nextVisibleLeft * zoom,
|
||
y: transform[1],
|
||
zoom,
|
||
});
|
||
};
|
||
const setVerticalViewportFromScroll = (nextScrollTop: number) => {
|
||
const nextVisibleTop =
|
||
worldBounds.minY +
|
||
(verticalScrollRange > 0
|
||
? (nextScrollTop / verticalScrollRange) * verticalWorldRange
|
||
: 0);
|
||
void reactFlow.setViewport({
|
||
x: transform[0],
|
||
y: -nextVisibleTop * zoom,
|
||
zoom,
|
||
});
|
||
};
|
||
|
||
return (
|
||
<div aria-label="建模画布滚动条" className="flow-viewport-scrollbars">
|
||
<div
|
||
aria-label="水平滚动画布"
|
||
aria-orientation="horizontal"
|
||
aria-valuemax={Math.round(horizontalScrollRange)}
|
||
aria-valuemin={0}
|
||
aria-valuenow={Math.round(expectedScrollLeft)}
|
||
className="flow-viewport-scrollbar flow-viewport-scrollbar-horizontal nowheel nopan"
|
||
onScroll={(event) => {
|
||
const nextScrollLeft = event.currentTarget.scrollLeft;
|
||
if (Math.abs(nextScrollLeft - expectedScrollLeft) <= 0.5) {
|
||
return;
|
||
}
|
||
setHorizontalViewportFromScroll(nextScrollLeft);
|
||
}}
|
||
onKeyDown={(event) => {
|
||
const horizontal = event.currentTarget;
|
||
const lineStep = 40;
|
||
const pageStep = Math.max(40, horizontal.clientWidth * 0.8);
|
||
const nextScrollLeft =
|
||
event.key === "ArrowLeft"
|
||
? horizontal.scrollLeft - lineStep
|
||
: event.key === "ArrowRight"
|
||
? horizontal.scrollLeft + lineStep
|
||
: event.key === "PageUp"
|
||
? horizontal.scrollLeft - pageStep
|
||
: event.key === "PageDown"
|
||
? horizontal.scrollLeft + pageStep
|
||
: event.key === "Home"
|
||
? 0
|
||
: event.key === "End"
|
||
? horizontalScrollRange
|
||
: null;
|
||
if (nextScrollLeft === null) {
|
||
return;
|
||
}
|
||
event.preventDefault();
|
||
horizontal.scrollLeft = Math.max(
|
||
0,
|
||
Math.min(horizontalScrollRange, nextScrollLeft),
|
||
);
|
||
}}
|
||
ref={horizontalRef}
|
||
role="scrollbar"
|
||
tabIndex={0}
|
||
>
|
||
<span
|
||
aria-hidden="true"
|
||
className="flow-viewport-scrollbar-spacer"
|
||
style={{ width: horizontalContentWidth }}
|
||
/>
|
||
</div>
|
||
<div
|
||
aria-label="垂直滚动画布"
|
||
aria-orientation="vertical"
|
||
aria-valuemax={Math.round(verticalScrollRange)}
|
||
aria-valuemin={0}
|
||
aria-valuenow={Math.round(expectedScrollTop)}
|
||
className="flow-viewport-scrollbar flow-viewport-scrollbar-vertical nowheel nopan"
|
||
onScroll={(event) => {
|
||
const nextScrollTop = event.currentTarget.scrollTop;
|
||
if (Math.abs(nextScrollTop - expectedScrollTop) <= 0.5) {
|
||
return;
|
||
}
|
||
setVerticalViewportFromScroll(nextScrollTop);
|
||
}}
|
||
onKeyDown={(event) => {
|
||
const vertical = event.currentTarget;
|
||
const lineStep = 40;
|
||
const pageStep = Math.max(40, vertical.clientHeight * 0.8);
|
||
const nextScrollTop =
|
||
event.key === "ArrowUp"
|
||
? vertical.scrollTop - lineStep
|
||
: event.key === "ArrowDown"
|
||
? vertical.scrollTop + lineStep
|
||
: event.key === "PageUp"
|
||
? vertical.scrollTop - pageStep
|
||
: event.key === "PageDown"
|
||
? vertical.scrollTop + pageStep
|
||
: event.key === "Home"
|
||
? 0
|
||
: event.key === "End"
|
||
? verticalScrollRange
|
||
: null;
|
||
if (nextScrollTop === null) {
|
||
return;
|
||
}
|
||
event.preventDefault();
|
||
vertical.scrollTop = Math.max(
|
||
0,
|
||
Math.min(verticalScrollRange, nextScrollTop),
|
||
);
|
||
}}
|
||
ref={verticalRef}
|
||
role="scrollbar"
|
||
tabIndex={0}
|
||
>
|
||
<span
|
||
aria-hidden="true"
|
||
className="flow-viewport-scrollbar-spacer"
|
||
style={{ height: verticalContentHeight }}
|
||
/>
|
||
</div>
|
||
<span aria-hidden="true" className="flow-viewport-scrollbar-corner" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function connectionDraftTargetPosition(
|
||
source: EdgeRoutePoint,
|
||
target: EdgeRoutePoint,
|
||
) {
|
||
const deltaX = target.x - source.x;
|
||
const deltaY = target.y - source.y;
|
||
if (Math.abs(deltaX) >= Math.abs(deltaY)) {
|
||
return deltaX >= 0 ? Position.Left : Position.Right;
|
||
}
|
||
return deltaY >= 0 ? Position.Top : Position.Bottom;
|
||
}
|
||
|
||
function ConnectionDraftOverlay({
|
||
activeConnection,
|
||
draft,
|
||
nodes,
|
||
}: {
|
||
activeConnection: ActivePortConnection | null;
|
||
draft: PortConnectionDraft | null;
|
||
nodes: SimulationNode[];
|
||
}) {
|
||
if (!activeConnection || activeConnection.mode !== "click" || !draft?.pointer) {
|
||
return null;
|
||
}
|
||
const source = nodes.find((node) => node.id === activeConnection.nodeId);
|
||
const sourcePlacement = source
|
||
? nodePortFlowPlacement(source, activeConnection.portName)
|
||
: undefined;
|
||
if (!sourcePlacement) {
|
||
return null;
|
||
}
|
||
const sourcePoint = {
|
||
x: sourcePlacement.flowX,
|
||
y: sourcePlacement.flowY,
|
||
};
|
||
const routePoints = buildOrthogonalRoutePoints(
|
||
sourcePoint,
|
||
draft.pointer,
|
||
sourcePlacement.position,
|
||
connectionDraftTargetPosition(sourcePoint, draft.pointer),
|
||
draft.waypoints,
|
||
);
|
||
const points = [sourcePoint, ...routePoints, draft.pointer];
|
||
return (
|
||
<ViewportPortal>
|
||
<svg
|
||
aria-hidden="true"
|
||
className="manual-connection-draft"
|
||
height="1"
|
||
overflow="visible"
|
||
width="1"
|
||
>
|
||
<path
|
||
className="react-flow__connection-path"
|
||
d={orthogonalPolylinePath(points)}
|
||
data-waypoint-count={draft.waypoints.length}
|
||
/>
|
||
{draft.waypoints.map((waypoint, index) => (
|
||
<circle
|
||
className="manual-connection-waypoint"
|
||
cx={waypoint.x}
|
||
cy={waypoint.y}
|
||
key={`${waypoint.x}:${waypoint.y}:${index}`}
|
||
r={3.5}
|
||
/>
|
||
))}
|
||
</svg>
|
||
</ViewportPortal>
|
||
);
|
||
}
|
||
|
||
function PortGeometryRefresh({
|
||
nodeIds,
|
||
requestKey,
|
||
}: {
|
||
nodeIds: string[];
|
||
requestKey: number;
|
||
}) {
|
||
const zoom = useStore((state) => state.transform[2]);
|
||
const updateNodeInternals = useUpdateNodeInternals();
|
||
const lastRefreshKeyRef = useRef("");
|
||
|
||
useLayoutEffect(() => {
|
||
const refreshKey = `${requestKey}:${zoom}:${nodeIds.join("\u001f")}`;
|
||
if (lastRefreshKeyRef.current === refreshKey || nodeIds.length === 0) {
|
||
return;
|
||
}
|
||
lastRefreshKeyRef.current = refreshKey;
|
||
const frame = window.requestAnimationFrame(() => {
|
||
updateNodeInternals(nodeIds);
|
||
});
|
||
return () => window.cancelAnimationFrame(frame);
|
||
}, [nodeIds, requestKey, updateNodeInternals, zoom]);
|
||
|
||
return null;
|
||
}
|
||
|
||
function isVisibleComponentLibrary(library: ComponentLibraryDefinition) {
|
||
return library.id !== "experimental";
|
||
}
|
||
|
||
function FlowWorkbench() {
|
||
const [componentLibraries, setComponentLibraries] =
|
||
useState<ComponentLibraryDefinition[]>([]);
|
||
const [componentCatalogStatus, setComponentCatalogStatus] =
|
||
useState<ComponentCatalogStatus>("loading");
|
||
const [componentCatalogMessage, setComponentCatalogMessage] =
|
||
useState("正在加载后端组件目录");
|
||
const componentDefinitions = useMemo(
|
||
() => componentLibraries.flatMap((library) => library.components),
|
||
[componentLibraries],
|
||
);
|
||
const defaultParametersByComponentType = useMemo(
|
||
() =>
|
||
new Map(
|
||
componentDefinitions.map((definition) => [
|
||
definition.type,
|
||
defaultParameters(definition),
|
||
] as const),
|
||
),
|
||
[componentDefinitions],
|
||
);
|
||
const visibleComponentLibraries = useMemo(
|
||
() => componentLibraries.filter(isVisibleComponentLibrary),
|
||
[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 [componentParameterDraftErrors, setComponentParameterDraftErrors] =
|
||
useState<Record<string, string>>({});
|
||
const [activePortConnection, setActivePortConnection] =
|
||
useState<ActivePortConnection | null>(null);
|
||
const [portConnectionDraft, setPortConnectionDraft] =
|
||
useState<PortConnectionDraft | null>(null);
|
||
const [contactSnapCandidate, setContactSnapCandidate] =
|
||
useState<ContactSnapCandidate | null>(null);
|
||
const [nodeDragActive, setNodeDragActive] = useState(false);
|
||
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 [pendingPaste, setPendingPaste] = useState<PendingPaste | null>(null);
|
||
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 [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 projectChangeSignatureRef = useRef("");
|
||
const validationSignatureRef = useRef("");
|
||
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 = useMemo(() => {
|
||
let selectedCount = 0;
|
||
for (const node of nodes) {
|
||
if (node.selected && (selectedCount += 1) > 1) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}, [nodes]);
|
||
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 pendingPasteRef = useRef<PendingPaste | null>(null);
|
||
const portConnectionDraftRef = useRef<PortConnectionDraft | null>(null);
|
||
const modelingViewportRef = useRef<Viewport>({ x: 0, y: 0, zoom: 1 });
|
||
const lastFlowPointerRef = useRef<{ x: number; y: number } | null>(null);
|
||
const pendingPasteMiddlePointerRef = useRef<PendingPasteMiddlePointer | null>(
|
||
null,
|
||
);
|
||
const renderedNodeCacheRef = useRef<Map<string, RenderedNodeCacheEntry>>(
|
||
new Map(),
|
||
);
|
||
const nodePortGeometryCacheRef = useRef<Map<string, CachedNodePortGeometry>>(
|
||
new Map(),
|
||
);
|
||
const renderedEdgeCacheRef = useRef<Map<string, RenderedEdgeCacheEntry>>(
|
||
new Map(),
|
||
);
|
||
const renderedEdgesArrayRef = useRef<SimulationEdge[]>([]);
|
||
const renderedEdgesSourceRef = useRef<SimulationEdge[] | null>(null);
|
||
const renderedEdgesSelectedIdRef = useRef<string | null>(null);
|
||
const amesimGasMediumOptionsCacheRef = useRef<{
|
||
definitions: ComponentDefinition[];
|
||
nodeDataById: Map<string, SimulationNode["data"]>;
|
||
options: AmesimGasMediumOption[];
|
||
} | null>(null);
|
||
const consoleEntryIdRef = useRef(0);
|
||
const activeSimulationIdRef = useRef<string | null>(null);
|
||
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;
|
||
pendingPasteRef.current = pendingPaste;
|
||
portConnectionDraftRef.current = portConnectionDraft;
|
||
|
||
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 });
|
||
portConnectionDraftRef.current = null;
|
||
setPortConnectionDraft(null);
|
||
setActivePortConnection(null);
|
||
setNodeDragActive(false);
|
||
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 controlledSelectionNeedsCorrection = nodesRef.current.some(
|
||
(node) => Boolean(node.selected) !== selectedNodeIds.has(node.id),
|
||
);
|
||
const internalSelectionNeedsCorrection = Array.from(
|
||
flowState.nodeLookup.values(),
|
||
).some(
|
||
(node) => Boolean(node.selected) !== selectedNodeIds.has(node.id),
|
||
);
|
||
if (internalSelectionNeedsCorrection) {
|
||
// React Flow 的框选会在 onNodesChange 之前直接修改 nodeLookup;同步
|
||
// 覆盖该值,避免透明节点外框造成的错误虚线框先进入浏览器绘制。
|
||
for (const internalNode of flowState.nodeLookup.values()) {
|
||
internalNode.selected = selectedNodeIds.has(internalNode.id);
|
||
}
|
||
}
|
||
const nodesNeedCorrection =
|
||
controlledSelectionNeedsCorrection || internalSelectionNeedsCorrection;
|
||
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 });
|
||
}
|
||
tightSelectionRectRef.current = null;
|
||
});
|
||
}, [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,
|
||
});
|
||
tightSelectionRectRef.current = null;
|
||
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,
|
||
};
|
||
// React Flow 会先按整个透明节点外框写入选择集,再发布当前框选矩形。
|
||
// 必须在同一事件周期按实际图标包络覆盖,避免误选状态先绘制一帧。
|
||
applyTightSelectionRect(tightSelectionRectRef.current);
|
||
// 自动平移会先更新视口,再刷新 DOM transform;下一帧按最新矩形
|
||
// 复核一次,确保屏幕坐标包络与移动后的视图保持一致。
|
||
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();
|
||
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],
|
||
);
|
||
|
||
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;
|
||
}
|
||
const visibleLibraries = libraries.filter(isVisibleComponentLibrary);
|
||
if (visibleLibraries.length === 0) {
|
||
throw new Error("后端目录中没有可显示的正式组件库");
|
||
}
|
||
setComponentLibraries(libraries);
|
||
setComponentCatalogStatus("ready");
|
||
setComponentCatalogMessage(`已加载 ${visibleLibraries.length} 个组件库`);
|
||
} catch (error) {
|
||
if (controller.signal.aborted) {
|
||
return;
|
||
}
|
||
setComponentLibraries([]);
|
||
setComponentCatalogStatus("error");
|
||
setComponentCatalogMessage(
|
||
error instanceof ComponentCatalogLibraryError
|
||
? `加载失败的组件库:${error.libraryName}。详细原因:${error.message}`
|
||
: `正式组件库目录加载失败(当前包括 AMESim 组件库);目录接口不可用,无法确认是否还有其他组件库受影响。详细原因:${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_COLLAPSED_SECTIONS_KEY,
|
||
JSON.stringify([...collapsedPaletteSections]),
|
||
);
|
||
} catch {
|
||
// Section toggles remain available when browser storage is unavailable.
|
||
}
|
||
}, [collapsedPaletteSections]);
|
||
|
||
useEffect(() => {
|
||
saveParameterColumnLayout(parameterColumnLayout);
|
||
}, [parameterColumnLayout]);
|
||
|
||
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 (pendingPasteRef.current) {
|
||
appendConsoleEntry("info", "请先放置或按 Esc 取消待放置组件");
|
||
return;
|
||
}
|
||
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);
|
||
}
|
||
},
|
||
[appendConsoleEntry, cancelPortConnection],
|
||
);
|
||
|
||
const loadSimulationResultsSnapshot = useCallback(
|
||
(snapshot: SimulationResultsSnapshot) => {
|
||
setResultSnapshot(snapshot);
|
||
storeResultSnapshot(snapshot);
|
||
appendConsoleEntry(
|
||
"success",
|
||
`已加载结果文件:${snapshot.project.name}`,
|
||
);
|
||
changeWorkspaceView("results");
|
||
},
|
||
[appendConsoleEntry, changeWorkspaceView],
|
||
);
|
||
|
||
const nodeTypes = useMemo<NodeTypes>(
|
||
() => ({ simulationComponent: SimulationComponentNode }),
|
||
[],
|
||
);
|
||
|
||
const selectedNode = nodes.find((node) => node.id === selectedNodeId) ?? null;
|
||
const selectedNodesForTools = nodes.filter(
|
||
(node) => node.selected || node.id === selectedNodeId,
|
||
);
|
||
const allSelectedNodesMirrored =
|
||
selectedNodesForTools.length > 0 &&
|
||
selectedNodesForTools.every((node) => Boolean(node.data.mirrored));
|
||
const selectedDefinition = selectedNode
|
||
? componentDefinitions.find((definition) => definition.type === selectedNode.data.componentType)
|
||
: null;
|
||
const amesimGasMediumOptionsComputation = useMemo(() => {
|
||
const cached = amesimGasMediumOptionsCacheRef.current;
|
||
const canReuse =
|
||
cached?.definitions === componentDefinitions &&
|
||
cached.nodeDataById.size === nodes.length &&
|
||
nodes.every((node) => cached.nodeDataById.get(node.id) === node.data);
|
||
if (canReuse) {
|
||
return cached;
|
||
}
|
||
return {
|
||
definitions: componentDefinitions,
|
||
nodeDataById: new Map(
|
||
nodes.map((node) => [node.id, node.data] as const),
|
||
),
|
||
options: buildAmesimGasMediumOptions(nodes, componentDefinitions),
|
||
};
|
||
}, [componentDefinitions, nodes]);
|
||
useLayoutEffect(() => {
|
||
amesimGasMediumOptionsCacheRef.current = amesimGasMediumOptionsComputation;
|
||
}, [amesimGasMediumOptionsComputation]);
|
||
const amesimGasMediumOptions = amesimGasMediumOptionsComputation.options;
|
||
|
||
const buildCurrentProject = useCallback(
|
||
() => buildProjectPayload(projectName, nodes, edges, simulationConfig),
|
||
[edges, nodes, projectName, simulationConfig],
|
||
);
|
||
|
||
const projectChangeSignature = useMemo(
|
||
() =>
|
||
nodeDragActive && projectChangeSignatureRef.current
|
||
? projectChangeSignatureRef.current
|
||
: projectContentSignature(
|
||
projectName,
|
||
nodes,
|
||
edges,
|
||
simulationConfig,
|
||
),
|
||
[edges, nodeDragActive, nodes, projectName, simulationConfig],
|
||
);
|
||
const validationSignature = useMemo(
|
||
() =>
|
||
nodeDragActive && validationSignatureRef.current
|
||
? validationSignatureRef.current
|
||
: modelValidationSignature(nodes, edges, simulationConfig),
|
||
[edges, nodeDragActive, nodes, simulationConfig],
|
||
);
|
||
useLayoutEffect(() => {
|
||
if (!nodeDragActive) {
|
||
projectChangeSignatureRef.current = projectChangeSignature;
|
||
validationSignatureRef.current = validationSignature;
|
||
}
|
||
}, [nodeDragActive, projectChangeSignature, validationSignature]);
|
||
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(
|
||
match.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 renderedNodePresentationContext = useMemo(
|
||
() => ({
|
||
activeConnectionPort,
|
||
activePortConnection,
|
||
connectedPortKeys,
|
||
contactSnapOriginPortKeys,
|
||
contactSnapTargetPortKeys,
|
||
multipleNodesSelected,
|
||
validationSeverityByNode,
|
||
}),
|
||
[
|
||
activeConnectionPort,
|
||
activePortConnection,
|
||
connectedPortKeys,
|
||
contactSnapOriginPortKeys,
|
||
contactSnapTargetPortKeys,
|
||
multipleNodesSelected,
|
||
validationSeverityByNode,
|
||
],
|
||
);
|
||
const renderedNodeComputation = useMemo(
|
||
() => {
|
||
const previousCache = renderedNodeCacheRef.current;
|
||
const nextCache = new Map<string, RenderedNodeCacheEntry>();
|
||
const nextRenderedNodes = nodes.map((node) => {
|
||
const cached = previousCache.get(node.id);
|
||
if (
|
||
cached?.presentationContext === renderedNodePresentationContext &&
|
||
cached.sourceNode.data === node.data &&
|
||
cached.sourceNode.className === node.className
|
||
) {
|
||
if (cached.sourceNode === node) {
|
||
nextCache.set(node.id, cached);
|
||
return cached.renderedNode;
|
||
}
|
||
const renderedNode: SimulationNode = {
|
||
...node,
|
||
className: cached.renderedNode.className,
|
||
data: cached.renderedNode.data,
|
||
};
|
||
nextCache.set(node.id, {
|
||
...cached,
|
||
renderedNode,
|
||
sourceNode: node,
|
||
});
|
||
return renderedNode;
|
||
}
|
||
const hasDedicatedSymbol = hasComponentSymbol(
|
||
node.data.symbol ?? node.data.componentType,
|
||
);
|
||
const portStateSignature: string[] = [];
|
||
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";
|
||
}
|
||
portStateSignature.push(`${port.name}:${state}`);
|
||
return [port.name, state];
|
||
}),
|
||
);
|
||
const className = [
|
||
node.className,
|
||
hasDedicatedSymbol ? "component-icon-node" : "",
|
||
validationSeverityByNode.has(node.id)
|
||
? `validation-${validationSeverityByNode.get(node.id)}`
|
||
: "",
|
||
]
|
||
.filter(Boolean)
|
||
.join(" ");
|
||
const presentationSignature = [
|
||
className,
|
||
multipleNodesSelected ? "hide-connected-labels" : "show-all-labels",
|
||
...portStateSignature,
|
||
].join("|");
|
||
const canReusePresentation =
|
||
cached?.sourceNode.data === node.data &&
|
||
cached.presentationSignature === presentationSignature;
|
||
if (
|
||
cached?.sourceNode === node &&
|
||
canReusePresentation
|
||
) {
|
||
nextCache.set(
|
||
node.id,
|
||
cached.presentationContext === renderedNodePresentationContext
|
||
? cached
|
||
: {
|
||
...cached,
|
||
presentationContext: renderedNodePresentationContext,
|
||
},
|
||
);
|
||
return cached.renderedNode;
|
||
}
|
||
const renderedData = canReusePresentation
|
||
? cached.renderedNode.data
|
||
: {
|
||
...node.data,
|
||
hideConnectedPortIndexLabels: multipleNodesSelected,
|
||
portConnectionStates,
|
||
};
|
||
const renderedNode: SimulationNode = {
|
||
...node,
|
||
className: className || undefined,
|
||
data: renderedData,
|
||
};
|
||
nextCache.set(node.id, {
|
||
presentationContext: renderedNodePresentationContext,
|
||
presentationSignature,
|
||
renderedNode,
|
||
sourceNode: node,
|
||
});
|
||
return renderedNode;
|
||
});
|
||
return { cache: nextCache, nodes: nextRenderedNodes };
|
||
},
|
||
[
|
||
activeConnectionPort,
|
||
activePortConnection,
|
||
contactSnapOriginPortKeys,
|
||
contactSnapTargetPortKeys,
|
||
connectedPortKeys,
|
||
multipleNodesSelected,
|
||
nodes,
|
||
renderedNodePresentationContext,
|
||
validationSeverityByNode,
|
||
],
|
||
);
|
||
useLayoutEffect(() => {
|
||
renderedNodeCacheRef.current = renderedNodeComputation.cache;
|
||
}, [renderedNodeComputation.cache]);
|
||
const renderedNodes = renderedNodeComputation.nodes;
|
||
|
||
function handleEdgeRouteEditStart() {
|
||
recordHistory();
|
||
}
|
||
|
||
function handleEdgeRoutePointsChange(
|
||
edgeId: string,
|
||
routePoints: EdgeRoutePoint[],
|
||
) {
|
||
setEdges((currentEdges) => {
|
||
let changed = false;
|
||
const nextEdges = currentEdges.map((edge) => {
|
||
if (edge.id !== edgeId) {
|
||
return edge;
|
||
}
|
||
changed = true;
|
||
return {
|
||
...edge,
|
||
data: {
|
||
...edge.data,
|
||
isContactEdge: false,
|
||
routePoints: routePoints.map((point) => ({ ...point })),
|
||
},
|
||
};
|
||
});
|
||
const resolvedEdges = changed ? nextEdges : currentEdges;
|
||
edgesRef.current = resolvedEdges;
|
||
return resolvedEdges;
|
||
});
|
||
}
|
||
|
||
const edgeIndexesByNodeId = useMemo(() => {
|
||
const indexes = new Map<string, number[]>();
|
||
edges.forEach((edge, edgeIndex) => {
|
||
const sourceIndexes = indexes.get(edge.source) ?? [];
|
||
sourceIndexes.push(edgeIndex);
|
||
indexes.set(edge.source, sourceIndexes);
|
||
if (edge.target !== edge.source) {
|
||
const targetIndexes = indexes.get(edge.target) ?? [];
|
||
targetIndexes.push(edgeIndex);
|
||
indexes.set(edge.target, targetIndexes);
|
||
}
|
||
});
|
||
return indexes;
|
||
}, [edges]);
|
||
const renderedEdgeComputation = useMemo(
|
||
() => {
|
||
const nodeById = new Map(nodes.map((node) => [node.id, node] as const));
|
||
const previousGeometryCache = nodePortGeometryCacheRef.current;
|
||
const geometryUpdates = new Map<string, CachedNodePortGeometry>();
|
||
const geometryDeletes: string[] = [];
|
||
const geometryMatchesNode = (
|
||
cached: CachedNodePortGeometry | undefined,
|
||
node: SimulationNode,
|
||
) =>
|
||
Boolean(
|
||
cached &&
|
||
cached.sourceData === node.data &&
|
||
cached.positionX === node.position.x &&
|
||
cached.positionY === node.position.y &&
|
||
cached.rotation === normalizeNodeRotation(node.data.rotation) &&
|
||
cached.mirrored === Boolean(node.data.mirrored),
|
||
);
|
||
const geometryForNode = (node: SimulationNode): CachedNodePortGeometry => {
|
||
const updated = geometryUpdates.get(node.id);
|
||
if (updated) {
|
||
return updated;
|
||
}
|
||
const rotation = normalizeNodeRotation(node.data.rotation);
|
||
const mirrored = Boolean(node.data.mirrored);
|
||
const cached = previousGeometryCache.get(node.id);
|
||
if (cached && geometryMatchesNode(cached, node)) {
|
||
geometryUpdates.set(node.id, cached);
|
||
return cached;
|
||
}
|
||
const geometry: CachedNodePortGeometry = {
|
||
mirrored,
|
||
placementByPort: new Map(
|
||
nodePortFlowPlacements(node).map((placement) => [
|
||
placement.port.name,
|
||
{ flowX: placement.flowX, flowY: placement.flowY },
|
||
] as const),
|
||
),
|
||
positionX: node.position.x,
|
||
positionY: node.position.y,
|
||
rotation,
|
||
sourceData: node.data,
|
||
};
|
||
geometryUpdates.set(node.id, geometry);
|
||
return geometry;
|
||
};
|
||
const previousEdgeCache = renderedEdgeCacheRef.current;
|
||
const previousRenderedEdges = renderedEdgesArrayRef.current;
|
||
const requiresFullRecompute =
|
||
renderedEdgesSourceRef.current !== edges ||
|
||
previousRenderedEdges.length !== edges.length ||
|
||
renderedEdgesSelectedIdRef.current !== selectedEdgeId;
|
||
const affectedEdgeIndexes = new Set<number>();
|
||
if (requiresFullRecompute) {
|
||
edges.forEach((_edge, edgeIndex) => affectedEdgeIndexes.add(edgeIndex));
|
||
} else {
|
||
nodes.forEach((node) => {
|
||
if (geometryMatchesNode(previousGeometryCache.get(node.id), node)) {
|
||
return;
|
||
}
|
||
for (const edgeIndex of edgeIndexesByNodeId.get(node.id) ?? []) {
|
||
affectedEdgeIndexes.add(edgeIndex);
|
||
}
|
||
});
|
||
for (const nodeId of previousGeometryCache.keys()) {
|
||
if (nodeById.has(nodeId)) {
|
||
continue;
|
||
}
|
||
geometryDeletes.push(nodeId);
|
||
for (const edgeIndex of edgeIndexesByNodeId.get(nodeId) ?? []) {
|
||
affectedEdgeIndexes.add(edgeIndex);
|
||
}
|
||
}
|
||
}
|
||
|
||
const edgeCacheUpdates = new Map<string, RenderedEdgeCacheEntry>();
|
||
let nextRenderedEdges = previousRenderedEdges;
|
||
let renderedEdgesChanged = false;
|
||
const updateRenderedEdge = (edgeIndex: number) => {
|
||
const edge = edges[edgeIndex];
|
||
if (!edge) {
|
||
return;
|
||
}
|
||
const selected = edge.selected || edge.id === selectedEdgeId;
|
||
const sourceNode = nodeById.get(edge.source);
|
||
const targetNode = nodeById.get(edge.target);
|
||
const source =
|
||
sourceNode && edge.sourceHandle
|
||
? geometryForNode(sourceNode).placementByPort.get(edge.sourceHandle)
|
||
: undefined;
|
||
const target =
|
||
targetNode && edge.targetHandle
|
||
? geometryForNode(targetNode).placementByPort.get(edge.targetHandle)
|
||
: undefined;
|
||
const contact = Boolean(
|
||
source &&
|
||
target &&
|
||
Math.hypot(target.flowX - source.flowX, target.flowY - source.flowY) <=
|
||
CONTACT_GEOMETRY_TOLERANCE,
|
||
);
|
||
const className = [
|
||
edge.className,
|
||
contact ? "editor-edge-contact" : "",
|
||
selected ? "editor-edge-selected" : "",
|
||
]
|
||
.filter(Boolean)
|
||
.join(" ");
|
||
const presentationSignature = `${className}|${contact ? "contact" : "line"}|${selected ? "selected" : "idle"}`;
|
||
const cached = previousEdgeCache.get(edge.id);
|
||
if (
|
||
cached?.sourceEdge === edge &&
|
||
cached.presentationSignature === presentationSignature
|
||
) {
|
||
if (requiresFullRecompute) {
|
||
edgeCacheUpdates.set(edge.id, cached);
|
||
}
|
||
if (
|
||
requiresFullRecompute ||
|
||
previousRenderedEdges[edgeIndex] !== cached.renderedEdge
|
||
) {
|
||
if (!renderedEdgesChanged) {
|
||
nextRenderedEdges = previousRenderedEdges.slice();
|
||
renderedEdgesChanged = true;
|
||
}
|
||
nextRenderedEdges[edgeIndex] = cached.renderedEdge;
|
||
}
|
||
return;
|
||
}
|
||
const renderedEdge: SimulationEdge = {
|
||
...edge,
|
||
className: className || undefined,
|
||
data: {
|
||
...edge.data,
|
||
editable: !contact,
|
||
isContactEdge: contact,
|
||
onRouteEditStart: handleEdgeRouteEditStart,
|
||
onRoutePointsChange: handleEdgeRoutePointsChange,
|
||
},
|
||
focusable: contact ? false : edge.focusable,
|
||
selectable: contact ? false : edge.selectable,
|
||
type: CONTACT_AWARE_EDGE_TYPE,
|
||
};
|
||
edgeCacheUpdates.set(edge.id, {
|
||
presentationSignature,
|
||
renderedEdge,
|
||
sourceEdge: edge,
|
||
});
|
||
if (!renderedEdgesChanged) {
|
||
nextRenderedEdges = previousRenderedEdges.slice();
|
||
renderedEdgesChanged = true;
|
||
}
|
||
nextRenderedEdges[edgeIndex] = renderedEdge;
|
||
};
|
||
|
||
if (requiresFullRecompute) {
|
||
nextRenderedEdges = new Array<SimulationEdge>(edges.length);
|
||
renderedEdgesChanged = true;
|
||
}
|
||
[...affectedEdgeIndexes]
|
||
.sort((first, second) => first - second)
|
||
.forEach(updateRenderedEdge);
|
||
return {
|
||
edgeCacheUpdates,
|
||
edges: nextRenderedEdges,
|
||
fullRecompute: requiresFullRecompute,
|
||
geometryDeletes,
|
||
geometryUpdates,
|
||
selectedEdgeId,
|
||
sourceEdges: edges,
|
||
};
|
||
},
|
||
[edgeIndexesByNodeId, edges, nodes, selectedEdgeId],
|
||
);
|
||
useLayoutEffect(() => {
|
||
if (renderedEdgeComputation.fullRecompute) {
|
||
nodePortGeometryCacheRef.current = renderedEdgeComputation.geometryUpdates;
|
||
renderedEdgeCacheRef.current = renderedEdgeComputation.edgeCacheUpdates;
|
||
} else {
|
||
renderedEdgeComputation.geometryDeletes.forEach((nodeId) => {
|
||
nodePortGeometryCacheRef.current.delete(nodeId);
|
||
});
|
||
renderedEdgeComputation.geometryUpdates.forEach((geometry, nodeId) => {
|
||
nodePortGeometryCacheRef.current.set(nodeId, geometry);
|
||
});
|
||
renderedEdgeComputation.edgeCacheUpdates.forEach((entry, edgeId) => {
|
||
renderedEdgeCacheRef.current.set(edgeId, entry);
|
||
});
|
||
}
|
||
renderedEdgesArrayRef.current = renderedEdgeComputation.edges;
|
||
renderedEdgesSourceRef.current = renderedEdgeComputation.sourceEdges;
|
||
renderedEdgesSelectedIdRef.current = renderedEdgeComputation.selectedEdgeId;
|
||
}, [renderedEdgeComputation]);
|
||
const renderedEdges = renderedEdgeComputation.edges;
|
||
const crossingJumpsByEdgeId = useMemo(() => {
|
||
const nodeById = new Map(nodes.map((node) => [node.id, node] as const));
|
||
return detectEdgeCrossingJumps(
|
||
renderedEdges.flatMap((edge) => {
|
||
if (edge.data?.isContactEdge === true) {
|
||
return [];
|
||
}
|
||
const sourceNode = nodeById.get(edge.source);
|
||
const targetNode = nodeById.get(edge.target);
|
||
const source = sourceNode
|
||
? nodePortFlowPlacement(sourceNode, edge.sourceHandle ?? null)
|
||
: undefined;
|
||
const target = targetNode
|
||
? nodePortFlowPlacement(targetNode, edge.targetHandle ?? null)
|
||
: undefined;
|
||
if (!source || !target) {
|
||
return [];
|
||
}
|
||
return [
|
||
{
|
||
id: edge.id,
|
||
nodeIds: [edge.source, edge.target] as const,
|
||
points: orthogonalEdgePoints({
|
||
routePoints: routePointsForEdge(edge),
|
||
sourcePosition: source.position,
|
||
sourceX: source.flowX,
|
||
sourceY: source.flowY,
|
||
targetPosition: target.position,
|
||
targetX: target.flowX,
|
||
targetY: target.flowY,
|
||
}),
|
||
},
|
||
];
|
||
}),
|
||
);
|
||
}, [nodes, renderedEdges]);
|
||
const routedRenderedEdges = useMemo(
|
||
() =>
|
||
renderedEdges.map((edge) => {
|
||
const crossingJumps = crossingJumpsByEdgeId.get(edge.id);
|
||
return crossingJumps?.length
|
||
? {
|
||
...edge,
|
||
data: {
|
||
...edge.data,
|
||
crossingJumps,
|
||
},
|
||
}
|
||
: edge;
|
||
}),
|
||
[crossingJumpsByEdgeId, renderedEdges],
|
||
);
|
||
const flowNodes = useMemo(() => {
|
||
if (!pendingPaste) {
|
||
return renderedNodes;
|
||
}
|
||
const pendingConnectedPortKeys = new Set<string>();
|
||
pendingPaste.edges.forEach((edge) => {
|
||
if (edge.sourceHandle) {
|
||
pendingConnectedPortKeys.add(
|
||
portConnectionKey(edge.source, edge.sourceHandle),
|
||
);
|
||
}
|
||
if (edge.targetHandle) {
|
||
pendingConnectedPortKeys.add(
|
||
portConnectionKey(edge.target, edge.targetHandle),
|
||
);
|
||
}
|
||
});
|
||
const previewNodes = pendingPaste.nodes.map((node) => ({
|
||
...node,
|
||
className: [
|
||
node.className,
|
||
hasComponentSymbol(node.data.symbol ?? node.data.componentType)
|
||
? "component-icon-node"
|
||
: "",
|
||
"pending-paste-node",
|
||
]
|
||
.filter(Boolean)
|
||
.join(" "),
|
||
connectable: false,
|
||
deletable: false,
|
||
draggable: false,
|
||
focusable: false,
|
||
selectable: false,
|
||
selected: false,
|
||
data: {
|
||
...node.data,
|
||
hideConnectedPortIndexLabels: true,
|
||
portConnectionStates: Object.fromEntries(
|
||
node.data.ports.map((port) => {
|
||
const key = portConnectionKey(node.id, port.name);
|
||
return [
|
||
port.name,
|
||
pendingConnectedPortKeys.has(key)
|
||
? "connected"
|
||
: contactSnapOriginPortKeys.has(key)
|
||
? "origin"
|
||
: "idle",
|
||
];
|
||
}),
|
||
) as Record<string, PortConnectionState>,
|
||
},
|
||
}));
|
||
return [
|
||
...renderedNodes.map((node) => ({ ...node, selected: false })),
|
||
...previewNodes,
|
||
];
|
||
}, [contactSnapOriginPortKeys, pendingPaste, renderedNodes]);
|
||
const flowEdges = useMemo(() => {
|
||
if (!pendingPaste) {
|
||
return routedRenderedEdges;
|
||
}
|
||
const previewEdges = pendingPaste.edges.map((edge) => {
|
||
const contact = isContactEdgeGeometry(pendingPaste.nodes, edge);
|
||
return {
|
||
...edge,
|
||
className: [edge.className, "pending-paste-edge"]
|
||
.filter(Boolean)
|
||
.join(" "),
|
||
deletable: false,
|
||
focusable: false,
|
||
selectable: false,
|
||
selected: false,
|
||
data: {
|
||
...edge.data,
|
||
isContactEdge: contact,
|
||
},
|
||
type: CONTACT_AWARE_EDGE_TYPE,
|
||
};
|
||
});
|
||
return [
|
||
...routedRenderedEdges.map((edge) => ({
|
||
...edge,
|
||
className: edge.className
|
||
?.split(/\s+/)
|
||
.filter((className) => className !== "editor-edge-selected")
|
||
.join(" "),
|
||
selected: false,
|
||
})),
|
||
...previewEdges,
|
||
];
|
||
}, [pendingPaste, routedRenderedEdges]);
|
||
|
||
const updateHistoryAvailability = useCallback(() => {
|
||
setHistoryAvailability({
|
||
canUndo: historyRef.current.length > 0,
|
||
canRedo: futureRef.current.length > 0,
|
||
});
|
||
}, []);
|
||
|
||
const captureSnapshot = useCallback(
|
||
(): EditorSnapshot => {
|
||
const signature =
|
||
projectChangeSignatureRef.current ||
|
||
projectContentSignature(
|
||
projectNameRef.current,
|
||
nodesRef.current,
|
||
edgesRef.current,
|
||
simulationConfigRef.current,
|
||
);
|
||
return {
|
||
// 编辑器节点和边按不可变方式更新;历史记录保留结构共享,恢复时再深拷贝。
|
||
// 这样拖动开始时无需同步克隆整张大图。
|
||
nodes: nodesRef.current.slice(),
|
||
edges: edgesRef.current.slice(),
|
||
projectName: projectNameRef.current,
|
||
signature,
|
||
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) {
|
||
setNodes((currentNodes) => applyNodeChanges(changes, currentNodes));
|
||
return;
|
||
}
|
||
|
||
cancelPortConnection();
|
||
recordHistory();
|
||
const preview = repairAmesimGasReferences(
|
||
applyNodeChanges(changes, nodesRef.current),
|
||
componentDefinitions,
|
||
);
|
||
setNodes((currentNodes) =>
|
||
repairAmesimGasReferences(
|
||
applyNodeChanges(changes, currentNodes),
|
||
componentDefinitions,
|
||
).nodes,
|
||
);
|
||
if (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) => {
|
||
setNodeDragActive(true);
|
||
recordHistory();
|
||
clearContactSnapCandidate();
|
||
const movingNodes =
|
||
draggedNodes.length > 0 ? draggedNodes : [draggedNode];
|
||
const draggedNodeIds = new Set(movingNodes.map((node) => node.id));
|
||
const draggedNodeById = new Map(
|
||
movingNodes.map((node) => [node.id, node] as const),
|
||
);
|
||
const effectiveNodes = nodesRef.current.map((node) => {
|
||
const moved = draggedNodeById.get(node.id);
|
||
return moved
|
||
? { ...node, position: { ...moved.position } }
|
||
: node;
|
||
});
|
||
const contactEdgeIds = new Set(
|
||
edgesRef.current.flatMap((edge) => {
|
||
const sourceMoves = draggedNodeIds.has(edge.source);
|
||
const targetMoves = draggedNodeIds.has(edge.target);
|
||
// Only a contact crossing the block boundary is disconnected.
|
||
// Internal contact topology moves with the block and remains hidden.
|
||
return sourceMoves !== targetMoves &&
|
||
isContactEdgeGeometry(effectiveNodes, edge)
|
||
? [edge.id]
|
||
: [];
|
||
}),
|
||
);
|
||
contactDragSessionRef.current = {
|
||
contactEdgeIds,
|
||
disconnected: false,
|
||
draggedNodeIds,
|
||
effectiveEdges: edgesRef.current,
|
||
lastCandidate: null,
|
||
lastEvaluatedPosition: null,
|
||
lastSnapRadius: 0,
|
||
multipleNodes: draggedNodeIds.size > 1,
|
||
nodeId: draggedNode.id,
|
||
snapSearchContext: buildContactSnapSearchContext(
|
||
draggedNodeIds,
|
||
effectiveNodes,
|
||
edgesRef.current,
|
||
),
|
||
startPosition: { ...draggedNode.position },
|
||
};
|
||
},
|
||
[clearContactSnapCandidate, recordHistory],
|
||
);
|
||
|
||
const handleNodeDrag = useCallback<OnNodeDrag<SimulationNode>>(
|
||
(_event, draggedNode, draggedNodes) => {
|
||
const session = contactDragSessionRef.current;
|
||
if (!session || session.nodeId !== draggedNode.id) {
|
||
return;
|
||
}
|
||
const movingNodes =
|
||
draggedNodes.length > 0 ? draggedNodes : [draggedNode];
|
||
const draggedNodeById = new Map(
|
||
movingNodes.map((node) => [node.id, node] as const),
|
||
);
|
||
const effectiveNodes = nodesRef.current.map((node) => {
|
||
const moved = draggedNodeById.get(node.id);
|
||
return moved
|
||
? { ...node, position: { ...moved.position } }
|
||
: node;
|
||
});
|
||
const effectiveDraggedNodes = effectiveNodes.filter((node) =>
|
||
session.draggedNodeIds.has(node.id),
|
||
);
|
||
const effectivePrimary =
|
||
effectiveDraggedNodes.find((node) => node.id === session.nodeId) ??
|
||
draggedNode;
|
||
const screenDistance =
|
||
Math.hypot(
|
||
effectivePrimary.position.x - session.startPosition.x,
|
||
effectivePrimary.position.y - session.startPosition.y,
|
||
) * reactFlow.getZoom();
|
||
const disconnectDistance = session.multipleNodes
|
||
? 0.5
|
||
: CONTACT_DISCONNECT_DISTANCE_PX;
|
||
if (
|
||
!session.disconnected &&
|
||
session.contactEdgeIds.size > 0 &&
|
||
screenDistance >= disconnectDistance
|
||
) {
|
||
const remainingEdges = session.effectiveEdges.filter(
|
||
(edge) => !session.contactEdgeIds.has(edge.id),
|
||
);
|
||
session.disconnected = true;
|
||
session.effectiveEdges = remainingEdges;
|
||
session.snapSearchContext = buildContactSnapSearchContext(
|
||
session.draggedNodeIds,
|
||
effectiveNodes,
|
||
remainingEdges,
|
||
);
|
||
edgesRef.current = remainingEdges;
|
||
setEdges(remainingEdges);
|
||
setSelectedEdgeId((current) =>
|
||
current && session.contactEdgeIds.has(current) ? null : current,
|
||
);
|
||
}
|
||
|
||
const snapRadius = contactSnapRadiusForZoom(reactFlow.getZoom());
|
||
const candidate = findContactSnapCandidate(
|
||
effectiveDraggedNodes,
|
||
effectiveNodes,
|
||
session.effectiveEdges,
|
||
snapRadius,
|
||
session.snapSearchContext,
|
||
);
|
||
session.lastCandidate = candidate;
|
||
session.lastEvaluatedPosition = { ...effectivePrimary.position };
|
||
session.lastSnapRadius = snapRadius;
|
||
setContactSnapCandidate((current) =>
|
||
sameContactSnapCandidate(current, candidate) ? current : candidate,
|
||
);
|
||
const previewBlockDelta = {
|
||
x: effectivePrimary.position.x - session.startPosition.x,
|
||
y: effectivePrimary.position.y - session.startPosition.y,
|
||
};
|
||
const previewEdges = translateInternalEdgeRoutes(
|
||
session.effectiveEdges,
|
||
session.draggedNodeIds,
|
||
previewBlockDelta.x,
|
||
previewBlockDelta.y,
|
||
);
|
||
if (previewEdges !== session.effectiveEdges) {
|
||
edgesRef.current = previewEdges;
|
||
setEdges(previewEdges);
|
||
}
|
||
},
|
||
[reactFlow, setEdges],
|
||
);
|
||
|
||
const handleNodeDragStop = useCallback<OnNodeDrag<SimulationNode>>(
|
||
(_event, draggedNode, draggedNodes) => {
|
||
setNodeDragActive(false);
|
||
const session = contactDragSessionRef.current;
|
||
if (!session || session.nodeId !== draggedNode.id) {
|
||
clearContactSnapCandidate();
|
||
return;
|
||
}
|
||
const movingNodes =
|
||
draggedNodes.length > 0 ? draggedNodes : [draggedNode];
|
||
const draggedNodeById = new Map(
|
||
movingNodes.map((node) => [node.id, node] as const),
|
||
);
|
||
const effectiveNodes = nodesRef.current.map((node) => {
|
||
const moved = draggedNodeById.get(node.id);
|
||
return moved
|
||
? { ...node, position: { ...moved.position } }
|
||
: node;
|
||
});
|
||
const effectiveDraggedNodes = effectiveNodes.filter((node) =>
|
||
session.draggedNodeIds.has(node.id),
|
||
);
|
||
const effectivePrimary =
|
||
effectiveDraggedNodes.find((node) => node.id === session.nodeId) ??
|
||
draggedNode;
|
||
const snapRadius = contactSnapRadiusForZoom(reactFlow.getZoom());
|
||
const canReuseLastCandidate =
|
||
session.lastEvaluatedPosition?.x === effectivePrimary.position.x &&
|
||
session.lastEvaluatedPosition.y === effectivePrimary.position.y &&
|
||
session.lastSnapRadius === snapRadius;
|
||
const candidate = canReuseLastCandidate
|
||
? session.lastCandidate
|
||
: findContactSnapCandidate(
|
||
effectiveDraggedNodes,
|
||
effectiveNodes,
|
||
session.effectiveEdges,
|
||
snapRadius,
|
||
session.snapSearchContext,
|
||
);
|
||
|
||
contactDragSessionRef.current = null;
|
||
clearContactSnapCandidate();
|
||
const finalNodes = candidate
|
||
? effectiveNodes.map((node) =>
|
||
session.draggedNodeIds.has(node.id)
|
||
? {
|
||
...node,
|
||
position: {
|
||
x: node.position.x + candidate.positionDelta.x,
|
||
y: node.position.y + candidate.positionDelta.y,
|
||
},
|
||
}
|
||
: node,
|
||
)
|
||
: effectiveNodes;
|
||
const finalPrimary =
|
||
finalNodes.find((node) => node.id === session.nodeId) ??
|
||
effectivePrimary;
|
||
const blockDelta = {
|
||
x: finalPrimary.position.x - session.startPosition.x,
|
||
y: finalPrimary.position.y - session.startPosition.y,
|
||
};
|
||
let connectedEdges = session.effectiveEdges.filter(
|
||
(edge) =>
|
||
!session.contactEdgeIds.has(edge.id) ||
|
||
isContactEdgeGeometry(finalNodes, edge),
|
||
);
|
||
connectedEdges = translateInternalEdgeRoutes(
|
||
connectedEdges,
|
||
session.draggedNodeIds,
|
||
blockDelta.x,
|
||
blockDelta.y,
|
||
);
|
||
|
||
const timestamp = Date.now();
|
||
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(finalNodes, connectedEdges, contactEdge) &&
|
||
isContactEdgeGeometry(finalNodes, contactEdge)
|
||
) {
|
||
connectedEdges = addEdge(contactEdge, connectedEdges);
|
||
}
|
||
});
|
||
|
||
nodesRef.current = finalNodes;
|
||
edgesRef.current = connectedEdges;
|
||
setNodes(finalNodes);
|
||
setEdges(connectedEdges);
|
||
setSelectedEdgeId(null);
|
||
},
|
||
[clearContactSnapCandidate, reactFlow, setEdges, setNodes],
|
||
);
|
||
|
||
const beginPortConnection = useCallback(
|
||
(
|
||
nodeId: string | null,
|
||
portName: string | null,
|
||
mode: ActivePortConnection["mode"],
|
||
) => {
|
||
const port = nodeId && portName
|
||
? findNodePort(nodesRef.current, nodeId, portName)
|
||
: undefined;
|
||
if (
|
||
!nodeId ||
|
||
!portName ||
|
||
!port ||
|
||
!portAcceptsAnotherConnection(edgesRef.current, nodeId, port)
|
||
) {
|
||
cancelPortConnection();
|
||
return;
|
||
}
|
||
suppressCancelledConnectionRef.current = false;
|
||
if (mode === "click") {
|
||
const placement = nodePortFlowPlacement(
|
||
nodesRef.current.find((node) => node.id === nodeId) as SimulationNode,
|
||
portName,
|
||
);
|
||
const draft: PortConnectionDraft = {
|
||
pointer: placement
|
||
? { x: placement.flowX, y: placement.flowY }
|
||
: null,
|
||
waypoints: [],
|
||
};
|
||
portConnectionDraftRef.current = draft;
|
||
setPortConnectionDraft(draft);
|
||
}
|
||
setActivePortConnection((current) => {
|
||
if (mode === "drag" && current?.mode === "click") {
|
||
return current;
|
||
}
|
||
return { nodeId, portName, mode };
|
||
});
|
||
},
|
||
[cancelPortConnection],
|
||
);
|
||
|
||
const endDragPortConnection = useCallback(() => {
|
||
suppressCancelledConnectionRef.current = false;
|
||
setActivePortConnection((current) =>
|
||
current?.mode === "click" ? current : null,
|
||
);
|
||
}, []);
|
||
|
||
const endClickPortConnection = useCallback(() => {
|
||
suppressCancelledConnectionRef.current = false;
|
||
portConnectionDraftRef.current = null;
|
||
setPortConnectionDraft(null);
|
||
setActivePortConnection(null);
|
||
}, []);
|
||
|
||
const onConnect = useCallback(
|
||
(connection: Connection) => {
|
||
const clearConnectionUi = () => {
|
||
portConnectionDraftRef.current = null;
|
||
setPortConnectionDraft(null);
|
||
setActivePortConnection(null);
|
||
};
|
||
if (suppressCancelledConnectionRef.current) {
|
||
clearConnectionUi();
|
||
return;
|
||
}
|
||
if (!canConnectPorts(nodesRef.current, edgesRef.current, connection)) {
|
||
clearConnectionUi();
|
||
return;
|
||
}
|
||
|
||
let routePoints: EdgeRoutePoint[] | undefined;
|
||
const draft = portConnectionDraftRef.current;
|
||
if (activePortConnection?.mode === "click" && draft) {
|
||
const sourceNode = nodesRef.current.find(
|
||
(node) => node.id === connection.source,
|
||
);
|
||
const targetNode = nodesRef.current.find(
|
||
(node) => node.id === connection.target,
|
||
);
|
||
const sourcePlacement = sourceNode
|
||
? nodePortFlowPlacement(sourceNode, connection.sourceHandle ?? null)
|
||
: undefined;
|
||
const targetPlacement = targetNode
|
||
? nodePortFlowPlacement(targetNode, connection.targetHandle ?? null)
|
||
: undefined;
|
||
const originIsSource =
|
||
activePortConnection.nodeId === connection.source &&
|
||
activePortConnection.portName === connection.sourceHandle;
|
||
const originIsTarget =
|
||
activePortConnection.nodeId === connection.target &&
|
||
activePortConnection.portName === connection.targetHandle;
|
||
if (sourcePlacement && targetPlacement && (originIsSource || originIsTarget)) {
|
||
const orderedWaypoints = originIsSource
|
||
? draft.waypoints
|
||
: [...draft.waypoints].reverse();
|
||
routePoints = buildOrthogonalRoutePoints(
|
||
{ x: sourcePlacement.flowX, y: sourcePlacement.flowY },
|
||
{ x: targetPlacement.flowX, y: targetPlacement.flowY },
|
||
sourcePlacement.position,
|
||
targetPlacement.position,
|
||
orderedWaypoints,
|
||
);
|
||
}
|
||
}
|
||
|
||
recordHistory();
|
||
setEdges((currentEdges) => {
|
||
const nextEdges = addEdge(
|
||
{
|
||
...connection,
|
||
data: {
|
||
isContactEdge: false,
|
||
...(routePoints && routePoints.length > 0
|
||
? { routePoints }
|
||
: {}),
|
||
},
|
||
id: `edge-${connection.source}-${connection.sourceHandle}-${connection.target}-${connection.targetHandle}-${Date.now()}`,
|
||
type: CONTACT_AWARE_EDGE_TYPE,
|
||
},
|
||
currentEdges,
|
||
);
|
||
edgesRef.current = nextEdges;
|
||
return nextEdges;
|
||
});
|
||
suppressCancelledConnectionRef.current = false;
|
||
clearConnectionUi();
|
||
},
|
||
[activePortConnection, recordHistory, setEdges],
|
||
);
|
||
|
||
const isValidConnection = useCallback(
|
||
(connection: Connection | SimulationEdge) =>
|
||
canConnectPorts(nodesRef.current, edgesRef.current, connection),
|
||
[],
|
||
);
|
||
|
||
const onDragStart = (event: DragEvent<HTMLButtonElement>, type: string) => {
|
||
if (pendingPasteRef.current) {
|
||
event.preventDefault();
|
||
return;
|
||
}
|
||
dismissPaletteIconPreview();
|
||
event.dataTransfer.setData("application/system-component", type);
|
||
event.dataTransfer.effectAllowed = "move";
|
||
const definition = componentDefinitions.find((item) => item.type === type);
|
||
const paletteSymbol =
|
||
document.querySelector<HTMLElement>(
|
||
`.palette-canvas-drag-template[data-component-type="${CSS.escape(type)}"] > .component-symbol`,
|
||
) ??
|
||
event.currentTarget.querySelector<HTMLElement>(
|
||
".palette-icon > .component-symbol",
|
||
);
|
||
if (definition && paletteSymbol) {
|
||
const layout = componentSymbolLayout(
|
||
definition.symbol,
|
||
defaultParameters(definition),
|
||
);
|
||
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();
|
||
if (pendingPasteRef.current) {
|
||
return;
|
||
}
|
||
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,
|
||
new Set(nodesRef.current.map((node) => node.id)),
|
||
);
|
||
const data = { ...buildNodeData(definition), label: id };
|
||
if (mediumIndex !== null) {
|
||
data.parameters = {
|
||
...data.parameters,
|
||
gi: mediumIndex,
|
||
};
|
||
}
|
||
const layout = componentSymbolLayout(
|
||
data.symbol ?? data.componentType,
|
||
data.parameters,
|
||
);
|
||
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,
|
||
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;
|
||
let lmechn1PositionOffset: { x: number; y: number } | null = null;
|
||
|
||
const draftErrorKey = selectedNodeId ? `${selectedNodeId}:${key}` : key;
|
||
if (
|
||
selectedNode &&
|
||
isLmechn1NodeData(selectedNode.data) &&
|
||
key === "v1"
|
||
) {
|
||
const nextRightPortCount = convertedBaseValue;
|
||
const validationError =
|
||
!hasFiniteNumericValue || !Number.isInteger(nextRightPortCount)
|
||
? "右侧端口数量必须为整数"
|
||
: nextRightPortCount < 1
|
||
? "右侧端口数量不能小于1"
|
||
: nextRightPortCount > LMECHN1_MAX_RIGHT_PORT_COUNT
|
||
? "右侧端口数量不能超过20"
|
||
: "";
|
||
if (validationError) {
|
||
setComponentParameterDraftErrors((current) => ({
|
||
...current,
|
||
[draftErrorKey]: validationError,
|
||
}));
|
||
return;
|
||
}
|
||
|
||
const previousRightPortCount = lmechn1RightPortCount(
|
||
selectedNode.data.parameters,
|
||
);
|
||
if (nextRightPortCount < previousRightPortCount) {
|
||
const portsThatWouldDisappear = new Set(
|
||
Array.from(
|
||
{ length: previousRightPortCount - nextRightPortCount },
|
||
(_, index) => `port_${nextRightPortCount + 1 + index}`,
|
||
),
|
||
);
|
||
const connectedDisappearingPort = edgesRef.current.some(
|
||
(edge) =>
|
||
(edge.source === selectedNode.id &&
|
||
portsThatWouldDisappear.has(edge.sourceHandle ?? "")) ||
|
||
(edge.target === selectedNode.id &&
|
||
portsThatWouldDisappear.has(edge.targetHandle ?? "")),
|
||
);
|
||
if (connectedDisappearingPort) {
|
||
const message = "请先断开将被隐藏的右侧端口连接";
|
||
setComponentParameterDraftErrors((current) => ({
|
||
...current,
|
||
[draftErrorKey]: message,
|
||
}));
|
||
appendConsoleEntry("warning", message);
|
||
return;
|
||
}
|
||
}
|
||
|
||
setComponentParameterDraftErrors((current) => {
|
||
if (!(draftErrorKey in current)) {
|
||
return current;
|
||
}
|
||
const next = { ...current };
|
||
delete next[draftErrorKey];
|
||
return next;
|
||
});
|
||
if (nextRightPortCount !== previousRightPortCount) {
|
||
const symbol = selectedNode.data.symbol ?? selectedNode.data.componentType;
|
||
const rotation = normalizeNodeRotation(selectedNode.data.rotation);
|
||
const previousSize = nodeFrameDimensions(
|
||
componentSymbolLayout(symbol, selectedNode.data.parameters),
|
||
rotation,
|
||
);
|
||
const nextSize = nodeFrameDimensions(
|
||
componentSymbolLayout(symbol, {
|
||
...selectedNode.data.parameters,
|
||
v1: nextRightPortCount,
|
||
}),
|
||
rotation,
|
||
);
|
||
lmechn1PositionOffset = {
|
||
x: (previousSize.width - nextSize.width) / 2,
|
||
y: (previousSize.height - nextSize.height) / 2,
|
||
};
|
||
const previousReferencePort = `port_${previousRightPortCount + 1}`;
|
||
const nextReferencePort = `port_${nextRightPortCount + 1}`;
|
||
setEdges((currentEdges) => {
|
||
const nextEdges = currentEdges.map((edge) => ({
|
||
...edge,
|
||
...(edge.source === selectedNode.id &&
|
||
edge.sourceHandle === previousReferencePort
|
||
? { sourceHandle: nextReferencePort }
|
||
: {}),
|
||
...(edge.target === selectedNode.id &&
|
||
edge.targetHandle === previousReferencePort
|
||
? { targetHandle: nextReferencePort }
|
||
: {}),
|
||
}));
|
||
edgesRef.current = nextEdges;
|
||
return nextEdges;
|
||
});
|
||
}
|
||
} else {
|
||
setComponentParameterDraftErrors((current) => {
|
||
if (!(draftErrorKey in current)) {
|
||
return current;
|
||
}
|
||
const next = { ...current };
|
||
delete next[draftErrorKey];
|
||
return next;
|
||
});
|
||
}
|
||
|
||
updateSelectedNode((node) => {
|
||
const parameterScientificNotation = {
|
||
...node.data.parameterScientificNotation,
|
||
};
|
||
if (scientificNotation) {
|
||
parameterScientificNotation[key] = scientificNotation;
|
||
} else {
|
||
delete parameterScientificNotation[key];
|
||
}
|
||
return {
|
||
...node,
|
||
...(lmechn1PositionOffset
|
||
? {
|
||
position: {
|
||
x: node.position.x + lmechn1PositionOffset.x,
|
||
y: node.position.y + lmechn1PositionOffset.y,
|
||
},
|
||
}
|
||
: {}),
|
||
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),
|
||
),
|
||
),
|
||
};
|
||
appendConsoleEntry("info", `已复制 ${selectedNodes.length} 个组件`);
|
||
}, [appendConsoleEntry, selectedNodeId]);
|
||
|
||
const flowPointForScreenPoint = useCallback(
|
||
(screenPoint: { x: number; y: number }) => {
|
||
const flowPoint = reactFlow.screenToFlowPosition(screenPoint);
|
||
return snapEnabled
|
||
? {
|
||
x: Math.round(flowPoint.x / GRID_SIZE) * GRID_SIZE,
|
||
y: Math.round(flowPoint.y / GRID_SIZE) * GRID_SIZE,
|
||
}
|
||
: flowPoint;
|
||
},
|
||
[reactFlow, snapEnabled],
|
||
);
|
||
|
||
const positionPendingPasteAtScreenPoint = useCallback(
|
||
(screenPoint: { x: number; y: number }) => {
|
||
const current = pendingPasteRef.current;
|
||
if (!current) {
|
||
return;
|
||
}
|
||
const nextAnchor = flowPointForScreenPoint(screenPoint);
|
||
// Positions currently include the previous snap offset. Remove it while
|
||
// following the raw pointer, then derive a fresh block-wide snap.
|
||
const rawDelta = {
|
||
x: nextAnchor.x - current.anchor.x - current.snapOffset.x,
|
||
y: nextAnchor.y - current.anchor.y - current.snapOffset.y,
|
||
};
|
||
const pendingNodeIds = new Set(current.nodes.map((node) => node.id));
|
||
const rawNodes = current.nodes.map((node) => ({
|
||
...node,
|
||
position: {
|
||
x: node.position.x + rawDelta.x,
|
||
y: node.position.y + rawDelta.y,
|
||
},
|
||
}));
|
||
const rawEdges = translateInternalEdgeRoutes(
|
||
current.edges,
|
||
pendingNodeIds,
|
||
rawDelta.x,
|
||
rawDelta.y,
|
||
);
|
||
const allNodes = [...nodesRef.current, ...rawNodes];
|
||
const allEdges = [...edgesRef.current, ...rawEdges];
|
||
const candidate = findContactSnapCandidate(
|
||
rawNodes,
|
||
allNodes,
|
||
allEdges,
|
||
contactSnapRadiusForZoom(reactFlow.getZoom()),
|
||
);
|
||
const snapOffset = candidate?.positionDelta ?? { x: 0, y: 0 };
|
||
const nextNodes = rawNodes.map((node) => ({
|
||
...node,
|
||
position: {
|
||
x: node.position.x + snapOffset.x,
|
||
y: node.position.y + snapOffset.y,
|
||
},
|
||
}));
|
||
const nextEdges = translateInternalEdgeRoutes(
|
||
rawEdges,
|
||
pendingNodeIds,
|
||
snapOffset.x,
|
||
snapOffset.y,
|
||
);
|
||
const next = {
|
||
...current,
|
||
anchor: nextAnchor,
|
||
edges: nextEdges,
|
||
nodes: nextNodes,
|
||
snapOffset,
|
||
};
|
||
pendingPasteRef.current = next;
|
||
setPendingPaste(next);
|
||
setContactSnapCandidate((existing) =>
|
||
sameContactSnapCandidate(existing, candidate) ? existing : candidate,
|
||
);
|
||
},
|
||
[flowPointForScreenPoint, reactFlow, setPendingPaste],
|
||
);
|
||
|
||
const syncPendingPasteToPointer = useCallback(() => {
|
||
const pointer = lastFlowPointerRef.current;
|
||
if (pointer && pendingPasteRef.current) {
|
||
positionPendingPasteAtScreenPoint(pointer);
|
||
}
|
||
}, [positionPendingPasteAtScreenPoint]);
|
||
|
||
const rotatePendingPaste = useCallback(() => {
|
||
setPendingPaste((current) => {
|
||
if (!current) {
|
||
return current;
|
||
}
|
||
const next = {
|
||
...current,
|
||
// A rotated block gets a fresh automatic internal route. Absolute
|
||
// manual bends from the previous orientation must not remain behind.
|
||
edges: current.edges.map((edge) => ({
|
||
...edge,
|
||
data: {
|
||
...edge.data,
|
||
routePoints: undefined,
|
||
},
|
||
})),
|
||
nodes: current.nodes.map(rotateNodeClockwise),
|
||
};
|
||
pendingPasteRef.current = next;
|
||
return next;
|
||
});
|
||
clearContactSnapCandidate();
|
||
}, [clearContactSnapCandidate, setPendingPaste]);
|
||
|
||
const cancelPendingPaste = useCallback(() => {
|
||
if (!pendingPasteRef.current) {
|
||
return;
|
||
}
|
||
pendingPasteRef.current = null;
|
||
pendingPasteMiddlePointerRef.current = null;
|
||
setPendingPaste(null);
|
||
clearContactSnapCandidate();
|
||
appendConsoleEntry("info", "已取消粘贴放置");
|
||
}, [appendConsoleEntry, clearContactSnapCandidate]);
|
||
|
||
const commitPendingPaste = useCallback(() => {
|
||
const pending = pendingPasteRef.current;
|
||
if (!pending) {
|
||
return;
|
||
}
|
||
const occupiedNodeIds = new Set(nodesRef.current.map((node) => node.id));
|
||
const incomingNodeIds = new Set<string>();
|
||
const conflictingNode = pending.nodes.find((node) => {
|
||
const conflict = occupiedNodeIds.has(node.id) || incomingNodeIds.has(node.id);
|
||
incomingNodeIds.add(node.id);
|
||
return conflict;
|
||
});
|
||
const occupiedEdgeIds = new Set(edgesRef.current.map((edge) => edge.id));
|
||
const incomingEdgeIds = new Set<string>();
|
||
const conflictingEdge = pending.edges.find((edge) => {
|
||
const conflict = occupiedEdgeIds.has(edge.id) || incomingEdgeIds.has(edge.id);
|
||
incomingEdgeIds.add(edge.id);
|
||
return conflict;
|
||
});
|
||
if (conflictingNode || conflictingEdge) {
|
||
pendingPasteRef.current = null;
|
||
pendingPasteMiddlePointerRef.current = null;
|
||
setPendingPaste(null);
|
||
clearContactSnapCandidate();
|
||
appendConsoleEntry(
|
||
"error",
|
||
`粘贴已取消:检测到重复${conflictingNode ? `节点 ID ${conflictingNode.id}` : `连接 ID ${conflictingEdge?.id}`}`,
|
||
);
|
||
return;
|
||
}
|
||
|
||
const pendingNodeIds = new Set(pending.nodes.map((node) => node.id));
|
||
const combinedNodes = [...nodesRef.current, ...pending.nodes];
|
||
const combinedEdges = [...edgesRef.current, ...pending.edges];
|
||
const candidate = findContactSnapCandidate(
|
||
pending.nodes,
|
||
combinedNodes,
|
||
combinedEdges,
|
||
contactSnapRadiusForZoom(reactFlow.getZoom()),
|
||
);
|
||
const placementDelta = candidate?.positionDelta ?? { x: 0, y: 0 };
|
||
const placedNodes = pending.nodes.map((node) => ({
|
||
...node,
|
||
position: {
|
||
x: node.position.x + placementDelta.x,
|
||
y: node.position.y + placementDelta.y,
|
||
},
|
||
selected: true,
|
||
}));
|
||
const placedInternalEdges = translateInternalEdgeRoutes(
|
||
pending.edges,
|
||
pendingNodeIds,
|
||
placementDelta.x,
|
||
placementDelta.y,
|
||
).map((edge) => ({ ...edge, selected: false }));
|
||
const nextNodes = [
|
||
...nodesRef.current.map((node) => ({ ...node, selected: false })),
|
||
...placedNodes,
|
||
];
|
||
let nextEdges: SimulationEdge[] = [
|
||
...edgesRef.current.map((edge) => ({ ...edge, selected: false })),
|
||
...placedInternalEdges,
|
||
];
|
||
const timestamp = Date.now();
|
||
let attachedPortCount = 0;
|
||
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}-paste-${index}`,
|
||
type: CONTACT_AWARE_EDGE_TYPE,
|
||
};
|
||
if (
|
||
canConnectPorts(nextNodes, nextEdges, contactEdge) &&
|
||
isContactEdgeGeometry(nextNodes, contactEdge)
|
||
) {
|
||
nextEdges = addEdge(contactEdge, nextEdges);
|
||
attachedPortCount += 1;
|
||
}
|
||
});
|
||
|
||
recordHistory();
|
||
nodesRef.current = nextNodes;
|
||
edgesRef.current = nextEdges;
|
||
setNodes(nextNodes);
|
||
setEdges(nextEdges);
|
||
setSelectedNodeId(placedNodes.length === 1 ? placedNodes[0].id : null);
|
||
setSelectedEdgeId(null);
|
||
pendingPasteRef.current = null;
|
||
pendingPasteMiddlePointerRef.current = null;
|
||
setPendingPaste(null);
|
||
clearContactSnapCandidate();
|
||
appendConsoleEntry(
|
||
"info",
|
||
`已粘贴 ${placedNodes.length} 个组件${attachedPortCount > 0 ? `,并吸附连接 ${attachedPortCount} 个端口` : ""}`,
|
||
);
|
||
if (pending.repairedReferenceCount > 0) {
|
||
appendConsoleEntry(
|
||
"warning",
|
||
`${pending.repairedReferenceCount} 个粘贴的介质物性引用没有对应介质定义,已切回 0 — 空气(理想气体,内置默认)`,
|
||
);
|
||
}
|
||
}, [
|
||
appendConsoleEntry,
|
||
clearContactSnapCandidate,
|
||
reactFlow,
|
||
recordHistory,
|
||
setEdges,
|
||
setNodes,
|
||
]);
|
||
|
||
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);
|
||
}
|
||
});
|
||
|
||
const flowRoot = reactFlowStore.getState().domNode;
|
||
const flowBounds = flowRoot?.getBoundingClientRect();
|
||
const screenAnchor = lastFlowPointerRef.current ?? {
|
||
x: flowBounds ? flowBounds.left + flowBounds.width / 2 : window.innerWidth / 2,
|
||
y: flowBounds ? flowBounds.top + flowBounds.height / 2 : window.innerHeight / 2,
|
||
};
|
||
lastFlowPointerRef.current = screenAnchor;
|
||
const anchor = flowPointForScreenPoint(screenAnchor);
|
||
const copiedBounds = clipboard.nodes.reduce(
|
||
(bounds, node) => {
|
||
const size = nodeFrameDimensions(
|
||
componentSymbolLayout(
|
||
node.data.symbol ?? node.data.componentType,
|
||
node.data.parameters,
|
||
),
|
||
normalizeNodeRotation(node.data.rotation),
|
||
);
|
||
return {
|
||
minX: Math.min(bounds.minX, node.position.x),
|
||
minY: Math.min(bounds.minY, node.position.y),
|
||
maxX: Math.max(bounds.maxX, node.position.x + size.width),
|
||
maxY: Math.max(bounds.maxY, node.position.y + size.height),
|
||
};
|
||
},
|
||
{
|
||
minX: Number.POSITIVE_INFINITY,
|
||
minY: Number.POSITIVE_INFINITY,
|
||
maxX: Number.NEGATIVE_INFINITY,
|
||
maxY: Number.NEGATIVE_INFINITY,
|
||
},
|
||
);
|
||
const copiedCenter = {
|
||
x: (copiedBounds.minX + copiedBounds.maxX) / 2,
|
||
y: (copiedBounds.minY + copiedBounds.maxY) / 2,
|
||
};
|
||
const initialPasteDelta = {
|
||
x: anchor.x - copiedCenter.x,
|
||
y: anchor.y - copiedCenter.y,
|
||
};
|
||
const idMap = new Map<string, string>();
|
||
const reservedNodeIds = new Set(nodesRef.current.map((node) => node.id));
|
||
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, reservedNodeIds);
|
||
reservedNodeIds.add(id);
|
||
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 + initialPasteDelta.x,
|
||
y: node.position.y + initialPasteDelta.y,
|
||
},
|
||
selected: false,
|
||
data: {
|
||
...cloneValue(node.data),
|
||
label,
|
||
parameters,
|
||
},
|
||
} satisfies SimulationNode;
|
||
});
|
||
const reservedEdgeIds = new Set(edgesRef.current.map((edge) => edge.id));
|
||
const pastedEdges = clipboard.edges.map((edge, index) => {
|
||
const id = takeNextCopiedEdgeId(index, reservedEdgeIds);
|
||
reservedEdgeIds.add(id);
|
||
return translateEdgeRoute(
|
||
{
|
||
...cloneValue(edge),
|
||
id,
|
||
source: idMap.get(edge.source) as string,
|
||
target: idMap.get(edge.target) as string,
|
||
selected: false,
|
||
},
|
||
initialPasteDelta.x,
|
||
initialPasteDelta.y,
|
||
);
|
||
});
|
||
|
||
const nextPendingPaste = {
|
||
anchor,
|
||
edges: pastedEdges,
|
||
nodes: pastedNodes,
|
||
repairedReferenceCount: repairedPastedReferenceCount,
|
||
snapOffset: { x: 0, y: 0 },
|
||
} satisfies PendingPaste;
|
||
cancelPortConnection();
|
||
setSelectedNodeId(null);
|
||
setSelectedEdgeId(null);
|
||
pendingPasteRef.current = nextPendingPaste;
|
||
setPendingPaste(nextPendingPaste);
|
||
appendConsoleEntry(
|
||
"info",
|
||
`已准备 ${pastedNodes.length} 个组件:左键放置,中键旋转,Esc 取消`,
|
||
);
|
||
}, [
|
||
appendConsoleEntry,
|
||
cancelPortConnection,
|
||
componentDefinitions,
|
||
flowPointForScreenPoint,
|
||
reactFlowStore,
|
||
]);
|
||
|
||
const handleFlowPointerDownCapture = useCallback(
|
||
(event: ReactPointerEvent<HTMLElement>) => {
|
||
lastFlowPointerRef.current = { x: event.clientX, y: event.clientY };
|
||
rememberFlowPointer(event);
|
||
if (pendingPasteRef.current && event.button === 1) {
|
||
pendingPasteMiddlePointerRef.current = {
|
||
pointerId: event.pointerId,
|
||
startX: event.clientX,
|
||
startY: event.clientY,
|
||
moved: false,
|
||
};
|
||
}
|
||
},
|
||
[rememberFlowPointer],
|
||
);
|
||
|
||
const handleFlowPointerMoveCapture = useCallback(
|
||
(event: ReactPointerEvent<HTMLElement>) => {
|
||
lastFlowPointerRef.current = { x: event.clientX, y: event.clientY };
|
||
const middlePointer = pendingPasteMiddlePointerRef.current;
|
||
if (
|
||
middlePointer &&
|
||
middlePointer.pointerId === event.pointerId &&
|
||
Math.hypot(
|
||
event.clientX - middlePointer.startX,
|
||
event.clientY - middlePointer.startY,
|
||
) > 4
|
||
) {
|
||
middlePointer.moved = true;
|
||
}
|
||
if (pendingPasteRef.current) {
|
||
positionPendingPasteAtScreenPoint(lastFlowPointerRef.current);
|
||
}
|
||
const connectionDraft = portConnectionDraftRef.current;
|
||
if (connectionDraft) {
|
||
const pointer = reactFlow.screenToFlowPosition(
|
||
{ x: event.clientX, y: event.clientY },
|
||
{ snapToGrid: false },
|
||
);
|
||
if (
|
||
connectionDraft.pointer?.x !== pointer.x ||
|
||
connectionDraft.pointer?.y !== pointer.y
|
||
) {
|
||
const nextDraft = { ...connectionDraft, pointer };
|
||
portConnectionDraftRef.current = nextDraft;
|
||
setPortConnectionDraft(nextDraft);
|
||
}
|
||
}
|
||
},
|
||
[positionPendingPasteAtScreenPoint, reactFlow],
|
||
);
|
||
|
||
const handleFlowPointerUpCapture = useCallback(
|
||
(event: ReactPointerEvent<HTMLElement>) => {
|
||
lastFlowPointerRef.current = { x: event.clientX, y: event.clientY };
|
||
const middlePointer = pendingPasteMiddlePointerRef.current;
|
||
if (!middlePointer || middlePointer.pointerId !== event.pointerId) {
|
||
return;
|
||
}
|
||
pendingPasteMiddlePointerRef.current = null;
|
||
if (!middlePointer.moved && pendingPasteRef.current) {
|
||
rotatePendingPaste();
|
||
}
|
||
},
|
||
[rotatePendingPaste],
|
||
);
|
||
|
||
const handleFlowClickCapture = useCallback(
|
||
(event: ReactMouseEvent<HTMLElement>) => {
|
||
if (!pendingPasteRef.current || event.button !== 0) {
|
||
return;
|
||
}
|
||
const target = event.target instanceof Element ? event.target : null;
|
||
if (
|
||
target?.closest(
|
||
".flow-viewport-scrollbars, .react-flow__panel, button, input, select, textarea, a, [role='button'], [role='scrollbar']",
|
||
)
|
||
) {
|
||
return;
|
||
}
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
commitPendingPaste();
|
||
},
|
||
[commitPendingPaste],
|
||
);
|
||
|
||
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, nodes);
|
||
appendConsoleEntry(
|
||
"success",
|
||
`XML 已生成:${nodes.length} 个组件,${edges.length} 条连接`,
|
||
);
|
||
appendConsoleEntry("info", xml, "xml");
|
||
} catch (error) {
|
||
appendConsoleEntry("error", `XML 生成失败:${formatError(error)}`);
|
||
}
|
||
};
|
||
|
||
const downloadXml = () => {
|
||
try {
|
||
const xml = buildSystemXml(buildCurrentProject(), componentDefinitions, nodes);
|
||
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 legacyMigration = migrateLegacyAmesimComponents(
|
||
project,
|
||
componentDefinitions,
|
||
);
|
||
const normalizedNodes = legacyMigration.project.nodes.map((node) =>
|
||
normalizeLoadedNode(
|
||
node,
|
||
componentDefinitions,
|
||
),
|
||
);
|
||
const indexedMedia = assignMissingAmesimGasDefinitionIndices(
|
||
normalizedNodes,
|
||
componentDefinitions,
|
||
);
|
||
const loadedNodes = indexedMedia.nodes;
|
||
const edgeNormalization = loadProjectEdges(
|
||
legacyMigration.project.edges,
|
||
loadedNodes,
|
||
);
|
||
const loadedEdges = edgeNormalization.edges;
|
||
setNodes(loadedNodes);
|
||
setEdges(loadedEdges);
|
||
setProjectName(project.name || "untitled");
|
||
setSimulationConfig({ ...defaultSimulationConfig, ...project.simulation });
|
||
setSelectedNodeId(null);
|
||
setSelectedEdgeId(null);
|
||
setValidationIssues([]);
|
||
setValidatedSignature("");
|
||
syncNextNodeNumbers(loadedNodes);
|
||
appendConsoleEntry("success", message);
|
||
if (legacyMigration.migratedLmechn1NodeCount > 0) {
|
||
appendConsoleEntry(
|
||
"info",
|
||
`已升级 ${legacyMigration.migratedLmechn1NodeCount} 个 LMECHN1 元件的动态端口合同`,
|
||
);
|
||
}
|
||
if (legacyMigration.migratedForcNodeCount > 0) {
|
||
appendConsoleEntry(
|
||
"info",
|
||
`已升级 ${legacyMigration.migratedForcNodeCount} 个 FORC 元件的显式力方向合同`,
|
||
);
|
||
}
|
||
loadedNodes
|
||
.flatMap((node) => node.data.modelContractIssues ?? [])
|
||
.forEach((issue) => appendConsoleEntry("error", `${issue};该组件仅可查看,不能仿真`));
|
||
edgeNormalization.droppedEdges.forEach(({ id, reason }) =>
|
||
appendConsoleEntry(
|
||
"warning",
|
||
`已丢弃失效连接 ${id}:${reason}`,
|
||
),
|
||
);
|
||
if (indexedMedia.assignedDefinitionCount > 0) {
|
||
appendConsoleEntry(
|
||
"info",
|
||
`已为 ${indexedMedia.assignedDefinitionCount} 个介质定义补充分配介质定义索引`,
|
||
);
|
||
}
|
||
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 = () => {
|
||
if (pendingPasteRef.current) {
|
||
return;
|
||
}
|
||
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 || pendingPasteRef.current) {
|
||
return;
|
||
}
|
||
try {
|
||
const fileText = await file.text();
|
||
if (pendingPasteRef.current) {
|
||
return;
|
||
}
|
||
const project = parseProjectPayload(fileText);
|
||
if (!project) {
|
||
throw new Error("文件不是有效的 ReactFlow 工程 JSON");
|
||
}
|
||
applyProject(project, `已导入工程:${file.name}`);
|
||
} catch (error) {
|
||
appendConsoleEntry("error", `导入失败:${formatError(error)}`);
|
||
}
|
||
};
|
||
|
||
const checkModel = useCallback(() => {
|
||
const issues = validateModel(
|
||
nodesRef.current,
|
||
edgesRef.current,
|
||
simulationConfigRef.current,
|
||
componentDefinitions,
|
||
);
|
||
const signature = modelValidationSignature(
|
||
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} 条警告`;
|
||
issues.forEach((issue) =>
|
||
appendConsoleEntry(issue.severity === "error" ? "error" : "warning", issue.message),
|
||
);
|
||
appendConsoleEntry(
|
||
errorCount > 0 ? "error" : warningCount > 0 ? "warning" : "success",
|
||
summary,
|
||
);
|
||
return issues;
|
||
}, [appendConsoleEntry, 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) {
|
||
setSimulationProgress({
|
||
state: "error",
|
||
percent: 0,
|
||
message: "模型检查未通过",
|
||
startTime: progressConfig.t_start,
|
||
simulatedTime: progressConfig.t_start,
|
||
totalTime: progressConfig.t_stop,
|
||
});
|
||
return;
|
||
}
|
||
if (!simulationResolution.ok) {
|
||
appendConsoleEntry("error", simulationResolution.message);
|
||
return;
|
||
}
|
||
const project: ExecutableProjectPayload = {
|
||
...buildCurrentProject(),
|
||
simulation: simulationResolution.value,
|
||
};
|
||
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, nodesRef.current);
|
||
const simulationId = createSimulationId();
|
||
activeSimulationIdRef.current = simulationId;
|
||
setSimulationStopPending(false);
|
||
let lastLoggedPhase = "";
|
||
let lastLoggedBucket = -1;
|
||
let lastWatchdogStatus: SimulationActivityWatchdogStatus = "progressing";
|
||
try {
|
||
const result = await streamSystemSimulation(
|
||
xml,
|
||
(event, watchdog) => {
|
||
if (event.event !== "progress") {
|
||
return;
|
||
}
|
||
const normalizedProgress = normalizeSimulationProgressEvent(
|
||
event,
|
||
project.simulation,
|
||
);
|
||
const reportsSimulationTime = ![
|
||
"queued",
|
||
"validation",
|
||
"compilation",
|
||
"initialization",
|
||
"initializing",
|
||
].includes(event.phase);
|
||
const progressMessage =
|
||
watchdog?.status === "slow-active"
|
||
? `正在处理耗时积分步,内部计算仍活跃${
|
||
event.activityKind ? `(${event.activityKind})` : ""
|
||
},可手动停止`
|
||
: watchdog?.status === "slow-telemetry-unavailable"
|
||
? "积分步耗时较长,后端未提供活动遥测;任务保持运行,可手动停止"
|
||
: event.message;
|
||
setSimulationProgress((current) => ({
|
||
...current,
|
||
state: "running",
|
||
percent: normalizedProgress.percent,
|
||
message: progressMessage,
|
||
simulatedTime: normalizedProgress.simulatedTime,
|
||
totalTime: normalizedProgress.totalTime,
|
||
}));
|
||
if (watchdog && watchdog.status !== lastWatchdogStatus) {
|
||
if (watchdog.status === "slow-active") {
|
||
appendConsoleEntry(
|
||
"warning",
|
||
`积分步超过 ${SIMULATION_ACTIVITY_STALL_TIMEOUT_SECONDS} 秒尚未接受,但后端内部活动仍在推进;继续等待,可随时手动停止`,
|
||
);
|
||
} else if (watchdog.status === "slow-telemetry-unavailable") {
|
||
appendConsoleEntry(
|
||
"warning",
|
||
`积分步超过 ${SIMULATION_ACTIVITY_STALL_TIMEOUT_SECONDS} 秒尚未接受,且后端未提供活动遥测;将按 ${SIMULATION_SOLVER_STALL_TIMEOUT_MINUTES} 分钟保守上限继续等待,可手动停止`,
|
||
);
|
||
} else if (lastWatchdogStatus !== "progressing") {
|
||
appendConsoleEntry("info", "求解器已接受新的积分步,仿真继续推进");
|
||
}
|
||
lastWatchdogStatus = watchdog.status;
|
||
}
|
||
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"
|
||
? error.message.includes("内部活动")
|
||
? solverActivityStallTimeoutMessage("recovering")
|
||
: solverStallTimeoutMessage("recovering")
|
||
: "超过 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 || pendingPasteRef.current) {
|
||
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);
|
||
}, [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);
|
||
};
|
||
|
||
const toggleGridLines = () => {
|
||
const nextVisible = !gridVisibility.lines;
|
||
setGridVisibility((current) => ({ ...current, lines: nextVisible }));
|
||
};
|
||
|
||
const toggleGridDots = () => {
|
||
const nextVisible = !gridVisibility.dots;
|
||
setGridVisibility((current) => ({ ...current, dots: nextVisible }));
|
||
};
|
||
|
||
const fitModelingView = () => {
|
||
if (nodesRef.current.length === 0) {
|
||
appendConsoleEntry("warning", "画布中没有可适配的组件");
|
||
return;
|
||
}
|
||
void reactFlow.fitView({ duration: 180, minZoom: 0.1, padding: 0.18 });
|
||
};
|
||
|
||
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 || nodeDragActive || recoveryProject) {
|
||
return;
|
||
}
|
||
setSaveState("有未保存更改");
|
||
const timer = window.setTimeout(() => {
|
||
try {
|
||
const project = buildProjectPayload(
|
||
projectNameRef.current,
|
||
nodesRef.current,
|
||
edgesRef.current,
|
||
simulationConfigRef.current,
|
||
);
|
||
localStorage.setItem(AUTOSAVE_KEY, JSON.stringify(project));
|
||
setSaveState(`已自动保存 ${formatTime(new Date())}`);
|
||
} catch (error) {
|
||
setSaveState(`自动保存失败:${formatError(error)}`);
|
||
}
|
||
}, 600);
|
||
return () => window.clearTimeout(timer);
|
||
}, [autosaveReady, nodeDragActive, projectChangeSignature, recoveryProject]);
|
||
|
||
useEffect(() => {
|
||
const syncViewFromLocation = () => {
|
||
if (pendingPasteRef.current) {
|
||
if (window.location.hash !== "#/modeling") {
|
||
window.history.replaceState(
|
||
{ workspaceView: "modeling" },
|
||
"",
|
||
"#/modeling",
|
||
);
|
||
}
|
||
setActiveView("modeling");
|
||
return;
|
||
}
|
||
setActiveView(workspaceViewFromLocation());
|
||
};
|
||
window.addEventListener("hashchange", syncViewFromLocation);
|
||
window.addEventListener("popstate", syncViewFromLocation);
|
||
return () => {
|
||
window.removeEventListener("hashchange", syncViewFromLocation);
|
||
window.removeEventListener("popstate", syncViewFromLocation);
|
||
};
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (activeView !== "modeling") {
|
||
cancelPortConnection();
|
||
}
|
||
}, [activeView, cancelPortConnection]);
|
||
|
||
useEffect(() => {
|
||
if (activeView === "results" && hasUnreadResults) {
|
||
setHasUnreadResults(false);
|
||
}
|
||
}, [activeView, hasUnreadResults]);
|
||
|
||
useEffect(() => {
|
||
if (activeView !== "modeling") {
|
||
return;
|
||
}
|
||
const handleKeyDown = (event: KeyboardEvent) => {
|
||
const key = event.key.toLowerCase();
|
||
const commandKey = event.ctrlKey || event.metaKey;
|
||
if (event.key === "Escape" && pendingPasteRef.current) {
|
||
event.preventDefault();
|
||
cancelPendingPaste();
|
||
return;
|
||
}
|
||
if (event.key === "Escape" && activePortConnection) {
|
||
event.preventDefault();
|
||
cancelPortConnection();
|
||
return;
|
||
}
|
||
if (shouldPreserveNativeConsoleShortcut(event, commandKey, key)) {
|
||
return;
|
||
}
|
||
if (event.ctrlKey && !event.shiftKey && key === "r") {
|
||
event.preventDefault();
|
||
pendingPasteRef.current ? rotatePendingPaste() : rotateSelection();
|
||
return;
|
||
}
|
||
if (
|
||
isEditableTarget(event.target) ||
|
||
(event.target instanceof Element &&
|
||
Boolean(event.target.closest("[data-editor-shortcuts-ignore='true']")))
|
||
) {
|
||
return;
|
||
}
|
||
if (
|
||
pendingPasteRef.current &&
|
||
((event.shiftKey && !commandKey && !event.altKey && key === "h") ||
|
||
(commandKey && ["a", "c", "v", "y", "z"].includes(key)))
|
||
) {
|
||
event.preventDefault();
|
||
return;
|
||
}
|
||
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,
|
||
cancelPendingPaste,
|
||
cancelPortConnection,
|
||
copySelection,
|
||
mirrorSelection,
|
||
pasteSelection,
|
||
redo,
|
||
rotatePendingPaste,
|
||
rotateSelection,
|
||
selectAllElements,
|
||
undo,
|
||
]);
|
||
|
||
const simulationConsole = USE_DOCKED_SIMULATION_CONSOLE ? (
|
||
<DockedSimulationConsole
|
||
entries={consoleEntries}
|
||
mode={consoleMode}
|
||
onClear={() => setConsoleEntries([])}
|
||
onModeChange={changeConsoleMode}
|
||
onStop={() => void stopSimulation()}
|
||
progress={simulationProgress}
|
||
stopPending={simulationStopPending}
|
||
/>
|
||
) : (
|
||
<SimulationConsole
|
||
entries={consoleEntries}
|
||
mode={consoleMode}
|
||
onClear={() => setConsoleEntries([])}
|
||
onModeChange={changeConsoleMode}
|
||
onStop={() => void stopSimulation()}
|
||
progress={simulationProgress}
|
||
stopPending={simulationStopPending}
|
||
/>
|
||
);
|
||
const handleDragConnectStart = useCallback<OnConnectStart>(
|
||
(_event, { nodeId, handleId }) =>
|
||
beginPortConnection(nodeId, handleId, "drag"),
|
||
[beginPortConnection],
|
||
);
|
||
const handleClickConnectStart = useCallback<OnConnectStart>(
|
||
(_event, { nodeId, handleId }) =>
|
||
beginPortConnection(nodeId, handleId, "click"),
|
||
[beginPortConnection],
|
||
);
|
||
const handleModelingEdgeClick = useCallback<
|
||
EdgeMouseHandler<SimulationEdge>
|
||
>(
|
||
(_event, edge) => {
|
||
cancelPortConnection();
|
||
setSelectedEdgeId(edge.id);
|
||
setSelectedNodeId(null);
|
||
},
|
||
[cancelPortConnection],
|
||
);
|
||
const handleModelingEdgesDelete = useCallback(() => {
|
||
setSelectedEdgeId(null);
|
||
}, []);
|
||
const handleModelingNodeClick = useCallback<
|
||
NodeMouseHandler<SimulationNode>
|
||
>(
|
||
(event, node) => {
|
||
if (
|
||
!(event.target instanceof Element) ||
|
||
!event.target.closest(".port-handle")
|
||
) {
|
||
cancelPortConnection();
|
||
}
|
||
setSelectedNodeId(node.id);
|
||
setSelectedEdgeId(null);
|
||
},
|
||
[cancelPortConnection],
|
||
);
|
||
const handleModelingNodesDelete = useCallback(() => {
|
||
setSelectedNodeId(null);
|
||
cancelPortConnection();
|
||
}, [cancelPortConnection]);
|
||
const handleModelingPaneClick = useCallback(
|
||
(event: ReactMouseEvent) => {
|
||
setSelectedNodeId(null);
|
||
setSelectedEdgeId(null);
|
||
const connectionDraft = portConnectionDraftRef.current;
|
||
if (activePortConnection?.mode === "click" && connectionDraft) {
|
||
const waypoint = reactFlow.screenToFlowPosition(
|
||
{ x: event.clientX, y: event.clientY },
|
||
{ snapToGrid: false },
|
||
);
|
||
const previousWaypoint = connectionDraft.waypoints.at(-1);
|
||
if (
|
||
!previousWaypoint ||
|
||
Math.hypot(
|
||
waypoint.x - previousWaypoint.x,
|
||
waypoint.y - previousWaypoint.y,
|
||
) > 1
|
||
) {
|
||
const nextDraft = {
|
||
pointer: waypoint,
|
||
waypoints: [...connectionDraft.waypoints, waypoint],
|
||
};
|
||
portConnectionDraftRef.current = nextDraft;
|
||
setPortConnectionDraft(nextDraft);
|
||
}
|
||
return;
|
||
}
|
||
cancelPortConnection();
|
||
},
|
||
[activePortConnection, cancelPortConnection, reactFlow],
|
||
);
|
||
const handleModelingSelectionChange = useCallback<
|
||
OnSelectionChangeFunc<SimulationNode, SimulationEdge>
|
||
>(({ nodes: selectedNodes, edges: selectedEdges }) => {
|
||
setSelectedNodeId(selectedNodes.length === 1 ? selectedNodes[0].id : null);
|
||
setSelectedEdgeId(
|
||
selectedNodes.length === 0 && selectedEdges.length === 1
|
||
? selectedEdges[0].id
|
||
: null,
|
||
);
|
||
}, []);
|
||
|
||
if (activeView === "results") {
|
||
return resultSnapshot ? (
|
||
<ReactFlowProvider>
|
||
<SimulationResultsView
|
||
gridVisibility={gridVisibility}
|
||
key={resultSnapshot.id}
|
||
nodeTypes={nodeTypes}
|
||
onSnapshotLoad={loadSimulationResultsSnapshot}
|
||
onViewChange={changeWorkspaceView}
|
||
resolvePortPlacement={resultNodePortFlowPlacement}
|
||
snapshot={resultSnapshot}
|
||
/>
|
||
</ReactFlowProvider>
|
||
) : (
|
||
<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 draftError = componentParameterDraftErrors[
|
||
`${selectedNode.id}:${key}`
|
||
];
|
||
const error =
|
||
draftError || componentParameterValidationMessage(
|
||
definition,
|
||
baseValue,
|
||
selectedUnit,
|
||
gasReference ? amesimGasMediumOptions : undefined,
|
||
selectedDefinition.type,
|
||
key,
|
||
);
|
||
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,
|
||
selectedDefinition.type,
|
||
name,
|
||
),
|
||
);
|
||
});
|
||
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
|
||
disabled={Boolean(pendingPaste)}
|
||
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 || Boolean(pendingPaste)}
|
||
icon={Save}
|
||
label="保存工程"
|
||
onClick={saveProject}
|
||
/>
|
||
<ToolbarButton
|
||
disabled={busy || Boolean(pendingPaste)}
|
||
icon={FolderOpen}
|
||
label="加载工程"
|
||
onClick={loadProject}
|
||
/>
|
||
<ToolbarButton
|
||
disabled={Boolean(pendingPaste)}
|
||
icon={FileDown}
|
||
label="导出工程 JSON"
|
||
onClick={exportProject}
|
||
/>
|
||
<ToolbarButton
|
||
disabled={Boolean(pendingPaste)}
|
||
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 || Boolean(pendingPaste)}
|
||
icon={FileCode2}
|
||
label="生成系统 XML"
|
||
onClick={generateXml}
|
||
/>
|
||
<ToolbarButton
|
||
disabled={busy || Boolean(pendingPaste)}
|
||
icon={Download}
|
||
label="下载系统 XML"
|
||
onClick={downloadXml}
|
||
/>
|
||
</div>
|
||
<span aria-hidden="true" className="toolbar-separator" />
|
||
<div aria-label="历史记录" className="toolbar-group" role="group">
|
||
<ToolbarButton
|
||
disabled={Boolean(pendingPaste) || !historyAvailability.canUndo}
|
||
icon={Undo2}
|
||
label="撤销 (Ctrl+Z)"
|
||
onClick={undo}
|
||
/>
|
||
<ToolbarButton
|
||
disabled={Boolean(pendingPaste) || !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
|
||
disabled={Boolean(pendingPaste)}
|
||
icon={Copy}
|
||
label="复制 (Ctrl+C)"
|
||
onClick={copySelection}
|
||
/>
|
||
<ToolbarButton
|
||
disabled={Boolean(pendingPaste)}
|
||
icon={ClipboardPaste}
|
||
label="粘贴 (Ctrl+V)"
|
||
onClick={pasteSelection}
|
||
/>
|
||
<ToolbarButton
|
||
disabled={Boolean(pendingPaste)}
|
||
icon={Trash2}
|
||
label="删除 (Delete)"
|
||
onClick={deleteSelection}
|
||
/>
|
||
</div>
|
||
<span aria-hidden="true" className="toolbar-separator" />
|
||
<div aria-label="组件变换" className="toolbar-group" role="group">
|
||
<ToolbarButton
|
||
disabled={!pendingPaste && selectedNodesForTools.length === 0}
|
||
icon={RotateCw}
|
||
label="顺时针旋转 90° (Ctrl+R)"
|
||
onClick={pendingPaste ? rotatePendingPaste : rotateSelection}
|
||
/>
|
||
<ToolbarButton
|
||
disabled={Boolean(pendingPaste) || 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
|
||
disabled={Boolean(pendingPaste)}
|
||
icon={ListChecks}
|
||
label="检查模型"
|
||
onClick={checkModel}
|
||
/>
|
||
<ToolbarButton
|
||
disabled={busy || Boolean(pendingPaste)}
|
||
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">
|
||
<div className="panel-title palette-panel-title">
|
||
<span>组件库</span>
|
||
<small
|
||
aria-describedby={
|
||
componentCatalogStatus === "error"
|
||
? "component-catalog-error-detail"
|
||
: undefined
|
||
}
|
||
aria-label={
|
||
componentCatalogStatus === "ready"
|
||
? componentCatalogMessage
|
||
: `组件库${componentCatalogStatus === "loading" ? "正在加载" : "加载失败"}:${componentCatalogMessage}`
|
||
}
|
||
aria-live="polite"
|
||
className={`catalog-source ${componentCatalogStatus}`}
|
||
title={componentCatalogMessage}
|
||
role="status"
|
||
tabIndex={componentCatalogStatus === "error" ? 0 : undefined}
|
||
>
|
||
<span aria-hidden="true" className="catalog-source-dot" />
|
||
{componentCatalogStatus === "ready"
|
||
? componentCatalogMessage
|
||
: componentCatalogStatus === "loading"
|
||
? "加载中"
|
||
: "加载失败"}
|
||
{componentCatalogStatus === "error" ? (
|
||
<span
|
||
className="catalog-source-tooltip"
|
||
id="component-catalog-error-detail"
|
||
role="tooltip"
|
||
>
|
||
{componentCatalogMessage}
|
||
</span>
|
||
) : null}
|
||
</small>
|
||
</div>
|
||
<div className="palette-list">
|
||
{visibleComponentLibraries.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} 个组件
|
||
</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"
|
||
disabled={Boolean(pendingPaste)}
|
||
draggable={!pendingPaste}
|
||
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}
|
||
type="button"
|
||
>
|
||
<span className="palette-icon">
|
||
<ComponentSymbol
|
||
parameters={defaultParametersByComponentType.get(
|
||
definition.type,
|
||
)}
|
||
surface="palette"
|
||
symbol={definition.symbol}
|
||
/>
|
||
</span>
|
||
<span className="palette-item-copy">
|
||
<strong className="palette-item-name">
|
||
{definition.label}
|
||
</strong>
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
) : null}
|
||
</section>
|
||
);
|
||
})}
|
||
</div>
|
||
</aside>
|
||
<div aria-hidden="true" className="palette-canvas-drag-templates">
|
||
{visibleComponentLibraries.flatMap((library) =>
|
||
library.components.map((definition) => (
|
||
<span
|
||
className="palette-canvas-drag-template"
|
||
data-component-type={definition.type}
|
||
key={definition.type}
|
||
>
|
||
<ComponentSymbol
|
||
parameters={defaultParametersByComponentType.get(
|
||
definition.type,
|
||
)}
|
||
surface="canvas"
|
||
symbol={definition.symbol}
|
||
/>
|
||
</span>
|
||
)),
|
||
)}
|
||
</div>
|
||
|
||
<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>
|
||
|
||
<div className="modeling-canvas-stack">
|
||
<section
|
||
className={[
|
||
"flow-canvas",
|
||
activePortConnection?.mode === "click" ? "connection-planning" : "",
|
||
].filter(Boolean).join(" ")}
|
||
onClickCapture={handleFlowClickCapture}
|
||
onDragOver={(event) => {
|
||
if (pendingPasteRef.current) {
|
||
event.dataTransfer.dropEffect = "none";
|
||
return;
|
||
}
|
||
event.preventDefault();
|
||
event.dataTransfer.dropEffect = "move";
|
||
}}
|
||
onDrop={onDrop}
|
||
onPointerCancelCapture={() => {
|
||
pendingPasteMiddlePointerRef.current = null;
|
||
setNodeDragActive(false);
|
||
contactDragSessionRef.current = null;
|
||
clearContactSnapCandidate();
|
||
handleTightSelectionPointerCancel();
|
||
}}
|
||
onPointerDownCapture={handleFlowPointerDownCapture}
|
||
onPointerMoveCapture={handleFlowPointerMoveCapture}
|
||
onPointerUpCapture={handleFlowPointerUpCapture}
|
||
>
|
||
<ReactFlow
|
||
colorMode="light"
|
||
connectionMode={ConnectionMode.Loose}
|
||
connectionLineType={ConnectionLineType.Step}
|
||
connectionRadius={24}
|
||
connectionDragThreshold={0}
|
||
defaultEdgeOptions={MODELING_DEFAULT_EDGE_OPTIONS}
|
||
defaultViewport={modelingViewportRef.current}
|
||
edges={flowEdges}
|
||
edgeTypes={contactAwareEdgeTypes}
|
||
elementsSelectable={!pendingPaste}
|
||
nodeTypes={nodeTypes}
|
||
nodes={flowNodes}
|
||
nodesConnectable={!pendingPaste}
|
||
nodesDraggable={!pendingPaste}
|
||
deleteKeyCode={pendingPaste ? null : MODELING_DELETE_KEYS}
|
||
onConnect={onConnect}
|
||
onConnectEnd={endDragPortConnection}
|
||
onConnectStart={handleDragConnectStart}
|
||
onClickConnectEnd={endClickPortConnection}
|
||
onClickConnectStart={handleClickConnectStart}
|
||
isValidConnection={isValidConnection}
|
||
minZoom={0.1}
|
||
onEdgeClick={handleModelingEdgeClick}
|
||
onEdgesChange={handleEdgesChange}
|
||
onEdgesDelete={handleModelingEdgesDelete}
|
||
onNodeClick={handleModelingNodeClick}
|
||
onNodeDrag={handleNodeDrag}
|
||
onNodeDragStart={handleNodeDragStart}
|
||
onNodeDragStop={handleNodeDragStop}
|
||
onNodesChange={handleNodesChange}
|
||
onNodesDelete={handleModelingNodesDelete}
|
||
onMove={(_event, viewport) => {
|
||
modelingViewportRef.current = { ...viewport };
|
||
syncPendingPasteToPointer();
|
||
}}
|
||
onPaneClick={handleModelingPaneClick}
|
||
onSelectionChange={handleModelingSelectionChange}
|
||
onSelectionEnd={finishTightSelection}
|
||
panActivationKeyCode="Space"
|
||
panOnDrag={MODELING_PAN_ON_DRAG}
|
||
panOnScroll
|
||
panOnScrollMode={PanOnScrollMode.Vertical}
|
||
panOnScrollSpeed={1}
|
||
preventScrolling
|
||
proOptions={MODELING_PRO_OPTIONS}
|
||
selectionMode={SelectionMode.Partial}
|
||
selectionOnDrag={!pendingPaste}
|
||
snapGrid={MODELING_SNAP_GRID}
|
||
snapToGrid={snapEnabled}
|
||
zoomActivationKeyCode="Control"
|
||
zoomOnDoubleClick={false}
|
||
zoomOnPinch
|
||
zoomOnScroll={false}
|
||
>
|
||
<AutoFitView
|
||
enabled={renderedNodes.length > 0}
|
||
expectedNodeCount={renderedNodes.length}
|
||
requestKey={modelingFitRequest}
|
||
/>
|
||
<PortGeometryRefresh
|
||
nodeIds={renderedNodes.map((node) => node.id)}
|
||
requestKey={modelingFitRequest}
|
||
/>
|
||
<ConnectionDraftOverlay
|
||
activeConnection={activePortConnection}
|
||
draft={portConnectionDraft}
|
||
nodes={nodes}
|
||
/>
|
||
<CanvasGridBackground visibility={gridVisibility} />
|
||
{pendingPaste ? (
|
||
<Panel className="pending-paste-hint" position="top-center">
|
||
待放置:左键放置 · 中键旋转 · Esc 取消
|
||
</Panel>
|
||
) : null}
|
||
{!pendingPaste && activePortConnection?.mode === "click" ? (
|
||
<Panel className="pending-paste-hint" position="top-center">
|
||
连接模式:单击空白添加折点 · 单击兼容端口完成 · Esc 取消
|
||
</Panel>
|
||
) : null}
|
||
{recoveryProject ? (
|
||
<Panel className="recovery-banner" position="top-center">
|
||
<span>检测到上次自动保存的工程</span>
|
||
<button
|
||
disabled={Boolean(pendingPaste)}
|
||
onClick={restoreRecoveryProject}
|
||
type="button"
|
||
>
|
||
恢复
|
||
</button>
|
||
<button
|
||
disabled={Boolean(pendingPaste)}
|
||
onClick={discardRecoveryProject}
|
||
type="button"
|
||
>
|
||
忽略
|
||
</button>
|
||
</Panel>
|
||
) : null}
|
||
<FlowViewportScrollbars
|
||
deferBounds={nodeDragActive}
|
||
nodes={renderedNodes}
|
||
/>
|
||
</ReactFlow>
|
||
</section>
|
||
{USE_DOCKED_SIMULATION_CONSOLE ? simulationConsole : null}
|
||
</div>
|
||
|
||
<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
|
||
aria-disabled={Boolean(pendingPaste)}
|
||
className="properties"
|
||
inert={Boolean(pendingPaste)}
|
||
>
|
||
<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>
|
||
{!USE_DOCKED_SIMULATION_CONSOLE ? simulationConsole : null}
|
||
{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,
|
||
calculateContactGeometry = true,
|
||
): ReactFlowProjectPayload {
|
||
const contactGeometry = calculateContactGeometry
|
||
? buildNodePortGeometryIndex(nodes)
|
||
: null;
|
||
return {
|
||
projectSchemaVersion: 1,
|
||
name: name.trim() || "untitled",
|
||
nodes: nodes.map((node) => ({
|
||
id: node.id,
|
||
type: "simulationComponent",
|
||
position: {
|
||
x: node.position.x,
|
||
y: node.position.y,
|
||
},
|
||
data: {
|
||
label: node.data.label,
|
||
componentType: node.data.componentType,
|
||
modelType: node.data.modelType,
|
||
modelVersion: node.data.modelVersion,
|
||
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) => {
|
||
if (
|
||
typeof edge.sourceHandle !== "string" ||
|
||
typeof edge.targetHandle !== "string"
|
||
) {
|
||
throw new Error(`连接 ${edge.id} 缺少明确的接口标识`);
|
||
}
|
||
const routePoints = routePointsForEdge(edge);
|
||
return {
|
||
id: edge.id,
|
||
source: edge.source,
|
||
target: edge.target,
|
||
sourceHandle: edge.sourceHandle,
|
||
targetHandle: edge.targetHandle,
|
||
data: {
|
||
isContactEdge: contactGeometry
|
||
? isContactEdgeGeometryInIndex(contactGeometry, edge)
|
||
: Boolean(edge.data?.isContactEdge),
|
||
...(routePoints.length > 0
|
||
? {
|
||
routePoints: routePoints.map((point) => ({
|
||
...point,
|
||
})),
|
||
}
|
||
: {}),
|
||
},
|
||
};
|
||
}),
|
||
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>,
|
||
) {
|
||
// 只有一个固定选项的参数不是用户可配置项,不在右侧参数表制造无效行。
|
||
if (definition.editor === "choice" && definition.options?.length === 1) {
|
||
return false;
|
||
}
|
||
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;
|
||
}
|
||
|
||
/** 升级本次合同变更前保存的 AMESim 机械元件,保留其可执行语义。 */
|
||
function migrateLegacyAmesimComponents(
|
||
project: ReactFlowProjectPayload,
|
||
componentDefinitions: ComponentDefinition[],
|
||
) {
|
||
const lmechn1Definition = componentDefinitions.find(
|
||
(definition) =>
|
||
definition.type === "amesim_lmechn1" &&
|
||
definition.modelType === "amesim_lmechn1",
|
||
);
|
||
const forcDefinition = componentDefinitions.find(
|
||
(definition) =>
|
||
definition.type === "amesim_forc" &&
|
||
definition.modelType === "amesim_forc",
|
||
);
|
||
|
||
const referencePortByNodeId = new Map<string, string>();
|
||
let migratedForcNodeCount = 0;
|
||
const nodes = project.nodes.map((node) => {
|
||
const isLegacyLmechn1 =
|
||
node.data.componentType === "amesim_lmechn1" &&
|
||
node.data.modelType === "amesim_lmechn1" &&
|
||
node.data.modelVersion === "0.1.0";
|
||
if (
|
||
isLegacyLmechn1 &&
|
||
lmechn1Definition &&
|
||
lmechn1Definition.modelVersion !== "0.1.0"
|
||
) {
|
||
const rightPortCount = Number(node.data.parameters.v1);
|
||
if (
|
||
Number.isInteger(rightPortCount) &&
|
||
rightPortCount >= 1 &&
|
||
rightPortCount <= 8
|
||
) {
|
||
referencePortByNodeId.set(node.id, `port_${rightPortCount + 1}`);
|
||
return {
|
||
...node,
|
||
data: {
|
||
...node.data,
|
||
modelVersion: lmechn1Definition.modelVersion,
|
||
// 旧 sum 数值没有参与方程;新合同仅公开实际支持的标准节点模式。
|
||
parameters: { ...node.data.parameters, sum: 1 },
|
||
},
|
||
};
|
||
}
|
||
}
|
||
|
||
const isLegacyForc =
|
||
node.data.componentType === "amesim_forc" &&
|
||
node.data.modelType === "amesim_forc" &&
|
||
node.data.modelVersion === "0.1.0";
|
||
if (
|
||
isLegacyForc &&
|
||
forcDefinition &&
|
||
forcDefinition.modelVersion !== "0.1.0"
|
||
) {
|
||
migratedForcNodeCount += 1;
|
||
return {
|
||
...node,
|
||
data: {
|
||
...node.data,
|
||
modelVersion: forcDefinition.modelVersion,
|
||
parameters: {
|
||
...node.data.parameters,
|
||
direction: node.data.parameters.direction ?? 1,
|
||
},
|
||
},
|
||
};
|
||
}
|
||
return node;
|
||
});
|
||
|
||
const edges = project.edges.map((edge) => {
|
||
const sourceReferencePort = referencePortByNodeId.get(edge.source);
|
||
const targetReferencePort = referencePortByNodeId.get(edge.target);
|
||
return {
|
||
...edge,
|
||
sourceHandle:
|
||
sourceReferencePort && edge.sourceHandle === "port_9"
|
||
? sourceReferencePort
|
||
: edge.sourceHandle,
|
||
targetHandle:
|
||
targetReferencePort && edge.targetHandle === "port_9"
|
||
? targetReferencePort
|
||
: edge.targetHandle,
|
||
};
|
||
});
|
||
|
||
return {
|
||
project: { ...project, nodes, edges },
|
||
migratedLmechn1NodeCount: referencePortByNodeId.size,
|
||
migratedForcNodeCount,
|
||
};
|
||
}
|
||
|
||
function normalizeLoadedNode(
|
||
node: ProjectNodePayload,
|
||
componentDefinitions: ComponentDefinition[],
|
||
): SimulationNode {
|
||
const definition =
|
||
componentDefinitions.find((item) => item.type === node.data.componentType) ??
|
||
componentDefinitions.find((item) => item.modelType === node.data.modelType);
|
||
const rawParameters = node.data.parameters;
|
||
const modelContractCompatible = Boolean(
|
||
definition &&
|
||
node.data.componentType === definition.type &&
|
||
node.data.modelType === definition.modelType &&
|
||
node.data.modelVersion === definition.modelVersion,
|
||
);
|
||
const parameters = definition && modelContractCompatible
|
||
? { ...defaultParameters(definition), ...rawParameters }
|
||
: { ...rawParameters };
|
||
if (
|
||
modelContractCompatible &&
|
||
isAmesimGasMediumDefinition(definition) &&
|
||
!Object.prototype.hasOwnProperty.call(rawParameters, "gi")
|
||
) {
|
||
parameters.gi = "";
|
||
}
|
||
const parameterUnits = definition && modelContractCompatible
|
||
? { ...defaultParameterUnits(definition), ...(node.data.parameterUnits ?? {}) }
|
||
: { ...(node.data.parameterUnits ?? {}) };
|
||
const parameterScientificNotation = normalizeParameterScientificNotation(
|
||
node.data.parameterScientificNotation,
|
||
);
|
||
const modelContractIssues = loadedNodeModelContractIssues(
|
||
node,
|
||
definition,
|
||
);
|
||
|
||
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,
|
||
modelVersion: node.data.modelVersion,
|
||
...(modelContractIssues.length > 0 ? { modelContractIssues } : {}),
|
||
// 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: PortDefinition[],
|
||
definition?: ComponentDefinition,
|
||
): PortDefinition[] {
|
||
const loadedPorts = rawPorts.map((port) => ({ ...port }));
|
||
if (!definition) {
|
||
return loadedPorts;
|
||
}
|
||
|
||
// 工程端口只是历史快照。端口名称及 kind/domain/role/flow direction
|
||
// 都属于当前模型版本的目录合同,不能被旧工程覆盖或凭位置猜测。
|
||
return definition.ports.map((port) => ({ ...port }));
|
||
}
|
||
|
||
function loadedNodeModelContractIssues(
|
||
node: ProjectNodePayload,
|
||
definition: ComponentDefinition | undefined,
|
||
) {
|
||
const label = node.data.label || node.id;
|
||
const issues: string[] = [];
|
||
if (!definition) {
|
||
issues.push(`组件“${label}”找不到当前组件目录定义`);
|
||
return issues;
|
||
}
|
||
if (
|
||
node.data.componentType !== definition.type ||
|
||
node.data.modelType !== definition.modelType
|
||
) {
|
||
issues.push(
|
||
`组件“${label}”的工程类型与当前目录不一致(工程 ${node.data.componentType}/${node.data.modelType},目录 ${definition.type}/${definition.modelType})`,
|
||
);
|
||
}
|
||
if (!node.data.modelVersion) {
|
||
issues.push(`组件“${label}”缺少 modelVersion,无法确认方程和参数含义`);
|
||
} else if (node.data.modelVersion !== definition.modelVersion) {
|
||
issues.push(
|
||
`组件“${label}”模型版本不匹配(工程 ${node.data.modelVersion},当前 ${definition.modelVersion})`,
|
||
);
|
||
}
|
||
|
||
return issues;
|
||
}
|
||
|
||
function isPortNominalRole(value: unknown): value is PortNominalRole {
|
||
return ["inlet", "outlet", "bidirectional", "input", "output"].includes(
|
||
String(value),
|
||
);
|
||
}
|
||
|
||
function findNodePort(
|
||
nodes: SimulationNode[],
|
||
componentId: string | null,
|
||
portName: string | null,
|
||
) {
|
||
if (!componentId || !portName) {
|
||
return undefined;
|
||
}
|
||
const node = nodes.find((candidate) => candidate.id === componentId);
|
||
return node
|
||
? displayedPortsForNodeData(node.data).find((port) => port.name === portName)
|
||
: undefined;
|
||
}
|
||
|
||
function portConnectionKey(nodeId: string, portName: string) {
|
||
return `${nodeId}:${portName}`;
|
||
}
|
||
|
||
function isPortConnected(
|
||
edges: SimulationEdge[],
|
||
nodeId: string,
|
||
portName: string,
|
||
) {
|
||
return edges.some(
|
||
(edge) =>
|
||
(edge.source === nodeId && edge.sourceHandle === portName) ||
|
||
(edge.target === nodeId && edge.targetHandle === portName),
|
||
);
|
||
}
|
||
|
||
function portAcceptsAnotherConnection(
|
||
edges: SimulationEdge[],
|
||
nodeId: string,
|
||
port: PortDefinition,
|
||
) {
|
||
return !isPortConnected(edges, nodeId, port.name);
|
||
}
|
||
|
||
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;
|
||
}
|
||
return (
|
||
portAcceptsAnotherConnection(edges, connection.source, first) &&
|
||
portAcceptsAnotherConnection(edges, connection.target, second)
|
||
);
|
||
}
|
||
|
||
function connectionIdentity(edge: SimulationEdge, unordered: boolean) {
|
||
const endpoints = [
|
||
`${edge.source}:${edge.sourceHandle ?? ""}`,
|
||
`${edge.target}:${edge.targetHandle ?? ""}`,
|
||
];
|
||
if (unordered) {
|
||
endpoints.sort();
|
||
}
|
||
return endpoints.join(unordered ? "--" : "->");
|
||
}
|
||
|
||
function loadProjectEdges(edges: ProjectEdgePayload[], nodes: SimulationNode[]) {
|
||
const loadedEdges: SimulationEdge[] = [];
|
||
const droppedEdges: Array<{ id: string; reason: string }> = [];
|
||
const nodeById = new Map(nodes.map((node) => [node.id, node] as const));
|
||
const portByNodeId = new Map(
|
||
nodes.map((node) => [
|
||
node.id,
|
||
new Map(
|
||
displayedPortsForNodeData(node.data).map((port) => [port.name, port] as const),
|
||
),
|
||
] as const),
|
||
);
|
||
const occupiedPortKeys = new Set<string>();
|
||
edges.forEach((edge) => {
|
||
const routePoints = Array.isArray(edge.data.routePoints)
|
||
? edge.data.routePoints.map((point) => ({ ...point }))
|
||
: undefined;
|
||
const candidate: SimulationEdge = {
|
||
...edge,
|
||
data: {
|
||
...edge.data,
|
||
...(routePoints && routePoints.length > 0 ? { routePoints } : {}),
|
||
},
|
||
type: CONTACT_AWARE_EDGE_TYPE,
|
||
};
|
||
const reason = loadedEdgeContractIssue(
|
||
nodeById,
|
||
portByNodeId,
|
||
occupiedPortKeys,
|
||
candidate,
|
||
);
|
||
if (reason) {
|
||
droppedEdges.push({ id: edge.id, reason });
|
||
return;
|
||
}
|
||
loadedEdges.push(candidate);
|
||
occupiedPortKeys.add(
|
||
portConnectionKey(candidate.source, candidate.sourceHandle ?? ""),
|
||
);
|
||
occupiedPortKeys.add(
|
||
portConnectionKey(candidate.target, candidate.targetHandle ?? ""),
|
||
);
|
||
});
|
||
return { edges: loadedEdges, droppedEdges };
|
||
}
|
||
|
||
function projectContentSignature(
|
||
name: string,
|
||
nodes: SimulationNode[],
|
||
edges: SimulationEdge[],
|
||
simulation: SimulationConfig,
|
||
) {
|
||
// 选择、拖动和参数编辑需要快速判断“模型检查结果是否仍有效”。
|
||
// 接触边状态可由节点位置和接口信息推导,不应为生成签名再次执行整图几何计算。
|
||
return JSON.stringify(
|
||
buildProjectPayload(name, nodes, edges, simulation, false),
|
||
);
|
||
}
|
||
|
||
function modelValidationSignature(
|
||
nodes: SimulationNode[],
|
||
edges: SimulationEdge[],
|
||
simulation: SimulationConfig,
|
||
) {
|
||
// 画布坐标、旋转、镜像和科学计数法展示格式不参与模型有效性判断。
|
||
// 将它们排除后,移动/选择元件不会让检查结果失效,也不会序列化无关展示状态。
|
||
return JSON.stringify({
|
||
nodes: nodes.map((node) => ({
|
||
id: node.id,
|
||
label: node.data.label,
|
||
componentType: node.data.componentType,
|
||
modelType: node.data.modelType,
|
||
modelVersion: node.data.modelVersion,
|
||
modelContractIssues: node.data.modelContractIssues,
|
||
ports: node.data.ports,
|
||
parameters: node.data.parameters,
|
||
parameterUnits: node.data.parameterUnits,
|
||
})),
|
||
edges: edges.map((edge) => ({
|
||
id: edge.id,
|
||
source: edge.source,
|
||
sourceHandle: edge.sourceHandle,
|
||
target: edge.target,
|
||
targetHandle: edge.targetHandle,
|
||
})),
|
||
simulation,
|
||
});
|
||
}
|
||
|
||
function loadedEdgeContractIssue(
|
||
nodeById: ReadonlyMap<string, SimulationNode>,
|
||
portByNodeId: ReadonlyMap<string, ReadonlyMap<string, PortDefinition>>,
|
||
occupiedPortKeys: ReadonlySet<string>,
|
||
edge: SimulationEdge,
|
||
) {
|
||
if (edge.source === edge.target) {
|
||
return "不允许连接同一组件";
|
||
}
|
||
const sourceNode = nodeById.get(edge.source);
|
||
const targetNode = nodeById.get(edge.target);
|
||
if (!sourceNode || !targetNode) {
|
||
return "引用了不存在的组件";
|
||
}
|
||
const sourcePort = portByNodeId.get(sourceNode.id)?.get(edge.sourceHandle ?? "");
|
||
const targetPort = portByNodeId.get(targetNode.id)?.get(edge.targetHandle ?? "");
|
||
if (!sourcePort || !targetPort) {
|
||
const endpoints = [
|
||
!sourcePort ? `${sourceNode.data.label}.${edge.sourceHandle ?? "?"}` : null,
|
||
!targetPort ? `${targetNode.data.label}.${edge.targetHandle ?? "?"}` : null,
|
||
].filter(Boolean);
|
||
return `端口 ${endpoints.join("、")} 不在当前组件目录中(可能已删除或改名)`;
|
||
}
|
||
if (!arePortsCompatible(sourcePort, targetPort)) {
|
||
return "当前目录中的端口类型、物理域或信号方向不兼容";
|
||
}
|
||
if (
|
||
occupiedPortKeys.has(portConnectionKey(sourceNode.id, sourcePort.name)) ||
|
||
occupiedPortKeys.has(portConnectionKey(targetNode.id, targetPort.name))
|
||
) {
|
||
return "当前目录合同不允许端口被多条连接占用";
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function takeNextNodeId(
|
||
componentType: string,
|
||
reservedNodeIds: ReadonlySet<string> = new Set(),
|
||
) {
|
||
let nextNumber = nextNodeNumberByType[componentType] ?? 1;
|
||
let candidate = `${componentType}_${nextNumber}`;
|
||
while (reservedNodeIds.has(candidate)) {
|
||
nextNumber += 1;
|
||
candidate = `${componentType}_${nextNumber}`;
|
||
}
|
||
nextNodeNumberByType[componentType] = nextNumber + 1;
|
||
return candidate;
|
||
}
|
||
|
||
function takeNextCopiedEdgeId(index: number, reservedEdgeIds: ReadonlySet<string>) {
|
||
const baseId = `edge-copy-${Date.now()}-${index}`;
|
||
let candidate = baseId;
|
||
let suffix = 1;
|
||
while (reservedEdgeIds.has(candidate)) {
|
||
candidate = `${baseId}-${suffix}`;
|
||
suffix += 1;
|
||
}
|
||
return candidate;
|
||
}
|
||
|
||
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 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[],
|
||
componentType?: string,
|
||
parameterName?: string,
|
||
) {
|
||
if (gasMediumOptions !== undefined) {
|
||
const gasReferenceIndex = normalizeAmesimGasIndex(value);
|
||
if (
|
||
gasReferenceIndex === null ||
|
||
!gasMediumOptions.some((option) => option.value === gasReferenceIndex)
|
||
) {
|
||
return "引用的介质定义不存在";
|
||
}
|
||
}
|
||
if (componentType === "amesim_lmechn1" && parameterName === "v1") {
|
||
const resolved = resolveParameterValue(value, definition, selectedUnit);
|
||
if (resolved.ok) {
|
||
if (resolved.value > LMECHN1_MAX_RIGHT_PORT_COUNT) {
|
||
return "右侧端口数量不能超过20";
|
||
}
|
||
if (resolved.value < 1) {
|
||
return "右侧端口数量不能小于1";
|
||
}
|
||
if (!Number.isInteger(resolved.value)) {
|
||
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;
|
||
}
|
||
const modelContractIssues = new Set(node.data.modelContractIssues ?? []);
|
||
if (
|
||
node.data.componentType !== definition.type ||
|
||
node.data.modelType !== definition.modelType
|
||
) {
|
||
modelContractIssues.add(
|
||
`${node.data.label}:工程组件类型与当前目录定义不一致`,
|
||
);
|
||
}
|
||
if (!node.data.modelVersion) {
|
||
modelContractIssues.add(
|
||
`${node.data.label}:缺少 modelVersion,无法确认方程和参数含义`,
|
||
);
|
||
} else if (node.data.modelVersion !== definition.modelVersion) {
|
||
modelContractIssues.add(
|
||
`${node.data.label}:模型版本不匹配(工程 ${node.data.modelVersion},当前 ${definition.modelVersion})`,
|
||
);
|
||
}
|
||
if (definition.modelType === "amesim_mecmas21") {
|
||
(["useFriction", "strib"] as const).forEach((name) => {
|
||
const value = numericChoiceValue(node.data.parameters[name]);
|
||
if (value !== 1 && value !== 2) {
|
||
modelContractIssues.add(
|
||
`${node.data.label}:${name}=${String(node.data.parameters[name])} 不是统一的 AMESim 1/2 编码`,
|
||
);
|
||
}
|
||
});
|
||
}
|
||
modelContractIssues.forEach((message) =>
|
||
addIssue("error", message, { nodeId: node.id }),
|
||
);
|
||
Object.entries(definition.parameters).forEach(([key, parameter]) => {
|
||
const selectedUnit =
|
||
node.data.parameterUnits[key] ?? parameter.unit ?? "";
|
||
const message = componentParameterValidationMessage(
|
||
parameter,
|
||
node.data.parameters[key] ?? "",
|
||
selectedUnit,
|
||
undefined,
|
||
definition.type,
|
||
key,
|
||
);
|
||
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 },
|
||
);
|
||
}
|
||
});
|
||
}
|
||
displayedPortsForNodeData(node.data).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 = findNodePort(nodes, source.id, edge.sourceHandle ?? null);
|
||
const targetPort = findNodePort(nodes, target.id, edge.targetHandle ?? null);
|
||
if (!sourcePort) {
|
||
addIssue("error", `连接 ${edge.id} 的源端口无效`, {
|
||
edgeId: edge.id,
|
||
nodeId: source.id,
|
||
});
|
||
}
|
||
if (!targetPort) {
|
||
addIssue("error", `连接 ${edge.id} 的目标端口无效`, {
|
||
edgeId: edge.id,
|
||
nodeId: target.id,
|
||
});
|
||
}
|
||
if (sourcePort && targetPort && !arePortsCompatible(sourcePort, targetPort)) {
|
||
addIssue("error", `连接 ${edge.id} 的端口类型或物理域不兼容`, { edgeId: edge.id });
|
||
}
|
||
const key = connectionIdentity(edge, sourcePort?.kind === "physical");
|
||
if (connectionKeys.has(key)) {
|
||
addIssue("warning", "检测到重复连接", { edgeId: edge.id });
|
||
}
|
||
connectionKeys.add(key);
|
||
});
|
||
|
||
const pneumaticAdjacency = new Map<string, Set<string>>();
|
||
nodes.forEach((node) => {
|
||
if (
|
||
node.data.ports.some(
|
||
(port) => port.kind === "physical" && port.domain === "pneumatic",
|
||
)
|
||
) {
|
||
pneumaticAdjacency.set(node.id, new Set());
|
||
}
|
||
});
|
||
edges.forEach((edge) => {
|
||
const source = nodeMap.get(edge.source);
|
||
const target = nodeMap.get(edge.target);
|
||
const sourcePort = source?.data.ports.find(
|
||
(port) => port.name === edge.sourceHandle,
|
||
);
|
||
const targetPort = target?.data.ports.find(
|
||
(port) => port.name === edge.targetHandle,
|
||
);
|
||
if (
|
||
!source ||
|
||
!target ||
|
||
sourcePort?.kind !== "physical" ||
|
||
targetPort?.kind !== "physical" ||
|
||
sourcePort.domain !== "pneumatic" ||
|
||
targetPort.domain !== "pneumatic"
|
||
) {
|
||
return;
|
||
}
|
||
pneumaticAdjacency.get(source.id)?.add(target.id);
|
||
pneumaticAdjacency.get(target.id)?.add(source.id);
|
||
});
|
||
|
||
const visitedPneumaticNodes = new Set<string>();
|
||
pneumaticAdjacency.forEach((_, startNodeId) => {
|
||
if (visitedPneumaticNodes.has(startNodeId)) {
|
||
return;
|
||
}
|
||
const pendingNodeIds = [startNodeId];
|
||
const connectedNodeIds: string[] = [];
|
||
while (pendingNodeIds.length > 0) {
|
||
const nodeId = pendingNodeIds.pop() as string;
|
||
if (visitedPneumaticNodes.has(nodeId)) {
|
||
continue;
|
||
}
|
||
visitedPneumaticNodes.add(nodeId);
|
||
connectedNodeIds.push(nodeId);
|
||
pneumaticAdjacency.get(nodeId)?.forEach((neighborId) => {
|
||
if (!visitedPneumaticNodes.has(neighborId)) {
|
||
pendingNodeIds.push(neighborId);
|
||
}
|
||
});
|
||
}
|
||
|
||
const referencedIndices = new Set<number>();
|
||
const referencingNodes = new Map<string, SimulationNode>();
|
||
connectedNodeIds.forEach((nodeId) => {
|
||
const node = nodeMap.get(nodeId);
|
||
if (!node) {
|
||
return;
|
||
}
|
||
const definition = componentDefinitionForNode(node, componentDefinitions);
|
||
if (!definition || isAmesimGasMediumDefinition(definition)) {
|
||
return;
|
||
}
|
||
Object.entries(definition.parameters).forEach(([key, parameter]) => {
|
||
if (parameter.editor !== "amesimGasReference") {
|
||
return;
|
||
}
|
||
const index = normalizeAmesimGasIndex(node.data.parameters[key]);
|
||
if (index === null) {
|
||
return;
|
||
}
|
||
referencedIndices.add(index);
|
||
referencingNodes.set(node.id, node);
|
||
});
|
||
});
|
||
if (referencedIndices.size <= 1) {
|
||
return;
|
||
}
|
||
const indexList = [...referencedIndices]
|
||
.sort((first, second) => first - second)
|
||
.join("、");
|
||
referencingNodes.forEach((node) => {
|
||
addIssue(
|
||
"error",
|
||
`${node.data.label}:同一气路引用了不同的介质物性索引(${indexList})`,
|
||
{ nodeId: node.id },
|
||
);
|
||
});
|
||
});
|
||
|
||
const simulationErrors = simulationConfigValidationMessages(simulation);
|
||
(Object.keys(simulationErrors) as SimulationNumericKey[]).forEach((key) => {
|
||
const message = simulationErrors[key];
|
||
if (message) {
|
||
addIssue("error", `仿真设置:${simulationConfigLabels[key]}:${message}`);
|
||
}
|
||
});
|
||
if (!SUPPORTED_SOLVER_METHODS.includes(simulation.method)) {
|
||
addIssue("error", "求解器不受支持");
|
||
}
|
||
|
||
return issues;
|
||
}
|
||
|
||
function workspaceViewFromLocation(): WorkspaceView {
|
||
return window.location.hash === "#/results" ? "results" : "modeling";
|
||
}
|
||
|
||
|
||
function loadStoredResultSnapshot(): SimulationResultsSnapshot | null {
|
||
try {
|
||
const raw = sessionStorage.getItem(RESULT_SNAPSHOT_KEY);
|
||
if (!raw) {
|
||
return null;
|
||
}
|
||
const value = JSON.parse(raw) as unknown;
|
||
if (!isSimulationResultsSnapshot(value)) {
|
||
sessionStorage.removeItem(RESULT_SNAPSHOT_KEY);
|
||
return null;
|
||
}
|
||
return value;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function storeResultSnapshot(snapshot: SimulationResultsSnapshot) {
|
||
try {
|
||
sessionStorage.setItem(RESULT_SNAPSHOT_KEY, JSON.stringify(snapshot));
|
||
} catch {
|
||
// Large result sets remain available in memory for the current page.
|
||
}
|
||
}
|
||
|
||
function isSimulationResultsSnapshot(
|
||
value: unknown,
|
||
): value is SimulationResultsSnapshot {
|
||
if (!value || typeof value !== "object") {
|
||
return false;
|
||
}
|
||
const snapshot = value as Partial<SimulationResultsSnapshot>;
|
||
return (
|
||
typeof snapshot.id === "string" &&
|
||
typeof snapshot.createdAt === "string" &&
|
||
Boolean(snapshot.project) &&
|
||
snapshot.project?.projectSchemaVersion === 1 &&
|
||
typeof snapshot.project?.name === "string" &&
|
||
Array.isArray(snapshot.project?.nodes) &&
|
||
Array.isArray(snapshot.project?.edges) &&
|
||
Boolean(snapshot.result) &&
|
||
Array.isArray(snapshot.result?.variables) &&
|
||
Boolean(snapshot.result?.series) &&
|
||
Array.isArray(snapshot.result?.series?.time)
|
||
);
|
||
}
|
||
|
||
function cloneValue<T>(value: T): T {
|
||
return structuredClone(value);
|
||
}
|
||
|
||
function snapshotSignature(snapshot: EditorSnapshot) {
|
||
return (
|
||
snapshot.signature ||
|
||
projectContentSignature(
|
||
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 isProjectRecord(value: unknown): value is Record<string, unknown> {
|
||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||
}
|
||
|
||
function isParameterValueRecord(value: unknown) {
|
||
return (
|
||
isProjectRecord(value) &&
|
||
Object.values(value).every(
|
||
(entry) => typeof entry === "string" || typeof entry === "number",
|
||
)
|
||
);
|
||
}
|
||
|
||
function isStringRecord(value: unknown) {
|
||
return (
|
||
isProjectRecord(value) &&
|
||
Object.values(value).every((entry) => typeof entry === "string")
|
||
);
|
||
}
|
||
|
||
function isProjectPortDefinitionValue(value: unknown): value is PortDefinition {
|
||
if (!isProjectRecord(value)) {
|
||
return false;
|
||
}
|
||
return (
|
||
typeof value.name === "string" &&
|
||
value.name.length > 0 &&
|
||
(value.kind === "physical" || value.kind === "signal") &&
|
||
typeof value.domain === "string" &&
|
||
value.domain.length > 0 &&
|
||
isPortNominalRole(value.nominalRole) &&
|
||
(value.positiveFlowDirection === undefined ||
|
||
value.positiveFlowDirection === "intoComponent") &&
|
||
(value.side === "left" || value.side === "right")
|
||
);
|
||
}
|
||
|
||
function isProjectNodePayloadValue(value: unknown): value is ProjectNodePayload {
|
||
if (
|
||
!isProjectRecord(value) ||
|
||
!isProjectRecord(value.position) ||
|
||
!isProjectRecord(value.data)
|
||
) {
|
||
return false;
|
||
}
|
||
const data = value.data;
|
||
if (
|
||
typeof value.id !== "string" ||
|
||
value.id.length === 0 ||
|
||
value.type !== "simulationComponent" ||
|
||
!Number.isFinite(value.position.x) ||
|
||
!Number.isFinite(value.position.y) ||
|
||
typeof data.label !== "string" ||
|
||
typeof data.componentType !== "string" ||
|
||
data.componentType.length === 0 ||
|
||
typeof data.modelType !== "string" ||
|
||
data.modelType.length === 0 ||
|
||
(data.modelVersion !== undefined && typeof data.modelVersion !== "string") ||
|
||
!Array.isArray(data.ports) ||
|
||
!data.ports.every(isProjectPortDefinitionValue) ||
|
||
!isParameterValueRecord(data.parameters) ||
|
||
(data.parameterUnits !== undefined && !isStringRecord(data.parameterUnits)) ||
|
||
(data.parameterScientificNotation !== undefined &&
|
||
!isProjectRecord(data.parameterScientificNotation)) ||
|
||
![0, 90, 180, 270].includes(Number(data.rotation)) ||
|
||
typeof data.mirrored !== "boolean"
|
||
) {
|
||
return false;
|
||
}
|
||
const portNames = data.ports.map((port) => port.name);
|
||
return new Set(portNames).size === portNames.length;
|
||
}
|
||
|
||
function isProjectEdgePayloadValue(value: unknown): value is ProjectEdgePayload {
|
||
if (!(
|
||
isProjectRecord(value) &&
|
||
typeof value.id === "string" &&
|
||
value.id.length > 0 &&
|
||
typeof value.source === "string" &&
|
||
value.source.length > 0 &&
|
||
typeof value.target === "string" &&
|
||
value.target.length > 0 &&
|
||
typeof value.sourceHandle === "string" &&
|
||
value.sourceHandle.length > 0 &&
|
||
typeof value.targetHandle === "string" &&
|
||
value.targetHandle.length > 0 &&
|
||
isProjectRecord(value.data) &&
|
||
typeof value.data.isContactEdge === "boolean"
|
||
)) {
|
||
return false;
|
||
}
|
||
const routePoints = value.data.routePoints;
|
||
return (
|
||
routePoints === undefined ||
|
||
(Array.isArray(routePoints) &&
|
||
routePoints.every(
|
||
(point) =>
|
||
isProjectRecord(point) &&
|
||
typeof point.x === "number" &&
|
||
Number.isFinite(point.x) &&
|
||
typeof point.y === "number" &&
|
||
Number.isFinite(point.y),
|
||
))
|
||
);
|
||
}
|
||
|
||
function isSimulationConfigValue(value: unknown): value is SimulationConfig {
|
||
if (!isProjectRecord(value) || typeof value.method !== "string") {
|
||
return false;
|
||
}
|
||
return [value.t_start, value.t_stop, value.step, value.max_step].every(
|
||
(entry) => typeof entry === "number" || typeof entry === "string",
|
||
);
|
||
}
|
||
|
||
function parseProjectPayload(raw: string | null): ReactFlowProjectPayload | null {
|
||
if (!raw) {
|
||
return null;
|
||
}
|
||
const value = JSON.parse(raw) as unknown;
|
||
if (!isProjectRecord(value)) {
|
||
return null;
|
||
}
|
||
if (
|
||
value.projectSchemaVersion !== 1 ||
|
||
typeof value.name !== "string" ||
|
||
!Array.isArray(value.nodes) ||
|
||
!value.nodes.every(isProjectNodePayloadValue) ||
|
||
!Array.isArray(value.edges) ||
|
||
!value.edges.every(isProjectEdgePayloadValue) ||
|
||
!isSimulationConfigValue(value.simulation)
|
||
) {
|
||
return null;
|
||
}
|
||
const project = value as ReactFlowProjectPayload;
|
||
const nodeIds = project.nodes.map((node) => node.id);
|
||
const edgeIds = project.edges.map((edge) => edge.id);
|
||
if (
|
||
new Set(nodeIds).size !== nodeIds.length ||
|
||
new Set(edgeIds).size !== edgeIds.length
|
||
) {
|
||
return null;
|
||
}
|
||
// 连接是否仍符合当前目录合同只能在目录加载后判断。解析阶段保留它们,
|
||
// applyProject 会按当前端口定义逐条丢弃失效连接并向用户报告原因。
|
||
return project;
|
||
}
|
||
|
||
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 nodeIsInsideElement(node: Node | null, element: Element) {
|
||
if (!node) {
|
||
return false;
|
||
}
|
||
return element.contains(node.nodeType === Node.TEXT_NODE ? node.parentElement : node);
|
||
}
|
||
|
||
function shouldPreserveNativeConsoleShortcut(
|
||
event: KeyboardEvent,
|
||
commandKey: boolean,
|
||
key: string,
|
||
) {
|
||
const targetInsideConsole =
|
||
event.target instanceof Element &&
|
||
Boolean(event.target.closest("[data-editor-shortcuts-ignore='true']"));
|
||
if (targetInsideConsole) {
|
||
return true;
|
||
}
|
||
if (!commandKey || key !== "c") {
|
||
return false;
|
||
}
|
||
const selection = window.getSelection();
|
||
if (!selection || selection.isCollapsed || selection.rangeCount === 0) {
|
||
return false;
|
||
}
|
||
const consoleElement = document.querySelector(
|
||
"[data-editor-shortcuts-ignore='true']",
|
||
);
|
||
if (!consoleElement) {
|
||
return false;
|
||
}
|
||
return (
|
||
nodeIsInsideElement(selection.anchorNode, consoleElement) ||
|
||
nodeIsInsideElement(selection.focusNode, consoleElement) ||
|
||
nodeIsInsideElement(
|
||
selection.getRangeAt(0).commonAncestorContainer,
|
||
consoleElement,
|
||
)
|
||
);
|
||
}
|
||
|
||
function formatTime(date: Date) {
|
||
return date.toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit" });
|
||
}
|
||
|
||
function projectExecutionContractIssues(
|
||
project: ReactFlowProjectPayload,
|
||
componentDefinitions: ComponentDefinition[],
|
||
sourceNodes: SimulationNode[] = [],
|
||
) {
|
||
const issues = new Set(
|
||
sourceNodes.flatMap((node) => node.data.modelContractIssues ?? []),
|
||
);
|
||
project.nodes.forEach((node) => {
|
||
const label = node.data.label || node.id;
|
||
const definition = componentDefinitions.find(
|
||
(candidate) => candidate.type === node.data.componentType,
|
||
);
|
||
if (!definition) {
|
||
issues.add(`组件“${label}”找不到当前组件目录定义`);
|
||
return;
|
||
}
|
||
if (node.data.modelType !== definition.modelType) {
|
||
issues.add(
|
||
`组件“${label}”模型类型不匹配(工程 ${node.data.modelType},当前 ${definition.modelType})`,
|
||
);
|
||
}
|
||
if (!node.data.modelVersion) {
|
||
issues.add(`组件“${label}”缺少 modelVersion,无法确认方程和参数含义`);
|
||
} else if (node.data.modelVersion !== definition.modelVersion) {
|
||
issues.add(
|
||
`组件“${label}”模型版本不匹配(工程 ${node.data.modelVersion},当前 ${definition.modelVersion})`,
|
||
);
|
||
}
|
||
if (definition.modelType !== "amesim_mecmas21") {
|
||
return;
|
||
}
|
||
(["useFriction", "strib"] as const).forEach((name) => {
|
||
const value = numericChoiceValue(node.data.parameters[name]);
|
||
if (value !== 1 && value !== 2) {
|
||
issues.add(
|
||
`组件“${label}”的 ${name}=${String(node.data.parameters[name])} 不是统一的 AMESim 1/2 编码`,
|
||
);
|
||
}
|
||
});
|
||
});
|
||
return [...issues];
|
||
}
|
||
|
||
function buildSystemXml(
|
||
project: ReactFlowProjectPayload,
|
||
componentDefinitions: ComponentDefinition[],
|
||
sourceNodes: SimulationNode[] = [],
|
||
) {
|
||
const contractIssues = projectExecutionContractIssues(
|
||
project,
|
||
componentDefinitions,
|
||
sourceNodes,
|
||
);
|
||
if (contractIssues.length > 0) {
|
||
throw new Error(
|
||
`工程包含不兼容的模型合同,不能生成执行 XML:\n- ${contractIssues.join("\n- ")}`,
|
||
);
|
||
}
|
||
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;
|
||
if (project.name.trim()) {
|
||
system.setAttribute("name", project.name.trim());
|
||
}
|
||
system.setAttribute("schemaVersion", "3");
|
||
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("sampleStep", 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");
|
||
const registeredPortsByNodeId = new Map<string, PortDefinition[]>();
|
||
const exportedEdgeIds = new Set<string>();
|
||
system.append(simulation, components, connections);
|
||
|
||
project.nodes.forEach((node) => {
|
||
if (registeredPortsByNodeId.has(node.id)) {
|
||
throw new Error(`工程包含重复的组件 ID:${node.id}`);
|
||
}
|
||
const definitionByComponentType = componentDefinitions.find(
|
||
(candidate) => candidate.type === node.data.componentType,
|
||
);
|
||
const definitionByModelType = componentDefinitions.find(
|
||
(candidate) => candidate.modelType === node.data.modelType,
|
||
);
|
||
const definition = definitionByComponentType ?? definitionByModelType;
|
||
if (!definition) {
|
||
throw new Error(
|
||
`组件“${node.data.label || node.id}”找不到目录定义,无法确定模型版本`,
|
||
);
|
||
}
|
||
if (
|
||
definition.type !== node.data.componentType ||
|
||
definition.modelType !== node.data.modelType
|
||
) {
|
||
throw new Error(
|
||
`组件“${node.data.label || node.id}”的工程类型与目录定义不一致:` +
|
||
`componentType=${node.data.componentType},modelType=${node.data.modelType}`,
|
||
);
|
||
}
|
||
registeredPortsByNodeId.set(
|
||
node.id,
|
||
displayedPortsForNodeData({
|
||
componentType: definition.type,
|
||
modelType: definition.modelType,
|
||
ports: definition.ports,
|
||
parameters: node.data.parameters,
|
||
}),
|
||
);
|
||
const unknownParameters = Object.keys(node.data.parameters).filter(
|
||
(name) => !Object.prototype.hasOwnProperty.call(definition.parameters, name),
|
||
);
|
||
if (unknownParameters.length > 0) {
|
||
throw new Error(
|
||
`组件“${node.data.label || node.id}”包含目录未定义的参数:${unknownParameters.join(
|
||
"、",
|
||
)}`,
|
||
);
|
||
}
|
||
const component = xmlDocument.createElement("Component");
|
||
component.setAttribute("id", node.id);
|
||
component.setAttribute("type", definition.modelType);
|
||
component.setAttribute("modelVersion", definition.modelVersion);
|
||
|
||
Object.entries(definition.parameters).forEach(([name, parameterDefinition]) => {
|
||
const value = node.data.parameters[name] ?? parameterDefinition.default;
|
||
const selectedUnit =
|
||
node.data.parameterUnits?.[name] ?? parameterDefinition?.unit ?? "";
|
||
const validationMessage = componentParameterValidationMessage(
|
||
parameterDefinition,
|
||
value,
|
||
selectedUnit,
|
||
undefined,
|
||
definition.type,
|
||
name,
|
||
);
|
||
if (validationMessage) {
|
||
throw new Error(
|
||
`组件“${node.data.label || node.id}”的参数“${parameterDefinition.label}”:${validationMessage}`,
|
||
);
|
||
}
|
||
const resolved = resolveParameterValue(value, parameterDefinition, selectedUnit);
|
||
if (!resolved.ok) {
|
||
throw new Error(
|
||
`组件“${node.data.label || node.id}”的参数“${parameterDefinition.label}”:${resolved.message}`,
|
||
);
|
||
}
|
||
const parameter = xmlDocument.createElement("Parameter");
|
||
parameter.setAttribute("name", name);
|
||
// Number#toString emits the shortest decimal that round-trips to the
|
||
// same IEEE-754 value. UI formatting intentionally uses fewer digits,
|
||
// but the execution XML must not change the resolved physical input.
|
||
parameter.setAttribute("value", String(resolved.value));
|
||
component.appendChild(parameter);
|
||
});
|
||
components.appendChild(component);
|
||
});
|
||
|
||
project.edges.forEach((edge) => {
|
||
if (exportedEdgeIds.has(edge.id)) {
|
||
throw new Error(`工程包含重复的连接 ID:${edge.id}`);
|
||
}
|
||
exportedEdgeIds.add(edge.id);
|
||
const metadata = projectConnectionMetadata(
|
||
registeredPortsByNodeId,
|
||
edge,
|
||
);
|
||
const connection = xmlDocument.createElement("Connection");
|
||
connection.setAttribute("id", edge.id);
|
||
metadata.endpoints.forEach((endpoint) => {
|
||
const endpointElement = xmlDocument.createElement("Endpoint");
|
||
endpointElement.setAttribute("component", endpoint.component);
|
||
endpointElement.setAttribute("port", endpoint.port);
|
||
connection.appendChild(endpointElement);
|
||
});
|
||
connections.appendChild(connection);
|
||
});
|
||
|
||
indentXmlElement(system);
|
||
return `<?xml version="1.0" encoding="UTF-8"?>\n${new XMLSerializer().serializeToString(system)}`;
|
||
}
|
||
|
||
function projectConnectionMetadata(
|
||
registeredPortsByNodeId: Map<string, PortDefinition[]>,
|
||
edge: ProjectEdgePayload,
|
||
) {
|
||
const first = findRegisteredPort(
|
||
registeredPortsByNodeId,
|
||
edge.source,
|
||
edge.sourceHandle,
|
||
);
|
||
const second = findRegisteredPort(
|
||
registeredPortsByNodeId,
|
||
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 findRegisteredPort(
|
||
registeredPortsByNodeId: Map<string, PortDefinition[]>,
|
||
componentId: string,
|
||
portName: string | null,
|
||
) {
|
||
if (!portName) {
|
||
return undefined;
|
||
}
|
||
return registeredPortsByNodeId
|
||
.get(componentId)
|
||
?.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,
|
||
watchdog?: SimulationActivityWatchdogObservation,
|
||
) => 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 activityWatchdog = createSimulationActivityWatchdog();
|
||
|
||
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;
|
||
let watchdogObservation: SimulationActivityWatchdogObservation | undefined;
|
||
if (event.event === "progress") {
|
||
const watched = observeSimulationActivityWatchdog(
|
||
activityWatchdog,
|
||
event,
|
||
);
|
||
activityWatchdog = watched.state;
|
||
if (isSimulationSolverActivityPhase(event.phase)) {
|
||
watchdogObservation = watched.observation;
|
||
if (watchdogObservation.status === "stalled") {
|
||
throw new SimulationStreamError(
|
||
watchdogObservation.activityTelemetryAvailable
|
||
? solverActivityStallTimeoutMessage("detected")
|
||
: solverStallTimeoutMessage("detected"),
|
||
[],
|
||
undefined,
|
||
"SOLVER_STALLED",
|
||
);
|
||
}
|
||
}
|
||
}
|
||
onEvent(event, watchdogObservation);
|
||
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>
|
||
);
|
||
}
|