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:
2026-09-30 12:44:15 +03:00
co-authored by Claude Opus 5.5
commit 22815a9940
119 changed files with 66708 additions and 0 deletions
+171
View File
@@ -0,0 +1,171 @@
# Lifecycle Renderer
Render `diagram_type: "lifecycle"` JSON files into the standard Archify HTML
template.
```bash
node archify/renderers/lifecycle/render-lifecycle.mjs input.lifecycle.json output.html
```
The renderer validates input against `archify/schemas/lifecycle.schema.json`
with the bundled standalone validator. No dependency installation is required.
If `output.html` is omitted, the renderer uses the required `meta.output` value
from the JSON file.
## Input
Lifecycle JSON files must set:
```json
{
"schema_version": 2,
"diagram_type": "lifecycle",
"meta": {
"title": "Deployment Release Lifecycle",
"output": "deployment-release-lifecycle.html"
},
"lanes": [],
"states": [],
"transitions": [],
"cards": []
}
```
`schema_version` is `1` or `2`; author new diagrams as `2`. Lane ids `main`
(required) and `terminal` are reserved in both versions.
- **v2** renders one row per populated lane: `main` first, `terminal` last,
other lanes in `lanes[]` order, each titled in the left gutter. A complete
example lives at `archify/examples/deployment-release.lifecycle.json`.
- **v1** keeps the fixed three bands: `main` is the top phase band,
`terminal` the bottom outcome band, and every other lane shares the middle
event band, whose header joins their labels with ` + `. A complete example
lives at `archify/examples/agent-run.lifecycle.json`.
The schema lives at:
```text
archify/schemas/lifecycle.schema.json
```
## Legend and state marks
State color follows `states[].type`: active and start are cyan, waiting amber,
decision purple, success green, failure rose, neutral and external slate.
Structure is drawn, not colored: every `start` state gets a UML initial marker
(a dot and arrow into its left side), and a state with no outgoing transition
gets a double border as a final state (in v1, a main state followed by
another main column is not final, because the implied rail continues).
The default legend derives kinds from `states[].type`; the `start` entry shows
the initial marker, and a non-interactive `final` entry appears when a final
state exists. Supported `meta.legend.entries` keys, in stable order, are
`start`, `active`, `waiting`, `decision`, `success`, `failure`, `neutral`, and
`external`. Labels and visibility may be overridden through the shared legend
contract; only kinds backed by rendered states receive Semantic Legend
controls.
State decorations share one top rail: the type sigil and `step` on the left,
the brand mark at the right corner, and the Viewer's runtime source badge just
left of the brand. Label layout reserves the badge's width whenever the state
has verified repository sources.
## Layout budget (v2)
| Item | Value |
|------|-------|
| Columns | `col` 0–4, one x grid shared by every row |
| Default state | 140×64 (text 11px label, 8px sublabel and tag) |
| Column gap | 64px, widened until a labelled same-row neighbour transition fits beside its line; all gaps shrink toward 44px when the canvas would exceed the desktop readability budget of the smallest state text |
| Row gap | at least 120px, opened further for the horizontal tracks its routes need |
| Canvas | sized from the rows, columns, and measured legend when `meta.viewBox` is omitted; an authored `viewBox` is honored and validated |
Transitions without `via`, a channel, or a non-`auto` route use the v2 grid
router: neighbours in one row connect horizontally (a reciprocal pair runs as
two parallel lines), rows connect through the facing top/bottom sides with one
turn in a row gap, a state blocking a straight descent sends the route through
the empty corridor between columns, and an unlabelled route blocked in an
outer column loops around the outside of the grid. Each gap assigns tracks in
the order that minimizes crossings. There is no implied rail; a forward
transition between two `main` states without a `variant` renders as the
emphasized primary path. Showcase labels are ranked beside their line, then on
it, then outward past neighbouring parallels.
Explicit `fromSide` / `toSide` values remain authoritative. Pins that match the
grid router's chosen sides keep its routes and adaptive row gaps. If an automatic
transition pins a different side, the scene uses the shared side-aware obstacle
planner, retaining the v2 state grid and shared port spreading.
## Layout budget (v1)
| Band | Lane id | Top y | Column centers | Default state |
|------|---------|-------|----------------|---------------|
| Phase | `main` (required) | 126 | `col` 0–4 → x = 94, 248, 402, 556, 710 | 118×62 |
| Event | any other id | 278 | `col` 0–2 → x = 402, 556, 710 | 126×58 |
| Outcome | `terminal` | 450 | `col` 0–2 → x = 402, 556, 710 | 118×58 |
Event and terminal columns are intentionally offset from the main rail:
event/terminal `col: N` uses the same x coordinate as main `col: N + 2`.
For example, lower-band columns 0, 1, and 2 align beneath main columns 2, 3,
and 4 respectively.
| Constant | Value |
|----------|-------|
| viewBox | default `[980, 660]`; schema minimum `[420, 566]` |
| State area | x within `[32, width − 32]`; state bottom at or above `height − 122` |
| State spacing | ≥10px between any two states — checked across lanes, because all event lanes share one band; separate same-band states with `col` or `yOffset` |
| Transition length | ≥32px between endpoints |
| Legend row | final baseline y = height − 36; extra measured rows wrap upward |
The primary lifecycle rail runs along the phase band and extends to the
furthest occupied phase column. Route presets for transitions: `straight`,
`drop` (bend at `channelY`, defaulting to the vertical midpoint),
`bottom-channel`, `top-channel`, `right-channel`, `left-channel`, explicit
`via` points, or the default `auto`. Multi-segment transitions get rounded
corners; tune them with `cornerRadius` (default 10, `0` for sharp bends).
Transition `label` and `note` are independently optional. A non-empty `note`
renders even when `label` is omitted or empty, using its existing secondary
text style on a single row and retaining the note's fine-detail visibility.
With both fields present, the note stays below the label. Notes participate in
automatic label placement, route-space reservation, and label collision checks;
the existing `labelAt`, `labelDx`, `labelDy`, and
`labelSegment` controls also position a note-only text block.
## Design Rules
- Treat lifecycle diagrams as a phase map, not a dense state-transition graph.
- Put the primary lifecycle on one horizontal row using the `main` lane; in v2,
author each step of it as a transition.
- In v2, place an interruption, recovery, or exit in the column of the state it
leaves so its transition drops straight down.
- Use `step` labels for ordered phases, such as `01`, `02`, and `03`.
- Use lower lanes only for interruptions, recovery, and terminal exits.
- Keep transition labels out of the main SVG unless the label is essential;
prefer node labels, tags, legend entries, and summary cards.
- Prefer axis-aligned lines and avoid crossings. Terminal exits should drop
vertically from their source event whenever possible. Explicit `straight`
routes remain supported; see the [authored routing contract](../../references/authoring-contract.md#executable-geometry-rules).
- Use `success` for completion, `failure` for failure/terminal exits,
`waiting` for pauses, and `decision` for quality gates.
Schema violations exit non-zero with path-prefixed messages annotated with the
element's id or label. The renderer additionally fails when it can detect
layout problems, including a missing `main` lane, duplicate state IDs, unknown
lanes, unknown transition endpoints, states outside the lifecycle area,
overlapping states (including across lanes), labels colliding with states or
other labels, labels wider than their state, unreadably short transitions, or
transitions crossing unrelated states (2px Clean Flow clearance). Lifecycle
bands remain intentional pass-through containers.
Text width is estimated CJK-aware: fullwidth glyphs count as two units.
Set `meta.quality_profile` to `showcase` for polished delivery. Unrelated proper
X crossings then fail with `composition/proper-crossing`; default `standard`
keeps them as artifact-receipt warnings. The final artifact check samples
rounded `Q` corners. Collinear corridors remain outside the proper-X rule, but
a separate gate warns in `standard` and fails in `showcase` when unrelated
transitions overlap for at least 8px. Shared semantic endpoints, point touches,
and shorter overlaps remain valid. Showcase also rejects any route segment
below 8px and any interior turn segment below 16px; ordinary 8–15px endpoint
stubs remain valid.
+429
View File
@@ -0,0 +1,429 @@
// 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);
},
};
}
+938
View File
@@ -0,0 +1,938 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { esc, renderDefinitions, renderSemanticSigil, textUnits } from '../shared/utils.mjs';
import { animateAttr, focusEdgeAttrs, focusNodeAttrs, focusNodeTitle, loadDiagramWithBrandMarks, writeDiagram, svgAccessibleText, svgRootAttrs } from '../shared/cli.mjs';
import { recordDiagnostic, throwDiagnosticProblems } from '../shared/diagnostics.mjs';
import { createRouter } from '../architecture/routing.mjs';
import { createLifecycleGridRouter } from './grid-routing.mjs';
import { placeAutomaticLabels, reservedLabelRect } from '../architecture/labels.mjs';
import { legendFootprint, resolveLegend, renderLegend as renderResolvedLegend } from '../shared/legend.mjs';
import { availableNodeTextWidth, fittedNodeFontSize, minimumNodeTextWidth, nodeLabelLayout } from '../shared/text-fit.mjs';
import { brandLabelFitWidth, brandMarkFor, brandMetadataFor, brandTopRailProblem, renderBrandMark } from '../shared/brand-marks.mjs';
import { translateMessage as i18nText } from '../shared/i18n.mjs';
import { DESKTOP_READER_DIAGRAM_WIDTH, MIN_PROJECTED_NODE_TEXT_PX } from '../shared/desktop-readability.mjs';
import {
asArray,
isFinitePoint,
rectsOverlap,
cleanEndpointSideProblems,
cleanFlowProblems,
cleanCrossingProblems,
cleanAmbiguousCorridorProblems,
cleanBorderRunProblems,
cleanRouteRhythmProblems,
cleanLabelRouteClearanceProblems,
cleanLabelCanvasContainmentProblems,
suggestLabelObstacleFix,
suggestLabelPairFix,
anchor,
automaticPortSpread,
legacyDefaultFromSide as defaultFromSide,
legacyDefaultToSide as defaultToSide,
chosenSide,
roundedPath,
routePointsValue,
authoredStraightRouteAttrs,
labelPoint,
arrowClassMap,
edgeLabelAccent
} from '../shared/geometry.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const { diagram: lifecycle, template, outPath, sourceEvidence } = await loadDiagramWithBrandMarks({
rendererDir: __dirname,
diagramType: 'lifecycle',
defaultExample: 'agent-run.lifecycle.json'
});
// v2 sets state text one step larger; its canvas width is then budgeted
// from the smallest fitted text so the desktop Reader keeps it legible.
const stateTextFit = lifecycle.schema_version === 2 ? {
labelPreferred: 11,
labelMinimum: 9,
sublabelPreferred: 8,
sublabelMinimum: 7,
tagPreferred: 8,
tagMinimum: 7,
step: 8,
} : {
labelPreferred: 10,
labelMinimum: 8,
sublabelPreferred: 7,
sublabelMinimum: 6,
tagPreferred: 7,
tagMinimum: 6,
step: 7,
};
// schema_version 2 replaces the three fixed bands (main/event/outcome, with
// non-main lanes sharing one band and lower columns offset by +2) with one
// row per populated lane on a shared 0..4 column grid. v1 keeps its exact
// state geometry; only presentation (colors, markers, legend, sigil side)
// is shared between versions.
const isV2 = lifecycle.schema_version === 2;
const authoredViewBox = lifecycle.meta?.viewBox;
const layout = {
phaseY: 126,
eventY: 278,
outcomeY: 450,
phaseW: 118,
phaseH: 62,
eventW: 126,
eventH: 58,
outcomeW: 118,
outcomeH: 58,
phaseXs: [94, 248, 402, 556, 710],
eventXs: [402, 556, 710],
outcomeXs: [402, 556, 710]
};
const layoutV2 = {
stateW: 140,
stateH: 64,
marginX: 60,
minGap: 64,
floorGap: 44,
firstRowTop: 56,
rowPitch: 184,
};
function transitionLabelWidth(transition) {
const longestLine = Math.max(textUnits(transition.label), textUnits(transition.note || ''));
return Math.max(32, longestLine * 4.9 + 12);
}
function stateFontSizes(state, width) {
return {
label: fittedNodeFontSize(state.label, brandLabelFitWidth(state, width), stateTextFit.labelPreferred, stateTextFit.labelMinimum),
sublabel: fittedNodeFontSize(state.sublabel, width, stateTextFit.sublabelPreferred, stateTextFit.sublabelMinimum),
tag: fittedNodeFontSize(state.tag, width, stateTextFit.tagPreferred, stateTextFit.tagMinimum),
};
}
// v2 column centers. A gap between two columns widens until the label of a
// same-row neighbour transition fits beside its line; the whole canvas then
// stays inside the desktop readability budget of its smallest state text.
const v2ColumnCenters = (() => {
if (!isV2) return [];
const authored = asArray(lifecycle.states);
const widths = [0, 1, 2, 3, 4].map((col) => Math.max(
layoutV2.stateW, ...authored.filter((state) => state.col === col).map((state) => state.width || 0),
));
const cols = authored.map((state) => state.col).filter((col) => Number.isInteger(col) && col >= 0 && col <= 4);
const lastCol = cols.length ? Math.max(...cols) : 0;
const gaps = [0, 1, 2, 3].map(() => layoutV2.minGap);
const byId = new Map(authored.map((state) => [state.id, state]));
for (const transition of asArray(lifecycle.transitions)) {
const [from, to] = [byId.get(transition.from), byId.get(transition.to)];
if (!(transition.label || transition.note) || !from || !to || from.lane !== to.lane
|| !Number.isInteger(from.col) || !Number.isInteger(to.col) || Math.abs(from.col - to.col) !== 1) continue;
const gap = Math.min(from.col, to.col);
if (gap >= 0 && gap < 4) gaps[gap] = Math.max(gaps[gap], Math.ceil(transitionLabelWidth(transition) + 24));
}
const smallestText = Math.min(stateTextFit.step, ...authored.flatMap((state) => (
Object.values(stateFontSizes(state, state.width || layoutV2.stateW))
)));
const budget = Math.floor(DESKTOP_READER_DIAGRAM_WIDTH * smallestText / MIN_PROJECTED_NODE_TEXT_PX);
const fixed = layoutV2.marginX * 2 + widths.slice(0, lastCol + 1).reduce((sum, width) => sum + width, 0);
const used = gaps.slice(0, lastCol);
const excess = fixed + used.reduce((sum, gap) => sum + gap, 0) - budget;
const slack = used.reduce((sum, gap) => sum + gap - layoutV2.floorGap, 0);
if (excess > 0 && slack > 0) {
const ratio = Math.min(1, excess / slack);
for (let index = 0; index < used.length; index += 1) {
gaps[index] = Math.floor(gaps[index] - (gaps[index] - layoutV2.floorGap) * ratio);
}
}
const centers = [];
let x = layoutV2.marginX;
for (let col = 0; col <= 4; col += 1) {
centers.push(x + widths[col] / 2);
x += widths[col] + (gaps[col] ?? 0);
}
return centers;
})();
// Rows render in authored lane order with `main` first and `terminal` last;
// lanes without states get no row and no title.
function laneRowOrder() {
const lanes = asArray(lifecycle.lanes);
const populated = new Set(asArray(lifecycle.states).map((state) => state.lane));
const ordered = [];
if (populated.has('main')) ordered.push('main');
for (const lane of lanes) {
if (lane.id !== 'main' && lane.id !== 'terminal' && populated.has(lane.id)) ordered.push(lane.id);
}
if (populated.has('terminal')) ordered.push('terminal');
return ordered;
}
const v2RowTop = new Map(isV2
? laneRowOrder().map((laneId, index) => [laneId, layoutV2.firstRowTop + index * layoutV2.rowPitch])
: []);
const typeClass = {
start: 'c-frontend',
active: 'c-frontend',
waiting: 'c-cloud',
decision: 'c-database',
success: 'c-backend',
failure: 'c-security',
neutral: 'c-external',
external: 'c-external'
};
const textClass = {
start: 't-frontend',
active: 't-frontend',
waiting: 't-cloud',
decision: 't-database',
success: 't-backend',
failure: 't-security',
neutral: 't-muted',
external: 't-muted'
};
// Lane semantics are fixed: lane id "main" maps to the top phase band, lane id
// "terminal" maps to the bottom outcome band, and every other lane shares the
// middle event band (separated visually via yOffset). v1 only.
function bandFor(lane) {
if (lane === 'main') return 'phase';
if (lane === 'terminal') return 'outcome';
return 'event';
}
function measureState(state) {
let width;
let height;
let cx;
let y;
if (isV2) {
width = state.width || layoutV2.stateW;
height = state.height || layoutV2.stateH;
cx = v2ColumnCenters[state.col] ?? NaN;
y = (v2RowTop.get(state.lane) ?? NaN) + (state.yOffset || 0);
} else {
const isPhase = bandFor(state.lane) === 'phase';
const isOutcome = bandFor(state.lane) === 'outcome';
width = state.width || (isPhase ? layout.phaseW : isOutcome ? layout.outcomeW : layout.eventW);
height = state.height || (isPhase ? layout.phaseH : isOutcome ? layout.outcomeH : layout.eventH);
const xs = isPhase ? layout.phaseXs : isOutcome ? layout.outcomeXs : layout.eventXs;
cx = xs[state.col] ?? xs[xs.length - 1];
y = (
isPhase ? layout.phaseY :
isOutcome ? layout.outcomeY :
layout.eventY
) + (state.yOffset || 0);
}
return {
...state,
width,
height,
x: cx - width / 2,
y,
cx,
cy: y + height / 2
};
}
const states = new Map(asArray(lifecycle.states).map((state) => [state.id, measureState(state)]));
const plannedTransitions = asArray(lifecycle.transitions).filter(plannerRouted);
const v2Rows = laneRowOrder();
const v2RowOf = (state) => (v2Rows.includes(state.lane) ? v2Rows.indexOf(state.lane) : undefined);
let useGridRouter = isV2;
if (isV2) {
// Route once to learn how many horizontal tracks each row gap carries,
// then open every gap to fit them before the final routing pass.
const probe = createLifecycleGridRouter(states, plannedTransitions, {
rowOf: v2RowOf, columnXs: v2ColumnCenters,
});
// Compatible pins keep the grid layout. A conflicting pin sends the whole
// scene to the side-aware planner so all edges still share port spreading
// and obstacle reservations.
useGridRouter = plannedTransitions.every(transition => {
const sides = probe.connectionSides(transition);
return ['fromSide', 'toSide'].every(key => !transition[key] || transition[key] === 'auto' || transition[key] === sides[key]);
});
let top = layoutV2.firstRowTop;
v2Rows.forEach((laneId, index) => {
const rowHeight = Math.max(layoutV2.stateH, ...[...states.values()]
.filter((state) => state.lane === laneId)
.map((state) => state.y + state.height - v2RowTop.get(laneId)));
v2RowTop.set(laneId, top);
top += rowHeight + Math.max(layoutV2.rowPitch - layoutV2.stateH, useGridRouter ? probe.gapHeight(index) : 0);
});
for (const state of asArray(lifecycle.states)) states.set(state.id, measureState(state));
}
const laneLabels = new Map(asArray(lifecycle.lanes).map((lane) => [lane.id, lane.label]));
const authoredOutgoing = new Set(asArray(lifecycle.transitions).map((transition) => transition.from));
// A state with no authored outgoing transition is terminal in the UML sense.
// v1 main states rely on the implied phase rail, so a main state is only final
// at the furthest occupied main column; every other v1 lane is explicit.
function isFinal(state) {
if (authoredOutgoing.has(state.id)) return false;
if (isV2) return true;
if (bandFor(state.lane) !== 'phase') return true;
const mainCols = [...states.values()].filter((s) => bandFor(s.lane) === 'phase').map((s) => s.col);
return state.col === Math.max(...mainCols);
}
const LEGEND_CATALOG = [
'start',
'active',
'waiting',
'decision',
'success',
'failure',
'neutral',
'external',
].map((kind) => ({
kind,
label: i18nText(lifecycle.meta.locale, `legend.lifecycle.${kind}`),
swatchWidth: kind === 'start' ? 26 : undefined,
}));
// Entries exist before the canvas so an auto-sized v2 viewBox can reserve the
// measured legend rows below the last row instead of painting over them.
// The structural `final` entry explains the double border; it follows the
// kind entries and disappears with them, so a hidden legend stays empty.
function legendCatalog() {
const presentKinds = new Set([...states.values()].map((state) => state.type));
const entries = resolveLegend(lifecycle.meta?.legend, LEGEND_CATALOG, presentKinds);
if (!entries.length || ![...states.values()].some(isFinal)) return entries;
return [...entries, {
kind: 'final',
label: i18nText(lifecycle.meta.locale, 'legend.lifecycle.final'),
interactive: false,
present: true,
swatchWidth: 16,
}];
}
const resolvedLegendEntries = legendCatalog();
let viewBox;
if (isV2 && !authoredViewBox) {
const finite = [...states.values()];
const maxRight = Math.max(0, ...finite.map((state) => state.cx + state.width / 2).filter(Number.isFinite));
const width = Math.max(640, Math.ceil(maxRight + layoutV2.marginX));
const footprint = legendFootprint(resolvedLegendEntries, { width: width - 80 });
const statesBottom = Math.max(0, ...finite.map((state) => state.y + state.height).filter(Number.isFinite));
viewBox = [width, Math.ceil(statesBottom + footprint.extraHeight + 96)];
} else {
viewBox = authoredViewBox || [980, 660];
}
const legendExtraHeight = legendFootprint(resolvedLegendEntries, { width: viewBox[0] - 80 }).extraHeight;
function legendY() {
return viewBox[1] - 36;
}
// Keep the authored state-placement contract independent from the measured
// legend's lower baseline. Moving legend chrome must not admit new state
// geometry into the reserved outcome/legend band.
function lifecycleAreaBottom() {
return isV2 ? viewBox[1] - legendExtraHeight - 96 : viewBox[1] - 122;
}
const stateSteps = new Map();
for (const [index, transition] of asArray(lifecycle.transitions).entries()) {
if (!stateSteps.has(transition.from)) stateSteps.set(transition.from, index);
if (!stateSteps.has(transition.to)) stateSteps.set(transition.to, index + 1);
}
for (const [index, state] of asArray(lifecycle.states).entries()) {
if (!stateSteps.has(state.id)) stateSteps.set(state.id, index);
}
function validateLifecycle() {
const problems = [];
if (states.size !== asArray(lifecycle.states).length) problems.push('State ids must be unique.');
// The three bands are fixed at y=112/264/436. Preserve the original
// outcome/legend reserve even though measured legend rows now sit lower.
// v2 derives its canvas from rendered rows, so the floor does not apply.
if (!isV2 && lifecycleAreaBottom() + 4 < 448) {
problems.push(`viewBox height ${viewBox[1]} is too short for the fixed band layout — set meta.viewBox[1] to at least 566.`);
}
const laneIds = new Set(asArray(lifecycle.lanes).map((lane) => lane.id));
if (laneIds.size !== asArray(lifecycle.lanes).length) problems.push('Lane ids must be unique.');
if (!laneIds.has('main')) {
problems.push(isV2
? 'Lifecycle diagrams need a lane with id "main" (the first row). Lane ids "main" and "terminal" are reserved: "main" renders as the first row, "terminal" as the last, and every other lane in authored order.'
: 'Lifecycle diagrams need a lane with id "main" (the phase rail). Lane ids "main" and "terminal" are reserved: "main" maps to the top phase band, "terminal" to the bottom outcome band, and all other lanes share the middle event band.');
}
for (const state of states.values()) {
if (!laneIds.has(state.lane)) {
problems.push(`State "${state.id}" uses unknown lane "${state.lane}".`);
continue;
}
if (isV2) {
if (!Number.isInteger(state.col) || state.col < 0 || state.col > 4) {
problems.push(`State "${state.id}" uses invalid column ${state.col} — every lifecycle row has integer columns 0..4.`);
continue;
}
} else {
const band = bandFor(state.lane);
const maxCol = band === 'phase'
? layout.phaseXs.length
: band === 'outcome'
? layout.outcomeXs.length
: layout.eventXs.length;
if (!Number.isInteger(state.col) || state.col < 0 || state.col >= maxCol) {
problems.push(`State "${state.id}" uses invalid column ${state.col} — the ${band} band has integer columns 0..${maxCol - 1}.`);
continue;
}
}
if (!isFinitePoint(state.x, state.y, state.cx, state.cy)) {
problems.push(`State "${state.id}" produced non-finite coordinates — check col, width, height, and yOffset are numbers.`);
continue;
}
if (state.x < (isV2 ? 28 : 32) || state.x + state.width > viewBox[0] - (isV2 ? 28 : 32)) {
problems.push(`State "${state.id}" exceeds the horizontal bounds of the diagram — reduce state.width${isV2 ? ', lower its col,' : ''} or increase meta.viewBox[0].`);
}
if (state.y < (isV2 ? 44 : 64) || state.y + state.height > lifecycleAreaBottom()) {
problems.push(`State "${state.id}" exceeds the vertical lifecycle area — keep y between ${isV2 ? 44 : 64} and ${lifecycleAreaBottom()} (adjust yOffset or increase meta.viewBox[1]).`);
}
const estLabelW = textUnits(state.label) * 6.2;
if (estLabelW > state.width + 6) {
problems.push(`Label "${state.label}" (~${Math.round(estLabelW)}px) is wider than state "${state.id}" (${state.width}px) — shorten the label or increase state.width.`);
}
const brandRailProblem = brandTopRailProblem(state, state.width, 8, 'State');
if (brandRailProblem) problems.push(brandRailProblem);
// sublabel and tag render as single unwrapped <text> elements; shrink-to-fit
// handles the ordinary case, this rejects what it cannot rescue.
const availableTextW = availableNodeTextWidth(state.width);
for (const [field, value, minimum] of [
['Sublabel', state.sublabel, stateTextFit.sublabelMinimum],
['Tag', state.tag, stateTextFit.tagMinimum],
]) {
if (!value) continue;
const minimumW = minimumNodeTextWidth(value, minimum);
if (minimumW > availableTextW) {
problems.push(`${field} "${value}" needs ~${Math.ceil(minimumW)}px at the ${minimum}px legible minimum, but state "${state.id}" provides ${availableTextW}px — shorten the ${field.toLowerCase()} or increase state.width.`);
}
}
}
// v1: all non-main/non-terminal lanes share the same y band, so the overlap
// check must run across lanes — not per-lane. v2 gives each lane its own
// row, so only same-row neighbours can collide, but the shared check still
// covers custom widths and yOffset nudges.
const allStates = [...states.values()];
for (let i = 0; i < allStates.length; i += 1) {
for (let j = i + 1; j < allStates.length; j += 1) {
if (rectsOverlap(allStates[i], allStates[j], 10)) {
problems.push(`States "${allStates[i].id}" and "${allStates[j].id}" are less than 10px apart — move one to another col or separate them with yOffset${isV2 ? '.' : ' (lanes other than "main"/"terminal" share one band).'}`);
}
}
}
for (const transition of asArray(lifecycle.transitions)) {
if (!states.has(transition.from)) problems.push(`Transition "${transition.label || transition.from}" references unknown source "${transition.from}".`);
if (!states.has(transition.to)) problems.push(`Transition "${transition.label || transition.to}" references unknown target "${transition.to}".`);
if (states.has(transition.from) && states.has(transition.to)) {
const routed = pathFor(transition);
const [start, end] = [routed.points[0], routed.points[routed.points.length - 1]];
const distance = Math.hypot(end[0] - start[0], end[1] - start[1]);
if (distance < 32) problems.push(`Transition "${transition.label || `${transition.from}->${transition.to}`}" is too short (${Math.round(distance)}px; minimum 32px) — route it through a channel or drop its label.`);
}
}
// Authored via points are authoritative in schema v1, including under a
// quality profile. Preserve and render them exactly: applying the endpoint
// gate would either reject an existing typed input or require silently
// falsifying its geometry. Automatic routes still receive the side gate.
problems.push(...cleanEndpointSideProblems({
relations: lifecycle.transitions,
endpointIds: new Set(states.keys()),
pathFor,
diagramType: 'lifecycle',
relationCollection: 'transitions',
fromSideFor: (transition) => transitionSides(transition).fromSide,
toSideFor: (transition) => transitionSides(transition).toSide,
shouldCheckRelation: (transition) => !Array.isArray(transition.via),
routeHint: 'keep automatic routing, or choose fromSide/toSide and via points whose first and final segments cross state borders perpendicularly',
}));
problems.push(...cleanFlowProblems({
relations: lifecycle.transitions,
obstacles: states.values(),
pathFor,
diagramType: 'lifecycle',
relationCollection: 'transitions',
obstacleKind: 'state',
routeHint: 'adjust fromSide/toSide, set route/via or channelX/channelY, or move the state with col/yOffset'
}));
problems.push(...cleanCrossingProblems({
relations: lifecycle.transitions,
endpointIds: new Set(states.keys()),
pathFor,
diagramType: 'lifecycle',
relationCollection: 'transitions',
profile: lifecycle.meta?.quality_profile,
// Planner routes render with the opaque crossover halo, like architecture.
crossingResolved: (left, right) => plannerRouted(left) && plannerRouted(right),
routeHint: 'adjust route/via or channelX/channelY so the transitions use separate lifecycle corridors'
}));
problems.push(...cleanAmbiguousCorridorProblems({
relations: lifecycle.transitions,
endpointIds: new Set(states.keys()),
pathFor,
diagramType: 'lifecycle',
relationCollection: 'transitions',
profile: lifecycle.meta?.quality_profile,
routeHint: 'adjust route/via or channelX/channelY so unrelated transitions do not visually merge'
}));
// Lifecycle bands are dashed reading guides, not closed containers. Keep the
// shared contract wired with an explicit empty frame set so future typed
// lifecycle containers cannot accidentally inherit presentation geometry.
problems.push(...cleanBorderRunProblems({
relations: lifecycle.transitions,
endpointIds: new Set(states.keys()),
frames: [],
pathFor,
diagramType: 'lifecycle',
relationCollection: 'transitions',
profile: lifecycle.meta?.quality_profile
}));
problems.push(...cleanRouteRhythmProblems({
relations: lifecycle.transitions,
endpointIds: new Set(states.keys()),
pathFor,
diagramType: 'lifecycle',
relationCollection: 'transitions',
profile: lifecycle.meta?.quality_profile,
routeHint: 'move route/via or channel coordinates so each lifecycle turn has a readable run-up'
}));
const labelRects = transitionLabelRects();
if (lifecycle.meta?.quality_profile === 'showcase') {
for (const rect of labelRects) {
for (const title of bandGeometry()) {
if (!rectsOverlap(rect, title)) continue;
const message = `Transition ${rect.relationIndex} label "${rect.label}" overlaps lifecycle band title "${title.label}" — move the label with labelAt/labelDx/labelDy/labelSegment or provide more space.`;
recordDiagnostic({
code: 'composition/label-band-title-overlap', severity: 'error', message,
subject: { diagramType: 'lifecycle', collection: 'transitions', index: rect.relationIndex, from: rect.relation.from, to: rect.relation.to },
evidence: { labelRect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, bandTitle: title },
supportedFixes: ['move the transition label with labelAt/labelDx/labelDy/labelSegment while preserving its text'],
});
problems.push(message);
}
}
}
for (const rect of labelRects) {
for (const state of states.values()) {
if (rectsOverlap(rect, state, -2)) {
problems.push(`Label "${rect.label}" overlaps state "${state.id}" — adjust labelDx/labelDy/labelSegment or set labelAt.\n${suggestLabelObstacleFix(rect, rect.lx, rect.ly, state, 'state', viewBox, states.values())}`);
}
}
}
for (let i = 0; i < labelRects.length; i += 1) {
for (let j = i + 1; j < labelRects.length; j += 1) {
if (rectsOverlap(labelRects[i], labelRects[j], -2)) {
problems.push(`Labels "${labelRects[i].label}" and "${labelRects[j].label}" overlap — adjust labelDx/labelDy.\n${suggestLabelPairFix(labelRects[i], labelRects[j])}`);
}
}
}
problems.push(...cleanLabelRouteClearanceProblems({
relations: lifecycle.transitions,
labels: labelRects,
endpointIds: new Set(states.keys()),
pathFor,
diagramType: 'lifecycle',
relationCollection: 'transitions',
profile: lifecycle.meta?.quality_profile,
}));
problems.push(...cleanLabelCanvasContainmentProblems({
labels: labelRects,
viewBox,
diagramType: 'lifecycle',
relationCollection: 'transitions',
profile: lifecycle.meta?.quality_profile,
}));
if (problems.length) {
throwDiagnosticProblems('Lifecycle layout validation failed', problems, {
subject: { diagramType: 'lifecycle' },
});
}
}
function routeVia(transition, from, to, start, end, fromSide, toSide) {
if (transition.via) return transition.via;
switch (transition.route || 'auto') {
case 'straight':
return [];
case 'drop': {
const y = transition.channelY ?? (start[1] + end[1]) / 2;
return [[start[0], y], [end[0], y]];
}
case 'bottom-channel': {
const y = transition.channelY ?? Math.max(from.y + from.height, to.y + to.height) + 34;
return [[start[0], y], [end[0], y]];
}
case 'top-channel': {
const y = transition.channelY ?? Math.min(from.y, to.y) - 28;
return [[start[0], y], [end[0], y]];
}
case 'right-channel': {
const x = transition.channelX ?? Math.max(from.x + from.width, to.x + to.width) + 36;
return [[x, start[1]], [x, end[1]]];
}
case 'left-channel': {
const x = transition.channelX ?? Math.min(from.x, to.x) - 36;
return [[x, start[1]], [x, end[1]]];
}
case 'auto':
default: {
if (start[0] === end[0] || start[1] === end[1]) return [];
const fromVertical = fromSide === 'top' || fromSide === 'bottom';
const toVertical = toSide === 'top' || toSide === 'bottom';
if (fromVertical !== toVertical) {
return [fromVertical ? [start[0], end[1]] : [end[0], start[1]]];
}
if (fromVertical) {
const y = transition.channelY ?? (start[1] + end[1]) / 2;
return [[start[0], y], [end[0], y]];
}
const x = transition.channelX ?? (start[0] + end[0]) / 2;
return [[x, start[1]], [x, end[1]]];
}
}
}
const pathCache = new Map();
// A transition without via, channel, or a lifecycle route preset is routed by
// the obstacle-aware planner shared with architecture, so a first draft that
// leaves routing to the renderer does not cross unrelated states or produce
// micro jogs. Authored via/route/channel geometry keeps the lifecycle presets.
function plannerRouted(transition) {
return !transition.via
&& (!transition.route || transition.route === 'auto')
&& transition.channelX === undefined
&& transition.channelY === undefined;
}
// v2 states sit on a fixed row/column grid, so automatic transitions use the
// dedicated orthogonal grid router; v1 keeps the shared obstacle planner.
const planner = useGridRouter ? createLifecycleGridRouter(states, plannedTransitions, {
rowOf: v2RowOf,
columnXs: v2ColumnCenters,
}) : createRouter(states, plannedTransitions, {
labelRectFor: (transition, points, { routes, labels }) => ((transition.label || transition.note) ? reservedLabelRect({
label: { relation: transition, label: transition.label || transition.note, ...transitionLabelBoxAt(transition, labelPoint(transition, points)) },
points,
routes: routes.map((route, index) => ({ relationIndex: index, points: route })),
labels,
components: [...states.values()],
viewBox,
placementBottom: lifecycleAreaBottom(),
}) : null),
});
function transitionSides(transition) {
if (plannerRouted(transition)) return planner.connectionSides(transition);
const from = states.get(transition.from);
const to = states.get(transition.to);
return {
fromSide: chosenSide(transition.fromSide, defaultFromSide(from, to)),
toSide: chosenSide(transition.toSide, defaultToSide(from, to)),
};
}
const automaticPorts = automaticPortSpread(
asArray(lifecycle.transitions).filter((transition) => !plannerRouted(transition)),
states,
{ sideFor: (transition, endpoint) => transitionSides(transition)[endpoint === 'source' ? 'fromSide' : 'toSide'] },
);
function pathFor(transition) {
if (pathCache.has(transition)) return pathCache.get(transition);
if (plannerRouted(transition)) {
let routed = planner.pathFor(transition);
if (useGridRouter) routed = { d: roundedPath(routed, transition.cornerRadius ?? 10), points: routed };
pathCache.set(transition, routed);
return routed;
}
const from = states.get(transition.from);
const to = states.get(transition.to);
const ports = automaticPorts.get(transition);
const { fromSide, toSide } = transitionSides(transition);
const start = ports?.from || anchor(from, fromSide);
const end = ports?.to || anchor(to, toSide);
let via = routeVia(transition, from, to, start, end, fromSide, toSide);
if (ports && !via.length && Math.abs(start[0] - end[0]) >= 4 && Math.abs(start[1] - end[1]) >= 4) {
const midX = (start[0] + end[0]) / 2;
via = [[midX, start[1]], [midX, end[1]]];
}
const points = [start, ...via, end];
const routed = {
d: roundedPath(points, transition.cornerRadius ?? 10),
points
};
pathCache.set(transition, routed);
return routed;
}
const resolvedLabelPoints = new Map();
function transitionLabelBox(transition) {
return transitionLabelBoxAt(
transition,
resolvedLabelPoints.get(transition) || labelPoint(transition, pathFor(transition).points),
);
}
function transitionLabelBoxAt(transition, [lx, ly]) {
const width = transitionLabelWidth(transition);
const height = transition.label && transition.note ? 27 : 16;
return { x: lx - width / 2, y: ly - 11, width, height, lx, ly };
}
function transitionLabelRects() {
const rects = [];
for (const [relationIndex, transition] of asArray(lifecycle.transitions).entries()) {
if (!(transition.label || transition.note) || !states.has(transition.from) || !states.has(transition.to)) continue;
rects.push({ relation: transition, relationIndex, label: transition.label || transition.note, ...transitionLabelBox(transition) });
}
return rects;
}
// Showcase drafts leave label positions to the renderer too: move an unpinned
// label off other routes and states instead of reporting a clearance defect.
if (lifecycle.meta?.quality_profile === 'showcase') {
const placed = placeAutomaticLabels({
labels: transitionLabelRects(),
routes: asArray(lifecycle.transitions).flatMap((transition, relationIndex) => (
states.has(transition.from) && states.has(transition.to)
? [{ relationIndex, points: pathFor(transition).points }] : []
)),
components: [...states.values()],
titles: bandGeometry(),
viewBox,
placementBottom: lifecycleAreaBottom(),
keepFallbackNearRoute: isV2,
gridSweep: isV2,
});
for (const rect of placed) resolvedLabelPoints.set(rect.relation, [rect.lx, rect.ly]);
}
function bandGeometry() {
if (isV2) {
// One row header per rendered row, set vertically in the left gutter so
// routes entering a row never run through its title.
return laneRowOrder().map((laneId, index) => {
const cy = v2RowTop.get(laneId) + layoutV2.stateH / 2;
const label = laneLabels.get(laneId) || laneId;
const length = textUnits(label) * 6.2;
return { index, label, vertical: true, cx: 18, cy, x: 11, y: cy - length / 2, width: 14, height: length };
});
}
const lanes = asArray(lifecycle.lanes);
const mainLane = lanes.find((lane) => lane.id === 'main');
const terminalLane = lanes.find((lane) => lane.id === 'terminal');
const eventLanes = lanes.filter((lane) => lane.id !== 'main' && lane.id !== 'terminal');
const populated = new Set([...states.values()].map((state) => bandFor(state.lane)));
return [
mainLane?.label || 'Lifecycle phases',
eventLanes.length ? eventLanes.map((lane) => lane.label).join(' + ') : 'Interruptions + recovery',
terminalLane?.label || 'Outcomes'
].map((title, index) => {
const baseline = [100, 252, 424][index];
const label = `${String(index + 1).padStart(2, '0')} / ${title}`;
return { index, band: ['phase', 'event', 'outcome'][index], label, x: 72, y: baseline - 11, width: textUnits(label) * 6.2, height: 14, baseline };
}).filter((band) => populated.has(band.band));
}
function renderBands() {
return bandGeometry().map((band) => (band.vertical
? ` <text x="${band.cx}" y="${band.cy}" class="t-dim" font-size="10" font-weight="600" writing-mode="vertical-rl" text-anchor="middle">${esc(band.label)}</text>`
: ` <path d="M ${band.x} ${band.baseline + 12} L ${viewBox[0] - band.x} ${band.baseline + 12}" class="a-default" stroke-width="0.8" stroke-dasharray="3,8"/>
<text x="${band.x}" y="${band.baseline}" class="t-dim" font-size="10" font-weight="600">${esc(band.label)}</text>`)).join('\n');
}
function renderState(state) {
const fill = typeClass[state.type] || typeClass.neutral;
const accent = textClass[state.type] || 't-muted';
const hasSub = state.sublabel != null && state.sublabel !== '';
const { label: labelFontSize, sublabel: sublabelFontSize, tag: tagFontSize } = stateFontSizes(state, state.width);
const textRows = [{ text: state.label, font: labelFontSize, y: isV2 ? 23 : 21 }];
if (hasSub) textRows.push({ text: state.sublabel, font: sublabelFontSize, y: isV2 ? 40 : 37 });
if (state.tag) textRows.push({ text: state.tag, font: tagFontSize, y: state.height - (isV2 ? 12 : 11) });
const hasBrand = Boolean(brandMarkFor(state));
const hasSource = Boolean(sourceEvidence?.nodes?.[state.id]?.length);
const labelLayout = nodeLabelLayout({ width: state.width, height: state.height, rows: textRows,
brand: hasBrand, source: hasSource, side: 'left', step: state.step });
const sub = hasSub
? `\n <text data-detail="context" x="${state.cx}" y="${state.y + labelLayout.ys[1]}" class="t-muted" font-size="${sublabelFontSize}" text-anchor="middle">${esc(state.sublabel)}</text>`
: '';
const tag = state.tag
? `\n <text data-detail="fine" x="${state.cx}" y="${state.y + labelLayout.ys[hasSub ? 2 : 1]}" class="${accent}" font-size="${tagFontSize}" text-anchor="middle">${esc(state.tag)}</text>`
: '';
const step = state.step
? `\n <text data-detail="fine" x="${state.x + 23}" y="${state.y + 14}" class="${accent}" font-size="${stateTextFit.step}" font-weight="700">${esc(state.step)}</text>`
: '';
const brand = renderBrandMark(state, { x: state.x + state.width - 22, y: state.y + 6 });
// UML pseudo-state markers: start states get an initial dot + arrow into
// the left border; states with no authored outgoing transition get a double
// border. Both are decorations, not focus/relationship edges.
const initialMarker = state.type === 'start'
? `\n <g aria-hidden="true" data-lifecycle-initial-marker="">
${initialMarkerShape(state.x - 22, state.x - 1, state.cy)}
</g>`
: '';
const finalBorder = isFinal(state)
? `\n <rect x="${state.x + 3}" y="${state.y + 3}" width="${state.width - 6}" height="${state.height - 6}" rx="4" class="${fill}" style="fill: none" stroke-width="1"/>`
: '';
const passport = {
kind: state.type,
sublabel: state.sublabel,
tag: state.tag,
context: laneLabels.get(state.lane) || i18nText(lifecycle.meta.locale, 'node.context.lifecycle'),
...brandMetadataFor(state),
};
return ` <g ${focusNodeAttrs(state.id, state.label, passport, lifecycle.meta.locale)}>
${focusNodeTitle(state.label, passport)}
<rect x="${state.x}" y="${state.y}" width="${state.width}" height="${state.height}" rx="7" class="c-mask"/>
<rect x="${state.x}" y="${state.y}" width="${state.width}" height="${state.height}" rx="7" class="${fill}"${animateAttr(lifecycle.meta, 'node', stateSteps.get(state.id))} stroke-width="1.5"/>${finalBorder}${initialMarker}
${renderSemanticSigil(state.type, { icon: state.icon, x: state.x + 6, y: state.y + labelLayout.sigilY, size: labelLayout.sigilSize })}${brand ? `\n ${brand}` : ''}${step}
<text data-node-label=""${hasSub ? ' data-detail-anchor=""' : ''} x="${state.x + labelLayout.x}" y="${state.y + labelLayout.ys[0]}" class="t-primary" font-size="${labelFontSize}" font-weight="600" text-anchor="middle">${esc(state.label)}</text>${sub}${tag}
</g>`;
}
// v2 replaces the implied phase rail with explicit topology: a forward
// transition between two main-lane states renders as the emphasized primary
// path unless the author chose another variant.
function effectiveVariant(transition) {
if (transition.variant) return transition.variant;
if (isV2) {
const from = states.get(transition.from);
const to = states.get(transition.to);
if (from?.lane === 'main' && to?.lane === 'main' && to.col > from.col) return 'emphasis';
}
return 'default';
}
function renderTransitionPath(transition, index) {
const variant = effectiveVariant(transition);
const [cls, marker] = arrowClassMap[variant] || arrowClassMap.default;
const routed = pathFor(transition);
const strokeWidth = transition.width || (variant === 'emphasis' ? (isV2 ? 1.6 : 2) : 1.1);
const automaticRoute = plannerRouted(transition);
const crossover = automaticRoute ? ' data-composition-crossover="halo"' : '';
const edge = ` <path ${focusEdgeAttrs(transition.from, transition.to, transition.label || transition.note, index, transition.id)} data-composition-points="${routePointsValue(routed.points)}"${crossover}${authoredStraightRouteAttrs(transition, routed.points)} d="${routed.d}" class="${cls}"${animateAttr(lifecycle.meta, 'edge', index)} stroke-width="${strokeWidth}" marker-end="url(#${marker})"/>`;
if (!automaticRoute) return edge;
// Same presentation-only wrapper as architecture: the mask underlay lets two
// planner routes cross legibly while the viewer still sees one semantic edge.
const underlay = ` <path data-graph-role="automatic-crossover-underlay" d="${routed.d}" fill="none" stroke="var(--mask)" stroke-width="${strokeWidth + 4}" stroke-linecap="round" stroke-linejoin="round" pointer-events="none"/>\n`;
return ` <g data-graph-role="automatic-crossover" style="--step:${index}">\n${underlay}${edge.replace(/^ /, ' ')}\n </g>`;
}
function renderTransitionLabel(transition, index) {
if (!(transition.label || transition.note)) return '';
const { lx, ly, width: labelW, height: labelH } = transitionLabelBox(transition);
const label = transition.label
? `\n <text x="${lx}" y="${ly}" class="${edgeLabelAccent(effectiveVariant(transition))}" font-size="8" text-anchor="middle">${esc(transition.label)}</text>`
: '';
const note = transition.note
? `\n <text data-detail="fine" x="${lx}" y="${ly + (transition.label ? 11 : 0)}" class="t-dim" font-size="7" text-anchor="middle">${esc(transition.note)}</text>`
: '';
return ` <g data-detail="${transition.label ? 'context' : 'fine'}" ${focusEdgeAttrs(transition.from, transition.to, transition.label || transition.note, index, transition.id)}>
<rect x="${lx - labelW / 2}" y="${ly - 11}" width="${labelW}" height="${labelH}" rx="4" class="c-mask"/>${label}${note}
</g>`;
}
// UML initial pseudo-state: a filled dot and a short arrow ending at `tipX`.
function initialMarkerShape(dotX, tipX, y) {
return `<circle cx="${dotX}" cy="${y}" r="4.5" style="fill: var(--arrow-emphasis)"/><path d="M ${dotX + 4.5} ${y} L ${tipX - 6} ${y}" class="a-emphasis" stroke-width="1.4"/><path d="M ${tipX - 7} ${y - 3.5} L ${tipX} ${y} L ${tipX - 7} ${y + 3.5} Z" style="fill: var(--arrow-emphasis)"/>`;
}
function renderSwatch(entry) {
// `start` is structural, not a color: its swatch is the same initial
// pseudo-state marker drawn on the canvas. `final` is a non-interactive
// structural entry (a double-border rect) appended when a final state exists.
if (entry.kind === 'start') {
return initialMarkerShape(entry.x + 4.5, entry.x + 24, entry.baseline - 3.5);
}
if (entry.kind === 'final') {
return `<rect x="${entry.x}" y="${entry.baseline - 8}" width="14" height="9" rx="2" class="c-external" stroke-width="1"/><rect x="${entry.x + 2.5}" y="${entry.baseline - 5.5}" width="9" height="4" rx="1" class="c-external" style="fill: none" stroke-width="0.8"/>`;
}
return `<rect x="${entry.x}" y="${entry.baseline - 8}" width="14" height="9" rx="2" class="${typeClass[entry.kind] || 'c-external'}" stroke-width="1"/>`;
}
function renderLegend() {
return renderResolvedLegend({
entries: resolvedLegendEntries,
locale: lifecycle.meta.locale,
layout: {
x: 40,
baselineY: legendY(),
width: viewBox[0] - 80,
minTitleY: lifecycleAreaBottom() + 8,
unfit: lifecycle.meta?.legend === undefined ? 'hide' : 'error',
diagramType: 'lifecycle',
},
renderSwatch,
});
}
// v1 only: the implied emphasis line behind main states. v2 topology is fully
// explicit, so the rail would double the authored forward transitions.
function renderLifecycleRail() {
if (isV2) return '';
const mainCols = [...states.values()]
.filter((state) => bandFor(state.lane) === 'phase')
.map((state) => state.col);
if (!mainCols.length) return '';
const railEnd = layout.phaseXs[mainCols.reduce((max, col) => Math.max(max, col))] + 38;
return ` <path data-lifecycle-rail="" d="M 154 ${layout.phaseY + 31} L ${railEnd} ${layout.phaseY + 31}" class="a-emphasis" stroke-width="2.2" marker-end="url(#arrowhead-emphasis)"/>`;
}
function renderSvg() {
// A renderer-sized canvas declares the intrinsic-height fit exactly like
// architecture: the default 980x660 band layout is below the 1.55 wide
// ratio, so without this the desktop Reader could neither narrow it nor
// scroll it and every default lifecycle failed the browser gate.
const readerFit = lifecycle.meta?.viewBox ? '' : ' data-reader-fit="intrinsic-height"';
return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}"${readerFit} ${svgRootAttrs(lifecycle.meta)}>
${svgAccessibleText(lifecycle.meta, 'lifecycle')}
${renderDefinitions()}
<!-- Background Grid -->
<rect width="100%" height="100%" fill="url(#grid)" />
<!-- Lifecycle bands -->
${renderBands()}
<!-- Primary lifecycle rail -->
${renderLifecycleRail()}
<!-- Transition paths -->
${asArray(lifecycle.transitions).map(renderTransitionPath).join('\n')}
<!-- States -->
${[...states.values()].map(renderState).join('\n\n')}
<!-- Transition labels -->
${asArray(lifecycle.transitions).map(renderTransitionLabel).join('\n')}
<!-- Legend -->
${renderLegend()}
</svg>`;
}
validateLifecycle();
writeDiagram({
outPath,
template,
diagramType: 'lifecycle',
meta: lifecycle.meta,
svg: renderSvg(),
cards: lifecycle.cards,
sourceEvidence,
});