Skill Tracer
Skills › Archify › archify › render-lifecycle.mjs

render-lifecycle.mjs

1134 lines · archify/renderers/lifecycle/render-lifecycle.mjs at a406594

1import path from 'node:path';
2import { fileURLToPath } from 'node:url';
3import { esc, renderDefinitions, renderSemanticSigil, textUnits, SEMANTIC_SIGIL_FOOTPRINT, SOURCE_BADGE_FOOTPRINT } from '../shared/utils.mjs';
4import { animateAttr, focusEdgeAttrs, focusNodeAttrs, focusNodeTitle, loadDiagramWithBrandMarks, writeDiagram, svgAccessibleText, svgRootAttrs } from '../shared/cli.mjs';
5import { throwDiagnosticProblems } from '../shared/diagnostics.mjs';
6import { legendFootprint, resolveLegend, renderLegend as renderResolvedLegend } from '../shared/legend.mjs';
7import { availableNodeTextWidth, fittedNodeFontSize, minimumNodeTextWidth, nodeLabelLayout, nodeTextFit } from '../shared/text-fit.mjs';
8import { brandLabelFitWidth, brandMarkFor, brandMetadataFor, brandTopRailProblem, renderBrandMark } from '../shared/brand-marks.mjs';
9import { translateMessage as i18nText } from '../shared/i18n.mjs';
10import { DESKTOP_READER_DIAGRAM_WIDTH, MIN_PROJECTED_NODE_TEXT_PX } from '../shared/desktop-readability.mjs';
11import { asArray, rectsOverlap, segmentRectClearance, roundedPath, routePointsValue, arrowClassMap, edgeLabelAccent } from '../shared/geometry.mjs';
13// Lifecycle v3 draws one shape: the main path as a left-to-right row, loops
14// back to earlier phases as arcs above it, and every other state (exits,
15// waiting or recovery states) in one row below the phase it branches from.
16// Geometry is derived from that structure alone; there are no routing controls.
18const __dirname = path.dirname(fileURLToPath(import.meta.url));
19const { diagram: lifecycle, template, outPath, sourceEvidence } = await loadDiagramWithBrandMarks({
20 rendererDir: __dirname,
21 diagramType: 'lifecycle',
22 defaultExample: 'agent-run.lifecycle.json',
23});
25const TEXT = { labelPreferred: 12, labelMinimum: 10, sublabelPreferred: 9, sublabelMinimum: 8, tagPreferred: 8, tagMinimum: 7, step: 8 };
26const LABEL_FONT = 10;
27const NOTE_FONT = 9;
28const STATE_H = 64;
29const STATE_MIN_W = 140;
30const STATE_MAX_W = 220;
31const MARGIN_X = 56;
32const MARGIN_TOP = 28;
33const PORT_PAD = 22;
34const PORT_STEP = 26;
35const CORNER = 12;
36const FRAME_PAD = 10;
37const ARC_BASE = 30;
38const ARC_STEP = 24;
39const TRACK_STEP = 18;
40const LABEL_GAP = 6;
41const CLEARANCE = 5;
42const SPACING_ROUNDS = 5;
44const typeClass = {
45 start: 'c-frontend', active: 'c-frontend', waiting: 'c-cloud', decision: 'c-database',
46 success: 'c-backend', failure: 'c-security', neutral: 'c-external', external: 'c-external',
47};
48// Ordinary states carry no default corner sigil, so the ones a reader should
49// notice (waiting, decision, outcomes, external) stand out. An ordinary state
50// that ends the lifecycle is still an outcome and gets a stop sigil.
51const QUIET_TYPES = new Set(['start', 'active', 'neutral']);
52const textClass = {
53 start: 't-frontend', active: 't-frontend', waiting: 't-cloud', decision: 't-database',
54 success: 't-backend', failure: 't-security', neutral: 't-muted', external: 't-muted',
55};
57const transitions = asArray(lifecycle.transitions);
58const mainPath = asArray(lifecycle.mainPath);
59const states = new Map(asArray(lifecycle.states).map((state) => [state.id, { ...state }]));
60const spineIndex = new Map(mainPath.map((id, index) => [id, index]));
61const onSpine = (id) => spineIndex.has(id);
63validateStructure();
65// ---------------------------------------------------------------- measure
67function fonts(state, width) {
68 return {
69 label: fittedNodeFontSize(state.label, brandLabelFitWidth(state, width), TEXT.labelPreferred, TEXT.labelMinimum),
70 sublabel: fittedNodeFontSize(state.sublabel, width, TEXT.sublabelPreferred, TEXT.sublabelMinimum),
71 tag: fittedNodeFontSize(state.tag, width, TEXT.tagPreferred, TEXT.tagMinimum),
72 };
75// Wide enough for every row at its preferred size; the label also clears the
76// top-rail decorations (sigil or step, brand, source badge) that share its row.
77function preferredWidth(state) {
78 const need = (text, font) => (text ? textUnits(text) * font * nodeTextFit.widthFactor + nodeTextFit.horizontalPadding + 36 : 0);
79 const left = Math.max(SEMANTIC_SIGIL_FOOTPRINT + 2, state.step ? 26 + minimumNodeTextWidth(state.step, TEXT.step) : 0);
80 const right = (brandMarkFor(state) ? 26 : 4) + (sourceEvidence?.nodes?.[state.id]?.length ? SOURCE_BADGE_FOOTPRINT : 0);
81 const label = minimumNodeTextWidth(state.label, TEXT.labelPreferred) + left + right + 4;
82 return Math.ceil(Math.min(STATE_MAX_W, Math.max(STATE_MIN_W,
83 label, need(state.sublabel, TEXT.sublabelPreferred), need(state.tag, TEXT.tagPreferred))));
86for (const state of states.values()) {
87 state.width = preferredWidth(state);
88 state.height = STATE_H;
90const smallestText = Math.min(LABEL_FONT, ...[...states.values()].flatMap((state) => {
91 const fitted = fonts(state, state.width);
92 return [fitted.label, state.sublabel ? fitted.sublabel : Infinity];
93}));
94const readableWidthBudget = Math.floor(DESKTOP_READER_DIAGRAM_WIDTH * smallestText / MIN_PROJECTED_NODE_TEXT_PX);
96function labelBox(transition) {
97 const text = transition.label || '';
98 const note = transition.note || '';
99 const width = Math.ceil(Math.max(
100 text ? textUnits(text) * LABEL_FONT * 0.6 : 0,
101 note ? textUnits(note) * NOTE_FONT * 0.6 : 0,
102 ) + 12);
103 return { width, height: text && note ? 28 : 16 };
105const hasLabel = (transition) => Boolean(transition.label || transition.note);
107// ---------------------------------------------------------------- classify
109// The first forward transition between consecutive main-path states is the
110// row itself; any other main-path pair is an arc above the row.
111const spineEdges = new Map();
112for (const transition of transitions) {
113 const from = spineIndex.get(transition.from);
114 const to = spineIndex.get(transition.to);
115 if (from !== undefined && to === from + 1 && !spineEdges.has(from)) spineEdges.set(from, transition);
117const mainPathStateWidth = mainPath.reduce((sum, id) => sum + states.get(id).width, 0);
118const minimumSpineGaps = mainPath.slice(0, -1).map((_, index) => {
119 const edge = spineEdges.get(index);
120 return (edge && hasLabel(edge) ? labelBox(edge).width : 0) + 26;
121});
123// Every other state sits in a row below the main path at its distance from
124// it, so each transition stays within one row or joins two adjacent rows.
125const depth = new Map(mainPath.map((id) => [id, 0]));
126for (let frontier = [...mainPath]; frontier.length;) {
127 const next = [];
128 for (const id of frontier) {
129 for (const transition of transitions) {
130 const other = transition.from === id ? transition.to : transition.to === id ? transition.from : null;
131 if (other && states.has(other) && !depth.has(other)) {
132 depth.set(other, depth.get(id) + 1);
133 next.push(other);
134 }
135 }
136 }
137 frontier = next;
139for (const id of states.keys()) if (!depth.has(id)) depth.set(id, 1);
140const depthOf = (id) => depth.get(id);
141const rowCount = Math.max(...depth.values()) + 1;
142const rows = Array.from({ length: rowCount }, (_, row) => (row === 0
143 ? mainPath.map((id) => states.get(id))
144 : [...states.values()].filter((state) => depthOf(state.id) === row)));
145const offStates = rows.slice(1).flat();
147const kindOf = new Map(transitions.map((transition) => {
148 const [from, to] = [depthOf(transition.from), depthOf(transition.to)];
149 if (from === 0 && to === 0) return [transition, spineEdges.get(spineIndex.get(transition.from)) === transition ? 'spine' : 'arc'];
150 return [transition, from === to ? 'same' : 'between'];
151}));
152const ofKind = (kind) => transitions.filter((transition) => kindOf.get(transition) === kind);
154// Exits that leave several main-path phases for one state are drawn once:
155// from a composite frame around consecutive phases, or from a shared bus.
156const downByTarget = new Map();
157for (const transition of ofKind('between')) {
158 if (depthOf(transition.from) !== 0) continue;
159 if (!downByTarget.has(transition.to)) downByTarget.set(transition.to, []);
160 downByTarget.get(transition.to).push(transition);
162// A merged exit promises one meaning. Only a target with one eligible
163// complete-signature bucket joins a bracket; all other exits stay singles.
164const exitSignature = (transition) => [transition.label ?? '', transition.note ?? '', variantOf(transition)].join('\u0000');
165const brackets = new Map();
166for (const [target, list] of downByTarget) {
167 const buckets = new Map();
168 for (const transition of list) {
169 const signature = exitSignature(transition);
170 if (!buckets.has(signature)) buckets.set(signature, []);
171 buckets.get(signature).push(transition);
172 }
173 const eligible = [...buckets.values()].filter((bucket) => new Set(bucket.map((transition) => transition.from)).size >= 2);
174 if (eligible.length !== 1) continue;
175 const [group] = eligible;
176 const sources = [...new Set(group.map((transition) => transition.from))].sort((a, b) => spineIndex.get(a) - spineIndex.get(b));
177 const key = sources.join('\u0000');
178 if (!brackets.has(key)) brackets.set(key, { key, sources, targets: [], transitions: [] });
179 brackets.get(key).targets.push(target);
180 brackets.get(key).transitions.push(...group);
182const bracketOf = new Map();
183for (const bracket of brackets.values()) {
184 for (const transition of bracket.transitions) bracketOf.set(transition, bracket);
186// Consecutive main-path phases form a composite state: one frame around them
187// and one exit per target, like a UML superstate transition. Frames never
188// partially overlap; such a bracket keeps a shared bus under its phases.
189const frameSpans = [];
190for (const bracket of brackets.values()) {
191 const indexes = bracket.sources.map((id) => spineIndex.get(id));
192 const lo = Math.min(...indexes);
193 const hi = Math.max(...indexes);
194 if (hi - lo + 1 !== indexes.length || frameSpans.some((span) => span.lo <= hi && lo <= span.hi)) continue;
195 bracket.frame = { lo, hi };
196 frameSpans.push(bracket.frame);
198const bracketTargets = new Map();
199for (const bracket of brackets.values()) for (const target of bracket.targets) bracketTargets.set(target, bracket);
201const outgoing = new Set(transitions.map((transition) => transition.from));
202const isFinal = (state) => !outgoing.has(state.id);
204// ---------------------------------------------------------------- layout
206// Each round tries ports pulled toward their partners and ports spread evenly,
207// and keeps the placed layout with the fewest bends and short jogs.
208let spacing = { spineGap: 72, rowGap: 44, drop: 58, firstTrack: 34 };
209let geometry;
210// Labels that could not be placed at tighter spacing and prompted retries.
211const spacingWideners = new Set();
212for (let round = 0; round < SPACING_ROUNDS; round += 1) {
213 const candidates = ['partner', 'even'].map((ports) => ({ ports, ...layout({ ...spacing, ports }) }));
214 const placed = candidates.filter((candidate) => !candidate.unplacedLabels.length);
215 const best = placed.sort((a, b) => routeCost(a.routes) - routeCost(b.routes))[0] || candidates[0];
216 // layout() positions the shared states, so the winner is laid out again.
217 geometry = best === candidates.at(-1) ? best : layout({ ...spacing, ports: best.ports });
218 if (placed.length) break;
219 if (round === 0) {
220 const closest = [...candidates].sort((a, b) => a.unplacedLabels.length - b.unplacedLabels.length)[0];
221 for (const transition of closest.unplacedLabels) spacingWideners.add(transition);
222 }
223 const nextSpineGap = spacing.spineGap + 28;
224 // The canvas includes this whole row. Widening beyond its text budget
225 // cannot succeed; keep its current gaps while other spacing still grows.
226 const nextMainPathWidth = MARGIN_X * 2 + mainPathStateWidth
227 + minimumSpineGaps.reduce((sum, minimum) => sum + Math.max(nextSpineGap, minimum), 0);
228 spacing = { spineGap: nextMainPathWidth > readableWidthBudget ? spacing.spineGap : nextSpineGap,
229 rowGap: spacing.rowGap + 28, drop: spacing.drop + 14, firstTrack: spacing.firstTrack + 6 };
232function routeCost(routes) {
233 let cost = 0;
234 for (const { points } of routes.values()) {
235 cost += Math.max(0, points.length - 2) * 10;
236 for (let index = 1; index < points.length; index += 1) {
237 const run = Math.abs(points[index][0] - points[index - 1][0]);
238 if (points.length > 2 && Math.abs(points[index][1] - points[index - 1][1]) < 0.5) cost += run * 0.1 + (run < 30 ? 40 : 0);
239 }
240 }
241 return cost;
244function layout({ spineGap, rowGap, drop, firstTrack, ports: portMode }) {
245 // Main-path x positions: each gap fits its row label.
246 let cursor = MARGIN_X;
247 mainPath.forEach((id, index) => {
248 const state = states.get(id);
249 state.x = cursor;
250 state.cx = cursor + state.width / 2;
251 cursor += state.width + Math.max(spineGap, minimumSpineGaps[index] ?? 26);
252 });
253 for (let row = 1; row < rowCount; row += 1) placeRow(row, rowGap);
255 // Shift horizontally so the leftmost element keeps the margin.
256 const minX = Math.min(...[...states.values()].map((state) => state.x));
257 const dx = MARGIN_X - minX;
258 for (const state of states.values()) { state.x += dx; state.cx += dx; }
260 const arcs = assignArcLevels();
261 const maxLevel = Math.max(0, ...arcs.map((arc) => arc.level));
262 const arcLabelReserve = arcs.some((arc) => hasLabel(arc.transition)) ? 12 : 0;
263 const spineTop = MARGIN_TOP + (maxLevel ? ARC_BASE + (maxLevel - 1) * ARC_STEP + arcLabelReserve : 0);
265 const ports = new Map();
266 const portOf = (id, side, key) => ports.get(`${id}|${side}`)?.get(key);
267 const requestPort = (id, side, key, otherX, order) => {
268 const slot = `${id}|${side}`;
269 if (!ports.has(slot)) ports.set(slot, new Map());
270 if (!ports.get(slot).has(key)) ports.get(slot).set(key, { key, otherX, order, x: 0 });
271 };
272 const cx = (id) => states.get(id).cx;
274 // Connectors cross the gap below row `gap`: single transitions between two
275 // rows, composite frame exits, shared buses, and loops between two
276 // non-adjacent states of one row.
277 const connectors = [];
278 for (const transition of ofKind('between')) {
279 if (bracketOf.has(transition)) continue;
280 const down = depthOf(transition.from) < depthOf(transition.to);
281 const upperId = down ? transition.from : transition.to;
282 const lowerId = down ? transition.to : transition.from;
283 connectors.push({ kind: 'single', transition, down, upperId, lowerId, gap: depthOf(upperId), order: transitions.indexOf(transition) });
284 }
285 const frames = [];
286 for (const bracket of brackets.values()) {
287 const order = transitions.indexOf(bracket.transitions[0]);
288 if (!bracket.frame) {
289 connectors.push({ kind: 'bracket', bracket, gap: 0, order });
290 continue;
291 }
292 const first = states.get(mainPath[bracket.frame.lo]);
293 const last = states.get(mainPath[bracket.frame.hi]);
294 const rect = { x: first.x - FRAME_PAD, y: spineTop - FRAME_PAD, width: last.x + last.width - first.x + FRAME_PAD * 2, height: STATE_H + FRAME_PAD * 2 };
295 frames.push(rect);
296 for (const target of bracket.targets) connectors.push({ kind: 'frame', bracket, rect, lowerId: target, gap: 0, order });
297 }
298 // Neighbours in one lower row connect side to side; others loop underneath.
299 const rowOrder = rows.map((list) => [...list].sort((a, b) => a.x - b.x).map((state) => state.id));
300 const adjacent = (transition) => transition.from !== transition.to
301 && Math.abs(rowOrder[depthOf(transition.from)].indexOf(transition.from) - rowOrder[depthOf(transition.from)].indexOf(transition.to)) === 1;
302 const sides = ofKind('same').filter(adjacent);
303 // Side labels ride on the line for groups of three or more. Reserve their
304 // actual height before placing rows: horizontal gap growth cannot clear a
305 // label trapped between two full-width parallel strokes.
306 const sideGroups = new Map();
307 for (const transition of sides) {
308 const key = [transition.from, transition.to].sort().join('\u0000');
309 if (!sideGroups.has(key)) sideGroups.set(key, []);
310 sideGroups.get(key).push(transition);
311 }
312 const sideLanes = [];
313 const rowHeights = rows.map(() => STATE_H);
314 for (const group of sideGroups.values()) {
315 const rightward = (transition) => states.get(transition.from).x < states.get(transition.to).x;
316 const ordered = [...group.filter(rightward), ...group.filter((transition) => !rightward(transition))];
317 if (ordered.length > 5) {
318 const message = `${ordered.length} transitions between the neighbouring states "${ordered[0].from}" and "${ordered[0].to}" cannot spread apart on the shared edge; at most five stay legible.`;
319 throwDiagnosticProblems('Lifecycle validation failed', [message], {
320 subject: { diagramType: 'lifecycle' },
321 diagnostics: [structureProblem('lifecycle/crowded-side-transitions', message,
322 { collection: 'transitions', from: ordered[0].from, to: ordered[0].to },
323 ['merge the triggers into one transition label'])],
324 });
325 }
326 let step = Math.min(20, 48 / (ordered.length - 1));
327 if (ordered.length > 2) {
328 const heights = ordered.map((transition) => hasLabel(transition) ? labelBox(transition).height : 0);
329 step = Math.max(step, ...heights.map((height) => height ? height / 2 + CLEARANCE : 0),
330 ...heights.slice(1).map((height, index) => height && heights[index] ? (height + heights[index]) / 2 + 4 : 0));
331 const row = depthOf(ordered[0].from);
332 // Keep every side port eight pixels inside the rounded state corners.
333 rowHeights[row] = Math.max(rowHeights[row], step * (ordered.length - 1) + 16);
334 }
335 sideLanes.push({ ordered, step });
336 }
337 for (const transition of ofKind('same').filter((transition) => !adjacent(transition))) {
338 connectors.push({ kind: 'loop', transition, gap: depthOf(transition.from), order: transitions.indexOf(transition) });
339 }
341 for (const connector of connectors) {
342 if (connector.kind === 'single') {
343 requestPort(connector.upperId, 'bottom', connector, cx(connector.lowerId), connector.order);
344 requestPort(connector.lowerId, 'top', connector, cx(connector.upperId), connector.order);
345 } else if (connector.kind === 'frame') {
346 requestPort(connector.lowerId, 'top', connector, connector.rect.x + connector.rect.width / 2, connector.order);
347 } else if (connector.kind === 'loop') {
348 const { from, to } = connector.transition;
349 if (from === to) {
350 requestPort(from, 'bottom', `${connector.order}:out`, Infinity, 0);
351 requestPort(from, 'bottom', `${connector.order}:in`, Infinity, 1);
352 } else {
353 requestPort(from, 'bottom', `${connector.order}:out`, cx(to), connector.order);
354 requestPort(to, 'bottom', `${connector.order}:in`, cx(from), connector.order);
355 }
356 } else {
357 const { sources, targets } = connector.bracket;
358 const dropCenter = targets.reduce((sum, id) => sum + cx(id), 0) / targets.length;
359 const sourceCenter = sources.reduce((sum, id) => sum + cx(id), 0) / sources.length;
360 for (const id of sources) requestPort(id, 'bottom', connector, dropCenter, connector.order);
361 for (const id of targets) requestPort(id, 'top', connector, sourceCenter, connector.order);
362 }
363 }
364 for (const arc of arcs) {
365 const { transition } = arc;
366 if (transition.from === transition.to) {
367 requestPort(transition.from, 'top', `${arc.key}:out`, Infinity, 0);
368 requestPort(transition.from, 'top', `${arc.key}:in`, Infinity, 1);
369 } else {
370 requestPort(transition.from, 'top', `${arc.key}:out`, cx(transition.to), 0);
371 requestPort(transition.to, 'top', `${arc.key}:in`, cx(transition.from), 0);
372 }
373 }
374 distributePorts(ports, arcs, portMode);
375 placeFrameExits(connectors, ports, portOf);
377 // Each gap assigns its horizontal runs to tracks, then the next row follows.
378 const runs = connectors.map((connector) => {
379 if (connector.kind === 'loop') {
380 const { from, to } = connector.transition;
381 const tops = [portOf(from, 'bottom', `${connector.order}:out`).x, portOf(to, 'bottom', `${connector.order}:in`).x];
382 return { connector, tops, bottoms: [], lo: Math.min(...tops), hi: Math.max(...tops), straight: false };
383 }
384 if (connector.kind === 'bracket') {
385 const { sources, targets } = connector.bracket;
386 const tops = sources.map((id) => portOf(id, 'bottom', connector).x);
387 const bottoms = targets.map((id) => portOf(id, 'top', connector).x);
388 const xs = [...tops, ...bottoms];
389 return { connector, tops, bottoms, lo: Math.min(...xs), hi: Math.max(...xs), straight: false };
390 }
391 const top = connector.kind === 'frame' ? connector.exitX : portOf(connector.upperId, 'bottom', connector).x;
392 const bottom = portOf(connector.lowerId, 'top', connector).x;
393 return { connector, tops: [top], bottoms: [bottom], lo: Math.min(top, bottom), hi: Math.max(top, bottom), straight: Math.abs(top - bottom) < 0.5 };
394 });
395 const rowTop = [spineTop];
396 const trackY = [];
397 for (let row = 0; row < rowCount; row += 1) {
398 for (const state of rows[row]) {
399 state.y = rowTop[row];
400 state.height = rowHeights[row];
401 state.cy = rowTop[row] + state.height / 2;
402 }
403 const bottom = rowTop[row] + rowHeights[row];
404 const count = assignTracks(runs.filter((run) => run.connector.gap === row));
405 trackY[row] = (track) => bottom + firstTrack + track * TRACK_STEP;
406 if (row + 1 < rowCount) rowTop.push((count ? trackY[row](count - 1) : bottom + firstTrack) + drop);
407 }
408 const rowBottom = (row) => rowTop[row] + rowHeights[row];
410 // Routes, keyed by transition.
411 const routes = new Map();
412 const junctions = [];
413 for (const [index, transition] of spineEdges) {
414 const from = states.get(mainPath[index]);
415 const to = states.get(mainPath[index + 1]);
416 routes.set(transition, { points: [[from.x + from.width, from.cy], [to.x, to.cy]], segment: 0, labelMode: 'above' });
417 }
418 for (const arc of arcs) {
419 const { transition } = arc;
420 const out = portOf(transition.from, 'top', `${arc.key}:out`).x;
421 const inn = portOf(transition.to, 'top', `${arc.key}:in`).x;
422 const y = spineTop - ARC_BASE - (arc.level - 1) * ARC_STEP;
423 routes.set(transition, { points: [[out, spineTop], [out, y], [inn, y], [inn, spineTop]], segment: 1, labelMode: 'on' });
424 }
425 for (const run of runs) {
426 const { connector } = run;
427 const { gap } = connector;
428 const y = trackY[gap](run.track ?? 0);
429 const upper = rowBottom(gap);
430 const lower = rowTop[gap + 1];
431 if (connector.kind === 'loop') {
432 const [a, b] = run.tops;
433 routes.set(connector.transition, { points: [[a, upper], [a, y], [b, y], [b, upper]], segment: 1, labelMode: 'on' });
434 continue;
435 }
436 if (connector.kind === 'single' || connector.kind === 'frame') {
437 const [top] = run.tops;
438 const [bottom] = run.bottoms;
439 const start = connector.kind === 'frame' ? connector.rect.y + connector.rect.height : upper;
440 const down = run.straight ? [[top, start], [top, lower]] : [[top, start], [top, y], [bottom, y], [bottom, lower]];
441 if (connector.kind === 'single') {
442 const points = connector.down ? down : [...down].reverse();
443 routes.set(connector.transition, { points, segment: connector.down ? points.length - 2 : 0, labelMode: 'beside' });
444 } else {
445 connector.bracket.transitions.filter((transition) => transition.to === connector.lowerId)
446 .forEach((transition, index) => routes.set(transition, {
447 points: down, segment: down.length - 2, labelMode: index ? 'none' : 'beside', bracket: connector.bracket,
448 }));
449 }
450 continue;
451 }
452 const { bracket } = connector;
453 const labelled = new Set();
454 for (const transition of bracket.transitions) {
455 const tick = portOf(transition.from, 'bottom', connector).x;
456 const dropX = portOf(transition.to, 'top', connector).x;
457 const points = Math.abs(tick - dropX) < 0.5
458 ? [[tick, upper], [tick, lower]]
459 : [[tick, upper], [tick, y], [dropX, y], [dropX, lower]];
460 const owner = !labelled.has(transition.to);
461 labelled.add(transition.to);
462 routes.set(transition, { points, segment: points.length - 2, labelMode: owner ? 'beside' : 'none', bracket });
463 }
464 for (const x of [...run.tops, ...run.bottoms]) {
465 if (x > run.lo + 0.5 && x < run.hi - 0.5) junctions.push([x, y]);
466 }
467 }
468 // Parallel transitions between two neighbours spread symmetrically around
469 // the shared edge, left-to-right ones first; a reciprocal pair lands at ±10.
470 for (const { ordered, step } of sideLanes) {
471 ordered.forEach((transition, index) => {
472 const from = states.get(transition.from);
473 const to = states.get(transition.to);
474 const y = from.cy + (index - (ordered.length - 1) / 2) * step;
475 const points = from.x < to.x ? [[from.x + from.width, y], [to.x, y]] : [[from.x, y], [to.x + to.width, y]];
476 // With three or more parallels the inner strokes leave no lane for a
477 // floating label, so their labels ride on the line like arc labels.
478 routes.set(transition, { points, segment: 0, labelMode: ordered.length > 2 ? 'on' : 'above' });
479 });
480 }
482 const stateRight = Math.max(...[...states.values()].map((state) => state.x + state.width));
483 const placed = placeLabels({ routes, contentRight: stateRight });
484 const boxes = [...states.values(), ...placed.placedLabels.values()];
485 const contentBottom = Math.max(
486 ...boxes.map((box) => box.y + box.height),
487 ...[...routes.values()].flatMap((route) => route.points.map((point) => point[1])),
488 );
489 const contentRight = Math.max(...boxes.map((box) => box.x + box.width));
490 return { routes, junctions, frames, contentBottom, contentRight, ...placed };
493// A composite exit leaves the frame directly above its target when possible,
494// clear of the inner phases' own connectors and of sibling exits.
495function placeFrameExits(connectors, ports, portOf) {
496 const frameConnectors = connectors.filter((connector) => connector.kind === 'frame');
497 for (const connector of frameConnectors) {
498 const { rect, bracket } = connector;
499 const inner = mainPath.slice(bracket.frame.lo, bracket.frame.hi + 1)
500 .flatMap((id) => [...(ports.get(`${id}|bottom`)?.values() || [])].map((port) => port.x));
501 const taken = [...inner, ...frameConnectors.filter((other) => other.rect === rect && other.exitX !== undefined).map((other) => other.exitX)];
502 const target = portOf(connector.lowerId, 'top', connector);
503 const low = rect.x + 16;
504 const high = rect.x + rect.width - 16;
505 const wanted = Math.min(high, Math.max(low, target.x));
506 const free = (x) => x >= low && x <= high && taken.every((other) => Math.abs(other - x) >= 14);
507 let exitX = wanted;
508 for (let step = 0; step <= 40 && !free(exitX); step += 1) {
509 exitX = [wanted + step * 7, wanted - step * 7].find(free) ?? exitX;
510 }
511 connector.exitX = exitX;
512 // Snap the target's port to the exit when that keeps its spacing.
513 const delta = Math.abs(exitX - target.x);
514 if (delta >= 0.5 && delta < 24) {
515 const state = states.get(connector.lowerId);
516 const others = [...ports.get(`${connector.lowerId}|top`).values()].filter((port) => port !== target);
517 if (exitX >= state.x + 12 && exitX <= state.x + state.width - 12 && others.every((port) => Math.abs(port.x - exitX) >= 12)) {
518 target.x = exitX;
519 } else if (delta < 16) {
520 connector.exitX = [target.x, exitX + Math.sign(exitX - target.x || 1) * (16 - delta)].find(free) ?? exitX;
521 }
522 }
523 }
526// Each lower row starts every state under the states it connects to in the
527// row above; overlapping blocks merge and centre on their combined ideal.
528function placeRow(row, rowGap) {
529 const members = rows[row];
530 const ideal = new Map();
531 for (const state of members) {
532 const xs = transitions.flatMap((transition) => {
533 const other = transition.to === state.id ? transition.from : transition.from === state.id ? transition.to : null;
534 return other && depthOf(other) === row - 1 ? [states.get(other).cx] : [];
535 });
536 if (xs.length) ideal.set(state.id, xs.reduce((sum, x) => sum + x, 0) / xs.length);
537 }
538 if (row === 1) {
539 for (const bracket of brackets.values()) {
540 const center = bracket.sources.reduce((sum, id) => sum + states.get(id).cx, 0) / bracket.sources.length;
541 bracket.targets.forEach((id) => ideal.set(id, center));
542 }
543 }
544 const fallback = Math.max(...mainPath.map((id) => states.get(id).cx)) + 1;
545 const blocks = [];
546 const grouped = new Set();
547 for (const state of members) {
548 if (grouped.has(state.id)) continue;
549 const bracket = row === 1 ? bracketTargets.get(state.id) : null;
550 const list = bracket ? bracket.targets.map((id) => states.get(id)) : [state];
551 list.forEach((member) => grouped.add(member.id));
552 blocks.push({ members: list, ideal: ideal.get(state.id) ?? fallback, weight: ideal.has(state.id) ? list.length : 0.05, order: members.indexOf(state) });
553 }
554 // Neighbours joined by a labelled transition keep room for its label.
555 const gapBetween = (left, right) => Math.max(rowGap, ...transitions
556 .filter((transition) => hasLabel(transition) && ((transition.from === left.id && transition.to === right.id)
557 || (transition.from === right.id && transition.to === left.id)))
558 .map((transition) => labelBox(transition).width + 32));
559 blocks.sort((a, b) => a.ideal - b.ideal || a.order - b.order);
560 // Lay a cluster of blocks side by side and return each block's centre offset.
561 const arrange = (list) => {
562 const offsets = [];
563 let x = 0;
564 let previous = null;
565 for (const block of list) {
566 const start = x;
567 for (const member of block.members) {
568 if (previous) x += gapBetween(previous, member);
569 member.offset = x;
570 x += member.width;
571 previous = member;
572 }
573 offsets.push((start + x) / 2);
574 }
575 return { width: x, offsets: offsets.map((offset) => offset - x / 2) };
576 };
577 // A cluster's centre is the weighted mean of (ideal - offset) over its
578 // blocks, which minimises their squared drift.
579 const centre = (list) => {
580 const { width, offsets } = arrange(list);
581 const weight = list.reduce((sum, block) => sum + block.weight, 0);
582 return { width, x: list.reduce((sum, block, index) => sum + block.weight * (block.ideal - offsets[index]), 0) / weight };
583 };
584 const clusters = [];
585 for (const block of blocks) {
586 clusters.push([block]);
587 while (clusters.length > 1) {
588 const last = clusters.at(-1);
589 const previous = clusters.at(-2);
590 const a = centre(previous);
591 const b = centre(last);
592 if (a.x + a.width / 2 + gapBetween(previous.at(-1).members.at(-1), last[0].members[0]) <= b.x - b.width / 2) break;
593 clusters.splice(-2, 2, [...previous, ...last]);
594 }
595 }
596 for (const cluster of clusters) {
597 const { x, width } = centre(cluster);
598 arrange(cluster);
599 for (const block of cluster) {
600 for (const member of block.members) {
601 member.x = x - width / 2 + member.offset;
602 member.cx = member.x + member.width / 2;
603 }
604 }
605 }
608// Arcs nest by span: a shorter arc stays lower; overlapping spans never share a level.
609function assignArcLevels() {
610 const arcs = ofKind('arc').map((transition, index) => {
611 const a = spineIndex.get(transition.from);
612 const b = spineIndex.get(transition.to);
613 return { transition, key: `arc${transitions.indexOf(transition)}`, lo: Math.min(a, b), hi: Math.max(a, b), index };
614 });
615 const sorted = [...arcs].sort((left, right) => (left.hi - left.lo) - (right.hi - right.lo) || left.lo - right.lo || left.index - right.index);
616 for (const arc of sorted) {
617 const blocking = sorted.filter((other) => other !== arc && other.level
618 && ((other.lo < arc.hi && arc.lo < other.hi) || (other.lo === other.hi && arc.lo < other.lo && other.lo < arc.hi)
619 || (other.lo === arc.lo && other.hi === arc.hi)));
620 arc.level = 1 + Math.max(0, ...blocking.map((other) => other.level));
621 }
622 return arcs;
625// Port order on one side avoids crossings: connections toward the left use
626// the left part of the side, connections toward the right the right part.
627// Nested arcs put the outer (higher) arc's port furthest out and spread evenly;
628// every other port moves toward its partner so connectors can drop straight.
629function distributePorts(ports, arcs, mode) {
630 const arcLevel = new Map(arcs.flatMap((arc) => [[`${arc.key}:out`, arc.level], [`${arc.key}:in`, arc.level]]));
631 // Lower-row tops follow the upper ports they connect to, so place them last.
632 const lowerTop = ([slot]) => slot.endsWith('|top') && !onSpine(slot.split('|')[0]);
633 const slots = [...ports].sort((a, b) => lowerTop(a) - lowerTop(b));
634 for (const [slot, entries] of slots) {
635 const id = slot.split('|')[0];
636 const state = states.get(id);
637 const list = [...entries.values()];
638 if (lowerTop([slot])) {
639 for (const port of list) {
640 const upper = port.key?.kind === 'single' ? ports.get(`${port.key.upperId}|bottom`)?.get(port.key) : null;
641 if (upper) port.otherX = upper.x;
642 }
643 }
644 list.sort((left, right) => {
645 const leftDirection = Math.sign(left.otherX - state.cx);
646 const rightDirection = Math.sign(right.otherX - state.cx);
647 if (leftDirection !== rightDirection) return leftDirection - rightDirection;
648 const leftLevel = arcLevel.get(left.key);
649 const rightLevel = arcLevel.get(right.key);
650 if (leftLevel !== undefined && rightLevel !== undefined && leftLevel !== rightLevel) {
651 // An outer arc's riser must stay outside every inner span it rises past.
652 return leftDirection < 0 ? leftLevel - rightLevel : rightLevel - leftLevel;
653 }
654 return left.otherX - right.otherX || left.order - right.order;
655 });
656 const usable = state.width - PORT_PAD * 2;
657 const step = list.length > 1 ? Math.min(PORT_STEP, usable / (list.length - 1)) : 0;
658 const xs = mode === 'even' || (onSpine(id) && slot.endsWith('|top'))
659 ? list.map((_, index) => state.cx - (step * (list.length - 1)) / 2 + index * step)
660 : fitPorts(list.map((port) => port.otherX), step, state.x + PORT_PAD, state.x + state.width - PORT_PAD);
661 list.forEach((port, index) => { port.x = Math.round(xs[index] * 10) / 10; });
662 }
663 snapPorts(ports, mode);
666// Ordered ports as close to their wanted x as the step and side allow: a pool
667// of adjacent violators (isotonic regression on wanted - index * step).
668function fitPorts(wanted, step, low, high) {
669 const n = wanted.length;
670 const top = high - step * (n - 1);
671 const clamp = (x) => Math.min(Math.max(x, low), Math.max(low, top));
672 const pools = [];
673 wanted.forEach((x, index) => {
674 pools.push({ sum: (Number.isFinite(x) ? x : x > 0 ? high : low) - index * step, count: 1 });
675 while (pools.length > 1 && pools.at(-2).sum / pools.at(-2).count > pools.at(-1).sum / pools.at(-1).count) {
676 const last = pools.pop();
677 pools.at(-1).sum += last.sum;
678 pools.at(-1).count += last.count;
679 }
680 });
681 return pools.flatMap((pool) => Array(pool.count).fill(clamp(pool.sum / pool.count))).map((x, index) => x + index * step);
684// A nearly vertical connector becomes exactly vertical when a port can move
685// without crowding its neighbours; otherwise its jog widens to a readable run.
686function snapPorts(ports, mode) {
687 const fits = (id, side, moving, x) => {
688 const state = states.get(id);
689 return x >= state.x + PORT_PAD - 8 && x <= state.x + state.width - PORT_PAD + 8
690 && [...ports.get(`${id}|${side}`).values()].every((other) => other === moving || Math.abs(other.x - x) >= 12);
691 };
692 for (const [slot, entries] of ports) {
693 if (!slot.endsWith('|top')) continue;
694 const lowerId = slot.split('|')[0];
695 if (onSpine(lowerId)) continue;
696 // Validate the final group, since neighboring ports may move together.
697 const group = [...entries.values()].sort((a, b) => a.x - b.x);
698 const state = states.get(lowerId);
699 const targets = group.map((port) => port.key?.kind === 'single'
700 ? ports.get(`${port.key.upperId}|bottom`)?.get(port.key)?.x : undefined);
701 if (targets.every((x, index) => Number.isFinite(x)
702 && x >= state.x + PORT_PAD - 8 && x <= state.x + state.width - PORT_PAD + 8
703 && (index === 0 || x - targets[index - 1] >= 12))) {
704 group.forEach((port, index) => { port.x = targets[index]; });
705 continue;
706 }
707 for (const port of entries.values()) {
708 const connector = port.key;
709 if (connector?.kind === 'single') {
710 const upperPort = ports.get(`${connector.upperId}|bottom`).get(connector);
711 const delta = Math.abs(upperPort.x - port.x);
712 if (delta < 0.5 || (delta >= 16 && mode === 'even')) continue;
713 if (fits(lowerId, 'top', port, upperPort.x)) port.x = upperPort.x;
714 else if (delta >= 16) continue;
715 else if (fits(connector.upperId, 'bottom', upperPort, port.x)) upperPort.x = port.x;
716 else {
717 const away = Math.sign(port.x - upperPort.x) || 1;
718 const pushed = [upperPort.x + away * 16, upperPort.x - away * 16].find((x) => fits(lowerId, 'top', port, x));
719 if (pushed !== undefined) port.x = pushed;
720 }
721 } else if (connector?.kind === 'bracket') {
722 // A drop next to a tick would leave a micro run on the shared bus.
723 const ticks = connector.bracket.sources.map((id) => ports.get(`${id}|bottom`).get(connector).x);
724 const near = ticks.find((x) => Math.abs(x - port.x) >= 0.5 && Math.abs(x - port.x) < 16);
725 if (near !== undefined && fits(lowerId, 'top', port, near)) port.x = near;
726 }
727 }
728 }
731// Greedy interval tracks for one gap, trying several orderings and keeping
732// the one with the fewest crossings.
733function assignTracks(runs) {
734 const needs = runs.filter((run) => !run.straight);
735 if (!needs.length) return 0;
736 const mean = (xs) => xs.reduce((sum, x) => sum + x, 0) / xs.length;
737 const direction = (run) => (run.bottoms.length ? Math.sign(mean(run.bottoms) - mean(run.tops)) : 0);
738 const start = (run) => mean(run.tops);
739 const byStart = (a, b) => (direction(a) >= 0 ? start(b) - start(a) : start(a) - start(b));
740 const loopsFirst = (a, b) => (a.bottoms.length ? 1 : 0) - (b.bottoms.length ? 1 : 0);
741 const orderings = [
742 [...needs].sort((a, b) => (direction(b) - direction(a)) || byStart(a, b)),
743 [...needs].sort((a, b) => (direction(a) - direction(b)) || byStart(a, b)),
744 [...needs].sort((a, b) => (a.hi - a.lo) - (b.hi - b.lo)),
745 [...needs].sort((a, b) => loopsFirst(a, b) || (a.hi - a.lo) - (b.hi - b.lo)),
746 [...needs].sort((a, b) => -loopsFirst(a, b) || (a.hi - a.lo) - (b.hi - b.lo)),
747 ];
748 let best = null;
749 for (const ordering of orderings) {
750 const tracks = [];
751 const assignment = new Map();
752 for (const run of ordering) {
753 let track = 0;
754 while (tracks[track]?.some((other) => other.lo - 10 < run.hi && run.lo < other.hi + 10)) track += 1;
755 (tracks[track] ||= []).push(run);
756 assignment.set(run, track);
757 }
758 const crossings = countCrossings(runs, assignment);
759 if (!best || crossings < best.crossings || (crossings === best.crossings && tracks.length < best.count)) {
760 best = { assignment, crossings, count: tracks.length };
761 }
762 }
763 for (const run of needs) run.track = best.assignment.get(run);
764 return best.count;
767function countCrossings(runs, assignment) {
768 // Verticals run from the upper row (y 0) to a track, then on to the lower row (y 1000).
769 const pieces = runs.map((run) => {
770 const track = assignment.get(run);
771 if (track === undefined) return { verticals: [[run.tops[0], 0, 1000]], horizontals: [] };
772 const y = 10 + track;
773 return {
774 verticals: [...run.tops.map((x) => [x, 0, y]), ...run.bottoms.map((x) => [x, y, 1000])],
775 horizontals: [[y, run.lo, run.hi]],
776 };
777 });
778 let count = 0;
779 for (let left = 0; left < pieces.length; left += 1) {
780 for (let right = 0; right < pieces.length; right += 1) {
781 if (left === right) continue;
782 for (const [x, y1, y2] of pieces[left].verticals) {
783 for (const [y, x1, x2] of pieces[right].horizontals) {
784 if (x > x1 + 0.5 && x < x2 - 0.5 && y > y1 && y < y2) count += 1;
785 }
786 }
787 }
788 }
789 return count;
792// Labels sit beside the segment that enters the target (or leaves the lower
793// row), above main-path arrows, and on arc and lower-loop runs. Every
794// candidate keeps clear of other routes, states and labels.
795function placeLabels({ routes, contentRight }) {
796 const placedLabels = new Map();
797 const unplacedLabels = [];
798 const segmentsOf = (transition) => {
799 const points = routes.get(transition)?.points || [];
800 return points.slice(1).map((end, index) => ({ start: points[index], end }));
801 };
802 const siblings = (transition) => {
803 const bracket = routes.get(transition)?.bracket;
804 return bracket ? bracket.transitions.filter((other) => other.to === transition.to) : [transition];
805 };
806 const others = (transition) => transitions.filter((other) => !siblings(transition).includes(other));
807 for (const transition of transitions) {
808 const route = routes.get(transition);
809 if (!route || route.labelMode === 'none' || !hasLabel(transition)) continue;
810 const { width, height } = labelBox(transition);
811 const [start, end] = [route.points[route.segment], route.points[route.segment + 1]];
812 const candidates = [];
813 const at = (cx, cy) => ({ x: cx - width / 2, y: cy - height / 2, width, height, cx, cy });
814 if (route.labelMode === 'above') {
815 const mid = (start[0] + end[0]) / 2;
816 candidates.push(at(mid, start[1] - LABEL_GAP - height / 2), at(mid, start[1] + LABEL_GAP + height / 2));
817 } else if (route.labelMode === 'on') {
818 for (const fraction of [0.5, 0.35, 0.65, 0.2, 0.8]) {
819 candidates.push(at(start[0] + (end[0] - start[0]) * fraction, start[1]));
820 }
821 } else {
822 const verticals = route.points.slice(1).map((point, index) => [route.points[index], point])
823 .filter(([a, b]) => Math.abs(a[0] - b[0]) < 0.5);
824 const preferred = [[start, end], ...verticals.filter(([a]) => a !== start)];
825 const rowState = states.get(depthOf(transition.to) < depthOf(transition.from) ? transition.from : transition.to);
826 for (const [a, b] of preferred) {
827 const x = a[0];
828 const mid = (a[1] + b[1]) / 2;
829 const sides = x <= rowState.cx ? [-1, 1] : [1, -1];
830 for (const side of sides) {
831 for (const shift of [0, -10, 10]) candidates.push(at(x + side * (LABEL_GAP + width / 2), mid + shift));
832 }
833 }
834 const horizontal = route.points.slice(1).map((point, index) => [route.points[index], point])
835 .find(([a, b]) => Math.abs(a[1] - b[1]) < 0.5 && Math.abs(a[0] - b[0]) > width + 8);
836 if (horizontal) candidates.push(at((horizontal[0][0] + horizontal[1][0]) / 2, horizontal[0][1]));
837 }
838 const otherSegments = others(transition).flatMap(segmentsOf);
839 const ownSegments = siblings(transition).flatMap(segmentsOf);
840 const free = (rect) => rect.x >= 8 && rect.x + rect.width <= contentRight + MARGIN_X - 8 && rect.y >= 4
841 && [...states.values()].every((state) => !rectsOverlap(rect, state, 4))
842 && [...placedLabels.values()].every((other) => !rectsOverlap(rect, other, 4))
843 && otherSegments.every((segment) => (segmentRectClearance(segment, rect) ?? Infinity) >= CLEARANCE)
844 && (route.labelMode === 'on' || ownSegments.every((segment) => (segmentRectClearance(segment, rect) ?? Infinity) >= LABEL_GAP - 1));
845 const chosen = candidates.find(free);
846 if (chosen) placedLabels.set(transition, chosen);
847 else {
848 unplacedLabels.push(transition);
849 placedLabels.set(transition, candidates[0]);
850 }
851 }
852 return { placedLabels, unplacedLabels };
855// ---------------------------------------------------------------- validate
857function structureProblem(code, message, subject, supportedFixes) {
858 return { code, severity: 'error', message, subject: { diagramType: 'lifecycle', ...subject }, evidence: {}, supportedFixes };
861function validateStructure() {
862 const diagnostics = [];
863 const problems = [];
864 const add = (...args) => {
865 const diagnostic = structureProblem(...args);
866 diagnostics.push(diagnostic);
867 problems.push(diagnostic.message);
868 };
869 if (states.size !== asArray(lifecycle.states).length) {
870 add('lifecycle/duplicate-state', 'State ids must be unique.', { collection: 'states' }, ['give every state a unique id']);
871 }
872 const ids = [...states.keys()];
873 mainPath.forEach((id, index) => {
874 if (!states.has(id)) {
875 add('lifecycle/unknown-main-path-state', `mainPath[${index}] "${id}" is not a state.`, { path: `/mainPath/${index}` },
876 ids.slice(0, 3).map((known) => `set /mainPath/${index} to state id "${known}"`));
877 }
878 });
879 if (new Set(mainPath).size !== mainPath.length) {
880 add('lifecycle/repeated-main-path-state', 'mainPath lists a state more than once; a return to an earlier phase is a transition, not a repeated step.',
881 { path: '/mainPath' }, ['list each main-path state once and keep the return as a transition']);
882 }
883 transitions.forEach((transition, index) => {
884 for (const field of ['from', 'to']) {
885 if (states.has(transition[field])) continue;
886 add('lifecycle/unknown-endpoint', `Transition ${index} references unknown ${field === 'from' ? 'source' : 'target'} "${transition[field]}".`,
887 { collection: 'transitions', path: `/transitions/${index}/${field}`, transition: transition.id ?? null },
888 ids.slice(0, 3).map((known) => `set /transitions/${index}/${field} to state id "${known}"`));
889 }
890 });
891 for (let index = 0; index + 1 < mainPath.length; index += 1) {
892 const [from, to] = [mainPath[index], mainPath[index + 1]];
893 if (!transitions.some((transition) => transition.from === from && transition.to === to)) {
894 add('lifecycle/main-path-gap', `mainPath step "${from}" -> "${to}" has no transition.`, { path: `/mainPath/${index}` },
895 [`add a transition from "${from}" to "${to}"`, 'remove the state from mainPath if it is not on the happy path']);
896 }
897 }
898 asArray(lifecycle.states).forEach((state, index) => {
899 const brandRail = brandTopRailProblem(state, preferredWidth(state), TEXT.labelMinimum, 'State');
900 if (brandRail) problems.push(brandRail);
901 for (const [field, name, minimum] of [['label', 'Label', TEXT.labelMinimum], ['sublabel', 'Sublabel', TEXT.sublabelMinimum], ['tag', 'Tag', TEXT.tagMinimum]]) {
902 const value = state[field];
903 const needed = Math.ceil(minimumNodeTextWidth(value || '', minimum));
904 const available = availableNodeTextWidth(STATE_MAX_W) - (field === 'label' && brandMarkFor(state) ? 26 : 0);
905 if (value && needed > available) {
906 add('lifecycle/state-text-too-long',
907 `${name} "${value}" of state "${state.id}" is wider than the widest ${STATE_MAX_W}px state: it needs ~${needed}px at the ${minimum}px legible minimum, but ${available}px fit — shorten the ${field} and move detail into a card.`,
908 { collection: 'states', path: `/states/${index}/${field}` }, [`shorten the ${field} and move detail into a card`]);
909 }
910 }
911 });
912 if (problems.length) throwDiagnosticProblems('Lifecycle validation failed', problems, { subject: { diagramType: 'lifecycle' }, diagnostics });
915function validateLayout() {
916 const problems = [];
917 const diagnostics = [];
918 // The common width factor cancels when comparing the two text lines.
919 const widthDriver = (transition) => {
920 const label = textUnits(transition.label || '') * LABEL_FONT;
921 const note = textUnits(transition.note || '') * NOTE_FONT;
922 return label === note ? 'label+note' : label > note ? 'label' : 'note';
923 };
924 const preserveConditions = 'preserve every condition; shorten wording only, or move complete detail to a card explicitly associated with the same transition; shorter wording may not resolve other layout constraints';
925 if (viewBox[0] > readableWidthBudget) {
926 // A labelled main-path step widens its gap past the default spacing.
927 const wideStepLabels = mainPath.slice(0, -1).flatMap((id, index) => {
928 const edge = spineEdges.get(index);
929 const excessPx = edge && hasLabel(edge) ? Math.round(labelBox(edge).width + 26 - spacing.spineGap) : 0;
930 return excessPx > 0 ? [{ from: edge.from, to: edge.to, label: edge.label || edge.note, excessPx, widthDriver: widthDriver(edge) }] : [];
931 }).sort((a, b) => b.excessPx - a.excessPx);
932 const widenerLabels = [...spacingWideners].map((transition) => `"${transition.label || transition.note}"`);
933 const labelFixes = [
934 ...(widenerLabels.length ? [`relieve the routes crowding ${widenerLabels.join(', ')}: those label/note boxes found no clear spot at the base spacing, so lower-row and route spacing grew; main-path gap growth is bounded by the text readability budget. Only equivalent exits with identical labels, notes and variants can share an arrow; keep distinct notes separate, or move a secondary transition elsewhere`] : []),
935 ...(wideStepLabels.length ? [`review the width-driving main-path transition text: ${wideStepLabels.map((step) => `"${step.from}" -> "${step.to}" ${step.widthDriver.replace('+', ' and ')} (+${step.excessPx}px)`).join(', ')}; ${preserveConditions}`] : []),
936 ];
937 const message = `The lifecycle is ${viewBox[0]}px wide; at ${smallestText}px text it stays readable on a desktop only up to ${readableWidthBudget}px.${wideStepLabels.length ? ` Transition labels or notes widen their main-path gaps.` : ''}`;
938 diagnostics.push({
939 code: 'lifecycle/too-wide', severity: 'error', message,
940 subject: { diagramType: 'lifecycle', path: '/mainPath' },
941 evidence: { viewBoxWidth: viewBox[0], budgetPx: readableWidthBudget, mainPathStates: mainPath.length, lowerRowStates: offStates.length, wideStepLabels, spacingWideners: [...spacingWideners].map((transition) => ({ from: transition.from, to: transition.to, label: transition.label || transition.note, widthDriver: widthDriver(transition) })) },
942 supportedFixes: [...labelFixes, 'shorten state labels and sublabels', 'move secondary phases off mainPath or merge adjacent phases', 'split the lifecycle into two diagrams'],
943 });
944 problems.push(message);
945 }
946 for (const transition of geometry.unplacedLabels) {
947 const message = `Transition "${transition.from}" -> "${transition.to}" label "${transition.label || transition.note}" cannot be placed clear of nearby routes.${transition.note ? ' Its note contributes to the label box; the box size and nearby geometry both affect placement.' : ''}`;
948 diagnostics.push({
949 code: 'lifecycle/label-unplaced', severity: 'error', message,
950 subject: { diagramType: 'lifecycle', collection: 'transitions', index: transitions.indexOf(transition), from: transition.from, to: transition.to },
951 evidence: { widthDriver: widthDriver(transition) },
952 supportedFixes: [`review the transition ${transition.note ? transition.label ? 'label and note' : 'note' : 'label'} wording; ${preserveConditions}`, 'only equivalent exits with identical labels, notes and variants can share one arrow; keep distinct notes on separate exits without removing them to force sharing'],
953 });
954 problems.push(message);
955 }
956 if (problems.length) throwDiagnosticProblems('Lifecycle layout validation failed', problems, { subject: { diagramType: 'lifecycle' }, diagnostics });
959// ---------------------------------------------------------------- canvas
961const LEGEND_CATALOG = ['start', 'active', 'waiting', 'decision', 'success', 'failure', 'neutral', 'external'].map((kind) => ({
962 kind,
963 label: i18nText(lifecycle.meta.locale, `legend.lifecycle.${kind}`),
964 swatchWidth: kind === 'start' ? 26 : undefined,
965}));
967function legendCatalog() {
968 const presentKinds = new Set([...states.values()].map((state) => state.type));
969 presentKinds.add('start');
970 const entries = resolveLegend(lifecycle.meta?.legend, LEGEND_CATALOG, presentKinds);
971 if (!entries.length || ![...states.values()].some(isFinal)) return entries;
972 return [...entries, { kind: 'final', label: i18nText(lifecycle.meta.locale, 'legend.lifecycle.final'), interactive: false, present: true, swatchWidth: 16 }];
974const legendEntries = legendCatalog();
975const canvasWidth = Math.max(640, Math.ceil(geometry.contentRight + MARGIN_X));
976const legend = legendFootprint(legendEntries, { width: canvasWidth - 80 });
977const viewBox = [canvasWidth, Math.ceil(geometry.contentBottom + (legendEntries.length ? 78 + legend.extraHeight : 32))];
978validateLayout();
980const stateSteps = new Map();
981mainPath.forEach((id, index) => stateSteps.set(id, index));
982for (const [index, transition] of transitions.entries()) {
983 if (!stateSteps.has(transition.to)) stateSteps.set(transition.to, index + 1);
986// ---------------------------------------------------------------- render
988function renderState(state) {
989 const fill = typeClass[state.type] || typeClass.neutral;
990 const accent = textClass[state.type] || 't-muted';
991 const hasSub = state.sublabel != null && state.sublabel !== '';
992 const { label: labelFont, sublabel: sublabelFont, tag: tagFont } = fonts(state, state.width);
993 // Baselines keep each row's full glyph box (ascent and descent) separate.
994 const [labelY, sublabelY, tagY] = hasSub && state.tag ? [23, 40, 55] : hasSub ? [27, 45] : state.tag ? [29, 0, 50] : [37];
995 const textOffset = (state.height - STATE_H) / 2;
996 const rows = [{ text: state.label, font: labelFont, y: labelY + textOffset }];
997 if (hasSub) rows.push({ text: state.sublabel, font: sublabelFont, y: sublabelY + textOffset });
998 if (state.tag) rows.push({ text: state.tag, font: tagFont, y: tagY + textOffset });
999 const labelLayout = nodeLabelLayout({
1000 width: state.width, height: state.height, rows,
1001 brand: Boolean(brandMarkFor(state)), source: Boolean(sourceEvidence?.nodes?.[state.id]?.length), side: 'left', step: state.step,
1002 });
1003 const sub = hasSub
1004 ? `\n <text data-detail="context" x="${state.cx}" y="${state.y + labelLayout.ys[1]}" class="t-muted" font-size="${sublabelFont}" text-anchor="middle">${esc(state.sublabel)}</text>`
1005 : '';
1006 const tag = state.tag
1007 ? `\n <text data-detail="fine" x="${state.cx}" y="${state.y + labelLayout.ys[hasSub ? 2 : 1]}" class="${accent}" font-size="${tagFont}" text-anchor="middle">${esc(state.tag)}</text>`
1008 : '';
1009 const icon = state.icon ?? (QUIET_TYPES.has(state.type) ? (isFinal(state) ? 'stop' : 'none') : undefined);
1010 const step = state.step
1011 ? `\n <text data-detail="fine" x="${state.x + 23}" y="${state.y + 14}" class="${accent}" font-size="${TEXT.step}" font-weight="700">${esc(state.step)}</text>`
1012 : '';
1013 const brand = renderBrandMark(state, { x: state.x + state.width - 22, y: state.y + 6 });
1014 const initial = state.id === mainPath[0]
1015 ? `\n <g aria-hidden="true" data-lifecycle-initial-marker="">${initialMarkerShape(state.x - 26, state.x - 1, state.cy)}</g>`
1016 : '';
1017 const finalBorder = isFinal(state)
1018 ? `\n <rect x="${state.x + 3}" y="${state.y + 3}" width="${state.width - 6}" height="${state.height - 6}" rx="5" class="${fill}" style="fill: none" stroke-width="1"/>`
1019 : '';
1020 const passport = {
1021 kind: state.type,
1022 sublabel: state.sublabel,
1023 tag: state.tag,
1024 context: i18nText(lifecycle.meta.locale, 'node.context.lifecycle'),
1025 ...brandMetadataFor(state),
1026 };
1027 return ` <g ${focusNodeAttrs(state.id, state.label, passport, lifecycle.meta.locale)}>
1028 ${focusNodeTitle(state.label, passport)}
1029 <rect x="${state.x}" y="${state.y}" width="${state.width}" height="${state.height}" rx="8" class="c-mask"/>
1030 <rect x="${state.x}" y="${state.y}" width="${state.width}" height="${state.height}" rx="8" class="${fill}"${animateAttr(lifecycle.meta, 'node', stateSteps.get(state.id))} stroke-width="1.5"/>${finalBorder}${initial}
1031 ${renderSemanticSigil(state.type, { icon, x: state.x + 6, y: state.y + labelLayout.sigilY, size: labelLayout.sigilSize })}${brand ? `\n ${brand}` : ''}${step}
1032 <text data-node-label=""${hasSub ? ' data-detail-anchor=""' : ''} x="${state.x + labelLayout.x}" y="${state.y + labelLayout.ys[0]}" class="t-primary" font-size="${labelFont}" font-weight="600" text-anchor="middle">${esc(state.label)}</text>${sub}${tag}
1033 </g>`;
1036function variantOf(transition) {
1037 if (transition.variant) return transition.variant;
1038 return kindOf.get(transition) === 'spine' ? 'emphasis' : 'default';
1041function renderTransition(transition, index) {
1042 const route = geometry.routes.get(transition);
1043 const variant = variantOf(transition);
1044 const [cls, marker] = arrowClassMap[variant] || arrowClassMap.default;
1045 const strokeWidth = variant === 'emphasis' ? 1.8 : 1.2;
1046 const d = roundedPath(route.points, CORNER);
1047 const junction = route.bracket ? ` data-composition-junction="${esc(route.bracket.key.replaceAll('\u0000', '+'))}"` : '';
1048 const edge = ` <path ${focusEdgeAttrs(transition.from, transition.to, transition.label || transition.note, index, transition.id)} data-composition-points="${routePointsValue(route.points)}" data-composition-crossover="halo" data-composition-independent="true"${junction} d="${d}" class="${cls}"${animateAttr(lifecycle.meta, 'edge', index)} stroke-width="${strokeWidth}" marker-end="url(#${marker})"/>`;
1049 const underlay = ` <path data-graph-role="automatic-crossover-underlay" d="${d}" fill="none" stroke="var(--mask)" stroke-width="${strokeWidth + 4}" stroke-linecap="round" stroke-linejoin="round" pointer-events="none"/>`;
1050 return ` <g data-graph-role="automatic-crossover" style="--step:${index}">\n${underlay}\n${edge}\n </g>`;
1053function renderLabel(transition, index) {
1054 const rect = geometry.placedLabels.get(transition);
1055 if (!rect) return '';
1056 const variant = variantOf(transition);
1057 const accent = variant === 'default' ? 't-muted' : edgeLabelAccent(variant);
1058 const baseline = rect.y + (transition.label ? 11.5 : 11);
1059 const label = transition.label
1060 ? `\n <text x="${rect.cx}" y="${baseline}" class="${accent}" font-size="${LABEL_FONT}" text-anchor="middle">${esc(transition.label)}</text>`
1061 : '';
1062 const note = transition.note
1063 ? `\n <text data-detail="fine" x="${rect.cx}" y="${baseline + (transition.label ? 12 : 0)}" class="t-muted" font-size="${NOTE_FONT}" text-anchor="middle">${esc(transition.note)}</text>`
1064 : '';
1065 return ` <g data-detail="${transition.label ? 'context' : 'fine'}" ${focusEdgeAttrs(transition.from, transition.to, transition.label || transition.note, index, transition.id)}>
1066 <rect x="${rect.x}" y="${rect.y}" width="${rect.width}" height="${rect.height}" rx="4" class="c-mask"/>${label}${note}
1067 </g>`;
1070function initialMarkerShape(dotX, tipX, y) {
1071 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)"/>`;
1074function renderSwatch(entry) {
1075 if (entry.kind === 'start') return initialMarkerShape(entry.x + 4.5, entry.x + 24, entry.baseline - 3.5);
1076 if (entry.kind === 'final') {
1077 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"/>`;
1079 return `<rect x="${entry.x}" y="${entry.baseline - 8}" width="14" height="9" rx="2" class="${typeClass[entry.kind] || 'c-external'}" stroke-width="1"/>`;
1082function renderLegend() {
1083 return renderResolvedLegend({
1084 entries: legendEntries,
1085 locale: lifecycle.meta.locale,
1086 layout: {
1087 x: 40,
1088 baselineY: viewBox[1] - 32,
1089 width: viewBox[0] - 80,
1090 minTitleY: geometry.contentBottom + 12,
1091 unfit: lifecycle.meta?.legend === undefined ? 'hide' : 'error',
1092 diagramType: 'lifecycle',
1093 },
1094 renderSwatch,
1095 });
1098function renderSvg() {
1099 const junctions = geometry.junctions.map(([x, y]) => ` <circle cx="${x}" cy="${y}" r="2.6" data-lifecycle-junction="" style="fill: var(--arrow)" aria-hidden="true"/>`).join('\n');
1100 const frames = geometry.frames.map((rect) => ` <rect x="${rect.x}" y="${rect.y}" width="${rect.width}" height="${rect.height}" rx="14" data-lifecycle-frame="" fill="none" stroke="var(--arrow)" stroke-opacity="0.55" stroke-width="1" stroke-dasharray="1.5 3.5" stroke-linecap="round" aria-hidden="true"/>`).join('\n');
1101 return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}" data-reader-fit="intrinsic-height" data-lifecycle-layout="main-path-v3" ${svgRootAttrs(lifecycle.meta)} data-motion-choreography="walk">
1102${svgAccessibleText(lifecycle.meta, 'lifecycle')}
1103${renderDefinitions()}
1105 <!-- Background Grid -->
1106 <rect width="100%" height="100%" fill="url(#grid)" />
1108 <!-- Composite phases -->
1109${frames}
1111 <!-- Transition paths -->
1112${transitions.map(renderTransition).join('\n')}
1113${junctions}
1115 <!-- States -->
1116${[...states.values()].map(renderState).join('\n\n')}
1118 <!-- Transition labels -->
1119${transitions.map(renderLabel).filter(Boolean).join('\n')}
1121 <!-- Legend -->
1122${renderLegend()}
1123 </svg>`;
1126writeDiagram({
1127 outPath,
1128 template,
1129 diagramType: 'lifecycle',
1130 meta: lifecycle.meta,
1131 svg: renderSvg(),
1132 cards: lifecycle.cards,
1133 sourceEvidence,
1134});