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