From 938547dbeffd03caca35a9236c7e6851d5e0b6bc Mon Sep 17 00:00:00 2001 From: Adil Faisal Date: Mon, 27 Jul 2026 17:17:50 -0400 Subject: [PATCH] fixed issues with node overlap --- whiteboard-dashboard/package-lock.json | 34 +++++++++ whiteboard-dashboard/package.json | 2 + whiteboard-dashboard/src/App.tsx | 71 +++++++++--------- whiteboard-dashboard/src/layoutGraph.ts | 95 +++++++++++++++++++++++++ 4 files changed, 164 insertions(+), 38 deletions(-) create mode 100644 whiteboard-dashboard/src/layoutGraph.ts diff --git a/whiteboard-dashboard/package-lock.json b/whiteboard-dashboard/package-lock.json index 6d9bd39..93ff03b 100644 --- a/whiteboard-dashboard/package-lock.json +++ b/whiteboard-dashboard/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@xyflow/react": "^12.10.2", "d3-force": "^3.0.0", + "dagre": "^0.8.5", "katex": "^0.16.44", "react": "^19.2.4", "react-dom": "^19.2.4", @@ -23,6 +24,7 @@ "@testing-library/jest-dom": "^7.0.0", "@testing-library/react": "^16.3.2", "@types/d3-force": "^3.0.10", + "@types/dagre": "^0.7.54", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@typescript-eslint/eslint-plugin": "^8.65.0", @@ -1298,6 +1300,13 @@ "@types/d3-selection": "*" } }, + "node_modules/@types/dagre": { + "version": "0.7.54", + "resolved": "https://registry.npmjs.org/@types/dagre/-/dagre-0.7.54.tgz", + "integrity": "sha512-QjcRY+adGbYvBFS7cwv5txhVIwX1XXIUswWl+kSQTbI6NjgZydrZkEKX/etzVd7i+bCsCb40Z/xlBY5eoFuvWQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -2416,6 +2425,16 @@ "node": ">=12" } }, + "node_modules/dagre": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz", + "integrity": "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==", + "license": "MIT", + "dependencies": { + "graphlib": "^2.1.8", + "lodash": "^4.17.15" + } + }, "node_modules/data-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -2938,6 +2957,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/graphlib": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz", + "integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.15" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -3774,6 +3802,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", diff --git a/whiteboard-dashboard/package.json b/whiteboard-dashboard/package.json index c615b6b..1a135cb 100644 --- a/whiteboard-dashboard/package.json +++ b/whiteboard-dashboard/package.json @@ -14,6 +14,7 @@ "dependencies": { "@xyflow/react": "^12.10.2", "d3-force": "^3.0.0", + "dagre": "^0.8.5", "katex": "^0.16.44", "react": "^19.2.4", "react-dom": "^19.2.4", @@ -27,6 +28,7 @@ "@testing-library/jest-dom": "^7.0.0", "@testing-library/react": "^16.3.2", "@types/d3-force": "^3.0.10", + "@types/dagre": "^0.7.54", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@typescript-eslint/eslint-plugin": "^8.65.0", diff --git a/whiteboard-dashboard/src/App.tsx b/whiteboard-dashboard/src/App.tsx index 8b6e8dd..713c1ce 100644 --- a/whiteboard-dashboard/src/App.tsx +++ b/whiteboard-dashboard/src/App.tsx @@ -1,5 +1,5 @@ // Main application component — manages the ReactFlow graph, WebSocket connection, -// d3-force physics layout, conversation history, and user input. +// tree-based layout, conversation history, and user input. import { useEffect, useState, useRef } from "react"; import { ReactFlow, @@ -11,25 +11,21 @@ import { Controls, MarkerType, useReactFlow, + useNodesInitialized, ReactFlowProvider, type Node, } from "@xyflow/react"; -import { forceSimulation, forceManyBody, forceCollide, forceX, forceY, type SimulationNodeDatum } from "d3-force"; import MathNode from "./MathNode"; +import { getLayoutedElements, getGraphExtent } from "./layoutGraph"; import "@xyflow/react/dist/style.css"; import type { ReasoningNode, HistoryEntry } from "./types"; -interface D3Node extends SimulationNodeDatum { - id: string; - x: number; - y: number; -} - const nodeTypes = { mathNode: MathNode } as const; function FlowBoard() { const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); + const [translateExtent, setTranslateExtent] = useState<[[number, number], [number, number]]>([[-1000, -1000], [5000, 5000]]); const [input, setInput] = useState(""); const [isThinking, setIsThinking] = useState(false); @@ -38,7 +34,8 @@ function FlowBoard() { const [editText, setEditText] = useState(""); const socketRef = useRef(null); - const { setCenter } = useReactFlow(); + const { setCenter, fitView } = useReactFlow(); + const nodesInitialized = useNodesInitialized(); const nodeCount = nodes.length; const hasFinalAnswer = nodes.some((n) => (n.data as Record).type === "FINAL ANSWER"); @@ -79,10 +76,10 @@ function FlowBoard() { } }; - const makeNode = (data: ReasoningNode, position: { x: number; y: number }): Node => ({ + const makeNode = (data: ReasoningNode): Node => ({ id: String(data.id), type: "mathNode", - position, + position: { x: 0, y: 0 }, data: { label: data.label, math: data.content || "", @@ -91,24 +88,30 @@ function FlowBoard() { }, }); + // Re-layout with dagre whenever the graph changes and nodes have been measured. useEffect(() => { + if (!nodesInitialized) return; if (nodes.length === 0) return; - const simNodes = nodes as unknown as D3Node[]; - const sim = forceSimulation(simNodes) - .force("charge", forceManyBody().strength(-150)) - .force("collision", forceCollide().radius(300)) - .force("y", forceY(window.innerHeight / 2).strength(0.8)) - .force("x", forceX(window.innerWidth / 2).strength(0.02)) - .velocityDecay(0.6) - .alpha(0.1) - .on("tick", () => { - setNodes((nds) => - nds.map((node) => ({ ...node, position: { x: (node as unknown as D3Node).x, y: (node as unknown as D3Node).y } })) - ); - }); - return () => { sim.stop(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [nodes.length, setNodes]); + + const layouted = getLayoutedElements(nodes, edges, { + rankdir: "LR", + nodesep: 80, + ranksep: 200, + edgesep: 40, + }); + + setNodes((current) => + current.map((n) => { + const ln = layouted.find((l) => l.id === n.id); + return ln ? { ...n, position: ln.position } : n; + }) + ); + + setTranslateExtent(getGraphExtent(layouted)); + fitView({ padding: 0.2, duration: 800 }); + // Only re-run when the count of nodes/edges changes, not every position update. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [nodesInitialized, nodes.length, edges.length, setNodes, fitView]); useEffect(() => { const ws = new WebSocket("wss://professor-backend-656601378878.us-central1.run.app/ws/reason"); @@ -124,15 +127,7 @@ function FlowBoard() { setIsThinking(false); - setNodes((nds) => { - const parentNode = nds.find((n) => String(n.id) === String(data.parent_id)); - const isAlternative = data.node_type === "Alternative"; - const position = { - x: parentNode ? parentNode.position.x + (isAlternative ? 0 : 550) : 100, - y: parentNode ? parentNode.position.y + (isAlternative ? 220 : 0) : window.innerHeight / 2, - }; - return nds.concat(makeNode(data, position)); - }); + setNodes((nds) => nds.concat(makeNode(data))); if (data.parent_id) { setEdges((eds) => @@ -140,7 +135,7 @@ function FlowBoard() { id: `e-${data.parent_id}-${data.id}`, source: String(data.parent_id), target: String(data.id), - type: "straight", + type: "smoothstep", animated: true, style: { stroke: "#3b82f6", strokeWidth: 3 }, markerEnd: { type: MarkerType.ArrowClosed, color: "#3b82f6" }, @@ -187,7 +182,7 @@ function FlowBoard() { onEdgesChange={onEdgesChange} minZoom={0.2} maxZoom={1.5} - translateExtent={[[-1000, -1000], [5000, 5000]]} + translateExtent={translateExtent} fitView={false} > diff --git a/whiteboard-dashboard/src/layoutGraph.ts b/whiteboard-dashboard/src/layoutGraph.ts new file mode 100644 index 0000000..8ea72c9 --- /dev/null +++ b/whiteboard-dashboard/src/layoutGraph.ts @@ -0,0 +1,95 @@ +// Layout helper using dagre for a left-to-right, non-overlapping tree. +import dagre from "dagre"; +import type { Edge, Node } from "@xyflow/react"; + +const DEFAULT_NODE_WIDTH = 350; +const DEFAULT_NODE_HEIGHT = 200; + +interface LayoutOptions { + rankdir?: "TB" | "BT" | "LR" | "RL"; + nodesep?: number; + ranksep?: number; + edgesep?: number; +} + +/** Compute a generous pan extent that contains all nodes plus padding. */ +export function getGraphExtent(nodes: Node[]): [[number, number], [number, number]] { + if (nodes.length === 0) { + return [ + [-1000, -1000], + [5000, 5000], + ]; + } + + const padding = 1000; + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + + for (const node of nodes) { + const width = node.measured?.width ?? node.width ?? DEFAULT_NODE_WIDTH; + const height = node.measured?.height ?? node.height ?? DEFAULT_NODE_HEIGHT; + minX = Math.min(minX, node.position.x); + minY = Math.min(minY, node.position.y); + maxX = Math.max(maxX, node.position.x + width); + maxY = Math.max(maxY, node.position.y + height); + } + + return [ + [Math.min(minX - padding, -1000), Math.min(minY - padding, -1000)], + [Math.max(maxX + padding, 5000), Math.max(maxY + padding, 5000)], + ]; +} + +/** + * Compute collision-free positions for ReactFlow nodes using dagre. + * + * Falls back to sensible default dimensions for nodes that have not been + * measured yet. After ReactFlow has rendered nodes once, callers should pass + * `node.measured.width / height` so the layout uses true rendered sizes. + */ +export function getLayoutedElements>( + nodes: Node[], + edges: Edge[], + options: LayoutOptions = {} +): Node[] { + if (nodes.length === 0) return []; + + const { + rankdir = "LR", + nodesep = 80, + ranksep = 180, + edgesep = 40, + } = options; + + const graph = new dagre.graphlib.Graph<{ width: number; height: number }>(); + graph.setGraph({ rankdir, nodesep, ranksep, edgesep }); + graph.setDefaultEdgeLabel(() => ({})); + + for (const node of nodes) { + const width = node.measured?.width ?? node.width ?? DEFAULT_NODE_WIDTH; + const height = node.measured?.height ?? node.height ?? DEFAULT_NODE_HEIGHT; + graph.setNode(node.id, { width, height }); + } + + for (const edge of edges) { + if (graph.hasNode(edge.source) && graph.hasNode(edge.target)) { + graph.setEdge(edge.source, edge.target); + } + } + + dagre.layout(graph); + + return nodes.map((node) => { + const graphNode = graph.node(node.id); + if (!graphNode) return node; + return { + ...node, + position: { + x: graphNode.x - graphNode.width / 2, + y: graphNode.y - graphNode.height / 2, + }, + }; + }); +}