diff --git a/understand-anything-plugin/packages/dashboard/src/utils/__tests__/elk-layout.test.ts b/understand-anything-plugin/packages/dashboard/src/utils/__tests__/elk-layout.test.ts new file mode 100644 index 0000000..bd8b772 --- /dev/null +++ b/understand-anything-plugin/packages/dashboard/src/utils/__tests__/elk-layout.test.ts @@ -0,0 +1,72 @@ +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/); + }); +}); diff --git a/understand-anything-plugin/packages/dashboard/src/utils/elk-layout.ts b/understand-anything-plugin/packages/dashboard/src/utils/elk-layout.ts new file mode 100644 index 0000000..d5181f3 --- /dev/null +++ b/understand-anything-plugin/packages/dashboard/src/utils/elk-layout.ts @@ -0,0 +1,237 @@ +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"], + category: string, + message: string, +): GraphIssue { + return { level, category, 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", + "elk-missing-dimensions", + `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", + "elk-duplicate-id", + `Removed ${dupesRemoved} duplicate child id(s).`, + ); + issues.push(issue); + maybeThrow(strict, issue); + } + + // 3. dropOrphanChildren — children whose parentId references nonexistent parent + 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", + "elk-orphan-parent", + `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", + "elk-orphan-edge", + `Dropped ${orphanEdges} edge(s) referencing nonexistent nodes.`, + ); + issues.push(issue); + maybeThrow(strict, issue); + } + + // 5. dropCircularContainment + 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", + "elk-containment-cycle", + `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", + category: "elk-layout-failed", + 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] }; + } +}