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
+101
View File
@@ -0,0 +1,101 @@
import type { DiagramEdge, DiagramIR, DiagramNode, DiagramPage } from "../model/diagram-ir.js";
import { normalizeDiagramIR } from "../model/diagram-ir.js";
import { routePageEdges } from "../services/authoring-router/orthogonal-router.js";
import { layoutPage } from "../services/layout/layout-engine.js";
const DEFAULT_NODE_STYLE = "whiteSpace=wrap;html=1;rounded=0;fillColor=#f5f5f5;strokeColor=#666666;";
const DEFAULT_CONTAINER_STYLE = "swimlane;html=1;rounded=0;startSize=30;fillColor=#f5f5f5;strokeColor=#666666;";
const DEFAULT_EDGE_STYLE = "edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;";
function escapeXml(value: string): string {
return value
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&apos;");
}
function stableJson(value: unknown): string {
if (value === undefined) return "null";
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
if (value && typeof value === "object") {
const record = value as Record<string, unknown>;
return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`).join(",")}}`;
}
return JSON.stringify(value);
}
function dataAttributes(values: Record<string, unknown>): string {
return Object.entries(values)
.filter(([, value]) => value !== undefined)
.map(([key, value]) => ` data-${key}="${escapeXml(typeof value === "string" ? value : stableJson(value))}"`)
.join("");
}
function geometryForNode(node: DiagramNode, index: number, page: DiagramPage): { x: number; y: number; width: number; height: number } {
if (node.geometry) return node.geometry;
const horizontal = (page.layout?.direction ?? "horizontal") === "horizontal";
return {
x: horizontal ? 40 + index * 200 : 40,
y: horizontal ? 80 : 80 + index * 120,
width: node.width ?? 120,
height: node.height ?? 60,
};
}
function serializeNode(node: DiagramNode, index: number, page: DiagramPage): string {
const geometry = geometryForNode(node, index, page);
const style = node.style ?? (node.kind === "container" ? DEFAULT_CONTAINER_STYLE : DEFAULT_NODE_STYLE);
const parent = node.parentId ?? "1";
const metadata = dataAttributes({ kind: node.kind, properties: node.properties, provenance: node.provenance, extensions: node.extensions });
return ` <mxCell id="${escapeXml(node.id)}" value="${escapeXml(node.label)}" style="${escapeXml(style)}" vertex="1" parent="${escapeXml(parent)}"${metadata}><mxGeometry x="${geometry.x}" y="${geometry.y}" width="${geometry.width}" height="${geometry.height}" as="geometry"/></mxCell>`;
}
function serializeEdge(edge: DiagramEdge): string {
const points = edge.waypoints?.length
? `<Array as="points">${edge.waypoints.map((point) => `<mxPoint x="${point.x}" y="${point.y}"/>`).join("")}</Array>`
: "";
const metadata = dataAttributes({ kind: edge.kind, properties: edge.properties, provenance: edge.provenance, extensions: edge.extensions });
return ` <mxCell id="${escapeXml(edge.id)}" value="${escapeXml(edge.label ?? "")}" style="${escapeXml(edge.style ?? DEFAULT_EDGE_STYLE)}" edge="1" parent="1" source="${escapeXml(edge.source)}" target="${escapeXml(edge.target)}"${metadata}><mxGeometry relative="1" as="geometry">${points}</mxGeometry></mxCell>`;
}
function contentExtent(page: DiagramPage): { width: number; height: number } {
const byId = new Map(page.nodes.map((node) => [node.id, node]));
const absolute = new Map<string, { x: number; y: number; width: number; height: number }>();
const resolve = (id: string): { x: number; y: number; width: number; height: number } | undefined => {
const cached = absolute.get(id);
if (cached) return cached;
const node = byId.get(id);
if (!node?.geometry) return undefined;
const parent = node.parentId ? resolve(node.parentId) : undefined;
const geometry = { ...node.geometry, x: node.geometry.x + (parent?.x ?? 0), y: node.geometry.y + (parent?.y ?? 0) };
absolute.set(id, geometry);
return geometry;
};
const geometries = page.nodes.map((node) => resolve(node.id)).filter((geometry) => geometry !== undefined);
const points = page.edges.flatMap((edge) => edge.waypoints ?? []);
return {
width: Math.max(0, ...geometries.map((geometry) => geometry.x + geometry.width), ...points.map((point) => point.x)),
height: Math.max(0, ...geometries.map((geometry) => geometry.y + geometry.height), ...points.map((point) => point.y)),
};
}
function serializePage(input: DiagramPage): string {
const page = routePageEdges(layoutPage(input));
const nodeCells = page.nodes.map((node, index) => serializeNode(node, index, page));
const edgeCells = page.edges.map(serializeEdge);
const extent = contentExtent(page);
const grid = page.layout?.gridSize ?? 10;
const fit = (value: number) => Math.ceil((value + 40) / grid) * grid;
const pageWidth = Math.max(page.width ?? 1169, fit(extent.width));
const pageHeight = Math.max(page.height ?? 827, fit(extent.height));
const metadata = dataAttributes({ properties: page.properties, extensions: page.extensions });
return ` <diagram id="${escapeXml(page.id)}" name="${escapeXml(page.title)}"${metadata}>\n <mxGraphModel grid="1" gridSize="${page.layout?.gridSize ?? 10}" page="1" pageWidth="${pageWidth}" pageHeight="${pageHeight}">\n <root>\n <mxCell id="0"/>\n <mxCell id="1" parent="0"/>\n${[...nodeCells, ...edgeCells].join("\n")}\n </root>\n </mxGraphModel>\n </diagram>`;
}
export function diagramIRToDrawio(ir: DiagramIR): string {
const normalized = normalizeDiagramIR(ir);
const metadata = dataAttributes({ version: normalized.version, title: normalized.title, theme: normalized.theme, provenance: normalized.provenance, assets: normalized.assets, properties: normalized.properties, extensions: normalized.extensions });
return `<?xml version="1.0" encoding="UTF-8"?>\n<mxfile host="drawio-tools"${metadata}>\n${normalized.pages.map(serializePage).join("\n")}\n</mxfile>\n`;
}