feat: add deterministic Diagram IR v2 authoring
This commit is contained in:
@@ -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 })),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user