Files
diagrams-drawio/scripts/src/services/authoring-router/orthogonal-router.ts
T
oleg-lukasonokandClaude Code 6584df0666 Fix review findings: importer DoS, sync data loss, views/profile rendering, docs
Source importers: lstat before open so FIFOs no longer hang the directory
walk; replace the quadratic Rust macro regex with a linear scan.
Three-way sync: report page add/add and delete-vs-modify as conflicts
instead of silently overwriting or resurrecting; canonical comparison so
key order is not a change; the sync action withholds output and fails on
conflicts unless --force. Story publishing escapes <, >, & and U+2028/9
inside the embedded JSON.
Views: drop parentId of unselected containers, re-layout instead of
manual geometry, validate before serialising; flatten() merges identical
nodes repeated across pages so C4 output works with every analysis
action. Dark theme edge labels get a background; tube-map corridors sit
above the stations; C4 containers keep the swimlane style and orphan
relationships land on the matching page; router keeps container header
bands as obstacles; sequence self-messages loop on one side.
Docs: SKILL.md lists all 23 CLI actions and how each capability family
is invoked, task wrappers for query/test/what-if/doctor, capability
tables and --page scope corrected, maintenance snippet uses the real
synchronous action signature, duplicated rule bullets moved to the rule
references, coverage matrix wording made verifiable.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-06 17:01:50 +03:00

196 lines
9.0 KiB
TypeScript

import type { DiagramEdge, DiagramGeometry, DiagramNode, 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;
}
// Height of a container's label band. Mirrors ir-to-drawio's default container style (swimlane with
// startSize=30) and draw.io's own swimlane default (40) when a custom style omits startSize.
function headerHeight(node: DiagramNode): number {
if (node.style === undefined) return node.kind === "container" ? 30 : 0;
const explicit = /(?:^|;)startSize=(\d+(?:\.\d+)?)/.exec(node.style);
if (explicit) return Number(explicit[1]);
return /(?:^|;)swimlane(?:;|$)/.test(node.style) ? 40 : 0;
}
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)]);
// Containers enclosing an endpoint may be crossed, except for their header band: a route through the
// label bar (or along its bottom line) merges visually with the container border.
const obstacles: Obstacle[] = page.nodes
.filter((node) => node.id !== edge.source && node.id !== edge.target && geometries.has(node.id))
.flatMap((node) => {
const geometry = geometries.get(node.id)!;
if (!endpointAncestors.has(node.id)) return [{ id: node.id, ...geometry }];
const header = headerHeight(node);
return header > 0 ? [{ id: node.id, ...geometry, height: header }] : [];
});
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)),
};
}