diff --git a/docs/superpowers/plans/2026-05-03-graph-layout-scaling.md b/docs/superpowers/plans/2026-05-03-graph-layout-scaling.md new file mode 100644 index 0000000..05bdb5e --- /dev/null +++ b/docs/superpowers/plans/2026-05-03-graph-layout-scaling.md @@ -0,0 +1,2304 @@ +# Graph Layout Scaling Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace dagre with ELK across structural-style dashboard views, add folder/community-based containers in the layer-detail view, and compute layout in two lazy stages so layers with many nodes are readable and large graphs stay performant. + +**Architecture:** Three views (overview, DomainGraphView, layer-detail) call a new `applyElkLayout` instead of `applyDagreLayout`. The layer-detail view gains a `deriveContainers` step (folder strategy with Louvain fallback), aggregated cross-container edges, lazy two-stage ELK calls (Stage 1 = containers; Stage 2 = a container's children on demand), a new `ContainerNode` React Flow node type, and store extensions for expand state and layout caches. + +**Tech Stack:** TypeScript, React 19, Vite, React Flow (`@xyflow/react`), Zustand, Vitest, ELK.js (`elkjs`), `graphology` + `graphology-communities-louvain`. + +**Spec:** `docs/superpowers/specs/2026-05-03-graph-layout-scaling-design.md` + +--- + +## File Map + +``` +packages/dashboard/ +├── package.json [modify] add elkjs, graphology, graphology-communities-louvain, vitest +├── vite.config.ts [modify] add vitest test config +├── src/ +│ ├── utils/ +│ │ ├── layout.ts [modify] export applyElkLayout +│ │ ├── elk-layout.ts [new] runElk + repairElkInput + GraphIssue mapping +│ │ ├── containers.ts [new] deriveContainers (folder + community fallback) +│ │ ├── louvain.ts [new] thin wrapper around graphology-communities-louvain +│ │ ├── edgeAggregation.ts [modify] add aggregateContainerEdges +│ │ └── __tests__/ +│ │ ├── containers.test.ts [new] +│ │ ├── edgeAggregation.test.ts [new] +│ │ └── elk-layout.test.ts [new] +│ ├── components/ +│ │ ├── ContainerNode.tsx [new] +│ │ ├── GraphView.tsx [modify] Stage 1 / Stage 2 wiring, expand state, auto-expand +│ │ └── DomainGraphView.tsx [modify] dagre → ELK +│ └── store.ts [modify] expandedContainers, containerLayoutCache, containerSizeMemory +└── scripts/ + └── benchmark-layout.mjs [new] perf benchmark (uses scripts/generate-large-graph.mjs) +``` + +--- + +## Task 1: Dependencies + Vitest setup + +**Files:** +- Modify: `understand-anything-plugin/packages/dashboard/package.json` +- Create: `understand-anything-plugin/packages/dashboard/src/utils/__tests__/smoke.test.ts` +- Modify: `understand-anything-plugin/packages/dashboard/vite.config.ts` + +- [ ] **Step 1: Add deps and devDeps to package.json** + +Edit `understand-anything-plugin/packages/dashboard/package.json`. Add to `dependencies`: + +```json + "elkjs": "^0.9.3", + "graphology": "^0.25.4", + "graphology-communities-louvain": "^2.0.1", +``` + +Add to `devDependencies`: + +```json + "vitest": "^3.1.0", + "@vitest/coverage-v8": "^3.2.4", +``` + +Add to `scripts`: + +```json + "test": "vitest run", + "test:watch": "vitest" +``` + +- [ ] **Step 2: Update vite.config.ts to register vitest** + +In `understand-anything-plugin/packages/dashboard/vite.config.ts` add a triple-slash reference at the top and a `test` block. Open the file, then at the very top add: + +```ts +/// +``` + +Inside the `defineConfig({ ... })` object add: + +```ts + test: { + environment: "node", + include: ["src/**/__tests__/**/*.test.ts"], + }, +``` + +- [ ] **Step 3: Install deps** + +Run from the repo root: + +```bash +pnpm install +``` + +Expected: pnpm resolves and installs without errors. + +- [ ] **Step 4: Write smoke test** + +Create `understand-anything-plugin/packages/dashboard/src/utils/__tests__/smoke.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import ELK from "elkjs/lib/elk.bundled.js"; +import Graph from "graphology"; +import louvain from "graphology-communities-louvain"; + +describe("dependency smoke test", () => { + it("imports elkjs", () => { + expect(typeof ELK).toBe("function"); + }); + + it("imports graphology", () => { + const g = new Graph(); + g.addNode("a"); + expect(g.order).toBe(1); + }); + + it("imports graphology-communities-louvain", () => { + expect(typeof louvain).toBe("function"); + }); +}); +``` + +- [ ] **Step 5: Run smoke test** + +```bash +pnpm --filter @understand-anything/dashboard test +``` + +Expected: 3 tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add understand-anything-plugin/packages/dashboard/package.json \ + understand-anything-plugin/packages/dashboard/vite.config.ts \ + understand-anything-plugin/packages/dashboard/src/utils/__tests__/smoke.test.ts \ + pnpm-lock.yaml +git commit -m "chore(dashboard): add elkjs, graphology, vitest" +``` + +--- + +## Task 2: deriveContainers — folder strategy + edge cases + +**Files:** +- Create: `understand-anything-plugin/packages/dashboard/src/utils/containers.ts` +- Create: `understand-anything-plugin/packages/dashboard/src/utils/__tests__/containers.test.ts` + +- [ ] **Step 1: Write failing tests** + +Create `understand-anything-plugin/packages/dashboard/src/utils/__tests__/containers.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { deriveContainers } from "../containers"; +import type { GraphNode, GraphEdge } from "@understand-anything/core/types"; + +function node(id: string, filePath?: string): GraphNode { + return { + id, + type: "file", + name: id, + filePath, + summary: "", + complexity: "simple", + } as GraphNode; +} + +describe("deriveContainers — folder strategy", () => { + it("groups nodes by first folder segment after LCP", () => { + const nodes = [ + node("a", "src/auth/login.go"), + node("b", "src/auth/oauth.go"), + node("c", "src/cart/cart.go"), + node("d", "src/cart/checkout.go"), + ]; + const { containers, ungrouped } = deriveContainers(nodes, []); + expect(ungrouped).toEqual([]); + expect(containers).toHaveLength(2); + const names = containers.map((c) => c.name).sort(); + expect(names).toEqual(["auth", "cart"]); + const auth = containers.find((c) => c.name === "auth")!; + expect(auth.strategy).toBe("folder"); + expect(auth.nodeIds.sort()).toEqual(["a", "b"]); + }); + + it("strips deep LCP", () => { + const nodes = [ + node("a", "monorepo/backend/src/auth/login.go"), + node("b", "monorepo/backend/src/cart/cart.go"), + ]; + const { containers } = deriveContainers(nodes, []); + const names = containers.map((c) => c.name).sort(); + expect(names).toEqual(["auth", "cart"]); + }); + + it("collapses nested folders into the first segment", () => { + const nodes = [ + node("a", "auth/handlers/oauth.go"), + node("b", "auth/services/token.go"), + node("c", "cart/cart.go"), + ]; + const { containers } = deriveContainers(nodes, []); + expect(containers.find((c) => c.name === "auth")?.nodeIds.sort()).toEqual(["a", "b"]); + }); + + it("places nodes without filePath in '~' container", () => { + const nodes = [ + node("a", "auth/login.go"), + node("b", "auth/oauth.go"), + node("c"), + node("d"), + ]; + const { containers } = deriveContainers(nodes, []); + expect(containers.find((c) => c.name === "~")?.nodeIds.sort()).toEqual(["c", "d"]); + }); + + it("suppresses single-child containers (single child becomes ungrouped)", () => { + const nodes = [ + node("a", "auth/login.go"), + node("b", "auth/oauth.go"), + node("c", "cart/cart.go"), + ]; + const { containers, ungrouped } = deriveContainers(nodes, []); + // 'cart' has only 1 child → suppressed + expect(containers.find((c) => c.name === "cart")).toBeUndefined(); + expect(ungrouped).toContain("c"); + // 'auth' kept + expect(containers.find((c) => c.name === "auth")?.nodeIds.sort()).toEqual(["a", "b"]); + }); + + it("returns flat (no containers) when total nodes < 8", () => { + const nodes = [ + node("a", "auth/x.go"), + node("b", "cart/y.go"), + node("c", "logs/z.go"), + ]; + const { containers, ungrouped } = deriveContainers(nodes, []); + expect(containers).toHaveLength(0); + expect(ungrouped.sort()).toEqual(["a", "b", "c"]); + }); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +```bash +pnpm --filter @understand-anything/dashboard test containers +``` + +Expected: import error — `Cannot find module '../containers'`. + +- [ ] **Step 3: Implement `containers.ts`** + +Create `understand-anything-plugin/packages/dashboard/src/utils/containers.ts`: + +```ts +import type { + GraphNode, + GraphEdge, +} from "@understand-anything/core/types"; +import { detectCommunities } from "./louvain"; + +export interface DerivedContainer { + id: string; + name: string; + nodeIds: string[]; + strategy: "folder" | "community"; +} + +export interface DeriveResult { + containers: DerivedContainer[]; + ungrouped: string[]; +} + +const MIN_LAYER_SIZE_FOR_GROUPING = 8; +const MIN_FOLDER_COUNT = 3; +const MAX_CONCENTRATION = 0.6; +const ROOT_BUCKET = "~"; + +function commonPrefix(paths: string[]): string { + if (paths.length === 0) return ""; + let prefix = paths[0]; + for (const p of paths) { + while (!p.startsWith(prefix)) { + prefix = prefix.slice(0, -1); + if (!prefix) return ""; + } + } + // Trim back to a directory boundary + const lastSlash = prefix.lastIndexOf("/"); + return lastSlash >= 0 ? prefix.slice(0, lastSlash + 1) : ""; +} + +function firstSegment(path: string): string { + const slash = path.indexOf("/"); + return slash >= 0 ? path.slice(0, slash) : path; +} + +function groupByFolder( + nodes: GraphNode[], +): { groups: Map; rooted: string[] } { + const withPath = nodes.filter((n) => n.filePath); + const lcp = commonPrefix(withPath.map((n) => n.filePath!)); + const groups = new Map(); + const rooted: string[] = []; + for (const n of withPath) { + const stripped = n.filePath!.slice(lcp.length); + if (!stripped.includes("/")) { + rooted.push(n.id); + continue; + } + const seg = firstSegment(stripped); + const arr = groups.get(seg) ?? []; + arr.push(n.id); + groups.set(seg, arr); + } + for (const n of nodes) { + if (!n.filePath) rooted.push(n.id); + } + return { groups, rooted }; +} + +function shouldFallbackToCommunity( + groups: Map, + totalNodes: number, +): boolean { + if (groups.size < MIN_FOLDER_COUNT) return true; + for (const ids of groups.values()) { + if (ids.length / totalNodes > MAX_CONCENTRATION) return true; + } + return false; +} + +export function deriveContainers( + nodes: GraphNode[], + edges: GraphEdge[], +): DeriveResult { + if (nodes.length < MIN_LAYER_SIZE_FOR_GROUPING) { + return { containers: [], ungrouped: nodes.map((n) => n.id) }; + } + + const { groups, rooted } = groupByFolder(nodes); + + const useCommunity = shouldFallbackToCommunity(groups, nodes.length); + let containers: DerivedContainer[]; + + if (useCommunity) { + const communities = detectCommunities( + nodes.map((n) => n.id), + edges, + ); + const byCommunity = new Map(); + for (const [nodeId, cid] of communities) { + const arr = byCommunity.get(cid) ?? []; + arr.push(nodeId); + byCommunity.set(cid, arr); + } + const sorted = [...byCommunity.entries()].sort((a, b) => a[0] - b[0]); + containers = sorted.map(([cid, ids], i) => ({ + id: `container:cluster-${cid}`, + name: `Cluster ${String.fromCharCode(65 + i)}`, + nodeIds: ids, + strategy: "community" as const, + })); + } else { + containers = [...groups.entries()].map(([seg, ids]) => ({ + id: `container:${seg}`, + name: seg, + nodeIds: ids, + strategy: "folder" as const, + })); + if (rooted.length > 0) { + containers.push({ + id: `container:${ROOT_BUCKET}`, + name: ROOT_BUCKET, + nodeIds: rooted, + strategy: "folder" as const, + }); + } + } + + // Suppress single-child containers + const ungrouped: string[] = []; + containers = containers.filter((c) => { + if (c.nodeIds.length === 1) { + ungrouped.push(c.nodeIds[0]); + return false; + } + return true; + }); + + return { containers, ungrouped }; +} +``` + +- [ ] **Step 4: Stub `louvain.ts` (real impl in Task 3)** + +Create `understand-anything-plugin/packages/dashboard/src/utils/louvain.ts`: + +```ts +import type { GraphEdge } from "@understand-anything/core/types"; + +/** Returns [nodeId, communityId] for every node provided. */ +export function detectCommunities( + _nodeIds: string[], + _edges: GraphEdge[], +): Map { + // Real implementation arrives in Task 3. Stub: every node in community 0. + const m = new Map(); + for (const id of _nodeIds) m.set(id, 0); + return m; +} +``` + +- [ ] **Step 5: Run tests** + +```bash +pnpm --filter @understand-anything/dashboard test containers +``` + +Expected: 6 tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add understand-anything-plugin/packages/dashboard/src/utils/containers.ts \ + understand-anything-plugin/packages/dashboard/src/utils/louvain.ts \ + understand-anything-plugin/packages/dashboard/src/utils/__tests__/containers.test.ts +git commit -m "feat(dashboard): deriveContainers folder strategy" +``` + +--- + +## Task 3: deriveContainers — community fallback (Louvain) + +**Files:** +- Modify: `understand-anything-plugin/packages/dashboard/src/utils/louvain.ts` +- Modify: `understand-anything-plugin/packages/dashboard/src/utils/__tests__/containers.test.ts` + +- [ ] **Step 1: Add failing test for community fallback** + +Append to `containers.test.ts`: + +```ts +describe("deriveContainers — community fallback", () => { + it("falls back to communities when only one folder present", () => { + const nodes = Array.from({ length: 10 }, (_, i) => + node(`n${i}`, `services/n${i}.go`), + ); + // Two clusters of 5 nodes; densely connected within, no edges between + const edges: GraphEdge[] = []; + for (const i of [0, 1, 2, 3, 4]) { + for (const j of [0, 1, 2, 3, 4]) { + if (i !== j) edges.push({ source: `n${i}`, target: `n${j}`, type: "calls" } as GraphEdge); + } + } + for (const i of [5, 6, 7, 8, 9]) { + for (const j of [5, 6, 7, 8, 9]) { + if (i !== j) edges.push({ source: `n${i}`, target: `n${j}`, type: "calls" } as GraphEdge); + } + } + const { containers } = deriveContainers(nodes, edges); + expect(containers.length).toBeGreaterThanOrEqual(2); + for (const c of containers) { + expect(c.strategy).toBe("community"); + expect(c.name).toMatch(/^Cluster [A-Z]$/); + } + }); + + it("falls back when one folder holds > 60%", () => { + const nodes = [ + ...Array.from({ length: 8 }, (_, i) => node(`big${i}`, `big/file${i}.go`)), + node("a", "small1/a.go"), + node("b", "small2/b.go"), + ]; + const { containers } = deriveContainers(nodes, []); + expect(containers.every((c) => c.strategy === "community")).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run tests, expect failure** + +```bash +pnpm --filter @understand-anything/dashboard test containers +``` + +Expected: the new tests fail because the louvain stub puts every node in community 0 (only 1 container after suppression). + +- [ ] **Step 3: Replace louvain stub with real implementation** + +Overwrite `understand-anything-plugin/packages/dashboard/src/utils/louvain.ts`: + +```ts +import Graph from "graphology"; +import louvain from "graphology-communities-louvain"; +import type { GraphEdge } from "@understand-anything/core/types"; + +/** + * Run Louvain community detection over the provided node set and the + * subset of edges whose endpoints are both in the set. Returns a map of + * nodeId → communityId. Disconnected nodes get unique community ids so + * they don't collapse into a single cluster. + */ +export function detectCommunities( + nodeIds: string[], + edges: GraphEdge[], +): Map { + const ids = new Set(nodeIds); + const g = new Graph({ type: "undirected", multi: false }); + for (const id of nodeIds) g.addNode(id); + for (const e of edges) { + if (!ids.has(e.source) || !ids.has(e.target)) continue; + if (e.source === e.target) continue; + if (g.hasEdge(e.source, e.target)) continue; + g.addEdge(e.source, e.target); + } + // graphology-communities-louvain returns Record + const result = louvain(g) as Record; + const map = new Map(); + for (const id of nodeIds) { + map.set(id, result[id] ?? -1); + } + // Reassign disconnected nodes (community -1) to unique ids past the max + let next = + Math.max(...Array.from(map.values()).filter((v) => v >= 0), -1) + 1; + for (const [id, c] of map) { + if (c === -1) { + map.set(id, next++); + } + } + return map; +} +``` + +- [ ] **Step 4: Run tests** + +```bash +pnpm --filter @understand-anything/dashboard test containers +``` + +Expected: all 8 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add understand-anything-plugin/packages/dashboard/src/utils/louvain.ts \ + understand-anything-plugin/packages/dashboard/src/utils/__tests__/containers.test.ts +git commit -m "feat(dashboard): deriveContainers community fallback via Louvain" +``` + +--- + +## Task 4: aggregateContainerEdges + +**Files:** +- Modify: `understand-anything-plugin/packages/dashboard/src/utils/edgeAggregation.ts` +- Create: `understand-anything-plugin/packages/dashboard/src/utils/__tests__/edgeAggregation.test.ts` + +- [ ] **Step 1: Write failing tests** + +Create `understand-anything-plugin/packages/dashboard/src/utils/__tests__/edgeAggregation.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { aggregateContainerEdges } from "../edgeAggregation"; +import type { GraphEdge } from "@understand-anything/core/types"; + +const ce = (source: string, target: string, type: string = "calls"): GraphEdge => + ({ source, target, type }) as GraphEdge; + +describe("aggregateContainerEdges", () => { + it("returns empty arrays for empty input", () => { + const r = aggregateContainerEdges([], new Map()); + expect(r.intraContainer).toEqual([]); + expect(r.interContainerAggregated).toEqual([]); + }); + + it("preserves intra-container edges as-is", () => { + const m = new Map([ + ["a", "auth"], + ["b", "auth"], + ]); + const r = aggregateContainerEdges([ce("a", "b")], m); + expect(r.intraContainer).toHaveLength(1); + expect(r.interContainerAggregated).toEqual([]); + }); + + it("merges multiple same-direction inter edges into one", () => { + const m = new Map([ + ["a", "auth"], + ["b", "auth"], + ["c", "cart"], + ["d", "cart"], + ]); + const edges = [ce("a", "c"), ce("a", "d"), ce("b", "c", "imports")]; + const r = aggregateContainerEdges(edges, m); + expect(r.interContainerAggregated).toHaveLength(1); + const agg = r.interContainerAggregated[0]; + expect(agg.sourceContainerId).toBe("auth"); + expect(agg.targetContainerId).toBe("cart"); + expect(agg.count).toBe(3); + expect(agg.types.sort()).toEqual(["calls", "imports"]); + }); + + it("treats opposite directions as separate aggregated edges", () => { + const m = new Map([ + ["a", "auth"], + ["c", "cart"], + ]); + const r = aggregateContainerEdges([ce("a", "c"), ce("c", "a")], m); + expect(r.interContainerAggregated).toHaveLength(2); + const dirs = r.interContainerAggregated.map( + (e) => `${e.sourceContainerId}→${e.targetContainerId}`, + ); + expect(dirs.sort()).toEqual(["auth→cart", "cart→auth"]); + }); + + it("ignores edges whose endpoints have no container mapping", () => { + const m = new Map([["a", "auth"]]); + const r = aggregateContainerEdges([ce("a", "z")], m); + expect(r.intraContainer).toEqual([]); + expect(r.interContainerAggregated).toEqual([]); + }); +}); +``` + +- [ ] **Step 2: Run tests, expect failure** + +```bash +pnpm --filter @understand-anything/dashboard test edgeAggregation +``` + +Expected: import error — `aggregateContainerEdges` not exported. + +- [ ] **Step 3: Implement** + +Append to `understand-anything-plugin/packages/dashboard/src/utils/edgeAggregation.ts`: + +```ts +import type { GraphEdge } from "@understand-anything/core/types"; + +export interface AggregatedContainerEdge { + sourceContainerId: string; + targetContainerId: string; + count: number; + types: string[]; +} + +export interface ContainerEdgeBuckets { + intraContainer: GraphEdge[]; + interContainerAggregated: AggregatedContainerEdge[]; +} + +/** + * Bucket edges into intra-container (preserved) and inter-container + * (aggregated by directed (source,target) container pair). + * + * Direction is significant: A→B and B→A produce two independent + * aggregated edges. Edges whose endpoints have no container mapping + * are dropped (treat them as pre-filtered). + */ +export function aggregateContainerEdges( + edges: GraphEdge[], + nodeToContainer: Map, +): ContainerEdgeBuckets { + const intra: GraphEdge[] = []; + const interMap = new Map< + string, + { + sourceContainerId: string; + targetContainerId: string; + count: number; + types: Set; + } + >(); + + for (const e of edges) { + const sc = nodeToContainer.get(e.source); + const tc = nodeToContainer.get(e.target); + if (!sc || !tc) continue; + if (sc === tc) { + intra.push(e); + continue; + } + const key = `${sc}${tc}`; + const existing = interMap.get(key); + if (existing) { + existing.count++; + existing.types.add(e.type); + } else { + interMap.set(key, { + sourceContainerId: sc, + targetContainerId: tc, + count: 1, + types: new Set([e.type]), + }); + } + } + + const interContainerAggregated: AggregatedContainerEdge[] = [...interMap.values()].map( + (v) => ({ + sourceContainerId: v.sourceContainerId, + targetContainerId: v.targetContainerId, + count: v.count, + types: [...v.types], + }), + ); + + return { intraContainer: intra, interContainerAggregated }; +} +``` + +- [ ] **Step 4: Run tests** + +```bash +pnpm --filter @understand-anything/dashboard test edgeAggregation +``` + +Expected: 5 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add understand-anything-plugin/packages/dashboard/src/utils/edgeAggregation.ts \ + understand-anything-plugin/packages/dashboard/src/utils/__tests__/edgeAggregation.test.ts +git commit -m "feat(dashboard): aggregateContainerEdges (directional, types-set)" +``` + +--- + +## Task 5: ELK input repair + +**Files:** +- Create: `understand-anything-plugin/packages/dashboard/src/utils/elk-layout.ts` +- Create: `understand-anything-plugin/packages/dashboard/src/utils/__tests__/elk-layout.test.ts` + +- [ ] **Step 1: Write failing tests for repair functions** + +Create `understand-anything-plugin/packages/dashboard/src/utils/__tests__/elk-layout.test.ts`: + +```ts +import { describe, it, expect } from "vitest"; +import { repairElkInput, type ElkInput } from "../elk-layout"; + +describe("repairElkInput", () => { + it("ensures node dimensions when missing", () => { + const input: ElkInput = { + id: "root", + children: [{ id: "a" }, { id: "b", width: 100, height: 50 }] as ElkInput["children"], + edges: [], + }; + const { input: out, issues } = repairElkInput(input); + expect(out.children![0].width).toBeGreaterThan(0); + expect(out.children![0].height).toBeGreaterThan(0); + expect(out.children![1]).toEqual({ id: "b", width: 100, height: 50 }); + expect(issues.some((i) => i.level === "auto-corrected" && /dimensions/.test(i.message))).toBe(true); + }); + + it("dedupes duplicate child ids and reports auto-corrected", () => { + const input: ElkInput = { + id: "root", + children: [ + { id: "a", width: 1, height: 1 }, + { id: "a", width: 1, height: 1 }, + ], + edges: [], + }; + const { input: out, issues } = repairElkInput(input); + expect(out.children).toHaveLength(1); + expect(issues.some((i) => i.level === "auto-corrected" && /duplicate/.test(i.message))).toBe(true); + }); + + it("drops orphan edges referencing nonexistent nodes", () => { + const input: ElkInput = { + id: "root", + children: [{ id: "a", width: 1, height: 1 }], + edges: [ + { id: "e1", sources: ["a"], targets: ["ghost"] }, + ], + }; + const { input: out, issues } = repairElkInput(input); + expect(out.edges).toHaveLength(0); + expect(issues.some((i) => i.level === "dropped" && /edge/.test(i.message))).toBe(true); + }); + + it("drops children referencing nonexistent parents", () => { + const input: ElkInput = { + id: "root", + children: [ + { + id: "p", + width: 100, + height: 100, + children: [{ id: "c1", width: 1, height: 1 }], + }, + { id: "orphan", width: 1, height: 1, parentId: "ghost" } as ElkInput["children"][0] & { parentId: string }, + ], + edges: [], + }; + const { input: out, issues } = repairElkInput(input); + expect(out.children!.find((c) => c.id === "orphan")).toBeUndefined(); + expect(issues.some((i) => i.level === "dropped" && /parent/.test(i.message))).toBe(true); + }); + + it("strict mode throws on any issue", () => { + const input: ElkInput = { + id: "root", + children: [{ id: "a" }] as ElkInput["children"], + edges: [], + }; + expect(() => repairElkInput(input, { strict: true })).toThrow(/dimensions/); + }); +}); +``` + +- [ ] **Step 2: Run tests, expect failure** + +```bash +pnpm --filter @understand-anything/dashboard test elk-layout +``` + +Expected: import error — `../elk-layout` not found. + +- [ ] **Step 3: Implement repairElkInput** + +Create `understand-anything-plugin/packages/dashboard/src/utils/elk-layout.ts`: + +```ts +import ELK from "elkjs/lib/elk.bundled.js"; +import type { GraphIssue } from "@understand-anything/core/schema"; + +export interface ElkChild { + id: string; + width?: number; + height?: number; + children?: ElkChild[]; + parentId?: string; +} + +export interface ElkEdge { + id: string; + sources: string[]; + targets: string[]; +} + +export interface ElkInput { + id: string; + children: ElkChild[]; + edges: ElkEdge[]; + layoutOptions?: Record; +} + +const DEFAULT_NODE_WIDTH = 280; +const DEFAULT_NODE_HEIGHT = 120; + +interface RepairOptions { + strict?: boolean; +} + +interface RepairResult { + input: ElkInput; + issues: GraphIssue[]; +} + +function makeIssue(level: GraphIssue["level"], message: string): GraphIssue { + return { level, message }; +} + +function maybeThrow(strict: boolean | undefined, issue: GraphIssue): void { + if (strict) throw new Error(`[ELK repair] ${issue.level}: ${issue.message}`); +} + +export function repairElkInput( + input: ElkInput, + opts: RepairOptions = {}, +): RepairResult { + const issues: GraphIssue[] = []; + const strict = opts.strict; + + // 1. ensureNodeDimensions + let dimsAdded = 0; + const fillDims = (children: ElkChild[]): ElkChild[] => + children.map((c) => { + const next: ElkChild = { ...c }; + if (next.width == null || next.height == null) { + next.width = next.width ?? DEFAULT_NODE_WIDTH; + next.height = next.height ?? DEFAULT_NODE_HEIGHT; + dimsAdded++; + } + if (next.children) next.children = fillDims(next.children); + return next; + }); + const childrenA = fillDims(input.children); + if (dimsAdded > 0) { + const issue = makeIssue( + "auto-corrected", + `Set default dimensions on ${dimsAdded} node(s) missing width/height.`, + ); + issues.push(issue); + maybeThrow(strict, issue); + } + + // 2. dedupeNodeIds (per parent) + let dupesRemoved = 0; + const dedupe = (children: ElkChild[]): ElkChild[] => { + const seen = new Set(); + const out: ElkChild[] = []; + for (const c of children) { + if (seen.has(c.id)) { + dupesRemoved++; + continue; + } + seen.add(c.id); + out.push({ + ...c, + children: c.children ? dedupe(c.children) : undefined, + }); + } + return out; + }; + const childrenB = dedupe(childrenA); + if (dupesRemoved > 0) { + const issue = makeIssue( + "auto-corrected", + `Removed ${dupesRemoved} duplicate child id(s).`, + ); + issues.push(issue); + maybeThrow(strict, issue); + } + + // 3. dropOrphanChildren — children whose parentId references nonexistent parent + // (Only top-level children are checked; ELK uses nesting, not parentId, but + // upstream code may use parentId hints.) + const allIds = new Set(); + const walk = (children: ElkChild[]) => { + for (const c of children) { + allIds.add(c.id); + if (c.children) walk(c.children); + } + }; + walk(childrenB); + let orphanChildren = 0; + const childrenC = childrenB.filter((c) => { + if (c.parentId && !allIds.has(c.parentId)) { + orphanChildren++; + return false; + } + return true; + }); + if (orphanChildren > 0) { + const issue = makeIssue( + "dropped", + `Dropped ${orphanChildren} child(ren) with missing parent reference.`, + ); + issues.push(issue); + maybeThrow(strict, issue); + } + + // 4. dropOrphanEdges + let orphanEdges = 0; + const edges = input.edges.filter((e) => { + const ok = e.sources.every((s) => allIds.has(s)) && + e.targets.every((t) => allIds.has(t)); + if (!ok) { + orphanEdges++; + return false; + } + return true; + }); + if (orphanEdges > 0) { + const issue = makeIssue( + "dropped", + `Dropped ${orphanEdges} edge(s) referencing nonexistent nodes.`, + ); + issues.push(issue); + maybeThrow(strict, issue); + } + + // 5. dropCircularContainment + // Build parent map by walking nesting + const parentOf = new Map(); + const fillParents = (children: ElkChild[], parent?: string) => { + for (const c of children) { + if (parent) parentOf.set(c.id, parent); + if (c.children) fillParents(c.children, c.id); + } + }; + fillParents(childrenC); + let cyclesRemoved = 0; + const isCyclic = (id: string): boolean => { + const seen = new Set(); + let cur = parentOf.get(id); + while (cur) { + if (cur === id || seen.has(cur)) return true; + seen.add(cur); + cur = parentOf.get(cur); + } + return false; + }; + const stripCycles = (children: ElkChild[]): ElkChild[] => + children + .filter((c) => { + if (isCyclic(c.id)) { + cyclesRemoved++; + return false; + } + return true; + }) + .map((c) => ({ + ...c, + children: c.children ? stripCycles(c.children) : undefined, + })); + const childrenD = stripCycles(childrenC); + if (cyclesRemoved > 0) { + const issue = makeIssue( + "dropped", + `Dropped ${cyclesRemoved} node(s) in containment cycles.`, + ); + issues.push(issue); + maybeThrow(strict, issue); + } + + return { + input: { ...input, children: childrenD, edges }, + issues, + }; +} + +const elk = new ELK(); + +export interface ElkLayoutOptions { + strict?: boolean; +} + +export interface ElkLayoutResult { + positioned: ElkInput; + issues: GraphIssue[]; +} + +export async function applyElkLayout( + input: ElkInput, + opts: ElkLayoutOptions = {}, +): Promise { + const { input: repaired, issues } = repairElkInput(input, opts); + try { + const positioned = (await elk.layout(repaired as never)) as ElkInput; + return { positioned, issues }; + } catch (err) { + const fatal: GraphIssue = { + level: "fatal", + message: + `ELK layout failed: ${err instanceof Error ? err.message : String(err)}. ` + + `This looks like a dashboard rendering bug — please file an issue with the copied error.`, + }; + if (opts.strict) throw err; + return { positioned: { ...repaired, children: [], edges: [] }, issues: [...issues, fatal] }; + } +} +``` + +- [ ] **Step 4: Run tests** + +```bash +pnpm --filter @understand-anything/dashboard test elk-layout +``` + +Expected: 5 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add understand-anything-plugin/packages/dashboard/src/utils/elk-layout.ts \ + understand-anything-plugin/packages/dashboard/src/utils/__tests__/elk-layout.test.ts +git commit -m "feat(dashboard): elk-layout repair pipeline + applyElkLayout" +``` + +--- + +## Task 6: applyElkLayout integration test + +**Files:** +- Modify: `understand-anything-plugin/packages/dashboard/src/utils/__tests__/elk-layout.test.ts` + +- [ ] **Step 1: Add integration test** + +Append to `elk-layout.test.ts`: + +```ts +import { applyElkLayout } from "../elk-layout"; + +describe("applyElkLayout", () => { + it("lays out a small graph and returns positions", async () => { + const result = await applyElkLayout({ + id: "root", + children: [ + { id: "a", width: 100, height: 50 }, + { id: "b", width: 100, height: 50 }, + ], + edges: [{ id: "e1", sources: ["a"], targets: ["b"] }], + layoutOptions: { algorithm: "layered", "elk.direction": "DOWN" }, + }); + expect(result.issues).toEqual([]); + expect(result.positioned.children).toHaveLength(2); + for (const c of result.positioned.children) { + expect(typeof (c as { x?: number }).x).toBe("number"); + expect(typeof (c as { y?: number }).y).toBe("number"); + } + }); + + it("returns fatal issue when ELK rejects (without throwing in non-strict)", async () => { + // Force ELK rejection by giving an invalid algorithm + const result = await applyElkLayout( + { + id: "root", + children: [{ id: "a", width: 1, height: 1 }], + edges: [], + layoutOptions: { algorithm: "this-algorithm-does-not-exist" }, + }, + { strict: false }, + ); + expect(result.issues.some((i) => i.level === "fatal")).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run tests** + +```bash +pnpm --filter @understand-anything/dashboard test elk-layout +``` + +Expected: 7 tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add understand-anything-plugin/packages/dashboard/src/utils/__tests__/elk-layout.test.ts +git commit -m "test(dashboard): applyElkLayout integration cases" +``` + +--- + +## Task 7: Store extensions + +**Files:** +- Modify: `understand-anything-plugin/packages/dashboard/src/store.ts` + +- [ ] **Step 1: Read current store** + +Open `understand-anything-plugin/packages/dashboard/src/store.ts` and locate the `DashboardStore` interface (around line 62) and the `create` initializer. + +- [ ] **Step 2: Add fields to interface** + +Inside `interface DashboardStore { ... }`, after the existing fields, add: + +```ts + // Container expand/collapse + lazy layout caches + expandedContainers: Set; + toggleContainer: (containerId: string) => void; + expandContainer: (containerId: string) => void; + collapseAllContainers: () => void; + + containerLayoutCache: Map< + string, + { + childPositions: Map; + actualSize: { width: number; height: number }; + } + >; + setContainerLayout: ( + containerId: string, + childPositions: Map, + actualSize: { width: number; height: number }, + ) => void; + clearContainerLayouts: () => void; + + containerSizeMemory: Map; +``` + +- [ ] **Step 3: Add initializer + methods** + +Inside the `create((set) => ({ ... }))` block, add: + +```ts + expandedContainers: new Set(), + toggleContainer: (containerId) => + set((state) => { + const next = new Set(state.expandedContainers); + if (next.has(containerId)) next.delete(containerId); + else next.add(containerId); + return { expandedContainers: next }; + }), + expandContainer: (containerId) => + set((state) => { + if (state.expandedContainers.has(containerId)) return {}; + const next = new Set(state.expandedContainers); + next.add(containerId); + return { expandedContainers: next }; + }), + collapseAllContainers: () => set({ expandedContainers: new Set() }), + + containerLayoutCache: new Map(), + setContainerLayout: (containerId, childPositions, actualSize) => + set((state) => { + const next = new Map(state.containerLayoutCache); + next.set(containerId, { childPositions, actualSize }); + const sizeNext = new Map(state.containerSizeMemory); + sizeNext.set(containerId, actualSize); + return { containerLayoutCache: next, containerSizeMemory: sizeNext }; + }), + clearContainerLayouts: () => + set({ containerLayoutCache: new Map(), expandedContainers: new Set() }), + + containerSizeMemory: new Map(), +``` + +- [ ] **Step 4: Hook clearing into existing graph-load action** + +Find `setGraph` (or whichever action loads a new graph). Inside its body, add a call to clear container caches when `graph.id` (or the graph object reference) changes. Example: at the end of the `setGraph` setter, append to the `set(...)` call: + +```ts + containerLayoutCache: new Map(), + expandedContainers: new Set(), + containerSizeMemory: new Map(), +``` + +(Keep `containerSizeMemory` resetting only on full graph reload — per spec, it persists across collapses but not across distinct graphs.) + +- [ ] **Step 5: Sanity-check by building** + +```bash +pnpm --filter @understand-anything/dashboard build +``` + +Expected: build succeeds. + +- [ ] **Step 6: Commit** + +```bash +git add understand-anything-plugin/packages/dashboard/src/store.ts +git commit -m "feat(dashboard): store fields for expanded containers + layout cache" +``` + +--- + +## Task 8: ContainerNode component + +**Files:** +- Create: `understand-anything-plugin/packages/dashboard/src/components/ContainerNode.tsx` + +- [ ] **Step 1: Create the component** + +Create `understand-anything-plugin/packages/dashboard/src/components/ContainerNode.tsx`: + +```tsx +import { memo } from "react"; +import type { NodeProps, Node } from "@xyflow/react"; +import { getLayerColor } from "./LayerLegend"; + +export interface ContainerNodeData extends Record { + containerId: string; + name: string; + childCount: number; + strategy: "folder" | "community"; + colorIndex: number; + isExpanded: boolean; + hasSearchHits: boolean; + searchHitCount?: number; + isDiffAffected: boolean; + isFocusedViaChild: boolean; + onToggle: (containerId: string) => void; +} + +export type ContainerFlowNode = Node; + +const ContainerNode = memo(({ data, width, height }: NodeProps) => { + const color = getLayerColor(data.colorIndex); + + const borderColor = data.isDiffAffected + ? "rgba(224,82,82,0.5)" + : data.isExpanded || data.isFocusedViaChild + ? "rgba(212,165,116,0.6)" + : "rgba(212,165,116,0.25)"; + const borderWidth = data.isExpanded || data.isFocusedViaChild ? 1.5 : 1; + + const labelDimmed = data.name === "~"; + const labelText = labelDimmed ? "(root)" : data.name; + + return ( +
{ + e.stopPropagation(); + data.onToggle(data.containerId); + }} + > +
+ + {data.isExpanded && } + {labelText} + {data.hasSearchHits && data.searchHitCount && data.searchHitCount > 0 && ( + + 🔍 {data.searchHitCount} + + )} + + {data.childCount} +
+
+ ); +}); + +export default ContainerNode; +``` + +- [ ] **Step 2: Build to verify it type-checks** + +```bash +pnpm --filter @understand-anything/dashboard build +``` + +Expected: build succeeds. + +- [ ] **Step 3: Commit** + +```bash +git add understand-anything-plugin/packages/dashboard/src/components/ContainerNode.tsx +git commit -m "feat(dashboard): ContainerNode component (visual + click toggle)" +``` + +--- + +## Task 9: Switch overview-level layout to ELK + +**Files:** +- Modify: `understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx` + +- [ ] **Step 1: Read current useOverviewGraph** + +Open `GraphView.tsx`. Locate `useOverviewGraph` (starts ~line 125). Identify the `applyDagreLayout(clusterNodes ..., flowEdges, "TB", dims)` call (line ~202). + +- [ ] **Step 2: Convert to async ELK** + +Replace the sync layout block. The current shape returns `{ nodes, edges }` from `useMemo`. Change to use `useState` + `useEffect` so the async ELK call sets state. Example replacement (keep existing code building `clusterNodes` and `flowEdges` and `dims`; only change the layout call and surrounding hooks): + +```tsx + const [overview, setOverview] = useState<{ nodes: Node[]; edges: Edge[] }>({ + nodes: [], + edges: [], + }); + + useEffect(() => { + if (!graph) { + setOverview({ nodes: [], edges: [] }); + return; + } + let cancelled = false; + const elkInput = clusterNodesToElkInput(clusterNodes, flowEdges, dims); + applyElkLayout(elkInput, { strict: import.meta.env.DEV }).then(({ positioned }) => { + if (cancelled) return; + const positionedNodes = mergeElkPositions(clusterNodes as unknown as Node[], positioned); + setOverview({ nodes: positionedNodes, edges: flowEdges }); + }); + return () => { + cancelled = true; + }; + }, [graph, searchResults, drillIntoLayer]); + + return overview; +``` + +- [ ] **Step 3: Add helpers `clusterNodesToElkInput` and `mergeElkPositions`** + +Append to the bottom of `utils/layout.ts`: + +```ts +import type { ElkInput } from "./elk-layout"; + +const ELK_DEFAULT_LAYOUT_OPTIONS: Record = { + algorithm: "layered", + "elk.direction": "DOWN", + "elk.layered.spacing.nodeNodeBetweenLayers": "80", + "elk.spacing.nodeNode": "60", + "elk.layered.crossingMinimization.strategy": "LAYER_SWEEP", + "elk.edgeRouting": "ORTHOGONAL", + "elk.layered.compaction.postCompaction.strategy": "LEFT", + "elk.padding": "[top=40,left=20,right=20,bottom=20]", +}; + +export function nodesToElkInput( + nodes: Node[], + edges: Edge[], + dims: Map, +): ElkInput { + return { + id: "root", + layoutOptions: ELK_DEFAULT_LAYOUT_OPTIONS, + children: nodes.map((n) => { + const d = dims.get(n.id); + return { + id: n.id, + width: d?.width ?? NODE_WIDTH, + height: d?.height ?? NODE_HEIGHT, + }; + }), + edges: edges.map((e, i) => ({ + id: e.id ?? `e${i}`, + sources: [String(e.source)], + targets: [String(e.target)], + })), + }; +} + +export function mergeElkPositions( + nodes: T[], + positioned: ElkInput, +): T[] { + const posMap = new Map(); + for (const c of positioned.children ?? []) { + const cx = (c as ElkInput["children"][number] & { x?: number; y?: number }).x ?? 0; + const cy = (c as ElkInput["children"][number] & { x?: number; y?: number }).y ?? 0; + posMap.set(c.id, { x: cx, y: cy }); + } + return nodes.map((n) => ({ + ...n, + position: posMap.get(n.id) ?? n.position ?? { x: 0, y: 0 }, + })); +} +``` + +In `GraphView.tsx` rename the helper call from `clusterNodesToElkInput` (used in step 2) to `nodesToElkInput`. Update the import: + +```tsx +import { applyDagreLayout, nodesToElkInput, mergeElkPositions, NODE_WIDTH, NODE_HEIGHT, LAYER_CLUSTER_WIDTH, LAYER_CLUSTER_HEIGHT, PORTAL_NODE_WIDTH, PORTAL_NODE_HEIGHT } from "../utils/layout"; +import { applyElkLayout } from "../utils/elk-layout"; +``` + +- [ ] **Step 4: Manually verify overview** + +```bash +pnpm dev:dashboard +``` + +In the browser, open the project overview view (the layer-cluster level). Expected: layer clusters laid out top-to-bottom (DOWN direction matches dagre TB), edges visible, no console errors. + +- [ ] **Step 5: Commit** + +```bash +git add understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx \ + understand-anything-plugin/packages/dashboard/src/utils/layout.ts +git commit -m "feat(dashboard): overview view uses ELK" +``` + +--- + +## Task 10: Switch DomainGraphView to ELK + +**Files:** +- Modify: `understand-anything-plugin/packages/dashboard/src/components/DomainGraphView.tsx` + +- [ ] **Step 1: Read current DomainGraphView** + +Open the file and find every `applyDagreLayout(...)` call. + +- [ ] **Step 2: Replace with ELK in the same async pattern** + +For each `applyDagreLayout` site, swap to: + +```tsx +import { applyElkLayout } from "../utils/elk-layout"; +import { nodesToElkInput, mergeElkPositions } from "../utils/layout"; + +// ... inside the component: +const [layout, setLayout] = useState<{ nodes: Node[]; edges: Edge[] }>({ nodes: [], edges: [] }); + +useEffect(() => { + let cancelled = false; + const elkInput = nodesToElkInput(domainNodes, domainEdges, dims); + applyElkLayout(elkInput, { strict: import.meta.env.DEV }).then(({ positioned }) => { + if (cancelled) return; + setLayout({ + nodes: mergeElkPositions(domainNodes, positioned), + edges: domainEdges, + }); + }); + return () => { cancelled = true; }; +}, [/* same deps as the previous useMemo */]); +``` + +Match the deps array exactly to whatever the original `useMemo` used. + +- [ ] **Step 3: Build + manual smoke** + +```bash +pnpm --filter @understand-anything/dashboard build +pnpm dev:dashboard +``` + +Open a graph with domain data and switch to the Domain view. Expected: nodes render, edges visible, no console errors. + +- [ ] **Step 4: Commit** + +```bash +git add understand-anything-plugin/packages/dashboard/src/components/DomainGraphView.tsx +git commit -m "feat(dashboard): DomainGraphView uses ELK" +``` + +--- + +## Task 11: Layer-detail Stage 1 + +**Files:** +- Modify: `understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx` + +This task replaces dagre with ELK in `useLayerDetailTopology` AND introduces `deriveContainers` + edge aggregation, but renders containers as opaque nodes only (no children visible). Stage 2 expansion is wired in Task 12. + +- [ ] **Step 1: Register the ContainerNode type** + +Near the top of `GraphView.tsx`, find the `nodeTypes` registration and add `container`: + +```tsx +import ContainerNode from "./ContainerNode"; +// ... +const nodeTypes = { + custom: CustomNode, + "layer-cluster": LayerClusterNode, + portal: PortalNode, + container: ContainerNode, +}; +``` + +- [ ] **Step 2: Inside `useLayerDetailTopology`, add container derivation** + +After the existing filtering logic (`filteredGraphNodes`, `filteredGraphEdges`) but before flow node construction, add: + +```tsx +import { deriveContainers } from "../utils/containers"; +import { aggregateContainerEdges } from "../utils/edgeAggregation"; +import { nodesToElkInput, mergeElkPositions } from "../utils/layout"; +import { applyElkLayout } from "../utils/elk-layout"; + +// Inside useLayerDetailTopology, after filteredGraphEdges: +const { containers, ungrouped } = deriveContainers(filteredGraphNodes, filteredGraphEdges); +const nodeToContainer = new Map(); +for (const c of containers) { + for (const id of c.nodeIds) nodeToContainer.set(id, c.id); +} +const { intraContainer, interContainerAggregated } = + aggregateContainerEdges(filteredGraphEdges, nodeToContainer); +``` + +- [ ] **Step 3: Build Stage 1 ELK input from containers + ungrouped** + +Continue inside `useLayerDetailTopology`: + +```tsx +const colorIndexFor = (containerId: string) => { + const idx = containers.findIndex((c) => c.id === containerId); + return idx === -1 ? 0 : idx % 12; +}; +const sizeMemory = useDashboardStore.getState().containerSizeMemory; + +const stage1Children: ElkChild[] = [ + ...containers.map((c) => { + const memorySize = sizeMemory.get(c.id); + return { + id: c.id, + width: + memorySize?.width ?? Math.sqrt(c.nodeIds.length) * NODE_WIDTH * 1.2, + height: + memorySize?.height ?? Math.sqrt(c.nodeIds.length) * NODE_HEIGHT * 1.2, + }; + }), + ...ungrouped.map((id) => ({ id, width: NODE_WIDTH, height: NODE_HEIGHT })), +]; + +const stage1Edges: ElkEdge[] = interContainerAggregated.map((agg, i) => ({ + id: `agg-${i}`, + sources: [agg.sourceContainerId], + targets: [agg.targetContainerId], +})); +``` + +(`ElkChild` and `ElkEdge` types come from `../utils/elk-layout` — add to imports.) + +- [ ] **Step 4: Replace `useMemo` with `useState + useEffect`** + +Convert `useLayerDetailTopology` from returning a memoized value to managing state with effects, mirroring Task 9. Keep all the existing visual prep (`flowNodes`, `flowEdges`, `portalNodes`, `portalEdges`) but feed Stage 1 layout into a state setter: + +```tsx +const [topology, setTopology] = useState<{ + nodes: Node[]; + edges: Edge[]; + portalNodes: PortalFlowNode[]; + portalEdges: Edge[]; + filteredEdges: KnowledgeGraph["edges"]; +}>({ nodes: [], edges: [], portalNodes: [], portalEdges: [], filteredEdges: [] }); + +useEffect(() => { + if (!graph || !activeLayerId) { + setTopology({ nodes: [], edges: [], portalNodes: [], portalEdges: [], filteredEdges: [] }); + return; + } + let cancelled = false; + applyElkLayout( + { + id: "stage1", + layoutOptions: { /* default options copied from nodesToElkInput */ }, + children: stage1Children, + edges: stage1Edges, + }, + { strict: import.meta.env.DEV }, + ).then(({ positioned }) => { + if (cancelled) return; + + // Build container flow nodes + const containerFlowNodes = containers.map((c) => { + const elkChild = positioned.children?.find((ch) => ch.id === c.id); + return { + id: c.id, + type: "container" as const, + position: { + x: (elkChild as { x?: number })?.x ?? 0, + y: (elkChild as { y?: number })?.y ?? 0, + }, + data: { + containerId: c.id, + name: c.name, + childCount: c.nodeIds.length, + strategy: c.strategy, + colorIndex: colorIndexFor(c.id), + isExpanded: false, + hasSearchHits: false, + searchHitCount: 0, + isDiffAffected: false, + isFocusedViaChild: false, + onToggle: (id: string) => useDashboardStore.getState().toggleContainer(id), + }, + width: + (elkChild as { width?: number })?.width ?? NODE_WIDTH, + height: + (elkChild as { height?: number })?.height ?? NODE_HEIGHT, + }; + }); + + // Ungrouped (top-level files outside any container) — keep existing flow node logic for these + const ungroupedFlowNodes = ungrouped + .map((id) => filteredGraphNodes.find((n) => n.id === id)) + .filter((n): n is GraphNode => n != null) + .map((node) => buildCustomFlowNode(node, /* helpers */)); + // (positioned.children also has positions for ungrouped ids) + for (const ufn of ungroupedFlowNodes) { + const ec = positioned.children?.find((c) => c.id === ufn.id); + ufn.position = { + x: (ec as { x?: number })?.x ?? 0, + y: (ec as { y?: number })?.y ?? 0, + }; + } + + // Stage 1 edges = aggregated inter-container edges + const aggEdges: Edge[] = interContainerAggregated.map((agg, i) => ({ + id: `agg-${i}`, + source: agg.sourceContainerId, + target: agg.targetContainerId, + label: String(agg.count), + style: { + stroke: "rgba(212,165,116,0.4)", + strokeWidth: Math.min(1 + Math.log2(agg.count + 1), 5), + }, + labelStyle: { fill: "#a39787", fontSize: 11 }, + })); + + setTopology({ + nodes: [...containerFlowNodes, ...ungroupedFlowNodes] as unknown as Node[], + edges: aggEdges, + portalNodes, + portalEdges, + filteredEdges: filteredGraphEdges, + }); + }); + return () => { cancelled = true; }; +}, [graph, activeLayerId, persona, diffMode, changedNodeIds, affectedNodeIds, focusNodeId, nodeTypeFilters, drillIntoLayer]); + +return topology; +``` + +The helper `buildCustomFlowNode` is the existing inline logic that converts a `GraphNode` into the existing `CustomFlowNode` shape — extract it from the current code (the same data fields populated for `flowNodes`). + +- [ ] **Step 5: Manual smoke** + +```bash +pnpm dev:dashboard +``` + +Drill into a layer in microservices-demo (or any sample). Expected: containers visible as gold-bordered boxes, with name + count, no children rendered yet, aggregated edges between containers. + +- [ ] **Step 6: Commit** + +```bash +git add understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx +git commit -m "feat(dashboard): layer-detail Stage 1 — containers + ELK" +``` + +--- + +## Task 12: Layer-detail Stage 2 + edge expansion + +**Files:** +- Modify: `understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx` + +- [ ] **Step 1: Add Stage 2 effect** + +After the Stage 1 effect from Task 11, add another effect that watches `expandedContainers` and runs Stage 2 ELK for any newly expanded container without a cache entry: + +```tsx +const expandedContainers = useDashboardStore((s) => s.expandedContainers); +const containerLayoutCache = useDashboardStore((s) => s.containerLayoutCache); +const setContainerLayout = useDashboardStore((s) => s.setContainerLayout); + +useEffect(() => { + let cancelled = false; + const toCompute = [...expandedContainers].filter( + (id) => !containerLayoutCache.has(id), + ); + if (toCompute.length === 0) return; + + Promise.all( + toCompute.map(async (containerId) => { + const c = containers.find((c) => c.id === containerId); + if (!c) return null; + const childIds = new Set(c.nodeIds); + const childEdges = intraContainer.filter( + (e) => childIds.has(e.source) && childIds.has(e.target), + ); + const stage2Input = { + id: containerId, + layoutOptions: { /* default opts */ }, + children: c.nodeIds.map((id) => { + const dims = filteredGraphNodes.find((n) => n.id === id); + return { id, width: NODE_WIDTH, height: NODE_HEIGHT }; + }), + edges: childEdges.map((e, i) => ({ + id: `${containerId}-e${i}`, + sources: [e.source], + targets: [e.target], + })), + }; + const { positioned } = await applyElkLayout(stage2Input, { strict: import.meta.env.DEV }); + const childPositions = new Map(); + let maxX = 0, maxY = 0; + for (const ch of positioned.children ?? []) { + const x = (ch as { x?: number }).x ?? 0; + const y = (ch as { y?: number }).y ?? 0; + const w = (ch as { width?: number }).width ?? NODE_WIDTH; + const h = (ch as { height?: number }).height ?? NODE_HEIGHT; + childPositions.set(ch.id, { x, y }); + if (x + w > maxX) maxX = x + w; + if (y + h > maxY) maxY = y + h; + } + const actualSize = { width: maxX + 40, height: maxY + 60 }; + return { containerId, childPositions, actualSize }; + }), + ).then((results) => { + if (cancelled) return; + for (const r of results) { + if (!r) continue; + setContainerLayout(r.containerId, r.childPositions, r.actualSize); + } + }); + + return () => { cancelled = true; }; +}, [expandedContainers, containers, intraContainer, filteredGraphNodes, containerLayoutCache, setContainerLayout]); +``` + +- [ ] **Step 2: Render expanded children + replace edges in visual overlay** + +In the visual-overlay function (`useLayerDetailGraph`), after retrieving `topo`, fold in the expanded state: + +```tsx +// Build child flow nodes for each expanded container with cached layout +const expandedChildNodes: Node[] = []; +for (const containerId of expandedContainers) { + const cache = containerLayoutCache.get(containerId); + const container = topo.containers?.find((c) => c.id === containerId); + if (!cache || !container) continue; + for (const childId of container.nodeIds) { + const node = filteredGraphNodes.find((n) => n.id === childId); + const pos = cache.childPositions.get(childId); + if (!node || !pos) continue; + expandedChildNodes.push({ + ...buildCustomFlowNode(node, /* helpers */), + parentId: containerId, + extent: "parent", + position: pos, + } as Node); + } +} + +// Replace aggregated edges where the container is expanded +const expandedEdges: Edge[] = []; +for (const e of topo.edges) { + const srcExpanded = expandedContainers.has(String(e.source)); + const tgtExpanded = expandedContainers.has(String(e.target)); + if (!srcExpanded && !tgtExpanded) { + expandedEdges.push(e); + } else { + // Replace this aggregated edge with the underlying file→file edges + // that match its source/target containers + const matching = filteredGraphEdges.filter((fe) => { + const fsc = nodeToContainer.get(fe.source); + const ftc = nodeToContainer.get(fe.target); + return fsc === e.source && ftc === e.target; + }); + for (const m of matching) { + expandedEdges.push({ + id: `inflated-${m.source}-${m.target}`, + source: m.source, + target: m.target, + label: m.type, + style: { stroke: "rgba(212,165,116,0.5)", strokeWidth: 1.5 }, + labelStyle: { fill: "#a39787", fontSize: 10 }, + }); + } + } +} +``` + +Then return `{ nodes: [...topo.nodes, ...expandedChildNodes], edges: expandedEdges, ... }`. + +(Update Stage 1 effect to also expose `containers` and `nodeToContainer` in the topology return so they're reachable here.) + +- [ ] **Step 3: Manual smoke** + +```bash +pnpm dev:dashboard +``` + +Drill into a layer. Click a container. Expected: container shows children laid out inside; aggregated edges from/to that container replaced by individual file→file edges. Click again to collapse. + +- [ ] **Step 4: Commit** + +```bash +git add understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx +git commit -m "feat(dashboard): layer-detail Stage 2 — lazy children layout + edge expansion" +``` + +--- + +## Task 13: Auto-expand triggers (zoom + search/focus/tour) + +**Files:** +- Modify: `understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx` + +- [ ] **Step 1: Zoom-driven auto-expand inside the ReactFlow component** + +Inside the inner component that mounts `` (where you have access to React Flow viewport via `useReactFlow` or the `onMove` prop), add a debounced viewport listener: + +```tsx +import { useReactFlow } from "@xyflow/react"; + +function ZoomAutoExpand({ containers }: { containers: Array<{ id: string }> }) { + const { getViewport, getNodes } = useReactFlow(); + const expandContainer = useDashboardStore((s) => s.expandContainer); + const expandedContainers = useDashboardStore((s) => s.expandedContainers); + const collapseAllContainers = useDashboardStore((s) => s.collapseAllContainers); + const timeoutRef = useRef(null); + + const onMove = useCallback(() => { + if (timeoutRef.current) window.clearTimeout(timeoutRef.current); + timeoutRef.current = window.setTimeout(() => { + const vp = getViewport(); + if (vp.zoom > 1.0) { + // Auto-expand visible containers + const nodes = getNodes(); + for (const c of containers) { + if (expandedContainers.has(c.id)) continue; + const n = nodes.find((nn) => nn.id === c.id); + if (!n) continue; + // Crude visibility check: node origin in negative-window coordinates + // (refined check could project the bounding box, but this is enough + // to pre-warm common cases) + expandContainer(c.id); + } + } else if (vp.zoom < 0.6) { + // Hysteresis: only auto-collapse below 0.6 + // Don't collapse explicit user-clicked containers — for now treat all + // as one set; user can re-expand + // (simple approach: do nothing on auto-collapse; user collapses manually) + } + }, 200); + }, [getViewport, getNodes, containers, expandContainer, expandedContainers, collapseAllContainers]); + + return <>; // attach via ReactFlow's onMove prop instead +} +``` + +Then wire `onMove={onMoveDebounced}` on the `` element. The component above is illustrative — you can inline `onMove` directly in the layer-detail render path for simplicity. + +- [ ] **Step 2: Search/focus/tour auto-expand** + +Add to the layer-detail component: + +```tsx +const searchResults = useDashboardStore((s) => s.searchResults); +const focusNodeId = useDashboardStore((s) => s.focusNodeId); +const tourHighlightedNodeIds = useDashboardStore((s) => s.tourHighlightedNodeIds); +const expandContainer = useDashboardStore((s) => s.expandContainer); + +// Focus mode: expand container of focused node +useEffect(() => { + if (!focusNodeId) return; + const containerId = nodeToContainer.get(focusNodeId); + if (containerId) expandContainer(containerId); +}, [focusNodeId, nodeToContainer, expandContainer]); + +// Tour: expand containers of tour-highlighted nodes +useEffect(() => { + for (const nid of tourHighlightedNodeIds) { + const cid = nodeToContainer.get(nid); + if (cid) expandContainer(cid); + } +}, [tourHighlightedNodeIds, nodeToContainer, expandContainer]); + +// Search: do NOT auto-expand. Surface a hasSearchHits flag on the container. +// (this is consumed in Task 14's visual overlay update) +``` + +- [ ] **Step 3: Manual smoke** + +```bash +pnpm dev:dashboard +``` + +- Drill into layer. Zoom in past ~1.0. Expected: visible containers expand within ~200ms. +- Use search to find a node inside a container. Expected: container shows search badge but does **not** auto-expand. Click the badge → container expands and `fitView`s. +- Use focus mode on a child file. Expected: its container expands, neighbors fade. + +- [ ] **Step 4: Commit** + +```bash +git add understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx +git commit -m "feat(dashboard): auto-expand on zoom, focus, and tour" +``` + +--- + +## Task 14: Container visual overlays — search hit, diff, focused-via-child + +**Files:** +- Modify: `understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx` + +- [ ] **Step 1: Compute container overlay data in `useLayerDetailGraph`** + +Inside the visual overlay pass, compute per-container flags and update the container nodes' `data` accordingly: + +```tsx +const searchByContainer = new Map(); +for (const r of searchResults) { + const cid = nodeToContainer.get(r.nodeId); + if (!cid) continue; + searchByContainer.set(cid, (searchByContainer.get(cid) ?? 0) + 1); +} + +const diffByContainer = new Set(); +if (diffMode) { + for (const id of [...changedNodeIds, ...affectedNodeIds]) { + const cid = nodeToContainer.get(id); + if (cid) diffByContainer.add(cid); + } +} + +const focusContainer = focusNodeId ? nodeToContainer.get(focusNodeId) : null; + +const visualNodes = topo.nodes.map((n) => { + if (n.type !== "container") return n; + const cid = String(n.id); + return { + ...n, + data: { + ...(n.data as ContainerNodeData), + isExpanded: expandedContainers.has(cid), + hasSearchHits: searchByContainer.has(cid), + searchHitCount: searchByContainer.get(cid) ?? 0, + isDiffAffected: diffByContainer.has(cid), + isFocusedViaChild: focusContainer === cid, + }, + }; +}); +``` + +- [ ] **Step 2: Manual smoke** + +```bash +pnpm dev:dashboard +``` + +- Search "login" in a layer with `auth/` files. Expected: auth container shows `🔍 N` badge. +- Open a diff view. Expected: containers with changed files have red borders. +- Use focus mode. Expected: container with focused child has gold border + chevron. + +- [ ] **Step 3: Commit** + +```bash +git add understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx +git commit -m "feat(dashboard): container overlays — search/diff/focus" +``` + +--- + +## Task 15: Stage 2 size-deviation re-layout + +**Files:** +- Modify: `understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx` + +- [ ] **Step 1: Detect deviation after Stage 2** + +In Task 12's Stage 2 effect, after `setContainerLayout` is called, compare `actualSize` against the Stage 1 estimate that was used. If the deviation exceeds 20% in width or height, schedule a Stage 1 re-run by invalidating its dependency. + +The simplest mechanism: bump a `stage1Tick` counter in the store on deviation. Add to the store: + +```ts +stage1Tick: number; +bumpStage1Tick: () => void; +``` + +Initializer: + +```ts +stage1Tick: 0, +bumpStage1Tick: () => set((s) => ({ stage1Tick: s.stage1Tick + 1 })), +``` + +In Stage 2 effect, after caching: + +```tsx +const stage1Estimate = stage1Children.find((sc) => sc.id === r.containerId); +if (stage1Estimate) { + const dw = Math.abs(r.actualSize.width - (stage1Estimate.width ?? 1)) / (stage1Estimate.width ?? 1); + const dh = Math.abs(r.actualSize.height - (stage1Estimate.height ?? 1)) / (stage1Estimate.height ?? 1); + if (dw > 0.2 || dh > 0.2) { + useDashboardStore.getState().bumpStage1Tick(); + } +} +``` + +Add `stage1Tick` to the Stage 1 effect's dependency array. + +- [ ] **Step 2: Manual smoke** + +```bash +pnpm dev:dashboard +``` + +Drill into a layer with a folder that has many files. Click to expand. The container should grow to its actual size and the surrounding layout should reflow once. + +- [ ] **Step 3: Commit** + +```bash +git add understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx \ + understand-anything-plugin/packages/dashboard/src/store.ts +git commit -m "feat(dashboard): Stage 2 size-deviation triggers Stage 1 re-layout" +``` + +--- + +## Task 16: WarningBanner copy for layout issues + Computing layout overlay + +**Files:** +- Modify: `understand-anything-plugin/packages/dashboard/src/components/WarningBanner.tsx` +- Modify: `understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx` +- Modify: `understand-anything-plugin/packages/dashboard/src/store.ts` + +- [ ] **Step 1: Add layout-issue funnel to store** + +Add to store: + +```ts +layoutIssues: GraphIssue[]; +appendLayoutIssues: (issues: GraphIssue[]) => void; +clearLayoutIssues: () => void; +``` + +Initializer + setters with merge semantics (dedupe by `level + message`). + +- [ ] **Step 2: Funnel ELK issues into store after each layout call** + +After every `applyElkLayout(...)` resolution in `GraphView.tsx` and `DomainGraphView.tsx`, append `issues` to the store: + +```tsx +const { positioned, issues } = await applyElkLayout(input, { strict: import.meta.env.DEV }); +if (issues.length > 0) useDashboardStore.getState().appendLayoutIssues(issues); +``` + +- [ ] **Step 3: Display banner across all sources** + +In `App.tsx`, where `` is rendered, replace with a merged source: + +```tsx +const layoutIssues = useDashboardStore((s) => s.layoutIssues); + +``` + +- [ ] **Step 4: Update copy text in WarningBanner** + +Edit `buildCopyText` in `WarningBanner.tsx`. Replace the hardcoded preamble with conditional copy based on whether there are any `fatal` issues: + +```ts +const hasFatal = issues.some((i) => i.level === "fatal"); +const lines = hasFatal + ? [ + "Some of these issues look like dashboard rendering bugs.", + "Please file an issue at github.com/Lum1104/Understand-Anything/issues with the text below.", + "", + ] + : [ + "The following issues were found in your knowledge-graph.json.", + "These are LLM generation errors — not a system bug.", + "You can ask your agent to fix these specific issues in the knowledge-graph.json file:", + "", + ]; +``` + +- [ ] **Step 5: "Computing layout…" overlay** + +In `GraphView.tsx`, track a `layoutStatus` state (`"computing" | "ready"`) per view. While computing, render an absolute-positioned overlay over the React Flow surface: + +```tsx +{layoutStatus === "computing" && ( +
+ Computing layout… +
+)} +``` + +Set `layoutStatus` to `"computing"` before `applyElkLayout(...)` and `"ready"` after. + +- [ ] **Step 6: Manual smoke** + +```bash +pnpm dev:dashboard +``` + +- Open a graph. Expected: brief "Computing layout…" overlay during initial layout. +- Manually corrupt a graph (e.g., introduce an edge to a nonexistent node) → expected: WarningBanner shows the dropped-edge issue with the graph-data copy text. +- Force a fatal (set an invalid ELK option in dev tools) → expected: WarningBanner shows fatal with the rendering-bug copy text. + +- [ ] **Step 7: Commit** + +```bash +git add understand-anything-plugin/packages/dashboard/src/components/WarningBanner.tsx \ + understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx \ + understand-anything-plugin/packages/dashboard/src/components/DomainGraphView.tsx \ + understand-anything-plugin/packages/dashboard/src/store.ts \ + understand-anything-plugin/packages/dashboard/src/App.tsx +git commit -m "feat(dashboard): layout-issue banner + Computing layout overlay" +``` + +--- + +## Task 17: Performance benchmark + +**Files:** +- Create: `understand-anything-plugin/packages/dashboard/scripts/benchmark-layout.mjs` + +- [ ] **Step 1: Write benchmark** + +Create `understand-anything-plugin/packages/dashboard/scripts/benchmark-layout.mjs`: + +```js +import { performance } from "node:perf_hooks"; +import { applyElkLayout } from "../dist/utils/elk-layout.js"; + +function makeGraph(nodeCount, containerCount = Math.min(20, Math.ceil(nodeCount / 25))) { + const containers = Array.from({ length: containerCount }, (_, i) => ({ + id: `c${i}`, + width: 400, + height: 300, + })); + const edges = []; + for (let i = 0; i < containerCount; i++) { + for (let j = i + 1; j < containerCount; j++) { + if (Math.random() < 0.3) { + edges.push({ id: `e-${i}-${j}`, sources: [`c${i}`], targets: [`c${j}`] }); + } + } + } + return { id: "root", children: containers, edges }; +} + +async function bench(label, n) { + const input = makeGraph(n); + const t0 = performance.now(); + await applyElkLayout(input); + const t1 = performance.now(); + console.log(`${label} (${n} nodes): ${(t1 - t0).toFixed(1)}ms`); +} + +await bench("Stage1", 500); +await bench("Stage1", 1000); +await bench("Stage1", 3000); +``` + +- [ ] **Step 2: Build dashboard so the script can import dist** + +```bash +pnpm --filter @understand-anything/dashboard build +``` + +- [ ] **Step 3: Run benchmark** + +```bash +node understand-anything-plugin/packages/dashboard/scripts/benchmark-layout.mjs +``` + +Expected: prints three lines. Verify Stage 1 < 200ms at 500 nodes, < 500ms at 3000 nodes per spec §8.3. + +If a budget is missed, **investigate** — don't lower the budget. Likely culprits: container size estimation creating overlapping initial positions, ELK options misconfigured, or main-thread blocking that should move to a worker. + +- [ ] **Step 4: Commit** + +```bash +git add understand-anything-plugin/packages/dashboard/scripts/benchmark-layout.mjs +git commit -m "test(dashboard): layout perf benchmark script" +``` + +--- + +## Self-Review + +**Spec coverage check** (compared against `docs/superpowers/specs/2026-05-03-graph-layout-scaling-design.md`): + +- §1 Architecture — Tasks 9 (overview), 10 (domain), 11 (Stage 1 layer-detail), 12 (Stage 2) +- §2 Container derivation — Tasks 2 (folder + edge cases) + 3 (community fallback) +- §3 ELK integration — Tasks 5 (repair), 6 (applyElkLayout integration), 9–12 (per-view wiring) +- §3.5 Loading + failure handling — Task 16 +- §3.6 GraphIssue model — Task 5 (repair functions emit issues), Task 16 (banner) +- §3.7 Dev strict mode — Task 5 (`strict` flag in repair + apply) +- §4 Edge aggregation + expand/collapse — Tasks 4 (aggregator), 7 (store), 12 (visual) +- §5 ContainerNode visual — Task 8, refined in Task 14 +- §6 Lazy two-stage — Tasks 11 (Stage 1), 12 (Stage 2), 13 (auto-expand triggers), 15 (size deviation) +- §7 Interaction matrix — Tasks 13 (focus/tour/zoom auto-expand), 14 (search/diff/focus container overlays) +- §8 Files + tests — Each task creates the test file alongside the impl; perf benchmark in Task 17 + +**Placeholder scan:** No `TBD`/`TODO` left. Function signatures, options, and thresholds are concrete. Tests have full code, not "similar to above." + +**Type consistency:** `ElkInput` / `ElkChild` / `ElkEdge` defined once in `elk-layout.ts` (Task 5), used unchanged everywhere. Container shapes (`DerivedContainer`, `ContainerNodeData`) are defined in their canonical files (Tasks 2, 8) and not redefined later. Method names (`expandContainer`, `toggleContainer`, `collapseAllContainers`, `setContainerLayout`, `bumpStage1Tick`) are stable across Tasks 7 → 13 → 15. + +**Out of plan (deferred):** Removing `applyDagreLayout` from `utils/layout.ts` is deferred to a follow-up after this plan ships and is verified stable, per spec Migration Notes. + +--- + +## Execution Handoff + +Plan complete and saved to `docs/superpowers/plans/2026-05-03-graph-layout-scaling.md`. Two execution options: + +1. **Subagent-Driven (recommended)** — Dispatch a fresh subagent per task, review between tasks, fast iteration. +2. **Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints. + +Which approach? diff --git a/docs/superpowers/specs/2026-05-03-graph-layout-scaling-design.md b/docs/superpowers/specs/2026-05-03-graph-layout-scaling-design.md new file mode 100644 index 0000000..a949b50 --- /dev/null +++ b/docs/superpowers/specs/2026-05-03-graph-layout-scaling-design.md @@ -0,0 +1,488 @@ +# Dashboard Graph Layout Scaling — Design + +## Problem + +When a structural-graph layer contains many nodes, the current `applyDagreLayout` (TB direction) places same-rank nodes in a single horizontal row. With 50+ nodes per rank, the row stretches into thousands of pixels and the view becomes unreadable: nodes shrink, labels disappear, edges tangle, and there are no visual anchors to orient the reader. + +This design replaces dagre with ELK across all structural-style views, introduces folder/community-based **containers** for the layer-detail view, and computes layout in **two lazy stages** — a single-pass over containers, then per-container child layout on demand. + +The graph schema and pipeline output (`graph.json`) are unchanged. All improvements derive from existing data. + +## Goals + +- Eliminate horizontal sprawl in layer-detail views at ≤100 nodes per layer (current target), and remain workable up to 1000+ nodes (future scaling). +- Give each layer-detail view explicit visual anchors so structure is readable at a glance. +- Aggregate cross-cluster edges by default; surface individual edges on demand. +- Keep visual style continuous with the existing layer-cluster (overview-level) presentation. +- Treat layout failures with the same `GraphIssue` model already used for schema validation. + +## Non-Goals + +- No regeneration of `graph.json`. All grouping is derived client-side. +- No change to KnowledgeGraphView (already force-directed; out of scope). +- No multi-level container nesting (single depth only in v1). +- No remote error reporting (Sentry-style) — open-source plugin, no default telemetry. +- No persona-specific grouping behavior beyond the existing node-type filter. + +## Scope + +Three views are affected: + +| View | Change | +|---|---| +| Overview (layer clusters) | Replace dagre → ELK. No new grouping (layers are already groups). | +| DomainGraphView | Replace dagre → ELK with domain-as-parent of flow/step. | +| Layer-detail | Replace dagre → ELK + new folder/community containers + edge aggregation + lazy two-stage layout. | + +KnowledgeGraphView remains on `applyForceLayout` and is not touched. + +--- + +## §1. Architecture + +``` +existing graph (immutable) + │ + ▼ +deriveContainers(nodes, edges) // §2 — folder strategy with community fallback + │ + ▼ +buildCompoundGraph() // §4 — aggregate inter-container edges, keep intra-container + │ + ▼ +runStage1Layout(containers, aggEdges) // §6 — ELK on containers only; uses size memory + │ + ▼ ┌──────────────────────────────┐ + │ │ render: containers laid │ + │ │ out, children unrendered │ + │ └──────────────────────────────┘ + │ + │ triggered by: click | zoom > 1.0 | search/focus/tour hit child + ▼ +runStage2Layout(container) // §6 — ELK on one container's children; cached + │ + ▼ +React Flow render (parentId for parent-child) + visual overlay (selection/diff/search/tour) +``` + +Two invariants preserved from current code: + +1. **Layout computation is pure and memoized.** It only re-runs when graph topology / persona / diff / focus / nodeTypeFilters change. +2. **Visual state is a separate O(n) overlay pass.** Selection, search highlight, tour highlight, hover do not trigger relayout. + +This matches the existing `useLayerDetailTopology` / `useLayerDetailGraph` split in `GraphView.tsx`. + +--- + +## §2. Container Derivation (Layer-Detail Only) + +### 2.1 Folder strategy (default) + +1. Collect every node's `filePath` in the layer. +2. Compute longest common prefix (LCP) across all paths and strip it. +3. Group by the **first path segment after the LCP**. + - `auth/login.go` → container `auth` + - `auth/handlers/oauth.go` → container `auth` + - `cart/cart.go` → container `cart` +4. Single-depth grouping only; no recursive nesting in v1. +5. Nodes with no `filePath` (e.g. `concept` type) → container `~` (rendered as `(root)`, dimmed). + +### 2.2 Community fallback (Louvain) + +Triggered when **any** of: + +- All nodes share the same single folder after LCP stripping. +- Bucket count (folders + rooted) `< 2`. +- Any single bucket (folder or rooted) holds `> 70%` of nodes. + +Run Louvain modularity-based community detection on the layer's internal edges. Each community becomes a container. Names are placeholders (`Cluster A`, `Cluster B`, ...) since no semantic name is available. + +Implementation: use `graphology` + `graphology-communities-louvain` (~30KB total). Pure JS, no native deps, runs on main thread synchronously for layer-internal edges. + +### 2.3 Edge cases + +| Case | Behavior | +|---|---| +| Container has 1 child (only when layer total ≥ 3) | No container box rendered; child becomes a top-level node in Stage 1 layout | +| Container has 2 children | Container rendered; label dimmed | +| All nodes lack `filePath` | All go to `~` container; if it would become single-child, fall back to flat | + +### 2.4 Function signature + +```ts +function deriveContainers( + nodes: GraphNode[], + edges: GraphEdge[], +): { + containers: Array<{ + id: string; // e.g. "container:auth" or "container:cluster-0" + name: string; // "auth" or "Cluster A" + nodeIds: string[]; + strategy: "folder" | "community"; + }>; + ungrouped: string[]; // nodes that bypass containerization +}; +``` + +The `strategy` field is exposed in the UI ("Grouped by folder" vs "Grouped by edge density") so the user knows how a particular layer was organized. + +--- + +## §3. ELK Integration + +### 3.1 Package + +- `elkjs` ^0.9 (~250KB gzipped). Use `elk.bundled.js`, not the worker variant. +- Promise-based API. Runs on main thread for graphs ≤500 nodes; <100ms typical. + +### 3.2 Configuration + +```ts +{ + algorithm: "layered", + "elk.direction": "DOWN", // matches dagre TB + "elk.layered.spacing.nodeNodeBetweenLayers": 80, + "elk.spacing.nodeNode": 60, + "elk.layered.crossingMinimization.strategy": "LAYER_SWEEP", + "elk.edgeRouting": "ORTHOGONAL", + "elk.layered.compaction.postCompaction.strategy": "LEFT", + "elk.padding": "[top=40,left=20,right=20,bottom=20]", // container internal padding +} +``` + +`hierarchyHandling: INCLUDE_CHILDREN` is **not** used — the two-stage approach (§6) issues separate ELK calls for top-level containers and per-container children, so a single compound graph is never assembled. + +### 3.3 Per-view input shaping + +| View | ELK input | +|---|---| +| Overview | Flat. Children = layer-cluster nodes. | +| DomainGraphView | Flat in v1 (domain stays as the only grouping; flow/step nodes positioned within). | +| Layer-detail Stage 1 | Flat. Children = containers (treated as opaque atoms). | +| Layer-detail Stage 2 | Flat per container. Children = files within. | + +A single `runElk(input): Promise` function services all four cases. + +### 3.4 Boundaries with existing `utils/layout.ts` + +| Function | Status | +|---|---| +| `applyDagreLayout` | Kept temporarily; removed in the version after layout migration is verified stable | +| `applyForceLayout` | Untouched (KnowledgeGraphView only) | +| `applyElkLayout` (new) | Wrapper that handles repair → ELK → result coercion | + +### 3.5 Async + loading state + +Stage 1 runs in a `useEffect` with cancellation on dependency change: + +```ts +useEffect(() => { + let cancelled = false; + setLayoutStatus("computing"); + applyElkLayout(input).then(result => { + if (!cancelled) { + setLayout(result); + setLayoutStatus("ready"); + } + }); + return () => { cancelled = true }; +}, [graph, activeLayerId, persona, diffMode, nodeTypeFilters]); +``` + +While `layoutStatus === "computing"`, render a `"Computing layout…"` overlay (semi-transparent, centered). Stale layout from the previous state is kept underneath so the viewport doesn't blink. + +### 3.6 Failure handling — reuses existing GraphIssue model + +Before invoking ELK, run `repairElkInput()` over the assembled input. Each repair emits a `GraphIssue` consumed by the existing `WarningBanner`. + +| Repair function | Triggered by | Issue level | +|---|---|---| +| `ensureNodeDimensions` | Node missing width/height | `auto-corrected` | +| `dedupeNodeIds` | Duplicate child id under same parent | `auto-corrected` | +| `dropOrphanEdges` | Edge source/target not in node set | `dropped` | +| `dropOrphanChildren` | Child references a non-existent parent | `dropped` | +| `dropCircularContainment` | Container containment cycle | `dropped` | + +If ELK still rejects after repair → emit a `fatal` `GraphIssue`, render an empty graph + the existing fatal banner. The fatal copy text is augmented with "this looks like a dashboard rendering bug — please file an issue with the copied error" so the user knows to direct the report at the dashboard, not the graph data. + +### 3.7 Dev mode strict failures + +Both `repairElkInput` and `runElk` accept a `strict: boolean`. In `import.meta.env.DEV`, strict is on — repairs and ELK errors throw immediately rather than producing graceful issues. This catches input-construction bugs during development before they ship as silent fallbacks. + +--- + +## §4. Edge Aggregation + +### 4.1 Algorithm + +Performed inside `buildCompoundGraph()`, before either ELK stage. + +```ts +function aggregateContainerEdges( + nodes: GraphNode[], + edges: GraphEdge[], + nodeToContainer: Map, +): { + intraContainer: Edge[]; // preserved as-is + interContainerAggregated: AggregatedEdge[]; // one per (sourceContainer, targetContainer) +}; +``` + +Rules: + +- For each edge, look up source/target containers. +- Same container → intra (unchanged). +- Different containers → bucket by `(sourceContainer, targetContainer)`. Direction matters: A→B and B→A are independent. +- Each aggregated edge carries `count` and `types` (set of edge types appearing in the bucket). + +### 4.2 Visual + +Reuse the styling pattern already in overview-level edge aggregation (`GraphView.tsx` line ~186): + +- `strokeWidth: Math.min(1 + Math.log2(count + 1), 5)` +- Label: count number +- Color: existing `rgba(212,165,116,0.4)` + +### 4.3 Expand / collapse + +State (zustand store): + +```ts +expandedContainers: Set; // currently expanded container ids +``` + +Triggers: + +- **Click container** → toggle membership. +- **Click empty canvas** or `Esc` → clear all. +- **Multi-container expansion is allowed** (user comparing two folders' relationships). + +When a container is expanded: + +- Its inter-container aggregated edges (both directions) are replaced with the underlying file→file individual edges. +- Other containers' aggregated edges remain aggregated. +- Position re-layout is **not** triggered. Only React Flow's edge array changes. + +### 4.4 Interactions with persona / diff + +- **Persona filter** changes `count` (post-filter edges only). Aggregated edge re-derived in the memoized pipeline. +- **Diff mode**: aggregated edge containing any changed node → red stroke + animated; on expand, individual edges follow normal diff styling. + +--- + +## §5. Container Visual + +### 5.1 New component: `ContainerNode` + +A new React Flow node type `"container"` registered alongside the existing `custom` / `layer-cluster` / `portal`. + +It does **not** reuse `LayerClusterNode` because: + +- Click semantics differ (`LayerClusterNode` drills into a layer; `ContainerNode` toggles edge expansion). +- Metadata differs (`ContainerNode` does not carry `aggregateComplexity`). + +Visual language is shared: rounded translucent box, gold border, DM Serif title. + +### 5.2 Spec + +| Element | Style | +|---|---| +| Border (default) | `1px solid rgba(212,165,116,0.25)` | +| Border (hover / expanded) | `1.5px rgba(212,165,116,0.6)`, expanded adds chevron `▾` | +| Background | `rgba(255,255,255,0.02)` | +| Corner radius | `12px` | +| Title | DM Serif, 14px, `#d4a574`, top-left padding `12px 16px` | +| Child-count badge | top-right chip, `#a39787`, 11px | +| Internal padding (around children) | `40px top / 20px L,R,B` | + +### 5.3 Color coding + +Container index modulo 12-color palette (same palette used for `layerColorIndex` in `LayerClusterNode`). Hue is applied at low saturation to border + title only — never to the body fill — so the palette doesn't overpower individual nodes inside. + +### 5.4 State styles + +| State | Visual | +|---|---| +| `default` | Base spec | +| `hover` | Brighter border, title underline | +| `expanded` | 1.5px gold border + chevron `▾` | +| `search-hit-inside` | Search badge in title row showing match count | +| `diff-affected` | Border swaps to `rgba(224,82,82,0.5)` | +| `focused-via-child` | Same as expanded plus brightness boost | + +### 5.5 Label source + +| Strategy | Label | +|---|---| +| `folder` | First path segment after LCP (e.g. `auth`) | +| `community` | `Cluster A`, `Cluster B`, ... ordered by community id | +| `~` (root) | `(root)` in dimmed style | + +--- + +## §6. Lazy Two-Stage Layout + +### 6.1 State machine + +``` +[layer entered] + │ + │ Stage 1: ELK on containers (always runs) + ▼ +[containers laid out, children unrendered] + │ + ├── click container ─────┐ + ├── zoom > 1.0 in viewport (200ms debounce, hysteresis) ─┤ + └── search / focus / tour hit a child ─┘ + ▼ + Stage 2 (per container) + │ + ▼ + [container expanded, children laid out + rendered] +``` + +### 6.2 Store extensions + +```ts +expandedContainers: Set; +containerLayoutCache: Map; + actualSize: { width: number; height: number }; +}>; +containerSizeMemory: Map; +``` + +- `containerLayoutCache` invalidated by `(graphHash, containerId)`. +- `containerSizeMemory` persists across container collapses to prevent jitter on next expand. + +### 6.3 Stage 1 + +```ts +async function runStage1Layout(containers, aggregatedInterEdges, sizeMemory) { + const elkInput = { + id: "root", + children: containers.map(c => ({ + id: c.id, + width: sizeMemory.get(c.id)?.width + ?? Math.sqrt(c.nodeIds.length) * NODE_WIDTH * 1.2, + height: sizeMemory.get(c.id)?.height + ?? Math.sqrt(c.nodeIds.length) * NODE_HEIGHT * 1.2, + })), + edges: aggregatedInterEdges.map(toElkEdge), + }; + return runElk(elkInput); +} +``` + +Container size is estimated from `sqrt(childCount)` so it grows sub-linearly with content. If memory has the actual size from a previous run, that wins. + +### 6.4 Stage 2 + +```ts +async function runStage2Layout(container, intraEdges) { + if (containerLayoutCache.has(container.id)) { + return containerLayoutCache.get(container.id)!; + } + const elkInput = { + id: container.id, + children: container.nodeIds.map(toElkChild), + edges: intraEdges.filter(e => isWithin(container, e)).map(toElkEdge), + }; + const result = await runElk(elkInput); + containerLayoutCache.set(container.id, result); + containerSizeMemory.set(container.id, result.actualSize); + return result; +} +``` + +If `result.actualSize` differs from the Stage 1 estimate by **> 20%** in either dimension, trigger a Stage 1 re-layout (full re-run; <100ms at this scale, so the user perceives a small reflow rather than two distinct layouts). + +### 6.5 Auto-expand triggers + +| Trigger | Implementation | +|---|---| +| Click | `onClick` toggles `expandedContainers` | +| Zoom | React Flow `onMove` listener (200ms debounce). When viewport zoom > 1.0, all containers in viewport added to `expandedContainers`. Hysteresis: containers don't auto-collapse until zoom < 0.6, preventing flapping. | +| Search / focus / tour | `useEffect` watches `searchResults` / `focusNodeId` / `tourHighlightedNodeIds`; finds the parent container of any matched leaf node and adds to `expandedContainers` | + +### 6.6 Performance budget + +| Operation | Target | +|---|---| +| Stage 1 (any layer) | < 100ms | +| Stage 2 (first expand of a container) | < 100ms | +| Stage 2 (cache hit) | < 5ms | +| Zoom-driven auto-expand | 200ms debounce | +| Stage 1 re-layout after >20% deviation | < 100ms (re-uses Stage 1 path) | + +--- + +## §7. Interaction Matrix + +| Existing feature | Behavior with new layout | +|---|---| +| Persona filter | Drives `nodeTypeFilters` dependency in Stage 1 memo. Filtered-out nodes don't enter container derivation; containers with all-filtered children disappear. | +| Diff mode | Container with a changed child gets red border (§5.4); aggregated edges containing a changed node animate red; on expand, individual diff styling applies. | +| Focus mode (1-hop) | Focus node's container auto-expands. Non-neighbor containers fade to opacity 0.2; their children remain unrendered. | +| Search | Container with a hit gets search badge in title; container does **not** auto-expand to avoid expanding many at once. Clicking the badge expands and `fitView`s. | +| Tour | Tour-highlighted child auto-expands its container. `TourFitView` fits to the highlighted leaf positions (cached after expand). | +| Drill-in (`overview → layer-detail`) | Unchanged. After drill-in, Stage 1 runs on the new layer's containers. | +| Breadcrumb | Containers do not enter the breadcrumb. Path remains `Project > LAYER`. | +| Code viewer | Unchanged. Click a file node inside a container → existing slide-up viewer. | +| WarningBanner | Layout repair issues feed the same banner. Fatal copy text augmented to differentiate render bugs from data bugs. | +| Export (PNG/SVG) | Captures current state including expanded containers. Filename includes layer name. | + +--- + +## §8. Files & Test Plan + +### 8.1 Files + +``` +packages/dashboard/src/ +├── utils/ +│ ├── layout.ts [modify] add applyElkLayout export +│ ├── elk-layout.ts [new] runElk + repairElkInput + GraphIssue mapping +│ ├── containers.ts [new] deriveContainers (folder + community fallback) +│ ├── louvain.ts [new] thin wrapper around graphology-communities-louvain +│ └── edgeAggregation.ts [modify] add aggregateContainerEdges +├── components/ +│ ├── ContainerNode.tsx [new] container box visual +│ ├── GraphView.tsx [modify] Stage 1 / Stage 2 wiring, expand state, auto-expand triggers +│ └── DomainGraphView.tsx [modify] dagre → ELK +├── store.ts [modify] expandedContainers, containerLayoutCache, containerSizeMemory +└── package.json [modify] add elkjs ^0.9, graphology, graphology-communities-louvain +``` + +### 8.2 Test matrix + +| Type | Target | Cases | +|---|---|---| +| Unit | `deriveContainers` | folder grouping happy path; all-in-root fallback; <2 buckets fallback; >70% concentration fallback; no-`filePath` nodes; single-child container suppression (gated by layer ≥ 3) | +| Unit | `aggregateContainerEdges` | empty edges; multiple same-direction edges merge; bidirectional edges split; intra + inter mix; types deduped | +| Unit | `repairElkInput` | each repair function in isolation; validates correct `GraphIssue` level emitted | +| Unit | `runElk` | minimal valid input; dev-mode strict throw; production graceful fatal; cancellation on dependency change | +| Integration | Stage 1 + Stage 2 flow | 50-node fixture; click → cache miss; second click → cache hit; size-deviation >20% → re-layout | +| Integration | Persona / focus / search interactions | switching persona reruns Stage 1; focusing a child auto-expands its container; search hit adds badge without auto-expanding | +| Visual regression (optional) | Playwright + microservices-demo fixture | baseline screenshots for overview, layer-detail, domain views | + +### 8.3 Performance benchmarks + +Generate fixtures with `scripts/generate-large-graph.mjs` at 500 / 1000 / 3000 nodes. Verify: + +- Stage 1 < 200ms at 500 nodes; < 500ms at 3000 nodes. +- Stage 2 any container < 100ms. + +If 3000-node Stage 1 misses the budget, revisit container size estimation or ELK config — do not lower the budget. + +--- + +## Open Questions + +None at this point. All decisions made during brainstorming are captured above. + +## Migration Notes + +- `applyDagreLayout` is kept in the codebase for one release after this lands, then removed in the next. This gives a fallback path during the rollout and a clean uninstall once stable. +- No graph data migration needed. +- New dependencies (elkjs, graphology, graphology-communities-louvain) are pure JS, no native bindings — safe across the supported platform matrix. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b026d0b..b86b7a4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -115,6 +115,18 @@ importers: devlop: specifier: ^1.1.0 version: 1.1.0 + elkjs: + specifier: ^0.9.3 + version: 0.9.3 + graphology: + specifier: ^0.25.4 + version: 0.25.4(graphology-types@0.24.8) + graphology-communities-louvain: + specifier: ^2.0.1 + version: 2.0.2(graphology-types@0.24.8) + graphology-types: + specifier: ^0.24.8 + version: 0.24.8 hast-util-to-jsx-runtime: specifier: ^2.3.6 version: 2.3.6 @@ -149,6 +161,9 @@ importers: '@vitejs/plugin-react': specifier: ^4.3.0 version: 4.7.0(vite@6.4.2(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)) + '@vitest/coverage-v8': + specifier: ^3.2.4 + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)) tailwindcss: specifier: ^4.0.0 version: 4.2.1 @@ -158,6 +173,9 @@ importers: vite: specifier: ^6.0.0 version: 6.4.2(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3) + vitest: + specifier: ^3.1.0 + version: 3.2.4(@types/debug@4.1.12)(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3) packages: @@ -1455,6 +1473,9 @@ packages: electron-to-chromium@1.5.335: resolution: {integrity: sha512-q9n5T4BR4Xwa2cwbrwcsDJtHD/enpQ5S1xF1IAtdqf5AAgqDFmR/aakqH3ChFdqd/QXJhS3rnnXFtexU7rax6Q==} + elkjs@0.9.3: + resolution: {integrity: sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -1509,6 +1530,10 @@ packages: eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -1564,6 +1589,29 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + graphology-communities-louvain@2.0.2: + resolution: {integrity: sha512-zt+2hHVPYxjEquyecxWXoUoIuN/UvYzsvI7boDdMNz0rRvpESQ7+e+Ejv6wK7AThycbZXuQ6DkG8NPMCq6XwoA==} + peerDependencies: + graphology-types: '>=0.19.0' + + graphology-indices@0.17.0: + resolution: {integrity: sha512-A7RXuKQvdqSWOpn7ZVQo4S33O0vCfPBnUSf7FwE0zNCasqwZVUaCXePuWo5HBpWw68KJcwObZDHpFk6HKH6MYQ==} + peerDependencies: + graphology-types: '>=0.20.0' + + graphology-types@0.24.8: + resolution: {integrity: sha512-hDRKYXa8TsoZHjgEaysSRyPdT6uB78Ci8WnjgbStlQysz7xR52PInxNsmnB7IBOM1BhikxkNyCVEFgmPKnpx3Q==} + + graphology-utils@2.5.2: + resolution: {integrity: sha512-ckHg8MXrXJkOARk56ZaSCM1g1Wihe2d6iTmz1enGOz4W/l831MBCKSayeFQfowgF8wd+PQ4rlch/56Vs/VZLDQ==} + peerDependencies: + graphology-types: '>=0.23.0' + + graphology@0.25.4: + resolution: {integrity: sha512-33g0Ol9nkWdD6ulw687viS8YJQBxqG5LWII6FI6nul0pq6iM2t5EKquOTFDbyTblRB3O9I+7KX4xI8u5ffekAQ==} + peerDependencies: + graphology-types: '>=0.24.0' + h3@1.15.6: resolution: {integrity: sha512-oi15ESLW5LRthZ+qPCi5GNasY/gvynSKUQxgiovrY63bPAtG59wtM+LSrlcwvOHAXzGrXVLnI97brbkdPF9WoQ==} @@ -2034,6 +2082,9 @@ packages: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} + mnemonist@0.39.8: + resolution: {integrity: sha512-vyWo2K3fjrUw8YeeZ1zF0fy6Mu59RHokURlld8ymdUPjMlD9EC9ov1/YPqTgqRvUN9nTr3Gqfz29LYAmu0PHPQ==} + mrmime@2.0.1: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} @@ -2077,6 +2128,9 @@ packages: nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + obliterator@2.0.5: + resolution: {integrity: sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==} + obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} @@ -2110,6 +2164,9 @@ packages: package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + pandemonium@2.4.1: + resolution: {integrity: sha512-wRqjisUyiUfXowgm7MFH2rwJzKIr20rca5FsHXCMNm1W5YPP1hCtrZfgmQ62kP7OZ7Xt+cR858aB28lu5NX55g==} + parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} @@ -3656,6 +3713,14 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 + '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3) + '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))': dependencies: '@vitest/spy': 3.2.4 @@ -4048,6 +4113,8 @@ snapshots: electron-to-chromium@1.5.335: {} + elkjs@0.9.3: {} + emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} @@ -4137,6 +4204,8 @@ snapshots: eventemitter3@5.0.4: {} + events@3.3.0: {} + expect-type@1.3.0: {} extend@3.0.2: {} @@ -4184,6 +4253,32 @@ snapshots: graceful-fs@4.2.11: {} + graphology-communities-louvain@2.0.2(graphology-types@0.24.8): + dependencies: + graphology-indices: 0.17.0(graphology-types@0.24.8) + graphology-types: 0.24.8 + graphology-utils: 2.5.2(graphology-types@0.24.8) + mnemonist: 0.39.8 + pandemonium: 2.4.1 + + graphology-indices@0.17.0(graphology-types@0.24.8): + dependencies: + graphology-types: 0.24.8 + graphology-utils: 2.5.2(graphology-types@0.24.8) + mnemonist: 0.39.8 + + graphology-types@0.24.8: {} + + graphology-utils@2.5.2(graphology-types@0.24.8): + dependencies: + graphology-types: 0.24.8 + + graphology@0.25.4(graphology-types@0.24.8): + dependencies: + events: 3.3.0 + graphology-types: 0.24.8 + obliterator: 2.0.5 + h3@1.15.6: dependencies: cookie-es: 1.2.2 @@ -4888,6 +4983,10 @@ snapshots: minipass@7.1.3: {} + mnemonist@0.39.8: + dependencies: + obliterator: 2.0.5 + mrmime@2.0.1: {} ms@2.1.3: {} @@ -4916,6 +5015,8 @@ snapshots: dependencies: boolbase: 1.0.0 + obliterator@2.0.5: {} + obug@2.1.1: {} ofetch@1.5.1: @@ -4949,6 +5050,10 @@ snapshots: package-manager-detector@1.6.0: {} + pandemonium@2.4.1: + dependencies: + mnemonist: 0.39.8 + parse-entities@4.0.2: dependencies: '@types/unist': 2.0.11 @@ -5616,7 +5721,7 @@ snapshots: dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)) + '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 diff --git a/understand-anything-plugin/packages/dashboard/package.json b/understand-anything-plugin/packages/dashboard/package.json index 8bb9196..7b2b54e 100644 --- a/understand-anything-plugin/packages/dashboard/package.json +++ b/understand-anything-plugin/packages/dashboard/package.json @@ -7,7 +7,9 @@ "dev": "vite", "build": "tsc -b && vite build", "build:demo": "tsc -b && vite build --config vite.config.demo.ts", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@dagrejs/dagre": "^2.0.4", @@ -15,6 +17,10 @@ "@xyflow/react": "^12.0.0", "d3-force": "^3.0.0", "devlop": "^1.1.0", + "elkjs": "^0.9.3", + "graphology": "^0.25.4", + "graphology-communities-louvain": "^2.0.1", + "graphology-types": "^0.24.8", "hast-util-to-jsx-runtime": "^2.3.6", "prism-react-renderer": "^2.4.1", "react": "^19.0.0", @@ -28,8 +34,10 @@ "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.0", + "@vitest/coverage-v8": "^3.2.4", "tailwindcss": "^4.0.0", "typescript": "^5.7.0", - "vite": "^6.0.0" + "vite": "^6.0.0", + "vitest": "^3.1.0" } } diff --git a/understand-anything-plugin/packages/dashboard/scripts/benchmark-layout.mjs b/understand-anything-plugin/packages/dashboard/scripts/benchmark-layout.mjs new file mode 100644 index 0000000..c0da501 --- /dev/null +++ b/understand-anything-plugin/packages/dashboard/scripts/benchmark-layout.mjs @@ -0,0 +1,80 @@ +// Stage 1 ELK layout perf benchmark. +// +// Mirrors `applyElkLayout` from `src/utils/elk-layout.ts` using `elkjs` +// directly. The dashboard build is a Vite bundle (hashed chunks), so it has +// no per-module `dist/utils/elk-layout.js` we can import. The Stage 1 hot +// path is `elk.layout()` on a sized input, which we reproduce faithfully +// here — same default node dimensions, same dim-defaulting behavior. +// +// Targets (spec §8.3): +// - Stage 1 < 200ms at 500 nodes +// - Stage 1 < 500ms at 3000 nodes +// +// Usage: +// node understand-anything-plugin/packages/dashboard/scripts/benchmark-layout.mjs + +import { performance } from "node:perf_hooks"; +import ELK from "elkjs/lib/elk.bundled.js"; + +// Keep in lockstep with NODE_WIDTH / NODE_HEIGHT in src/utils/layout.ts. +const DEFAULT_NODE_WIDTH = 280; +const DEFAULT_NODE_HEIGHT = 120; + +const elk = new ELK(); + +/** + * Default missing width/height on every node (mirrors repairElkInput's + * ensureNodeDimensions step). Stage 1 in prod always feeds ELK sized nodes, + * but the repair pass is part of the measured path so we model it. + */ +function fillDims(children) { + return children.map((c) => { + const next = { ...c }; + if (next.width == null) next.width = DEFAULT_NODE_WIDTH; + if (next.height == null) next.height = DEFAULT_NODE_HEIGHT; + if (next.children) next.children = fillDims(next.children); + return next; + }); +} + +async function applyElkLayout(input) { + const repaired = { ...input, children: fillDims(input.children) }; + return elk.layout(repaired); +} + +/** + * Synthetic Stage 1 graph: top-level container nodes with a sparse edge mesh. + * Stage 1 only lays out containers (lazy children — see plan §3), so the + * "node count" parameter is interpreted as total leaves while the container + * count scales sub-linearly, matching production shape. + */ +function makeGraph(nodeCount, containerCount = Math.min(20, Math.ceil(nodeCount / 25))) { + const containers = Array.from({ length: containerCount }, (_, i) => ({ + id: `c${i}`, + width: 400, + height: 300, + })); + const edges = []; + for (let i = 0; i < containerCount; i++) { + for (let j = i + 1; j < containerCount; j++) { + if (Math.random() < 0.3) { + edges.push({ id: `e-${i}-${j}`, sources: [`c${i}`], targets: [`c${j}`] }); + } + } + } + return { id: "root", children: containers, edges }; +} + +async function bench(label, n) { + const input = makeGraph(n); + const t0 = performance.now(); + await applyElkLayout(input); + const t1 = performance.now(); + const ms = t1 - t0; + console.log(`${label} (${n} nodes): ${ms.toFixed(1)}ms`); + return ms; +} + +await bench("Stage1", 500); +await bench("Stage1", 1000); +await bench("Stage1", 3000); diff --git a/understand-anything-plugin/packages/dashboard/src/App.tsx b/understand-anything-plugin/packages/dashboard/src/App.tsx index b3240e6..efa03bd 100644 --- a/understand-anything-plugin/packages/dashboard/src/App.tsx +++ b/understand-anything-plugin/packages/dashboard/src/App.tsx @@ -117,6 +117,13 @@ function Dashboard({ accessToken }: { accessToken: string }) { const isKnowledgeGraph = useDashboardStore((s) => s.isKnowledgeGraph); const domainGraph = useDashboardStore((s) => s.domainGraph); const setDomainGraph = useDashboardStore((s) => s.setDomainGraph); + const layoutIssues = useDashboardStore((s) => s.layoutIssues); + // Schema issues + ELK layout issues share the WarningBanner — graph-load + // problems and dashboard rendering problems are equally surfaced. + const allIssues = useMemo( + () => [...graphIssues, ...layoutIssues], + [graphIssues, layoutIssues], + ); useEffect(() => { fetch(dataUrl("meta.json", accessToken)) @@ -506,8 +513,8 @@ function Dashboard({ accessToken }: { accessToken: string }) { {/* Validation warning banner */} - {graphIssues.length > 0 && !loadError && ( - + {allIssues.length > 0 && !loadError && ( + )} {/* Error banner */} diff --git a/understand-anything-plugin/packages/dashboard/src/components/ContainerNode.tsx b/understand-anything-plugin/packages/dashboard/src/components/ContainerNode.tsx new file mode 100644 index 0000000..941e5e4 --- /dev/null +++ b/understand-anything-plugin/packages/dashboard/src/components/ContainerNode.tsx @@ -0,0 +1,101 @@ +import { memo } from "react"; +import type { NodeProps, Node } from "@xyflow/react"; +import { getLayerColor } from "./LayerLegend"; + +export interface ContainerNodeData extends Record { + containerId: string; + name: string; + childCount: number; + strategy: "folder" | "community"; + colorIndex: number; + isExpanded: boolean; + hasSearchHits: boolean; + searchHitCount?: number; + isDiffAffected: boolean; + isFocusedViaChild: boolean; + onToggle: (containerId: string) => void; +} + +export type ContainerFlowNode = Node; + +function ContainerNodeComponent({ data, width, height }: NodeProps) { + const color = getLayerColor(data.colorIndex); + + const borderColor = data.isDiffAffected + ? "var(--color-diff-changed)" + : data.isExpanded || data.isFocusedViaChild + ? "rgba(212,165,116,0.6)" + : "rgba(212,165,116,0.25)"; + const borderWidth = data.isExpanded || data.isFocusedViaChild ? 1.5 : 1; + + const labelDimmed = data.name === "~"; + const labelText = labelDimmed ? "(root)" : data.name; + + const handleToggle = (e: React.SyntheticEvent) => { + e.stopPropagation(); + data.onToggle(data.containerId); + }; + + return ( +
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleToggle(e); + } + }} + > +
+ + {data.isExpanded && } + {labelText} + {data.searchHitCount != null && data.searchHitCount > 0 && ( + + {data.searchHitCount} hit{data.searchHitCount !== 1 ? "s" : ""} + + )} + + {data.childCount} +
+
+ ); +} + +const ContainerNode = memo(ContainerNodeComponent); +ContainerNode.displayName = "ContainerNode"; + +export default ContainerNode; diff --git a/understand-anything-plugin/packages/dashboard/src/components/DomainGraphView.tsx b/understand-anything-plugin/packages/dashboard/src/components/DomainGraphView.tsx index 6426cc2..6730000 100644 --- a/understand-anything-plugin/packages/dashboard/src/components/DomainGraphView.tsx +++ b/understand-anything-plugin/packages/dashboard/src/components/DomainGraphView.tsx @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; import { ReactFlow, ReactFlowProvider, @@ -17,7 +17,8 @@ import type { FlowFlowNode } from "./FlowNode"; import StepNode from "./StepNode"; import type { StepFlowNode } from "./StepNode"; import { useDashboardStore } from "../store"; -import { applyDagreLayout } from "../utils/layout"; +import { mergeElkPositions, nodesToElkInput } from "../utils/layout"; +import { applyElkLayout } from "../utils/elk-layout"; import type { KnowledgeGraph, GraphNode } from "@understand-anything/core/types"; const nodeTypes = { @@ -30,7 +31,13 @@ function getDomainMeta(node: GraphNode) { return node.domainMeta; } -function buildDomainOverview(graph: KnowledgeGraph): { nodes: Node[]; edges: Edge[] } { +interface BuiltGraph { + nodes: Node[]; + edges: Edge[]; + dims: Map; +} + +function buildDomainOverview(graph: KnowledgeGraph): BuiltGraph { const dims = new Map(); const domainNodes = graph.nodes.filter((n) => n.type === "domain"); @@ -76,17 +83,13 @@ function buildDomainOverview(graph: KnowledgeGraph): { nodes: Node[]; edges: Edg animated: true, })); - // Compute spacing based on longest edge label (~6px per char at fontSize 10) - const maxLabelLen = Math.max(0, ...rfEdges.map((e) => String(e.label ?? "").length)); - const ranksep = Math.max(120, maxLabelLen * 6); - - return applyDagreLayout(rfNodes, rfEdges, "LR", dims, { ranksep }); + return { nodes: rfNodes as unknown as Node[], edges: rfEdges, dims }; } function buildDomainDetail( graph: KnowledgeGraph, domainId: string, -): { nodes: Node[]; edges: Edge[] } { +): BuiltGraph { // Find flows for this domain const flowIds = new Set( graph.edges @@ -157,7 +160,7 @@ function buildDomainDetail( animated: false, })); - return applyDagreLayout(rfNodes, rfEdges, "LR", dims); + return { nodes: rfNodes, edges: rfEdges, dims }; } function DomainGraphViewInner() { @@ -165,14 +168,56 @@ function DomainGraphViewInner() { const activeDomainId = useDashboardStore((s) => s.activeDomainId); const clearActiveDomain = useDashboardStore((s) => s.clearActiveDomain); - const { nodes, edges } = useMemo(() => { - if (!domainGraph) return { nodes: [], edges: [] }; + // Build structural nodes/edges/dims synchronously; only the layout call + // itself is async, so we memo the structural pieces and run ELK in an + // effect. + const built = useMemo(() => { + if (!domainGraph) return null; if (activeDomainId) { return buildDomainDetail(domainGraph, activeDomainId); } return buildDomainOverview(domainGraph); }, [domainGraph, activeDomainId]); + const [layout, setLayout] = useState<{ nodes: Node[]; edges: Edge[] }>({ + nodes: [], + edges: [], + }); + + useEffect(() => { + if (!built) { + setLayout({ nodes: [], edges: [] }); + return; + } + let cancelled = false; + const { nodes: nodesArray, edges: edgesArray, dims } = built; + // DomainGraphView used dagre LR; preserve that direction with ELK. + const elkInput = nodesToElkInput(nodesArray, edgesArray, dims, { + "elk.direction": "RIGHT", + }); + applyElkLayout(elkInput, { strict: import.meta.env.DEV }) + .then(({ positioned, issues }) => { + if (cancelled) return; + if (issues.length > 0) { + // Funnel into store so WarningBanner surfaces them. + useDashboardStore.getState().appendLayoutIssues(issues); + } + setLayout({ + nodes: mergeElkPositions(nodesArray, positioned), + edges: edgesArray, + }); + }) + .catch((err) => { + if (cancelled) return; + console.error("[domain ELK] layout failed:", err); + }); + return () => { + cancelled = true; + }; + }, [built]); + + const { nodes, edges } = layout; + // Double-click is handled by individual node components (e.g. DomainClusterNode) if (!domainGraph) { diff --git a/understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx b/understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx index aa75f09..d6367af 100644 --- a/understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx +++ b/understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ReactFlow, ReactFlowProvider, @@ -19,29 +19,44 @@ import LayerClusterNode from "./LayerClusterNode"; import type { LayerClusterFlowNode } from "./LayerClusterNode"; import PortalNode from "./PortalNode"; import type { PortalFlowNode } from "./PortalNode"; +import ContainerNode from "./ContainerNode"; +import type { ContainerFlowNode, ContainerNodeData } from "./ContainerNode"; import Breadcrumb from "./Breadcrumb"; import { useDashboardStore } from "../store"; -import type { KnowledgeGraph, NodeType } from "@understand-anything/core/types"; +import type { + GraphEdge, + GraphNode, + KnowledgeGraph, + NodeType, +} from "@understand-anything/core/types"; import { useTheme } from "../themes/index.ts"; import { - applyDagreLayout, NODE_WIDTH, NODE_HEIGHT, LAYER_CLUSTER_WIDTH, LAYER_CLUSTER_HEIGHT, PORTAL_NODE_WIDTH, PORTAL_NODE_HEIGHT, + ELK_DEFAULT_LAYOUT_OPTIONS, + nodesToElkInput, + mergeElkPositions, } from "../utils/layout"; +import { applyElkLayout } from "../utils/elk-layout"; +import type { ElkChild, ElkEdge, ElkInput } from "../utils/elk-layout"; import { + aggregateContainerEdges, aggregateLayerEdges, computePortals, findCrossLayerFileNodes, } from "../utils/edgeAggregation"; +import { deriveContainers } from "../utils/containers"; +import type { DerivedContainer } from "../utils/containers"; const nodeTypes = { custom: CustomNode, "layer-cluster": LayerClusterNode, portal: PortalNode, + container: ContainerNode, }; import type { NodeCategory } from "../store"; @@ -127,11 +142,17 @@ function useOverviewGraph() { const searchResults = useDashboardStore((s) => s.searchResults); const drillIntoLayer = useDashboardStore((s) => s.drillIntoLayer); - return useMemo(() => { - if (!graph) return { nodes: [] as Node[], edges: [] as Edge[] }; - + // Build cluster nodes / flow edges / dims synchronously; only the layout + // call itself is async, so we memo the structural pieces and run ELK in an + // effect. + const built = useMemo(() => { + if (!graph) { + return null; + } const layers = graph.layers ?? []; - if (layers.length === 0) return { nodes: [] as Node[], edges: [] as Edge[] }; + if (layers.length === 0) { + return null; + } // Build search match counts per layer const searchMatchByLayer = new Map(); @@ -199,19 +220,89 @@ function useOverviewGraph() { for (const n of clusterNodes) { dims.set(n.id, { width: LAYER_CLUSTER_WIDTH, height: LAYER_CLUSTER_HEIGHT }); } - const laid = applyDagreLayout(clusterNodes as unknown as Node[], flowEdges, "TB", dims); - return { nodes: laid.nodes, edges: laid.edges }; + + return { clusterNodes, flowEdges, dims }; }, [graph, searchResults, drillIntoLayer]); + + const [overview, setOverview] = useState<{ nodes: Node[]; edges: Edge[] }>({ + nodes: [], + edges: [], + }); + const [layoutStatus, setLayoutStatus] = useState<"computing" | "ready">("ready"); + + useEffect(() => { + if (!built) { + setOverview({ nodes: [], edges: [] }); + setLayoutStatus("ready"); + return; + } + let cancelled = false; + const { clusterNodes, flowEdges, dims } = built; + const baseNodes = clusterNodes as unknown as Node[]; + const elkInput = nodesToElkInput(baseNodes, flowEdges, dims); + setLayoutStatus("computing"); + applyElkLayout(elkInput, { strict: import.meta.env.DEV }) + .then(({ positioned, issues }) => { + if (cancelled) return; + if (issues.length > 0) { + // Funnel into store so WarningBanner surfaces them. getState() + // avoids re-creating the closure on every layoutIssues change. + useDashboardStore.getState().appendLayoutIssues(issues); + } + const positionedNodes = mergeElkPositions(baseNodes, positioned); + setOverview({ nodes: positionedNodes, edges: flowEdges }); + setLayoutStatus("ready"); + }) + .catch((err) => { + if (cancelled) return; + console.error("[overview ELK] layout failed:", err); + setLayoutStatus("ready"); + }); + return () => { + cancelled = true; + }; + }, [built]); + + return { ...overview, layoutStatus }; } -// ── Layer detail level: topology (dagre) + visual overlay ─────────────── +// ── Layer detail level: topology (ELK Stage 1) + visual overlay ───────── + +interface LayerDetailTopology { + nodes: Node[]; + edges: Edge[]; + portalNodes: PortalFlowNode[]; + portalEdges: Edge[]; + filteredEdges: KnowledgeGraph["edges"]; + filteredNodes: GraphNode[]; + containers: DerivedContainer[]; + nodeToContainer: Map; + intraContainer: GraphEdge[]; +} + +const EMPTY_TOPOLOGY: LayerDetailTopology = { + nodes: [], + edges: [], + portalNodes: [], + portalEdges: [], + filteredEdges: [], + filteredNodes: [], + containers: [], + nodeToContainer: new Map(), + intraContainer: [], +}; /** - * Topology memo: computes node positions via dagre. Only recomputes when - * the graph structure, active layer, persona, diff, or focus changes. - * Does NOT depend on selectedNodeId, searchResults, or tourHighlightedNodeIds. + * Topology hook: derives containers, aggregates inter-container edges, then + * runs Stage 1 ELK on container atoms (no children rendered yet — Task 12 + * lazy-expands them). Only recomputes when the graph structure, active + * layer, persona, diff state, focus, or filters change. Does NOT depend on + * selectedNodeId, searchResults, tourHighlightedNodeIds, or + * expandedContainers (Stage 2 concern). */ -function useLayerDetailTopology() { +function useLayerDetailTopology(): LayerDetailTopology & { + layoutStatus: "computing" | "ready"; +} { const graph = useDashboardStore((s) => s.graph); const activeLayerId = useDashboardStore((s) => s.activeLayerId); const selectNode = useDashboardStore((s) => s.selectNode); @@ -230,18 +321,27 @@ function useLayerDetailTopology() { [selectNode], ); - return useMemo(() => { - if (!graph || !activeLayerId) - return { nodes: [] as CustomFlowNode[], edges: [] as Edge[], portalNodes: [] as PortalFlowNode[], portalEdges: [] as Edge[], filteredEdges: [] as KnowledgeGraph["edges"] }; + // Stable across renders so ContainerNode's memo() actually short-circuits. + // Reading toggleContainer via getState() avoids subscribing this hook to + // expandedContainers — Stage 1 must not relayout on expand. + const handleContainerToggle = useCallback( + (id: string) => useDashboardStore.getState().toggleContainer(id), + [], + ); + + // ── Structural build (synchronous): filtering + containers + nodes/edges + // pre-layout. Re-runs whenever the inputs that drive container derivation + // change. The only async piece is the ELK call below. + const built = useMemo(() => { + if (!graph || !activeLayerId) return null; const activeLayer = graph.layers.find((l) => l.id === activeLayerId); - if (!activeLayer) return { nodes: [] as CustomFlowNode[], edges: [] as Edge[], portalNodes: [] as PortalFlowNode[], portalEdges: [] as Edge[], filteredEdges: [] as KnowledgeGraph["edges"] }; + if (!activeLayer) return null; const layerNodeIds = new Set(activeLayer.nodeIds); - // Expand layer membership to include sub-file nodes (function/class) whose - // parent file is in this layer. These nodes aren't in layer.nodeIds directly - // but belong to the layer via their "contains" edge from a file node. + // Expand layer membership to include sub-file nodes (function/class) + // whose parent file is in this layer. Joined via "contains" edges. const expandedLayerNodeIds = new Set(layerNodeIds); for (const edge of graph.edges) { if (edge.type === "contains" && layerNodeIds.has(edge.source)) { @@ -249,8 +349,6 @@ function useLayerDetailTopology() { } } - // All node types visible at each persona level (single source of truth). - // Sub-file types (function, class) are only shown for junior/experienced. const subFileTypes = new Set(["function", "class"]); const allVisibleTypes = new Set([ "file", "module", "concept", @@ -267,7 +365,6 @@ function useLayerDetailTopology() { return true; }); - // Apply node type category filters filteredGraphNodes = filteredGraphNodes.filter((n) => { const category = NODE_TYPE_TO_CATEGORY[n.type as NodeType]; if (!category) { @@ -301,70 +398,117 @@ function useLayerDetailTopology() { ); } - const diffNodeIds = diffMode - ? new Set([...changedNodeIds, ...affectedNodeIds]) - : new Set(); + // Derive containers + bucket edges + const { containers, ungrouped } = deriveContainers( + filteredGraphNodes, + filteredGraphEdges, + ); + const ungroupedSet = new Set(ungrouped); + const nodeToContainer = new Map(); + for (const c of containers) { + for (const id of c.nodeIds) nodeToContainer.set(id, c.id); + } + // Ungrouped nodes are their own atoms — register them so edge + // aggregation treats inter-(container,ungrouped) edges as cross-atom. + for (const id of ungroupedSet) { + nodeToContainer.set(id, id); + } + const { intraContainer, interContainerAggregated } = aggregateContainerEdges( + filteredGraphEdges, + nodeToContainer, + ); - const flowNodes: CustomFlowNode[] = filteredGraphNodes.map((node) => ({ - id: node.id, - type: "custom" as const, + // Container size estimate (size memory takes priority). + // Caps prevent first-paint sprawl: at 100 children sqrt() yields + // ~3360px which renders as a huge empty box pre-expansion. Stage 2 + // sets the actual size once it's measured, and Task 15 re-flows. + const STAGE1_MAX_CONTAINER_WIDTH = 800; + const STAGE1_MAX_CONTAINER_HEIGHT = 600; + const sizeMemory = useDashboardStore.getState().containerSizeMemory; + const containerWidth = (c: DerivedContainer) => { + const memo = sizeMemory.get(c.id)?.width; + if (memo) return memo; + const estimate = Math.sqrt(c.nodeIds.length) * NODE_WIDTH * 1.2; + return Math.min(STAGE1_MAX_CONTAINER_WIDTH, Math.max(NODE_WIDTH, estimate)); + }; + const containerHeight = (c: DerivedContainer) => { + const memo = sizeMemory.get(c.id)?.height; + if (memo) return memo; + const estimate = Math.sqrt(c.nodeIds.length) * NODE_HEIGHT * 1.2; + return Math.min(STAGE1_MAX_CONTAINER_HEIGHT, Math.max(NODE_HEIGHT, estimate)); + }; + + // Build container flow nodes (children NOT rendered yet — Task 12) + const containerFlowNodes: ContainerFlowNode[] = containers.map((c, idx) => ({ + id: c.id, + type: "container" as const, position: { x: 0, y: 0 }, + width: containerWidth(c), + height: containerHeight(c), data: { - label: node.name ?? node.filePath?.split("/").pop() ?? node.id, - nodeType: node.type, - summary: node.summary, - complexity: node.complexity, - isHighlighted: false, - searchScore: undefined, - isSelected: false, - isTourHighlighted: false, - isDiffChanged: diffMode && changedNodeIds.has(node.id), - isDiffAffected: diffMode && affectedNodeIds.has(node.id), - isDiffFaded: diffMode && !changedNodeIds.has(node.id) && !affectedNodeIds.has(node.id), - isNeighbor: false, - isSelectionFaded: false, - onNodeClick: handleNodeSelect, + containerId: c.id, + name: c.name, + childCount: c.nodeIds.length, + strategy: c.strategy, + colorIndex: idx % 12, + isExpanded: false, + hasSearchHits: false, + isDiffAffected: false, // Task 14 will populate this + isFocusedViaChild: false, + onToggle: handleContainerToggle, }, })); - const flowEdges: Edge[] = filteredGraphEdges.map((edge, i) => { - const sourceInDiff = diffMode && diffNodeIds.has(edge.source); - const targetInDiff = diffMode && diffNodeIds.has(edge.target); - const isImpacted = diffMode && (sourceInDiff || targetInDiff); - - let edgeStyle: React.CSSProperties; - let edgeLabelStyle: React.CSSProperties; - let edgeAnimated: boolean; - - if (isImpacted) { - edgeStyle = { - stroke: sourceInDiff && targetInDiff ? "rgba(224, 82, 82, 0.7)" : "rgba(212, 160, 48, 0.5)", - strokeWidth: 2.5, - }; - edgeLabelStyle = { fill: "#a39787", fontSize: 10 }; - edgeAnimated = true; - } else if (diffMode) { - edgeStyle = { stroke: "rgba(212,165,116,0.08)", strokeWidth: 1 }; - edgeLabelStyle = { fill: "rgba(163,151,135,0.3)", fontSize: 10 }; - edgeAnimated = false; - } else { - edgeStyle = { stroke: "rgba(212,165,116,0.3)", strokeWidth: 1.5 }; - edgeLabelStyle = { fill: "#a39787", fontSize: 10 }; - edgeAnimated = edge.type === "calls"; - } + // Build ungrouped file flow nodes (existing CustomFlowNode shape) + const ungroupedFlowNodes: CustomFlowNode[] = filteredGraphNodes + .filter((n) => ungroupedSet.has(n.id)) + .map((node) => ({ + id: node.id, + type: "custom" as const, + position: { x: 0, y: 0 }, + data: { + label: node.name ?? node.filePath?.split("/").pop() ?? node.id, + nodeType: node.type, + summary: node.summary, + complexity: node.complexity, + isHighlighted: false, + searchScore: undefined, + isSelected: false, + isTourHighlighted: false, + isDiffChanged: diffMode && changedNodeIds.has(node.id), + isDiffAffected: diffMode && affectedNodeIds.has(node.id), + isDiffFaded: diffMode && !changedNodeIds.has(node.id) && !affectedNodeIds.has(node.id), + isNeighbor: false, + isSelectionFaded: false, + onNodeClick: handleNodeSelect, + }, + })); + // Aggregated cross-atom edges (count label, log-scaled stroke). + // diffMode dims unaffected aggregated edges (no per-edge diff data — we + // can't tell which underlying edges are impacted without expanding a + // container, so just fade everything in diff mode at this stage). + const aggEdges: Edge[] = interContainerAggregated.map((agg, i) => { + const baseStyle = diffMode + ? { stroke: "rgba(212,165,116,0.08)", strokeWidth: 1 } + : { + stroke: "rgba(212,165,116,0.4)", + strokeWidth: Math.min(1 + Math.log2(agg.count + 1), 5), + }; return { - id: `e-${i}`, - source: edge.source, - target: edge.target, - label: edge.type, - animated: edgeAnimated, - style: edgeStyle, - labelStyle: edgeLabelStyle, + id: `agg-${i}`, + source: agg.sourceContainerId, + target: agg.targetContainerId, + label: String(agg.count), + style: baseStyle, + labelStyle: { + fill: diffMode ? "rgba(163,151,135,0.3)" : "#a39787", + fontSize: 11, + }, }; }); - // Portal nodes for connected external layers + // Portal nodes for connected external layers (unchanged) const portals = computePortals(graph, activeLayerId); const layerIndexMap = new Map(graph.layers.map((l, i) => [l.id, i])); @@ -382,53 +526,492 @@ function useLayerDetailTopology() { })); const portalEdges: Edge[] = []; - let portalEdgeIdx = flowEdges.length; + let portalEdgeIdx = aggEdges.length; for (const portal of portals) { const crossFiles = findCrossLayerFileNodes(graph, activeLayerId, portal.layerId); + // Dedupe by atom — multiple files in the same container hitting the + // same portal collapse to one Stage 1 edge. Task 12 will re-route to + // the actual file ids when the source container expands. + const seenAtoms = new Set(); for (const fileId of crossFiles) { - if (filteredNodeIds.has(fileId)) { - portalEdges.push({ - id: `e-${portalEdgeIdx++}`, - source: fileId, - target: `portal:${portal.layerId}`, - style: { stroke: "rgba(212,165,116,0.2)", strokeWidth: 1, strokeDasharray: "4 4" }, - animated: false, - }); - } + if (!filteredNodeIds.has(fileId)) continue; + const atomId = nodeToContainer.get(fileId) ?? fileId; + if (seenAtoms.has(atomId)) continue; + seenAtoms.add(atomId); + portalEdges.push({ + id: `e-${portalEdgeIdx++}`, + source: atomId, + target: `portal:${portal.layerId}`, + style: { stroke: "rgba(212,165,116,0.2)", strokeWidth: 1, strokeDasharray: "4 4" }, + animated: false, + }); } } - const allFlowNodes: Node[] = [ - ...(flowNodes as unknown as Node[]), - ...(portalNodes as unknown as Node[]), + return { + containers, + ungrouped, + nodeToContainer, + intraContainer, + filteredGraphNodes, + filteredGraphEdges, + containerFlowNodes, + ungroupedFlowNodes, + aggEdges, + portalNodes, + portalEdges, + }; + }, [ + graph, + activeLayerId, + persona, + diffMode, + changedNodeIds, + affectedNodeIds, + focusNodeId, + nodeTypeFilters, + drillIntoLayer, + handleNodeSelect, + handleContainerToggle, + ]); + + // ── Async ELK Stage 1 layout ──────────────────────────────────────────── + // `stage1Tick` is bumped by the Stage 2 effect when an actual container + // size deviates >20% from the Stage 1 estimate — it forces this effect + // to re-run with the now-cached actual size in containerSizeMemory so + // surrounding atoms reflow into the correct positions. + const stage1Tick = useDashboardStore((s) => s.stage1Tick); + const [topology, setTopology] = useState(EMPTY_TOPOLOGY); + const [layoutStatus, setLayoutStatus] = useState<"computing" | "ready">("ready"); + + useEffect(() => { + if (!built) { + setTopology(EMPTY_TOPOLOGY); + setLayoutStatus("ready"); + return; + } + let cancelled = false; + const { + containers, + nodeToContainer, + intraContainer, + filteredGraphNodes, + filteredGraphEdges, + containerFlowNodes, + ungroupedFlowNodes, + aggEdges, + portalNodes, + portalEdges, + } = built; + + // Build Stage 1 ELK input: containers as opaque atoms + ungrouped files + // + portals, all at the top level. + // + // Read containerSizeMemory at effect-run time so that a Stage 2-driven + // re-layout (via `stage1Tick`) picks up the freshly measured actual + // size and routes around the now-correctly-sized atom. The structural + // memo intentionally does NOT depend on `stage1Tick` (avoids rebuilding + // the entire layer's structural state), so we override widths from + // size memory here. + const sizeMemoryAtRun = useDashboardStore.getState().containerSizeMemory; + const stage1Children: ElkChild[] = [ + ...containerFlowNodes.map((cn) => { + const memo = sizeMemoryAtRun.get(cn.id); + return { + id: cn.id, + width: memo?.width ?? cn.width ?? NODE_WIDTH, + height: memo?.height ?? cn.height ?? NODE_HEIGHT, + }; + }), + ...ungroupedFlowNodes.map((un) => ({ + id: un.id, + width: NODE_WIDTH, + height: NODE_HEIGHT, + })), + ...portalNodes.map((pn) => ({ + id: pn.id, + width: PORTAL_NODE_WIDTH, + height: PORTAL_NODE_HEIGHT, + })), ]; - const allFlowEdges = [...flowEdges, ...portalEdges]; - const dims = new Map(); - for (const n of flowNodes) { - dims.set(n.id, { width: NODE_WIDTH, height: NODE_HEIGHT }); - } - for (const n of portalNodes) { - dims.set(n.id, { width: PORTAL_NODE_WIDTH, height: PORTAL_NODE_HEIGHT }); - } + const stage1Edges: ElkEdge[] = [ + ...aggEdges.map((e) => ({ + id: e.id, + sources: [String(e.source)], + targets: [String(e.target)], + })), + ...portalEdges.map((e) => ({ + id: e.id, + sources: [String(e.source)], + targets: [String(e.target)], + })), + ]; - const laid = applyDagreLayout(allFlowNodes, allFlowEdges, "TB", dims); - return { nodes: laid.nodes, edges: laid.edges, portalNodes, portalEdges, filteredEdges: filteredGraphEdges }; - }, [graph, activeLayerId, persona, handleNodeSelect, diffMode, changedNodeIds, affectedNodeIds, focusNodeId, nodeTypeFilters, drillIntoLayer]); + const elkInput: ElkInput = { + id: "layer", + layoutOptions: ELK_DEFAULT_LAYOUT_OPTIONS, + children: stage1Children, + edges: stage1Edges, + }; + + setLayoutStatus("computing"); + applyElkLayout(elkInput, { strict: import.meta.env.DEV }) + .then(({ positioned, issues }) => { + if (cancelled) return; + if (issues.length > 0) { + // Funnel into store so WarningBanner surfaces them. + useDashboardStore.getState().appendLayoutIssues(issues); + } + const allBaseNodes: Node[] = [ + ...(containerFlowNodes as unknown as Node[]), + ...(ungroupedFlowNodes as unknown as Node[]), + ...(portalNodes as unknown as Node[]), + ]; + const positionedNodes = mergeElkPositions(allBaseNodes, positioned); + setTopology({ + nodes: positionedNodes, + edges: aggEdges, + portalNodes, + portalEdges, + filteredEdges: filteredGraphEdges, + filteredNodes: filteredGraphNodes, + containers, + nodeToContainer, + intraContainer, + }); + setLayoutStatus("ready"); + }) + .catch((err) => { + if (cancelled) return; + console.error("[layer-detail Stage 1 ELK] layout failed:", err); + setLayoutStatus("ready"); + }); + + return () => { + cancelled = true; + }; + }, [built, stage1Tick]); + + // ── Stage 2: lazy per-container layout on expand ─────────────────────── + // Watches expandedContainers and computes ELK on each newly-expanded + // container's children (without a cache entry). Critically does NOT + // depend on `built` — expanding a container must not trigger Stage 1 + // relayout of the surrounding atoms. + const expandedContainers = useDashboardStore((s) => s.expandedContainers); + const containerLayoutCache = useDashboardStore((s) => s.containerLayoutCache); + const setContainerLayout = useDashboardStore((s) => s.setContainerLayout); + const bumpStage1Tick = useDashboardStore((s) => s.bumpStage1Tick); + + const stage2Containers = topology.containers; + const stage2Intra = topology.intraContainer; + + useEffect(() => { + if (stage2Containers.length === 0) return; + const toCompute = [...expandedContainers].filter( + (id) => !containerLayoutCache.has(id), + ); + if (toCompute.length === 0) return; + + let cancelled = false; + // Capture sizeMemory BEFORE any setContainerLayout writes so the + // deviation check below compares against the size Stage 1 actually + // used. (setContainerLayout overwrites containerSizeMemory with the + // new actualSize.) + const sizeMemoryBefore = useDashboardStore.getState().containerSizeMemory; + Promise.all( + toCompute.map(async (containerId) => { + const c = stage2Containers.find((cc) => cc.id === containerId); + if (!c) return null; + const childIds = new Set(c.nodeIds); + const childEdges = stage2Intra.filter( + (e) => childIds.has(e.source) && childIds.has(e.target), + ); + const stage2Children: ElkChild[] = c.nodeIds.map((id) => ({ + id, + width: NODE_WIDTH, + height: NODE_HEIGHT, + })); + const stage2Edges: ElkEdge[] = childEdges.map((e, i) => ({ + id: `${containerId}-e${i}`, + sources: [e.source], + targets: [e.target], + })); + const stage2Input: ElkInput = { + id: containerId, + layoutOptions: ELK_DEFAULT_LAYOUT_OPTIONS, + children: stage2Children, + edges: stage2Edges, + }; + try { + const { positioned, issues } = await applyElkLayout(stage2Input, { + strict: import.meta.env.DEV, + }); + if (issues.length > 0) { + // Funnel into store so WarningBanner surfaces them. + useDashboardStore.getState().appendLayoutIssues(issues); + } + const childPositions = new Map(); + let maxX = 0; + let maxY = 0; + for (const ch of positioned.children ?? []) { + const x = ch.x ?? 0; + const y = ch.y ?? 0; + const w = ch.width ?? NODE_WIDTH; + const h = ch.height ?? NODE_HEIGHT; + childPositions.set(ch.id, { x, y }); + if (x + w > maxX) maxX = x + w; + if (y + h > maxY) maxY = y + h; + } + // Pad for container chrome (header + border) + const actualSize = { width: maxX + 40, height: maxY + 60 }; + + // Recompute the Stage 1 estimate for this container using the + // SAME formula `built` used so we know what Stage 1 actually + // routed against. (Memo if present, else sqrt-clamped estimate.) + const memo = sizeMemoryBefore.get(containerId); + const STAGE1_MAX_W = 800; + const STAGE1_MAX_H = 600; + const stage1Width = memo?.width + ?? Math.min( + STAGE1_MAX_W, + Math.max(NODE_WIDTH, Math.sqrt(c.nodeIds.length) * NODE_WIDTH * 1.2), + ); + const stage1Height = memo?.height + ?? Math.min( + STAGE1_MAX_H, + Math.max(NODE_HEIGHT, Math.sqrt(c.nodeIds.length) * NODE_HEIGHT * 1.2), + ); + const dw = Math.abs(actualSize.width - stage1Width) / stage1Width; + const dh = Math.abs(actualSize.height - stage1Height) / stage1Height; + const deviated = dw > 0.2 || dh > 0.2; + return { containerId, childPositions, actualSize, deviated }; + } catch (err) { + console.error(`[Stage 2 ${containerId}] layout failed:`, err); + return null; + } + }), + ).then((results) => { + if (cancelled) return; + let anyDeviated = false; + for (const r of results) { + if (!r) continue; + setContainerLayout(r.containerId, r.childPositions, r.actualSize); + if (r.deviated) anyDeviated = true; + } + // Only bump if at least one container's actual size differed >20% + // from its Stage 1 estimate. Bumping unconditionally would loop: + // Stage 1 → Stage 2 → bump → Stage 1 → ... With the >20% gate, + // after the re-layout containerSizeMemory holds the actual size, so + // the next Stage 2 sees a 0% deviation and the loop terminates. + if (anyDeviated) bumpStage1Tick(); + }); + + return () => { + cancelled = true; + }; + }, [ + expandedContainers, + stage2Containers, + stage2Intra, + containerLayoutCache, + setContainerLayout, + bumpStage1Tick, + ]); + + return { ...topology, layoutStatus }; +} + +/** + * Build a CustomFlowNode from a GraphNode. Mirrors the shape produced by + * the inline ungroupedFlowNodes builder in useLayerDetailTopology — kept + * symmetric so Stage 2 lazy-expanded children look the same as ungrouped + * file nodes. + */ +function buildCustomFlowNode( + node: GraphNode, + opts: { + diffMode: boolean; + changedNodeIds: Set; + affectedNodeIds: Set; + onNodeClick: (nodeId: string) => void; + }, +): CustomFlowNode { + return { + id: node.id, + type: "custom" as const, + position: { x: 0, y: 0 }, + data: { + label: node.name ?? node.filePath?.split("/").pop() ?? node.id, + nodeType: node.type, + summary: node.summary, + complexity: node.complexity, + isHighlighted: false, + searchScore: undefined, + isSelected: false, + isTourHighlighted: false, + isDiffChanged: opts.diffMode && opts.changedNodeIds.has(node.id), + isDiffAffected: opts.diffMode && opts.affectedNodeIds.has(node.id), + isDiffFaded: + opts.diffMode && + !opts.changedNodeIds.has(node.id) && + !opts.affectedNodeIds.has(node.id), + isNeighbor: false, + isSelectionFaded: false, + onNodeClick: opts.onNodeClick, + }, + }; } /** * Visual overlay: cheap O(n) pass that applies selection, search, and tour - * state onto already-positioned nodes. Avoids triggering dagre relayout. + * state onto already-positioned nodes. Avoids triggering ELK relayout. + * + * Container atoms whose children are focused or selected light up via + * `isFocusedViaChild` — neighbor sets are mapped through `nodeToContainer` + * so collapsed containers still show the relationship. + * + * Also folds in Stage 2 outputs: + * - Expanded children are emitted as React Flow children (`parentId` + + * `extent: "parent"`) using cached positions from `containerLayoutCache`. + * - Aggregated edges incident to an expanded container are replaced with + * the underlying file→file edges from `topo.filteredEdges`. */ function useLayerDetailGraph() { const selectedNodeId = useDashboardStore((s) => s.selectedNodeId); const searchResults = useDashboardStore((s) => s.searchResults); const tourHighlightedNodeIds = useDashboardStore((s) => s.tourHighlightedNodeIds); + const expandedContainers = useDashboardStore((s) => s.expandedContainers); + const containerLayoutCache = useDashboardStore((s) => s.containerLayoutCache); + const diffMode = useDashboardStore((s) => s.diffMode); + const changedNodeIds = useDashboardStore((s) => s.changedNodeIds); + const affectedNodeIds = useDashboardStore((s) => s.affectedNodeIds); + const focusNodeId = useDashboardStore((s) => s.focusNodeId); + const selectNode = useDashboardStore((s) => s.selectNode); + + const handleNodeSelect = useCallback( + (nodeId: string) => selectNode(nodeId), + [selectNode], + ); const topo = useLayerDetailTopology(); + // Build expanded child nodes from the layout cache for any expanded + // container whose layout has been computed. Collapsed containers + // contribute zero children (gating on `expandedContainers`). + const expandedChildNodes = useMemo(() => { + if (expandedContainers.size === 0) return []; + const out: Node[] = []; + const nodeById = new Map(topo.filteredNodes.map((n) => [n.id, n])); + for (const containerId of expandedContainers) { + const cache = containerLayoutCache.get(containerId); + const container = topo.containers.find((c) => c.id === containerId); + if (!cache || !container) continue; + for (const childId of container.nodeIds) { + const node = nodeById.get(childId); + const pos = cache.childPositions.get(childId); + if (!node || !pos) continue; + const base = buildCustomFlowNode(node, { + diffMode, + changedNodeIds, + affectedNodeIds, + onNodeClick: handleNodeSelect, + }); + out.push({ + ...base, + parentId: containerId, + extent: "parent", + position: pos, + } as Node); + } + } + return out; + }, [ + expandedContainers, + containerLayoutCache, + topo.containers, + topo.filteredNodes, + diffMode, + changedNodeIds, + affectedNodeIds, + handleNodeSelect, + ]); + + // ── Container visual overlay flags (Task 14) ──────────────────────────── + // O(searchResults) — bucket search hits by container atom. + const searchHitsByContainer = useMemo(() => { + const m = new Map(); + if (searchResults.length === 0) return m; + for (const r of searchResults) { + const cid = topo.nodeToContainer.get(r.nodeId); + // Only count when the file is actually inside a container (cid !== file id). + if (!cid || cid === r.nodeId) continue; + m.set(cid, (m.get(cid) ?? 0) + 1); + } + return m; + }, [searchResults, topo.nodeToContainer]); + + // O(changed + affected) — set of container atoms touched by the diff. + const diffContainers = useMemo(() => { + const s = new Set(); + if (!diffMode) return s; + for (const id of changedNodeIds) { + const cid = topo.nodeToContainer.get(id); + if (cid && cid !== id) s.add(cid); + } + for (const id of affectedNodeIds) { + const cid = topo.nodeToContainer.get(id); + if (cid && cid !== id) s.add(cid); + } + return s; + }, [diffMode, changedNodeIds, affectedNodeIds, topo.nodeToContainer]); + + // O(filteredEdges) — focus node's container + 1-hop neighbor containers. + const focusContainerIds = useMemo(() => { + const s = new Set(); + if (!focusNodeId) return s; + const focusCid = topo.nodeToContainer.get(focusNodeId); + if (focusCid && focusCid !== focusNodeId) s.add(focusCid); + for (const e of topo.filteredEdges) { + if (e.source === focusNodeId) { + const cid = topo.nodeToContainer.get(e.target); + if (cid && cid !== e.target) s.add(cid); + } else if (e.target === focusNodeId) { + const cid = topo.nodeToContainer.get(e.source); + if (cid && cid !== e.source) s.add(cid); + } + } + return s; + }, [focusNodeId, topo.filteredEdges, topo.nodeToContainer]); + + // Selection neighbor highlighting for containers: when the selected node + // (or one of its neighbors) lives inside a container, that container atom + // should pop visually so the user can see where the relationship lives + // even when the container is collapsed. We piggyback on `isFocusedViaChild` + // since ContainerNode already styles that flag (gold border emphasis). + const selectionContainerIds = useMemo(() => { + const s = new Set(); + if (!selectedNodeId) return s; + const selCid = topo.nodeToContainer.get(selectedNodeId); + if (selCid && selCid !== selectedNodeId) s.add(selCid); + for (const e of topo.filteredEdges) { + if (e.source === selectedNodeId) { + const cid = topo.nodeToContainer.get(e.target); + if (cid && cid !== e.target) s.add(cid); + } else if (e.target === selectedNodeId) { + const cid = topo.nodeToContainer.get(e.source); + if (cid && cid !== e.source) s.add(cid); + } + } + return s; + }, [selectedNodeId, topo.filteredEdges, topo.nodeToContainer]); + + // Combine Stage 1 nodes with Stage 2 expanded children, then apply the + // visual overlay (selection, search, tour) to every CustomFlowNode in + // the combined set. Container nodes get their own overlay branch. const nodes = useMemo(() => { + const combined: Node[] = [...topo.nodes, ...expandedChildNodes]; + const searchMap = new Map(searchResults.map((r) => [r.nodeId, r.score])); const tourSet = new Set(tourHighlightedNodeIds); @@ -442,10 +1025,46 @@ function useLayerDetailGraph() { neighborNodeIds.add(selectedNodeId); } - return topo.nodes.map((node) => { - // Skip portal nodes — they have no CustomNodeData + return combined.map((node) => { + // Portal nodes have no overlay state. if (node.type === "portal") return node; + // Container nodes: apply container-specific visual flags. + if (node.type === "container") { + const cid = String(node.id); + const data = node.data as ContainerNodeData; + const isExpanded = expandedContainers.has(cid); + const rawHits = searchHitsByContainer.get(cid) ?? 0; + const hasSearchHits = rawHits > 0; + const searchHitCount = hasSearchHits ? rawHits : undefined; + const isDiffAffected = diffContainers.has(cid); + const isFocusedViaChild = + focusContainerIds.has(cid) || selectionContainerIds.has(cid); + + // Skip creating a new object if nothing changed. + if ( + data.isExpanded === isExpanded && + data.hasSearchHits === hasSearchHits && + data.searchHitCount === searchHitCount && + data.isDiffAffected === isDiffAffected && + data.isFocusedViaChild === isFocusedViaChild + ) { + return node; + } + + return { + ...node, + data: { + ...data, + isExpanded, + hasSearchHits, + searchHitCount, + isDiffAffected, + isFocusedViaChild, + }, + }; + } + const searchScore = searchMap.get(node.id); const isHighlighted = searchScore !== undefined; const isSelected = selectedNodeId === node.id; @@ -470,13 +1089,103 @@ function useLayerDetailGraph() { return { ...node, data: { ...data, isHighlighted, searchScore, isSelected, isTourHighlighted, isNeighbor, isSelectionFaded } }; }); - }, [topo.nodes, topo.filteredEdges, selectedNodeId, searchResults, tourHighlightedNodeIds]); + }, [ + topo.nodes, + expandedChildNodes, + topo.filteredEdges, + selectedNodeId, + searchResults, + tourHighlightedNodeIds, + expandedContainers, + searchHitsByContainer, + diffContainers, + focusContainerIds, + selectionContainerIds, + ]); + + // Replace aggregated edges incident to an expanded container with the + // underlying file→file edges from filteredEdges. Aggregated edges where + // neither endpoint is expanded pass through unchanged. Also surface + // intra-container edges for each expanded container — these are stored + // separately on topo.intraContainer (Stage 1 doesn't render them since + // the children aren't visible there). + // + // Important: when only one side of an aggregated edge is expanded, the + // collapsed side MUST keep its container-atom id as the endpoint — + // otherwise React Flow would receive edges referencing file ids that + // aren't rendered (the collapsed container's children don't exist as + // nodes), and the edges would silently disappear. Multiple file→file + // edges that collapse to the same (collapsed-atom → expanded-file) + // pair are deduped. + const expandedEdges = useMemo(() => { + if (expandedContainers.size === 0) return topo.edges; + + const out: Edge[] = []; + const seen = new Set(); + for (const e of topo.edges) { + const srcAtom = String(e.source); + const tgtAtom = String(e.target); + const srcExpanded = expandedContainers.has(srcAtom); + const tgtExpanded = expandedContainers.has(tgtAtom); + if (!srcExpanded && !tgtExpanded) { + out.push(e); + continue; + } + const matching = topo.filteredEdges.filter((fe) => { + const fsc = topo.nodeToContainer.get(fe.source); + const ftc = topo.nodeToContainer.get(fe.target); + return fsc === srcAtom && ftc === tgtAtom; + }); + for (const m of matching) { + const realSrc = srcExpanded ? m.source : srcAtom; + const realTgt = tgtExpanded ? m.target : tgtAtom; + const key = `${realSrc}|${realTgt}|${m.type}`; + if (seen.has(key)) continue; + seen.add(key); + out.push({ + id: `inflated-${key}`, + source: realSrc, + target: realTgt, + label: m.type, + style: { stroke: "rgba(212,165,116,0.5)", strokeWidth: 1.5 }, + labelStyle: { fill: "#a39787", fontSize: 10 }, + }); + } + } + // Add intra-container edges for each expanded container so the user + // can see the wiring between sibling files inside an expanded folder. + for (const e of topo.intraContainer) { + const cid = topo.nodeToContainer.get(e.source); + if (!cid || !expandedContainers.has(cid)) continue; + const key = `intra|${e.source}|${e.target}|${e.type}`; + if (seen.has(key)) continue; + seen.add(key); + out.push({ + id: key, + source: e.source, + target: e.target, + label: e.type, + style: { stroke: "rgba(212,165,116,0.5)", strokeWidth: 1.5 }, + labelStyle: { fill: "#a39787", fontSize: 10 }, + }); + } + return out; + }, [ + topo.edges, + topo.filteredEdges, + topo.intraContainer, + topo.nodeToContainer, + expandedContainers, + ]); const edges = useMemo(() => { - if (!selectedNodeId) return topo.edges; + // Compose: Stage 1 / inflated edges, plus portal edges (Stage 1 sources + // them off container atoms — re-sourcing on expand is deferred). + const base = [...expandedEdges, ...topo.portalEdges]; + if (!selectedNodeId) return base; // Apply selection-based edge styling on top of topology edges - return topo.edges.map((edge) => { + return base.map((edge) => { const isSelectedEdge = edge.source === selectedNodeId || edge.target === selectedNodeId; // Don't restyle diff-impacted or portal edges if ((edge.style as Record)?.strokeDasharray) return edge; @@ -487,9 +1196,22 @@ function useLayerDetailGraph() { // Fade unrelated edges return { ...edge, animated: false, style: { stroke: "rgba(212,165,116,0.08)", strokeWidth: 1 }, labelStyle: { fill: "rgba(163,151,135,0.2)", fontSize: 10 } }; }); - }, [topo.edges, selectedNodeId]); + }, [expandedEdges, topo.portalEdges, selectedNodeId]); - return { nodes, edges }; + // Expose container topology so the parent component can wire auto-expand + // triggers (focus, tour, zoom) without having to re-derive containers. + const containerIds = useMemo( + () => topo.containers.map((c) => c.id), + [topo.containers], + ); + + return { + nodes, + edges, + nodeToContainer: topo.nodeToContainer, + containerIds, + layoutStatus: topo.layoutStatus, + }; } // ── Main inner component (must be inside ReactFlowProvider) ──────────── @@ -503,18 +1225,27 @@ function GraphViewInner() { const focusNodeId = useDashboardStore((s) => s.focusNodeId); const setFocusNode = useDashboardStore((s) => s.setFocusNode); const setReactFlowInstance = useDashboardStore((s) => s.setReactFlowInstance); + const tourHighlightedNodeIds = useDashboardStore((s) => s.tourHighlightedNodeIds); + const expandContainer = useDashboardStore((s) => s.expandContainer); const { preset } = useTheme(); const overviewGraph = useOverviewGraph(); const detailGraph = useLayerDetailGraph(); - const { nodes: initialNodes, edges: initialEdges } = - navigationLevel === "overview" ? overviewGraph : detailGraph; + const { + nodes: initialNodes, + edges: initialEdges, + nodeToContainer, + containerIds, + layoutStatus, + } = navigationLevel === "overview" + ? { ...overviewGraph, nodeToContainer: undefined, containerIds: undefined } + : detailGraph; const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); - const { fitView } = useReactFlow(); + const { fitView, getViewport } = useReactFlow(); useEffect(() => { setNodes(initialNodes); @@ -524,13 +1255,93 @@ function GraphViewInner() { setEdges(initialEdges); }, [initialEdges, setEdges]); - // Fit view on level/layer transitions + // Fit view on level/layer transitions. Layout is async (~125ms+ for + // medium layers), so a fixed-delay timer can fire before positions + // arrive and leave the viewport on the previous layer. Instead, mark + // a pending fit on navigation and run it when nodes actually populate. + const pendingFitRef = useRef(false); useEffect(() => { - const timer = setTimeout(() => { + pendingFitRef.current = true; + }, [navigationLevel, activeLayerId]); + + useEffect(() => { + if (!pendingFitRef.current) return; + if (nodes.length === 0) return; + pendingFitRef.current = false; + // One frame so React Flow has positioned the nodes before fit. + const raf = requestAnimationFrame(() => { fitView({ duration: 400, padding: 0.2 }); - }, 50); - return () => clearTimeout(timer); - }, [navigationLevel, activeLayerId, fitView]); + }); + return () => cancelAnimationFrame(raf); + }, [nodes, fitView]); + + // ── Auto-expand triggers (Task 13) ───────────────────────────────────── + // Only meaningful in layer-detail; in overview mode there are no + // containers so all three effects no-op. + + // Focus: when focusNodeId resolves to a node inside a container, expand it. + // Reading expandContainer is stable (Zustand setter); intentionally omitting + // expandedContainers from deps so focus changes are the only trigger. + useEffect(() => { + if (!focusNodeId || !nodeToContainer) return; + const cid = nodeToContainer.get(focusNodeId); + // Self-maps mean ungrouped nodes have cid === focusNodeId — skip those. + if (cid && cid !== focusNodeId) expandContainer(cid); + }, [focusNodeId, nodeToContainer, expandContainer]); + + // Tour: expand containers for every tour-highlighted node so the tour + // can fitView onto real nodes rather than collapsed atoms. + useEffect(() => { + if (tourHighlightedNodeIds.length === 0 || !nodeToContainer) return; + for (const nid of tourHighlightedNodeIds) { + const cid = nodeToContainer.get(nid); + if (cid && cid !== nid) expandContainer(cid); + } + }, [tourHighlightedNodeIds, nodeToContainer, expandContainer]); + + // Zoom: debounced auto-expand when the user has zoomed in past 1.0. + // Hysteresis: zoom < 0.6 = no auto-expand AND no auto-collapse (v1, the + // user collapses manually). The handler reads expandedContainers via + // getState() inside the timeout to avoid re-creating on every expand. + const zoomTimeoutRef = useRef(null); + // Only auto-expand on user-driven zoom-INs. Skip programmatic moves + // (e.g. fitView at layer entry, which would otherwise cascade-expand + // every container the moment the layer paints) and skip pans/zoom-outs + // (so a user who manually collapses a container at zoom > 1 can pan + // around without seeing it pop back open). + const prevZoomRef = useRef(null); + const onMove = useCallback( + (event: MouseEvent | TouchEvent | null) => { + if (event === null) return; // programmatic — skip + if (!containerIds || containerIds.length === 0) return; + if (zoomTimeoutRef.current !== null) { + window.clearTimeout(zoomTimeoutRef.current); + } + zoomTimeoutRef.current = window.setTimeout(() => { + const vp = getViewport(); + const prev = prevZoomRef.current; + prevZoomRef.current = vp.zoom; + if (vp.zoom <= 1.0) return; + // Only fire when zoom actually increased — pan and zoom-out are no-ops. + if (prev !== null && vp.zoom <= prev) return; + const expanded = useDashboardStore.getState().expandedContainers; + for (const cid of containerIds) { + if (!expanded.has(cid)) expandContainer(cid); + } + }, 200); + }, + [containerIds, getViewport, expandContainer], + ); + + // Clear any pending zoom timer on unmount or when handler identity changes. + useEffect(() => { + return () => { + if (zoomTimeoutRef.current !== null) { + window.clearTimeout(zoomTimeoutRef.current); + zoomTimeoutRef.current = null; + } + }; + }, [onMove]); const onNodeClick = useCallback( (_: React.MouseEvent, node: { id: string }) => { @@ -579,6 +1390,7 @@ function GraphViewInner() { onEdgesChange={onEdgesChange} onNodeClick={onNodeClick} onPaneClick={onPaneClick} + onMove={navigationLevel === "layer-detail" ? onMove : undefined} onInit={setReactFlowInstance} nodeTypes={nodeTypes} nodesDraggable={false} @@ -602,6 +1414,22 @@ function GraphViewInner() {
+ {layoutStatus === "computing" && ( +
+ Computing layout… +
+ )} ); } diff --git a/understand-anything-plugin/packages/dashboard/src/components/WarningBanner.tsx b/understand-anything-plugin/packages/dashboard/src/components/WarningBanner.tsx index 753a2c5..8fd7c74 100644 --- a/understand-anything-plugin/packages/dashboard/src/components/WarningBanner.tsx +++ b/understand-anything-plugin/packages/dashboard/src/components/WarningBanner.tsx @@ -6,17 +6,27 @@ interface WarningBannerProps { } function buildCopyText(issues: GraphIssue[]): string { - const lines = [ - "The following issues were found in your knowledge-graph.json.", - "These are LLM generation errors — not a system bug.", - "You can ask your agent to fix these specific issues in the knowledge-graph.json file:", - "", - ]; + const hasFatal = issues.some((i) => i.level === "fatal"); + // Fatal issues are dashboard rendering bugs (e.g. ELK layout failures), not + // LLM generation errors — route the user to file a bug report instead of + // asking their agent to "fix" the knowledge-graph.json. + const lines = hasFatal + ? [ + "Some of these issues look like dashboard rendering bugs.", + "Please file an issue at github.com/Lum1104/Understand-Anything/issues with the text below.", + "", + ] + : [ + "The following issues were found in your knowledge-graph.json.", + "These are LLM generation errors — not a system bug.", + "You can ask your agent to fix these specific issues in the knowledge-graph.json file:", + "", + ]; - // Auto-corrected first, then dropped + // Show fatal first (most actionable for bug reports), then dropped, then auto-corrected. const sorted = [...issues].sort((a, b) => { - const order: Record = { "auto-corrected": 0, dropped: 1, fatal: 2 }; - return (order[a.level] ?? 2) - (order[b.level] ?? 2); + const order: Record = { fatal: 0, dropped: 1, "auto-corrected": 2 }; + return (order[a.level] ?? 3) - (order[b.level] ?? 3); }); for (const issue of sorted) { @@ -36,18 +46,25 @@ export default function WarningBanner({ issues }: WarningBannerProps) { const [expanded, setExpanded] = useState(false); const [copied, setCopied] = useState(false); + const fatal = issues.filter((i) => i.level === "fatal"); const autoCorrected = issues.filter((i) => i.level === "auto-corrected"); const dropped = issues.filter((i) => i.level === "dropped"); + const hasFatal = fatal.length > 0; // Build summary text — only mention counts > 0 const parts: string[] = []; + if (fatal.length > 0) { + parts.push(`${fatal.length} fatal error${fatal.length !== 1 ? "s" : ""}`); + } if (autoCorrected.length > 0) { parts.push(`${autoCorrected.length} auto-correction${autoCorrected.length !== 1 ? "s" : ""}`); } if (dropped.length > 0) { parts.push(`${dropped.length} dropped item${dropped.length !== 1 ? "s" : ""}`); } - const summary = `Knowledge graph loaded with ${parts.join(" and ")}`; + const summary = hasFatal + ? `Dashboard hit ${parts.join(", ")}` + : `Knowledge graph loaded with ${parts.join(" and ")}`; const handleCopy = useCallback(async () => { const text = buildCopyText(issues); @@ -62,18 +79,36 @@ export default function WarningBanner({ issues }: WarningBannerProps) { if (issues.length === 0) return null; + // Fatal issues escalate the banner from amber (warning) to red (error). + const containerClasses = hasFatal + ? "bg-red-900/25 border-b border-red-700 text-red-200 text-sm" + : "bg-amber-900/20 border-b border-amber-700 text-amber-200 text-sm"; + const hoverClasses = hasFatal + ? "hover:bg-red-900/15" + : "hover:bg-amber-900/10"; + const iconClasses = hasFatal ? "text-red-400" : "text-amber-400"; + const hintClasses = hasFatal ? "text-red-400/60" : "text-amber-400/60"; + const dividerClasses = hasFatal ? "border-red-700/50" : "border-amber-700/50"; + const footerTextClasses = hasFatal ? "text-red-200/70" : "text-amber-200/60"; + const buttonClasses = hasFatal + ? "bg-red-800/40 text-red-200 hover:bg-red-800/60" + : "bg-amber-800/40 text-amber-200 hover:bg-amber-800/60"; + const footerCopy = hasFatal + ? "Copy these issues and file a bug report on GitHub" + : "Copy these issues and ask your agent to fix them in knowledge-graph.json"; + return ( -
+
{/* Collapsed summary row */} @@ -115,9 +150,36 @@ export default function WarningBanner({ issues }: WarningBannerProps) {
{/* Issue list */}
+ {/* Fatal issues — top of list, red, most prominent */} + {fatal.length > 0 && ( +
+

+ Fatal ({fatal.length}) +

+ {fatal.map((issue, i) => ( +
+ + + + + + {issue.message} +
+ ))} +
+ )} + {/* Auto-corrected issues */} {autoCorrected.length > 0 && ( -
+
0 ? "mt-2" : ""}>

Auto-corrected ({autoCorrected.length})

@@ -136,7 +198,7 @@ export default function WarningBanner({ issues }: WarningBannerProps) { {/* Dropped issues */} {dropped.length > 0 && ( -
0 ? "mt-2" : ""}> +
0 || autoCorrected.length > 0 ? "mt-2" : ""}>

Dropped ({dropped.length})

@@ -155,14 +217,12 @@ export default function WarningBanner({ issues }: WarningBannerProps) {
{/* Footer with copy button and actionable message */} -
-

- Copy these issues and ask your agent to fix them in knowledge-graph.json -

+
+

{footerCopy}