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