|
|
@@ -15,23 +15,22 @@ import {
|
|
|
import "@xyflow/react/dist/style.css";
|
|
|
|
|
|
import styles from "./TopologyBuilder.module.css";
|
|
|
-import type { TopologyEdge, TopologyNode } from "./model";
|
|
|
+import type { TopologyEdge, TopologyNode, ValidationReport } from "./model";
|
|
|
import { ContainerType } from "../simulator";
|
|
|
import NodeInspector from "./NodeInspector";
|
|
|
import EdgeInspector from "./EdgeInspector";
|
|
|
import TopologyNodeUI from "./TopologyNodeUI";
|
|
|
import InserterEdge from "./InserterEdge";
|
|
|
import { downloadJSON } from "../../utils";
|
|
|
+import { computeMachineStats } from "../stats";
|
|
|
|
|
|
const nodeTypes = { containerNode: TopologyNodeUI };
|
|
|
const edgeTypes = { inserterEdge: InserterEdge };
|
|
|
|
|
|
-// FIX: Satisfy React Flow's strict generic constraints
|
|
|
type FlowNodeData = TopologyNode & Record<string, unknown>;
|
|
|
type FlowEdgeData = TopologyEdge & Record<string, unknown> & { onEdgeSelect?: (id: string) => void };
|
|
|
|
|
|
export default function TopologyBuilder() {
|
|
|
- // FIX: Properly typed as Arrays
|
|
|
const [nodes, setNodes] = useState<Node<FlowNodeData>[]>([]);
|
|
|
const [edges, setEdges] = useState<Edge<FlowEdgeData>[]>([]);
|
|
|
|
|
|
@@ -39,13 +38,14 @@ export default function TopologyBuilder() {
|
|
|
const [selectionType, setSelectionType] = useState<"node" | "edge" | null>(null);
|
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
|
|
- // Reusable callback for Edge clicks (passed into Edge payload)
|
|
|
const handleEdgeSelect = useCallback((id: string) => {
|
|
|
setSelectedId(id);
|
|
|
setSelectionType("edge");
|
|
|
+
|
|
|
+ setEdges((eds) => eds.map((e) => ({ ...e, selected: e.id === id })));
|
|
|
+ setNodes((nds) => nds.map((n) => ({ ...n, selected: false })));
|
|
|
}, []);
|
|
|
|
|
|
- // --- React Flow Interaction Handlers ---
|
|
|
const onNodesChange = useCallback(
|
|
|
(changes: NodeChange<Node<FlowNodeData>>[]) =>
|
|
|
setNodes((nds) => applyNodeChanges(changes, nds) as Node<FlowNodeData>[]),
|
|
|
@@ -61,24 +61,47 @@ export default function TopologyBuilder() {
|
|
|
const onConnect = useCallback(
|
|
|
(connection: Connection) => {
|
|
|
const id = `edge_${crypto.randomUUID().slice(0, 8)}`;
|
|
|
+
|
|
|
+ // Auto-detect filter from the target handle ID!
|
|
|
+ // Example: "in-iron-plate" -> extracts "iron-plate"
|
|
|
+ let autoFilters: string[] = [];
|
|
|
+ let autoItemId = "";
|
|
|
+
|
|
|
+ if (
|
|
|
+ connection.targetHandle &&
|
|
|
+ connection.targetHandle.startsWith("in-") &&
|
|
|
+ connection.targetHandle !== "in-any"
|
|
|
+ ) {
|
|
|
+ const extractedItem = connection.targetHandle.replace("in-", "");
|
|
|
+ autoFilters = [extractedItem];
|
|
|
+ autoItemId = extractedItem; // Set both just in case other parts of your app still rely on itemId
|
|
|
+ }
|
|
|
+
|
|
|
+ // Save to Domain Model
|
|
|
const newDomainEdge: TopologyEdge = {
|
|
|
id,
|
|
|
sourceId: connection.source,
|
|
|
destinationId: connection.target,
|
|
|
- itemId: "",
|
|
|
+ sourceHandle: connection.sourceHandle,
|
|
|
+ targetHandle: connection.targetHandle,
|
|
|
+ itemId: autoItemId,
|
|
|
+ filters: autoFilters, // <-- Automatically applied!
|
|
|
stackSize: 16,
|
|
|
};
|
|
|
|
|
|
+ // Save to React Flow
|
|
|
const newFlowEdge: Edge<FlowEdgeData> = {
|
|
|
id,
|
|
|
source: connection.source,
|
|
|
target: connection.target,
|
|
|
+ sourceHandle: connection.sourceHandle,
|
|
|
+ targetHandle: connection.targetHandle,
|
|
|
type: "inserterEdge",
|
|
|
data: { ...newDomainEdge, onEdgeSelect: handleEdgeSelect },
|
|
|
animated: true,
|
|
|
};
|
|
|
|
|
|
- setEdges((eds) => connectEdges(newFlowEdge, eds));
|
|
|
+ setEdges((eds) => connectEdges(newFlowEdge, eds) as Edge<FlowEdgeData>[]);
|
|
|
setSelectedId(id);
|
|
|
setSelectionType("edge");
|
|
|
},
|
|
|
@@ -86,29 +109,32 @@ export default function TopologyBuilder() {
|
|
|
);
|
|
|
|
|
|
const addNode = (type: ContainerType) => {
|
|
|
+ const shortHash = crypto.randomUUID().slice(0, 4).toUpperCase();
|
|
|
const id = `node_${crypto.randomUUID().slice(0, 8)}`;
|
|
|
- const domainNode: TopologyNode = {
|
|
|
- id,
|
|
|
- name: `New ${type}`,
|
|
|
- type,
|
|
|
- ...(type === ContainerType.Machine
|
|
|
- ? {
|
|
|
- machineConfig: {
|
|
|
- setup: { machine: null as any, machineModules: [], beacons: [], machineQualityLevel: 0 },
|
|
|
- recipe: null,
|
|
|
- multiplier: 1,
|
|
|
- },
|
|
|
- }
|
|
|
- : {}),
|
|
|
- };
|
|
|
+ let defaultName = `Node ${shortHash}`;
|
|
|
+ if (type === ContainerType.Machine) defaultName = `Assembler ${shortHash}`;
|
|
|
+ if (type === ContainerType.Chest) defaultName = `Chest ${shortHash}`;
|
|
|
+ if (type === ContainerType.Belt) defaultName = `Belt ${shortHash}`;
|
|
|
|
|
|
const flowNode: Node<FlowNodeData> = {
|
|
|
id,
|
|
|
type: "containerNode",
|
|
|
position: { x: Math.random() * 200 + 100, y: Math.random() * 200 + 100 },
|
|
|
- data: domainNode,
|
|
|
+ data: {
|
|
|
+ id,
|
|
|
+ name: defaultName,
|
|
|
+ type,
|
|
|
+ ...(type === ContainerType.Machine
|
|
|
+ ? {
|
|
|
+ machineConfig: {
|
|
|
+ setup: { machine: null as any, machineModules: [], beacons: [], machineQualityLevel: 0 },
|
|
|
+ recipe: null,
|
|
|
+ multiplier: 1,
|
|
|
+ },
|
|
|
+ }
|
|
|
+ : {}),
|
|
|
+ },
|
|
|
};
|
|
|
-
|
|
|
setNodes((nds) => [...nds, flowNode]);
|
|
|
setSelectedId(id);
|
|
|
setSelectionType("node");
|
|
|
@@ -119,7 +145,7 @@ export default function TopologyBuilder() {
|
|
|
}, [nodes]);
|
|
|
|
|
|
const updateDomainNode = (updated: TopologyNode) => {
|
|
|
- setNodes((nds) => nds.map((n) => (n.id === updated.id ? { ...n, data: updated } : n)));
|
|
|
+ setNodes((nds) => nds.map((n) => (n.id === updated.id ? { ...n, data: { ...n.data, ...updated } } : n)));
|
|
|
};
|
|
|
|
|
|
const updateDomainEdge = (updated: TopologyEdge) => {
|
|
|
@@ -130,8 +156,9 @@ export default function TopologyBuilder() {
|
|
|
...e,
|
|
|
source: updated.sourceId,
|
|
|
target: updated.destinationId,
|
|
|
- // Ensure we preserve the callback when replacing the data object!
|
|
|
- data: { ...updated, onEdgeSelect: handleEdgeSelect },
|
|
|
+ sourceHandle: updated.sourceHandle,
|
|
|
+ targetHandle: updated.targetHandle,
|
|
|
+ data: { ...e.data, ...updated, onEdgeSelect: handleEdgeSelect },
|
|
|
}
|
|
|
: e,
|
|
|
),
|
|
|
@@ -139,6 +166,7 @@ export default function TopologyBuilder() {
|
|
|
};
|
|
|
|
|
|
const handleExport = () => downloadJSON({ nodes, edges }, `topology-${Date.now()}.json`);
|
|
|
+
|
|
|
const handleImport = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
|
const file = event.target.files?.[0];
|
|
|
if (!file) return;
|
|
|
@@ -146,7 +174,6 @@ export default function TopologyBuilder() {
|
|
|
reader.onload = (e) => {
|
|
|
try {
|
|
|
const payload = JSON.parse(e.target?.result as string);
|
|
|
- // Re-inject the edge selection callback which is lost during JSON stringification
|
|
|
const restoredEdges = (payload.edges || []).map((edge: any) => ({
|
|
|
...edge,
|
|
|
data: { ...edge.data, onEdgeSelect: handleEdgeSelect },
|
|
|
@@ -160,19 +187,270 @@ export default function TopologyBuilder() {
|
|
|
reader.readAsText(file);
|
|
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
|
|
};
|
|
|
+ const onNodesDelete = useCallback(
|
|
|
+ (deleted: Node<FlowNodeData>[]) => {
|
|
|
+ if (deleted.some((n) => n.id === selectedId)) {
|
|
|
+ setSelectedId(null);
|
|
|
+ setSelectionType(null);
|
|
|
+ }
|
|
|
+ },
|
|
|
+ [selectedId],
|
|
|
+ );
|
|
|
+
|
|
|
+ const onEdgesDelete = useCallback(
|
|
|
+ (deleted: Edge<FlowEdgeData>[]) => {
|
|
|
+ if (deleted.some((e) => e.id === selectedId)) {
|
|
|
+ setSelectedId(null);
|
|
|
+ setSelectionType(null);
|
|
|
+ }
|
|
|
+ },
|
|
|
+ [selectedId],
|
|
|
+ );
|
|
|
+ const handleValidateTopology = () => {
|
|
|
+ const outEdges: Record<string, typeof edges> = {};
|
|
|
+ const inEdges: Record<string, typeof edges> = {};
|
|
|
+ nodes.forEach((n) => {
|
|
|
+ outEdges[n.id] = [];
|
|
|
+ inEdges[n.id] = [];
|
|
|
+ });
|
|
|
+ edges.forEach((e) => {
|
|
|
+ outEdges[e.source]?.push(e);
|
|
|
+ inEdges[e.target]?.push(e);
|
|
|
+ });
|
|
|
+
|
|
|
+ const machineStats: Record<string, { maxCraftsPerSec: number; prodMultiplier: number; recipe: any }> = {};
|
|
|
+
|
|
|
+ nodes.forEach((n) => {
|
|
|
+ if (
|
|
|
+ n.data.type === ContainerType.Machine &&
|
|
|
+ n.data.machineConfig?.recipe &&
|
|
|
+ n.data.machineConfig?.setup?.machine
|
|
|
+ ) {
|
|
|
+ const timings = computeMachineStats(n.data.machineConfig.setup, n.data.machineConfig.recipe);
|
|
|
+ console.log(n.data.machineConfig.recipe.name, timings.craftsPerSecond, timings.actualCraftingSpeed);
|
|
|
+ machineStats[n.id] = {
|
|
|
+ maxCraftsPerSec: timings.craftsPerSecond,
|
|
|
+ // `productivityBonus` is e.g. 0.4. Multiplier becomes 1.4
|
|
|
+ prodMultiplier: 1 + timings.productivityBonus,
|
|
|
+ recipe: n.data.machineConfig.recipe,
|
|
|
+ };
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ const edgeFlows: Record<string, number> = {};
|
|
|
+ const edgeLimits: Record<string, number> = {};
|
|
|
+ const nodeRequiredInputs: Record<string, Record<string, number>> = {};
|
|
|
+ const nodeBottlenecks: Record<string, boolean> = {};
|
|
|
+ const nodeReports: Record<string, ValidationReport> = {};
|
|
|
+
|
|
|
+ nodes.forEach((n) => {
|
|
|
+ nodeRequiredInputs[n.id] = {};
|
|
|
+ nodeBottlenecks[n.id] = false;
|
|
|
+ nodeReports[n.id] = { demandedCrafts: 0, maxCrafts: 0, inputs: {}, outputs: {} }; // <-- ADD THIS
|
|
|
+ });
|
|
|
+
|
|
|
+ const queue = nodes.filter((n) => outEdges[n.id].length === 0 || n.data.role === "void").map((n) => n.id);
|
|
|
+ const inQueue = new Set(queue);
|
|
|
+ const visitCount: Record<string, number> = {};
|
|
|
+
|
|
|
+ while (queue.length > 0) {
|
|
|
+ const nodeId = queue.shift()!;
|
|
|
+ inQueue.delete(nodeId);
|
|
|
+
|
|
|
+ if ((visitCount[nodeId] || 0) > nodes.length + 2) continue;
|
|
|
+ visitCount[nodeId] = (visitCount[nodeId] || 0) + 1;
|
|
|
+
|
|
|
+ const node = nodes.find((n) => n.id === nodeId)!;
|
|
|
+ const isEndNode = outEdges[nodeId].length === 0 || node.data.role === "void";
|
|
|
|
|
|
+ // Calculate Cumulated Downstream Demand
|
|
|
+ const requiredOutputs: Record<string, number> = {};
|
|
|
+ outEdges[nodeId].forEach((e) => {
|
|
|
+ const flow = edgeFlows[e.id] || 0;
|
|
|
+ let item = (e.data?.computedItem as string) || e.data?.filters?.[0] || e.data?.itemId;
|
|
|
+ if (!item && e.targetHandle?.startsWith("in-") && e.targetHandle !== "in-any")
|
|
|
+ item = e.targetHandle.replace("in-", "");
|
|
|
+
|
|
|
+ if (item && flow > 0) {
|
|
|
+ requiredOutputs[item] = (requiredOutputs[item] || 0) + flow;
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ if (node.data.type === ContainerType.Machine && machineStats[nodeId]) {
|
|
|
+ const stats = machineStats[nodeId];
|
|
|
+ let demandedCrafts = 0;
|
|
|
+
|
|
|
+ // PATHFINDER: Does this machine eventually dump into a Sink without hitting another machine?
|
|
|
+ const goesToSink = (() => {
|
|
|
+ if (isEndNode) return true;
|
|
|
+ const stack = outEdges[nodeId].map((e) => e.target);
|
|
|
+ const visited = new Set<string>();
|
|
|
+ while (stack.length > 0) {
|
|
|
+ const curr = stack.pop()!;
|
|
|
+ if (visited.has(curr)) continue;
|
|
|
+ visited.add(curr);
|
|
|
+ const n = nodes.find((nx) => nx.id === curr);
|
|
|
+ if (!n) continue;
|
|
|
+ if (outEdges[curr].length === 0 || n.data.role === "void") return true;
|
|
|
+ if (n.data.type === ContainerType.Machine) continue; // Blocked by downstream machine demand
|
|
|
+ outEdges[curr].forEach((e) => stack.push(e.target));
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ })();
|
|
|
+
|
|
|
+ if (goesToSink) {
|
|
|
+ // If it dumps into a chest, it wants to run at 100% capacity!
|
|
|
+ demandedCrafts = stats.maxCraftsPerSec;
|
|
|
+ } else {
|
|
|
+ Object.keys(requiredOutputs).forEach((item) => {
|
|
|
+ const res = stats.recipe.results?.find((r: any) => r.name === item);
|
|
|
+ const yieldPerCraft = (res?.amount || res?.amount_min || 1) * stats.prodMultiplier;
|
|
|
+ const craftsForThis = requiredOutputs[item] / yieldPerCraft;
|
|
|
+ if (craftsForThis > demandedCrafts) demandedCrafts = craftsForThis;
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ nodeBottlenecks[nodeId] = demandedCrafts > stats.maxCraftsPerSec;
|
|
|
+ const actualCraftsToDraw = Math.min(demandedCrafts, stats.maxCraftsPerSec);
|
|
|
+
|
|
|
+ // Demand ingredients upstream
|
|
|
+ stats.recipe.ingredients?.forEach((ing: any) => {
|
|
|
+ nodeRequiredInputs[nodeId][ing.name] = actualCraftsToDraw * ing.amount;
|
|
|
+ });
|
|
|
+ const producedOutputs: Record<string, number> = {};
|
|
|
+ stats.recipe.results?.forEach((res: any) => {
|
|
|
+ const yieldPerCraft = (res.amount || res.amount_min || 1) * stats.prodMultiplier;
|
|
|
+ producedOutputs[res.name] = actualCraftsToDraw * yieldPerCraft;
|
|
|
+ });
|
|
|
+ nodeReports[nodeId] = {
|
|
|
+ demandedCrafts: demandedCrafts,
|
|
|
+ maxCrafts: stats.maxCraftsPerSec,
|
|
|
+ inputs: { ...nodeRequiredInputs[nodeId] },
|
|
|
+ outputs: producedOutputs,
|
|
|
+ };
|
|
|
+ outEdges[nodeId].forEach((e) => {
|
|
|
+ let item = e.data?.computedItem || e.data?.filters?.[0] || e.data?.itemId;
|
|
|
+ if (!item) item = stats.recipe.results?.[0]?.name;
|
|
|
+
|
|
|
+ if (item) {
|
|
|
+ const res = stats.recipe.results?.find((r: any) => r.name === item);
|
|
|
+ const yieldPerCraft = (res?.amount || res?.amount_min || 1) * stats.prodMultiplier;
|
|
|
+
|
|
|
+ const outEdgesForItem = outEdges[nodeId].filter((oe) => {
|
|
|
+ let oeItem = oe.data?.computedItem || oe.data?.filters?.[0] || oe.data?.itemId;
|
|
|
+ if (!oeItem) oeItem = stats.recipe.results?.[0]?.name;
|
|
|
+ return oeItem === item;
|
|
|
+ });
|
|
|
+
|
|
|
+ const flowPerEdge = (actualCraftsToDraw * yieldPerCraft) / (outEdgesForItem.length || 1);
|
|
|
+ edgeFlows[e.id] = flowPerEdge;
|
|
|
+ e.data.computedItem = item;
|
|
|
+ }
|
|
|
+ });
|
|
|
+ } else {
|
|
|
+ // Belts/Chests pass demand straight through
|
|
|
+ nodeRequiredInputs[nodeId] = { ...requiredOutputs };
|
|
|
+ }
|
|
|
+
|
|
|
+ // Propagate Required Inputs Upstream (Set edge flows for incoming edges)
|
|
|
+ const inGroups: Record<string, typeof edges> = {};
|
|
|
+ inEdges[nodeId].forEach((e) => {
|
|
|
+ let item = e.data?.filters?.[0] || e.data?.itemId;
|
|
|
+ if (!item && e.targetHandle?.startsWith("in-") && e.targetHandle !== "in-any")
|
|
|
+ item = e.targetHandle.replace("in-", "");
|
|
|
+
|
|
|
+ if (!item && nodes.find((n) => n.id === e.source)?.data.type === ContainerType.Machine) {
|
|
|
+ const srcStats = machineStats[e.source];
|
|
|
+ if (srcStats) item = srcStats.recipe.results?.[0]?.name;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (item) {
|
|
|
+ if (!inGroups[item]) inGroups[item] = [];
|
|
|
+ inGroups[item].push(e);
|
|
|
+ e.data.computedItem = item;
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ Object.keys(inGroups).forEach((item) => {
|
|
|
+ const edgesForItem = inGroups[item];
|
|
|
+ const totalReq = nodeRequiredInputs[nodeId][item] || 0;
|
|
|
+ const flowPerEdge = totalReq / edgesForItem.length;
|
|
|
+
|
|
|
+ edgesForItem.forEach((e) => {
|
|
|
+ // Only set the flow if it wasn't already forcefully set by a Machine pushing into it
|
|
|
+ if (edgeFlows[e.id] === undefined) {
|
|
|
+ edgeFlows[e.id] = flowPerEdge;
|
|
|
+ }
|
|
|
+
|
|
|
+ const srcNode = nodes.find((n) => n.id === e.source)!.data;
|
|
|
+ const tgtNode = node.data;
|
|
|
+ const isSrcInv = srcNode.type === ContainerType.Chest || srcNode.type === ContainerType.Machine;
|
|
|
+ const isTgtInv = tgtNode.type === ContainerType.Chest || tgtNode.type === ContainerType.Machine;
|
|
|
+
|
|
|
+ let limit = 80;
|
|
|
+ if (isSrcInv && isTgtInv) limit = 120;
|
|
|
+ else if (isSrcInv && tgtNode.type === ContainerType.Belt) limit = 80;
|
|
|
+ else if (srcNode.type === ContainerType.Belt && isTgtInv) limit = 80;
|
|
|
+
|
|
|
+ edgeLimits[e.id] = limit;
|
|
|
+
|
|
|
+ if (!inQueue.has(e.source)) {
|
|
|
+ queue.push(e.source);
|
|
|
+ inQueue.add(e.source);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ setNodes((nds) =>
|
|
|
+ nds.map((n) => ({
|
|
|
+ ...n,
|
|
|
+ data: { ...n.data, isBottleneck: nodeBottlenecks[n.id] || false, validationReport: nodeReports[n.id] },
|
|
|
+ })),
|
|
|
+ );
|
|
|
+
|
|
|
+ setEdges((eds) =>
|
|
|
+ eds.map((e) => {
|
|
|
+ const flow = edgeFlows[e.id] || 0;
|
|
|
+ const max = edgeLimits[e.id] || 80;
|
|
|
+ return {
|
|
|
+ ...e,
|
|
|
+ data: {
|
|
|
+ ...e.data,
|
|
|
+ computedThroughput: Number(flow.toFixed(2)),
|
|
|
+ maxThroughput: max,
|
|
|
+ isBottleneck: flow > max,
|
|
|
+ },
|
|
|
+ };
|
|
|
+ }),
|
|
|
+ );
|
|
|
+ };
|
|
|
return (
|
|
|
<div className={styles.container} style={{ display: "flex", height: "100vh", width: "100%" }}>
|
|
|
<input type="file" accept=".json" ref={fileInputRef} style={{ display: "none" }} onChange={handleImport} />
|
|
|
|
|
|
- {/* LEFT PANE: React Flow Canvas */}
|
|
|
- <div style={{ flexGrow: 1, position: "relative" }}>
|
|
|
+ {/* LEFT PANE: React Flow Canvas (Flex Grow ensures it takes most of the screen) */}
|
|
|
+ <div style={{ flexGrow: 1, minWidth: 0, position: "relative" }}>
|
|
|
<div style={{ position: "absolute", top: 16, left: 16, zIndex: 10, display: "flex", gap: "8px" }}>
|
|
|
- <button onClick={() => addNode(ContainerType.Machine)}>+ Machine</button>
|
|
|
- <button onClick={() => addNode(ContainerType.Chest)}>+ Chest</button>
|
|
|
- <button onClick={() => addNode(ContainerType.Belt)}>+ Belt</button>
|
|
|
- <button onClick={() => fileInputRef.current?.click()}>📂 Import</button>
|
|
|
- <button onClick={handleExport}>💾 Export</button>
|
|
|
+ <button className="button small" onClick={() => addNode(ContainerType.Machine)}>
|
|
|
+ + Machine
|
|
|
+ </button>
|
|
|
+ <button className="button small" onClick={() => addNode(ContainerType.Chest)}>
|
|
|
+ + Chest
|
|
|
+ </button>
|
|
|
+ <button className="button small" onClick={() => addNode(ContainerType.Belt)}>
|
|
|
+ + Belt
|
|
|
+ </button>
|
|
|
+ <div style={{ width: "1px", background: "#4a4a4a", margin: "0 4px" }} /> {/* Divider */}
|
|
|
+ <button className="button small" onClick={handleValidateTopology}>
|
|
|
+ Check topology
|
|
|
+ </button>
|
|
|
+ <button className="button small" onClick={() => fileInputRef.current?.click()}>
|
|
|
+ 📂 Import
|
|
|
+ </button>
|
|
|
+ <button className="button small" onClick={handleExport}>
|
|
|
+ 💾 Export
|
|
|
+ </button>
|
|
|
</div>
|
|
|
|
|
|
<ReactFlow
|
|
|
@@ -180,6 +458,8 @@ export default function TopologyBuilder() {
|
|
|
edges={edges}
|
|
|
onNodesChange={onNodesChange}
|
|
|
onEdgesChange={onEdgesChange}
|
|
|
+ onNodesDelete={onNodesDelete}
|
|
|
+ onEdgesDelete={onEdgesDelete}
|
|
|
onConnect={onConnect}
|
|
|
nodeTypes={nodeTypes}
|
|
|
edgeTypes={edgeTypes}
|
|
|
@@ -187,24 +467,22 @@ export default function TopologyBuilder() {
|
|
|
setSelectedId(node.id);
|
|
|
setSelectionType("node");
|
|
|
}}
|
|
|
- // Standard SVG line clicks:
|
|
|
onEdgeClick={(_, edge) => handleEdgeSelect(edge.id)}
|
|
|
onPaneClick={() => {
|
|
|
setSelectedId(null);
|
|
|
setSelectionType(null);
|
|
|
}}
|
|
|
+ deleteKeyCode={["Backspace", "Delete"]}
|
|
|
fitView
|
|
|
+ theme="dark"
|
|
|
>
|
|
|
<Background color="#3a3a3a" gap={20} />
|
|
|
<Controls />
|
|
|
</ReactFlow>
|
|
|
</div>
|
|
|
|
|
|
- {/* RIGHT PANE: Inspectors */}
|
|
|
- <div
|
|
|
- className={styles.inspector}
|
|
|
- style={{ width: 400, minWidth: 400, borderLeft: "1px solid #3a3a3a", background: "#1a1a1a" }}
|
|
|
- >
|
|
|
+ {/* RIGHT PANE: Inspectors (Fixed Width) */}
|
|
|
+ <div className={styles.inspector}>
|
|
|
{selectionType === "node" && selectedId && domainNodesMap[selectedId] && (
|
|
|
<NodeInspector node={domainNodesMap[selectedId]} onChange={updateDomainNode} />
|
|
|
)}
|