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:
+171
@@ -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
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
Reference in New Issue
Block a user