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 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 +``` 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) 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" diff --git a/web/src/DotNode.tsx b/web/src/DotNode.tsx new file mode 100644 index 0000000..c6baf07 --- /dev/null +++ b/web/src/DotNode.tsx @@ -0,0 +1,121 @@ +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 + state: "answered" | "unanswered" | "needs_review" | "deleted" + entropy: number | null + showLabel: boolean + selected: boolean + isRoot: boolean + nodeRadius?: number + isCollapsed?: boolean + childCount?: number + subtreeEntropy?: number | null + colorMode?: ColorMode + minChildCount?: number + maxChildCount?: number + minSubtreeEntropy?: number + maxSubtreeEntropy?: number +} + +const STATE_COLORS: Record = { + answered: "#22c55e", + unanswered: "#f59e0b", + needs_review: "#3b82f6", + deleted: "#6b7280", +} + +/** + * 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: cool blue(0) → indigo(0.33) → magenta(0.66) → dark red(1) + if (c < 0.33) { + const p = c / 0.33 + 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(100, 80, 200, 200, 50, 130, p) // indigo → magenta + } + const p = (c - 0.66) / 0.34 + 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 { + 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 { + if (text.length <= maxChars) return text + return text.slice(0, maxChars).trimEnd() + "…" +} + +function DotNode({ data }: NodeProps & { data: DotNodeData }) { + const colorMode = data.colorMode ?? "state" + + // Determine color — linear normalization over actual [min, max] range + let color: string + if (colorMode === "children" && data.childCount != null) { + 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 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] + } + + // Size: prefer layout-computed radius, fallback to default + const size = data.nodeRadius != null ? data.nodeRadius * 2 : (data.isRoot ? 28 : 16) + + return ( +
+
+ {data.showLabel && ( + + {truncateLabel(data.question, 40)} + + )} + + +
+ ) +} + +export default memo(DotNode) diff --git a/web/src/GraphView.tsx b/web/src/GraphView.tsx new file mode 100644 index 0000000..c42fbfc --- /dev/null +++ b/web/src/GraphView.tsx @@ -0,0 +1,425 @@ +import { useState, useCallback, useEffect, useMemo, useRef } from "react" +import { + ReactFlow, + ReactFlowProvider, + Controls, + MiniMap, + useReactFlow, + type Node, + type Edge, + type NodeMouseHandler, +} from "@xyflow/react" +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" + +const STATE_COLORS: Record = { + answered: "#22c55e", + unanswered: "#f59e0b", + needs_review: "#3b82f6", + deleted: "#6b7280", +} + +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 } +const edgeTypes = { radial: RadialEdge } + +interface GraphViewProps { + nodeMap: NodeMap + edges: DagEdge[] + contextNodeId: string | null + focusedNodeId: string | null + onSelectNode: (nodeId: string) => void + onFocusNode: (nodeId: string) => void +} + +function buildFlowNodes( + nodeMap: NodeMap, + subtreeEntropyMap: Map, +): Node[] { + const rootIds = new Set( + Object.values(nodeMap) + .filter((n) => n.parents.length === 0) + .map((n) => n.id), + ) + + // 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) { + 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, + type: "dot", + position: { x: 0, y: 0 }, + data: { + question: node.question, + answer: node.answer, + state: node.state, + entropy: node.entropy_score, + showLabel: false, + selected: false, + isRoot: rootIds.has(node.id), + childCount: node.children.length, + subtreeEntropy: subtreeEntropyMap.get(node.id) ?? null, + minChildCount, + maxChildCount, + minSubtreeEntropy, + maxSubtreeEntropy, + } satisfies DotNodeData, + })) +} + +function buildFlowEdges( + edges: DagEdge[], + activeNodeId: string | null, + nodeMap: NodeMap, + visibleIds?: Set, +): Edge[] { + const connectedEdges = new Set() + 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 + .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: "radial", + style: { + stroke: isConnected ? "#6b7280" : "#374151", + strokeWidth: isConnected ? 1.5 : 1, + }, + } + }) +} + +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() + if (!node.question.toLowerCase().includes(q) && !(node.answer?.toLowerCase().includes(q) ?? false)) return false + } + return true +} + +function GraphViewInner({ + nodeMap, + edges, + contextNodeId: _contextNodeId, + focusedNodeId, + onSelectNode, + onFocusNode, +}: GraphViewProps) { + const { fitView } = useReactFlow() + + const [flowNodes, setFlowNodes] = 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 [maxDepth, setMaxDepth] = useState(null) + const [colorMode, setColorMode] = useState("state") + const containerRef = useRef(null) + 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], + ) + + // 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 + 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) + // 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]) + + // 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) + } + return set + }, [nodeMap, searchQuery, activeStates]) + + 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) => ({ + ...n, + data: { + ...n.data, + showLabel: n.id === selectedNodeId, + selected: n.id === activeNodeId, + colorMode, + }, + style: { + ...n.style, + opacity: visibleNodeIds.has(n.id) ? 1 : 0.15, + transition: "opacity 200ms ease", + }, + })), + [flowNodes, activeNodeId, visibleNodeIds, colorMode], + ) + + const displayEdges = useMemo( + () => buildFlowEdges(edges, activeNodeId, nodeMap, layoutNodeIds), + [edges, activeNodeId, nodeMap, layoutNodeIds], + ) + + const onNodeClick: NodeMouseHandler = useCallback( + (event, node) => { + setSelectedNodeId(node.id) + onSelectNode(node.id) + 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) => { + setActiveStates((prev) => { + const next = new Set(prev) + if (next.has(state)) next.delete(state) + else next.add(state) + return next + }) + } + + 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-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) + return ( + + ) + })} + + | + + {/* Depth control */} + Depth: + {DEPTH_OPTIONS.map((d) => ( + + ))} + + + | + + {/* Color mode */} + Color: + {COLOR_MODES.map(({ value, label }) => ( + + ))} + +
+ +
+ + {/* React Flow canvas */} +
+ + + + STATE_COLORS[(node.data as unknown as DotNodeData).state] ?? "#6b7280" + } + /> + + + {/* Detail popover */} + {selectedNode && popoverPos && ( +
+

+ {selectedNode.question} +

+ {selectedNode.answer && ( +

+ {selectedNode.answer} +

+ )} +
+ + {selectedNode.state.replace("_", " ")} + + {selectedNode.entropy_score != null && ( + + entropy: {selectedNode.entropy_score.toFixed(1)} + + )} + + {selectedNode.children.length} children + +
+ +
+ )} +
+
+ ) +} + +export default function GraphView(props: GraphViewProps) { + return ( + + + + ) +} diff --git a/web/src/RadialEdge.tsx b/web/src/RadialEdge.tsx new file mode 100644 index 0000000..05ef3a0 --- /dev/null +++ b/web/src/RadialEdge.tsx @@ -0,0 +1,76 @@ +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 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 + + // 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 tw = targetNode.measured.width ?? targetRadius * 2 + + const sx = sourceNode.internals.positionAbsolute.x + sw / 2 + const sy = sourceNode.internals.positionAbsolute.y + sourceRadius + const tx = targetNode.internals.positionAbsolute.x + tw / 2 + const ty = targetNode.internals.positionAbsolute.y + targetRadius + + // 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) 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) */} diff --git a/web/src/graph-layout.ts b/web/src/graph-layout.ts new file mode 100644 index 0000000..69b1cf3 --- /dev/null +++ b/web/src/graph-layout.ts @@ -0,0 +1,328 @@ +import type { Node, Edge } from "@xyflow/react" + +// --- Constants --- +const MIN_NODE_RADIUS = 6 +const MAX_NODE_RADIUS = 16 +const ROOT_NODE_RADIUS = 24 +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 + +// --- 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 +} + +/** + * 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 + + 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) { + const list = childrenMap.get(edge.source) ?? [] + list.push(edge.target) + childrenMap.set(edge.source, list) + hasParent.add(edge.target) + } + + // 2. Find roots (no incoming edges) + const roots = nodes.filter((n) => !hasParent.has(n.id)) + if (roots.length === 0) roots.push(nodes[0]) + + // 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 + visited.add(id) + const kids = childrenMap.get(id) ?? [] + if (kids.length === 0) { + subtreeSize.set(id, 1) + return 1 + } + let total = 0 + for (const kid of kids) { + total += computeSize(kid, visited) + } + subtreeSize.set(id, total) + return total + } + const sizeVisited = new Set() + for (const root of roots) { + computeSize(root.id, sizeVisited) + } + + // 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())) + + // 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, + wedgeSize: number, + isRoot: boolean, + ) { + if (placedIds.has(nodeId)) return + if (maxDepthLimit != null && depth > maxDepthLimit) return + placedIds.add(nodeId) + + const r = nodeRadius(nodeId, isRoot) + placed.push({ + id: nodeId, + x: cx, + y: cy, + radius: r, + depth, + hasChildrenBeyond: hasChildrenBeyondLimit(nodeId, depth), + }) + + // 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 + + // Sort children by subtree size descending + const sorted = [...kids].sort( + (a, b) => (subtreeSize.get(b) ?? 1) - (subtreeSize.get(a) ?? 1), + ) + + 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: 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 = baseRing * (1 + t * RING_DISTANCE_GROWTH) + + // 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, + ) + } + } + + if (roots.length === 1) { + // Single root at center + placeSubtree(roots[0].id, 0, 0, 0, 0, 2 * Math.PI, true) + } else { + // 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) / totalRootLeaves + 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) + angle += rootWedge + } + } + + // 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 (!placedIds.has(node.id)) { + const outerR = MAX_RING_DISTANCE * (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++ + } + } + + // 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 + } + } + } + } + + // 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, + }, + } + }) +}