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>
This commit is contained in:
+614
@@ -0,0 +1,614 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user