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,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 })),
};
}