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>
196 lines
9.0 KiB
TypeScript
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)),
|
|
};
|
|
}
|