From 7c00f19ddede1b36a20db462c1c08fafee6a96b2 Mon Sep 17 00:00:00 2001 From: Nick Furfaro Date: Thu, 2 Apr 2026 11:17:33 -0600 Subject: [PATCH 01/32] chore: update gitignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index c93848a..70496b7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ target .fastembed_cache/ .claude/worktrees/ +.claude/settings.local.json *.db *.db-shm *.db-wal @@ -9,4 +10,4 @@ web/playwright-report/ web/e2e/ux-audit/ux-audit-results/ web/ux-audit-results/ macos/.build -macos/.build-output \ No newline at end of file +macos/.build-output From 781ee6b11ffaee2feea20a1467aa2d356e986fb1 Mon Sep 17 00:00:00 2001 From: Nick Furfaro Date: Thu, 2 Apr 2026 11:18:54 -0600 Subject: [PATCH 02/32] docs: design and impl plans --- docs/plans/2026-04-02-graph-view-design.md | 71 ++ docs/plans/2026-04-02-graph-view-impl.md | 725 +++++++++++++++++++++ 2 files changed, 796 insertions(+) create mode 100644 docs/plans/2026-04-02-graph-view-design.md create mode 100644 docs/plans/2026-04-02-graph-view-impl.md diff --git a/docs/plans/2026-04-02-graph-view-design.md b/docs/plans/2026-04-02-graph-view-design.md new file mode 100644 index 0000000..b18133f --- /dev/null +++ b/docs/plans/2026-04-02-graph-view-design.md @@ -0,0 +1,71 @@ +# Graph View Design + +## Goal + +Add an interactive graph visualization to the Workspace that shows the full DAG (nodes and edges) as a zoomable, filterable dot-based view. Toggled via a button in the Workspace header, replacing SimpleFocusView in the center area. + +## Architecture + +New `GraphView.tsx` component renders in the Workspace center area when `centerView === "graph"`. A toggle button in the header switches between `"focus"` (SimpleFocusView) and `"graph"` (GraphView). The outliner and all side panels remain available in both modes. + +**Dependencies**: `@xyflow/react` (React Flow v12) for the canvas, `elkjs` for auto-layout. Both lazy-imported to avoid bloating the initial bundle. + +**Data flow**: Workspace already has `nodeMap` and `edges`. These are transformed into React Flow `Node[]` and `Edge[]`, passed through ELK layout for positioning, then rendered. + +## Node Rendering + +- **Dots only by default** — circles, no text at rest +- **Size proportional to entropy** — high entropy = larger dot (24px), low = smaller (12px), no data = default (16px). Linear mapping across the visible set's min/max entropy. +- **Color by state** — green (answered), amber (unanswered), blue (needs_review), gray (deleted). Subtle glow on hover. +- **Adaptive labels** — when zoom level > 1.5, a short label fades in below the dot (first ~30 chars of the question). Below that zoom, dots only. + +## Edges + +- Thin lines (1-2px), `gray-600`, small arrowheads at child end +- Edges connected to the selected node highlight in `gray-400` + +## Interactions + +- **Hover**: Tooltip with question text (~100 chars), state, entropy score +- **Click**: Node selected — a floating detail card appears near the node showing question, answer preview, entropy, and an **"Open in Focus View"** button +- **"Open in Focus View"**: Sets `focusNodeId` and switches `centerView` to `"focus"` — same as clicking a node in the outliner + +## Filtering & Search + +Slim filter bar docked at the top of the graph area: + +- **Search input**: Debounced (200ms) text filter on question/answer content. Non-matching nodes fade to low opacity (not removed — preserves spatial context). Matching nodes get a highlight ring. +- **State filter pills**: Toggle pills for answered, unanswered, needs_review. All active by default. Toggling off fades those nodes. +- **Entropy range**: Min/max slider when entropy data exists. +- **Re-layout button**: Recomputes ELK positions using only the visible (non-faded) nodes for a compact filtered view. +- **Fit-to-view button**: Zooms/pans to fit all visible nodes (`fitView()`). + +Fade-not-remove keeps the spatial map stable — nodes don't shift when you filter. + +## Layout + +**ELK layered layout** (Sugiyama-style), top-to-bottom: + +- Roots at top, leaves at bottom, edges flow downward +- Spacing adapts to node count — generous for small graphs (<30), tighter for large (100+) +- Feature roots placed in distinct horizontal lanes via ELK partitioning (independent subtrees don't overlap) +- Layout runs in a **web worker** to avoid blocking the UI thread +- Recomputes when node/edge set changes or when "Re-layout" is clicked after filtering + +**Pan & zoom**: React Flow built-in — scroll to zoom, drag to pan. Minimap in bottom-right corner with dots colored to match node state. + +**Initial view**: `fitView()` on mount centers the entire graph. + +## Integration Points + +- **Workspace.tsx**: New `centerView` state (`"focus" | "graph"`), toggle button in header, conditional render of GraphView vs SimpleFocusView +- **Outliner sync**: Selecting a node in the outliner highlights it in the graph; selecting in the graph highlights in the outliner +- **Existing data**: Reuses `nodeMap`, `edges`, `computeSubtreeEntropy()` from `graph-utils.ts` + +## Files + +- Create: `web/src/GraphView.tsx` — main graph component +- Create: `web/src/DotNode.tsx` — custom React Flow node component +- Create: `web/src/graph-layout.ts` — ELK layout helper (web worker wrapper) +- Modify: `web/src/Workspace.tsx` — toggle state, conditional render, header button +- Modify: `web/package.json` — add `@xyflow/react`, `elkjs` diff --git a/docs/plans/2026-04-02-graph-view-impl.md b/docs/plans/2026-04-02-graph-view-impl.md new file mode 100644 index 0000000..d84de7f --- /dev/null +++ b/docs/plans/2026-04-02-graph-view-impl.md @@ -0,0 +1,725 @@ +# Graph View Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add an interactive, filterable graph visualization to the Workspace that shows the full DAG as zoomable dots with adaptive labels. + +**Architecture:** A new `GraphView.tsx` component replaces `SimpleFocusView` in the Workspace center area when toggled. Uses React Flow v12 for rendering and ELK for auto-layout. Nodes are colored dots sized by entropy. Filter bar at top for search + state filtering. Clicking a node shows a detail popover; "Open in Focus View" switches back to SimpleFocusView. + +**Tech Stack:** React Flow (`@xyflow/react`), ELK (`elkjs`), Tailwind CSS, TypeScript + +--- + +### Task 1: Install dependencies + +**Files:** +- Modify: `web/package.json` + +**Step 1: Install React Flow and ELK** + +```bash +cd /Users/nick/dev/essential/spec-forest/web && bun add @xyflow/react elkjs +``` + +**Step 2: Verify it builds** + +Run: `cd /Users/nick/dev/essential/spec-forest/web && bun run build` +Expected: clean build + +**Step 3: Commit** + +``` +feat(web): add @xyflow/react and elkjs dependencies +``` + +--- + +### Task 2: Create `DotNode.tsx` — custom React Flow node component + +**Files:** +- Create: `web/src/DotNode.tsx` + +**Step 1: Create the component** + +This is a custom React Flow node that renders as a colored circle. It receives data via React Flow's `data` prop. + +```tsx +import { memo } from "react" +import { Handle, Position, type NodeProps } from "@xyflow/react" + +export interface DotNodeData { + question: string + answer: string | null + state: "answered" | "unanswered" | "needs_review" | "deleted" + entropy: number | null + showLabel: boolean + selected: boolean +} + +const STATE_COLORS: Record = { + answered: "#22c55e", // green-500 + unanswered: "#f59e0b", // amber-500 + needs_review: "#3b82f6", // blue-500 + deleted: "#6b7280", // gray-500 +} + +function entropyToSize(entropy: number | null): number { + if (entropy == null) return 16 + // entropy typically 0-10, map to 12-28px + return Math.max(12, Math.min(28, 12 + entropy * 1.6)) +} + +function DotNode({ data }: NodeProps) { + const nodeData = data as unknown as DotNodeData + const size = entropyToSize(nodeData.entropy) + const color = STATE_COLORS[nodeData.state] ?? STATE_COLORS.deleted + + return ( +
+ +
+ {nodeData.showLabel && ( +
+ {nodeData.question.slice(0, 40)} +
+ )} + +
+ ) +} + +export default memo(DotNode) +``` + +**Step 2: Verify it builds** + +Run: `cd /Users/nick/dev/essential/spec-forest/web && bun run build` +Expected: clean build (component not yet used, but must compile) + +**Step 3: Commit** + +``` +feat(web): create DotNode custom React Flow node component +``` + +--- + +### Task 3: Create `graph-layout.ts` — ELK layout helper + +**Files:** +- Create: `web/src/graph-layout.ts` + +**Step 1: Create the layout helper** + +This module takes React Flow nodes/edges and returns them with computed positions from ELK. + +```typescript +import ELK from "elkjs/lib/elk.bundled.js" +import type { Node, Edge } from "@xyflow/react" + +const elk = new ELK() + +export async function layoutGraph( + nodes: Node[], + edges: Edge[], +): Promise { + const nodeCount = nodes.length + // Adaptive spacing: generous for small graphs, tighter for large + const nodeSpacing = Math.max(40, 100 - nodeCount * 0.5) + const layerSpacing = Math.max(60, 120 - nodeCount * 0.4) + + const elkGraph = { + id: "root", + layoutOptions: { + "elk.algorithm": "layered", + "elk.direction": "DOWN", + "elk.spacing.nodeNode": String(nodeSpacing), + "elk.layered.spacing.nodeNodeBetweenLayers": String(layerSpacing), + "elk.layered.crossingMinimization.strategy": "LAYER_SWEEP", + "elk.partitioning.activate": "true", + }, + children: nodes.map((node) => ({ + id: node.id, + width: 30, + height: 30, + layoutOptions: node.data.partition != null + ? { "elk.partitioning.partition": String(node.data.partition) } + : {}, + })), + edges: edges.map((edge) => ({ + id: `${edge.source}-${edge.target}`, + sources: [edge.source], + targets: [edge.target], + })), + } + + const layout = await elk.layout(elkGraph) + + const positionMap = new Map() + for (const child of layout.children ?? []) { + positionMap.set(child.id, { x: child.x ?? 0, y: child.y ?? 0 }) + } + + return nodes.map((node) => ({ + ...node, + position: positionMap.get(node.id) ?? node.position, + })) +} +``` + +**Step 2: Verify it builds** + +Run: `cd /Users/nick/dev/essential/spec-forest/web && bun run build` +Expected: clean build + +**Step 3: Commit** + +``` +feat(web): create ELK graph layout helper +``` + +--- + +### Task 4: Create `GraphView.tsx` — main graph component + +**Files:** +- Create: `web/src/GraphView.tsx` + +**Step 1: Create the component** + +This is the main graph view with filter bar, React Flow canvas, minimap, and detail popover. + +Key references: +- `web/src/types.ts`: `NodeMap`, `EnrichedNode`, `DagEdge`, `NodeState` +- `web/src/graph-utils.ts`: `getFeatureRoots(nodeMap, contextNodeId)` for partition assignment +- `web/src/graph-layout.ts`: `layoutGraph(nodes, edges)` for ELK positioning +- `web/src/DotNode.tsx`: custom node type + +```tsx +import { useState, useEffect, useMemo, useCallback } from "react" +import { + ReactFlow, + MiniMap, + Controls, + useReactFlow, + useViewport, + ReactFlowProvider, + type Node, + type Edge, + type NodeMouseHandler, +} from "@xyflow/react" +import "@xyflow/react/dist/style.css" +import type { NodeMap, DagEdge, EnrichedNode, NodeState } from "./types" +import { getFeatureRoots } from "./graph-utils" +import { layoutGraph } from "./graph-layout" +import DotNode, { type DotNodeData } from "./DotNode" + +const nodeTypes = { dot: DotNode } + +interface GraphViewProps { + nodeMap: NodeMap + edges: DagEdge[] + contextNodeId: string | null + focusedNodeId: string | null + onSelectNode: (nodeId: string) => void + onFocusNode: (nodeId: string) => void +} + +const STATE_COLORS: Record = { + answered: "#22c55e", + unanswered: "#f59e0b", + needs_review: "#3b82f6", + deleted: "#6b7280", +} + +type StateFilter = Record + +const ALL_STATES: StateFilter = { + answered: true, + unanswered: true, + needs_review: true, + deleted: true, +} + +function GraphViewInner({ + nodeMap, + edges, + contextNodeId, + focusedNodeId, + onSelectNode, + onFocusNode, +}: GraphViewProps) { + const [search, setSearch] = useState("") + const [stateFilter, setStateFilter] = useState({ ...ALL_STATES }) + const [selectedNodeId, setSelectedNodeId] = useState(null) + const [layoutNodes, setLayoutNodes] = useState([]) + const [flowEdges, setFlowEdges] = useState([]) + const [layoutReady, setLayoutReady] = useState(false) + + const { fitView } = useReactFlow() + const { zoom } = useViewport() + const showLabels = zoom > 1.5 + + // Assign partitions based on feature roots + const partitionMap = useMemo(() => { + const map = new Map() + const roots = getFeatureRoots(nodeMap, contextNodeId) + // Context node gets partition 0 if it exists + if (contextNodeId && nodeMap[contextNodeId]) { + map.set(contextNodeId, 0) + } + roots.forEach((root, i) => { + map.set(root.id, i + 1) + }) + // Propagate partition to children (BFS) + const queue = [...map.entries()].map(([id, p]) => ({ id, partition: p })) + while (queue.length > 0) { + const { id, partition } = queue.shift()! + const node = nodeMap[id] + if (!node) continue + for (const childId of node.children) { + if (!map.has(childId)) { + map.set(childId, partition) + queue.push({ id: childId, partition }) + } + } + } + return map + }, [nodeMap, contextNodeId]) + + // Build React Flow nodes and edges from nodeMap + const { rfNodes, rfEdges } = useMemo(() => { + const rfNodes: Node[] = Object.values(nodeMap) + .filter((n) => n.state !== "deleted" || stateFilter.deleted) + .map((n) => ({ + id: n.id, + type: "dot", + position: { x: 0, y: 0 }, + data: { + question: n.question, + answer: n.answer, + state: n.state, + entropy: n.residual_entropy ?? n.entropy_score, + showLabel: false, + selected: false, + partition: partitionMap.get(n.id) ?? 0, + } satisfies DotNodeData & { partition: number }, + })) + + const nodeIds = new Set(rfNodes.map((n) => n.id)) + const rfEdges: Edge[] = edges + .filter((e) => nodeIds.has(e.parent_id) && nodeIds.has(e.child_id)) + .map((e) => ({ + id: `${e.parent_id}-${e.child_id}`, + source: e.parent_id, + target: e.child_id, + style: { stroke: "#4b5563", strokeWidth: 1.5 }, + markerEnd: { type: "arrowclosed" as const, width: 12, height: 12, color: "#4b5563" }, + })) + + return { rfNodes, rfEdges } + }, [nodeMap, edges, stateFilter, partitionMap]) + + // Run ELK layout + useEffect(() => { + if (rfNodes.length === 0) { + setLayoutNodes([]) + setFlowEdges([]) + setLayoutReady(true) + return + } + let cancelled = false + layoutGraph(rfNodes, rfEdges).then((positioned) => { + if (cancelled) return + setLayoutNodes(positioned) + setFlowEdges(rfEdges) + setLayoutReady(true) + }) + return () => { cancelled = true } + }, [rfNodes, rfEdges]) + + // Fit view after layout + useEffect(() => { + if (layoutReady && layoutNodes.length > 0) { + // Small delay to let React Flow render first + const t = setTimeout(() => fitView({ padding: 0.15, duration: 300 }), 50) + return () => clearTimeout(t) + } + }, [layoutReady, layoutNodes.length, fitView]) + + // Apply search, labels, and selection to positioned nodes + const searchLower = search.toLowerCase() + const displayNodes = useMemo(() => { + return layoutNodes.map((node) => { + const d = node.data as unknown as DotNodeData & { partition: number } + const matchesSearch = !search || d.question.toLowerCase().includes(searchLower) || (d.answer?.toLowerCase().includes(searchLower) ?? false) + const matchesState = stateFilter[d.state as NodeState] + const faded = !matchesSearch || !matchesState + + return { + ...node, + data: { + ...d, + showLabel: showLabels && !faded, + selected: node.id === selectedNodeId || node.id === focusedNodeId, + }, + style: { + opacity: faded ? 0.15 : 1, + transition: "opacity 0.3s", + }, + } + }) + }, [layoutNodes, search, searchLower, stateFilter, showLabels, selectedNodeId, focusedNodeId]) + + // Highlight edges connected to selected node + const displayEdges = useMemo(() => { + const activeId = selectedNodeId ?? focusedNodeId + if (!activeId) return flowEdges + return flowEdges.map((e) => { + const connected = e.source === activeId || e.target === activeId + return { + ...e, + style: { + ...e.style, + stroke: connected ? "#9ca3af" : "#4b5563", + strokeWidth: connected ? 2 : 1.5, + }, + } + }) + }, [flowEdges, selectedNodeId, focusedNodeId]) + + const selectedNode = selectedNodeId ? nodeMap[selectedNodeId] : null + + const onNodeClick: NodeMouseHandler = useCallback((_event, node) => { + setSelectedNodeId(node.id) + onSelectNode(node.id) + }, [onSelectNode]) + + const onPaneClick = useCallback(() => { + setSelectedNodeId(null) + }, []) + + const handleRelayout = useCallback(() => { + // Filter to only visible nodes, re-layout + const visible = rfNodes.filter((n) => { + const d = n.data as unknown as DotNodeData + const matchesSearch = !search || d.question.toLowerCase().includes(searchLower) || (d.answer?.toLowerCase().includes(searchLower) ?? false) + const matchesState = stateFilter[d.state as NodeState] + return matchesSearch && matchesState + }) + const visibleIds = new Set(visible.map((n) => n.id)) + const visibleEdges = rfEdges.filter((e) => visibleIds.has(e.source) && visibleIds.has(e.target)) + layoutGraph(visible, visibleEdges).then((positioned) => { + setLayoutNodes(positioned) + setFlowEdges(visibleEdges) + setTimeout(() => fitView({ padding: 0.15, duration: 300 }), 50) + }) + }, [rfNodes, rfEdges, search, searchLower, stateFilter, fitView]) + + const toggleState = (state: NodeState) => { + setStateFilter((prev) => ({ ...prev, [state]: !prev[state] })) + } + + return ( +
+ {/* Filter bar */} +
+ setSearch(e.target.value)} + placeholder="Filter nodes..." + className="w-48 px-2 py-1 text-xs bg-gray-900 border border-gray-700 rounded text-gray-200 placeholder-gray-500 focus:outline-none focus:border-gray-500" + /> + {(["answered", "unanswered", "needs_review"] as const).map((state) => ( + + ))} +
+ + +
+ + {/* Graph canvas */} +
+ + + { + const d = node.data as unknown as DotNodeData + return STATE_COLORS[d.state] ?? "#6b7280" + }} + maskColor="rgba(0,0,0,0.7)" + className="!bg-gray-900 !border-gray-700" + /> + + + {/* Detail popover */} + {selectedNode && ( +
+

{selectedNode.question}

+ {selectedNode.answer && ( +

{selectedNode.answer}

+ )} +
+ + {selectedNode.state.replace("_", " ")} + + {(selectedNode.residual_entropy ?? selectedNode.entropy_score) != null && ( + entropy: {(selectedNode.residual_entropy ?? selectedNode.entropy_score)?.toFixed(1)} + )} +
+ +
+ )} +
+
+ ) +} + +export default function GraphView(props: GraphViewProps) { + return ( + + + + ) +} +``` + +**Step 2: Verify it builds** + +Run: `cd /Users/nick/dev/essential/spec-forest/web && bun run build` +Expected: clean build + +**Step 3: Commit** + +``` +feat(web): create GraphView component with React Flow + ELK layout +``` + +--- + +### Task 5: Wire GraphView into Workspace + +**Files:** +- Modify: `web/src/Workspace.tsx:57` (SidePanel type area) +- Modify: `web/src/Workspace.tsx:92` (state declarations) +- Modify: `web/src/Workspace.tsx:307` (header bar) +- Modify: `web/src/Workspace.tsx:405-429` (main content area) + +**Step 1: Add import** + +At the top of `web/src/Workspace.tsx`, add: + +```typescript +import GraphView from "./GraphView" +``` + +**Step 2: Add center view state** + +Near line 92, after the existing state declarations, add: + +```typescript +const [centerView, setCenterView] = useState<"focus" | "graph">("focus") +``` + +**Step 3: Add toggle button in the header** + +In the header bar, after the `NextQuestionButton` (around line 341) and before the ModelSelector, add: + +```tsx + +``` + +**Step 4: Conditional render in main content area** + +Replace the `
` section (around lines 406-429) that currently always renders SimpleFocusView: + +```tsx +
+ {centerView === "graph" ? ( + { + navigateTo(nodeId) + setCenterView("focus") + }} + /> + ) : ( + + )} +
+``` + +Note: the `
` tag needs `overflow-hidden` instead of `overflow-y-auto` when in graph mode (React Flow manages its own scrolling). Simplest approach: use `overflow-hidden` always — SimpleFocusView has its own internal scroll container. Check if this works; if not, make it conditional: `className={`flex-1 ${centerView === "graph" ? "overflow-hidden" : "overflow-y-auto"}`}`. + +**Step 5: Verify it builds** + +Run: `cd /Users/nick/dev/essential/spec-forest/web && bun run build` +Expected: clean build + +**Step 6: Commit** + +``` +feat(web): wire GraphView toggle into Workspace header +``` + +--- + +### Task 6: Import React Flow CSS + +**Files:** +- Modify: `web/src/GraphView.tsx` (already has the import, verify it works) + +React Flow requires its base CSS. The import `@xyflow/react/dist/style.css` is already in GraphView.tsx. If the Vite build doesn't handle this correctly (some setups need explicit CSS imports in the root), add it to `web/src/main.tsx` or `web/src/index.css` instead. + +**Step 1: Verify React Flow renders** + +Run: `cd /Users/nick/dev/essential/spec-forest/web && bun run dev` + +Open the app, navigate to a spec's workspace, click the "Graph" button. Verify: +- The graph renders with colored dots +- Pan and zoom work +- Minimap appears in the bottom-right +- Clicking a dot shows the detail popover +- "Open in Focus View" switches back + +**Step 2: Fix any visual issues** + +Common issues: +- React Flow container needs explicit height — the `flex-1` + `h-full` on the wrapper div should handle this, but verify +- Minimap may need dark theme overrides — the CSS class overrides in the plan should handle this +- Node handles may be visible — the transparent handle styles should hide them + +**Step 3: Commit any fixes** + +``` +fix(web): polish GraphView rendering and dark theme +``` + +--- + +### Task 7: Build and verify + +**Step 1: Full build** + +Run: `cd /Users/nick/dev/essential/spec-forest/web && bun run build` +Expected: clean build + +**Step 2: Cargo build** (to pick up the new web dist assets) + +Run: `cargo build` +Expected: clean build + +**Step 3: Manual testing checklist** + +1. Toggle button in workspace header switches between focus view and graph view +2. Nodes render as colored dots with correct size/color mapping +3. Labels appear when zoomed in past threshold +4. Search input fades non-matching nodes without shifting layout +5. State filter pills fade nodes by state +6. Clicking a node shows detail popover at bottom-left +7. "Open in Focus View" navigates to SimpleFocusView with that node focused +8. Minimap shows colored overview +9. Fit button works +10. Re-layout button compacts filtered view +11. Works with small (<10 nodes) and large specs + +**Step 4: Commit** + +``` +chore: final graph view polish and build verification +``` From c1beaa7f08cb7a1f722db86fc5a48cc891d945cd Mon Sep 17 00:00:00 2001 From: Nick Furfaro Date: Thu, 2 Apr 2026 11:26:13 -0600 Subject: [PATCH 03/32] feat(web): add @xyflow/react and elkjs dependencies --- web/bun.lock | 53 ++++++++++++++++++++++++++++++++++++++++++++++++ web/package.json | 2 ++ 2 files changed, 55 insertions(+) diff --git a/web/bun.lock b/web/bun.lock index 0ee14e9..5941310 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -5,13 +5,16 @@ "": { "name": "web", "dependencies": { + "@xyflow/react": "^12.10.2", "diff-match-patch": "^1.0.5", + "elkjs": "^0.11.1", "react": "^19.2.0", "react-dom": "^19.2.0", "react-markdown": "^10.1.0", }, "devDependencies": { "@eslint/js": "^9.39.1", + "@playwright/test": "^1.58.2", "@tailwindcss/vite": "^4.2.1", "@types/diff-match-patch": "^1.0.36", "@types/node": "^24.10.1", @@ -156,6 +159,8 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@playwright/test": ["@playwright/test@1.59.1", "", { "dependencies": { "playwright": "1.59.1" }, "bin": { "playwright": "cli.js" } }, "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg=="], + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="], @@ -246,6 +251,18 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], + + "@types/d3-drag": ["@types/d3-drag@3.0.7", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ=="], + + "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], + + "@types/d3-selection": ["@types/d3-selection@3.0.11", "", {}, "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w=="], + + "@types/d3-transition": ["@types/d3-transition@3.0.9", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg=="], + + "@types/d3-zoom": ["@types/d3-zoom@3.0.8", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw=="], + "@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="], "@types/diff-match-patch": ["@types/diff-match-patch@1.0.36", "", {}, "sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg=="], @@ -294,6 +311,10 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.4", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA=="], + "@xyflow/react": ["@xyflow/react@12.10.2", "", { "dependencies": { "@xyflow/system": "0.0.76", "classcat": "^5.0.3", "zustand": "^4.4.0" }, "peerDependencies": { "react": ">=17", "react-dom": ">=17" } }, "sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ=="], + + "@xyflow/system": ["@xyflow/system@0.0.76", "", { "dependencies": { "@types/d3-drag": "^3.0.7", "@types/d3-interpolate": "^3.0.4", "@types/d3-selection": "^3.0.10", "@types/d3-transition": "^3.0.8", "@types/d3-zoom": "^3.0.8", "d3-drag": "^3.0.0", "d3-interpolate": "^3.0.1", "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0" } }, "sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA=="], + "acorn": ["acorn@8.16.0", "", { "bin": "bin/acorn" }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], @@ -330,6 +351,8 @@ "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], + "classcat": ["classcat@5.0.5", "", {}, "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w=="], + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], @@ -344,6 +367,24 @@ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], + + "d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="], + + "d3-drag": ["d3-drag@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" } }, "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg=="], + + "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], + + "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], + + "d3-selection": ["d3-selection@3.0.0", "", {}, "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="], + + "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], + + "d3-transition": ["d3-transition@3.0.1", "", { "dependencies": { "d3-color": "1 - 3", "d3-dispatch": "1 - 3", "d3-ease": "1 - 3", "d3-interpolate": "1 - 3", "d3-timer": "1 - 3" }, "peerDependencies": { "d3-selection": "2 - 3" } }, "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w=="], + + "d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], @@ -360,6 +401,8 @@ "electron-to-chromium": ["electron-to-chromium@1.5.307", "", {}, "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg=="], + "elkjs": ["elkjs@0.11.1", "", {}, "sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg=="], + "enhanced-resolve": ["enhanced-resolve@5.20.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ=="], "esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": "bin/esbuild" }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], @@ -594,6 +637,10 @@ "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="], + + "playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="], + "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], @@ -676,6 +723,8 @@ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], @@ -694,6 +743,8 @@ "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], + "zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], @@ -710,6 +761,8 @@ "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], diff --git a/web/package.json b/web/package.json index 845ccbb..f65374b 100644 --- a/web/package.json +++ b/web/package.json @@ -13,7 +13,9 @@ "test:ux-audit": "playwright test --config e2e/ux-audit/ux-audit.config.ts" }, "dependencies": { + "@xyflow/react": "^12.10.2", "diff-match-patch": "^1.0.5", + "elkjs": "^0.11.1", "react": "^19.2.0", "react-dom": "^19.2.0", "react-markdown": "^10.1.0" From fc7f0741ae1e8320720ec3557073509ba39b9144 Mon Sep 17 00:00:00 2001 From: Nick Furfaro Date: Thu, 2 Apr 2026 11:27:34 -0600 Subject: [PATCH 04/32] feat(web): create DotNode custom React Flow node component --- web/src/DotNode.tsx | 68 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 web/src/DotNode.tsx diff --git a/web/src/DotNode.tsx b/web/src/DotNode.tsx new file mode 100644 index 0000000..5689231 --- /dev/null +++ b/web/src/DotNode.tsx @@ -0,0 +1,68 @@ +import { memo } from "react" +import { Handle, Position } from "@xyflow/react" +import type { NodeProps } from "@xyflow/react" + +export interface DotNodeData { + question: string + answer: string | null + state: "answered" | "unanswered" | "needs_review" | "deleted" + entropy: number | null + showLabel: boolean + selected: boolean +} + +const STATE_COLORS: Record = { + answered: "#22c55e", + unanswered: "#f59e0b", + needs_review: "#3b82f6", + deleted: "#6b7280", +} + +function entropyToSize(entropy: number | null): number { + if (entropy == null) return 16 + const clamped = Math.max(0, Math.min(10, entropy)) + return 12 + (clamped / 10) * 16 +} + +function truncateLabel(text: string, maxChars: number): string { + if (text.length <= maxChars) return text + return text.slice(0, maxChars).trimEnd() + "…" +} + +function DotNode({ data }: NodeProps & { data: DotNodeData }) { + const color = STATE_COLORS[data.state] + const size = entropyToSize(data.entropy) + + return ( +
+
+ {data.showLabel && ( + + {truncateLabel(data.question, 40)} + + )} + + +
+ ) +} + +export default memo(DotNode) From 5b76469e459907dd83f8ded456310356df2439f5 Mon Sep 17 00:00:00 2001 From: Nick Furfaro Date: Thu, 2 Apr 2026 11:29:50 -0600 Subject: [PATCH 05/32] feat(web): create ELK graph layout helper --- web/src/graph-layout.ts | 52 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 web/src/graph-layout.ts diff --git a/web/src/graph-layout.ts b/web/src/graph-layout.ts new file mode 100644 index 0000000..449c12a --- /dev/null +++ b/web/src/graph-layout.ts @@ -0,0 +1,52 @@ +import ELK from "elkjs/lib/elk.bundled.js" +import type { Node, Edge } from "@xyflow/react" + +const elk = new ELK() + +export async function layoutGraph( + nodes: Node[], + edges: Edge[], +): Promise { + const nodeCount = nodes.length + // Adaptive spacing: generous for small graphs, tighter for large + const nodeSpacing = Math.max(40, 100 - nodeCount * 0.5) + const layerSpacing = Math.max(60, 120 - nodeCount * 0.4) + + const elkGraph = { + id: "root", + layoutOptions: { + "elk.algorithm": "layered", + "elk.direction": "DOWN", + "elk.spacing.nodeNode": String(nodeSpacing), + "elk.layered.spacing.nodeNodeBetweenLayers": String(layerSpacing), + "elk.layered.crossingMinimization.strategy": "LAYER_SWEEP", + "elk.partitioning.activate": "true", + }, + children: nodes.map((node) => ({ + id: node.id, + width: 30, + height: 30, + layoutOptions: + node.data.partition != null + ? { "elk.partitioning.partition": String(node.data.partition) } + : ({} as Record), + })), + edges: edges.map((edge) => ({ + id: `${edge.source}-${edge.target}`, + sources: [edge.source], + targets: [edge.target], + })), + } + + const layout = await elk.layout(elkGraph) + + const positionMap = new Map() + for (const child of layout.children ?? []) { + positionMap.set(child.id, { x: child.x ?? 0, y: child.y ?? 0 }) + } + + return nodes.map((node) => ({ + ...node, + position: positionMap.get(node.id) ?? node.position, + })) +} From 315ba9f25abf2161eeed5824f2f5c5b7de3c613f Mon Sep 17 00:00:00 2001 From: Nick Furfaro Date: Thu, 2 Apr 2026 11:33:32 -0600 Subject: [PATCH 06/32] feat(web): create GraphView component with React Flow + ELK layout --- web/src/GraphView.tsx | 379 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 379 insertions(+) create mode 100644 web/src/GraphView.tsx diff --git a/web/src/GraphView.tsx b/web/src/GraphView.tsx new file mode 100644 index 0000000..572b9da --- /dev/null +++ b/web/src/GraphView.tsx @@ -0,0 +1,379 @@ +import { useState, useCallback, useEffect, useMemo, useRef } from "react" +import { + ReactFlow, + ReactFlowProvider, + Controls, + MiniMap, + useReactFlow, + useViewport, + type Node, + type Edge, + type NodeMouseHandler, +} from "@xyflow/react" +import "@xyflow/react/dist/style.css" + +import DotNode from "./DotNode" +import type { DotNodeData } from "./DotNode" +import { layoutGraph } from "./graph-layout" +import { getFeatureRoots } from "./graph-utils" +import type { NodeMap, DagEdge, NodeState } from "./types" + +const STATE_COLORS: Record = { + answered: "#22c55e", + unanswered: "#f59e0b", + needs_review: "#3b82f6", + deleted: "#6b7280", +} + +const FILTER_STATES: NodeState[] = ["answered", "unanswered", "needs_review"] + +const nodeTypes = { dot: DotNode } + +interface GraphViewProps { + nodeMap: NodeMap + edges: DagEdge[] + contextNodeId: string | null + focusedNodeId: string | null + onSelectNode: (nodeId: string) => void + onFocusNode: (nodeId: string) => void +} + +// BFS partition assignment from feature roots +function assignPartitions( + nodeMap: NodeMap, + contextNodeId: string | null, +): Map { + const roots = getFeatureRoots(nodeMap, contextNodeId) + const partitions = new Map() + + roots.forEach((root, idx) => { + const queue = [root.id] + while (queue.length > 0) { + const id = queue.shift()! + if (partitions.has(id)) continue + partitions.set(id, idx) + const node = nodeMap[id] + if (node) { + for (const childId of node.children) { + if (!partitions.has(childId)) queue.push(childId) + } + } + } + }) + + return partitions +} + +function buildFlowNodes( + nodeMap: NodeMap, + contextNodeId: string | null, + selectedNodeId: string | null, + showLabel: boolean, +): Node[] { + const partitions = assignPartitions(nodeMap, contextNodeId) + + return Object.values(nodeMap).map((node) => ({ + id: node.id, + type: "dot", + position: { x: 0, y: 0 }, + data: { + question: node.question, + answer: node.answer, + state: node.state, + entropy: node.entropy_score, + showLabel, + selected: node.id === selectedNodeId, + partition: partitions.get(node.id) ?? 0, + } satisfies DotNodeData & { partition: number }, + })) +} + +function buildFlowEdges( + edges: DagEdge[], + selectedNodeId: string | null, + nodeMap: NodeMap, +): Edge[] { + const connectedEdges = new Set() + if (selectedNodeId && nodeMap[selectedNodeId]) { + const node = nodeMap[selectedNodeId] + for (const pid of node.parents) { + connectedEdges.add(`${pid}-${selectedNodeId}`) + } + for (const cid of node.children) { + connectedEdges.add(`${selectedNodeId}-${cid}`) + } + } + + return edges.map((e) => { + const edgeId = `${e.parent_id}-${e.child_id}` + const isConnected = connectedEdges.has(edgeId) + return { + id: edgeId, + source: e.parent_id, + target: e.child_id, + style: { + stroke: isConnected ? "#9ca3af" : "#4b5563", + strokeWidth: isConnected ? 1.5 : 1, + }, + markerEnd: { + type: "arrowclosed" as const, + width: 10, + height: 10, + color: isConnected ? "#9ca3af" : "#4b5563", + }, + } + }) +} + +function isNodeVisible( + node: { question: string; answer: string | null; state: NodeState }, + searchQuery: string, + activeStates: Set, +): boolean { + if (activeStates.size > 0 && !activeStates.has(node.state)) return false + if (searchQuery) { + const q = searchQuery.toLowerCase() + const matchesQuestion = node.question.toLowerCase().includes(q) + const matchesAnswer = node.answer?.toLowerCase().includes(q) ?? false + if (!matchesQuestion && !matchesAnswer) return false + } + return true +} + +function GraphViewInner({ + nodeMap, + edges, + contextNodeId, + focusedNodeId: _focusedNodeId, + onSelectNode, + onFocusNode, +}: GraphViewProps) { + const { fitView } = useReactFlow() + const { zoom } = useViewport() + + const [flowNodes, setFlowNodes] = useState([]) + const [, setFlowEdges] = useState([]) + const [selectedNodeId, setSelectedNodeId] = useState(null) + const [searchQuery, setSearchQuery] = useState("") + const [activeStates, setActiveStates] = useState>(new Set()) + const layoutDone = useRef(false) + + const showLabel = zoom > 1.5 + + // Build and layout on mount or when nodeMap/edges change + useEffect(() => { + const nodes = buildFlowNodes(nodeMap, contextNodeId, null, false) + const rfEdges = buildFlowEdges(edges, null, nodeMap) + + layoutGraph(nodes, rfEdges).then((positioned) => { + setFlowNodes(positioned) + setFlowEdges(rfEdges) + layoutDone.current = true + // fitView after layout with a small delay for React Flow to render + setTimeout(() => fitView({ padding: 0.15 }), 50) + }) + }, [nodeMap, edges, contextNodeId, fitView]) + + // Update node data when selection, labels, or filters change (no re-layout) + const visibleNodeIds = useMemo(() => { + const set = new Set() + for (const [id, node] of Object.entries(nodeMap)) { + if (isNodeVisible(node, searchQuery, activeStates)) { + set.add(id) + } + } + return set + }, [nodeMap, searchQuery, activeStates]) + + const displayNodes = useMemo( + () => + flowNodes.map((n) => ({ + ...n, + data: { + ...n.data, + showLabel, + selected: n.id === selectedNodeId, + }, + style: { + ...n.style, + opacity: visibleNodeIds.has(n.id) ? 1 : 0.15, + transition: "opacity 200ms ease", + }, + })), + [flowNodes, showLabel, selectedNodeId, visibleNodeIds], + ) + + const displayEdges = useMemo( + () => buildFlowEdges(edges, selectedNodeId, nodeMap), + [edges, selectedNodeId, nodeMap], + ) + + const onNodeClick: NodeMouseHandler = useCallback( + (_event, node) => { + setSelectedNodeId(node.id) + onSelectNode(node.id) + }, + [onSelectNode], + ) + + const onPaneClick = useCallback(() => { + setSelectedNodeId(null) + }, []) + + const toggleState = (state: NodeState) => { + setActiveStates((prev) => { + const next = new Set(prev) + if (next.has(state)) next.delete(state) + else next.add(state) + return next + }) + } + + const handleRelayout = useCallback(() => { + // Re-layout with only visible nodes + const visibleFlowNodes = flowNodes.filter((n) => visibleNodeIds.has(n.id)) + const visibleEdgeSet = new Set( + visibleFlowNodes.map((n) => n.id), + ) + const visibleFlowEdges = displayEdges.filter( + (e) => visibleEdgeSet.has(e.source) && visibleEdgeSet.has(e.target), + ) + + layoutGraph(visibleFlowNodes, visibleFlowEdges).then((positioned) => { + // Merge positioned nodes back, keeping hidden nodes at their old positions + const posMap = new Map(positioned.map((n) => [n.id, n.position])) + setFlowNodes((prev) => + prev.map((n) => ({ + ...n, + position: posMap.get(n.id) ?? n.position, + })), + ) + setTimeout(() => fitView({ padding: 0.15 }), 50) + }) + }, [flowNodes, visibleNodeIds, displayEdges, fitView]) + + const handleFit = useCallback(() => { + fitView({ padding: 0.15 }) + }, [fitView]) + + const selectedNode = selectedNodeId ? nodeMap[selectedNodeId] : null + + return ( +
+ {/* Filter bar */} +
+ setSearchQuery(e.target.value)} + className="bg-gray-800 text-gray-200 text-sm rounded px-2 py-1 w-48 placeholder-gray-500 outline-none focus:ring-1 focus:ring-gray-600" + /> + {FILTER_STATES.map((state) => { + const active = activeStates.has(state) + return ( + + ) + })} +
+ + +
+ + {/* React Flow canvas */} +
+ + + + STATE_COLORS[(node.data as unknown as DotNodeData).state] ?? "#6b7280" + } + /> + + + {/* Detail popover */} + {selectedNode && ( +
+

+ {selectedNode.question} +

+ {selectedNode.answer && ( +

+ {selectedNode.answer} +

+ )} +
+ + {selectedNode.state.replace("_", " ")} + + {selectedNode.entropy_score != null && ( + + entropy: {selectedNode.entropy_score.toFixed(1)} + + )} +
+ +
+ )} +
+
+ ) +} + +export default function GraphView(props: GraphViewProps) { + return ( + + + + ) +} From 67fea770f581f0f087666579092a2ae80fda0461 Mon Sep 17 00:00:00 2001 From: Nick Furfaro Date: Thu, 2 Apr 2026 11:35:08 -0600 Subject: [PATCH 07/32] feat(web): wire GraphView toggle into Workspace header --- web/src/Workspace.tsx | 73 +++++++++++++++++++++++++++++-------------- 1 file changed, 50 insertions(+), 23 deletions(-) diff --git a/web/src/Workspace.tsx b/web/src/Workspace.tsx index 5e12458..ddc71f1 100644 --- a/web/src/Workspace.tsx +++ b/web/src/Workspace.tsx @@ -5,6 +5,7 @@ import { buildNodeMap, computeSubtreeEntropy, firstParentChain, getAllDescendant import { api } from "./api" import OutlinerPanel from "./OutlinerPanel" import SimpleFocusView from "./SimpleFocusView" +import GraphView from "./GraphView" import Breadcrumbs from "./Breadcrumbs" import GlobalSearch from "./GlobalSearch" import NextQuestionButton from "./NextQuestionButton" @@ -95,6 +96,7 @@ export default function Workspace({ const [sidePanel, setSidePanel] = useState(null) const [selectedOutput, setSelectedOutput] = useState(null) const [showConfig, setShowConfig] = useState(false) + const [centerView, setCenterView] = useState<"focus" | "graph">("focus") const nodeMap = useMemo(() => buildNodeMap(nodes, edges), [nodes, edges]) const subtreeEntropyMap = useMemo(() => computeSubtreeEntropy(nodeMap), [nodeMap]) @@ -339,6 +341,17 @@ export default function Workspace({ contextNodeId={contextNodeId} onNavigate={navigateTo} /> + {onModelChange && ( )} @@ -403,29 +416,43 @@ export default function Workspace({ {/* Main content + side panel */}
-
- +
+ {centerView === "graph" ? ( + { + navigateTo(nodeId) + setCenterView("focus") + }} + /> + ) : ( + + )}
{/* Right: Side panel (all panels except branches, which is on the left) */} From c26bf29e730072ce1701b55267120d2ae440cbd6 Mon Sep 17 00:00:00 2001 From: Nick Furfaro Date: Thu, 2 Apr 2026 11:48:17 -0600 Subject: [PATCH 08/32] fix(web): prevent graph view from auto-zooming out on interaction - Remove fitView prop from ReactFlow (was re-fitting on every node update) - Only fitView on initial layout, not on subsequent data changes - Use ref for fitView to avoid effect dependency loop --- web/src/GraphView.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/web/src/GraphView.tsx b/web/src/GraphView.tsx index 572b9da..a92110c 100644 --- a/web/src/GraphView.tsx +++ b/web/src/GraphView.tsx @@ -161,6 +161,9 @@ function GraphViewInner({ const showLabel = zoom > 1.5 // Build and layout on mount or when nodeMap/edges change + const fitViewRef = useRef(fitView) + fitViewRef.current = fitView + useEffect(() => { const nodes = buildFlowNodes(nodeMap, contextNodeId, null, false) const rfEdges = buildFlowEdges(edges, null, nodeMap) @@ -168,11 +171,13 @@ function GraphViewInner({ layoutGraph(nodes, rfEdges).then((positioned) => { setFlowNodes(positioned) setFlowEdges(rfEdges) - layoutDone.current = true - // fitView after layout with a small delay for React Flow to render - setTimeout(() => fitView({ padding: 0.15 }), 50) + if (!layoutDone.current) { + layoutDone.current = true + // fitView only on initial layout + setTimeout(() => fitViewRef.current({ padding: 0.15 }), 50) + } }) - }, [nodeMap, edges, contextNodeId, fitView]) + }, [nodeMap, edges, contextNodeId]) // Update node data when selection, labels, or filters change (no re-layout) const visibleNodeIds = useMemo(() => { @@ -314,7 +319,6 @@ function GraphViewInner({ onPaneClick={onPaneClick} minZoom={0.1} maxZoom={4} - fitView proOptions={{ hideAttribution: true }} style={{ background: "#030712" }} > From 008ad5e9896b707cf6a7bd4aefafb926c436c4bf Mon Sep 17 00:00:00 2001 From: Nick Furfaro Date: Thu, 2 Apr 2026 12:03:06 -0600 Subject: [PATCH 09/32] feat(web): switch graph to stress layout for dense, radial clustering - Replace ELK layered algorithm with stress (force-directed) - Tighter node spacing, nodes cluster naturally around connections - Remove partition assignment (not needed for stress layout) --- web/src/GraphView.tsx | 34 ++-------------------------------- web/src/graph-layout.ts | 18 +++++------------- 2 files changed, 7 insertions(+), 45 deletions(-) diff --git a/web/src/GraphView.tsx b/web/src/GraphView.tsx index a92110c..364fa7a 100644 --- a/web/src/GraphView.tsx +++ b/web/src/GraphView.tsx @@ -15,7 +15,6 @@ import "@xyflow/react/dist/style.css" import DotNode from "./DotNode" import type { DotNodeData } from "./DotNode" import { layoutGraph } from "./graph-layout" -import { getFeatureRoots } from "./graph-utils" import type { NodeMap, DagEdge, NodeState } from "./types" const STATE_COLORS: Record = { @@ -38,40 +37,12 @@ interface GraphViewProps { onFocusNode: (nodeId: string) => void } -// BFS partition assignment from feature roots -function assignPartitions( - nodeMap: NodeMap, - contextNodeId: string | null, -): Map { - const roots = getFeatureRoots(nodeMap, contextNodeId) - const partitions = new Map() - - roots.forEach((root, idx) => { - const queue = [root.id] - while (queue.length > 0) { - const id = queue.shift()! - if (partitions.has(id)) continue - partitions.set(id, idx) - const node = nodeMap[id] - if (node) { - for (const childId of node.children) { - if (!partitions.has(childId)) queue.push(childId) - } - } - } - }) - - return partitions -} - function buildFlowNodes( nodeMap: NodeMap, - contextNodeId: string | null, + _contextNodeId: string | null, selectedNodeId: string | null, showLabel: boolean, ): Node[] { - const partitions = assignPartitions(nodeMap, contextNodeId) - return Object.values(nodeMap).map((node) => ({ id: node.id, type: "dot", @@ -83,8 +54,7 @@ function buildFlowNodes( entropy: node.entropy_score, showLabel, selected: node.id === selectedNodeId, - partition: partitions.get(node.id) ?? 0, - } satisfies DotNodeData & { partition: number }, + } satisfies DotNodeData, })) } diff --git a/web/src/graph-layout.ts b/web/src/graph-layout.ts index 449c12a..8b7b46b 100644 --- a/web/src/graph-layout.ts +++ b/web/src/graph-layout.ts @@ -8,28 +8,20 @@ export async function layoutGraph( edges: Edge[], ): Promise { const nodeCount = nodes.length - // Adaptive spacing: generous for small graphs, tighter for large - const nodeSpacing = Math.max(40, 100 - nodeCount * 0.5) - const layerSpacing = Math.max(60, 120 - nodeCount * 0.4) + // Tighter spacing for dense, radial-ish layout + const desiredEdgeLength = Math.max(30, 60 - nodeCount * 0.2) const elkGraph = { id: "root", layoutOptions: { - "elk.algorithm": "layered", - "elk.direction": "DOWN", - "elk.spacing.nodeNode": String(nodeSpacing), - "elk.layered.spacing.nodeNodeBetweenLayers": String(layerSpacing), - "elk.layered.crossingMinimization.strategy": "LAYER_SWEEP", - "elk.partitioning.activate": "true", + "elk.algorithm": "stress", + "elk.stress.desiredEdgeLength": String(desiredEdgeLength), + "elk.spacing.nodeNode": "15", }, children: nodes.map((node) => ({ id: node.id, width: 30, height: 30, - layoutOptions: - node.data.partition != null - ? { "elk.partitioning.partition": String(node.data.partition) } - : ({} as Record), })), edges: edges.map((edge) => ({ id: `${edge.source}-${edge.target}`, From 1a149216e0462bc05a2e7f98ee96a421929bfb9a Mon Sep 17 00:00:00 2001 From: Nick Furfaro Date: Thu, 2 Apr 2026 12:14:33 -0600 Subject: [PATCH 10/32] fix(web): show labels only on selected node, position popover near click - Remove zoom-based adaptive labels (too noisy on dense graphs) - Only show label below the clicked/selected dot - Position detail popover near the clicked node instead of fixed bottom-left --- web/src/DotNode.tsx | 4 ++-- web/src/GraphView.tsx | 31 ++++++++++++++++++++----------- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/web/src/DotNode.tsx b/web/src/DotNode.tsx index 5689231..d26091d 100644 --- a/web/src/DotNode.tsx +++ b/web/src/DotNode.tsx @@ -46,8 +46,8 @@ function DotNode({ data }: NodeProps & { data: DotNodeData }) { : undefined, }} /> - {data.showLabel && ( - + {data.selected && ( + {truncateLabel(data.question, 40)} )} diff --git a/web/src/GraphView.tsx b/web/src/GraphView.tsx index 364fa7a..2173636 100644 --- a/web/src/GraphView.tsx +++ b/web/src/GraphView.tsx @@ -5,7 +5,6 @@ import { Controls, MiniMap, useReactFlow, - useViewport, type Node, type Edge, type NodeMouseHandler, @@ -119,16 +118,15 @@ function GraphViewInner({ onFocusNode, }: GraphViewProps) { const { fitView } = useReactFlow() - const { zoom } = useViewport() const [flowNodes, setFlowNodes] = useState([]) const [, setFlowEdges] = useState([]) const [selectedNodeId, setSelectedNodeId] = useState(null) + const [popoverPos, setPopoverPos] = useState<{ x: number; y: number } | null>(null) const [searchQuery, setSearchQuery] = useState("") const [activeStates, setActiveStates] = useState>(new Set()) const layoutDone = useRef(false) - - const showLabel = zoom > 1.5 + const containerRef = useRef(null) // Build and layout on mount or when nodeMap/edges change const fitViewRef = useRef(fitView) @@ -166,7 +164,7 @@ function GraphViewInner({ ...n, data: { ...n.data, - showLabel, + showLabel: n.id === selectedNodeId, selected: n.id === selectedNodeId, }, style: { @@ -175,7 +173,7 @@ function GraphViewInner({ transition: "opacity 200ms ease", }, })), - [flowNodes, showLabel, selectedNodeId, visibleNodeIds], + [flowNodes, selectedNodeId, visibleNodeIds], ) const displayEdges = useMemo( @@ -184,15 +182,23 @@ function GraphViewInner({ ) const onNodeClick: NodeMouseHandler = useCallback( - (_event, node) => { + (event, node) => { setSelectedNodeId(node.id) onSelectNode(node.id) + // Position popover near the click, relative to the graph container + if (containerRef.current) { + const rect = containerRef.current.getBoundingClientRect() + const x = (event as unknown as MouseEvent).clientX - rect.left + const y = (event as unknown as MouseEvent).clientY - rect.top + setPopoverPos({ x, y }) + } }, [onSelectNode], ) const onPaneClick = useCallback(() => { setSelectedNodeId(null) + setPopoverPos(null) }, []) const toggleState = (state: NodeState) => { @@ -280,7 +286,7 @@ function GraphViewInner({
{/* React Flow canvas */} -
+
- {/* Detail popover */} - {selectedNode && ( -
+ {/* Detail popover — positioned near clicked node */} + {selectedNode && popoverPos && ( +

{selectedNode.question}

From c5daf2317b5efc996a933c8686a07d59bbaebc10 Mon Sep 17 00:00:00 2001 From: Nick Furfaro Date: Thu, 2 Apr 2026 12:21:31 -0600 Subject: [PATCH 11/32] feat(web): switch to tree layout with smooth edges - Use ELK mrtree algorithm for clean hierarchical tree layout - Replace straight edges + arrowheads with smoothstep curves - Remove arrowhead markers for cleaner look - Adaptive spacing based on node count --- web/src/GraphView.tsx | 9 ++------- web/src/graph-layout.ts | 12 +++++++----- 2 files changed, 9 insertions(+), 12 deletions(-) diff --git a/web/src/GraphView.tsx b/web/src/GraphView.tsx index 2173636..3f58ec6 100644 --- a/web/src/GraphView.tsx +++ b/web/src/GraphView.tsx @@ -80,16 +80,11 @@ function buildFlowEdges( id: edgeId, source: e.parent_id, target: e.child_id, + type: "smoothstep", style: { - stroke: isConnected ? "#9ca3af" : "#4b5563", + stroke: isConnected ? "#6b7280" : "#374151", strokeWidth: isConnected ? 1.5 : 1, }, - markerEnd: { - type: "arrowclosed" as const, - width: 10, - height: 10, - color: isConnected ? "#9ca3af" : "#4b5563", - }, } }) } diff --git a/web/src/graph-layout.ts b/web/src/graph-layout.ts index 8b7b46b..b605a33 100644 --- a/web/src/graph-layout.ts +++ b/web/src/graph-layout.ts @@ -8,15 +8,17 @@ export async function layoutGraph( edges: Edge[], ): Promise { const nodeCount = nodes.length - // Tighter spacing for dense, radial-ish layout - const desiredEdgeLength = Math.max(30, 60 - nodeCount * 0.2) + // Adaptive spacing: tighter for large graphs + const nodeSpacing = Math.max(20, 50 - nodeCount * 0.15) + const layerSpacing = Math.max(40, 80 - nodeCount * 0.2) const elkGraph = { id: "root", layoutOptions: { - "elk.algorithm": "stress", - "elk.stress.desiredEdgeLength": String(desiredEdgeLength), - "elk.spacing.nodeNode": "15", + "elk.algorithm": "mrtree", + "elk.spacing.nodeNode": String(nodeSpacing), + "elk.mrtree.spacing.nodeNode": String(nodeSpacing), + "elk.mrtree.spacing.level": String(layerSpacing), }, children: nodes.map((node) => ({ id: node.id, From 4f7a4758207ddfabfc2591fd7e522464d6931048 Mon Sep 17 00:00:00 2001 From: Nick Furfaro Date: Thu, 2 Apr 2026 12:34:04 -0600 Subject: [PATCH 12/32] feat(web): radial tree layout with root at center, children in concentric rings - Replace ELK mrtree with custom radial layout algorithm - BFS from roots assigns depth, nodes placed in concentric rings - Ring spacing adapts to tree depth - Switch to bezier edges for organic curves - Drop elkjs dependency from layout (pure JS now) --- web/src/GraphView.tsx | 2 +- web/src/graph-layout.ts | 115 ++++++++++++++++++++++++++++++---------- 2 files changed, 87 insertions(+), 30 deletions(-) diff --git a/web/src/GraphView.tsx b/web/src/GraphView.tsx index 3f58ec6..89f70a3 100644 --- a/web/src/GraphView.tsx +++ b/web/src/GraphView.tsx @@ -80,7 +80,7 @@ function buildFlowEdges( id: edgeId, source: e.parent_id, target: e.child_id, - type: "smoothstep", + type: "bezier", style: { stroke: isConnected ? "#6b7280" : "#374151", strokeWidth: isConnected ? 1.5 : 1, diff --git a/web/src/graph-layout.ts b/web/src/graph-layout.ts index b605a33..0477283 100644 --- a/web/src/graph-layout.ts +++ b/web/src/graph-layout.ts @@ -1,42 +1,99 @@ -import ELK from "elkjs/lib/elk.bundled.js" import type { Node, Edge } from "@xyflow/react" -const elk = new ELK() - +/** + * Radial tree layout: root at center, children in concentric rings. + * Each depth level is a ring further from center. + */ export async function layoutGraph( nodes: Node[], edges: Edge[], ): Promise { - const nodeCount = nodes.length - // Adaptive spacing: tighter for large graphs - const nodeSpacing = Math.max(20, 50 - nodeCount * 0.15) - const layerSpacing = Math.max(40, 80 - nodeCount * 0.2) - - const elkGraph = { - id: "root", - layoutOptions: { - "elk.algorithm": "mrtree", - "elk.spacing.nodeNode": String(nodeSpacing), - "elk.mrtree.spacing.nodeNode": String(nodeSpacing), - "elk.mrtree.spacing.level": String(layerSpacing), - }, - children: nodes.map((node) => ({ - id: node.id, - width: 30, - height: 30, - })), - edges: edges.map((edge) => ({ - id: `${edge.source}-${edge.target}`, - sources: [edge.source], - targets: [edge.target], - })), + if (nodes.length === 0) return nodes + + // Build adjacency (parent → children) + const children = new Map() + const hasParent = new Set() + for (const edge of edges) { + const list = children.get(edge.source) ?? [] + list.push(edge.target) + children.set(edge.source, list) + hasParent.add(edge.target) + } + + // Find roots (nodes with no incoming edges) + const roots = nodes.filter((n) => !hasParent.has(n.id)) + if (roots.length === 0) { + // Fallback: use first node + roots.push(nodes[0]) } - const layout = await elk.layout(elkGraph) + // BFS to assign depth and order + const depth = new Map() + const depthNodes = new Map() + const queue: string[] = [] + + for (const root of roots) { + if (!depth.has(root.id)) { + depth.set(root.id, 0) + queue.push(root.id) + } + } + + while (queue.length > 0) { + const id = queue.shift()! + const d = depth.get(id)! + const list = depthNodes.get(d) ?? [] + list.push(id) + depthNodes.set(d, list) + + for (const childId of children.get(id) ?? []) { + if (!depth.has(childId)) { + depth.set(childId, d + 1) + queue.push(childId) + } + } + } + + // Handle disconnected nodes — place them at max depth + 1 + const maxDepth = Math.max(0, ...depthNodes.keys()) + for (const node of nodes) { + if (!depth.has(node.id)) { + const d = maxDepth + 1 + depth.set(node.id, d) + const list = depthNodes.get(d) ?? [] + list.push(node.id) + depthNodes.set(d, list) + } + } + + // Compute positions in concentric rings + const ringSpacing = Math.max(60, Math.min(120, 800 / (maxDepth + 1))) const positionMap = new Map() - for (const child of layout.children ?? []) { - positionMap.set(child.id, { x: child.x ?? 0, y: child.y ?? 0 }) + + for (const [d, ids] of depthNodes.entries()) { + if (d === 0 && ids.length === 1) { + // Single root at center + positionMap.set(ids[0], { x: 0, y: 0 }) + continue + } + + const radius = d * ringSpacing + const count = ids.length + // Spread nodes evenly around the ring + // For depth 0 with multiple roots, use a small radius + const effectiveRadius = d === 0 ? Math.max(40, count * 15) : radius + const angleStep = (2 * Math.PI) / count + // Offset so it doesn't always start at 12 o'clock + const angleOffset = d * 0.3 + + for (let i = 0; i < count; i++) { + const angle = angleOffset + i * angleStep + positionMap.set(ids[i], { + x: Math.cos(angle) * effectiveRadius, + y: Math.sin(angle) * effectiveRadius, + }) + } } return nodes.map((node) => ({ From b59d98917b26d89b17c2723b311ca347d96e33fe Mon Sep 17 00:00:00 2001 From: Nick Furfaro Date: Thu, 2 Apr 2026 12:39:34 -0600 Subject: [PATCH 13/32] feat(web): wedge-based radial layout to minimize edge crossings - Each parent gets an angular wedge proportional to its subtree size - Children placed within parent's wedge, keeping them close - Fills space more evenly, avoids crossing edges - Straight edges while iterating on layout --- web/src/GraphView.tsx | 2 +- web/src/graph-layout.ts | 178 +++++++++++++++++++++++++--------------- 2 files changed, 114 insertions(+), 66 deletions(-) diff --git a/web/src/GraphView.tsx b/web/src/GraphView.tsx index 89f70a3..1f51d63 100644 --- a/web/src/GraphView.tsx +++ b/web/src/GraphView.tsx @@ -80,7 +80,7 @@ function buildFlowEdges( id: edgeId, source: e.parent_id, target: e.child_id, - type: "bezier", + type: "straight", style: { stroke: isConnected ? "#6b7280" : "#374151", strokeWidth: isConnected ? 1.5 : 1, diff --git a/web/src/graph-layout.ts b/web/src/graph-layout.ts index 0477283..b04796a 100644 --- a/web/src/graph-layout.ts +++ b/web/src/graph-layout.ts @@ -2,7 +2,8 @@ import type { Node, Edge } from "@xyflow/react" /** * Radial tree layout: root at center, children in concentric rings. - * Each depth level is a ring further from center. + * Each parent gets an angular wedge proportional to its subtree size, + * and its children are placed within that wedge — minimizing edge crossings. */ export async function layoutGraph( nodes: Node[], @@ -11,88 +12,113 @@ export async function layoutGraph( if (nodes.length === 0) return nodes // Build adjacency (parent → children) - const children = new Map() + const childrenMap = new Map() const hasParent = new Set() for (const edge of edges) { - const list = children.get(edge.source) ?? [] + const list = childrenMap.get(edge.source) ?? [] list.push(edge.target) - children.set(edge.source, list) + childrenMap.set(edge.source, list) hasParent.add(edge.target) } - // Find roots (nodes with no incoming edges) + // Find roots const roots = nodes.filter((n) => !hasParent.has(n.id)) - if (roots.length === 0) { - // Fallback: use first node - roots.push(nodes[0]) - } - - // BFS to assign depth and order - const depth = new Map() - const depthNodes = new Map() - const queue: string[] = [] + if (roots.length === 0) roots.push(nodes[0]) - for (const root of roots) { - if (!depth.has(root.id)) { - depth.set(root.id, 0) - queue.push(root.id) + // Compute subtree sizes (leaf count) for wedge allocation + const subtreeSize = new Map() + function computeSize(id: string, visited: Set): number { + if (visited.has(id)) return 0 + visited.add(id) + const kids = childrenMap.get(id) ?? [] + if (kids.length === 0) { + subtreeSize.set(id, 1) + return 1 } - } - - while (queue.length > 0) { - const id = queue.shift()! - const d = depth.get(id)! - const list = depthNodes.get(d) ?? [] - list.push(id) - depthNodes.set(d, list) - - for (const childId of children.get(id) ?? []) { - if (!depth.has(childId)) { - depth.set(childId, d + 1) - queue.push(childId) - } + let total = 0 + for (const kid of kids) { + total += computeSize(kid, visited) } + subtreeSize.set(id, total) + return total + } + const visited = new Set() + for (const root of roots) { + computeSize(root.id, visited) } - // Handle disconnected nodes — place them at max depth + 1 - const maxDepth = Math.max(0, ...depthNodes.keys()) - for (const node of nodes) { - if (!depth.has(node.id)) { - const d = maxDepth + 1 - depth.set(node.id, d) - const list = depthNodes.get(d) ?? [] - list.push(node.id) - depthNodes.set(d, list) + // Assign positions: each node gets a wedge [startAngle, endAngle] + // and is placed at the midpoint of its wedge at its depth's radius + const positionMap = new Map() + const maxDepth = computeMaxDepth(roots.map((r) => r.id), childrenMap) + const ringSpacing = Math.max(50, Math.min(100, 600 / Math.max(1, maxDepth))) + + // Total leaf count for all roots + const totalLeaves = roots.reduce((sum, r) => sum + (subtreeSize.get(r.id) ?? 1), 0) + + function placeSubtree( + nodeId: string, + depth: number, + wedgeStart: number, + wedgeEnd: number, + placed: Set, + ) { + if (placed.has(nodeId)) return + placed.add(nodeId) + + const angle = (wedgeStart + wedgeEnd) / 2 + const radius = depth * ringSpacing + + positionMap.set(nodeId, { + x: Math.cos(angle) * radius, + y: Math.sin(angle) * radius, + }) + + const kids = (childrenMap.get(nodeId) ?? []).filter((k) => !placed.has(k)) + if (kids.length === 0) return + + // Distribute wedge among children proportional to their subtree size + const totalChildLeaves = kids.reduce((s, k) => s + (subtreeSize.get(k) ?? 1), 0) + let currentAngle = wedgeStart + + for (const kid of kids) { + const kidSize = subtreeSize.get(kid) ?? 1 + const kidWedge = ((wedgeEnd - wedgeStart) * kidSize) / totalChildLeaves + placeSubtree(kid, depth + 1, currentAngle, currentAngle + kidWedge, placed) + currentAngle += kidWedge } } - // Compute positions in concentric rings - const ringSpacing = Math.max(60, Math.min(120, 800 / (maxDepth + 1))) - - const positionMap = new Map() + const placed = new Set() - for (const [d, ids] of depthNodes.entries()) { - if (d === 0 && ids.length === 1) { - // Single root at center - positionMap.set(ids[0], { x: 0, y: 0 }) - continue + if (roots.length === 1) { + // Single root at center, children get full 2π + positionMap.set(roots[0].id, { x: 0, y: 0 }) + placed.add(roots[0].id) + const kids = (childrenMap.get(roots[0].id) ?? []) + const totalChildLeaves = kids.reduce((s, k) => s + (subtreeSize.get(k) ?? 1), 0) + let currentAngle = 0 + for (const kid of kids) { + const kidSize = subtreeSize.get(kid) ?? 1 + const kidWedge = (2 * Math.PI * kidSize) / totalChildLeaves + placeSubtree(kid, 1, currentAngle, currentAngle + kidWedge, placed) + currentAngle += kidWedge + } + } else { + // Multiple roots: divide 2π among roots by subtree size + let currentAngle = 0 + for (const root of roots) { + const rootSize = subtreeSize.get(root.id) ?? 1 + const rootWedge = (2 * Math.PI * rootSize) / totalLeaves + placeSubtree(root.id, 0, currentAngle, currentAngle + rootWedge, placed) + currentAngle += rootWedge } + } - const radius = d * ringSpacing - const count = ids.length - // Spread nodes evenly around the ring - // For depth 0 with multiple roots, use a small radius - const effectiveRadius = d === 0 ? Math.max(40, count * 15) : radius - const angleStep = (2 * Math.PI) / count - // Offset so it doesn't always start at 12 o'clock - const angleOffset = d * 0.3 - - for (let i = 0; i < count; i++) { - const angle = angleOffset + i * angleStep - positionMap.set(ids[i], { - x: Math.cos(angle) * effectiveRadius, - y: Math.sin(angle) * effectiveRadius, - }) + // Place any disconnected nodes not reached by BFS + for (const node of nodes) { + if (!positionMap.has(node.id)) { + positionMap.set(node.id, { x: 0, y: 0 }) } } @@ -101,3 +127,25 @@ export async function layoutGraph( position: positionMap.get(node.id) ?? node.position, })) } + +function computeMaxDepth( + rootIds: string[], + childrenMap: Map, +): number { + let max = 0 + const visited = new Set() + const queue: { id: string; depth: number }[] = rootIds.map((id) => ({ id, depth: 0 })) + for (const item of queue) visited.add(item.id) + + while (queue.length > 0) { + const { id, depth } = queue.shift()! + if (depth > max) max = depth + for (const kid of childrenMap.get(id) ?? []) { + if (!visited.has(kid)) { + visited.add(kid) + queue.push({ id: kid, depth: depth + 1 }) + } + } + } + return max +} From cc53c72bb79c31ed98a6b4eb3ca8d26062f8ac43 Mon Sep 17 00:00:00 2001 From: Nick Furfaro Date: Thu, 2 Apr 2026 12:44:54 -0600 Subject: [PATCH 14/32] =?UTF-8?q?fix(web):=20sync=20graph=20selection=20wi?= =?UTF-8?q?th=20outliner=20=E2=80=94=20selecting=20in=20outliner=20highlig?= =?UTF-8?q?hts=20in=20graph?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/GraphView.tsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/web/src/GraphView.tsx b/web/src/GraphView.tsx index 1f51d63..0dbd982 100644 --- a/web/src/GraphView.tsx +++ b/web/src/GraphView.tsx @@ -108,7 +108,7 @@ function GraphViewInner({ nodeMap, edges, contextNodeId, - focusedNodeId: _focusedNodeId, + focusedNodeId, onSelectNode, onFocusNode, }: GraphViewProps) { @@ -153,14 +153,17 @@ function GraphViewInner({ return set }, [nodeMap, searchQuery, activeStates]) + // Use graph click selection if set, otherwise fall back to outliner focus + const activeNodeId = selectedNodeId ?? focusedNodeId + const displayNodes = useMemo( () => flowNodes.map((n) => ({ ...n, data: { ...n.data, - showLabel: n.id === selectedNodeId, - selected: n.id === selectedNodeId, + showLabel: n.id === activeNodeId, + selected: n.id === activeNodeId, }, style: { ...n.style, @@ -168,12 +171,12 @@ function GraphViewInner({ transition: "opacity 200ms ease", }, })), - [flowNodes, selectedNodeId, visibleNodeIds], + [flowNodes, activeNodeId, visibleNodeIds], ) const displayEdges = useMemo( - () => buildFlowEdges(edges, selectedNodeId, nodeMap), - [edges, selectedNodeId, nodeMap], + () => buildFlowEdges(edges, activeNodeId, nodeMap), + [edges, activeNodeId, nodeMap], ) const onNodeClick: NodeMouseHandler = useCallback( From 5e768705f37ca625066d028fb0f93a30c722e994 Mon Sep 17 00:00:00 2001 From: Nick Furfaro Date: Thu, 2 Apr 2026 12:46:50 -0600 Subject: [PATCH 15/32] feat(web): visually distinguish root nodes with larger size and halo --- web/src/DotNode.tsx | 12 ++++++++---- web/src/GraphView.tsx | 8 ++++++++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/web/src/DotNode.tsx b/web/src/DotNode.tsx index d26091d..c7cbbad 100644 --- a/web/src/DotNode.tsx +++ b/web/src/DotNode.tsx @@ -9,6 +9,7 @@ export interface DotNodeData { entropy: number | null showLabel: boolean selected: boolean + isRoot: boolean } const STATE_COLORS: Record = { @@ -31,7 +32,8 @@ function truncateLabel(text: string, maxChars: number): string { function DotNode({ data }: NodeProps & { data: DotNodeData }) { const color = STATE_COLORS[data.state] - const size = entropyToSize(data.entropy) + const baseSize = entropyToSize(data.entropy) + const size = data.isRoot ? Math.max(baseSize, 28) : baseSize return (
@@ -41,9 +43,11 @@ function DotNode({ data }: NodeProps & { data: DotNodeData }) { width: size, height: size, backgroundColor: color, - boxShadow: data.selected - ? `0 0 8px 3px ${color}80, 0 0 0 2px ${color}` - : undefined, + boxShadow: data.isRoot + ? `0 0 12px 4px ${color}50, 0 0 0 3px ${color}90, 0 0 0 6px ${color}30` + : data.selected + ? `0 0 8px 3px ${color}80, 0 0 0 2px ${color}` + : undefined, }} /> {data.selected && ( diff --git a/web/src/GraphView.tsx b/web/src/GraphView.tsx index 0dbd982..e03317a 100644 --- a/web/src/GraphView.tsx +++ b/web/src/GraphView.tsx @@ -42,6 +42,13 @@ function buildFlowNodes( selectedNodeId: string | null, showLabel: boolean, ): Node[] { + // Identify root nodes (no parents) + const rootIds = new Set( + Object.values(nodeMap) + .filter((n) => n.parents.length === 0) + .map((n) => n.id), + ) + return Object.values(nodeMap).map((node) => ({ id: node.id, type: "dot", @@ -53,6 +60,7 @@ function buildFlowNodes( entropy: node.entropy_score, showLabel, selected: node.id === selectedNodeId, + isRoot: rootIds.has(node.id), } satisfies DotNodeData, })) } From ab0a4c0afd898044b4a27e20ba771f6d30489915 Mon Sep 17 00:00:00 2001 From: Nick Furfaro Date: Thu, 2 Apr 2026 12:57:01 -0600 Subject: [PATCH 16/32] feat(web): organic radial layout with variable distance and overlap resolution --- web/src/graph-layout.ts | 337 ++++++++++++++++++++++++++++++---------- 1 file changed, 258 insertions(+), 79 deletions(-) diff --git a/web/src/graph-layout.ts b/web/src/graph-layout.ts index b04796a..fbc0b72 100644 --- a/web/src/graph-layout.ts +++ b/web/src/graph-layout.ts @@ -1,17 +1,71 @@ import type { Node, Edge } from "@xyflow/react" +// --- Constants --- +const MIN_NODE_RADIUS = 8 +const MAX_NODE_RADIUS = 20 +const ROOT_NODE_RADIUS = 28 +const RING_MIN_FRACTION = 0.12 +const RING_MAX_FRACTION = 0.35 +const OVERLAP_PADDING = 25 +const OVERLAP_ITERATIONS = 16 +const JITTER_AMPLITUDE = 0.04 + +// --- Types --- +interface LayoutOptions { + maxDepth?: number | null +} + +interface PlacedNode { + id: string + x: number + y: number + radius: number + depth: number + hasChildrenBeyond: boolean +} + +// --- Helpers --- + +/** Deterministic hash of a string to a number in [0, 1). */ +function hashToUnit(s: string): number { + let h = 0 + for (let i = 0; i < s.length; i++) { + h = ((h << 5) - h + s.charCodeAt(i)) | 0 + } + return (((h >>> 0) % 10000) / 10000) +} + +/** Deterministic jitter in [-JITTER_AMPLITUDE, +JITTER_AMPLITUDE]. */ +function jitterForId(id: string): number { + return (hashToUnit(id) - 0.5) * 2 * JITTER_AMPLITUDE +} + /** - * Radial tree layout: root at center, children in concentric rings. - * Each parent gets an angular wedge proportional to its subtree size, - * and its children are placed within that wedge — minimizing edge crossings. + * Center-out slot assignment: most important child at center of available + * slots, then alternating left/right. + */ +function centerOutSlot(i: number, n: number): number { + const center = Math.floor((n - 1) / 2) + if (i === 0) return center + const offset = Math.floor((i + 1) / 2) + return i % 2 === 1 ? center + offset : center - offset +} + +/** + * Organic radial layout: root at center, children placed outward with + * distance and dot size proportional to subtree size. Center-out slot + * assignment and overlap repulsion create a natural, mind-map feel. */ export async function layoutGraph( nodes: Node[], edges: Edge[], + options?: LayoutOptions, ): Promise { if (nodes.length === 0) return nodes - // Build adjacency (parent → children) + const maxDepthLimit = options?.maxDepth ?? null + + // 1. Build parent -> children adjacency from edges const childrenMap = new Map() const hasParent = new Set() for (const edge of edges) { @@ -21,11 +75,11 @@ export async function layoutGraph( hasParent.add(edge.target) } - // Find roots + // 2. Find roots (no incoming edges) const roots = nodes.filter((n) => !hasParent.has(n.id)) if (roots.length === 0) roots.push(nodes[0]) - // Compute subtree sizes (leaf count) for wedge allocation + // 3. Compute subtree sizes (leaf count) via DFS const subtreeSize = new Map() function computeSize(id: string, visited: Set): number { if (visited.has(id)) return 0 @@ -42,110 +96,235 @@ export async function layoutGraph( subtreeSize.set(id, total) return total } - const visited = new Set() + const sizeVisited = new Set() for (const root of roots) { - computeSize(root.id, visited) + computeSize(root.id, sizeVisited) } - // Assign positions: each node gets a wedge [startAngle, endAngle] - // and is placed at the midpoint of its wedge at its depth's radius - const positionMap = new Map() - const maxDepth = computeMaxDepth(roots.map((r) => r.id), childrenMap) - const ringSpacing = Math.max(50, Math.min(100, 600 / Math.max(1, maxDepth))) + // 4. Compute depth of each node via BFS + const depthMap = new Map() + { + const queue: { id: string; depth: number }[] = roots.map((r) => ({ + id: r.id, + depth: 0, + })) + for (const item of queue) depthMap.set(item.id, 0) + while (queue.length > 0) { + const { id, depth } = queue.shift()! + for (const kid of childrenMap.get(id) ?? []) { + if (!depthMap.has(kid)) { + depthMap.set(kid, depth + 1) + queue.push({ id: kid, depth: depth + 1 }) + } + } + } + } + + // Max subtree size across all nodes (for normalization) + const maxSubtreeSize = Math.max(1, ...Array.from(subtreeSize.values())) - // Total leaf count for all roots - const totalLeaves = roots.reduce((sum, r) => sum + (subtreeSize.get(r.id) ?? 1), 0) + // 5. Layout radius + const layoutRadius = Math.max(400, Math.sqrt(nodes.length) * 80) + + // 6. Recursive placement + const placed: PlacedNode[] = [] + const placedIds = new Set() + function nodeRadius(id: string, isRoot: boolean): number { + if (isRoot) return ROOT_NODE_RADIUS + const size = subtreeSize.get(id) ?? 1 + const t = Math.sqrt(size / maxSubtreeSize) + return MIN_NODE_RADIUS + t * (MAX_NODE_RADIUS - MIN_NODE_RADIUS) + } + + /** Check if a node has children beyond the depth limit. */ + function hasChildrenBeyondLimit(id: string, depth: number): boolean { + if (maxDepthLimit == null) return false + if (depth < maxDepthLimit) return false + const kids = childrenMap.get(id) ?? [] + return kids.length > 0 + } function placeSubtree( nodeId: string, depth: number, + cx: number, + cy: number, wedgeStart: number, - wedgeEnd: number, - placed: Set, + wedgeSize: number, + isRoot: boolean, ) { - if (placed.has(nodeId)) return - placed.add(nodeId) - - const angle = (wedgeStart + wedgeEnd) / 2 - const radius = depth * ringSpacing + if (placedIds.has(nodeId)) return + if (maxDepthLimit != null && depth > maxDepthLimit) return + placedIds.add(nodeId) - positionMap.set(nodeId, { - x: Math.cos(angle) * radius, - y: Math.sin(angle) * radius, + const r = nodeRadius(nodeId, isRoot) + placed.push({ + id: nodeId, + x: cx, + y: cy, + radius: r, + depth, + hasChildrenBeyond: hasChildrenBeyondLimit(nodeId, depth), }) - const kids = (childrenMap.get(nodeId) ?? []).filter((k) => !placed.has(k)) + // Stop recursing if at depth limit + if (maxDepthLimit != null && depth >= maxDepthLimit) return + + const kids = (childrenMap.get(nodeId) ?? []).filter( + (k) => !placedIds.has(k), + ) if (kids.length === 0) return - // Distribute wedge among children proportional to their subtree size - const totalChildLeaves = kids.reduce((s, k) => s + (subtreeSize.get(k) ?? 1), 0) - let currentAngle = wedgeStart + // Sort children by subtree size descending + const sorted = [...kids].sort( + (a, b) => (subtreeSize.get(b) ?? 1) - (subtreeSize.get(a) ?? 1), + ) - for (const kid of kids) { - const kidSize = subtreeSize.get(kid) ?? 1 - const kidWedge = ((wedgeEnd - wedgeStart) * kidSize) / totalChildLeaves - placeSubtree(kid, depth + 1, currentAngle, currentAngle + kidWedge, placed) - currentAngle += kidWedge + const n = sorted.length + const totalChildLeaves = sorted.reduce( + (s, k) => s + (subtreeSize.get(k) ?? 1), + 0, + ) + + // Build slot assignments: sorted[i] -> centerOutSlot(i, n) + const slots: { childId: string; slot: number }[] = sorted.map( + (childId, i) => ({ + childId, + slot: centerOutSlot(i, n), + }), + ) + // Re-sort by slot index so we iterate in angular order + slots.sort((a, b) => a.slot - b.slot) + + for (const { childId, slot } of slots) { + const childSize = subtreeSize.get(childId) ?? 1 + + // Distance from parent: bigger subtrees are placed further out + const t = Math.sqrt(childSize / maxSubtreeSize) + const distFraction = + RING_MIN_FRACTION + t * (RING_MAX_FRACTION - RING_MIN_FRACTION) + const dist = distFraction * layoutRadius + + // Angle: slot position within parent's wedge + jitter + const slotAngle = + wedgeStart + + ((slot + 0.5) / n) * wedgeSize + + jitterForId(childId) + + const childX = cx + Math.cos(slotAngle) * dist + const childY = cy + Math.sin(slotAngle) * dist + + // Child gets a sub-wedge proportional to its leaf count + const childWedgeSize = (childSize / totalChildLeaves) * wedgeSize + const childWedgeStart = slotAngle - childWedgeSize / 2 + + placeSubtree( + childId, + depth + 1, + childX, + childY, + childWedgeStart, + childWedgeSize, + false, + ) } } - const placed = new Set() - if (roots.length === 1) { - // Single root at center, children get full 2π - positionMap.set(roots[0].id, { x: 0, y: 0 }) - placed.add(roots[0].id) - const kids = (childrenMap.get(roots[0].id) ?? []) - const totalChildLeaves = kids.reduce((s, k) => s + (subtreeSize.get(k) ?? 1), 0) - let currentAngle = 0 - for (const kid of kids) { - const kidSize = subtreeSize.get(kid) ?? 1 - const kidWedge = (2 * Math.PI * kidSize) / totalChildLeaves - placeSubtree(kid, 1, currentAngle, currentAngle + kidWedge, placed) - currentAngle += kidWedge - } + // Single root at center + placeSubtree(roots[0].id, 0, 0, 0, 0, 2 * Math.PI, true) } else { - // Multiple roots: divide 2π among roots by subtree size - let currentAngle = 0 + // Multiple roots: divide 2pi proportionally by subtree size + const totalRootLeaves = roots.reduce( + (s, r) => s + (subtreeSize.get(r.id) ?? 1), + 0, + ) + let angle = 0 for (const root of roots) { const rootSize = subtreeSize.get(root.id) ?? 1 - const rootWedge = (2 * Math.PI * rootSize) / totalLeaves - placeSubtree(root.id, 0, currentAngle, currentAngle + rootWedge, placed) - currentAngle += rootWedge + const rootWedge = (2 * Math.PI * rootSize) / totalRootLeaves + const rootDist = RING_MIN_FRACTION * layoutRadius + const rx = Math.cos(angle + rootWedge / 2) * rootDist + const ry = Math.sin(angle + rootWedge / 2) * rootDist + placeSubtree(root.id, 0, rx, ry, angle, rootWedge, true) + angle += rootWedge } } - // Place any disconnected nodes not reached by BFS + // Handle disconnected nodes: place on outermost ring + const maxPlacedDepth = placed.reduce((m, p) => Math.max(m, p.depth), 0) + let disconnectedAngle = 0 + const disconnectedCount = nodes.filter( + (n) => !placedIds.has(n.id), + ).length for (const node of nodes) { - if (!positionMap.has(node.id)) { - positionMap.set(node.id, { x: 0, y: 0 }) + if (!placedIds.has(node.id)) { + const outerR = RING_MAX_FRACTION * layoutRadius * (maxPlacedDepth + 1) + const a = + disconnectedCount > 1 + ? (disconnectedAngle / disconnectedCount) * 2 * Math.PI + : 0 + placed.push({ + id: node.id, + x: Math.cos(a) * outerR, + y: Math.sin(a) * outerR, + radius: MIN_NODE_RADIUS, + depth: maxPlacedDepth + 1, + hasChildrenBeyond: false, + }) + placedIds.add(node.id) + disconnectedAngle++ } } - return nodes.map((node) => ({ - ...node, - position: positionMap.get(node.id) ?? node.position, - })) -} - -function computeMaxDepth( - rootIds: string[], - childrenMap: Map, -): number { - let max = 0 - const visited = new Set() - const queue: { id: string; depth: number }[] = rootIds.map((id) => ({ id, depth: 0 })) - for (const item of queue) visited.add(item.id) - - while (queue.length > 0) { - const { id, depth } = queue.shift()! - if (depth > max) max = depth - for (const kid of childrenMap.get(id) ?? []) { - if (!visited.has(kid)) { - visited.add(kid) - queue.push({ id: kid, depth: depth + 1 }) + // 7. Overlap resolution: 16 iterations of pairwise repulsion + for (let iter = 0; iter < OVERLAP_ITERATIONS; iter++) { + for (let i = 0; i < placed.length; i++) { + for (let j = i + 1; j < placed.length; j++) { + const a = placed[i] + const b = placed[j] + const dx = b.x - a.x + const dy = b.y - a.y + const dist = Math.sqrt(dx * dx + dy * dy) + const minDist = a.radius + b.radius + OVERLAP_PADDING + if (dist < minDist && dist > 0.01) { + const overlap = (minDist - dist) / 2 + const nx = dx / dist + const ny = dy / dist + // Lighter nodes (smaller subtree) move more + const aWeight = subtreeSize.get(a.id) ?? 1 + const bWeight = subtreeSize.get(b.id) ?? 1 + const total = aWeight + bWeight + const aRatio = bWeight / total // a moves proportional to b's weight + const bRatio = aWeight / total + a.x -= nx * overlap * aRatio + a.y -= ny * overlap * aRatio + b.x += nx * overlap * bRatio + b.y += ny * overlap * bRatio + } } } } - return max + + // 8. Build result: filter by maxDepth, set position and extra data fields + const placedMap = new Map(placed.map((p) => [p.id, p])) + + return nodes + .filter((node) => { + if (maxDepthLimit == null) return true + const d = depthMap.get(node.id) + return d != null && d <= maxDepthLimit + }) + .map((node) => { + const p = placedMap.get(node.id) + return { + ...node, + position: p ? { x: p.x, y: p.y } : node.position, + data: { + ...node.data, + nodeRadius: p?.radius ?? MIN_NODE_RADIUS, + isCollapsed: p?.hasChildrenBeyond ?? false, + }, + } + }) } From f7f1414d6be77b21b85abb10bd75b9f07ceca76d Mon Sep 17 00:00:00 2001 From: Nick Furfaro Date: Thu, 2 Apr 2026 12:58:50 -0600 Subject: [PATCH 17/32] feat(web): DotNode supports layout-computed sizing, collapse indicator, and color modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - nodeRadius from layout overrides entropy-based sizing - isCollapsed shows dashed border ring - colorMode: state (default), children (blue→red by child count), entropy (blue→red by subtree entropy) --- web/src/DotNode.tsx | 49 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 42 insertions(+), 7 deletions(-) diff --git a/web/src/DotNode.tsx b/web/src/DotNode.tsx index c7cbbad..91764ac 100644 --- a/web/src/DotNode.tsx +++ b/web/src/DotNode.tsx @@ -2,6 +2,8 @@ import { memo } from "react" import { Handle, Position } from "@xyflow/react" import type { NodeProps } from "@xyflow/react" +export type ColorMode = "state" | "children" | "entropy" + export interface DotNodeData { question: string answer: string | null @@ -10,6 +12,11 @@ export interface DotNodeData { showLabel: boolean selected: boolean isRoot: boolean + nodeRadius?: number + isCollapsed?: boolean + childCount?: number + subtreeEntropy?: number | null + colorMode?: ColorMode } const STATE_COLORS: Record = { @@ -19,10 +26,23 @@ const STATE_COLORS: Record = { deleted: "#6b7280", } -function entropyToSize(entropy: number | null): number { - if (entropy == null) return 16 - const clamped = Math.max(0, Math.min(10, entropy)) - return 12 + (clamped / 10) * 16 +/** Blue → yellow → red gradient for heatmaps. t in [0, 1]. */ +function heatColor(t: number): string { + const clamped = Math.max(0, Math.min(1, t)) + if (clamped < 0.5) { + // blue → yellow + const p = clamped * 2 + const r = Math.round(59 + p * (245 - 59)) + const g = Math.round(130 + p * (158 - 130)) + const b = Math.round(246 + p * (11 - 246)) + return `rgb(${r},${g},${b})` + } + // yellow → red + const p = (clamped - 0.5) * 2 + const r = Math.round(245 + p * (239 - 245)) + const g = Math.round(158 + p * (68 - 158)) + const b = Math.round(11 + p * (68 - 11)) + return `rgb(${r},${g},${b})` } function truncateLabel(text: string, maxChars: number): string { @@ -31,9 +51,22 @@ function truncateLabel(text: string, maxChars: number): string { } function DotNode({ data }: NodeProps & { data: DotNodeData }) { - const color = STATE_COLORS[data.state] - const baseSize = entropyToSize(data.entropy) - const size = data.isRoot ? Math.max(baseSize, 28) : baseSize + const colorMode = data.colorMode ?? "state" + + // Determine color + let color: string + if (colorMode === "children" && data.childCount != null) { + // Normalize: 0 children = cool, 20+ = hot + color = heatColor(Math.min(data.childCount / 20, 1)) + } else if (colorMode === "entropy" && data.subtreeEntropy != null) { + // Normalize: 0 entropy = cool, 8+ = hot + color = heatColor(Math.min(data.subtreeEntropy / 8, 1)) + } else { + color = STATE_COLORS[data.state] + } + + // Size: prefer layout-computed radius, fallback to default + const size = data.nodeRadius != null ? data.nodeRadius * 2 : (data.isRoot ? 28 : 16) return (
@@ -48,6 +81,8 @@ function DotNode({ data }: NodeProps & { data: DotNodeData }) { : data.selected ? `0 0 8px 3px ${color}80, 0 0 0 2px ${color}` : undefined, + border: data.isCollapsed ? `2px dashed ${color}` : undefined, + boxSizing: "border-box", }} /> {data.selected && ( From 656cffe28df6158aab42237603a9bde6b757a4f4 Mon Sep 17 00:00:00 2001 From: Nick Furfaro Date: Thu, 2 Apr 2026 13:02:22 -0600 Subject: [PATCH 18/32] feat(web): add depth collapse control and color mode toggles to graph view - Depth pills (1/2/3/4/All) re-layout with maxDepth, collapsed nodes show dashed ring - Color mode toggle: State (default), Children (heatmap by child count), Entropy (heatmap by subtree entropy) - Pass layout-computed nodeRadius, isCollapsed, childCount, subtreeEntropy to DotNode - Edges filtered to only include visible (laid-out) nodes - Removed Re-layout button (depth change auto-relayouts) --- web/src/GraphView.tsx | 202 ++++++++++++++++++++++-------------------- 1 file changed, 108 insertions(+), 94 deletions(-) diff --git a/web/src/GraphView.tsx b/web/src/GraphView.tsx index e03317a..abc0f20 100644 --- a/web/src/GraphView.tsx +++ b/web/src/GraphView.tsx @@ -12,8 +12,9 @@ import { import "@xyflow/react/dist/style.css" import DotNode from "./DotNode" -import type { DotNodeData } from "./DotNode" +import type { DotNodeData, ColorMode } from "./DotNode" import { layoutGraph } from "./graph-layout" +import { computeSubtreeEntropy } from "./graph-utils" import type { NodeMap, DagEdge, NodeState } from "./types" const STATE_COLORS: Record = { @@ -24,6 +25,12 @@ const STATE_COLORS: Record = { } const FILTER_STATES: NodeState[] = ["answered", "unanswered", "needs_review"] +const DEPTH_OPTIONS = [1, 2, 3, 4] as const +const COLOR_MODES: { value: ColorMode; label: string }[] = [ + { value: "state", label: "State" }, + { value: "children", label: "Children" }, + { value: "entropy", label: "Entropy" }, +] const nodeTypes = { dot: DotNode } @@ -38,11 +45,8 @@ interface GraphViewProps { function buildFlowNodes( nodeMap: NodeMap, - _contextNodeId: string | null, - selectedNodeId: string | null, - showLabel: boolean, + subtreeEntropyMap: Map, ): Node[] { - // Identify root nodes (no parents) const rootIds = new Set( Object.values(nodeMap) .filter((n) => n.parents.length === 0) @@ -58,43 +62,44 @@ function buildFlowNodes( answer: node.answer, state: node.state, entropy: node.entropy_score, - showLabel, - selected: node.id === selectedNodeId, + showLabel: false, + selected: false, isRoot: rootIds.has(node.id), + childCount: node.children.length, + subtreeEntropy: subtreeEntropyMap.get(node.id) ?? null, } satisfies DotNodeData, })) } function buildFlowEdges( edges: DagEdge[], - selectedNodeId: string | null, + activeNodeId: string | null, nodeMap: NodeMap, + visibleIds?: Set, ): Edge[] { const connectedEdges = new Set() - if (selectedNodeId && nodeMap[selectedNodeId]) { - const node = nodeMap[selectedNodeId] - for (const pid of node.parents) { - connectedEdges.add(`${pid}-${selectedNodeId}`) - } - for (const cid of node.children) { - connectedEdges.add(`${selectedNodeId}-${cid}`) - } + if (activeNodeId && nodeMap[activeNodeId]) { + const node = nodeMap[activeNodeId] + for (const pid of node.parents) connectedEdges.add(`${pid}-${activeNodeId}`) + for (const cid of node.children) connectedEdges.add(`${activeNodeId}-${cid}`) } - return edges.map((e) => { - const edgeId = `${e.parent_id}-${e.child_id}` - const isConnected = connectedEdges.has(edgeId) - return { - id: edgeId, - source: e.parent_id, - target: e.child_id, - type: "straight", - style: { - stroke: isConnected ? "#6b7280" : "#374151", - strokeWidth: isConnected ? 1.5 : 1, - }, - } - }) + return edges + .filter((e) => !visibleIds || (visibleIds.has(e.parent_id) && visibleIds.has(e.child_id))) + .map((e) => { + const edgeId = `${e.parent_id}-${e.child_id}` + const isConnected = connectedEdges.has(edgeId) + return { + id: edgeId, + source: e.parent_id, + target: e.child_id, + type: "straight", + style: { + stroke: isConnected ? "#6b7280" : "#374151", + strokeWidth: isConnected ? 1.5 : 1, + }, + } + }) } function isNodeVisible( @@ -105,9 +110,7 @@ function isNodeVisible( if (activeStates.size > 0 && !activeStates.has(node.state)) return false if (searchQuery) { const q = searchQuery.toLowerCase() - const matchesQuestion = node.question.toLowerCase().includes(q) - const matchesAnswer = node.answer?.toLowerCase().includes(q) ?? false - if (!matchesQuestion && !matchesAnswer) return false + if (!node.question.toLowerCase().includes(q) && !(node.answer?.toLowerCase().includes(q) ?? false)) return false } return true } @@ -115,7 +118,7 @@ function isNodeVisible( function GraphViewInner({ nodeMap, edges, - contextNodeId, + contextNodeId: _contextNodeId, focusedNodeId, onSelectNode, onFocusNode, @@ -123,47 +126,51 @@ function GraphViewInner({ const { fitView } = useReactFlow() const [flowNodes, setFlowNodes] = useState([]) - const [, setFlowEdges] = useState([]) const [selectedNodeId, setSelectedNodeId] = useState(null) const [popoverPos, setPopoverPos] = useState<{ x: number; y: number } | null>(null) const [searchQuery, setSearchQuery] = useState("") const [activeStates, setActiveStates] = useState>(new Set()) - const layoutDone = useRef(false) + const [maxDepth, setMaxDepth] = useState(null) + const [colorMode, setColorMode] = useState("state") const containerRef = useRef(null) - - // Build and layout on mount or when nodeMap/edges change const fitViewRef = useRef(fitView) fitViewRef.current = fitView + // Precompute subtree entropy + const subtreeEntropyMap = useMemo( + () => computeSubtreeEntropy( + // buildNodeMap is already done in Workspace, we have nodeMap + // computeSubtreeEntropy expects NodeMap + nodeMap, + ), + [nodeMap], + ) + + // Build and layout when nodeMap, edges, or maxDepth change useEffect(() => { - const nodes = buildFlowNodes(nodeMap, contextNodeId, null, false) + const rfNodes = buildFlowNodes(nodeMap, subtreeEntropyMap) const rfEdges = buildFlowEdges(edges, null, nodeMap) - layoutGraph(nodes, rfEdges).then((positioned) => { + layoutGraph(rfNodes, rfEdges, { maxDepth }).then((positioned) => { setFlowNodes(positioned) - setFlowEdges(rfEdges) - if (!layoutDone.current) { - layoutDone.current = true - // fitView only on initial layout - setTimeout(() => fitViewRef.current({ padding: 0.15 }), 50) - } + setTimeout(() => fitViewRef.current({ padding: 0.15 }), 50) }) - }, [nodeMap, edges, contextNodeId]) + }, [nodeMap, edges, subtreeEntropyMap, maxDepth]) - // Update node data when selection, labels, or filters change (no re-layout) + // Visible node IDs for search/state filter (post-layout, just visual fading) const visibleNodeIds = useMemo(() => { const set = new Set() for (const [id, node] of Object.entries(nodeMap)) { - if (isNodeVisible(node, searchQuery, activeStates)) { - set.add(id) - } + if (isNodeVisible(node, searchQuery, activeStates)) set.add(id) } return set }, [nodeMap, searchQuery, activeStates]) - // Use graph click selection if set, otherwise fall back to outliner focus const activeNodeId = selectedNodeId ?? focusedNodeId + // Set of node IDs that are in the current layout (may be filtered by maxDepth) + const layoutNodeIds = useMemo(() => new Set(flowNodes.map((n) => n.id)), [flowNodes]) + const displayNodes = useMemo( () => flowNodes.map((n) => ({ @@ -172,6 +179,7 @@ function GraphViewInner({ ...n.data, showLabel: n.id === activeNodeId, selected: n.id === activeNodeId, + colorMode, }, style: { ...n.style, @@ -179,19 +187,18 @@ function GraphViewInner({ transition: "opacity 200ms ease", }, })), - [flowNodes, activeNodeId, visibleNodeIds], + [flowNodes, activeNodeId, visibleNodeIds, colorMode], ) const displayEdges = useMemo( - () => buildFlowEdges(edges, activeNodeId, nodeMap), - [edges, activeNodeId, nodeMap], + () => buildFlowEdges(edges, activeNodeId, nodeMap, layoutNodeIds), + [edges, activeNodeId, nodeMap, layoutNodeIds], ) const onNodeClick: NodeMouseHandler = useCallback( (event, node) => { setSelectedNodeId(node.id) onSelectNode(node.id) - // Position popover near the click, relative to the graph container if (containerRef.current) { const rect = containerRef.current.getBoundingClientRect() const x = (event as unknown as MouseEvent).clientX - rect.left @@ -216,29 +223,6 @@ function GraphViewInner({ }) } - const handleRelayout = useCallback(() => { - // Re-layout with only visible nodes - const visibleFlowNodes = flowNodes.filter((n) => visibleNodeIds.has(n.id)) - const visibleEdgeSet = new Set( - visibleFlowNodes.map((n) => n.id), - ) - const visibleFlowEdges = displayEdges.filter( - (e) => visibleEdgeSet.has(e.source) && visibleEdgeSet.has(e.target), - ) - - layoutGraph(visibleFlowNodes, visibleFlowEdges).then((positioned) => { - // Merge positioned nodes back, keeping hidden nodes at their old positions - const posMap = new Map(positioned.map((n) => [n.id, n.position])) - setFlowNodes((prev) => - prev.map((n) => ({ - ...n, - position: posMap.get(n.id) ?? n.position, - })), - ) - setTimeout(() => fitView({ padding: 0.15 }), 50) - }) - }, [flowNodes, visibleNodeIds, displayEdges, fitView]) - const handleFit = useCallback(() => { fitView({ padding: 0.15 }) }, [fitView]) @@ -248,13 +232,13 @@ function GraphViewInner({ return (
{/* Filter bar */} -
+
setSearchQuery(e.target.value)} - className="bg-gray-800 text-gray-200 text-sm rounded px-2 py-1 w-48 placeholder-gray-500 outline-none focus:ring-1 focus:ring-gray-600" + className="bg-gray-800 text-gray-200 text-xs rounded px-2 py-1 w-40 placeholder-gray-500 outline-none focus:ring-1 focus:ring-gray-600" /> {FILTER_STATES.map((state) => { const active = activeStates.has(state) @@ -262,30 +246,57 @@ function GraphViewInner({ ) })} -
+ + | + + {/* Depth control */} + Depth: + {DEPTH_OPTIONS.map((d) => ( + + ))} + onClick={() => setMaxDepth(null)} + className={`px-1.5 py-0.5 text-[10px] rounded cursor-pointer ${ + maxDepth === null ? "bg-gray-600 text-white" : "text-gray-400 hover:bg-gray-800" + }`} + >All + + | + + {/* Color mode */} + Color: + {COLOR_MODES.map(({ value, label }) => ( + + ))} + +
@@ -316,7 +327,7 @@ function GraphViewInner({ /> - {/* Detail popover — positioned near clicked node */} + {/* Detail popover */} {selectedNode && popoverPos && (
)} + + {selectedNode.children.length} children +
From 5caab8cff5e59310989ee8ec523d54d4d0e19db5 Mon Sep 17 00:00:00 2001 From: Nick Furfaro Date: Thu, 2 Apr 2026 13:11:12 -0600 Subject: [PATCH 19/32] =?UTF-8?q?fix(web):=20tune=20radial=20layout=20?= =?UTF-8?q?=E2=80=94=20fixed=20ring=20distance,=20better=20spacing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use fixed base distance per hop (120px) instead of fraction of global radius - Smaller nodes (6-16px radius vs 8-20px) to reduce clutter - More overlap resolution iterations (20) - Children always placed at consistent distance from parent - Larger subtrees get only slight distance bonus (not 3x variation) --- web/src/graph-layout.ts | 29 ++++++++++++----------------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/web/src/graph-layout.ts b/web/src/graph-layout.ts index fbc0b72..a3e5244 100644 --- a/web/src/graph-layout.ts +++ b/web/src/graph-layout.ts @@ -1,14 +1,14 @@ import type { Node, Edge } from "@xyflow/react" // --- Constants --- -const MIN_NODE_RADIUS = 8 -const MAX_NODE_RADIUS = 20 -const ROOT_NODE_RADIUS = 28 -const RING_MIN_FRACTION = 0.12 -const RING_MAX_FRACTION = 0.35 -const OVERLAP_PADDING = 25 -const OVERLAP_ITERATIONS = 16 -const JITTER_AMPLITUDE = 0.04 +const MIN_NODE_RADIUS = 6 +const MAX_NODE_RADIUS = 16 +const ROOT_NODE_RADIUS = 24 +const BASE_RING_DISTANCE = 120 // base distance from parent to child +const RING_DISTANCE_GROWTH = 0.15 // larger subtrees push children slightly further +const OVERLAP_PADDING = 20 +const OVERLAP_ITERATIONS = 20 +const JITTER_AMPLITUDE = 0.03 // --- Types --- interface LayoutOptions { @@ -123,9 +123,6 @@ export async function layoutGraph( // Max subtree size across all nodes (for normalization) const maxSubtreeSize = Math.max(1, ...Array.from(subtreeSize.values())) - // 5. Layout radius - const layoutRadius = Math.max(400, Math.sqrt(nodes.length) * 80) - // 6. Recursive placement const placed: PlacedNode[] = [] const placedIds = new Set() @@ -199,11 +196,9 @@ export async function layoutGraph( for (const { childId, slot } of slots) { const childSize = subtreeSize.get(childId) ?? 1 - // Distance from parent: bigger subtrees are placed further out + // Distance from parent: fixed base + small bonus for larger subtrees const t = Math.sqrt(childSize / maxSubtreeSize) - const distFraction = - RING_MIN_FRACTION + t * (RING_MAX_FRACTION - RING_MIN_FRACTION) - const dist = distFraction * layoutRadius + const dist = BASE_RING_DISTANCE * (1 + t * RING_DISTANCE_GROWTH) // Angle: slot position within parent's wedge + jitter const slotAngle = @@ -243,7 +238,7 @@ export async function layoutGraph( for (const root of roots) { const rootSize = subtreeSize.get(root.id) ?? 1 const rootWedge = (2 * Math.PI * rootSize) / totalRootLeaves - const rootDist = RING_MIN_FRACTION * layoutRadius + const rootDist = BASE_RING_DISTANCE * 0.5 const rx = Math.cos(angle + rootWedge / 2) * rootDist const ry = Math.sin(angle + rootWedge / 2) * rootDist placeSubtree(root.id, 0, rx, ry, angle, rootWedge, true) @@ -259,7 +254,7 @@ export async function layoutGraph( ).length for (const node of nodes) { if (!placedIds.has(node.id)) { - const outerR = RING_MAX_FRACTION * layoutRadius * (maxPlacedDepth + 1) + const outerR = BASE_RING_DISTANCE * (maxPlacedDepth + 1.5) const a = disconnectedCount > 1 ? (disconnectedAngle / disconnectedCount) * 2 * Math.PI From 7efc5c4bf0229e270114fe4920012e803639f804 Mon Sep 17 00:00:00 2001 From: Nick Furfaro Date: Thu, 2 Apr 2026 13:13:37 -0600 Subject: [PATCH 20/32] fix(web): adaptive ring distance based on sibling count - Few children (1-3) stay close (60px), many children (12+) spread wider (150px) - Prevents sparse layouts at depth 1 with few nodes - Prevents cramped layouts at depth 2+ with many siblings --- web/src/graph-layout.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/web/src/graph-layout.ts b/web/src/graph-layout.ts index a3e5244..69b1cf3 100644 --- a/web/src/graph-layout.ts +++ b/web/src/graph-layout.ts @@ -4,8 +4,9 @@ import type { Node, Edge } from "@xyflow/react" const MIN_NODE_RADIUS = 6 const MAX_NODE_RADIUS = 16 const ROOT_NODE_RADIUS = 24 -const BASE_RING_DISTANCE = 120 // base distance from parent to child -const RING_DISTANCE_GROWTH = 0.15 // larger subtrees push children slightly further +const MIN_RING_DISTANCE = 60 // minimum distance from parent to child +const MAX_RING_DISTANCE = 150 // maximum distance (many siblings) +const RING_DISTANCE_GROWTH = 0.12 // larger subtrees push children slightly further const OVERLAP_PADDING = 20 const OVERLAP_ITERATIONS = 20 const JITTER_AMPLITUDE = 0.03 @@ -196,9 +197,11 @@ export async function layoutGraph( for (const { childId, slot } of slots) { const childSize = subtreeSize.get(childId) ?? 1 - // Distance from parent: fixed base + small bonus for larger subtrees + // Distance from parent: scales with sibling count so few children stay tight + const siblingFactor = Math.min(1, n / 12) // 0..1 based on how many siblings + const baseRing = MIN_RING_DISTANCE + siblingFactor * (MAX_RING_DISTANCE - MIN_RING_DISTANCE) const t = Math.sqrt(childSize / maxSubtreeSize) - const dist = BASE_RING_DISTANCE * (1 + t * RING_DISTANCE_GROWTH) + const dist = baseRing * (1 + t * RING_DISTANCE_GROWTH) // Angle: slot position within parent's wedge + jitter const slotAngle = @@ -238,7 +241,7 @@ export async function layoutGraph( for (const root of roots) { const rootSize = subtreeSize.get(root.id) ?? 1 const rootWedge = (2 * Math.PI * rootSize) / totalRootLeaves - const rootDist = BASE_RING_DISTANCE * 0.5 + const rootDist = MIN_RING_DISTANCE const rx = Math.cos(angle + rootWedge / 2) * rootDist const ry = Math.sin(angle + rootWedge / 2) * rootDist placeSubtree(root.id, 0, rx, ry, angle, rootWedge, true) @@ -254,7 +257,7 @@ export async function layoutGraph( ).length for (const node of nodes) { if (!placedIds.has(node.id)) { - const outerR = BASE_RING_DISTANCE * (maxPlacedDepth + 1.5) + const outerR = MAX_RING_DISTANCE * (maxPlacedDepth + 1) const a = disconnectedCount > 1 ? (disconnectedAngle / disconnectedCount) * 2 * Math.PI From e05ff3ed43fb09284f7b07756edc190bd1dde526 Mon Sep 17 00:00:00 2001 From: Nick Furfaro Date: Thu, 2 Apr 2026 14:09:18 -0600 Subject: [PATCH 21/32] feat(web): custom RadialEdge with gentle curves clipped at node perimeters --- web/src/GraphView.tsx | 5 ++- web/src/RadialEdge.tsx | 77 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 web/src/RadialEdge.tsx diff --git a/web/src/GraphView.tsx b/web/src/GraphView.tsx index abc0f20..e9a29e9 100644 --- a/web/src/GraphView.tsx +++ b/web/src/GraphView.tsx @@ -13,6 +13,7 @@ import "@xyflow/react/dist/style.css" import DotNode from "./DotNode" import type { DotNodeData, ColorMode } from "./DotNode" +import RadialEdge from "./RadialEdge" import { layoutGraph } from "./graph-layout" import { computeSubtreeEntropy } from "./graph-utils" import type { NodeMap, DagEdge, NodeState } from "./types" @@ -33,6 +34,7 @@ const COLOR_MODES: { value: ColorMode; label: string }[] = [ ] const nodeTypes = { dot: DotNode } +const edgeTypes = { radial: RadialEdge } interface GraphViewProps { nodeMap: NodeMap @@ -93,7 +95,7 @@ function buildFlowEdges( id: edgeId, source: e.parent_id, target: e.child_id, - type: "straight", + type: "radial", style: { stroke: isConnected ? "#6b7280" : "#374151", strokeWidth: isConnected ? 1.5 : 1, @@ -308,6 +310,7 @@ function GraphViewInner({ nodes={displayNodes} edges={displayEdges} nodeTypes={nodeTypes} + edgeTypes={edgeTypes} onNodeClick={onNodeClick} onPaneClick={onPaneClick} minZoom={0.1} diff --git a/web/src/RadialEdge.tsx b/web/src/RadialEdge.tsx new file mode 100644 index 0000000..0aee26b --- /dev/null +++ b/web/src/RadialEdge.tsx @@ -0,0 +1,77 @@ +import { memo } from "react" +import { useInternalNode, type EdgeProps } from "@xyflow/react" + +/** + * Custom edge for radial layout: draws a gentle quadratic bezier curve + * from source node center to target node center, clipped at each node's + * perimeter (based on nodeRadius in data). + */ +function RadialEdge({ + id, + source, + target, + style, +}: EdgeProps) { + const sourceNode = useInternalNode(source) + const targetNode = useInternalNode(target) + + if (!sourceNode || !targetNode) return null + + // Node centers (internals have positionAbsolute) + const sourceRadius = ((sourceNode.internals.userNode.data as Record)?.nodeRadius as number) ?? 8 + const targetRadius = ((targetNode.internals.userNode.data as Record)?.nodeRadius as number) ?? 8 + + // React Flow node positions are top-left; add half the node dimensions to get center + // For our dot nodes, the visual size is 2*radius but the React Flow node wrapper may differ. + // Using measured dimensions from internals. + const sw = sourceNode.measured.width ?? sourceRadius * 2 + const sh = sourceNode.measured.height ?? sourceRadius * 2 + const tw = targetNode.measured.width ?? targetRadius * 2 + const th = targetNode.measured.height ?? targetRadius * 2 + + const sx = sourceNode.internals.positionAbsolute.x + sw / 2 + const sy = sourceNode.internals.positionAbsolute.y + sh / 2 + const tx = targetNode.internals.positionAbsolute.x + tw / 2 + const ty = targetNode.internals.positionAbsolute.y + th / 2 + + // Vector from source to target + const dx = tx - sx + const dy = ty - sy + const dist = Math.sqrt(dx * dx + dy * dy) + + if (dist < 0.1) return null + + // Clip at perimeters + const nx = dx / dist + const ny = dy / dist + const x1 = sx + nx * sourceRadius + const y1 = sy + ny * sourceRadius + const x2 = tx - nx * targetRadius + const y2 = ty - ny * targetRadius + + // Gentle curve: control point offset perpendicular to the line + // Offset is small (5-8% of distance) for subtle curvature + const midX = (x1 + x2) / 2 + const midY = (y1 + y2) / 2 + const curvature = dist * 0.06 + // Perpendicular direction (consistent side based on source position) + const cpX = midX + (-ny) * curvature + const cpY = midY + nx * curvature + + const path = `M ${x1} ${y1} Q ${cpX} ${cpY} ${x2} ${y2}` + + return ( + + + + ) +} + +export default memo(RadialEdge) From 45a9ad814694aa199099d7b448899acf3d72401e Mon Sep 17 00:00:00 2001 From: Nick Furfaro Date: Thu, 2 Apr 2026 14:11:06 -0600 Subject: [PATCH 22/32] fix(web): RadialEdge connects to dot center, not wrapper center - Dot sits at top of flex column, use radius for Y offset instead of half height - Fixes edges connecting to wrong point when label is visible --- web/src/RadialEdge.tsx | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/web/src/RadialEdge.tsx b/web/src/RadialEdge.tsx index 0aee26b..05ef3a0 100644 --- a/web/src/RadialEdge.tsx +++ b/web/src/RadialEdge.tsx @@ -17,22 +17,21 @@ function RadialEdge({ if (!sourceNode || !targetNode) return null - // Node centers (internals have positionAbsolute) + // Node radii from layout data const sourceRadius = ((sourceNode.internals.userNode.data as Record)?.nodeRadius as number) ?? 8 const targetRadius = ((targetNode.internals.userNode.data as Record)?.nodeRadius as number) ?? 8 - // React Flow node positions are top-left; add half the node dimensions to get center - // For our dot nodes, the visual size is 2*radius but the React Flow node wrapper may differ. - // Using measured dimensions from internals. + // The dot is centered horizontally in the node wrapper, and sits at the top. + // Node position is top-left of the wrapper. The dot diameter = 2*radius. + // Dot center X = position.x + wrapper_width/2 + // Dot center Y = position.y + radius (dot is at top of flex column) const sw = sourceNode.measured.width ?? sourceRadius * 2 - const sh = sourceNode.measured.height ?? sourceRadius * 2 const tw = targetNode.measured.width ?? targetRadius * 2 - const th = targetNode.measured.height ?? targetRadius * 2 const sx = sourceNode.internals.positionAbsolute.x + sw / 2 - const sy = sourceNode.internals.positionAbsolute.y + sh / 2 + const sy = sourceNode.internals.positionAbsolute.y + sourceRadius const tx = targetNode.internals.positionAbsolute.x + tw / 2 - const ty = targetNode.internals.positionAbsolute.y + th / 2 + const ty = targetNode.internals.positionAbsolute.y + targetRadius // Vector from source to target const dx = tx - sx From d393aa0287b6efd0bfdac7e06cea91b4d6a174fb Mon Sep 17 00:00:00 2001 From: Nick Furfaro Date: Thu, 2 Apr 2026 14:24:42 -0600 Subject: [PATCH 23/32] =?UTF-8?q?fix(web):=20clear=20node=20label=20on=20p?= =?UTF-8?q?ane=20click=20=E2=80=94=20only=20show=20label=20for=20graph-cli?= =?UTF-8?q?cked=20node?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/GraphView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/GraphView.tsx b/web/src/GraphView.tsx index e9a29e9..b90389c 100644 --- a/web/src/GraphView.tsx +++ b/web/src/GraphView.tsx @@ -179,7 +179,7 @@ function GraphViewInner({ ...n, data: { ...n.data, - showLabel: n.id === activeNodeId, + showLabel: n.id === selectedNodeId, selected: n.id === activeNodeId, colorMode, }, From fe49ae4391ff5fa423f776aa05eea65d0325f08b Mon Sep 17 00:00:00 2001 From: Nick Furfaro Date: Thu, 2 Apr 2026 14:28:16 -0600 Subject: [PATCH 24/32] docs: impl plan --- docs/plans/2026-04-02-graph-view-v2-impl.md | 211 ++++++++++++++++++++ 1 file changed, 211 insertions(+) create mode 100644 docs/plans/2026-04-02-graph-view-v2-impl.md diff --git a/docs/plans/2026-04-02-graph-view-v2-impl.md b/docs/plans/2026-04-02-graph-view-v2-impl.md new file mode 100644 index 0000000..b5c3b4f --- /dev/null +++ b/docs/plans/2026-04-02-graph-view-v2-impl.md @@ -0,0 +1,211 @@ +# Graph View v2 — Radial Layout + Controls Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Improve the graph view with an organic radial layout inspired by `eddy/lib/painting/radial_layout.dart`, add depth collapse control, and color mode toggles (state / child-count heatmap / subtree entropy heatmap). + +**Architecture:** Replace the current simple concentric-ring layout with a variable-distance radial layout that sizes nodes by subtree count, assigns angular slots center-out, and resolves overlaps via iterative repulsion. Add toolbar controls for max depth and color mode. + +**Tech Stack:** React, TypeScript, @xyflow/react, Tailwind + +--- + +### Task 1: Rewrite `graph-layout.ts` with organic radial layout + +**Files:** +- Modify: `web/src/graph-layout.ts` + +**Step 1: Rewrite the layout algorithm** + +Replace the entire file. The new algorithm: + +1. **Compute subtree sizes** (leaf count) for each node via DFS +2. **Sort children** by subtree size descending — largest subtrees get placed first +3. **Center-out angular slot assignment** — most significant child at center of available arc, then alternating left/right (from Eddy's `_centerOutSlot`) +4. **Variable distance from center** — nodes with larger subtrees placed further out (they need more angular space for their own children) +5. **Node radius proportional to subtree size** — returned as `data.nodeRadius` for DotNode to use +6. **Overlap resolution** — iterative pairwise repulsion (16 iterations), pushing overlapping nodes apart while keeping them roughly at their assigned angle +7. **Small angle jitter** — deterministic from node ID hash, for organic feel +8. **Depth limiting** — accept a `maxDepth` parameter; nodes beyond maxDepth are not included in output + +```typescript +import type { Node, Edge } from "@xyflow/react" + +interface LayoutOptions { + maxDepth?: number // null = show all +} + +export async function layoutGraph( + nodes: Node[], + edges: Edge[], + options: LayoutOptions = {}, +): Promise { + // ... full implementation +} +``` + +Key constants (adapted from Eddy): +- `RING_MIN_FRACTION = 0.15` — closest ring distance (fraction of layout radius) +- `RING_MAX_FRACTION = 0.45` — furthest ring distance +- `OVERLAP_PADDING = 20` — min gap between nodes +- `OVERLAP_ITERATIONS = 16` +- `JITTER_AMPLITUDE = 0.04` radians + +The layout radius should be computed from node count: `layoutRadius = Math.max(300, Math.sqrt(nodeCount) * 80)` + +For **depth limiting**: BFS from roots, skip nodes at depth > maxDepth. Nodes at exactly maxDepth that have children get a special `isCollapsed: true` flag in their data so DotNode can show a visual indicator (e.g., a small "+" or thicker ring). + +**Step 2: Verify build** + +Run: `cd /Users/nick/dev/essential/spec-forest/web && bun run build` + +**Step 3: Commit** +``` +feat(web): organic radial layout with variable distance, overlap resolution, depth limiting +``` + +--- + +### Task 2: Update `DotNode.tsx` for dynamic sizing and collapse indicator + +**Files:** +- Modify: `web/src/DotNode.tsx` + +**Step 1: Add new data fields** + +Add to `DotNodeData`: +```typescript +nodeRadius?: number // computed by layout, overrides entropy-based sizing +isCollapsed?: boolean // true if node has hidden children (at depth limit) +childCount?: number // for heatmap color mode +subtreeEntropy?: number // for heatmap color mode +colorMode?: "state" | "children" | "entropy" +``` + +**Step 2: Update rendering** + +- If `nodeRadius` is provided, use it directly instead of `entropyToSize()` +- If `isCollapsed`, add a dashed ring around the dot (CSS `border: 2px dashed`) to indicate hidden children +- If `colorMode` is `"children"`, color by `childCount` on a blue→red gradient +- If `colorMode` is `"entropy"`, color by `subtreeEntropy` on a green→yellow→red gradient +- Default `colorMode` is `"state"` (current behavior) + +**Step 3: Verify build** + +Run: `cd /Users/nick/dev/essential/spec-forest/web && bun run build` + +**Step 4: Commit** +``` +feat(web): DotNode supports dynamic sizing, collapse indicator, and color modes +``` + +--- + +### Task 3: Update `GraphView.tsx` — wire depth control and color modes + +**Files:** +- Modify: `web/src/GraphView.tsx` + +**Step 1: Add state for depth and color mode** + +```typescript +const [maxDepth, setMaxDepth] = useState(null) // null = all +const [colorMode, setColorMode] = useState<"state" | "children" | "entropy">("state") +``` + +**Step 2: Pass maxDepth to layoutGraph** + +Update the `useEffect` that calls `layoutGraph` to pass `{ maxDepth }` options. + +**Step 3: Pass color mode data to nodes** + +In `buildFlowNodes`, compute and attach: +- `childCount` from `nodeMap[id].children.length` +- `subtreeEntropy` from the existing `computeSubtreeEntropy()` in `graph-utils.ts` +- `colorMode` from the state + +**Step 4: Add toolbar controls** + +In the filter bar, add: + +**Depth slider**: A small numeric control or discrete buttons (1, 2, 3, 4, All) for `maxDepth`. Style as small pills like the state filter pills. + +**Color mode toggle**: Three small pills: "State" (default), "Children", "Entropy". When active, the corresponding pill gets a highlight. Mutually exclusive. + +```tsx +{/* Depth control */} +Depth: +{[1, 2, 3, 4].map((d) => ( + +))} + + +{/* Color mode */} +Color: +{(["state", "children", "entropy"] as const).map((mode) => ( + +))} +``` + +**Step 5: Re-layout when maxDepth changes** + +Add `maxDepth` to the dependency array of the layout effect. When it changes, re-run layout and fitView. + +**Step 6: Verify build** + +Run: `cd /Users/nick/dev/essential/spec-forest/web && bun run build` + +**Step 7: Commit** +``` +feat(web): add depth collapse control and color mode toggles to graph view +``` + +--- + +### Task 4: Build and verify + +**Step 1: Full build** +Run: `cd /Users/nick/dev/essential/spec-forest/web && bun run build` + +**Step 2: Manual testing** +1. Open graph view — root should be centered with children spreading organically +2. Nodes with large subtrees should be bigger and further from center +3. No overlapping nodes (overlap resolution working) +4. Depth buttons: clicking "2" collapses everything beyond depth 2, collapsed nodes show indicator +5. Clicking "All" shows everything +6. Color mode "children" shows blue→red heatmap by child count +7. Color mode "entropy" shows green→yellow→red by subtree entropy +8. Color mode "state" restores default green/amber/blue coloring +9. Selection still works (click node → popover, outliner sync) + +**Step 3: Commit any fixes** +``` +chore: graph view v2 polish +``` + +## Verification + +1. Graph renders with organic radial layout — not perfect circles +2. Larger subtrees get bigger nodes placed further from center +3. No node overlaps +4. Depth 1/2/3/4/All buttons collapse/expand correctly +5. Collapsed nodes show visual indicator +6. Three color modes work correctly +7. All existing features still work (search, state filter, selection, popover) From 3c2e370e15a4610006d6b07b2947d4051ea38645 Mon Sep 17 00:00:00 2001 From: Nick Furfaro Date: Thu, 2 Apr 2026 14:35:28 -0600 Subject: [PATCH 25/32] fix(web): use showLabel (not selected) to control label visibility in DotNode --- web/src/DotNode.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/DotNode.tsx b/web/src/DotNode.tsx index 91764ac..700dc3e 100644 --- a/web/src/DotNode.tsx +++ b/web/src/DotNode.tsx @@ -85,7 +85,7 @@ function DotNode({ data }: NodeProps & { data: DotNodeData }) { boxSizing: "border-box", }} /> - {data.selected && ( + {data.showLabel && ( {truncateLabel(data.question, 40)} From c228a5d316b1395f6329563f1db36e42fea0b923 Mon Sep 17 00:00:00 2001 From: Nick Furfaro Date: Thu, 2 Apr 2026 14:41:55 -0600 Subject: [PATCH 26/32] =?UTF-8?q?fix(web):=20prevent=20graph=20flicker=20f?= =?UTF-8?q?rom=20polling=20=E2=80=94=20only=20re-layout=20when=20structure?= =?UTF-8?q?=20changes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use stable fingerprint of node IDs + edge IDs + maxDepth - Polling creates new array references every 2s but same content - Layout only re-runs when nodes/edges are actually added or removed --- web/src/GraphView.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/web/src/GraphView.tsx b/web/src/GraphView.tsx index b90389c..b07f2b8 100644 --- a/web/src/GraphView.tsx +++ b/web/src/GraphView.tsx @@ -148,7 +148,14 @@ function GraphViewInner({ [nodeMap], ) - // Build and layout when nodeMap, edges, or maxDepth change + // Stable fingerprint: only re-layout when node IDs/edges/depth actually change + const graphFingerprint = useMemo(() => { + const nodeIds = Object.keys(nodeMap).sort().join(",") + const edgeIds = edges.map((e) => `${e.parent_id}-${e.child_id}`).sort().join(",") + return `${nodeIds}|${edgeIds}|${maxDepth ?? "all"}` + }, [nodeMap, edges, maxDepth]) + + // Build and layout only when graph structure or depth changes useEffect(() => { const rfNodes = buildFlowNodes(nodeMap, subtreeEntropyMap) const rfEdges = buildFlowEdges(edges, null, nodeMap) @@ -157,7 +164,8 @@ function GraphViewInner({ setFlowNodes(positioned) setTimeout(() => fitViewRef.current({ padding: 0.15 }), 50) }) - }, [nodeMap, edges, subtreeEntropyMap, maxDepth]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [graphFingerprint]) // Visible node IDs for search/state filter (post-layout, just visual fading) const visibleNodeIds = useMemo(() => { From d64a322babbe60a5fd3311322477e8554df3cdc5 Mon Sep 17 00:00:00 2001 From: Nick Furfaro Date: Thu, 2 Apr 2026 14:45:28 -0600 Subject: [PATCH 27/32] fix(web): vivid heatmap gradient with log scaling for better contrast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 4-stop gradient: deep purple → cyan → yellow → red-orange - Log scaling so leaf nodes (0) are clearly distinct from 1-2 children - Much higher perceptual contrast on dark backgrounds --- web/src/DotNode.tsx | 44 +++++++++++++++++++++++++------------------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/web/src/DotNode.tsx b/web/src/DotNode.tsx index 700dc3e..aeaa6c3 100644 --- a/web/src/DotNode.tsx +++ b/web/src/DotNode.tsx @@ -26,23 +26,27 @@ const STATE_COLORS: Record = { deleted: "#6b7280", } -/** Blue → yellow → red gradient for heatmaps. t in [0, 1]. */ +/** + * Vivid 4-stop heatmap: deep purple → cyan → yellow → red-orange. + * High perceptual contrast on dark backgrounds. t in [0, 1]. + */ function heatColor(t: number): string { - const clamped = Math.max(0, Math.min(1, t)) - if (clamped < 0.5) { - // blue → yellow - const p = clamped * 2 - const r = Math.round(59 + p * (245 - 59)) - const g = Math.round(130 + p * (158 - 130)) - const b = Math.round(246 + p * (11 - 246)) - return `rgb(${r},${g},${b})` + const c = Math.max(0, Math.min(1, t)) + // 4 stops: purple(0) → cyan(0.33) → yellow(0.66) → red-orange(1) + if (c < 0.33) { + const p = c / 0.33 + return lerpRgb(100, 40, 200, 0, 210, 210, p) // purple → cyan } - // yellow → red - const p = (clamped - 0.5) * 2 - const r = Math.round(245 + p * (239 - 245)) - const g = Math.round(158 + p * (68 - 158)) - const b = Math.round(11 + p * (68 - 11)) - return `rgb(${r},${g},${b})` + if (c < 0.66) { + const p = (c - 0.33) / 0.33 + return lerpRgb(0, 210, 210, 250, 220, 30, p) // cyan → yellow + } + const p = (c - 0.66) / 0.34 + return lerpRgb(250, 220, 30, 240, 60, 40, p) // yellow → red-orange +} + +function lerpRgb(r1: number, g1: number, b1: number, r2: number, g2: number, b2: number, t: number): string { + return `rgb(${Math.round(r1 + (r2 - r1) * t)},${Math.round(g1 + (g2 - g1) * t)},${Math.round(b1 + (b2 - b1) * t)})` } function truncateLabel(text: string, maxChars: number): string { @@ -56,11 +60,13 @@ function DotNode({ data }: NodeProps & { data: DotNodeData }) { // Determine color let color: string if (colorMode === "children" && data.childCount != null) { - // Normalize: 0 children = cool, 20+ = hot - color = heatColor(Math.min(data.childCount / 20, 1)) + // Log scale: 0→0, 1→0.3, 3→0.55, 10→0.8, 20+→1.0 + const t = data.childCount === 0 ? 0 : Math.min(Math.log(data.childCount + 1) / Math.log(21), 1) + color = heatColor(t) } else if (colorMode === "entropy" && data.subtreeEntropy != null) { - // Normalize: 0 entropy = cool, 8+ = hot - color = heatColor(Math.min(data.subtreeEntropy / 8, 1)) + // Log scale: 0→0, 1→0.35, 3→0.6, 8+→1.0 + const t = data.subtreeEntropy === 0 ? 0 : Math.min(Math.log(data.subtreeEntropy + 1) / Math.log(9), 1) + color = heatColor(t) } else { color = STATE_COLORS[data.state] } From 5175d4840a758ed2ca3aff5a797495746d22f906 Mon Sep 17 00:00:00 2001 From: Nick Furfaro Date: Thu, 2 Apr 2026 14:47:54 -0600 Subject: [PATCH 28/32] fix(web): only fitView on initial load and depth change, preserve manual zoom --- web/src/GraphView.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/web/src/GraphView.tsx b/web/src/GraphView.tsx index b07f2b8..f8a0229 100644 --- a/web/src/GraphView.tsx +++ b/web/src/GraphView.tsx @@ -156,13 +156,24 @@ function GraphViewInner({ }, [nodeMap, edges, maxDepth]) // Build and layout only when graph structure or depth changes + const initialFitDone = useRef(false) + const prevFingerprint = useRef(null) + useEffect(() => { const rfNodes = buildFlowNodes(nodeMap, subtreeEntropyMap) const rfEdges = buildFlowEdges(edges, null, nodeMap) + const isDepthChange = prevFingerprint.current !== null && + prevFingerprint.current.split("|")[2] !== (maxDepth ?? "all").toString() + prevFingerprint.current = graphFingerprint + layoutGraph(rfNodes, rfEdges, { maxDepth }).then((positioned) => { setFlowNodes(positioned) - setTimeout(() => fitViewRef.current({ padding: 0.15 }), 50) + // Only fitView on initial render or depth change — not on polling updates + if (!initialFitDone.current || isDepthChange) { + initialFitDone.current = true + setTimeout(() => fitViewRef.current({ padding: 0.15 }), 50) + } }) // eslint-disable-next-line react-hooks/exhaustive-deps }, [graphFingerprint]) From 29572270d3fae3308925db242a983766d1d0ac30 Mon Sep 17 00:00:00 2001 From: Nick Furfaro Date: Thu, 2 Apr 2026 14:58:40 -0600 Subject: [PATCH 29/32] =?UTF-8?q?fix(web):=20semantic=20heatmap=20gradient?= =?UTF-8?q?=20=E2=80=94=20cool=20blue=20=E2=86=92=20purple=20=E2=86=92=20m?= =?UTF-8?q?agenta=20=E2=86=92=20dark=20red?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/DotNode.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/web/src/DotNode.tsx b/web/src/DotNode.tsx index aeaa6c3..a7b265f 100644 --- a/web/src/DotNode.tsx +++ b/web/src/DotNode.tsx @@ -27,22 +27,22 @@ const STATE_COLORS: Record = { } /** - * Vivid 4-stop heatmap: deep purple → cyan → yellow → red-orange. - * High perceptual contrast on dark backgrounds. t in [0, 1]. + * Cool→hot heatmap: cool blue → purple → magenta → dark red. + * Semantic: low=cool blue, high=dark red. t in [0, 1]. */ function heatColor(t: number): string { const c = Math.max(0, Math.min(1, t)) - // 4 stops: purple(0) → cyan(0.33) → yellow(0.66) → red-orange(1) + // 4 stops: cool blue(0) → indigo(0.33) → magenta(0.66) → dark red(1) if (c < 0.33) { const p = c / 0.33 - return lerpRgb(100, 40, 200, 0, 210, 210, p) // purple → cyan + return lerpRgb(60, 140, 220, 100, 80, 200, p) // cool blue → indigo } if (c < 0.66) { const p = (c - 0.33) / 0.33 - return lerpRgb(0, 210, 210, 250, 220, 30, p) // cyan → yellow + return lerpRgb(100, 80, 200, 200, 50, 130, p) // indigo → magenta } const p = (c - 0.66) / 0.34 - return lerpRgb(250, 220, 30, 240, 60, 40, p) // yellow → red-orange + return lerpRgb(200, 50, 130, 180, 20, 20, p) // magenta → dark red } function lerpRgb(r1: number, g1: number, b1: number, r2: number, g2: number, b2: number, t: number): string { From c11c448f22443e4cdd12c2d48270b2db4eebc2f2 Mon Sep 17 00:00:00 2001 From: Nick Furfaro Date: Thu, 2 Apr 2026 15:10:18 -0600 Subject: [PATCH 30/32] fix(web): reduce root node halo glow to avoid giant dark blobs at shallow depths --- web/src/DotNode.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/DotNode.tsx b/web/src/DotNode.tsx index a7b265f..10090eb 100644 --- a/web/src/DotNode.tsx +++ b/web/src/DotNode.tsx @@ -83,9 +83,9 @@ function DotNode({ data }: NodeProps & { data: DotNodeData }) { height: size, backgroundColor: color, boxShadow: data.isRoot - ? `0 0 12px 4px ${color}50, 0 0 0 3px ${color}90, 0 0 0 6px ${color}30` + ? `0 0 6px 2px ${color}40, 0 0 0 2px ${color}70` : data.selected - ? `0 0 8px 3px ${color}80, 0 0 0 2px ${color}` + ? `0 0 6px 2px ${color}60, 0 0 0 2px ${color}` : undefined, border: data.isCollapsed ? `2px dashed ${color}` : undefined, boxSizing: "border-box", From bb434e830a2e3ea026c0176bf91811d791e338d3 Mon Sep 17 00:00:00 2001 From: Nick Furfaro Date: Thu, 2 Apr 2026 15:21:01 -0600 Subject: [PATCH 31/32] fix(web): normalize heatmap colors relative to dataset min/max - Compute maxChildCount and maxSubtreeEntropy from actual data - Pass to DotNode so colors use full gradient range - Use sqrt scaling for perceptual evenness - Fixes entropy heatmap looking uniform when values are in narrow range --- web/src/DotNode.tsx | 14 ++++++++------ web/src/GraphView.tsx | 11 +++++++++++ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/web/src/DotNode.tsx b/web/src/DotNode.tsx index 10090eb..b73d6a0 100644 --- a/web/src/DotNode.tsx +++ b/web/src/DotNode.tsx @@ -17,6 +17,8 @@ export interface DotNodeData { childCount?: number subtreeEntropy?: number | null colorMode?: ColorMode + maxChildCount?: number + maxSubtreeEntropy?: number } const STATE_COLORS: Record = { @@ -60,13 +62,13 @@ function DotNode({ data }: NodeProps & { data: DotNodeData }) { // Determine color let color: string if (colorMode === "children" && data.childCount != null) { - // Log scale: 0→0, 1→0.3, 3→0.55, 10→0.8, 20+→1.0 - const t = data.childCount === 0 ? 0 : Math.min(Math.log(data.childCount + 1) / Math.log(21), 1) - color = heatColor(t) + const maxC = data.maxChildCount ?? 20 + const t = maxC > 0 ? Math.sqrt(data.childCount / maxC) : 0 + color = heatColor(Math.min(t, 1)) } else if (colorMode === "entropy" && data.subtreeEntropy != null) { - // Log scale: 0→0, 1→0.35, 3→0.6, 8+→1.0 - const t = data.subtreeEntropy === 0 ? 0 : Math.min(Math.log(data.subtreeEntropy + 1) / Math.log(9), 1) - color = heatColor(t) + const maxE = data.maxSubtreeEntropy ?? 8 + const t = maxE > 0 ? Math.sqrt(data.subtreeEntropy / maxE) : 0 + color = heatColor(Math.min(t, 1)) } else { color = STATE_COLORS[data.state] } diff --git a/web/src/GraphView.tsx b/web/src/GraphView.tsx index f8a0229..75de71f 100644 --- a/web/src/GraphView.tsx +++ b/web/src/GraphView.tsx @@ -55,6 +55,15 @@ function buildFlowNodes( .map((n) => n.id), ) + // Compute dataset max values for relative heatmap normalization + let maxChildCount = 0 + let maxSubtreeEntropy = 0 + for (const node of Object.values(nodeMap)) { + maxChildCount = Math.max(maxChildCount, node.children.length) + const se = subtreeEntropyMap.get(node.id) + if (se != null) maxSubtreeEntropy = Math.max(maxSubtreeEntropy, se) + } + return Object.values(nodeMap).map((node) => ({ id: node.id, type: "dot", @@ -69,6 +78,8 @@ function buildFlowNodes( isRoot: rootIds.has(node.id), childCount: node.children.length, subtreeEntropy: subtreeEntropyMap.get(node.id) ?? null, + maxChildCount, + maxSubtreeEntropy, } satisfies DotNodeData, })) } From a62abcea0e9542719df00e6b68d6217a9fc5e0ec Mon Sep 17 00:00:00 2001 From: Nick Furfaro Date: Thu, 2 Apr 2026 15:24:46 -0600 Subject: [PATCH 32/32] fix(web): normalize heatmap over actual [min, max] range for full gradient spread - Compute min and max of both childCount and subtreeEntropy from dataset - Linear interpolation between min and max ensures full color range used - Fixes all nodes looking the same color when values cluster in narrow range --- web/src/DotNode.tsx | 20 +++++++++++++------- web/src/GraphView.tsx | 14 ++++++++++++-- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/web/src/DotNode.tsx b/web/src/DotNode.tsx index b73d6a0..c6baf07 100644 --- a/web/src/DotNode.tsx +++ b/web/src/DotNode.tsx @@ -17,7 +17,9 @@ export interface DotNodeData { childCount?: number subtreeEntropy?: number | null colorMode?: ColorMode + minChildCount?: number maxChildCount?: number + minSubtreeEntropy?: number maxSubtreeEntropy?: number } @@ -59,16 +61,20 @@ function truncateLabel(text: string, maxChars: number): string { function DotNode({ data }: NodeProps & { data: DotNodeData }) { const colorMode = data.colorMode ?? "state" - // Determine color + // Determine color — linear normalization over actual [min, max] range let color: string if (colorMode === "children" && data.childCount != null) { - const maxC = data.maxChildCount ?? 20 - const t = maxC > 0 ? Math.sqrt(data.childCount / maxC) : 0 - color = heatColor(Math.min(t, 1)) + const lo = data.minChildCount ?? 0 + const hi = data.maxChildCount ?? 20 + const range = hi - lo + const t = range > 0 ? (data.childCount - lo) / range : 0 + color = heatColor(Math.max(0, Math.min(1, t))) } else if (colorMode === "entropy" && data.subtreeEntropy != null) { - const maxE = data.maxSubtreeEntropy ?? 8 - const t = maxE > 0 ? Math.sqrt(data.subtreeEntropy / maxE) : 0 - color = heatColor(Math.min(t, 1)) + const lo = data.minSubtreeEntropy ?? 0 + const hi = data.maxSubtreeEntropy ?? 8 + const range = hi - lo + const t = range > 0 ? (data.subtreeEntropy - lo) / range : 0 + color = heatColor(Math.max(0, Math.min(1, t))) } else { color = STATE_COLORS[data.state] } diff --git a/web/src/GraphView.tsx b/web/src/GraphView.tsx index 75de71f..c42fbfc 100644 --- a/web/src/GraphView.tsx +++ b/web/src/GraphView.tsx @@ -55,14 +55,22 @@ function buildFlowNodes( .map((n) => n.id), ) - // Compute dataset max values for relative heatmap normalization + // Compute dataset min/max values for relative heatmap normalization + let minChildCount = Infinity let maxChildCount = 0 + let minSubtreeEntropy = Infinity let maxSubtreeEntropy = 0 for (const node of Object.values(nodeMap)) { + minChildCount = Math.min(minChildCount, node.children.length) maxChildCount = Math.max(maxChildCount, node.children.length) const se = subtreeEntropyMap.get(node.id) - if (se != null) maxSubtreeEntropy = Math.max(maxSubtreeEntropy, se) + if (se != null) { + minSubtreeEntropy = Math.min(minSubtreeEntropy, se) + maxSubtreeEntropy = Math.max(maxSubtreeEntropy, se) + } } + if (minChildCount === Infinity) minChildCount = 0 + if (minSubtreeEntropy === Infinity) minSubtreeEntropy = 0 return Object.values(nodeMap).map((node) => ({ id: node.id, @@ -78,7 +86,9 @@ function buildFlowNodes( isRoot: rootIds.has(node.id), childCount: node.children.length, subtreeEntropy: subtreeEntropyMap.get(node.id) ?? null, + minChildCount, maxChildCount, + minSubtreeEntropy, maxSubtreeEntropy, } satisfies DotNodeData, }))