ReactFlow前端拖拽模型与参数配置功能MVP
This commit is contained in:
1 parent
70c91ed019
commit
d3b99304fb
2001 files changed
+349044
-548
No files matched your search
@@ -0,0 +1,817 @@
|
||||
import { DragEvent, useCallback, useMemo, useState } from "react";
|
||||
import {
|
||||
Background,
|
||||
Controls,
|
||||
Handle,
|
||||
MiniMap,
|
||||
Position,
|
||||
ReactFlow,
|
||||
ReactFlowProvider,
|
||||
SelectionMode,
|
||||
addEdge,
|
||||
type Connection,
|
||||
type Edge,
|
||||
type Node,
|
||||
type NodeProps,
|
||||
type NodeTypes,
|
||||
useEdgesState,
|
||||
useNodesState,
|
||||
useReactFlow,
|
||||
} from "@xyflow/react";
|
||||
|
||||
type ParameterValue = number | string;
|
||||
|
||||
type ParameterDefinition = {
|
||||
label: string;
|
||||
unit?: string;
|
||||
default: ParameterValue;
|
||||
};
|
||||
|
||||
type ComponentDefinition = {
|
||||
type: string;
|
||||
label: string;
|
||||
modelType: string;
|
||||
ports: string[];
|
||||
parameters: Record<string, ParameterDefinition>;
|
||||
};
|
||||
|
||||
type SimulationNodeData = {
|
||||
label: string;
|
||||
componentType: string;
|
||||
modelType: string;
|
||||
ports: string[];
|
||||
parameters: Record<string, ParameterValue>;
|
||||
};
|
||||
|
||||
type SimulationNode = Node<SimulationNodeData, "simulationComponent">;
|
||||
type SimulationEdge = Edge;
|
||||
|
||||
type SimulationConfig = {
|
||||
t_start: number;
|
||||
t_stop: number;
|
||||
step: number;
|
||||
max_step: number;
|
||||
method: string;
|
||||
};
|
||||
|
||||
type ProjectNodePayload = {
|
||||
id: string;
|
||||
type: "simulationComponent";
|
||||
position: {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
data: SimulationNodeData;
|
||||
};
|
||||
|
||||
type ProjectEdgePayload = {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
sourceHandle: string | null;
|
||||
targetHandle: string | null;
|
||||
};
|
||||
|
||||
type ReactFlowProjectPayload = {
|
||||
name: string;
|
||||
nodes: ProjectNodePayload[];
|
||||
edges: ProjectEdgePayload[];
|
||||
simulation: SimulationConfig;
|
||||
};
|
||||
|
||||
type SaveProjectResult = {
|
||||
id: string;
|
||||
componentCount: number;
|
||||
connectionCount: number;
|
||||
};
|
||||
|
||||
type SimulationResult = {
|
||||
success: boolean;
|
||||
message: string;
|
||||
final: {
|
||||
time: number;
|
||||
tankPressure: number;
|
||||
tankTemperature: number;
|
||||
cylinderPressure: number;
|
||||
cylinderTemperature: number;
|
||||
};
|
||||
series: Record<string, number[]>;
|
||||
artifacts: Record<string, string>;
|
||||
networkSummary: string;
|
||||
};
|
||||
|
||||
const defaultSimulationConfig: SimulationConfig = {
|
||||
t_start: 0,
|
||||
t_stop: 2,
|
||||
step: 0.1,
|
||||
max_step: 0.005,
|
||||
method: "BDF",
|
||||
};
|
||||
|
||||
const componentDefinitions: ComponentDefinition[] = [
|
||||
{
|
||||
type: "cylinder",
|
||||
label: "气瓶",
|
||||
modelType: "cylinder",
|
||||
ports: ["port_b"],
|
||||
parameters: {
|
||||
volume: { label: "容积", unit: "m3", default: 0.01 },
|
||||
p0: { label: "初始压力", unit: "Pa", default: 35000000 },
|
||||
T0: { label: "初始温度", unit: "K", default: 300 },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "tank",
|
||||
label: "贮箱",
|
||||
modelType: "tank",
|
||||
ports: ["port_a"],
|
||||
parameters: {
|
||||
volume: { label: "容积", unit: "m3", default: 0.1 },
|
||||
p0: { label: "初始压力", unit: "Pa", default: 100000 },
|
||||
T0: { label: "初始温度", unit: "K", default: 300 },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "pipe",
|
||||
label: "管段",
|
||||
modelType: "pipe",
|
||||
ports: ["port_a", "port_b"],
|
||||
parameters: {
|
||||
length: { label: "长度", unit: "m", default: 5 },
|
||||
diameter: { label: "直径", unit: "m", default: 0.02 },
|
||||
lambda_darcy: { label: "摩阻系数", default: 0.02 },
|
||||
p0: { label: "初始压力", unit: "Pa", default: 100000 },
|
||||
T0: { label: "初始温度", unit: "K", default: 300 },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "orifice",
|
||||
label: "孔板/阀门",
|
||||
modelType: "orifice",
|
||||
ports: ["port_a", "port_b"],
|
||||
parameters: {
|
||||
K: { label: "流量系数", default: 0.00001 },
|
||||
opening: { label: "开度", default: 1 },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "tee",
|
||||
label: "三通",
|
||||
modelType: "tee",
|
||||
ports: ["port_in", "port_out1", "port_out2"],
|
||||
parameters: {},
|
||||
},
|
||||
];
|
||||
|
||||
const nextNodeNumberByType: Record<string, number> = {};
|
||||
|
||||
function defaultParameters(definition: ComponentDefinition) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(definition.parameters).map(([key, parameter]) => [
|
||||
key,
|
||||
parameter.default,
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function buildNodeData(definition: ComponentDefinition): SimulationNodeData {
|
||||
return {
|
||||
label: definition.label,
|
||||
componentType: definition.type,
|
||||
modelType: definition.modelType,
|
||||
ports: definition.ports,
|
||||
parameters: defaultParameters(definition),
|
||||
};
|
||||
}
|
||||
|
||||
function SimulationComponentNode({ data, selected }: NodeProps<SimulationNode>) {
|
||||
const inputPorts = data.ports.filter((port) => port.includes("in") || port === "port_a");
|
||||
const outputPorts = data.ports.filter((port) => !inputPorts.includes(port));
|
||||
const singlePorts = data.ports.length === 1 ? data.ports : [];
|
||||
|
||||
return (
|
||||
<div className={`sim-node sim-node-${data.componentType} ${selected ? "selected" : ""}`}>
|
||||
<div className="node-symbol" aria-hidden="true">
|
||||
{symbolForComponent(data.componentType)}
|
||||
</div>
|
||||
<div className="node-label">{data.label}</div>
|
||||
{inputPorts.map((port, index) => (
|
||||
<Handle
|
||||
className="port-handle"
|
||||
id={port}
|
||||
key={port}
|
||||
position={Position.Left}
|
||||
style={{ top: `${35 + index * 24}%` }}
|
||||
type="target"
|
||||
/>
|
||||
))}
|
||||
{outputPorts.map((port, index) => (
|
||||
<Handle
|
||||
className="port-handle"
|
||||
id={port}
|
||||
key={port}
|
||||
position={Position.Right}
|
||||
style={{ top: `${35 + index * 24}%` }}
|
||||
type="source"
|
||||
/>
|
||||
))}
|
||||
{singlePorts.map((port) => (
|
||||
<Handle
|
||||
className="port-handle"
|
||||
id={port}
|
||||
key={port}
|
||||
position={Position.Right}
|
||||
type="source"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function symbolForComponent(type: string) {
|
||||
if (type === "cylinder") {
|
||||
return <div className="symbol-cylinder" />;
|
||||
}
|
||||
if (type === "tank") {
|
||||
return <div className="symbol-tank" />;
|
||||
}
|
||||
if (type === "pipe") {
|
||||
return <div className="symbol-pipe" />;
|
||||
}
|
||||
if (type === "orifice") {
|
||||
return <div className="symbol-orifice" />;
|
||||
}
|
||||
return <div className="symbol-tee" />;
|
||||
}
|
||||
|
||||
function FlowWorkbench() {
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState<SimulationNode>([]);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState<SimulationEdge>([]);
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||
const [projectName, setProjectName] = useState("demo-system");
|
||||
const [simulationConfig, setSimulationConfig] = useState<SimulationConfig>(
|
||||
defaultSimulationConfig,
|
||||
);
|
||||
const [xmlText, setXmlText] = useState("");
|
||||
const [statusText, setStatusText] = useState("后端待调用");
|
||||
const [simulationResult, setSimulationResult] = useState<SimulationResult | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const reactFlow = useReactFlow<SimulationNode, SimulationEdge>();
|
||||
|
||||
const nodeTypes = useMemo<NodeTypes>(
|
||||
() => ({ simulationComponent: SimulationComponentNode }),
|
||||
[],
|
||||
);
|
||||
|
||||
const selectedNode = nodes.find((node) => node.id === selectedNodeId) ?? null;
|
||||
const selectedDefinition = selectedNode
|
||||
? componentDefinitions.find((definition) => definition.type === selectedNode.data.componentType)
|
||||
: null;
|
||||
|
||||
const buildCurrentProject = useCallback(
|
||||
() => buildProjectPayload(projectName, nodes, edges, simulationConfig),
|
||||
[edges, nodes, projectName, simulationConfig],
|
||||
);
|
||||
|
||||
const onConnect = useCallback(
|
||||
(connection: Connection) => {
|
||||
setEdges((currentEdges) =>
|
||||
addEdge(
|
||||
{
|
||||
...connection,
|
||||
id: `edge-${connection.source}-${connection.sourceHandle}-${connection.target}-${connection.targetHandle}-${Date.now()}`,
|
||||
},
|
||||
currentEdges,
|
||||
),
|
||||
);
|
||||
},
|
||||
[setEdges],
|
||||
);
|
||||
|
||||
const onDragStart = (event: DragEvent<HTMLButtonElement>, type: string) => {
|
||||
event.dataTransfer.setData("application/system-component", type);
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
};
|
||||
|
||||
const onDrop = useCallback(
|
||||
(event: DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
const type = event.dataTransfer.getData("application/system-component");
|
||||
const definition = componentDefinitions.find((item) => item.type === type);
|
||||
if (!definition) {
|
||||
return;
|
||||
}
|
||||
|
||||
const position = reactFlow.screenToFlowPosition({
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
const id = takeNextNodeId(definition.type);
|
||||
const node: SimulationNode = {
|
||||
id,
|
||||
type: "simulationComponent",
|
||||
position,
|
||||
data: buildNodeData(definition),
|
||||
};
|
||||
setNodes((currentNodes) => [...currentNodes, node]);
|
||||
setSelectedNodeId(id);
|
||||
},
|
||||
[reactFlow, setNodes],
|
||||
);
|
||||
|
||||
const updateSelectedNode = (updater: (node: SimulationNode) => SimulationNode) => {
|
||||
if (!selectedNodeId) {
|
||||
return;
|
||||
}
|
||||
setNodes((currentNodes) =>
|
||||
currentNodes.map((node) => (node.id === selectedNodeId ? updater(node) : node)),
|
||||
);
|
||||
};
|
||||
|
||||
const updateSimulationConfig = (key: keyof SimulationConfig, value: string) => {
|
||||
setSimulationConfig((current) => ({
|
||||
...current,
|
||||
[key]: key === "method" ? value : Number(value),
|
||||
}));
|
||||
};
|
||||
|
||||
const generateXml = async () => {
|
||||
await runBusyAction("正在调用后端生成 XML...", async () => {
|
||||
const xml = await postJsonText("/api/reactflow/system-xml", buildCurrentProject());
|
||||
setXmlText(xml);
|
||||
setStatusText(`XML 已生成:${nodes.length} 个组件,${edges.length} 条连接`);
|
||||
});
|
||||
};
|
||||
|
||||
const downloadXml = async () => {
|
||||
await runBusyAction("正在生成并下载 XML...", async () => {
|
||||
const xml = await postJsonText("/api/reactflow/system-xml", buildCurrentProject());
|
||||
setXmlText(xml);
|
||||
downloadText(`${safeFilename(projectName)}.xml`, xml);
|
||||
setStatusText("XML 已下载");
|
||||
});
|
||||
};
|
||||
|
||||
const saveProject = async () => {
|
||||
await runBusyAction("正在保存工程...", async () => {
|
||||
const project = buildCurrentProject();
|
||||
const result = await postJson<SaveProjectResult>(
|
||||
`/api/reactflow/projects/${encodeURIComponent(project.name)}`,
|
||||
project,
|
||||
);
|
||||
setStatusText(
|
||||
`工程已保存:${result.id},${result.componentCount} 个组件,${result.connectionCount} 条连接`,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const loadProject = async () => {
|
||||
await runBusyAction("正在加载工程...", async () => {
|
||||
const projectId = projectName.trim() || "untitled";
|
||||
const project = await getJson<ReactFlowProjectPayload>(
|
||||
`/api/reactflow/projects/${encodeURIComponent(projectId)}`,
|
||||
);
|
||||
const loadedNodes = project.nodes.map(normalizeLoadedNode);
|
||||
setNodes(loadedNodes);
|
||||
setEdges(project.edges.map(normalizeLoadedEdge));
|
||||
setProjectName(project.name || projectId);
|
||||
setSimulationConfig({ ...defaultSimulationConfig, ...project.simulation });
|
||||
setSelectedNodeId(null);
|
||||
setSimulationResult(null);
|
||||
setXmlText("");
|
||||
syncNextNodeNumbers(loadedNodes);
|
||||
setStatusText(`工程已加载:${loadedNodes.length} 个组件,${project.edges.length} 条连接`);
|
||||
window.requestAnimationFrame(() => reactFlow.fitView());
|
||||
});
|
||||
};
|
||||
|
||||
const runSimulation = async () => {
|
||||
await runBusyAction("正在调用 PythonModels 仿真...", async () => {
|
||||
const result = await postJson<SimulationResult>(
|
||||
"/api/reactflow/simulate-testmodel",
|
||||
buildCurrentProject(),
|
||||
);
|
||||
setSimulationResult(result);
|
||||
setStatusText(result.success ? "仿真完成" : `仿真返回:${result.message}`);
|
||||
});
|
||||
};
|
||||
|
||||
async function runBusyAction(message: string, action: () => Promise<void>) {
|
||||
setBusy(true);
|
||||
setStatusText(message);
|
||||
try {
|
||||
await action();
|
||||
} catch (error) {
|
||||
setStatusText(`操作失败:${formatError(error)}`);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
<div className="topbar-actions">
|
||||
<label className="project-name-field">
|
||||
<span>工程</span>
|
||||
<input value={projectName} onChange={(event) => setProjectName(event.target.value)} />
|
||||
</label>
|
||||
<button className="secondary-button" onClick={() => reactFlow.fitView()} type="button">
|
||||
适配视图
|
||||
</button>
|
||||
<button className="secondary-button" disabled={busy} onClick={saveProject} type="button">
|
||||
保存工程
|
||||
</button>
|
||||
<button className="secondary-button" disabled={busy} onClick={loadProject} type="button">
|
||||
加载工程
|
||||
</button>
|
||||
<button className="secondary-button" disabled={busy} onClick={generateXml} type="button">
|
||||
生成 XML
|
||||
</button>
|
||||
<button className="secondary-button" disabled={busy} onClick={downloadXml} type="button">
|
||||
下载 XML
|
||||
</button>
|
||||
<button className="primary-button" disabled={busy} onClick={runSimulation} type="button">
|
||||
运行仿真
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="workbench">
|
||||
<aside className="palette">
|
||||
<div className="panel-title">组件库</div>
|
||||
<div className="palette-list">
|
||||
{componentDefinitions.map((definition) => (
|
||||
<button
|
||||
className="palette-item"
|
||||
draggable
|
||||
key={definition.type}
|
||||
onDragStart={(event) => onDragStart(event, definition.type)}
|
||||
type="button"
|
||||
>
|
||||
<span className={`palette-icon icon-${definition.type}`} />
|
||||
<span>
|
||||
<strong>{definition.label}</strong>
|
||||
<small>{definition.ports.join(" / ") || "无端口"}</small>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section
|
||||
className="flow-canvas"
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
}}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
<ReactFlow
|
||||
colorMode="light"
|
||||
edges={edges}
|
||||
fitView
|
||||
nodeTypes={nodeTypes}
|
||||
nodes={nodes}
|
||||
onConnect={onConnect}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onNodeClick={(_, node) => setSelectedNodeId(node.id)}
|
||||
onNodesChange={onNodesChange}
|
||||
onPaneClick={() => setSelectedNodeId(null)}
|
||||
onSelectionChange={({ nodes: selectedNodes }) =>
|
||||
setSelectedNodeId(selectedNodes.length === 1 ? selectedNodes[0].id : null)
|
||||
}
|
||||
panActivationKeyCode="Space"
|
||||
panOnDrag={[1]}
|
||||
selectionMode={SelectionMode.Partial}
|
||||
selectionOnDrag
|
||||
zoomOnDoubleClick={false}
|
||||
>
|
||||
<Background gap={18} />
|
||||
<MiniMap pannable zoomable />
|
||||
<Controls />
|
||||
</ReactFlow>
|
||||
</section>
|
||||
|
||||
<aside className="properties">
|
||||
<div className="status-strip">{statusText}</div>
|
||||
|
||||
<div className="panel-title">组件参数</div>
|
||||
{selectedNode && selectedDefinition ? (
|
||||
<div className="parameter-form">
|
||||
<label className="field">
|
||||
<span>组件名称</span>
|
||||
<input
|
||||
value={selectedNode.data.label}
|
||||
onChange={(event) =>
|
||||
updateSelectedNode((node) => ({
|
||||
...node,
|
||||
data: { ...node.data, label: event.target.value },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<div className="node-id">{selectedNode.id}</div>
|
||||
{Object.entries(selectedDefinition.parameters).map(([key, definition]) => (
|
||||
<label className="field" key={key}>
|
||||
<span>
|
||||
{definition.label}
|
||||
{definition.unit ? <em>{definition.unit}</em> : null}
|
||||
</span>
|
||||
<input
|
||||
value={selectedNode.data.parameters[key] ?? ""}
|
||||
onChange={(event) =>
|
||||
updateSelectedNode((node) => ({
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
parameters: {
|
||||
...node.data.parameters,
|
||||
[key]: event.target.value,
|
||||
},
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty-state">选择画布上的组件后编辑参数</div>
|
||||
)}
|
||||
|
||||
<div className="panel-title">仿真设置</div>
|
||||
<div className="parameter-form compact-form">
|
||||
<label className="field">
|
||||
<span>起始时间</span>
|
||||
<input
|
||||
type="number"
|
||||
value={simulationConfig.t_start}
|
||||
onChange={(event) => updateSimulationConfig("t_start", event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>结束时间</span>
|
||||
<input
|
||||
type="number"
|
||||
value={simulationConfig.t_stop}
|
||||
onChange={(event) => updateSimulationConfig("t_stop", event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>采样步长</span>
|
||||
<input
|
||||
type="number"
|
||||
value={simulationConfig.step}
|
||||
onChange={(event) => updateSimulationConfig("step", event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>最大积分步长</span>
|
||||
<input
|
||||
type="number"
|
||||
value={simulationConfig.max_step}
|
||||
onChange={(event) => updateSimulationConfig("max_step", event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>求解器</span>
|
||||
<input
|
||||
value={simulationConfig.method}
|
||||
onChange={(event) => updateSimulationConfig("method", event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{simulationResult ? (
|
||||
<>
|
||||
<div className="panel-title">仿真结果</div>
|
||||
<div className="result-section">
|
||||
<div className="result-grid">
|
||||
<span>时间</span>
|
||||
<strong>{formatNumber(simulationResult.final.time)}</strong>
|
||||
<span>贮箱压力</span>
|
||||
<strong>{formatNumber(simulationResult.final.tankPressure)} Pa</strong>
|
||||
<span>贮箱温度</span>
|
||||
<strong>{formatNumber(simulationResult.final.tankTemperature)} K</strong>
|
||||
<span>气瓶压力</span>
|
||||
<strong>{formatNumber(simulationResult.final.cylinderPressure)} Pa</strong>
|
||||
<span>气瓶温度</span>
|
||||
<strong>{formatNumber(simulationResult.final.cylinderTemperature)} K</strong>
|
||||
</div>
|
||||
<div className="run-message">{simulationResult.message}</div>
|
||||
<div className="artifact-list">
|
||||
{Object.entries(simulationResult.artifacts).map(([key, path]) => (
|
||||
<div key={key}>
|
||||
<span>{key}</span>
|
||||
<code>{path}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{xmlText ? (
|
||||
<>
|
||||
<div className="panel-title">系统 XML</div>
|
||||
<textarea className="xml-output" readOnly value={xmlText} />
|
||||
</>
|
||||
) : null}
|
||||
</aside>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function buildProjectPayload(
|
||||
name: string,
|
||||
nodes: SimulationNode[],
|
||||
edges: SimulationEdge[],
|
||||
simulation: SimulationConfig,
|
||||
): ReactFlowProjectPayload {
|
||||
return {
|
||||
name: name.trim() || "untitled",
|
||||
nodes: nodes.map((node) => ({
|
||||
id: node.id,
|
||||
type: "simulationComponent",
|
||||
position: {
|
||||
x: node.position.x,
|
||||
y: node.position.y,
|
||||
},
|
||||
data: {
|
||||
label: node.data.label,
|
||||
componentType: node.data.componentType,
|
||||
modelType: node.data.modelType,
|
||||
ports: [...node.data.ports],
|
||||
parameters: { ...node.data.parameters },
|
||||
},
|
||||
})),
|
||||
edges: edges.map((edge) => ({
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
sourceHandle: edge.sourceHandle ?? null,
|
||||
targetHandle: edge.targetHandle ?? null,
|
||||
})),
|
||||
simulation,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLoadedNode(node: ProjectNodePayload): SimulationNode {
|
||||
const definition =
|
||||
componentDefinitions.find((item) => item.type === node.data.componentType) ??
|
||||
componentDefinitions.find((item) => item.modelType === node.data.modelType);
|
||||
const parameters = definition
|
||||
? { ...defaultParameters(definition), ...node.data.parameters }
|
||||
: { ...node.data.parameters };
|
||||
|
||||
return {
|
||||
id: node.id,
|
||||
type: "simulationComponent",
|
||||
position: {
|
||||
x: node.position?.x ?? 0,
|
||||
y: node.position?.y ?? 0,
|
||||
},
|
||||
data: {
|
||||
label: node.data.label || definition?.label || node.id,
|
||||
componentType: node.data.componentType || definition?.type || "component",
|
||||
modelType: node.data.modelType || definition?.modelType || node.data.componentType,
|
||||
ports: node.data.ports?.length ? node.data.ports : definition?.ports ?? [],
|
||||
parameters,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLoadedEdge(edge: ProjectEdgePayload): SimulationEdge {
|
||||
return {
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
sourceHandle: edge.sourceHandle,
|
||||
targetHandle: edge.targetHandle,
|
||||
};
|
||||
}
|
||||
|
||||
function takeNextNodeId(componentType: string) {
|
||||
const nextNumber = nextNodeNumberByType[componentType] ?? 1;
|
||||
nextNodeNumberByType[componentType] = nextNumber + 1;
|
||||
return `${componentType}_${nextNumber}`;
|
||||
}
|
||||
|
||||
function syncNextNodeNumbers(nodes: SimulationNode[]) {
|
||||
Object.keys(nextNodeNumberByType).forEach((type) => {
|
||||
delete nextNodeNumberByType[type];
|
||||
});
|
||||
|
||||
nodes.forEach((node) => {
|
||||
const componentType = node.data.componentType;
|
||||
const idPrefix = `${componentType}_`;
|
||||
if (!node.id.startsWith(idPrefix)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nodeNumber = Number(node.id.slice(idPrefix.length));
|
||||
if (!Number.isInteger(nodeNumber) || nodeNumber < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
nextNodeNumberByType[componentType] = Math.max(
|
||||
nextNodeNumberByType[componentType] ?? 1,
|
||||
nodeNumber + 1,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function postJson<TResponse>(url: string, body: unknown): Promise<TResponse> {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await readApiError(response));
|
||||
}
|
||||
return (await response.json()) as TResponse;
|
||||
}
|
||||
|
||||
async function postJsonText(url: string, body: unknown): Promise<string> {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(await readApiError(response));
|
||||
}
|
||||
return response.text();
|
||||
}
|
||||
|
||||
async function getJson<TResponse>(url: string): Promise<TResponse> {
|
||||
const response = await fetch(url);
|
||||
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;
|
||||
}
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function downloadText(filename: string, content: string) {
|
||||
const blob = new Blob([content], { type: "application/xml;charset=utf-8" });
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user