mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-01-06 22:03:59 -05:00
feat(frontend): add automatic collision resolution for flow editor nodes (#11506)
When users drag and drop nodes in the new flow editor, nodes can overlap with each other, making the graph difficult to read and interact with. This PR adds an automatic collision resolution algorithm that runs when a node is dropped, ensuring nodes are automatically separated to prevent overlaps and maintain a clean, readable graph layout. ### Changes 🏗️ - **Added collision resolution algorithm** (`resolve-collision.ts`): - Implements an iterative collision detection and resolution system using Flatbush for efficient spatial indexing - Automatically resolves overlaps by moving nodes apart along the axis with the smallest overlap - Configurable options: `maxIterations`, `overlapThreshold`, and `margin` - Uses actual node dimensions (`width`, `height`, or `measured` values) when available - **Integrated collision resolution into Flow component**: - Added `onNodeDragStop` callback that triggers collision resolution after a node is dropped - Configured with `maxIterations: Infinity`, `overlapThreshold: 0.5`, and `margin: 15px` - **Enhanced node dimension handling**: - Updated `nodeStore.ts` to prioritize actual node dimensions (`node.width`, `node.measured.width`) over hardcoded defaults when calculating positions - Ensures collision detection uses accurate node sizes - **Added dependency**: - Added `flatbush@4.5.0` for efficient spatial indexing and collision detection ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] Drag a node and drop it on top of another node - verify nodes automatically separate - [x] Drag multiple nodes to create overlapping clusters - verify all overlaps are resolved - [x] Drag nodes with different sizes (NOTE blocks vs regular blocks) - verify collision detection uses correct dimensions - [x] Drag nodes near the edge of the canvas - verify nodes don't get pushed off-screen - [x] Test with a graph containing many nodes (20+) - verify performance is acceptable - [x] Verify nodes maintain their positions when no collisions occur - [x] Test with nodes that have custom measured dimensions - verify accurate collision detection
This commit is contained in:
@@ -72,6 +72,7 @@
|
||||
"dotenv": "17.2.3",
|
||||
"elliptic": "6.6.1",
|
||||
"embla-carousel-react": "8.6.0",
|
||||
"flatbush": "4.5.0",
|
||||
"framer-motion": "12.23.24",
|
||||
"geist": "1.5.1",
|
||||
"highlight.js": "11.11.1",
|
||||
|
||||
31
autogpt_platform/frontend/pnpm-lock.yaml
generated
31
autogpt_platform/frontend/pnpm-lock.yaml
generated
@@ -140,6 +140,9 @@ importers:
|
||||
embla-carousel-react:
|
||||
specifier: 8.6.0
|
||||
version: 8.6.0(react@18.3.1)
|
||||
flatbush:
|
||||
specifier: 4.5.0
|
||||
version: 4.5.0
|
||||
framer-motion:
|
||||
specifier: 12.23.24
|
||||
version: 12.23.24(@emotion/is-prop-valid@1.2.2)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
@@ -4835,6 +4838,12 @@ packages:
|
||||
resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==}
|
||||
engines: {node: ^10.12.0 || >=12.0.0}
|
||||
|
||||
flatbush@4.5.0:
|
||||
resolution: {integrity: sha512-K7JSilGr4lySRLdJqKY45fu0m/dIs6YAAu/ESqdMsnW3pI0m3gpa6oRc6NDXW161Ov9+rIQjsuyOt5ObdIfgwg==}
|
||||
|
||||
flatqueue@3.0.0:
|
||||
resolution: {integrity: sha512-y1deYaVt+lIc/d2uIcWDNd0CrdQTO5xoCjeFdhX0kSXvm2Acm0o+3bAOiYklTEoRyzwio3sv3/IiBZdusbAe2Q==}
|
||||
|
||||
flatted@3.3.3:
|
||||
resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
|
||||
|
||||
@@ -12531,8 +12540,8 @@ snapshots:
|
||||
'@typescript-eslint/parser': 8.43.0(eslint@8.57.1)(typescript@5.9.3)
|
||||
eslint: 8.57.1
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.43.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1)
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.43.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.43.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1)
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.43.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
|
||||
eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1)
|
||||
eslint-plugin-react: 7.37.5(eslint@8.57.1)
|
||||
eslint-plugin-react-hooks: 5.2.0(eslint@8.57.1)
|
||||
@@ -12551,7 +12560,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.43.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1):
|
||||
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1):
|
||||
dependencies:
|
||||
'@nolyfill/is-core-module': 1.0.39
|
||||
debug: 4.4.3
|
||||
@@ -12562,22 +12571,22 @@ snapshots:
|
||||
tinyglobby: 0.2.15
|
||||
unrs-resolver: 1.11.1
|
||||
optionalDependencies:
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.43.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.43.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.43.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.43.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.43.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1):
|
||||
eslint-module-utils@2.12.1(@typescript-eslint/parser@8.43.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1):
|
||||
dependencies:
|
||||
debug: 3.2.7
|
||||
optionalDependencies:
|
||||
'@typescript-eslint/parser': 8.43.0(eslint@8.57.1)(typescript@5.9.3)
|
||||
eslint: 8.57.1
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.43.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1)
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.43.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.43.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1):
|
||||
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.43.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1):
|
||||
dependencies:
|
||||
'@rtsao/scc': 1.1.0
|
||||
array-includes: 3.1.9
|
||||
@@ -12588,7 +12597,7 @@ snapshots:
|
||||
doctrine: 2.1.0
|
||||
eslint: 8.57.1
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.43.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.43.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1)
|
||||
eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.43.0(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1)
|
||||
hasown: 2.0.2
|
||||
is-core-module: 2.16.1
|
||||
is-glob: 4.0.3
|
||||
@@ -12864,6 +12873,12 @@ snapshots:
|
||||
keyv: 4.5.4
|
||||
rimraf: 3.0.2
|
||||
|
||||
flatbush@4.5.0:
|
||||
dependencies:
|
||||
flatqueue: 3.0.0
|
||||
|
||||
flatqueue@3.0.0: {}
|
||||
|
||||
flatted@3.3.3: {}
|
||||
|
||||
for-each@0.3.5:
|
||||
|
||||
@@ -4,7 +4,7 @@ import CustomEdge from "../edges/CustomEdge";
|
||||
import { useFlow } from "./useFlow";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useNodeStore } from "../../../stores/nodeStore";
|
||||
import { useMemo, useEffect } from "react";
|
||||
import { useMemo, useEffect, useCallback } from "react";
|
||||
import { CustomNode } from "../nodes/CustomNode/CustomNode";
|
||||
import { useCustomEdge } from "../edges/useCustomEdge";
|
||||
import { useFlowRealtime } from "./useFlowRealtime";
|
||||
@@ -21,6 +21,7 @@ import { useGetV1GetSpecificGraph } from "@/app/api/__generated__/endpoints/grap
|
||||
import { GraphModel } from "@/app/api/__generated__/models/graphModel";
|
||||
import { okData } from "@/app/api/helpers";
|
||||
import { TriggerAgentBanner } from "./components/TriggerAgentBanner";
|
||||
import { resolveCollisions } from "./helpers/resolve-collision";
|
||||
|
||||
export const Flow = () => {
|
||||
const [{ flowID, flowExecutionID }] = useQueryStates({
|
||||
@@ -40,6 +41,7 @@ export const Flow = () => {
|
||||
);
|
||||
|
||||
const nodes = useNodeStore(useShallow((state) => state.nodes));
|
||||
const setNodes = useNodeStore(useShallow((state) => state.setNodes));
|
||||
const onNodesChange = useNodeStore(
|
||||
useShallow((state) => state.onNodesChange),
|
||||
);
|
||||
@@ -48,6 +50,15 @@ export const Flow = () => {
|
||||
);
|
||||
const nodeTypes = useMemo(() => ({ custom: CustomNode }), []);
|
||||
const edgeTypes = useMemo(() => ({ custom: CustomEdge }), []);
|
||||
const onNodeDragStop = useCallback(() => {
|
||||
setNodes(
|
||||
resolveCollisions(nodes, {
|
||||
maxIterations: Infinity,
|
||||
overlapThreshold: 0.5,
|
||||
margin: 15,
|
||||
}),
|
||||
);
|
||||
}, [setNodes, nodes]);
|
||||
const { edges, onConnect, onEdgesChange } = useCustomEdge();
|
||||
|
||||
// We use this hook to load the graph and convert them into custom nodes and edges.
|
||||
@@ -84,6 +95,7 @@ export const Flow = () => {
|
||||
edges={edges}
|
||||
onConnect={onConnect}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onNodeDragStop={onNodeDragStop}
|
||||
maxZoom={2}
|
||||
minZoom={0.1}
|
||||
onDragOver={onDragOver}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { CustomNode } from "../../nodes/CustomNode/CustomNode";
|
||||
import Flatbush from "flatbush";
|
||||
|
||||
export type CollisionAlgorithmOptions = {
|
||||
maxIterations: number;
|
||||
overlapThreshold: number;
|
||||
margin: number;
|
||||
};
|
||||
|
||||
export type CollisionAlgorithm = (
|
||||
nodes: CustomNode[],
|
||||
options: CollisionAlgorithmOptions,
|
||||
) => CustomNode[];
|
||||
|
||||
type Box = {
|
||||
minX: number;
|
||||
minY: number;
|
||||
maxX: number;
|
||||
maxY: number;
|
||||
id: string;
|
||||
moved: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
node: CustomNode;
|
||||
};
|
||||
|
||||
function rebuildFlatbush(boxes: Box[]) {
|
||||
const index = new Flatbush(boxes.length);
|
||||
for (const box of boxes) {
|
||||
index.add(box.minX, box.minY, box.maxX, box.maxY);
|
||||
}
|
||||
index.finish();
|
||||
return index;
|
||||
}
|
||||
|
||||
export const resolveCollisions: CollisionAlgorithm = (
|
||||
nodes,
|
||||
{ maxIterations = 50, overlapThreshold = 0.5, margin = 0 },
|
||||
) => {
|
||||
// Create boxes from nodes
|
||||
const boxes: Box[] = new Array(nodes.length);
|
||||
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const node = nodes[i];
|
||||
// Use measured dimensions if available, otherwise use defaults
|
||||
const width = (node.width ?? node.measured?.width ?? 0) + margin * 2;
|
||||
const height = (node.height ?? node.measured?.height ?? 0) + margin * 2;
|
||||
|
||||
console.log("width", width);
|
||||
console.log("height", height);
|
||||
const x = node.position.x - margin;
|
||||
const y = node.position.y - margin;
|
||||
|
||||
const box: Box = {
|
||||
minX: x,
|
||||
minY: y,
|
||||
maxX: x + width,
|
||||
maxY: y + height,
|
||||
id: node.id,
|
||||
moved: false,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
node,
|
||||
};
|
||||
|
||||
boxes[i] = box;
|
||||
}
|
||||
|
||||
let numIterations = 0;
|
||||
let index = rebuildFlatbush(boxes);
|
||||
|
||||
for (let iter = 0; iter <= maxIterations; iter++) {
|
||||
let moved = false;
|
||||
|
||||
// For each box, find potential collisions using spatial search
|
||||
for (let i = 0; i < boxes.length; i++) {
|
||||
const A = boxes[i];
|
||||
// Search for boxes that might overlap with A
|
||||
const candidateIndices = index.search(A.minX, A.minY, A.maxX, A.maxY);
|
||||
|
||||
for (const j of candidateIndices) {
|
||||
const B = boxes[j];
|
||||
// Skip self
|
||||
if (A.id === B.id) continue;
|
||||
|
||||
// Calculate center positions
|
||||
const centerAX = A.x + A.width * 0.5;
|
||||
const centerAY = A.y + A.height * 0.5;
|
||||
const centerBX = B.x + B.width * 0.5;
|
||||
const centerBY = B.y + B.height * 0.5;
|
||||
|
||||
// Calculate distance between centers
|
||||
const dx = centerAX - centerBX;
|
||||
const dy = centerAY - centerBY;
|
||||
|
||||
// Calculate overlap along each axis
|
||||
const px = (A.width + B.width) * 0.5 - Math.abs(dx);
|
||||
const py = (A.height + B.height) * 0.5 - Math.abs(dy);
|
||||
|
||||
// Check if there's significant overlap
|
||||
if (px > overlapThreshold && py > overlapThreshold) {
|
||||
A.moved = B.moved = moved = true;
|
||||
|
||||
// Resolve along the smallest overlap axis
|
||||
if (px < py) {
|
||||
// Move along x-axis
|
||||
const sx = dx > 0 ? 1 : -1;
|
||||
const moveAmount = (px / 2) * sx;
|
||||
|
||||
A.x += moveAmount;
|
||||
A.minX += moveAmount;
|
||||
A.maxX += moveAmount;
|
||||
B.x -= moveAmount;
|
||||
B.minX -= moveAmount;
|
||||
B.maxX -= moveAmount;
|
||||
} else {
|
||||
// Move along y-axis
|
||||
const sy = dy > 0 ? 1 : -1;
|
||||
const moveAmount = (py / 2) * sy;
|
||||
|
||||
A.y += moveAmount;
|
||||
A.minY += moveAmount;
|
||||
A.maxY += moveAmount;
|
||||
B.y -= moveAmount;
|
||||
B.minY -= moveAmount;
|
||||
B.maxY -= moveAmount;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
numIterations = numIterations + 1;
|
||||
|
||||
// Early exit if no overlaps were found
|
||||
if (!moved) {
|
||||
break;
|
||||
}
|
||||
|
||||
index = rebuildFlatbush(boxes);
|
||||
}
|
||||
|
||||
const newNodes = boxes.map((box) => {
|
||||
if (box.moved) {
|
||||
return {
|
||||
...box.node,
|
||||
position: {
|
||||
x: box.x + margin,
|
||||
y: box.y + margin,
|
||||
},
|
||||
};
|
||||
}
|
||||
return box.node;
|
||||
});
|
||||
|
||||
return newNodes;
|
||||
};
|
||||
@@ -139,8 +139,11 @@ export const useNodeStore = create<NodeStore>((set, get) => ({
|
||||
get().nodes.map((node) => ({
|
||||
position: node.position,
|
||||
measured: {
|
||||
width: node.data.uiType === BlockUIType.NOTE ? 300 : 500,
|
||||
height: 400,
|
||||
width:
|
||||
node.width ??
|
||||
node.measured?.width ??
|
||||
(node.data.uiType === BlockUIType.NOTE ? 300 : 500),
|
||||
height: node.height ?? node.measured?.height ?? 400,
|
||||
},
|
||||
})),
|
||||
block.uiType === BlockUIType.NOTE ? 300 : 400,
|
||||
|
||||
Reference in New Issue
Block a user