mirror of
https://github.com/Egonex-AI/Understand-Anything.git
synced 2026-06-22 10:58:03 +08:00
feat(dashboard): elk-layout repair pipeline + applyElkLayout
This commit is contained in:
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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<string, string>;
|
||||
}
|
||||
|
||||
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<string>();
|
||||
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<string>();
|
||||
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<string, string>();
|
||||
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<string>();
|
||||
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<ElkLayoutResult> {
|
||||
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] };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user