Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions whiteboard-dashboard/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions whiteboard-dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
71 changes: 33 additions & 38 deletions whiteboard-dashboard/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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);
Expand All @@ -38,7 +34,8 @@ function FlowBoard() {
const [editText, setEditText] = useState("");
const socketRef = useRef<WebSocket | null>(null);

const { setCenter } = useReactFlow();
const { setCenter, fitView } = useReactFlow();
const nodesInitialized = useNodesInitialized();

const nodeCount = nodes.length;
const hasFinalAnswer = nodes.some((n) => (n.data as Record<string, unknown>).type === "FINAL ANSWER");
Expand Down Expand Up @@ -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 || "",
Expand All @@ -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");
Expand All @@ -124,23 +127,15 @@ 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) =>
addEdge({
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" },
Expand Down Expand Up @@ -187,7 +182,7 @@ function FlowBoard() {
onEdgesChange={onEdgesChange}
minZoom={0.2}
maxZoom={1.5}
translateExtent={[[-1000, -1000], [5000, 5000]]}
translateExtent={translateExtent}
fitView={false}
>
<Background variant={BackgroundVariant.Dots} gap={20} color="#e2e8f0" />
Expand Down
95 changes: 95 additions & 0 deletions whiteboard-dashboard/src/layoutGraph.ts
Original file line number Diff line number Diff line change
@@ -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<T extends Record<string, unknown>>(
nodes: Node<T>[],
edges: Edge[],
options: LayoutOptions = {}
): Node<T>[] {
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,
},
};
});
}
Loading