feat: add deterministic Diagram IR v2 authoring

This commit is contained in:
2026-09-03 10:05:29 +00:00
parent 305006c8cb
commit 50a07b90a9
31 changed files with 2108 additions and 98 deletions
@@ -0,0 +1,108 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { DiagramPage, DiagramPoint, DiagramGeometry } from "../../model/diagram-ir.js";
import { routePageEdges } from "./orthogonal-router.js";
function segmentCrossesRect(a: DiagramPoint, b: DiagramPoint, rect: DiagramGeometry): boolean {
if (a.y === b.y) {
return a.y > rect.y && a.y < rect.y + rect.height && Math.max(Math.min(a.x, b.x), rect.x) < Math.min(Math.max(a.x, b.x), rect.x + rect.width);
}
if (a.x === b.x) {
return a.x > rect.x && a.x < rect.x + rect.width && Math.max(Math.min(a.y, b.y), rect.y) < Math.min(Math.max(a.y, b.y), rect.y + rect.height);
}
return true;
}
function segmentAlongRectEdge(a: DiagramPoint, b: DiagramPoint, rect: DiagramGeometry): boolean {
const verticalBoundary = a.x === b.x && (a.x === rect.x || a.x === rect.x + rect.width) && Math.max(Math.min(a.y, b.y), rect.y) < Math.min(Math.max(a.y, b.y), rect.y + rect.height);
const horizontalBoundary = a.y === b.y && (a.y === rect.y || a.y === rect.y + rect.height) && Math.max(Math.min(a.x, b.x), rect.x) < Math.min(Math.max(a.x, b.x), rect.x + rect.width);
return verticalBoundary || horizontalBoundary;
}
test("orthogonal routing emits deterministic waypoints around blocking nodes", () => {
const obstacle: DiagramGeometry = { x: 220, y: 60, width: 120, height: 100 };
const page: DiagramPage = {
id: "route",
title: "Route",
layout: { type: "manual", gridSize: 10 },
nodes: [
{ id: "source", label: "Source", geometry: { x: 40, y: 80, width: 120, height: 60 } },
{ id: "blocker", label: "Blocker", geometry: obstacle },
{ id: "target", label: "Target", geometry: { x: 420, y: 80, width: 120, height: 60 } },
],
edges: [{ id: "flow", source: "source", target: "target" }],
};
const first = routePageEdges(page);
const second = routePageEdges(page);
assert.deepEqual(first, second);
const waypoints = first.edges[0].waypoints!;
assert.ok(waypoints.length >= 2);
const path = [{ x: 160, y: 110 }, ...waypoints, { x: 420, y: 110 }];
for (let index = 0; index < path.length - 1; index += 1) {
assert.equal(segmentCrossesRect(path[index], path[index + 1], obstacle), false);
}
assert.match(first.edges[0].style ?? "", /edgeStyle=none/);
});
test("self-loop routing produces a visible external loop", () => {
const page: DiagramPage = {
id: "loop",
title: "Loop",
layout: { type: "manual", gridSize: 10 },
nodes: [{ id: "node", label: "Node", geometry: { x: 100, y: 100, width: 120, height: 60 } }],
edges: [{ id: "loop-edge", source: "node", target: "node" }],
};
const edge = routePageEdges(page).edges[0];
assert.ok((edge.waypoints?.length ?? 0) >= 3);
assert.ok(new Set(edge.waypoints!.map((point) => `${point.x},${point.y}`)).size >= 3);
assert.match(edge.style ?? "", /edgeStyle=none/);
});
test("routing leaves endpoint boundaries immediately instead of following their edges", () => {
const source: DiagramGeometry = { x: 40, y: 40, width: 120, height: 80 };
const target: DiagramGeometry = { x: 300, y: 180, width: 120, height: 80 };
const page: DiagramPage = {
id: "diagonal",
title: "Diagonal",
layout: { type: "manual", gridSize: 10 },
nodes: [
{ id: "source", label: "Source", geometry: source },
{ id: "target", label: "Target", geometry: target },
],
edges: [{ id: "flow", source: "source", target: "target" }],
};
const routed = routePageEdges(page);
const points = [{ x: 160, y: 80 }, ...routed.edges[0].waypoints!, { x: 300, y: 220 }];
for (let index = 0; index < points.length - 1; index += 1) {
assert.equal(segmentAlongRectEdge(points[index], points[index + 1], source), false);
assert.equal(segmentAlongRectEdge(points[index], points[index + 1], target), false);
}
});
test("routing resolves nested node geometry to page coordinates", () => {
const page: DiagramPage = {
id: "nested",
title: "Nested",
layout: { type: "manual", gridSize: 10 },
nodes: [
{ id: "zone", label: "Zone", kind: "container", geometry: { x: 100, y: 100, width: 240, height: 200 } },
{ id: "source", label: "Source", parentId: "zone", geometry: { x: 40, y: 60, width: 100, height: 60 } },
{ id: "blocker", label: "Blocker", geometry: { x: 360, y: 140, width: 100, height: 100 } },
{ id: "target", label: "Target", geometry: { x: 500, y: 160, width: 100, height: 60 } },
],
edges: [{ id: "flow", source: "source", target: "target" }],
};
const routed = routePageEdges(page);
const waypoints = routed.edges[0].waypoints!;
const absoluteSourceEdge = { x: 240, y: 190 };
const absoluteTargetEdge = { x: 500, y: 190 };
const blocker = page.nodes[2].geometry!;
const path = [absoluteSourceEdge, ...waypoints, absoluteTargetEdge];
assert.ok(waypoints.length >= 2);
for (let index = 0; index < path.length - 1; index += 1) {
assert.equal(segmentCrossesRect(path[index], path[index + 1], blocker), false);
}
});
@@ -0,0 +1,179 @@
import type { DiagramEdge, DiagramGeometry, DiagramPage, DiagramPoint } from "../../model/diagram-ir.js";
interface Obstacle extends DiagramGeometry {
id: string;
}
function center(geometry: DiagramGeometry): DiagramPoint {
return { x: geometry.x + geometry.width / 2, y: geometry.y + geometry.height / 2 };
}
function endpointPair(source: DiagramGeometry, target: DiagramGeometry): { source: DiagramPoint; target: DiagramPoint } {
const a = center(source);
const b = center(target);
const dx = b.x - a.x;
const dy = b.y - a.y;
if (Math.abs(dx) >= Math.abs(dy)) {
return dx >= 0
? { source: { x: source.x + source.width, y: a.y }, target: { x: target.x, y: b.y } }
: { source: { x: source.x, y: a.y }, target: { x: target.x + target.width, y: b.y } };
}
return dy >= 0
? { source: { x: a.x, y: source.y + source.height }, target: { x: b.x, y: target.y } }
: { source: { x: a.x, y: source.y }, target: { x: b.x, y: target.y + target.height } };
}
function outwardStub(point: DiagramPoint, geometry: DiagramGeometry, distance: number): DiagramPoint {
const middle = center(geometry);
return {
x: point.x + Math.sign(point.x - middle.x) * distance,
y: point.y + Math.sign(point.y - middle.y) * distance,
};
}
function compactPath(path: DiagramPoint[]): DiagramPoint[] {
const deduped = path.filter((point, index) => index === 0 || point.x !== path[index - 1].x || point.y !== path[index - 1].y);
return deduped.filter((point, index) => {
if (index === 0 || index === deduped.length - 1) return true;
const previous = deduped[index - 1];
const next = deduped[index + 1];
return !((previous.x === point.x && point.x === next.x) || (previous.y === point.y && point.y === next.y));
});
}
function segmentIntersectsRect(a: DiagramPoint, b: DiagramPoint, rect: Obstacle, padding: number): boolean {
const left = rect.x - padding;
const right = rect.x + rect.width + padding;
const top = rect.y - padding;
const bottom = rect.y + rect.height + padding;
if (a.y === b.y) {
return a.y > top && a.y < bottom && Math.max(Math.min(a.x, b.x), left) < Math.min(Math.max(a.x, b.x), right);
}
if (a.x === b.x) {
return a.x > left && a.x < right && Math.max(Math.min(a.y, b.y), top) < Math.min(Math.max(a.y, b.y), bottom);
}
return true;
}
function pathClear(path: DiagramPoint[], obstacles: Obstacle[], padding: number): boolean {
for (let index = 0; index < path.length - 1; index += 1) {
for (const obstacle of obstacles) {
if (segmentIntersectsRect(path[index], path[index + 1], obstacle, padding)) return false;
}
}
return true;
}
function pathLength(path: DiagramPoint[]): number {
let total = 0;
for (let index = 0; index < path.length - 1; index += 1) {
total += Math.abs(path[index + 1].x - path[index].x) + Math.abs(path[index + 1].y - path[index].y);
}
return total;
}
function routedStyle(style?: string): string {
const base = style ?? "rounded=0;orthogonalLoop=1;html=1;";
if (/edgeStyle=[^;]*;?/.test(base)) return base.replace(/edgeStyle=[^;]*;?/, "edgeStyle=none;");
return `edgeStyle=none;${base}`;
}
function absoluteGeometryById(page: DiagramPage): Map<string, DiagramGeometry> {
const nodes = new Map(page.nodes.map((node) => [node.id, node]));
const result = new Map<string, DiagramGeometry>();
const resolving = new Set<string>();
const resolve = (id: string): DiagramGeometry | undefined => {
const cached = result.get(id);
if (cached) return cached;
const node = nodes.get(id);
if (!node?.geometry || resolving.has(id)) return undefined;
resolving.add(id);
const parent = node.parentId ? resolve(node.parentId) : undefined;
resolving.delete(id);
const absolute = {
...node.geometry,
x: node.geometry.x + (parent?.x ?? 0),
y: node.geometry.y + (parent?.y ?? 0),
};
result.set(id, absolute);
return absolute;
};
for (const id of nodes.keys()) resolve(id);
return result;
}
function ancestorIds(page: DiagramPage, id: string): Set<string> {
const nodes = new Map(page.nodes.map((node) => [node.id, node]));
const ancestors = new Set<string>();
let parentId = nodes.get(id)?.parentId;
while (parentId && !ancestors.has(parentId)) {
ancestors.add(parentId);
parentId = nodes.get(parentId)?.parentId;
}
return ancestors;
}
function routeEdge(edge: DiagramEdge, page: DiagramPage, geometries: Map<string, DiagramGeometry>, padding: number, grid: number): DiagramEdge {
if (edge.waypoints?.length) return { ...edge, waypoints: edge.waypoints.map((point) => ({ ...point })) };
const source = geometries.get(edge.source);
const target = geometries.get(edge.target);
if (!source || !target) return { ...edge };
const snap = (value: number) => Math.round(value / grid) * grid;
if (edge.source === edge.target) {
const right = snap(source.x + source.width + padding * 2);
const top = snap(source.y - padding * 2);
return {
...edge,
style: routedStyle(edge.style),
waypoints: [
{ x: right, y: snap(source.y + source.height / 2) },
{ x: right, y: top },
{ x: snap(source.x + source.width / 2), y: top },
],
};
}
const endpoints = endpointPair(source, target);
const endpointAncestors = new Set([...ancestorIds(page, edge.source), ...ancestorIds(page, edge.target)]);
const obstacles: Obstacle[] = page.nodes
.filter((node) => node.id !== edge.source && node.id !== edge.target && !endpointAncestors.has(node.id) && geometries.has(node.id))
.map((node) => ({ id: node.id, ...geometries.get(node.id)! }));
const sourceStub = outwardStub(endpoints.source, source, padding);
const targetStub = outwardStub(endpoints.target, target, padding);
const middleX = snap((sourceStub.x + targetStub.x) / 2);
const middleY = snap((sourceStub.y + targetStub.y) / 2);
const directCandidates: DiagramPoint[][] = endpoints.source.x === endpoints.target.x || endpoints.source.y === endpoints.target.y
? [[endpoints.source, endpoints.target]]
: [
[endpoints.source, sourceStub, { x: middleX, y: sourceStub.y }, { x: middleX, y: targetStub.y }, targetStub, endpoints.target],
[endpoints.source, sourceStub, { x: sourceStub.x, y: middleY }, { x: targetStub.x, y: middleY }, targetStub, endpoints.target],
];
const minY = Math.min(source.y, target.y, ...obstacles.map((item) => item.y));
const maxY = Math.max(source.y + source.height, target.y + target.height, ...obstacles.map((item) => item.y + item.height));
const minX = Math.min(source.x, target.x, ...obstacles.map((item) => item.x));
const maxX = Math.max(source.x + source.width, target.x + target.width, ...obstacles.map((item) => item.x + item.width));
const corridorCandidates: DiagramPoint[][] = [
[endpoints.source, sourceStub, { x: sourceStub.x, y: snap(minY - padding * 2) }, { x: targetStub.x, y: snap(minY - padding * 2) }, targetStub, endpoints.target],
[endpoints.source, sourceStub, { x: sourceStub.x, y: snap(maxY + padding * 2) }, { x: targetStub.x, y: snap(maxY + padding * 2) }, targetStub, endpoints.target],
[endpoints.source, sourceStub, { x: snap(minX - padding * 2), y: sourceStub.y }, { x: snap(minX - padding * 2), y: targetStub.y }, targetStub, endpoints.target],
[endpoints.source, sourceStub, { x: snap(maxX + padding * 2), y: sourceStub.y }, { x: snap(maxX + padding * 2), y: targetStub.y }, targetStub, endpoints.target],
];
const candidates = [...directCandidates, ...corridorCandidates]
.map(compactPath)
.filter((path) => pathClear(path, obstacles, padding))
.sort((a, b) => pathLength(a) - pathLength(b) || JSON.stringify(a).localeCompare(JSON.stringify(b)));
const selected = candidates[0];
if (!selected) return { ...edge };
return { ...edge, style: routedStyle(edge.style), waypoints: selected.slice(1, -1) };
}
export function routePageEdges(page: DiagramPage, padding = 10): DiagramPage {
const grid = page.layout?.gridSize ?? 10;
const geometries = absoluteGeometryById(page);
return {
...page,
nodes: page.nodes.map((node) => ({ ...node, geometry: node.geometry ? { ...node.geometry } : undefined })),
edges: page.edges.map((edge) => routeEdge(edge, page, geometries, padding, grid)),
};
}
@@ -0,0 +1,42 @@
import assert from "node:assert/strict";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import { decodeDiagramContent, parseAllPages } from "./parser.js";
const SIMPLE_MODEL = `<mxGraphModel pageWidth="800" pageHeight="600"><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="node" value="Node" vertex="1" parent="1"><mxGeometry x="40" y="40" width="120" height="80" as="geometry"/></mxCell></root></mxGraphModel>`;
function withDrawioFile(content: string, run: (filePath: string) => void): void {
const dir = mkdtempSync(join(tmpdir(), "drawio-parser-test-"));
const filePath = join(dir, "diagram.drawio");
try {
writeFileSync(filePath, content, "utf8");
run(filePath);
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
test("parseAllPages accepts an XML declaration and UTF-8 BOM", () => {
withDrawioFile(`\uFEFF<?xml version="1.0" encoding="UTF-8"?>\n${SIMPLE_MODEL}`, (filePath) => {
const pages = parseAllPages(filePath);
assert.equal(pages.length, 1);
assert.equal(pages[0].shapes.get("node")?.label, "Node");
});
});
test("decodeDiagramContent rejects compressed input beyond configured limits", () => {
const encoded = Buffer.from("compressed bytes").toString("base64");
assert.throws(
() => decodeDiagramContent(encoded, { maxCompressedBytes: 2, maxDecompressedBytes: 32 }),
/compressed diagram exceeds 2 bytes/,
);
});
test("parseAllPages rejects malformed mxfile wrappers and trailing junk", () => {
withDrawioFile(`<mxfile><diagram name="Page">${SIMPLE_MODEL}</diagram></mxfile>trailing`, (filePath) => {
assert.throws(() => parseAllPages(filePath), /invalid XML/i);
});
});
+72 -22
View File
@@ -11,7 +11,7 @@
*/
import { readFileSync } from "node:fs";
import { inflateRaw } from "pako";
import { Inflate } from "pako";
import { JSDOM } from "jsdom";
import {
GraphDataModel,
@@ -104,16 +104,55 @@ export interface ParsedDiagram extends ParsedPage {}
// Decode <mxfile> base64+deflate diagram content
// ---------------------------------------------------------------------------
function decodeDiagramContent(content: string): string {
try {
const decoded = Buffer.from(content.trim(), "base64");
const decompressed = inflateRaw(decoded);
const text = new TextDecoder("utf-8").decode(decompressed);
return decodeURIComponent(text);
} catch {
// Already plain XML
return content;
export interface DecodeLimits {
maxCompressedBytes: number;
maxDecompressedBytes: number;
}
export function normalizeDrawioDocument(content: string): string {
return content
.replace(/^\uFEFF/, "")
.replace(/^\s*<\?xml[^?]*\?>/i, "")
.trim();
}
const DEFAULT_DECODE_LIMITS: DecodeLimits = {
maxCompressedBytes: 16 * 1024 * 1024,
maxDecompressedBytes: 64 * 1024 * 1024,
};
export function decodeDiagramContent(
content: string,
limits: DecodeLimits = DEFAULT_DECODE_LIMITS,
): string {
const trimmed = content.trim();
if (/^<mxGraphModel/i.test(trimmed)) return trimmed;
const decoded = Buffer.from(trimmed, "base64");
if (decoded.byteLength > limits.maxCompressedBytes) {
throw new Error(`compressed diagram exceeds ${limits.maxCompressedBytes} bytes`);
}
const chunks: Uint8Array[] = [];
let totalBytes = 0;
const inflater = new Inflate({ raw: true });
inflater.onData = (chunk: Uint8Array) => {
totalBytes += chunk.byteLength;
if (totalBytes > limits.maxDecompressedBytes) {
throw new Error(`Decompressed diagram exceeds ${limits.maxDecompressedBytes} bytes`);
}
chunks.push(chunk);
};
inflater.push(decoded, true);
if (inflater.err) throw new Error(inflater.msg || "Unable to decompress diagram");
const decompressed = new Uint8Array(totalBytes);
let offset = 0;
for (const chunk of chunks) {
decompressed.set(chunk, offset);
offset += chunk.byteLength;
}
return decodeURIComponent(new TextDecoder("utf-8").decode(decompressed));
}
// ---------------------------------------------------------------------------
@@ -233,6 +272,7 @@ function cellToEdge(cell: Cell): Edge | null {
// ---------------------------------------------------------------------------
function parseGraphModelXml(graphModelXml: string, pageIndex: number, pageName: string): ParsedPage {
assertWellFormedXml(graphModelXml, "mxGraphModel");
const { pageWidth, pageHeight } = extractPageDimensions(graphModelXml);
const model = new GraphDataModel();
@@ -263,18 +303,28 @@ interface DiagramBlock {
content: string;
}
function extractDiagramBlocks(mxfileXml: string): DiagramBlock[] {
const blocks: DiagramBlock[] = [];
// Match each <diagram ...>...</diagram> element
const re = /<diagram([^>]*)>([\s\S]*?)<\/diagram>/gi;
let match: RegExpExecArray | null;
while ((match = re.exec(mxfileXml)) !== null) {
const attrs = match[1];
const content = match[2];
const name = getAttrFromTag(attrs, "name") || "";
blocks.push({ name, content: content.trim() });
function assertWellFormedXml(xml: string, expectedRoot: string): Document {
if (/<!DOCTYPE/i.test(xml)) throw new Error("Invalid XML: DOCTYPE is not allowed");
const document = new DOMParser().parseFromString(xml, "text/xml");
const parseError = document.querySelector("parsererror");
if (parseError || document.documentElement.tagName !== expectedRoot) {
throw new Error(`Invalid XML: expected complete <${expectedRoot}> document`);
}
return blocks;
return document;
}
function extractDiagramBlocks(mxfileXml: string): DiagramBlock[] {
const document = assertWellFormedXml(mxfileXml, "mxfile");
const serializer = new XMLSerializer();
return Array.from(document.documentElement.children)
.filter((element) => element.tagName === "diagram")
.map((element) => {
const graphModel = Array.from(element.children).find((child) => child.tagName === "mxGraphModel");
return {
name: element.getAttribute("name") ?? "",
content: graphModel ? serializer.serializeToString(graphModel) : (element.textContent ?? "").trim(),
};
});
}
// ---------------------------------------------------------------------------
@@ -286,7 +336,7 @@ function extractDiagramBlocks(mxfileXml: string): DiagramBlock[] {
* Returns one ParsedPage per diagram/tab found.
*/
export function parseAllPages(filePath: string): ParsedPage[] {
const raw = readFileSync(filePath, "utf-8").trim();
const raw = normalizeDrawioDocument(readFileSync(filePath, "utf-8"));
if (/^<mxfile/i.test(raw)) {
const blocks = extractDiagramBlocks(raw);
@@ -0,0 +1,144 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { DiagramPage } from "../../model/diagram-ir.js";
import { layoutPage } from "./layout-engine.js";
const PAGE: DiagramPage = {
id: "system",
title: "System",
layout: { type: "layered", direction: "horizontal", gridSize: 10, nodeGap: 40, layerGap: 80 },
nodes: [
{ id: "client", label: "Client", width: 120, height: 60 },
{ id: "worker", label: "Worker", width: 120, height: 60 },
{ id: "api", label: "API", width: 120, height: 60 },
{ id: "db", label: "Database", width: 120, height: 60 },
],
edges: [
{ id: "client-api", source: "client", target: "api" },
{ id: "worker-api", source: "worker", target: "api" },
{ id: "api-db", source: "api", target: "db" },
],
};
test("layered layout is deterministic, dependency-aware, and grid-aligned", () => {
const first = layoutPage(PAGE);
const second = layoutPage(PAGE);
assert.deepEqual(first, second);
const positions = new Map(first.nodes.map((node) => [node.id, node.geometry!]));
assert.ok(positions.get("client")!.x < positions.get("api")!.x);
assert.ok(positions.get("worker")!.x < positions.get("api")!.x);
assert.ok(positions.get("api")!.x < positions.get("db")!.x);
assert.notEqual(positions.get("client")!.y, positions.get("worker")!.y);
for (const geometry of positions.values()) {
assert.equal(geometry.x % 10, 0);
assert.equal(geometry.y % 10, 0);
}
});
test("layered layout avoids explicit-geometry siblings", () => {
const page: DiagramPage = {
id: "explicit",
title: "Explicit",
layout: { type: "layered", direction: "horizontal", gridSize: 10, nodeGap: 40, layerGap: 80 },
nodes: [
{ id: "a", label: "A", geometry: { x: 40, y: 80, width: 120, height: 60 } },
{ id: "b", label: "B" },
],
edges: [],
};
const laidOut = layoutPage(page);
const [a, b] = laidOut.nodes.map((node) => node.geometry!);
const overlaps = a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
assert.equal(overlaps, false);
});
test("manual layout preserves explicit geometry", () => {
const page: DiagramPage = {
id: "manual",
title: "Manual",
layout: { type: "manual" },
nodes: [{ id: "node", label: "Node", geometry: { x: 35, y: 45, width: 130, height: 70 } }],
edges: [],
};
assert.deepEqual(layoutPage(page), page);
});
test("grid and linear layouts avoid explicit siblings and honor grid/gap settings", () => {
for (const type of ["grid", "linear"] as const) {
const page: DiagramPage = {
id: `mixed-${type}`,
title: "Mixed",
layout: { type, direction: "horizontal", gridSize: 20, nodeGap: 60 },
nodes: [
{ id: "generated-one", label: "Generated one", width: 130, height: 60 },
{ id: "fixed", label: "Fixed", geometry: { x: 40, y: 80, width: 130, height: 60 } },
{ id: "generated-two", label: "Generated two", width: 130, height: 60 },
],
edges: [],
};
const laidOut = layoutPage(page);
const geometries = laidOut.nodes.map((node) => node.geometry!);
for (const geometry of geometries) {
assert.equal(geometry.x % 20, 0);
assert.equal(geometry.y % 20, 0);
}
for (let left = 0; left < geometries.length; left += 1) for (let right = left + 1; right < geometries.length; right += 1) {
const a = geometries[left];
const b = geometries[right];
const overlaps = a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
assert.equal(overlaps, false, `${type} layout overlapped mixed siblings`);
}
if (type === "linear") {
const generated = laidOut.nodes.filter((node) => node.id.startsWith("generated")).map((node) => node.geometry!).sort((a, b) => a.x - b.x);
assert.ok(generated[1].x - (generated[0].x + generated[0].width) >= 60);
}
}
});
test("automatic layout uses parent-relative coordinates and expands containers", () => {
const page: DiagramPage = {
id: "containers",
title: "Containers",
layout: { type: "grid", gridSize: 10, nodeGap: 40 },
nodes: [
{ id: "zone", label: "Zone", kind: "container", width: 120, height: 80 },
{ id: "one", label: "One", parentId: "zone", width: 120, height: 60 },
{ id: "two", label: "Two", parentId: "zone", width: 120, height: 60 },
],
edges: [],
};
const laidOut = layoutPage(page);
const byId = new Map(laidOut.nodes.map((node) => [node.id, node.geometry!]));
const zone = byId.get("zone")!;
for (const childId of ["one", "two"]) {
const child = byId.get(childId)!;
assert.ok(child.x >= 40 && child.y >= 40);
assert.ok(child.x + child.width + 40 <= zone.width);
assert.ok(child.y + child.height + 40 <= zone.height);
}
});
test("automatic layout positions generated sibling containers without overlap", () => {
for (const type of ["grid", "linear"] as const) {
const page: DiagramPage = {
id: "siblings",
title: "Siblings",
layout: { type, gridSize: 10, nodeGap: 40 },
nodes: [
{ id: "left", label: "Left", kind: "container" },
{ id: "left-child", label: "Left child", parentId: "left", width: 240 },
{ id: "right", label: "Right", kind: "container" },
{ id: "right-child", label: "Right child", parentId: "right", width: 240 },
],
edges: [],
};
const laidOut = layoutPage(page);
const byId = new Map(laidOut.nodes.map((node) => [node.id, node.geometry!]));
const left = byId.get("left")!;
const right = byId.get("right")!;
const overlaps = left.x < right.x + right.width && left.x + left.width > right.x && left.y < right.y + right.height && left.y + left.height > right.y;
assert.equal(overlaps, false, `${type} layout overlapped sibling containers`);
}
});
@@ -0,0 +1,229 @@
import type { DiagramGeometry, DiagramNode, DiagramPage } from "../../model/diagram-ir.js";
function snap(value: number, grid: number): number {
return Math.round(value / grid) * grid;
}
function nodeSize(node: DiagramNode): { width: number; height: number } {
return {
width: node.geometry?.width ?? node.width ?? 120,
height: node.geometry?.height ?? node.height ?? 60,
};
}
function assignLayers(page: DiagramPage): Map<string, number> {
const ids = page.nodes.map((node) => node.id).sort();
const nodeIds = new Set(ids);
const outgoing = new Map(ids.map((id) => [id, [] as string[]]));
const indegree = new Map(ids.map((id) => [id, 0]));
for (const edge of page.edges) {
if (!nodeIds.has(edge.source) || !nodeIds.has(edge.target) || edge.source === edge.target) continue;
outgoing.get(edge.source)!.push(edge.target);
indegree.set(edge.target, indegree.get(edge.target)! + 1);
}
for (const targets of outgoing.values()) targets.sort();
const queue = ids.filter((id) => indegree.get(id) === 0);
const layer = new Map(ids.map((id) => [id, 0]));
const processed = new Set<string>();
while (queue.length > 0) {
queue.sort();
const id = queue.shift()!;
processed.add(id);
for (const target of outgoing.get(id)!) {
layer.set(target, Math.max(layer.get(target)!, layer.get(id)! + 1));
indegree.set(target, indegree.get(target)! - 1);
if (indegree.get(target) === 0) queue.push(target);
}
}
// Cycles have no complete topological order. Place unresolved nodes in a
// deterministic sequence after the deepest acyclic layer instead of looping.
let fallbackLayer = Math.max(0, ...layer.values());
for (const id of ids.filter((candidate) => !processed.has(candidate))) {
layer.set(id, fallbackLayer++);
}
return layer;
}
function overlaps(a: DiagramGeometry, b: DiagramGeometry): boolean {
return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
}
function gridLayout(page: DiagramPage, grid: number, nodeGap: number): DiagramPage {
const count = page.nodes.length;
const columns = Math.max(1, Math.ceil(Math.sqrt(count)));
const maxWidth = Math.max(...page.nodes.map((node) => nodeSize(node).width));
const maxHeight = Math.max(...page.nodes.map((node) => nodeSize(node).height));
const occupied = page.nodes.flatMap((node) => node.geometry ? [{ ...node.geometry }] : []);
let slot = 0;
return {
...page,
nodes: page.nodes.map((node) => {
if (node.geometry) return { ...node, geometry: { ...node.geometry } };
const { width, height } = nodeSize(node);
let geometry: DiagramGeometry;
do {
geometry = {
x: snap(40 + (slot % columns) * (maxWidth + nodeGap), grid),
y: snap(80 + Math.floor(slot / columns) * (maxHeight + nodeGap), grid),
width,
height,
};
slot += 1;
} while (occupied.some((existing) => overlaps(geometry, existing)));
occupied.push(geometry);
return { ...node, geometry };
}),
edges: page.edges.map((edge) => ({ ...edge })),
};
}
function layeredLayout(page: DiagramPage, grid: number, nodeGap: number, layerGap: number, horizontal: boolean): DiagramPage {
const layers = assignLayers(page);
const groups = new Map<number, DiagramNode[]>();
for (const node of page.nodes) {
const level = layers.get(node.id) ?? 0;
const group = groups.get(level) ?? [];
group.push(node);
groups.set(level, group);
}
for (const group of groups.values()) group.sort((a, b) => a.id.localeCompare(b.id));
const levels = [...groups.keys()].sort((a, b) => a - b);
const primaryOffsets = new Map<number, number>();
let primary = horizontal ? 40 : 80;
for (const level of levels) {
primaryOffsets.set(level, primary);
const group = groups.get(level)!;
const maxPrimarySize = Math.max(...group.map((node) => horizontal ? nodeSize(node).width : nodeSize(node).height));
primary += maxPrimarySize + layerGap;
}
const geometry = new Map<string, DiagramGeometry>();
for (const node of page.nodes) if (node.geometry) geometry.set(node.id, { ...node.geometry });
for (const level of levels) {
let secondary = horizontal ? 80 : 40;
for (const node of groups.get(level)!) {
if (node.geometry) {
geometry.set(node.id, { ...node.geometry });
continue;
}
const { width, height } = nodeSize(node);
let candidate: DiagramGeometry;
do {
candidate = {
x: snap(horizontal ? primaryOffsets.get(level)! : secondary, grid),
y: snap(horizontal ? secondary : primaryOffsets.get(level)!, grid),
width,
height,
};
if (![...geometry.values()].some((existing) => candidate.x < existing.x + existing.width && candidate.x + candidate.width > existing.x && candidate.y < existing.y + existing.height && candidate.y + candidate.height > existing.y)) break;
secondary += (horizontal ? height : width) + nodeGap;
} while (true);
geometry.set(node.id, candidate);
secondary += (horizontal ? height : width) + nodeGap;
}
}
return {
...page,
nodes: page.nodes.map((node) => ({ ...node, geometry: geometry.get(node.id)! })),
edges: page.edges.map((edge) => ({ ...edge })),
};
}
function layoutFlatPage(page: DiagramPage): DiagramPage {
const type = page.layout?.type ?? "linear";
if (type === "manual") return page;
const grid = page.layout?.gridSize ?? 10;
const nodeGap = page.layout?.nodeGap ?? 40;
const layerGap = page.layout?.layerGap ?? 80;
const horizontal = (page.layout?.direction ?? "horizontal") === "horizontal";
if (type === "grid") return gridLayout(page, grid, nodeGap);
if (type === "layered" || type === "tree") return layeredLayout(page, grid, nodeGap, layerGap, horizontal);
let cursor = snap(horizontal ? 40 : 80, grid);
const occupied = page.nodes.flatMap((node) => node.geometry ? [{ ...node.geometry }] : []);
return {
...page,
nodes: page.nodes.map((node) => {
if (node.geometry) return { ...node, geometry: { ...node.geometry } };
const { width, height } = nodeSize(node);
let geometry: DiagramGeometry;
do {
geometry = {
x: snap(horizontal ? cursor : 40, grid),
y: snap(horizontal ? 80 : cursor, grid),
width,
height,
};
const collision = occupied.find((existing) => overlaps(geometry, existing));
if (!collision) break;
cursor = snap((horizontal ? collision.x + collision.width : collision.y + collision.height) + nodeGap, grid);
} while (true);
occupied.push(geometry);
cursor = snap((horizontal ? geometry.x + width : geometry.y + height) + nodeGap, grid);
return { ...node, geometry };
}),
edges: page.edges.map((edge) => ({ ...edge })),
};
}
export function layoutPage(page: DiagramPage): DiagramPage {
if ((page.layout?.type ?? "linear") === "manual") return page;
const nodes = new Map<string, DiagramNode>(page.nodes.map((node) => [node.id, {
...node,
geometry: node.geometry ? { ...node.geometry } : undefined,
}]));
const children = new Map<string, string[]>();
for (const node of nodes.values()) {
const key = node.parentId ?? "";
const group = children.get(key) ?? [];
group.push(node.id);
children.set(key, group);
}
const layOutGroup = (parentId: string): void => {
const ids = children.get(parentId) ?? [];
for (const id of ids) layOutGroup(id);
if (ids.length === 0) return;
const idSet = new Set(ids);
const groupPage: DiagramPage = {
...page,
nodes: ids.map((id) => nodes.get(id)!),
edges: page.edges.filter((edge) => idSet.has(edge.source) && idSet.has(edge.target)),
};
const laidOut = layoutFlatPage(groupPage);
for (const node of laidOut.nodes) nodes.set(node.id, node);
if (parentId) {
const parent = nodes.get(parentId);
if (parent) {
const right = Math.max(...laidOut.nodes.map((node) => node.geometry!.x + node.geometry!.width));
const bottom = Math.max(...laidOut.nodes.map((node) => node.geometry!.y + node.geometry!.height));
const size = nodeSize(parent);
const width = Math.max(size.width, right + 40);
const height = Math.max(size.height, bottom + 40);
nodes.set(parentId, parent.geometry ? {
...parent,
geometry: { ...parent.geometry, width, height },
} : {
...parent,
width,
height,
geometry: undefined,
});
}
}
};
layOutGroup("");
return {
...page,
nodes: page.nodes.map((node) => nodes.get(node.id)!),
edges: page.edges.map((edge) => ({ ...edge })),
};
}