Files
root-at-skicandClaude Opus 5.5 22815a9940 Archify Diagram Viewer 0.1.0
VS Code extension that previews Archify diagrams from their JSON sources
(live, as you type) and opens rendered Archify HTML in a viewer tab.
Bundles the Archify 3.0.1 renderer and runs it on VS Code's Node runtime.
Adds validation diagnostics, JSON schema help, source-link navigation,
export saving, render-to-file and open-in-browser commands.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 12:44:15 +03:00

615 lines
23 KiB
JavaScript

import { recordDiagnostic } from './diagnostics.mjs';
import {
asArray,
isFinitePoint,
normalizeRoutePoints,
properSegmentIntersection,
segmentIntersectsRect,
} from './geometry.mjs';
const DEFAULTS = Object.freeze({
clearance: 2,
minimumDetourRatio: 2.5,
minimumExcessLengthPx: 200,
minimumEmptyExcursionPx: 96,
maximumObstacleCount: 80,
sharedCorridorMinimumPx: 32,
});
const OUTWARD = Object.freeze({
left: [-1, 0],
right: [1, 0],
top: [0, -1],
bottom: [0, 1],
});
function rounded(value) {
return Math.round(value * 100) / 100;
}
function pointKey(point) {
return `${point[0]}\u0000${point[1]}`;
}
class MinHeap {
constructor() {
this.entries = [];
}
push(key, distance) {
const entry = { key, distance };
this.entries.push(entry);
let index = this.entries.length - 1;
while (index > 0) {
const parent = Math.floor((index - 1) / 2);
if (this.entries[parent].distance <= distance) break;
this.entries[index] = this.entries[parent];
index = parent;
}
this.entries[index] = entry;
}
pop() {
if (!this.entries.length) return null;
const first = this.entries[0];
const last = this.entries.pop();
if (!this.entries.length) return first;
let index = 0;
while (true) {
const left = index * 2 + 1;
const right = left + 1;
if (left >= this.entries.length) break;
const child = right < this.entries.length
&& this.entries[right].distance < this.entries[left].distance ? right : left;
if (this.entries[child].distance >= last.distance) break;
this.entries[index] = this.entries[child];
index = child;
}
this.entries[index] = last;
return first;
}
}
function orthogonalLength(points) {
let total = 0;
for (let index = 0; index < points.length - 1; index += 1) {
const [x1, y1] = points[index];
const [x2, y2] = points[index + 1];
if (x1 !== x2 && y1 !== y2) return null;
total += Math.abs(x2 - x1) + Math.abs(y2 - y1);
}
return total;
}
function inferredSide(points, endpoint) {
if (points.length < 2) return null;
const start = endpoint === 'source' ? points[0] : points.at(-2);
const end = endpoint === 'source' ? points[1] : points.at(-1);
const dx = end[0] - start[0];
const dy = end[1] - start[1];
if (endpoint === 'source') {
if (dx > 0 && dy === 0) return 'right';
if (dx < 0 && dy === 0) return 'left';
if (dy > 0 && dx === 0) return 'bottom';
if (dy < 0 && dx === 0) return 'top';
} else {
if (dx > 0 && dy === 0) return 'left';
if (dx < 0 && dy === 0) return 'right';
if (dy > 0 && dx === 0) return 'top';
if (dy < 0 && dx === 0) return 'bottom';
}
return null;
}
function moveOutward(point, side, distance) {
const [dx, dy] = OUTWARD[side] || [0, 0];
return [point[0] + dx * distance, point[1] + dy * distance];
}
function expandedRect(rect, clearance) {
return {
id: rect.id,
x: rect.x - clearance,
y: rect.y - clearance,
width: rect.width + clearance * 2,
height: rect.height + clearance * 2,
};
}
function boundsForRects(rects) {
const usable = [...rects].filter((rect) => (
rect && isFinitePoint(rect.x, rect.y, rect.width, rect.height)
&& rect.width >= 0 && rect.height >= 0
));
if (!usable.length) return null;
const left = Math.min(...usable.map((rect) => rect.x));
const top = Math.min(...usable.map((rect) => rect.y));
const right = Math.max(...usable.map((rect) => rect.x + rect.width));
const bottom = Math.max(...usable.map((rect) => rect.y + rect.height));
return { left, top, right, bottom, width: right - left, height: bottom - top };
}
function boundsForPoints(points) {
if (!points.length) return null;
const xs = points.map(([x]) => x);
const ys = points.map(([, y]) => y);
const left = Math.min(...xs);
const top = Math.min(...ys);
const right = Math.max(...xs);
const bottom = Math.max(...ys);
return { left, top, right, bottom, width: right - left, height: bottom - top };
}
function outsideExcursion(routeBounds, contentBounds) {
if (!routeBounds || !contentBounds) return null;
const sides = {
left: Math.max(0, contentBounds.left - routeBounds.left),
top: Math.max(0, contentBounds.top - routeBounds.top),
right: Math.max(0, routeBounds.right - contentBounds.right),
bottom: Math.max(0, routeBounds.bottom - contentBounds.bottom),
};
return { ...sides, maximum: Math.max(...Object.values(sides)) };
}
function pointDistanceFromRect(point, rect) {
const dx = Math.max(rect.x - point[0], 0, point[0] - (rect.x + rect.width));
const dy = Math.max(rect.y - point[1], 0, point[1] - (rect.y + rect.height));
return dx + dy;
}
function emptyControlPointClearance(points, contentRects) {
const controls = points.slice(1, -1);
const rects = [...contentRects].filter((rect) => (
rect && isFinitePoint(rect.x, rect.y, rect.width, rect.height)
&& rect.width >= 0 && rect.height >= 0
));
if (!controls.length || !rects.length) return null;
const distances = controls.map((point) => Math.min(
...rects.map((rect) => pointDistanceFromRect(point, rect)),
));
const maximum = Math.max(...distances);
return { maximum, point: controls[distances.indexOf(maximum)] };
}
function pointBlocked(point, obstacles) {
return obstacles.some((rect) => (
point[0] >= rect.x && point[0] <= rect.x + rect.width
&& point[1] >= rect.y && point[1] <= rect.y + rect.height
));
}
function segmentBlocked(start, end, obstacles) {
return obstacles.some((rect) => segmentIntersectsRect({ start, end }, rect));
}
function segmentConflictsWithAvoided(start, end, avoidedSegments, minimumOverlapPx, allowCrossings) {
return avoidedSegments.some((segment) => (
(!allowCrossings && (properSegmentIntersection(start, end, segment.start, segment.end)
|| orthogonalTouchOnAvoidedInterior(start, end, segment.start, segment.end)))
|| collinearOverlap(start, end, segment.start, segment.end) >= minimumOverlapPx
));
}
function orthogonalTouchOnAvoidedInterior(start, end, avoidedStart, avoidedEnd) {
const epsilon = 0.0001;
const candidateHorizontal = Math.abs(start[1] - end[1]) <= epsilon;
const candidateVertical = Math.abs(start[0] - end[0]) <= epsilon;
const avoidedHorizontal = Math.abs(avoidedStart[1] - avoidedEnd[1]) <= epsilon;
const avoidedVertical = Math.abs(avoidedStart[0] - avoidedEnd[0]) <= epsilon;
if (candidateHorizontal && avoidedVertical) {
const x = avoidedStart[0];
const y = start[1];
return x >= Math.min(start[0], end[0]) - epsilon
&& x <= Math.max(start[0], end[0]) + epsilon
&& y > Math.min(avoidedStart[1], avoidedEnd[1]) + epsilon
&& y < Math.max(avoidedStart[1], avoidedEnd[1]) - epsilon;
}
if (candidateVertical && avoidedHorizontal) {
const x = start[0];
const y = avoidedStart[1];
return y >= Math.min(start[1], end[1]) - epsilon
&& y <= Math.max(start[1], end[1]) + epsilon
&& x > Math.min(avoidedStart[0], avoidedEnd[0]) + epsilon
&& x < Math.max(avoidedStart[0], avoidedEnd[0]) - epsilon;
}
return false;
}
function pointOnSegmentInterior(point, start, end) {
const epsilon = 0.0001;
const cross = (end[0] - start[0]) * (point[1] - start[1])
- (end[1] - start[1]) * (point[0] - start[0]);
if (Math.abs(cross) > epsilon) return false;
const dot = (point[0] - start[0]) * (point[0] - end[0])
+ (point[1] - start[1]) * (point[1] - end[1]);
return dot < -epsilon;
}
function pointOnAvoidedInterior(point, avoidedSegments) {
return avoidedSegments.some((segment) => (
pointOnSegmentInterior(point, segment.start, segment.end)
));
}
function writeGridMetrics(metrics, patch) {
if (!metrics || typeof metrics !== 'object') return;
Object.assign(metrics, patch);
}
export function shortestOrthogonalGridRoute({
start,
end,
points,
obstacles,
fromSide,
toSide,
clearance,
maximumObstacleCount,
endpointStubPx = clearance + 2,
maximumGridNodes = Infinity,
avoidedSegments = [],
allowAvoidedCrossings = false,
minimumAvoidedOverlapPx = 8,
routeSeparationPx = 8,
minimumSegmentPx = 8,
borderSegments = [],
bendPenaltyPx = 0,
metrics,
}) {
writeGridMetrics(metrics, {
status: 'initializing',
maximumGridNodes,
obstacleCount: 0,
avoidedSegmentCount: 0,
coordinateCount: 0,
candidateNodeCount: 0,
usableNodeCount: 0,
graphEdgeCount: 0,
visitedNodeCount: 0,
});
if (!OUTWARD[fromSide] || !OUTWARD[toSide]) {
writeGridMetrics(metrics, { status: 'unsupported-endpoint-side' });
return null;
}
const startStub = moveOutward(start, fromSide, endpointStubPx);
const endStub = moveOutward(end, toSide, endpointStubPx);
// The graph may legally leave the initial endpoint bounds to find a clear
// corridor. Keep every bounded obstacle and occupied relationship visible
// to that search; filtering them against the initial box lets a detour walk
// straight through geometry that only becomes relevant after it leaves the
// box. The explicit obstacle/node budgets below keep this deterministic.
const expanded = [...obstacles]
.filter((rect) => rect && isFinitePoint(rect.x, rect.y, rect.width, rect.height))
.map((rect) => expandedRect(rect, clearance));
const relevantAvoidedSegments = [...avoidedSegments]
.filter((segment) => segment?.start && segment?.end);
// Frame borders may be crossed perpendicularly but never borrowed as a
// corridor: the composition gate rejects any collinear run along them.
const relevantBorderSegments = [...borderSegments]
.filter((segment) => segment?.start && segment?.end);
writeGridMetrics(metrics, {
obstacleCount: expanded.length,
avoidedSegmentCount: relevantAvoidedSegments.length,
});
if (expanded.length > maximumObstacleCount) {
writeGridMetrics(metrics, { status: 'obstacle-budget-exceeded' });
return null;
}
const xs = new Set([startStub[0], endStub[0], ...points.map(([x]) => x)]);
const ys = new Set([startStub[1], endStub[1], ...points.map(([, y]) => y)]);
for (const rect of expanded) {
xs.add(rect.x - 1);
xs.add(rect.x + rect.width + 1);
ys.add(rect.y - 1);
ys.add(rect.y + rect.height + 1);
}
for (const segment of relevantAvoidedSegments) {
const [segmentStart, segmentEnd] = [segment.start, segment.end];
xs.add(segmentStart[0]);
xs.add(segmentEnd[0]);
ys.add(segmentStart[1]);
ys.add(segmentEnd[1]);
if (Math.abs(segmentStart[0] - segmentEnd[0]) <= 0.0001) {
xs.add(segmentStart[0] - routeSeparationPx);
xs.add(segmentStart[0] + routeSeparationPx);
}
if (Math.abs(segmentStart[1] - segmentEnd[1]) <= 0.0001) {
ys.add(segmentStart[1] - routeSeparationPx);
ys.add(segmentStart[1] + routeSeparationPx);
}
}
for (const segment of relevantBorderSegments) {
if (Math.abs(segment.start[0] - segment.end[0]) <= 0.0001) {
xs.add(segment.start[0] - routeSeparationPx);
xs.add(segment.start[0] + routeSeparationPx);
}
if (Math.abs(segment.start[1] - segment.end[1]) <= 0.0001) {
ys.add(segment.start[1] - routeSeparationPx);
ys.add(segment.start[1] + routeSeparationPx);
}
}
// Grid lines closer than a readable segment would let the search emit a
// micro jog between two obstacle edges; keep the endpoint stubs and coalesce
// the rest so every turn the route can take is at least one segment long.
const coalesce = (values, keep) => values.sort((a, b) => a - b).filter((value, index, sorted) => (
index === 0 || keep.has(value) || value - sorted[index - 1] >= minimumSegmentPx
));
const orderedX = coalesce([...xs], new Set([startStub[0], endStub[0]]));
const orderedY = coalesce([...ys], new Set([startStub[1], endStub[1]]));
const candidateNodeCount = orderedX.length * orderedY.length;
writeGridMetrics(metrics, {
coordinateCount: orderedX.length + orderedY.length,
candidateNodeCount,
});
if (candidateNodeCount > maximumGridNodes) {
writeGridMetrics(metrics, { status: 'node-budget-exceeded' });
return null;
}
const nodes = new Map();
for (const x of orderedX) {
for (const y of orderedY) {
const point = [x, y];
if (!pointBlocked(point, expanded)
&& (allowAvoidedCrossings || !pointOnAvoidedInterior(point, relevantAvoidedSegments))) {
nodes.set(pointKey(point), point);
}
}
}
writeGridMetrics(metrics, { usableNodeCount: nodes.size });
if (!nodes.has(pointKey(startStub)) || !nodes.has(pointKey(endStub))) {
writeGridMetrics(metrics, { status: 'endpoint-blocked' });
return null;
}
const adjacency = new Map([...nodes.keys()].map((key) => [key, []]));
let graphEdgeCount = 0;
const connectLine = (line, axis) => {
for (let index = 0; index < line.length - 1; index += 1) {
const left = line[index];
const right = line[index + 1];
if (segmentBlocked(left, right, expanded)) continue;
if (segmentConflictsWithAvoided(
left,
right,
relevantAvoidedSegments,
minimumAvoidedOverlapPx,
allowAvoidedCrossings,
)) continue;
if (relevantBorderSegments.some((segment) => (
collinearOverlap(left, right, segment.start, segment.end) > 0.0001
))) continue;
const distance = Math.abs(right[0] - left[0]) + Math.abs(right[1] - left[1]);
const leftKey = pointKey(left);
const rightKey = pointKey(right);
adjacency.get(leftKey).push([rightKey, distance, axis === 'h' ? 'R' : 'D']);
adjacency.get(rightKey).push([leftKey, distance, axis === 'h' ? 'L' : 'U']);
graphEdgeCount += 1;
}
};
for (const y of orderedY) {
connectLine(orderedX.map((x) => nodes.get(pointKey([x, y]))).filter(Boolean), 'h');
}
for (const x of orderedX) {
connectLine(orderedY.map((y) => nodes.get(pointKey([x, y]))).filter(Boolean), 'v');
}
writeGridMetrics(metrics, { graphEdgeCount });
// The search state carries the incoming direction so a turn can cost extra
// and a reversal is never taken: the pure shortest path hugs every obstacle
// corner with a staircase of short jogs, while a bend-penalised one takes
// the same corridor in a few long strokes. The first stub already leaves
// the endpoint along its side and the last one arrives along the end side.
const directionOf = ([dx, dy]) => (dx > 0 ? 'R' : dx < 0 ? 'L' : dy > 0 ? 'D' : 'U');
const opposite = { R: 'L', L: 'R', D: 'U', U: 'D' };
const stateKey = (key, direction) => `${key}|${direction}`;
const sourceAxis = directionOf(OUTWARD[fromSide]);
const targetAxis = opposite[directionOf(OUTWARD[toSide])];
const source = pointKey(startStub);
const target = pointKey(endStub);
const sourceState = stateKey(source, sourceAxis);
const distances = new Map([[sourceState, 0]]);
const previous = new Map();
const queue = new MinHeap();
queue.push(sourceState, 0);
let visitedNodeCount = 0;
let targetState = null;
while (queue.entries.length) {
const next = queue.pop();
const current = next.key;
const currentDistance = next.distance;
if (currentDistance !== distances.get(current)) continue;
visitedNodeCount += 1;
const [currentNode, currentAxis] = current.split('|');
if (currentNode === target) {
// Arriving on the wrong axis costs one final turn onto the end stub.
const arrival = currentDistance + (currentAxis === targetAxis ? 0 : bendPenaltyPx);
if (targetState == null || arrival < targetState.distance) {
targetState = { key: current, distance: arrival };
}
if (currentAxis === targetAxis || bendPenaltyPx === 0) break;
continue;
}
if (targetState && currentDistance >= targetState.distance) break;
for (const [neighbor, weight, axis] of adjacency.get(currentNode) || []) {
if (axis === opposite[currentAxis]) continue;
const candidate = currentDistance + weight + (axis === currentAxis ? 0 : bendPenaltyPx);
const neighborState = stateKey(neighbor, axis);
if (candidate >= (distances.get(neighborState) ?? Infinity)) continue;
distances.set(neighborState, candidate);
previous.set(neighborState, current);
queue.push(neighborState, candidate);
}
}
writeGridMetrics(metrics, { visitedNodeCount });
if (!targetState) {
writeGridMetrics(metrics, { status: 'no-route' });
return null;
}
const reversed = [];
for (let key = targetState.key; key; key = previous.get(key)) {
reversed.push(nodes.get(key.split('|')[0]));
if (key === sourceState) break;
}
if (pointKey(reversed.at(-1)) !== source) {
writeGridMetrics(metrics, { status: 'broken-predecessor-chain' });
return null;
}
const shortestPoints = normalizeRoutePoints([start, ...reversed.reverse(), end]);
writeGridMetrics(metrics, { status: 'routed' });
return {
points: shortestPoints,
length: orthogonalLength(shortestPoints),
obstacleCount: expanded.length,
};
}
function collinearOverlap(leftStart, leftEnd, rightStart, rightEnd) {
if (leftStart[0] === leftEnd[0] && rightStart[0] === rightEnd[0]
&& leftStart[0] === rightStart[0]) {
return Math.max(0, Math.min(Math.max(leftStart[1], leftEnd[1]), Math.max(rightStart[1], rightEnd[1]))
- Math.max(Math.min(leftStart[1], leftEnd[1]), Math.min(rightStart[1], rightEnd[1])));
}
if (leftStart[1] === leftEnd[1] && rightStart[1] === rightEnd[1]
&& leftStart[1] === rightStart[1]) {
return Math.max(0, Math.min(Math.max(leftStart[0], leftEnd[0]), Math.max(rightStart[0], rightEnd[0]))
- Math.max(Math.min(leftStart[0], leftEnd[0]), Math.min(rightStart[0], rightEnd[0])));
}
return 0;
}
function segmentOutsideContent(start, end, contentBounds) {
if (!contentBounds) return false;
const midpoint = [(start[0] + end[0]) / 2, (start[1] + end[1]) / 2];
return midpoint[0] < contentBounds.left || midpoint[0] > contentBounds.right
|| midpoint[1] < contentBounds.top || midpoint[1] > contentBounds.bottom;
}
function sharesOuterCorridor({ relation, relations, pathFor, points, contentBounds, minimumOverlap }) {
for (const other of asArray(relations)) {
if (!other || other === relation) continue;
const related = relation.from === other.from || relation.from === other.to
|| relation.to === other.from || relation.to === other.to;
if (!related) continue;
const otherPoints = normalizeRoutePoints(pathFor(other)?.points || []);
for (let left = 0; left < points.length - 1; left += 1) {
if (!segmentOutsideContent(points[left], points[left + 1], contentBounds)) continue;
for (let right = 0; right < otherPoints.length - 1; right += 1) {
if (collinearOverlap(points[left], points[left + 1], otherPoints[right], otherPoints[right + 1]) >= minimumOverlap) {
return true;
}
}
}
}
return false;
}
function relationshipSubject(diagramType, relationCollection, relationIndex, relation) {
return {
diagramType,
collection: relationCollection,
index: relationIndex,
...(relation.id ? { id: relation.id } : {}),
from: relation.from,
to: relation.to,
};
}
/**
* Reject conspicuous authored detours without penalizing routes whose length is
* explained by opaque-node avoidance or a related shared outer corridor.
*/
export function cleanRouteDetourProblems({
relations,
obstacles,
contentRects = obstacles,
endpointIds,
pathFor,
fromSideFor,
toSideFor,
diagramType,
relationCollection,
profile,
thresholds = {},
}) {
if (profile !== 'showcase') return [];
const policy = { ...DEFAULTS, ...thresholds };
const obstacleList = [...obstacles];
const contentBounds = boundsForRects(contentRects);
const problems = [];
for (const [relationIndex, relation] of asArray(relations).entries()) {
if (!relation || !endpointIds?.has(relation.from) || !endpointIds?.has(relation.to)) continue;
if (!Array.isArray(relation.via) || relation.via.length === 0) continue;
const points = normalizeRoutePoints(pathFor(relation)?.points || []);
if (points.length < 3 || !points.every((point) => Array.isArray(point) && isFinitePoint(...point))) continue;
const actualLength = orthogonalLength(points);
if (!Number.isFinite(actualLength)) continue;
const start = points[0];
const end = points.at(-1);
const manhattan = Math.abs(end[0] - start[0]) + Math.abs(end[1] - start[1]);
if (actualLength < manhattan * policy.minimumDetourRatio
|| actualLength - manhattan < policy.minimumExcessLengthPx) continue;
const routeBounds = boundsForPoints(points);
const excursion = outsideExcursion(routeBounds, contentBounds);
const emptyClearance = emptyControlPointClearance(points, obstacleList);
if (Math.max(excursion?.maximum || 0, emptyClearance?.maximum || 0)
< policy.minimumEmptyExcursionPx) continue;
if (sharesOuterCorridor({
relation,
relations,
pathFor,
points,
contentBounds,
minimumOverlap: policy.sharedCorridorMinimumPx,
})) continue;
const fromSide = fromSideFor?.(relation) || inferredSide(points, 'source');
const toSide = toSideFor?.(relation) || inferredSide(points, 'target');
const shortest = shortestOrthogonalGridRoute({
start,
end,
points,
obstacles: obstacleList,
fromSide,
toSide,
clearance: policy.clearance,
maximumObstacleCount: policy.maximumObstacleCount,
});
if (!shortest || !Number.isFinite(shortest.length) || shortest.length <= 0) continue;
const detourRatio = actualLength / shortest.length;
const excessLength = actualLength - shortest.length;
if (detourRatio < policy.minimumDetourRatio || excessLength < policy.minimumExcessLengthPx) continue;
const relationId = relation.id ? ` id "${relation.id}"` : '';
const message = `[composition/excessive-route-detour] ${diagramType} ${relationCollection}[${relationIndex}]${relationId} "${relation.from}" -> "${relation.to}" travels ${Math.round(actualLength)}px, ${rounded(detourRatio)}x the ${Math.round(shortest.length)}px shortest obstacle-clearing orthogonal route, and reaches ${Math.round(excursion.maximum)}px beyond the content bounds — remove the distant via corridor or move it close to the connected content.`;
const supportedFix = 'remove the distant via points and retry automatic routing, or keep the endpoint sides and move the via corridor near the connected nodes while preserving labels and direction';
recordDiagnostic({
code: 'composition/excessive-route-detour',
severity: 'error',
message,
subject: relationshipSubject(diagramType, relationCollection, relationIndex, relation),
evidence: {
points,
actualLengthPx: rounded(actualLength),
shortestLegalPoints: shortest.points,
shortestLegalLengthPx: rounded(shortest.length),
detourRatio: rounded(detourRatio),
excessLengthPx: rounded(excessLength),
routeBounds,
contentBounds,
emptyExcursionPx: excursion,
emptyControlPointClearancePx: emptyClearance,
obstacleCount: shortest.obstacleCount,
thresholds: {
minimumDetourRatio: policy.minimumDetourRatio,
minimumExcessLengthPx: policy.minimumExcessLengthPx,
minimumEmptyExcursionPx: policy.minimumEmptyExcursionPx,
},
},
supportedFixes: [supportedFix],
});
problems.push(message);
}
return problems;
}