diff --git a/autogpt_platform/frontend/package.json b/autogpt_platform/frontend/package.json index 29a28059a6..ff9073e436 100644 --- a/autogpt_platform/frontend/package.json +++ b/autogpt_platform/frontend/package.json @@ -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", diff --git a/autogpt_platform/frontend/pnpm-lock.yaml b/autogpt_platform/frontend/pnpm-lock.yaml index 406fcb212f..68f727470a 100644 --- a/autogpt_platform/frontend/pnpm-lock.yaml +++ b/autogpt_platform/frontend/pnpm-lock.yaml @@ -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: diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/Flow.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/Flow.tsx index 37417f632d..13268fc816 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/Flow.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/Flow.tsx @@ -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} diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/helpers/resolve-collision.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/helpers/resolve-collision.ts new file mode 100644 index 0000000000..c05f00b5fb --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/helpers/resolve-collision.ts @@ -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; +}; diff --git a/autogpt_platform/frontend/src/app/(platform)/build/stores/nodeStore.ts b/autogpt_platform/frontend/src/app/(platform)/build/stores/nodeStore.ts index dc9963194a..3beba0c615 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/stores/nodeStore.ts +++ b/autogpt_platform/frontend/src/app/(platform)/build/stores/nodeStore.ts @@ -139,8 +139,11 @@ export const useNodeStore = create((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,