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>
430 lines
20 KiB
JavaScript
430 lines
20 KiB
JavaScript
// Orthogonal router for schema_version 2 lifecycle diagrams.
|
|
//
|
|
// v2 states sit on a fixed grid: one row per lane and a shared column pitch,
|
|
// with empty gaps between rows and between columns. That structure lets
|
|
// every automatic transition use a small, predictable set of shapes instead
|
|
// of a general obstacle search:
|
|
// - neighbours in one row connect with a horizontal line;
|
|
// - other states in one row connect through the gap above (below for the
|
|
// first row);
|
|
// - states in different rows leave through the facing top/bottom side,
|
|
// turn once in a row gap, and enter the target's facing side; when a
|
|
// state blocks the straight descent, the route steps sideways through the
|
|
// empty corridor between two columns.
|
|
// Ports on each side are spread in the order of where their routes head, and
|
|
// the horizontal runs in each gap get their own tracks, ordered to minimize
|
|
// crossings. Reciprocal pairs therefore render as two parallel lines.
|
|
|
|
const PORT_GUTTER = 16;
|
|
const PORT_SPACING = 30;
|
|
const SNAP_LIMIT = 16;
|
|
const TRACK_TOP_CLEARANCE = 18;
|
|
const TRACK_BOTTOM_CLEARANCE = 18;
|
|
const PREFERRED_TRACK_SPACING = 22;
|
|
const MAX_TRACK_SPACING = 24;
|
|
const CORRIDOR_SPACING = 10;
|
|
const EXHAUSTIVE_TRACK_LIMIT = 7;
|
|
|
|
const opposite = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' };
|
|
|
|
function permutations(items) {
|
|
if (items.length <= 1) return [items];
|
|
return items.flatMap((item, index) => permutations([...items.slice(0, index), ...items.slice(index + 1)])
|
|
.map((rest) => [item, ...rest]));
|
|
}
|
|
|
|
export function createLifecycleGridRouter(states, transitions, { rowOf, columnXs }) {
|
|
const byRow = new Map();
|
|
for (const state of states.values()) {
|
|
const row = rowOf(state);
|
|
if (!Number.isInteger(row)) continue;
|
|
if (!byRow.has(row)) byRow.set(row, []);
|
|
byRow.get(row).push(state);
|
|
}
|
|
const rows = [...byRow.keys()].sort((a, b) => a - b);
|
|
const rowTop = new Map(rows.map((row) => [row, Math.min(...byRow.get(row).map((s) => s.y))]));
|
|
const rowBottom = new Map(rows.map((row) => [row, Math.max(...byRow.get(row).map((s) => s.y + s.height))]));
|
|
const nextRow = (row) => rows.find((candidate) => candidate > row);
|
|
const previousRow = (row) => [...rows].reverse().find((candidate) => candidate < row);
|
|
|
|
// Gap g sits below row g. The gap under the last row borders the legend,
|
|
// so it only receives tracks when nothing else is possible.
|
|
function gapBand(row) {
|
|
const below = nextRow(row);
|
|
const top = rowBottom.get(row) + TRACK_TOP_CLEARANCE;
|
|
const bottom = below === undefined ? rowBottom.get(row) + 40 : rowTop.get(below) - TRACK_BOTTOM_CLEARANCE;
|
|
return [top, Math.max(top, bottom)];
|
|
}
|
|
|
|
function blocksVertical(x, fromRow, toRow, exclude) {
|
|
const [low, high] = fromRow < toRow ? [fromRow, toRow] : [toRow, fromRow];
|
|
return [...states.values()].some((state) => {
|
|
if (exclude.has(state.id)) return false;
|
|
const row = rowOf(state);
|
|
return row > low && row < high && x >= state.x - 6 && x <= state.x + state.width + 6;
|
|
});
|
|
}
|
|
|
|
function blocksHorizontal(from, to) {
|
|
const row = rowOf(from);
|
|
const [left, right] = from.cx < to.cx ? [from, to] : [to, from];
|
|
return byRow.get(row).some((state) => state !== left && state !== right
|
|
&& state.x < right.x && state.x + state.width > left.x + left.width
|
|
&& state.y < Math.max(left.y + left.height, right.y + right.height)
|
|
&& state.y + state.height > Math.min(left.y, right.y));
|
|
}
|
|
|
|
// The empty corridors between neighbouring columns.
|
|
const corridorXs = columnXs.slice(1).map((x, index) => (x + columnXs[index]) / 2);
|
|
const allStates = [...states.values()];
|
|
const leftmostX = Math.min(...allStates.map((state) => state.cx));
|
|
const rightmostX = Math.max(...allStates.map((state) => state.cx));
|
|
const gridLeft = Math.min(...allStates.map((state) => state.x));
|
|
const gridRight = Math.max(...allStates.map((state) => state.x + state.width));
|
|
// The initial-state marker occupies a start state's left side.
|
|
function loopBlocked(from, to, side) {
|
|
if (side !== 'left') return false;
|
|
const [low, high] = [Math.min(rowOf(from), rowOf(to)), Math.max(rowOf(from), rowOf(to))];
|
|
return allStates.some((state) => state.type === 'start' && Math.abs(state.cx - from.cx) < 1
|
|
&& rowOf(state) >= low && rowOf(state) <= high);
|
|
}
|
|
|
|
// Plan: sides, the gap each horizontal run uses, and where each end heads.
|
|
const plans = new Map();
|
|
for (const transition of transitions) {
|
|
const from = states.get(transition.from);
|
|
const to = states.get(transition.to);
|
|
if (!from || !to || from === to) continue;
|
|
const fromRow = rowOf(from);
|
|
const toRow = rowOf(to);
|
|
if (!Number.isInteger(fromRow) || !Number.isInteger(toRow)) continue;
|
|
const exclude = new Set([from.id, to.id]);
|
|
if (fromRow === toRow) {
|
|
if (!blocksHorizontal(from, to)) {
|
|
const fromSide = to.cx > from.cx ? 'right' : 'left';
|
|
plans.set(transition, { kind: 'horizontal', from, to, fromSide, toSide: opposite[fromSide] });
|
|
} else {
|
|
const gapRow = previousRow(fromRow);
|
|
const useAbove = gapRow !== undefined;
|
|
const side = useAbove ? 'top' : 'bottom';
|
|
plans.set(transition, {
|
|
kind: 'channel', from, to, fromSide: side, toSide: side,
|
|
runs: [{ gap: useAbove ? gapRow : fromRow, legs: useAbove ? ['down', 'down'] : ['up', 'up'] }],
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
const down = toRow > fromRow;
|
|
const fromSide = down ? 'bottom' : 'top';
|
|
const toSide = down ? 'top' : 'bottom';
|
|
const gapNearTarget = down ? previousRow(toRow) : toRow;
|
|
const gapNearSource = down ? fromRow : previousRow(fromRow);
|
|
const legs = down ? ['up', 'down'] : ['down', 'up'];
|
|
if (!blocksVertical(from.cx, fromRow, toRow, exclude) && Math.abs(from.cx - to.cx) < 1) {
|
|
plans.set(transition, { kind: 'vertical', from, to, fromSide, toSide, runs: [{ gap: gapNearTarget, legs }] });
|
|
} else if (!blocksVertical(from.cx, fromRow, toRow, exclude)) {
|
|
plans.set(transition, { kind: 'channel', from, to, fromSide, toSide, runs: [{ gap: gapNearTarget, legs }] });
|
|
} else if (!blocksVertical(to.cx, fromRow, toRow, exclude)) {
|
|
plans.set(transition, { kind: 'channel', from, to, fromSide, toSide, runs: [{ gap: gapNearSource, legs }] });
|
|
} else if (!transition.label && !transition.note
|
|
&& Math.abs(from.cx - to.cx) < 1 && (from.cx <= leftmostX || from.cx >= rightmostX)
|
|
&& !loopBlocked(from, to, from.cx <= leftmostX ? 'left' : 'right')) {
|
|
// A blocked edge column loops around the outside of the grid, like a
|
|
// bracket, instead of weaving through the rows' interior corridors.
|
|
// The margin has no room for a label, so only unlabeled edges loop.
|
|
const side = from.cx <= leftmostX ? 'left' : 'right';
|
|
plans.set(transition, { kind: 'loop', from, to, fromSide: side, toSide: side, side });
|
|
} else {
|
|
const middle = (from.cx + to.cx) / 2;
|
|
const corridor = corridorXs
|
|
.filter((x) => !blocksVertical(x, fromRow, toRow, new Set()))
|
|
.sort((a, b) => Math.abs(a - middle) - Math.abs(b - middle))[0];
|
|
plans.set(transition, corridor === undefined
|
|
? { kind: 'channel', from, to, fromSide, toSide, runs: [{ gap: gapNearTarget, legs }] }
|
|
: {
|
|
kind: 'corridor', from, to, fromSide, toSide, corridor,
|
|
runs: [{ gap: gapNearSource, legs }, { gap: gapNearTarget, legs }],
|
|
});
|
|
}
|
|
}
|
|
|
|
// Corridor offsets: routes sharing one corridor run side by side.
|
|
const corridorUse = new Map();
|
|
for (const plan of plans.values()) {
|
|
if (plan.kind !== 'corridor') continue;
|
|
const list = corridorUse.get(plan.corridor) || [];
|
|
list.push(plan);
|
|
corridorUse.set(plan.corridor, list);
|
|
}
|
|
for (const [x, list] of corridorUse) {
|
|
list.sort((a, b) => a.from.cx - b.from.cx || a.to.cx - b.to.cx);
|
|
list.forEach((plan, index) => { plan.corridorX = x + (index - (list.length - 1) / 2) * CORRIDOR_SPACING; });
|
|
}
|
|
// Outer loops nest: a longer span sits further out so loops never cross.
|
|
for (const side of ['left', 'right']) {
|
|
const loops = [...plans.values()].filter((plan) => plan.kind === 'loop' && plan.side === side)
|
|
.sort((a, b) => Math.abs(rowOf(a.from) - rowOf(a.to)) - Math.abs(rowOf(b.from) - rowOf(b.to)));
|
|
loops.forEach((plan, index) => {
|
|
plan.loopX = side === 'left' ? gridLeft - 18 - index * CORRIDOR_SPACING : gridRight + 18 + index * CORRIDOR_SPACING;
|
|
});
|
|
}
|
|
|
|
// Where each end heads after leaving its side, used to order the ports.
|
|
function headingFor(plan, end) {
|
|
const self = end === 'source' ? plan.from : plan.to;
|
|
const other = end === 'source' ? plan.to : plan.from;
|
|
if (plan.kind === 'horizontal' || plan.kind === 'loop') return other.cy;
|
|
if (plan.kind === 'corridor') return plan.corridorX;
|
|
return other === self ? self.cx : other.cx;
|
|
}
|
|
|
|
const sideEnds = new Map();
|
|
for (const [transition, plan] of plans) {
|
|
for (const end of ['source', 'target']) {
|
|
const state = end === 'source' ? plan.from : plan.to;
|
|
const side = end === 'source' ? plan.fromSide : plan.toSide;
|
|
const key = `${state.id}:${side}`;
|
|
if (!sideEnds.has(key)) sideEnds.set(key, { state, side, ends: [] });
|
|
// Movers in the positive direction (right/down) take the first slot so
|
|
// both ends of a reciprocal pair line up.
|
|
const positive = plan.kind === 'horizontal'
|
|
? plan.to.cx > plan.from.cx
|
|
: rowOf(plan.to) > rowOf(plan.from) || (rowOf(plan.to) === rowOf(plan.from) && plan.to.cx > plan.from.cx);
|
|
sideEnds.get(key).ends.push({ transition, end, heading: headingFor(plan, end), positive });
|
|
}
|
|
}
|
|
|
|
const ports = new Map();
|
|
for (const { state, side, ends } of sideEnds.values()) {
|
|
ends.sort((a, b) => a.heading - b.heading || Number(b.positive) - Number(a.positive));
|
|
const horizontalSide = side === 'top' || side === 'bottom';
|
|
const length = horizontalSide ? state.width : state.height;
|
|
const gutter = Math.min(PORT_GUTTER, length / 4);
|
|
const spacing = ends.length > 1 ? Math.min(PORT_SPACING, (length - gutter * 2) / (ends.length - 1)) : 0;
|
|
ends.forEach((entry, index) => {
|
|
const offset = (index - (ends.length - 1) / 2) * spacing;
|
|
const point = horizontalSide
|
|
? [state.cx + offset, side === 'top' ? state.y : state.y + state.height]
|
|
: [side === 'left' ? state.x : state.x + state.width, state.cy + offset];
|
|
const record = ports.get(entry.transition) || {};
|
|
record[entry.end] = point;
|
|
ports.set(entry.transition, record);
|
|
});
|
|
}
|
|
|
|
// Straight connections whose spread ports landed a few px apart would
|
|
// otherwise need a jog shorter than a readable turn: move one end onto the
|
|
// other's line when that side still has room there.
|
|
function sideRange(state, side) {
|
|
return side === 'top' || side === 'bottom'
|
|
? [state.x + PORT_GUTTER / 2, state.x + state.width - PORT_GUTTER / 2]
|
|
: [state.y + PORT_GUTTER / 2, state.y + state.height - PORT_GUTTER / 2];
|
|
}
|
|
function portsOnSide(state, side, except) {
|
|
return (sideEnds.get(`${state.id}:${side}`)?.ends || [])
|
|
.filter((entry) => entry.transition !== except)
|
|
.map((entry) => ports.get(entry.transition)[entry.end]);
|
|
}
|
|
for (const [transition, plan] of plans) {
|
|
const record = ports.get(transition);
|
|
const axis = plan.kind === 'horizontal' ? 1 : 0;
|
|
const delta = Math.abs(record.source[axis] - record.target[axis]);
|
|
if (delta < 0.5 || delta >= SNAP_LIMIT || !(plan.kind === 'horizontal' || plan.kind === 'vertical' || plan.kind === 'channel')) continue;
|
|
for (const [end, fixed] of [['target', 'source'], ['source', 'target']]) {
|
|
const state = end === 'source' ? plan.from : plan.to;
|
|
const side = end === 'source' ? plan.fromSide : plan.toSide;
|
|
const value = record[fixed][axis];
|
|
const [low, high] = sideRange(state, side);
|
|
const crowded = portsOnSide(state, side, transition).some((point) => Math.abs(point[axis] - value) < 10);
|
|
if (value >= low && value <= high && !crowded) {
|
|
record[end] = axis ? [record[end][0], value] : [value, record[end][1]];
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Horizontal runs per gap, then tracks ordered to minimize crossings.
|
|
const runsByGap = new Map();
|
|
for (const [transition, plan] of plans) {
|
|
if (plan.kind === 'horizontal' || plan.kind === 'loop') continue;
|
|
const { source, target } = ports.get(transition);
|
|
plan.runs.forEach((run, index) => {
|
|
const x1 = index === 0 ? source[0] : plan.corridorX;
|
|
const x2 = plan.kind === 'corridor' && index === 0 ? plan.corridorX : target[0];
|
|
if (plan.kind !== 'corridor' && Math.abs(x1 - x2) < 0.5) return;
|
|
const entry = { transition, index, x1, x2, legs: run.legs };
|
|
if (!runsByGap.has(run.gap)) runsByGap.set(run.gap, []);
|
|
runsByGap.get(run.gap).push(entry);
|
|
});
|
|
}
|
|
|
|
const trackY = new Map();
|
|
const trackCounts = new Map();
|
|
for (const [gap, runs] of runsByGap) {
|
|
const [top, bottom] = gapBand(gap);
|
|
// Greedy interval colouring keeps unrelated runs on shared tracks only
|
|
// when they do not overlap.
|
|
const sorted = [...runs].sort((a, b) => Math.min(a.x1, a.x2) - Math.min(b.x1, b.x2));
|
|
const classes = [];
|
|
for (const run of sorted) {
|
|
const low = Math.min(run.x1, run.x2) - 8;
|
|
const high = Math.max(run.x1, run.x2) + 8;
|
|
let target = classes.find((members) => members.every((other) => (
|
|
high < Math.min(other.x1, other.x2) - 8 || low > Math.max(other.x1, other.x2) + 8
|
|
)));
|
|
if (!target || runs.length <= EXHAUSTIVE_TRACK_LIMIT) {
|
|
target = [];
|
|
classes.push(target);
|
|
}
|
|
target.push(run);
|
|
}
|
|
const count = classes.length;
|
|
trackCounts.set(gap, count);
|
|
// The renderer sizes each gap for its track count; a fixed canvas that
|
|
// cannot grow compresses the tracks rather than leaving the gap.
|
|
const spacing = count > 1 ? Math.min(MAX_TRACK_SPACING, (bottom - top) / (count - 1)) : 0;
|
|
const center = (top + bottom) / 2;
|
|
const ys = classes.map((_, index) => center + (index - (count - 1) / 2) * spacing);
|
|
const crossings = (order) => {
|
|
const y = new Map();
|
|
order.forEach((members, index) => members.forEach((run) => y.set(run, ys[index])));
|
|
let total = 0;
|
|
for (const a of runs) {
|
|
for (const b of runs) {
|
|
if (a === b || a.transition === b.transition) continue;
|
|
const [low, high] = [Math.min(a.x1, a.x2), Math.max(a.x1, a.x2)];
|
|
for (const [x, leg] of [[b.x1, b.legs[0]], [b.x2, b.legs[1]]]) {
|
|
// An up leg and a down leg on one x overlap when the down leg
|
|
// starts above where the up leg ends: that merges two routes.
|
|
for (const [ax, aLeg] of [[a.x1, a.legs[0]], [a.x2, a.legs[1]]]) {
|
|
if (Math.abs(ax - x) < 1 && aLeg === 'up' && leg === 'down' && y.get(b) < y.get(a)) total += 100;
|
|
}
|
|
if (x <= low + 0.5 || x >= high - 0.5) continue;
|
|
if ((leg === 'up' && y.get(a) < y.get(b)) || (leg === 'down' && y.get(a) > y.get(b))) total += 1;
|
|
}
|
|
}
|
|
}
|
|
return total;
|
|
};
|
|
let best = classes;
|
|
let bestScore = crossings(best);
|
|
if (count <= EXHAUSTIVE_TRACK_LIMIT) {
|
|
for (const order of permutations(classes).slice(1)) {
|
|
const score = crossings(order);
|
|
if (score < bestScore) {
|
|
best = order;
|
|
bestScore = score;
|
|
}
|
|
}
|
|
} else {
|
|
// Too many tracks to enumerate: swap pairs while that still helps.
|
|
for (let improved = true; improved;) {
|
|
improved = false;
|
|
for (let i = 0; i < count && !improved; i += 1) {
|
|
for (let j = i + 1; j < count && !improved; j += 1) {
|
|
const order = [...best];
|
|
[order[i], order[j]] = [order[j], order[i]];
|
|
const score = crossings(order);
|
|
if (score < bestScore) {
|
|
best = order;
|
|
bestScore = score;
|
|
improved = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
best.forEach((members, index) => members.forEach((run) => trackY.set(`${gap}:${run.index}:${transitions.indexOf(run.transition)}`, ys[index])));
|
|
}
|
|
|
|
const pathCache = new Map();
|
|
function pointsFor(transition) {
|
|
const plan = plans.get(transition);
|
|
if (!plan) {
|
|
// Self transitions and unknown endpoints are rejected by validation;
|
|
// a degenerate stub keeps that diagnostic reachable.
|
|
const state = states.get(transition.from) || states.get(transition.to);
|
|
const point = state ? [state.x + state.width, state.cy] : [0, 0];
|
|
return [point, point];
|
|
}
|
|
const { source, target } = ports.get(transition);
|
|
if (plan.kind === 'horizontal') {
|
|
if (Math.abs(source[1] - target[1]) < 0.5) return [source, target];
|
|
const x = (source[0] + target[0]) / 2;
|
|
return [source, [x, source[1]], [x, target[1]], target];
|
|
}
|
|
if (plan.kind === 'loop') return [source, [plan.loopX, source[1]], [plan.loopX, target[1]], target];
|
|
const trackFor = (index) => trackY.get(`${plan.runs[index].gap}:${index}:${transitions.indexOf(transition)}`);
|
|
if (plan.kind === 'corridor') {
|
|
const y1 = trackFor(0);
|
|
const y2 = trackFor(1);
|
|
return [source, [source[0], y1], [plan.corridorX, y1], [plan.corridorX, y2], [target[0], y2], target];
|
|
}
|
|
if (Math.abs(source[0] - target[0]) < 0.5) return [source, target];
|
|
const y = trackFor(0);
|
|
return [source, [source[0], y], [target[0], y], target];
|
|
}
|
|
|
|
// Ports of two routes can still line up across a gap so their vertical
|
|
// runs share one line. Nudge a turning route's end sideways on its side.
|
|
for (const transition of plans.keys()) pathCache.set(transition, pointsFor(transition));
|
|
const verticals = (points) => points.slice(1).flatMap((end, index) => {
|
|
const start = points[index];
|
|
if (Math.abs(start[0] - end[0]) >= 0.5 || Math.abs(start[1] - end[1]) < 0.5) return [];
|
|
return [{ x: start[0], low: Math.min(start[1], end[1]), high: Math.max(start[1], end[1]), index, last: index === points.length - 2 }];
|
|
});
|
|
for (let round = 0; round < 12; round += 1) {
|
|
const entries = [...pathCache.entries()];
|
|
let conflict = null;
|
|
for (let i = 0; i < entries.length && !conflict; i += 1) {
|
|
for (let j = i + 1; j < entries.length && !conflict; j += 1) {
|
|
for (const left of verticals(entries[i][1])) {
|
|
const right = verticals(entries[j][1]).find((other) => Math.abs(other.x - left.x) < 1
|
|
&& Math.min(other.high, left.high) - Math.max(other.low, left.low) > 0.5);
|
|
if (right) {
|
|
conflict = [[entries[i][0], left], [entries[j][0], right]];
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (!conflict) break;
|
|
let moved = false;
|
|
for (const [transition, segment] of conflict) {
|
|
const plan = plans.get(transition);
|
|
const end = segment.index === 0 ? 'source' : segment.last ? 'target' : null;
|
|
if (!end || pathCache.get(transition).length < 4 || !['channel', 'corridor'].includes(plan.kind)) continue;
|
|
const state = end === 'source' ? plan.from : plan.to;
|
|
const side = end === 'source' ? plan.fromSide : plan.toSide;
|
|
const record = ports.get(transition);
|
|
const [low, high] = sideRange(state, side);
|
|
const x = [12, -12, 20, -20].map((delta) => record[end][0] + delta).find((candidate) => (
|
|
candidate >= low && candidate <= high
|
|
&& !portsOnSide(state, side, transition).some((point) => Math.abs(point[0] - candidate) < 10)
|
|
));
|
|
if (x === undefined) continue;
|
|
record[end] = [x, record[end][1]];
|
|
pathCache.set(transition, pointsFor(transition));
|
|
moved = true;
|
|
break;
|
|
}
|
|
if (!moved) break;
|
|
}
|
|
|
|
return {
|
|
// Height a gap below `row` needs for its tracks at a readable spacing.
|
|
gapHeight(row) {
|
|
const count = trackCounts.get(row) || 0;
|
|
return TRACK_TOP_CLEARANCE + TRACK_BOTTOM_CLEARANCE + Math.max(0, count - 1) * PREFERRED_TRACK_SPACING;
|
|
},
|
|
connectionSides(transition) {
|
|
const plan = plans.get(transition);
|
|
return plan ? { fromSide: plan.fromSide, toSide: plan.toSide } : { fromSide: 'right', toSide: 'right' };
|
|
},
|
|
pathFor(transition) {
|
|
if (!pathCache.has(transition)) pathCache.set(transition, pointsFor(transition));
|
|
return pathCache.get(transition);
|
|
},
|
|
};
|
|
}
|