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

render-sequence.mjs

1105 lines · archify/renderers/sequence/render-sequence.mjs at a406594

1import path from 'node:path';
2import { fileURLToPath } from 'node:url';
3import { esc, renderDefinitions, renderSemanticSigil, textUnits } 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, measureLegend, resolveLegend, renderLegend as renderResolvedLegend } from '../shared/legend.mjs';
7import { componentFill, arrowClassMap, rectsOverlap, cleanFlowProblems, cleanCrossingProblems, cleanAmbiguousCorridorProblems, cleanBorderRunProblems, cleanRouteRhythmProblems, cleanLabelRouteClearanceProblems, cleanLabelCanvasContainmentProblems, routePointsValue, asArray, isFinitePoint, edgeLabelAccent } from '../shared/geometry.mjs';
8import { availableNodeTextWidth, fittedNodeFontSize, minimumNodeTextWidth } from '../shared/text-fit.mjs';
9import { brandLabelFitWidth, brandMetadataFor, brandTopRailProblem, renderBrandMark } from '../shared/brand-marks.mjs';
10import { translateMessage as i18nText } from '../shared/i18n.mjs';
11import { DESKTOP_READER_DIAGRAM_WIDTH, MIN_PROJECTED_NODE_TEXT_PX, minimumReadableSourceTextPx } from '../shared/desktop-readability.mjs';
13const participantTextFit = {
14 sublabelPreferred: 7,
15 sublabelMinimum: 6,
16};
18const __dirname = path.dirname(fileURLToPath(import.meta.url));
19const { diagram: sequence, template, outPath, sourceEvidence } = await loadDiagramWithBrandMarks({
20 rendererDir: __dirname,
21 diagramType: 'sequence',
22 defaultExample: 'cache-miss-request.sequence.json'
23});
25const LEGEND_CATALOG = [
26 { kind: 'emphasis', className: 'a-emphasis', marker: 'arrowhead-emphasis', strokeWidth: 1.8 },
27 { kind: 'return', className: 'a-default', marker: 'arrowhead', dash: '3,5' },
28 { kind: 'security', className: 'a-security', marker: 'arrowhead-security' },
29 { kind: 'dashed', className: 'a-dashed', marker: 'arrowhead-dashed' },
30 { kind: 'default', className: 'a-default', marker: 'arrowhead' },
31].map((entry) => ({
32 ...entry,
33 interactive: false,
34 swatchWidth: 34,
35 swatchGap: 9,
36 label: i18nText(sequence.meta.locale, `legend.sequence.${entry.kind}`),
37}));
39function legendEntries() {
40 const presentKinds = new Set(asArray(sequence.messages).map((message) => message.variant || 'default'));
41 return resolveLegend(sequence.meta?.legend, LEGEND_CATALOG, presentKinds);
44// The legend sits below the timeline content: the last message and its note,
45// activation bars, and segment frames. Its block starts LEGEND_CONTENT_GAP
46// below that content; from the block top to the canvas bottom a one-row legend
47// needs LEGEND_BLOCK_HEIGHT (title glyphs, row, and the 54px baseline inset).
48const LEGEND_CONTENT_GAP = 12;
49const LEGEND_BLOCK_HEIGHT = 86;
51// A renderer-sized spread canvas widens until every participant label fits,
52// but never past the width where 7px sublabels would project below the
53// desktop reading minimum; an inherently crowded row still fails below.
54const spreadParticipantWidth = (canvasWidth) => Math.max(86,
55 Math.min(190, Math.round((canvasWidth - 124) / Math.max(1, asArray(sequence.participants).length)) - 24));
56function spreadColumnGeometry(canvasWidth, width = spreadParticipantWidth(canvasWidth)) {
57 const count = Math.max(1, asArray(sequence.participants).length);
58 const span = count * width + (count - 1) * 16;
59 const margin = Math.max(40, Math.min(62, canvasWidth - 40 - span));
60 return { width, margin, gap: count > 1 ? Math.max(width + 16, (canvasWidth - 40 - margin - width) / (count - 1)) : 108 };
62// Evaluate the same spread coordinates and full masks used by the renderer;
63// short participant names alone do not imply a compact timeline will fit.
64function timelineLabelsFit(canvasWidth) {
65 const { width, margin, gap } = spreadColumnGeometry(canvasWidth);
66 const centers = new Map(asArray(sequence.participants).map((participant, index) => [participant.id, margin + width / 2 + index * gap]));
67 const unitWidth = sequence.meta?.quality_profile === 'showcase' ? 6.6 : 5.2;
68 return asArray(sequence.messages).every((message) => {
69 if (!centers.has(message.from) || !centers.has(message.to)) return true;
70 const center = (centers.get(message.from) + centers.get(message.to)) / 2;
71 const halfWidth = Math.max(34, textUnits(message.label) * unitWidth + 12) / 2;
72 return center - halfWidth >= 0 && center + halfWidth <= canvasWidth;
73 }) && asArray(sequence.segments).every((segment) => 56 + Math.max(42, textUnits(segment.label) * 5.2 + 14) <= canvasWidth - 48);
75const readableCanvasWidth = Math.floor(DESKTOP_READER_DIAGRAM_WIDTH * participantTextFit.sublabelPreferred / MIN_PROJECTED_NODE_TEXT_PX);
76function automaticCanvasWidth() {
77 const participantsFit = (width) => asArray(sequence.participants).every((participant) => (
78 textUnits(participant.label) * 6.8 <= width + 6
79 && (!participant.sublabel || minimumNodeTextWidth(participant.sublabel, participantTextFit.sublabelMinimum) <= availableNodeTextWidth(width))
80 ));
81 const count = Math.max(1, asArray(sequence.participants).length);
82 const columnFitMode = sequence.meta?.column_fit || 'spread';
83 if (columnFitMode !== 'spread') return 920;
84 if (participantsFit(spreadParticipantWidth(920))) {
85 // Few-participant automatic canvases: keep readable box sizes but drop the
86 // empty gutters a 920px spread leaves beside 2–3 lifelines. Authored
87 // viewBoxes and the widen-for-labels path below are unchanged.
88 if (count <= 3) {
89 for (const candidate of [560, 640, 720, 800, 920]) {
90 const box = spreadParticipantWidth(candidate);
91 const span = count * box + (count - 1) * 16;
92 if (box >= 86 && participantsFit(box) && candidate - 80 >= span && timelineLabelsFit(candidate)) return candidate;
93 }
94 }
95 return 920;
96 }
97 const needed = Math.max(...asArray(sequence.participants).map((participant) => Math.max(
98 textUnits(participant.label) * 6.8 - 6,
99 participant.sublabel ? minimumNodeTextWidth(participant.sublabel, participantTextFit.sublabelPreferred) + 86 - availableNodeTextWidth(86) : 0,
100 )));
101 return Math.min(readableCanvasWidth, Math.max(920, Math.ceil((Math.min(190, needed) + 25) * count + 124)));
103const automaticWidth = sequence.meta?.viewBox ? null : automaticCanvasWidth();
105// Column geometry depends only on the canvas width, which is known before the
106// automatic height is: notes wrap inside lane gaps, and their wrapped height
107// feeds that automatic height.
108// `column_fit: "spread"` widens the lanes with the viewBox instead of keeping
109// the fixed 108px gap, so a wide canvas gains column distance and label room
110// rather than dead space on the right. Spread is the default on both automatic
111// and authored canvases; explicit fixed retains historical coordinates.
112const canvasWidth = sequence.meta?.viewBox?.[0] ?? automaticWidth;
113const columnFit = sequence.meta?.column_fit || 'spread';
114const participantCount = Math.max(1, asArray(sequence.participants).length);
115// Resolve the actual final-canvas text floor before choosing header geometry.
116// Notes and automatic height must use the resulting column gaps below.
117const readableMessages = sequence.meta?.quality_profile === 'showcase';
118const readableAutomaticSublabel = readableMessages && automaticWidth !== null && columnFit === 'spread';
119const readableSublabelMinimum = readableAutomaticSublabel
120 ? Math.max(participantTextFit.sublabelMinimum, Math.ceil(minimumReadableSourceTextPx(canvasWidth) * 10) / 10)
121 : participantTextFit.sublabelMinimum;
122const readableSublabelPreferred = Math.max(participantTextFit.sublabelPreferred, readableSublabelMinimum);
124function participantSpreadGeometry() {
125 const nominal = spreadColumnGeometry(canvasWidth);
126 const participants = asArray(sequence.participants);
127 // Preserve every already-fitting row and all authored/fixed geometry. Only
128 // a failed primary label opens this bounded use of spare horizontal room.
129 if (automaticWidth === null || columnFit !== 'spread'
130 || !participants.some((participant) => textUnits(participant.label) * 6.8 > nominal.width + 6)) return nominal;
131 const required = Math.ceil(Math.max(nominal.width, ...participants.map((participant) => Math.max(
132 textUnits(participant.label) * 6.8 - 6,
133 participant.sublabel ? minimumNodeTextWidth(participant.sublabel, readableSublabelMinimum)
134 + nominal.width - availableNodeTextWidth(nominal.width) : 0,
135 ))));
136 const capacity = Math.floor(Math.min(190, (canvasWidth - 80 - (participantCount - 1) * 16) / participantCount));
137 // Keep the existing real failures if ordinary text or the brand rail cannot
138 // fit the minimum candidate. Do not clamp a demand into infeasible geometry.
139 if (required > capacity || participants.some((participant) => brandTopRailProblem(participant, required, 8, 'Participant'))) return nominal;
140 return spreadColumnGeometry(canvasWidth, required);
143const preferredSideMargin = 62;
144const spreadGeometry = participantSpreadGeometry();
145const participantW = columnFit === 'spread' ? spreadGeometry.width : 86;
146// Narrow feasible frames can reduce the left margin, while ordinary frames
147// keep 62px. Compute card width first so this does not change its sizing rule.
148const sideMargin = columnFit === 'spread'
149 ? spreadGeometry.margin
150 : preferredSideMargin;
151// Fit the authored width when feasible, preserving a real 16px card gutter.
152// Infeasible frames retain that minimum and fail the capacity check below.
153const colGap = columnFit === 'spread' && participantCount > 1
154 ? spreadGeometry.gap
155 : 108;
156const leftX = columnFit === 'spread' ? sideMargin + participantW / 2 : sideMargin;
157const participantIndexById = new Map(asArray(sequence.participants).map((participant, index) => [participant.id, index]));
159function participantX(index) {
160 return leftX + index * colGap;
163// A note wraps inside one gap between neighbouring lifelines, so it never
164// crosses an unrelated lifeline or activation bar (#676). Every character
165// stays visible, including in image exports: long unbroken text (a URL or
166// path) breaks after its punctuation, otherwise at any character.
167const NOTE_FONT_SIZE = 7;
168const NOTE_LINE_HEIGHT = NOTE_FONT_SIZE + 4;
169// The first line keeps its original position: 12px past the arrow start,
170// which sits 7px from the lifeline.
171const NOTE_ARROW_INSET = 7;
172const NOTE_TEXT_INSET = 12;
173// Activation bars reach 5px past a lifeline; keep 6px of air beyond them.
174const NOTE_LIFELINE_CLEARANCE = 11;
175// Spread columns share one computed gap, but subtracting floating-point column
176// centres can differ in the last digits; such gaps still count as equal.
177const NOTE_GAP_TOLERANCE = 0.5;
178const NOTE_BREAK_AFTER = new Set(['/', '.', '-', '?', '&', '=', '#', '_']);
179const noteSegmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
180let noteWordSegmenter;
181const noteLayouts = new Map();
183function noteTextWidth(text) {
184 return minimumNodeTextWidth(text, NOTE_FONT_SIZE);
187// Index after the last punctuation break in `piece`, or its full length.
188function noteBreakIndex(piece) {
189 for (let index = piece.length - 1; index > 0; index -= 1) {
190 if (NOTE_BREAK_AFTER.has(piece[index - 1])) return index;
191 }
192 return piece.length;
195function wrapNoteText(text, width) {
196 const original = wrapNoteWords(String(text).match(/\s+|\S+/gu) || [], width);
197 if (!/\p{Script=Han}/u.test(text)) return original;
199 // Space-only tokens can waste half a line before a Chinese phrase. Split Han
200 // words, preserving other runs (identifiers, URLs, +4) and closing punctuation.
201 noteWordSegmenter ??= new Intl.Segmenter(undefined, { granularity: 'word' });
202 const words = [];
203 for (const token of String(text).match(/\s+|\p{Script=Han}[\p{Script=Han}\p{Mark}]*|[^\s\p{Script=Han}]+/gu) || []) {
204 if (/\p{Script=Han}/u.test(token)) {
205 for (const { segment } of noteWordSegmenter.segment(token)) words.push(segment);
206 } else if (/^[、。,;:!?)】》」』]+$/u.test(token) && words.length) {
207 words[words.length - 1] += token;
208 } else {
209 words.push(token);
210 }
211 }
212 const compact = wrapNoteWords(words, width);
213 // Never grow a note or change a same-height wrap merely to rebalance lines.
214 return compact.length < original.length ? compact : original;
217function wrapNoteWords(words, width) {
218 const lines = [];
219 let line = '';
220 const flush = () => {
221 const trimmed = line.trimEnd();
222 if (trimmed) lines.push(trimmed);
223 line = '';
224 };
225 for (const word of words) {
226 if (/^\s+$/u.test(word)) {
227 if (line) line += ' ';
228 continue;
229 }
230 if (noteTextWidth(line + word) <= width) {
231 line += word;
232 continue;
233 }
234 flush();
235 let piece = '';
236 for (const { segment } of noteSegmenter.segment(word)) {
237 while (piece && noteTextWidth(piece + segment) > width) {
238 const cut = noteBreakIndex(piece);
239 lines.push(piece.slice(0, cut));
240 piece = piece.slice(cut);
241 }
242 piece += segment;
243 }
244 line = piece;
245 }
246 flush();
247 return lines;
250// The widest gap the message spans; equal gaps resolve to the left-most one,
251// where a note has always started, so notes keep their original position.
252function noteGapIndex(fromIndex, toIndex) {
253 const low = Math.min(fromIndex, toIndex);
254 const high = Math.max(fromIndex, toIndex);
255 let best = null;
256 for (let gap = low; gap < high; gap += 1) {
257 const width = participantX(gap + 1) - participantX(gap);
258 if (!best || width > best.width + NOTE_GAP_TOLERANCE) best = { gap, width };
259 }
260 return best?.gap ?? null;
263function noteRect(message) {
264 const note = noteLayout(message);
265 return note && { x: note.x, y: note.top, width: note.width, height: note.bottom - note.top };
268function noteLayout(message) {
269 if (noteLayouts.has(message)) return noteLayouts.get(message);
270 let result = null;
271 const fromIndex = participantIndexById.get(message.from);
272 const toIndex = participantIndexById.get(message.to);
273 if (message.note && typeof message.y === 'number' && fromIndex !== undefined && toIndex !== undefined && fromIndex !== toIndex) {
274 const gap = noteGapIndex(fromIndex, toIndex);
275 const x = participantX(gap) + NOTE_ARROW_INSET + NOTE_TEXT_INSET;
276 const maxWidth = Math.max(1, participantX(gap + 1) - NOTE_LIFELINE_CLEARANCE - x);
277 const lines = noteTextWidth(message.note) <= maxWidth ? [message.note] : wrapNoteText(message.note, maxWidth);
278 const baseline = message.y + 18;
279 result = {
280 x,
281 baseline,
282 lines,
283 width: Math.max(...lines.map(noteTextWidth)),
284 // Include fallback-font ascent in the same bounds used for title clearance.
285 top: baseline - NOTE_FONT_SIZE * 1.2,
286 bottom: message.y + 22 + (lines.length - 1) * NOTE_LINE_HEIGHT,
287 };
288 }
289 noteLayouts.set(message, result);
290 return result;
293const contentBottom = Math.max(
294 0,
295 ...asArray(sequence.messages).map((message) => (message.note
296 ? noteLayout(message)?.bottom ?? message.y + 22
297 : message.y + 6)),
298 ...asArray(sequence.activations).map((activation) => activation.to),
299 ...asArray(sequence.segments).map((segment) => segment.to),
300);
301function legendRequiredHeight(width) {
302 const entries = legendEntries();
303 if (!entries.length) return 0;
304 return Math.ceil(contentBottom + LEGEND_CONTENT_GAP + LEGEND_BLOCK_HEIGHT
305 + legendFootprint(entries, { width: width - 80 }).extraHeight);
307// A renderer-sized canvas grows to keep the legend clear of late messages and
308// shrinks when the timeline is short. Keep a readable minimum band (lifelineTop
309// 142 + 120px timeline + 65px footer = 327); honor authored canvases below.
310const SEQUENCE_MIN_AUTO_HEIGHT = 327;
311// Timeline clearance is independent of legend visibility. Messages need 18px
312// before the lifeline bottom; notes, activations and frames need their footer.
313const timelineRequiredHeight = Math.max(contentBottom + 65,
314 ...asArray(sequence.messages).map((message) => message.y + 18 + 65));
315const automaticHeight = Math.max(SEQUENCE_MIN_AUTO_HEIGHT, timelineRequiredHeight, legendRequiredHeight(automaticWidth));
316const viewBox = sequence.meta?.viewBox || [automaticWidth, automaticHeight];
317// The timeline scales with viewBox height: a taller viewBox gains message room,
318// a shorter one shrinks the readable band (validated below) instead of clipping.
320// Showcase is the fast-authoring default; standard retains legacy label geometry.
321const messageFontSize = readableMessages ? 11 : 9;
322const messageUnitWidth = readableMessages ? 6.6 : 5.2;
323const layout = {
324 topY: 72,
325 participantW,
326 // Keep a separate top rail for the 11px semantic sigil and 16px brand mark.
327 // Literal labels retain their fitted font size and full authored wording.
328 participantH: 60,
329 participantLabelY: 36,
330 participantSublabelY: 50,
331 lifelineTop: 142,
332 lifelineBottom: viewBox[1] - 65,
333 legendY: viewBox[1] - 54,
334 leftX,
335 colGap,
336 labelH: readableMessages ? 18 : 16,
337};
339const participantBoxWidthNote = automaticWidth
340 ? `participant boxes are ${participantW}px: the automatic ${viewBox[0]}px canvas cannot widen further without its 7px sublabels falling below the desktop reading minimum, so keep meta.viewBox omitted`
341 : columnFit === 'spread'
342 ? `participant boxes are ${participantW}px for this viewBox width and ${participantCount} participants`
343 : `participant boxes are a fixed ${participantW}px unless meta.column_fit is "spread"`;
345const arrowClass = {
346 ...arrowClassMap,
347 return: ['a-default', 'arrowhead']
348};
350const participants = new Map(asArray(sequence.participants).map((participant, index) => [
351 participant.id,
352 {
353 ...participant,
354 index,
355 cx: participantX(index),
356 x: participantX(index) - layout.participantW / 2,
357 y: layout.topY,
358 width: layout.participantW,
359 height: layout.participantH,
360 cy: layout.topY + layout.participantH / 2
361 }
362]));
364function messageGeometry(message) {
365 const from = participants.get(message.from);
366 const to = participants.get(message.to);
367 if (!from || !to || typeof message.y !== 'number') return null;
368 const direction = to.cx > from.cx ? 1 : -1;
369 const start = from.cx + direction * 7;
370 const end = to.cx - direction * 7;
371 return { start, end, center: (start + end) / 2 };
374function messageLabelBox(message, relationIndex = null) {
375 const geometry = messageGeometry(message);
376 if (!geometry) return null;
377 const width = Math.max(34, textUnits(message.label) * messageUnitWidth + 12);
378 return {
379 relation: message,
380 relationIndex,
381 label: message.label,
382 x: geometry.center - width / 2,
383 y: message.y - 20,
384 width,
385 height: layout.labelH,
386 };
389function messageRouteBox(message) {
390 const geometry = messageGeometry(message);
391 if (!geometry) return null;
392 return {
393 x: Math.min(geometry.start, geometry.end),
394 y: message.y - 2,
395 width: Math.abs(geometry.end - geometry.start),
396 height: 4,
397 };
400// Message labels, routes and notes a segment label must not cover, with the
401// source path of each so a failure can name what it hides.
402function segmentLabelOccupants() {
403 return asArray(sequence.messages).flatMap((message, messageIndex) => [
404 { kind: 'message label', path: `/messages/${messageIndex}/label`, message, rect: messageLabelBox(message) },
405 { kind: 'message route', path: `/messages/${messageIndex}/y`, message, rect: messageRouteBox(message) },
406 { kind: 'note', path: `/messages/${messageIndex}/note`, message, rect: noteRect(message) },
407 ]).filter((occupant) => occupant.rect);
410function legacySegmentLabelBox(segment) {
411 const labelW = Math.max(42, textUnits(segment.label) * 5.2 + 14);
412 const occupied = segmentLabelOccupants();
413 const label = { x: 56, y: segment.from - 22, width: labelW, height: 18 };
414 for (let attempt = 0; attempt < 4; attempt += 1) {
415 if (!occupied.some(({ rect }) => rectsOverlap(label, rect, 2))) break;
416 label.y -= 22;
417 }
418 if (!segmentLabelMisplaced(label, segment)) return label;
419 // Abutting segments can leave no room on the border: the badge climbed
420 // behind the participant headers or into the previous segment, where it
421 // names the wrong phase. Its own frame's top-left corner, clear of messages,
422 // names the right one.
423 // Another segment's title normally sits on that segment's own top border,
424 // so its strip is taken too.
425 const otherTitles = asArray(sequence.segments).filter((other) => other !== segment)
426 .map((other) => ({ x: 56, y: other.from - 22, width: Math.max(42, textUnits(other.label) * 5.2 + 14), height: 18 }));
427 const inside = { x: 56, y: segment.from + 4, width: labelW, height: 18 };
428 for (let attempt = 0; attempt < 4 && inside.y + inside.height <= segment.to - 2; attempt += 1) {
429 if (!segmentLabelMisplaced(inside, segment)
430 && ![...occupied.map(({ rect }) => rect), ...otherTitles, ...participants.values()].some((rect) => rectsOverlap(inside, rect, 2))) return inside;
431 inside.y += 22;
432 }
433 return label;
436function segmentLabelBox(segment) {
437 const label = legacySegmentLabelBox(segment);
438 const headers = [...participants.values()];
439 const coveredHeaders = headers.filter((header) => rectsOverlap(label, header, 0));
440 if (!coveredHeaders.length || label.y + label.height > segment.from) return label;
442 const others = asArray(sequence.segments).filter((other) => other !== segment);
443 // Only the unique first, non-overlapping phase gets this local adjustment.
444 // Nested phases keep their authored bounds and the existing title policy.
445 if (others.some((other) => other.from <= segment.from
446 || (other.from < segment.to && other.to > segment.from))) return label;
448 const candidate = { ...label, y: Math.max(...coveredHeaders.map((header) => header.y + header.height)) + 2 };
449 const bottom = candidate.y + candidate.height;
450 // The badge must still touch its own top border, before the first message;
451 // moving it deep into the phase would name the wrong part of the timeline.
452 if (candidate.y > segment.from || bottom < segment.from || bottom > segment.to - 2) return label;
454 const obstacles = [
455 ...segmentLabelOccupants().map(({ rect }) => rect),
456 ...headers,
457 // Use actual legacy placements: another title may have climbed above its
458 // nominal slot. Calling the unchanged helper keeps this non-recursive.
459 ...others.map(legacySegmentLabelBox),
460 ];
461 if (!obstacles.some((rect) => rectsOverlap(candidate, rect, 2))) return candidate;
463 // Only this blocked header-clear strip gains a horizontal fallback. Keep
464 // successful legacy placements intact, including their activation policy.
465 const fallbackObstacles = [
466 ...obstacles,
467 ...asArray(sequence.activations).flatMap((activation) => {
468 const participant = participants.get(activation.participant);
469 return participant ? [{
470 x: participant.cx - 5, y: activation.from,
471 width: 10, height: activation.to - activation.from,
472 }] : [];
473 }),
474 ];
475 // Every free interval on this strip starts at the left frame inset or just
476 // beyond an obstacle. Scan those finite boundaries instead of pixel steps.
477 const boundaries = [...new Set([
478 candidate.x,
479 ...fallbackObstacles
480 .filter((rect) => candidate.y < rect.y + rect.height + 2
481 && rect.y < candidate.y + candidate.height + 2)
482 .map((rect) => rect.x + rect.width + 2),
483 ])].filter((x) => x >= candidate.x && x + candidate.width <= viewBox[0] - 48)
484 .sort((a, b) => a - b);
485 for (const x of boundaries) {
486 const shifted = { ...candidate, x };
487 if (!fallbackObstacles.some((rect) => rectsOverlap(shifted, rect, 2))) return shifted;
488 }
489 return label;
492// Whether moving one message to `y` keeps it readable: inside the timeline,
493// 28px from messages sharing its horizontal span, and clear of every other
494// label, route and note. Used to offer only repairs that hold.
495function messageFitsAt(messageIndex, y) {
496 const messages = asArray(sequence.messages);
497 if (y < layout.lifelineTop + 18) return false;
498 // An automatic canvas recalculates its height after this edit; an authored
499 // canvas must still fit the suggested position without a second repair.
500 if (sequence.meta?.viewBox && y > layout.lifelineBottom - 18) return false;
501 const span = (message) => {
502 const from = participants.get(message.from)?.cx;
503 const to = participants.get(message.to)?.cx;
504 return [Math.min(from, to), Math.max(from, to)];
505 };
506 const moved = { ...messages[messageIndex], y };
507 const [left, right] = span(moved);
508 const movedRects = [messageLabelBox(moved), messageRouteBox(moved), noteRect(moved)].filter(Boolean);
509 return messages.every((other, otherIndex) => {
510 if (otherIndex === messageIndex || typeof other.y !== 'number') return true;
511 const [otherLeft, otherRight] = span(other);
512 if (Math.abs(other.y - y) < 28 && left < otherRight && otherLeft < right) return false;
513 const otherRects = [messageLabelBox(other), messageRouteBox(other), noteRect(other)].filter(Boolean);
514 return !movedRects.some((rect) => otherRects.some((otherRect) => rectsOverlap(rect, otherRect, 2)));
515 });
518// The first occupant the segment label still covers after its upward steps.
519function segmentLabelBlocker(segment) {
520 const label = segmentLabelBox(segment);
521 return segmentLabelOccupants().find(({ rect }) => rectsOverlap(label, rect, 2)) || null;
524// A badge on its frame's top border is the intended title. It stops naming
525// that frame once it hides mostly behind the participant headers, or once
526// message labels pushed it up into another segment.
527function segmentLabelMisplaced(label, segment) {
528 const half = label.height / 2;
529 if (layout.topY + layout.participantH - label.y > half) return true;
530 if (label.y >= segment.from - 22) return false;
531 return asArray(sequence.segments).some((other) => other !== segment
532 && Math.min(label.y + label.height, other.to) - Math.max(label.y, other.from) > half);
535const compositionFrames = asArray(sequence.segments).map((segment, index) => ({
536 id: index,
537 label: segment.label,
538 kind: 'segment',
539 x: 48,
540 y: segment.from,
541 width: viewBox[0] - 96,
542 height: segment.to - segment.from,
543 radius: 10,
544}));
546function messagePath(message) {
547 return {
548 points: participants.has(message.from) && participants.has(message.to)
549 ? [[participants.get(message.from).cx, message.y], [participants.get(message.to).cx, message.y]]
550 : []
551 };
554function validateSequence() {
555 const problems = [];
556 const diagnostics = [];
557 if (participants.size !== asArray(sequence.participants).length) problems.push('Participant ids must be unique.');
559 if (layout.lifelineBottom - layout.lifelineTop < 120) {
560 problems.push(`viewBox height ${viewBox[1]} leaves under 120px of timeline — set meta.viewBox[1] to at least ${layout.lifelineTop + 120 + 65}.`);
561 }
563 for (const participant of participants.values()) {
564 const participantIndex = asArray(sequence.participants).findIndex((entry) => entry.id === participant.id);
565 const participantProblem = (field, message, evidence, supportedFixes) => {
566 problems.push(message);
567 diagnostics.push({
568 code: `sequence/participant-${field}-overflow`, severity: 'error', message,
569 subject: { diagramType: 'sequence', nodeId: participant.id, path: `/participants/${participantIndex}/${field}` },
570 evidence: { viewBoxWidth: viewBox[0], participantWidth: layout.participantW, ...evidence },
571 supportedFixes,
572 });
573 };
574 const estLabelW = textUnits(participant.label) * 6.8;
575 if (estLabelW > layout.participantW + 6) {
576 const message = automaticWidth
577 ? `Label "${participant.label}" (~${Math.round(estLabelW)}px) is wider than component "${participant.id}" — shorten it to at most ${Math.floor((layout.participantW + 6) / 6.8)} text units (CJK counts 2; ${participantBoxWidthNote}).`
578 : `Label "${participant.label}" (~${Math.round(estLabelW)}px) is wider than component "${participant.id}" (${layout.participantW}px) — shorten the label or widen the participant box.`;
579 participantProblem('label', message, { text: participant.label, requiredWidth: estLabelW, availableWidth: layout.participantW + 6 },
580 ['shorten the participant label while preserving its role', ...(columnFit === 'fixed' ? ['set meta.column_fit to "spread" if fixed coordinates are not required'] : [])]);
581 }
582 const brandRailProblem = brandTopRailProblem(participant, layout.participantW, 8, 'Participant');
583 if (brandRailProblem) problems.push(brandRailProblem);
584 // sublabel renders as a single unwrapped <text>; shrink-to-fit handles the
585 // ordinary case, this rejects what it cannot rescue.
586 if (participant.sublabel) {
587 const availableTextW = availableNodeTextWidth(layout.participantW);
588 const minimumW = minimumNodeTextWidth(participant.sublabel, readableSublabelMinimum);
589 if (minimumW > availableTextW) {
590 const minimumDescription = readableAutomaticSublabel
591 ? `${readableSublabelMinimum}px minimum that stays readable on this ${viewBox[0]}px canvas`
592 : `${readableSublabelMinimum}px legible minimum`;
593 const message = `Sublabel "${participant.sublabel}" needs ~${Math.ceil(minimumW)}px at the ${minimumDescription}, but participant "${participant.id}" provides ${availableTextW}px — shorten the sublabel (${participantBoxWidthNote}).`;
594 participantProblem('sublabel', message, { text: participant.sublabel, requiredWidth: minimumW, availableWidth: availableTextW, minimumFontPx: readableSublabelMinimum },
595 ['shorten the participant sublabel while preserving its role or protocol; move supplementary detail into a card', ...(columnFit === 'fixed' ? ['set meta.column_fit to "spread" if fixed coordinates are not required'] : [])]);
596 }
597 }
598 }
600 const messageList = asArray(sequence.messages);
601 const participantList = asArray(sequence.participants);
602 const participantOrder = new Map(participantList.map((p, index) => [p.id, index]));
603 for (const message of messageList) {
604 const messageIndex = messageList.indexOf(message);
605 for (const [field, endpoint] of [['from', 'source'], ['to', 'target']]) {
606 if (participants.has(message[field])) continue;
607 const problem = `Message "${message.label}" references unknown ${endpoint} "${message[field]}".`;
608 const otherField = field === 'from' ? 'to' : 'from';
609 const anchorOrder = participantOrder.get(message[otherField]) ?? 0;
610 const candidates = [...participantOrder.keys()]
611 .filter((id) => id !== message[otherField])
612 .sort((a, b) => Math.abs(participantOrder.get(a) - anchorOrder) - Math.abs(participantOrder.get(b) - anchorOrder));
613 diagnostics.push({
614 code: 'sequence/unknown-endpoint', severity: 'error', message: problem,
615 subject: {
616 diagramType: 'sequence',
617 message: message.label ?? null,
618 path: `/messages/${messageIndex}/${field}`,
619 from: message.from,
620 to: message.to,
621 },
622 evidence: { endpoint, unknownNodeId: message[field], availableNodeIds: candidates },
623 supportedFixes: candidates.slice(0, 3).map((id) => `set /messages/${messageIndex}/${field} to verified node id "${id}"`),
624 });
625 problems.push(problem);
626 }
627 if (typeof message.y !== 'number') problems.push(`Message "${message.label}" must provide a numeric y.`);
628 if (message.y < layout.lifelineTop + 18 || message.y > layout.lifelineBottom - 18) {
629 problems.push(`Message "${message.label}" sits outside the readable timeline — keep y between ${layout.lifelineTop + 18} and ${layout.lifelineBottom - 18}.`);
630 }
631 if (participants.has(message.from) && participants.has(message.to)) {
632 if (message.from === message.to) {
633 const problem = `Message "${message.label}" is a self-message on participant "${message.from}"; this Sequence renderer supports only messages between distinct participants. Participant spacing cannot repair it. Preserve the internal step's meaning and order in a supported representation such as a note on a real message or a card; do not invent a participant.`;
634 diagnostics.push({
635 code: 'sequence/self-message-unsupported', severity: 'error', message: problem,
636 subject: {
637 diagramType: 'sequence',
638 message: message.label,
639 collection: 'messages',
640 index: messageIndex,
641 path: `/messages/${messageIndex}`,
642 ...(message.id ? { id: message.id } : {}),
643 from: message.from,
644 to: message.to,
645 fromPath: `/messages/${messageIndex}/from`,
646 toPath: `/messages/${messageIndex}/to`,
647 },
648 evidence: {
649 participant: message.from,
650 participantPath: `/participants/${participantOrder.get(message.from)}`,
651 y: message.y,
652 supportedMessageGeometry: 'horizontal-between-distinct-participants',
653 },
654 supportedFixes: [],
655 });
656 problems.push(problem);
657 } else {
658 const distance = Math.abs(participants.get(message.to).cx - participants.get(message.from).cx);
659 if (distance < 60) problems.push(`Message "${message.label}" spans ${Math.round(distance)}px (minimum 60px) — give its participants more column distance.`);
660 }
661 }
662 }
664 // Participant headers are opaque nodes. Lifelines, activation bars, and
665 // segment bands remain intentional pass-through geometry and are excluded.
666 problems.push(...cleanFlowProblems({
667 relations: sequence.messages,
668 obstacles: participants.values(),
669 pathFor: messagePath,
670 diagramType: 'sequence',
671 relationCollection: 'messages',
672 obstacleKind: 'participant header',
673 clearance: 0,
674 routeHint: 'move the message y below the participant headers or reorder participants'
675 }));
676 problems.push(...cleanCrossingProblems({
677 relations: sequence.messages,
678 endpointIds: new Set(participants.keys()),
679 pathFor: messagePath,
680 diagramType: 'sequence',
681 relationCollection: 'messages',
682 profile: sequence.meta?.quality_profile,
683 routeHint: 'separate the message y values; lifeline crossings remain allowed'
684 }));
685 problems.push(...cleanAmbiguousCorridorProblems({
686 relations: sequence.messages,
687 endpointIds: new Set(participants.keys()),
688 pathFor: messagePath,
689 diagramType: 'sequence',
690 relationCollection: 'messages',
691 profile: sequence.meta?.quality_profile,
692 routeHint: 'separate the message y values so unrelated messages do not visually merge'
693 }));
694 problems.push(...cleanBorderRunProblems({
695 relations: sequence.messages,
696 endpointIds: new Set(participants.keys()),
697 frames: compositionFrames,
698 pathFor: messagePath,
699 diagramType: 'sequence',
700 relationCollection: 'messages',
701 profile: sequence.meta?.quality_profile,
702 routeHint: 'move the message y so it crosses a segment boundary perpendicularly or stays clearly inside the segment'
703 }));
704 problems.push(...cleanRouteRhythmProblems({
705 relations: sequence.messages,
706 endpointIds: new Set(participants.keys()),
707 pathFor: messagePath,
708 diagramType: 'sequence',
709 relationCollection: 'messages',
710 profile: sequence.meta?.quality_profile,
711 routeHint: 'increase participant spacing or simplify message routing so every turn has room to read'
712 }));
714 // Vertical crowding only matters when the arrows share horizontal space;
715 // disjoint arrows may legitimately run in parallel rows.
716 const placed = asArray(sequence.messages)
717 .filter((m) => participants.has(m.from) && participants.has(m.to))
718 .map((m) => ({
719 label: m.label,
720 y: m.y,
721 x1: Math.min(participants.get(m.from).cx, participants.get(m.to).cx),
722 x2: Math.max(participants.get(m.from).cx, participants.get(m.to).cx)
723 }))
724 .sort((a, b) => a.y - b.y);
725 for (let i = 0; i < placed.length; i += 1) {
726 for (let j = i + 1; j < placed.length && placed[j].y - placed[i].y < 28; j += 1) {
727 if (placed[i].x1 < placed[j].x2 && placed[j].x1 < placed[i].x2) {
728 problems.push(`Messages "${placed[i].label}" and "${placed[j].label}" are less than 28px apart and share horizontal space — spread their y values.`);
729 }
730 }
731 }
733 // Label masks can extend well past the arrow span, so check the actual
734 // label rectangles too — tangent arrows with long labels still collide.
735 const labelRects = asArray(sequence.messages)
736 .map((m, messageIndex) => messageLabelBox(m, messageIndex))
737 .filter(Boolean);
738 for (let i = 0; i < labelRects.length; i += 1) {
739 for (let j = i + 1; j < labelRects.length; j += 1) {
740 if (rectsOverlap(labelRects[i], labelRects[j], -2)) {
741 problems.push(`Labels "${labelRects[i].label}" and "${labelRects[j].label}" overlap — spread their message y values or shorten the labels.`);
742 }
743 }
744 }
745 problems.push(...cleanLabelRouteClearanceProblems({
746 relations: sequence.messages,
747 labels: labelRects,
748 endpointIds: new Set(participants.keys()),
749 pathFor: messagePath,
750 diagramType: 'sequence',
751 relationCollection: 'messages',
752 profile: sequence.meta?.quality_profile,
753 routeHint: 'spread the message y values, shorten the label, or reorder participants so the adjacent route stays visible'
754 }));
755 problems.push(...cleanLabelCanvasContainmentProblems({
756 labels: labelRects,
757 viewBox,
758 diagramType: 'sequence',
759 relationCollection: 'messages',
760 profile: sequence.meta?.quality_profile,
761 routeHint: 'shorten the label, reorder participants, or enlarge meta.viewBox',
762 }));
764 // A wrapped note keeps every character (#676), so a note that still reaches a
765 // later message or the canvas bottom needs the diagram changed; authored y
766 // values and viewBox stay as written. Showcase only, so standard keeps
767 // accepting existing diagrams.
768 const report = (problem, detail) => {
769 problems.push(problem);
770 diagnostics.push({ severity: 'error', message: problem, ...detail });
771 };
772 const bounds = (rect) => ({
773 x: Math.round(rect.x * 10) / 10,
774 y: Math.round(rect.y * 10) / 10,
775 width: Math.round(rect.width * 10) / 10,
776 height: Math.round(rect.height * 10) / 10,
777 });
778 if (readableMessages) {
779 const messages = asArray(sequence.messages);
780 const noteFloor = layout.lifelineBottom + 20;
781 messages.forEach((message, messageIndex) => {
782 const note = noteLayout(message);
783 if (!note) return;
784 const lines = note.lines.length === 1 ? '1 line' : `${note.lines.length} lines`;
785 const bottom = Math.ceil(note.bottom);
786 const ownRect = noteRect(message);
787 const subject = {
788 diagramType: 'sequence',
789 message: message.label ?? null,
790 path: `/messages/${messageIndex}/note`,
791 from: message.from,
792 to: message.to,
793 };
794 if (note.bottom > noteFloor) {
795 // A visible legend needs its own room below the note as well.
796 const requiredHeight = Math.max(Math.ceil(viewBox[1] + note.bottom - noteFloor), legendRequiredHeight(viewBox[0]));
797 const fix = sequence.meta?.viewBox
798 ? `set /meta/viewBox/1 to ${requiredHeight}`
799 : `set /meta/viewBox to [${viewBox[0]}, ${requiredHeight}]`;
800 report(`Note on message "${message.label}" wraps to ${lines} and ends at y=${bottom}, below the canvas content limit y=${noteFloor} — set meta.viewBox[1] to at least ${requiredHeight}, move the message up, or shorten the note.`, {
801 code: 'sequence/note-canvas-limit',
802 subject,
803 evidence: { lines: note.lines.length, note: bounds(ownRect), canvasLimitY: noteFloor, viewBoxHeight: viewBox[1], requiredViewBoxHeight: requiredHeight },
804 supportedFixes: [fix],
805 });
806 }
807 const blocker = messages
808 .map((other, otherIndex) => {
809 if (otherIndex === messageIndex || typeof other.y !== 'number' || other.y <= message.y) return null;
810 const hit = [
811 ['message label', messageLabelBox(other)],
812 ['message route', messageRouteBox(other)],
813 ['note', noteRect(other)],
814 ].find(([, rect]) => rect && rectsOverlap(ownRect, rect, 2));
815 return hit ? { message: other, index: otherIndex, kind: hit[0], rect: hit[1] } : null;
816 })
817 .filter(Boolean)
818 .sort((a, b) => a.message.y - b.message.y)[0];
819 if (!blocker) return;
820 const requiredY = Math.ceil(note.bottom + 22);
821 const blockerPath = `/messages/${blocker.index}/y`;
822 report(`Note on message "${message.label}" wraps to ${lines} (down to y=${bottom}) and reaches message "${blocker.message.label}" at y=${blocker.message.y} — move "${blocker.message.label}" and later messages down so it sits at y=${requiredY} or below, or shorten the note.`, {
823 code: 'sequence/note-overlap',
824 subject,
825 evidence: {
826 lines: note.lines.length,
827 note: bounds(ownRect),
828 blocker: { message: blocker.message.label ?? null, path: blockerPath, y: blocker.message.y, kind: blocker.kind, bounds: bounds(blocker.rect) },
829 requiredY,
830 },
831 supportedFixes: messageFitsAt(blocker.index, requiredY) ? [`set ${blockerPath} to ${requiredY}`] : [],
832 });
833 });
834 }
836 asArray(sequence.segments).forEach((segment, segmentIndex) => {
837 if (segment.to <= segment.from) {
838 problems.push(`Segment "${segment.label}" has invalid y range (from ${segment.from} to ${segment.to}) — "to" must be greater than "from".`);
839 }
840 if (segment.from < layout.topY || segment.to > layout.lifelineBottom + 20) {
841 problems.push(`Segment "${segment.label}" extends outside the canvas — keep its y range between ${layout.topY} and ${layout.lifelineBottom + 20}.`);
842 }
843 // A frame edge may pass behind a masked message label, but an edge that
844 // runs along the arrow itself leaves the reader unable to tell which phase
845 // the message belongs to.
846 if (sequence.meta?.quality_profile === 'showcase') {
847 for (const edge of ['from', 'to']) {
848 const border = segment[edge];
849 const cut = asArray(sequence.messages).filter((message) => typeof message.y === 'number'
850 && Math.abs(border - message.y) < 4);
851 for (const message of cut) {
852 const problem = `Segment "${segment.label}" ${edge === 'from' ? 'top' : 'bottom'} edge at y ${border} runs along message "${message.label}" (arrow at y ${message.y}) — move the edge to at least ${message.y + 4} to leave the message above it, or to at most ${message.y - 4} to leave it below.`;
853 const segmentIndex = asArray(sequence.segments).indexOf(segment);
854 problems.push(problem);
855 diagnostics.push({
856 code: 'sequence/segment-message-border-run', severity: 'error', message: problem,
857 subject: { diagramType: 'sequence', collection: 'segments', index: segmentIndex, path: `/segments/${segmentIndex}/${edge}` },
858 evidence: { segmentLabel: segment.label, edge, borderY: border, messageIndex: messageList.indexOf(message), messageLabel: message.label, messageY: message.y, minimumClearancePx: 4 },
859 supportedFixes: [`set /segments/${segmentIndex}/${edge} to at least ${message.y + 4} to leave the message above it`, `set /segments/${segmentIndex}/${edge} to at most ${message.y - 4} to leave the message below it`],
860 });
861 }
862 }
863 }
864 const labelBox = segmentLabelBox(segment);
865 for (const other of asArray(sequence.segments)) {
866 if (other === segment || asArray(sequence.segments).indexOf(other) < asArray(sequence.segments).indexOf(segment)) continue;
867 if (rectsOverlap(labelBox, segmentLabelBox(other), 0)) {
868 problems.push(`Segment labels "${segment.label}" and "${other.label}" overlap — leave more room between the segments' messages near their shared border, or shorten a label.`);
869 }
870 }
871 const availableWidth = Math.max(0, viewBox[0] - 48 - labelBox.x);
872 if (labelBox.x + labelBox.width > viewBox[0] - 48) {
873 const requiredWidth = Math.ceil(labelBox.x + labelBox.width + 48);
874 problems.push(`Segment "${segment.label}" label (~${Math.round(labelBox.width)}px) exceeds the segment frame's available width (${availableWidth}px) — shorten the label or increase meta.viewBox[0] to at least ${requiredWidth}.`);
875 }
876 // Segment labels are drawn above messages and notes and step up to stay
877 // clear of them; in showcase, a label left covering any of them fails.
878 const covered = readableMessages ? segmentLabelBlocker(segment) : null;
879 if (covered) {
880 // Offer the nearest start whose label lands clear, inside the segment.
881 let clearFrom = null;
882 for (let distance = 1; distance <= 300 && clearFrom === null; distance += 1) {
883 for (const candidate of [segment.from + distance, segment.from - distance]) {
884 if (candidate < layout.topY || candidate >= segment.to) continue;
885 if (!segmentLabelBlocker({ ...segment, from: candidate })) {
886 clearFrom = candidate;
887 break;
888 }
889 }
890 }
891 const fromPath = `/segments/${segmentIndex}/from`;
892 report(`Segment "${segment.label}" label would cover the ${covered.kind} of message "${covered.message.label}" — move the segment start (from: ${segment.from}) or that message so they are further apart${covered.kind === 'note' ? ', or shorten the note' : ''}.`, {
893 code: 'sequence/segment-label-overlap',
894 subject: { diagramType: 'sequence', segment: segment.label ?? null, path: fromPath },
895 evidence: {
896 label: bounds(labelBox),
897 attempts: 4,
898 covered: { kind: covered.kind, message: covered.message.label ?? null, path: covered.path, bounds: bounds(covered.rect) },
899 },
900 supportedFixes: clearFrom === null ? [] : [`set ${fromPath} to ${clearFrom}`],
901 });
902 }
903 });
905 for (const activation of asArray(sequence.activations)) {
906 if (!participants.has(activation.participant)) problems.push(`Activation references unknown participant "${activation.participant}".`);
907 if (activation.to <= activation.from) problems.push(`Activation for "${activation.participant}" has invalid time range — "to" must be greater than "from".`);
908 }
910 const lastParticipant = asArray(sequence.participants)[asArray(sequence.participants).length - 1];
911 if (lastParticipant && participants.get(lastParticipant.id).cx + layout.participantW / 2 > viewBox[0] - 40) {
912 const requiredWidth = Math.ceil(participants.get(lastParticipant.id).cx + layout.participantW / 2 + 40);
913 problems.push(`Participants exceed viewBox width — set meta.viewBox[0] to at least ${requiredWidth} or remove a participant.`);
914 }
916 // Showcase must not silently drop the implicit legend because late content
917 // leaves no room for it; give the exact canvas height instead.
918 const legendHeight = legendRequiredHeight(viewBox[0]);
919 if (sequence.meta?.quality_profile === 'showcase' && sequence.meta?.legend === undefined
920 && legendHeight > viewBox[1] && !measureLegend(legendEntries(), legendLayout())) {
921 problems.push(`Sequence content ends at y=${contentBottom}, leaving no room for the legend below it — set meta.viewBox[1] to at least ${legendHeight} or omit meta.viewBox so the canvas grows.`);
922 }
924 if (problems.length) {
925 throwDiagnosticProblems('Sequence layout validation failed', problems, {
926 subject: { diagramType: 'sequence' },
927 diagnostics,
928 });
929 }
932function renderParticipant(participant) {
933 const fill = componentFill[participant.type] || 'c-external';
934 const hasSub = participant.sublabel != null && participant.sublabel !== '';
935 const sub = hasSub
936 ? `\n <text data-detail="context" x="${participant.cx}" y="${layout.topY + layout.participantSublabelY}" class="t-muted" font-size="${fittedNodeFontSize(participant.sublabel, layout.participantW, readableSublabelPreferred, readableSublabelMinimum)}" text-anchor="middle">${esc(participant.sublabel)}</text>`
937 : '';
938 const brand = renderBrandMark(participant, { x: participant.x + layout.participantW - 22, y: layout.topY + 6 });
939 const labelFontSize = fittedNodeFontSize(participant.label, brandLabelFitWidth(participant, layout.participantW), 11, 8);
940 const passport = {
941 kind: participant.type,
942 sublabel: participant.sublabel,
943 context: i18nText(sequence.meta.locale, 'node.context.sequence'),
944 ...brandMetadataFor(participant),
945 };
946 return ` <g ${focusNodeAttrs(participant.id, participant.label, passport, sequence.meta.locale)}>
947 ${focusNodeTitle(participant.label, passport)}
948 <rect x="${participant.x}" y="${layout.topY}" width="${layout.participantW}" height="${layout.participantH}" rx="6" class="c-mask"/>
949 <rect x="${participant.x}" y="${layout.topY}" width="${layout.participantW}" height="${layout.participantH}" rx="6" class="${fill}"${animateAttr(sequence.meta, 'node', participant.index)} stroke-width="1.5"/>
950 ${renderSemanticSigil(participant.type, { icon: participant.icon, x: participant.x + 6, y: layout.topY + 6 })}${brand ? `\n ${brand}` : ''}
951 <text data-node-label=""${hasSub ? ' data-detail-anchor=""' : ''} x="${participant.cx}" y="${layout.topY + layout.participantLabelY}" class="t-primary" font-size="${labelFontSize}" font-weight="600" text-anchor="middle">${esc(participant.label)}</text>${sub}
952 </g>`;
955// Lifelines never enter the legend band. The legend is placed below all
956// timeline content, so stopping above its title still reaches every message.
957function lifelineEnd() {
958 const legend = measureLegend(legendEntries(), legendLayout());
959 return legend?.titleY == null ? layout.lifelineBottom : Math.min(layout.lifelineBottom, legend.titleY - 22);
962function renderLifeline(participant, end) {
963 return ` <path d="M ${participant.cx} ${layout.lifelineTop} L ${participant.cx} ${end}" class="a-default" stroke-width="0.8" stroke-dasharray="3,7"/>`;
966function renderSegment(segment, index) {
967 return ` <rect data-graph-role="structural-frame" data-composition-frame-kind="segment" data-composition-frame-id="${index}" x="48" y="${segment.from}" width="${viewBox[0] - 96}" height="${segment.to - segment.from}" rx="10" class="c-lane" stroke-width="1"/>`;
970function renderSegmentLabel(segment, index) {
971 const label = segmentLabelBox(segment);
972 return ` <g data-graph-role="segment-label" data-segment-id="${index}">
973 <rect x="${label.x}" y="${label.y}" width="${label.width}" height="${label.height}" rx="3" class="c-mask"/>
974 <text x="${label.x + 6}" y="${label.y + 13}" class="t-muted" font-size="9" font-weight="600">${esc(segment.label)}</text>
975 </g>`;
978function renderActivation(activation) {
979 const participant = participants.get(activation.participant);
980 const fill = componentFill[activation.type] || componentFill[participant.type] || 'c-external';
981 const x = participant.cx - 5;
982 const height = activation.to - activation.from;
983 return ` <rect x="${x}" y="${activation.from}" width="10" height="${height}" rx="3" class="c-mask"/>
984 <rect x="${x}" y="${activation.from}" width="10" height="${height}" rx="3" class="${fill}" stroke-width="1"/>`;
987function messageLabel(message, x1, x2) {
988 const box = messageLabelBox(message);
989 const center = box ? box.x + box.width / 2 : (x1 + x2) / 2;
990 const y = message.y - 10;
991 const labelW = box?.width || Math.max(34, textUnits(message.label) * messageUnitWidth + 12);
992 // A colored line gets a label in the same color, as in the legend swatches.
993 // Gray lines (default and return) keep the readable muted text color.
994 const accent = ['emphasis', 'security', 'dashed'].includes(message.variant) ? edgeLabelAccent(message.variant) : 't-muted';
995 return ` <g data-detail="context">
996 <rect x="${center - labelW / 2}" y="${y - 10}" width="${labelW}" height="${layout.labelH}" rx="3" class="c-mask"/>
997 <text x="${center}" y="${y}" class="${accent}" font-size="${messageFontSize}" text-anchor="middle">${esc(message.label)}</text>
998 </g>`;
1001function noteBackdrop(message) {
1002 const note = noteRect(message);
1003 if (!note) return '';
1004 const box = { x: note.x - 2, y: note.y - 2, width: note.width + 4, height: note.height + 4 };
1005 const crossed = asArray(sequence.segments).some((segment) => [segment.from, segment.to]
1006 .some((border) => border >= box.y && border <= box.y + box.height));
1007 if (!crossed) return '';
1008 // The backdrop clears decorative frames, never another message. Standard
1009 // quality can retain overlapping annotations that showcase rejects.
1010 const coversMessage = asArray(sequence.messages).some((other) => other !== message
1011 && [messageLabelBox(other), messageRouteBox(other), noteRect(other)]
1012 .some((rect) => rect && rectsOverlap(box, rect, 0)));
1013 if (coversMessage) return '';
1014 return `\n <rect data-detail="context" data-graph-role="message-note-background" x="${box.x}" y="${box.y}" width="${box.width}" height="${box.height}" rx="2" class="c-mask" pointer-events="none"/>`;
1017function renderMessage(message, index) {
1018 const { start, end } = messageGeometry(message);
1019 const [cls, marker] = arrowClass[message.variant || 'default'] || arrowClass.default;
1020 const strokeWidth = message.variant === 'emphasis' ? 1.8 : 1.4;
1021 const dash = message.variant === 'return' ? ' stroke-dasharray="3,5"' : '';
1022 const noteBox = noteLayout(message);
1023 const noteContent = noteBox?.lines.length > 1
1024 ? noteBox.lines.map((line, lineIndex) => `<tspan x="${noteBox.x}" dy="${lineIndex ? NOTE_LINE_HEIGHT : 0}">${esc(line)}</tspan>`).join('')
1025 : esc(message.note);
1026 // Notes carry message conditions and already reserve timeline space. Keep
1027 // them readable at the default depth; Map still hides context without reflow.
1028 const note = noteBox
1029 ? `\n <text data-detail="context" x="${noteBox.x}" y="${noteBox.baseline}" class="t-muted" font-size="${NOTE_FONT_SIZE}">${noteContent}</text>`
1030 : '';
1031 return ` <g ${focusEdgeAttrs(message.from, message.to, message.label, index, message.id)}>
1032 <path data-composition-edge-from="${esc(message.from)}" data-composition-edge-to="${esc(message.to)}"${message.id ? ` data-composition-edge-id="${esc(message.id)}"` : ''} data-composition-points="${routePointsValue([[start, message.y], [end, message.y]])}" d="M ${start} ${message.y} L ${end} ${message.y}" class="${cls}"${animateAttr(sequence.meta, 'edge', index)} stroke-width="${strokeWidth}"${dash} marker-end="url(#${marker})"/>
1033${messageLabel(message, start, end)}${noteBackdrop(message)}${note}
1034 </g>`;
1038function legendLayout() {
1039 return {
1040 x: 40,
1041 baselineY: layout.legendY,
1042 width: viewBox[0] - 80,
1043 // The same content-based budget as legendRequiredHeight(): a wrapped
1044 // legend may use any rows it needs as long as it stays below the content.
1045 minTitleY: Math.max(layout.lifelineTop, contentBottom + LEGEND_CONTENT_GAP),
1046 unfit: sequence.meta?.legend === undefined ? 'hide' : 'error',
1047 diagramType: 'sequence',
1048 };
1051function renderLegend() {
1052 return renderResolvedLegend({
1053 entries: legendEntries(),
1054 locale: sequence.meta.locale,
1055 layout: legendLayout(),
1056 renderSwatch: (entry) => `<path d="M ${entry.x} ${entry.baseline - 3} L ${entry.x + 34} ${entry.baseline - 3}" class="${entry.className}" stroke-width="${entry.strokeWidth || 1.4}"${entry.dash ? ` stroke-dasharray="${entry.dash}"` : ''} marker-end="url(#${entry.marker})"/>`,
1057 });
1060function renderSvg() {
1061 const participantList = [...participants.values()];
1062 // Same default-canvas contract as lifecycle: 920x760 is below the 1.55 wide
1063 // ratio, so without intrinsic-height the desktop Reader can neither narrow
1064 // nor scroll it and every default sequence fails the browser gate.
1065 const readerFit = sequence.meta?.viewBox ? '' : ' data-reader-fit="width-first"';
1066 return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}" data-sequence-column-fit="${columnFit}"${readerFit} ${svgRootAttrs(sequence.meta)} data-motion-choreography="sequence">
1067${svgAccessibleText(sequence.meta, 'sequence')}
1068${renderDefinitions()}
1070 <!-- Background Grid -->
1071 <rect width="100%" height="100%" fill="url(#grid)" />
1073 <!-- Time Segments -->
1074${asArray(sequence.segments).map(renderSegment).join('\n\n')}
1076 <!-- Lifelines -->
1077${participantList.map((participant) => renderLifeline(participant, lifelineEnd())).join('\n')}
1079 <!-- Activations -->
1080${asArray(sequence.activations).map(renderActivation).join('\n')}
1082 <!-- Messages -->
1083${asArray(sequence.messages).map(renderMessage).join('\n\n')}
1085 <!-- Segment Labels -->
1086${asArray(sequence.segments).map(renderSegmentLabel).join('\n')}
1088 <!-- Participants -->
1089${participantList.map(renderParticipant).join('\n\n')}
1091 <!-- Legend -->
1092${renderLegend()}
1093 </svg>`;
1096validateSequence();
1097writeDiagram({
1098 outPath,
1099 template,
1100 diagramType: 'sequence',
1101 meta: sequence.meta,
1102 svg: renderSvg(),
1103 cards: sequence.cards,
1104 sourceEvidence,
1105});