Archify Diagram Viewer 0.1.0

VS Code extension that previews Archify diagrams from their JSON sources
(live, as you type) and opens rendered Archify HTML in a viewer tab.
Bundles the Archify 3.0.1 renderer and runs it on VS Code's Node runtime.
Adds validation diagnostics, JSON schema help, source-link navigation,
export saving, render-to-file and open-in-browser commands.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-30 12:40:55 +03:00
co-authored by Claude Opus 5.5
commit d56e784e97
119 changed files with 66708 additions and 0 deletions
+129
View File
@@ -0,0 +1,129 @@
# Sequence Renderer
Render `diagram_type: "sequence"` JSON files into the standard Archify HTML
template.
```bash
node archify/renderers/sequence/render-sequence.mjs input.sequence.json output.html
```
The renderer validates input against `archify/schemas/sequence.schema.json`
with the bundled standalone validator. No dependency installation is required.
If `output.html` is omitted, the renderer uses the required `meta.output` value
from the JSON file.
## Input
Sequence JSON files must set:
```json
{
"schema_version": 1,
"diagram_type": "sequence",
"meta": {
"title": "Cache Miss Request Sequence",
"output": "cache-miss-request.html",
"viewBox": [920, 760]
},
"participants": [],
"segments": [],
"messages": [],
"activations": [],
"cards": []
}
```
The timeline scales with the viewBox height: a taller `meta.viewBox` buys more
message room, a shorter one shrinks the readable band instead of clipping. A
complete worked example lives at
`archify/examples/cache-miss-request.sequence.json`.
The schema lives at:
```text
archify/schemas/sequence.schema.json
```
## Legend
The default visual legend derives kinds from `messages[].variant` (omitting
`variant` means `default`). Supported `meta.legend.entries` keys, in stable
order, are `emphasis`, `return`, `security`, `dashed`, and `default`. These are
visual message keys, not Semantic Lens controls; label/visibility overrides do
not create edge facts.
The legend sits below all timeline content: the last message and its note,
activation bars, and segment frames, with a 12px gap. Without `meta.viewBox`
the canvas grows to keep that gap. With an authored `viewBox` that is too short,
`showcase` fails with the exact height to set, and `standard` hides the implicit
legend rather than drawing it over content. Lifelines stop above the legend.
Message labels use their line's color; gray default and return lines keep the
muted text color.
## Layout budget
| Constant | Value |
|----------|-------|
| viewBox | default `[920, 760]`, taller when late content needs legend room; schema minimum `[480, 480]` |
| Participant boxes | `fixed` (default): 86×54 at y 72; `spread`: viewBox-relative width from 86px up to 190px |
| Participant columns | `fixed`: centers at x = 62 + index×108; `spread`: columns distribute across the available viewBox width |
| Participant count | the last box must end at or before width − 40; layouts that cannot fit fail closed |
| Lifelines | from y 142 down to height − 65 (drawn to just above the legend); band must be ≥120px tall |
| Message `y` range | `[160, height − 83]` |
| Message spacing | ≥28px vertical between messages that share horizontal space |
| Arrow span | ≥60px horizontal between the two participants |
| Segments | y pixel ranges with `to > from`, inside `[72, lifeline bottom + 20]` |
| Legend | last row baseline at height − 54; extra rows wrap upward and stay 12px below the timeline content |
`segments[].from/to` and `activations[].from/to` are y pixel coordinates, not
participant ids; activations also require `to > from`.
### Column fit
Sequence diagrams use `meta.column_fit: "fixed"` by default so existing
documents keep their historical coordinates. Use `"spread"` when a wide
viewBox would otherwise leave empty space on the right or when meaningful
participant labels do not fit the fixed 86px boxes. Spread derives box width
and column distance from the viewBox while preserving participant order,
lifelines, and message semantics.
The artifact checker reports `composition.sequenceColumnSpace` from the rendered
participants, routes and text. A large unused right-hand region in a fixed layout
can produce an `inspect-sequence-width` recommendation in `finalize`; it is advice,
not a new warning or failure. See [Sequence width review](../../references/delivery-contract.md#sequence-width-review)
for the bounded authoring repair and explicit-fixed/legacy preservation rules.
## Design Rules
- Put participants across the top, ordered by the story the reader should
follow.
- Time moves downward.
- Use `emphasis` for the main request path.
- Use `security` for auth, consent, permission, and policy calls.
- Use `return` for quiet response messages.
- Use `dashed` for async trace, event, logging, and non-blocking work.
- Use segments as light background guides; keep segment labels short.
- Keep labels concise, but try `meta.column_fit: "spread"` before shortening a
meaningful participant label just to fit the fixed boxes.
Schema violations exit non-zero with path-prefixed messages annotated with the
element's id or label. The renderer additionally fails when it can detect
layout problems, including missing participants, duplicate participant IDs,
participant labels wider than their box, unknown message endpoints, messages
outside the readable timeline, overly tight vertical spacing between messages
that overlap horizontally, invalid segment or activation ranges, or
participants that exceed the viewBox. The shared Clean Flow contract treats
participant headers as semantic boxes while explicitly allowing messages to
cross intermediate lifelines, activation bars, and segment frames. Text width is estimated CJK-aware:
fullwidth glyphs count as two units.
Set `meta.quality_profile` to `showcase` for polished delivery. Unrelated proper
message X crossings then fail with `composition/proper-crossing`; default
`standard` keeps them as artifact-receipt warnings. Messages may still cross
intermediate lifelines. Collinear corridors remain outside the proper-X rule,
but a separate gate warns in `standard` and fails in `showcase` when unrelated
messages overlap for at least 8px. Shared semantic endpoints, point touches,
and shorter overlaps remain valid. Showcase also rejects any route segment
below 8px and any interior turn segment below 16px; ordinary 8–15px endpoint
stubs remain valid.
+526
View File
@@ -0,0 +1,526 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { esc, renderDefinitions, renderSemanticSigil, textUnits } from '../shared/utils.mjs';
import { animateAttr, focusEdgeAttrs, focusNodeAttrs, focusNodeTitle, loadDiagramWithBrandMarks, writeDiagram, svgAccessibleText, svgRootAttrs } from '../shared/cli.mjs';
import { throwDiagnosticProblems } from '../shared/diagnostics.mjs';
import { legendFootprint, measureLegend, resolveLegend, renderLegend as renderResolvedLegend } from '../shared/legend.mjs';
import { componentFill, arrowClassMap, rectsOverlap, cleanFlowProblems, cleanCrossingProblems, cleanAmbiguousCorridorProblems, cleanBorderRunProblems, cleanRouteRhythmProblems, cleanLabelRouteClearanceProblems, cleanLabelCanvasContainmentProblems, routePointsValue, asArray, isFinitePoint, edgeLabelAccent } from '../shared/geometry.mjs';
import { availableNodeTextWidth, fittedNodeFontSize, minimumNodeTextWidth } from '../shared/text-fit.mjs';
import { brandLabelFitWidth, brandMetadataFor, brandTopRailProblem, renderBrandMark } from '../shared/brand-marks.mjs';
import { translateMessage as i18nText } from '../shared/i18n.mjs';
const participantTextFit = {
sublabelPreferred: 7,
sublabelMinimum: 6,
};
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const { diagram: sequence, template, outPath, sourceEvidence } = await loadDiagramWithBrandMarks({
rendererDir: __dirname,
diagramType: 'sequence',
defaultExample: 'cache-miss-request.sequence.json'
});
const LEGEND_CATALOG = [
{ kind: 'emphasis', className: 'a-emphasis', marker: 'arrowhead-emphasis', strokeWidth: 1.8 },
{ kind: 'return', className: 'a-default', marker: 'arrowhead', dash: '3,5' },
{ kind: 'security', className: 'a-security', marker: 'arrowhead-security' },
{ kind: 'dashed', className: 'a-dashed', marker: 'arrowhead-dashed' },
{ kind: 'default', className: 'a-default', marker: 'arrowhead' },
].map((entry) => ({
...entry,
interactive: false,
swatchWidth: 34,
swatchGap: 9,
label: i18nText(sequence.meta.locale, `legend.sequence.${entry.kind}`),
}));
function legendEntries() {
const presentKinds = new Set(asArray(sequence.messages).map((message) => message.variant || 'default'));
return resolveLegend(sequence.meta?.legend, LEGEND_CATALOG, presentKinds);
}
// The legend sits below the timeline content: the last message and its note,
// activation bars, and segment frames. Its block starts LEGEND_CONTENT_GAP
// below that content; from the block top to the canvas bottom a one-row legend
// needs LEGEND_BLOCK_HEIGHT (title glyphs, row, and the 54px baseline inset).
const LEGEND_CONTENT_GAP = 12;
const LEGEND_BLOCK_HEIGHT = 86;
const contentBottom = Math.max(
0,
...asArray(sequence.messages).map((message) => message.y + (message.note ? 22 : 6)),
...asArray(sequence.activations).map((activation) => activation.to),
...asArray(sequence.segments).map((segment) => segment.to),
);
function legendRequiredHeight(width) {
const entries = legendEntries();
if (!entries.length) return 0;
return Math.ceil(contentBottom + LEGEND_CONTENT_GAP + LEGEND_BLOCK_HEIGHT
+ legendFootprint(entries, { width: width - 80 }).extraHeight);
}
// A renderer-sized canvas grows to keep the legend clear of late messages;
// an authored viewBox is honored and validated below.
const viewBox = sequence.meta?.viewBox || [920, Math.max(760, legendRequiredHeight(920))];
// The timeline scales with viewBox height: a taller viewBox gains message room,
// a shorter one shrinks the readable band (validated below) instead of clipping.
// `column_fit: "spread"` widens the lanes with the viewBox instead of keeping
// the fixed 108px gap, so a wide canvas gains column distance and label room
// rather than dead space on the right. The default stays "fixed" so existing
// diagrams keep their coordinates.
const columnFit = sequence.meta?.column_fit === 'spread' ? 'spread' : 'fixed';
const participantCount = Math.max(1, asArray(sequence.participants).length);
const sideMargin = 62;
const participantW = columnFit === 'spread'
? Math.max(86, Math.min(190, Math.round((viewBox[0] - sideMargin * 2) / participantCount) - 24))
: 86;
const colGap = columnFit === 'spread' && participantCount > 1
? Math.max(108, (viewBox[0] - 40 - sideMargin - participantW) / (participantCount - 1))
: 108;
// Showcase is the fast-authoring default; standard retains legacy label geometry.
const readableMessages = sequence.meta?.quality_profile === 'showcase';
const messageFontSize = readableMessages ? 11 : 9;
const messageUnitWidth = readableMessages ? 6.6 : 5.2;
const layout = {
topY: 72,
participantW,
// Keep a separate top rail for the 11px semantic sigil and 16px brand mark.
// Literal labels retain their fitted font size and full authored wording.
participantH: 60,
participantLabelY: 36,
participantSublabelY: 50,
lifelineTop: 142,
lifelineBottom: viewBox[1] - 65,
legendY: viewBox[1] - 54,
leftX: columnFit === 'spread' ? sideMargin + participantW / 2 : sideMargin,
colGap,
labelH: readableMessages ? 18 : 16
};
const participantBoxWidthNote = columnFit === 'spread'
? `participant boxes are ${participantW}px for this viewBox width and ${participantCount} participants`
: `participant boxes are a fixed ${participantW}px unless meta.column_fit is "spread"`;
const arrowClass = {
...arrowClassMap,
return: ['a-default', 'arrowhead']
};
function participantX(index) {
return layout.leftX + index * layout.colGap;
}
const participants = new Map(asArray(sequence.participants).map((participant, index) => [
participant.id,
{
...participant,
index,
cx: participantX(index),
x: participantX(index) - layout.participantW / 2,
y: layout.topY,
width: layout.participantW,
height: layout.participantH,
cy: layout.topY + layout.participantH / 2
}
]));
function messageGeometry(message) {
const from = participants.get(message.from);
const to = participants.get(message.to);
if (!from || !to || typeof message.y !== 'number') return null;
const direction = to.cx > from.cx ? 1 : -1;
const start = from.cx + direction * 7;
const end = to.cx - direction * 7;
return { start, end, center: (start + end) / 2 };
}
function messageLabelBox(message, relationIndex = null) {
const geometry = messageGeometry(message);
if (!geometry) return null;
const width = Math.max(34, textUnits(message.label) * messageUnitWidth + 12);
return {
relation: message,
relationIndex,
label: message.label,
x: geometry.center - width / 2,
y: message.y - 20,
width,
height: layout.labelH,
};
}
function messageRouteBox(message) {
const geometry = messageGeometry(message);
if (!geometry) return null;
return {
x: Math.min(geometry.start, geometry.end),
y: message.y - 2,
width: Math.abs(geometry.end - geometry.start),
height: 4,
};
}
function segmentLabelBox(segment) {
const labelW = Math.max(42, textUnits(segment.label) * 5.2 + 14);
const occupied = asArray(sequence.messages)
.flatMap((message) => [messageLabelBox(message), messageRouteBox(message)])
.filter(Boolean);
const label = { x: 56, y: segment.from - 22, width: labelW, height: 18 };
for (let attempt = 0; attempt < 4; attempt += 1) {
if (!occupied.some((rect) => rectsOverlap(label, rect, 2))) break;
label.y -= 22;
}
return label;
}
const compositionFrames = asArray(sequence.segments).map((segment, index) => ({
id: index,
label: segment.label,
kind: 'segment',
x: 48,
y: segment.from,
width: viewBox[0] - 96,
height: segment.to - segment.from,
radius: 10,
}));
function messagePath(message) {
return {
points: participants.has(message.from) && participants.has(message.to)
? [[participants.get(message.from).cx, message.y], [participants.get(message.to).cx, message.y]]
: []
};
}
function validateSequence() {
const problems = [];
if (participants.size !== asArray(sequence.participants).length) problems.push('Participant ids must be unique.');
if (layout.lifelineBottom - layout.lifelineTop < 120) {
problems.push(`viewBox height ${viewBox[1]} leaves under 120px of timeline — set meta.viewBox[1] to at least ${layout.lifelineTop + 120 + 65}.`);
}
for (const participant of participants.values()) {
const estLabelW = textUnits(participant.label) * 6.8;
if (estLabelW > layout.participantW + 6) {
problems.push(`Label "${participant.label}" (~${Math.round(estLabelW)}px) is wider than the ${layout.participantW}px participant box — shorten it.`);
}
const brandRailProblem = brandTopRailProblem(participant, layout.participantW, 8, 'Participant');
if (brandRailProblem) problems.push(brandRailProblem);
// sublabel renders as a single unwrapped <text>; shrink-to-fit handles the
// ordinary case, this rejects what it cannot rescue.
if (participant.sublabel) {
const availableTextW = availableNodeTextWidth(layout.participantW);
const minimumW = minimumNodeTextWidth(participant.sublabel, participantTextFit.sublabelMinimum);
if (minimumW > availableTextW) {
problems.push(`Sublabel "${participant.sublabel}" needs ~${Math.ceil(minimumW)}px at the ${participantTextFit.sublabelMinimum}px legible minimum, but participant "${participant.id}" provides ${availableTextW}px — shorten the sublabel (${participantBoxWidthNote}).`);
}
}
}
for (const message of asArray(sequence.messages)) {
if (!participants.has(message.from)) problems.push(`Message "${message.label}" references unknown source "${message.from}".`);
if (!participants.has(message.to)) problems.push(`Message "${message.label}" references unknown target "${message.to}".`);
if (typeof message.y !== 'number') problems.push(`Message "${message.label}" must provide a numeric y.`);
if (message.y < layout.lifelineTop + 18 || message.y > layout.lifelineBottom - 18) {
problems.push(`Message "${message.label}" sits outside the readable timeline — keep y between ${layout.lifelineTop + 18} and ${layout.lifelineBottom - 18}.`);
}
if (participants.has(message.from) && participants.has(message.to)) {
const distance = Math.abs(participants.get(message.to).cx - participants.get(message.from).cx);
if (distance < 60) problems.push(`Message "${message.label}" spans ${Math.round(distance)}px (minimum 60px) — give its participants more column distance.`);
}
}
// Participant headers are opaque nodes. Lifelines, activation bars, and
// segment bands remain intentional pass-through geometry and are excluded.
problems.push(...cleanFlowProblems({
relations: sequence.messages,
obstacles: participants.values(),
pathFor: messagePath,
diagramType: 'sequence',
relationCollection: 'messages',
obstacleKind: 'participant header',
clearance: 0,
routeHint: 'move the message y below the participant headers or reorder participants'
}));
problems.push(...cleanCrossingProblems({
relations: sequence.messages,
endpointIds: new Set(participants.keys()),
pathFor: messagePath,
diagramType: 'sequence',
relationCollection: 'messages',
profile: sequence.meta?.quality_profile,
routeHint: 'separate the message y values; lifeline crossings remain allowed'
}));
problems.push(...cleanAmbiguousCorridorProblems({
relations: sequence.messages,
endpointIds: new Set(participants.keys()),
pathFor: messagePath,
diagramType: 'sequence',
relationCollection: 'messages',
profile: sequence.meta?.quality_profile,
routeHint: 'separate the message y values so unrelated messages do not visually merge'
}));
problems.push(...cleanBorderRunProblems({
relations: sequence.messages,
endpointIds: new Set(participants.keys()),
frames: compositionFrames,
pathFor: messagePath,
diagramType: 'sequence',
relationCollection: 'messages',
profile: sequence.meta?.quality_profile,
routeHint: 'move the message y so it crosses a segment boundary perpendicularly or stays clearly inside the segment'
}));
problems.push(...cleanRouteRhythmProblems({
relations: sequence.messages,
endpointIds: new Set(participants.keys()),
pathFor: messagePath,
diagramType: 'sequence',
relationCollection: 'messages',
profile: sequence.meta?.quality_profile,
routeHint: 'increase participant spacing or simplify message routing so every turn has room to read'
}));
// Vertical crowding only matters when the arrows share horizontal space;
// disjoint arrows may legitimately run in parallel rows.
const placed = asArray(sequence.messages)
.filter((m) => participants.has(m.from) && participants.has(m.to))
.map((m) => ({
label: m.label,
y: m.y,
x1: Math.min(participants.get(m.from).cx, participants.get(m.to).cx),
x2: Math.max(participants.get(m.from).cx, participants.get(m.to).cx)
}))
.sort((a, b) => a.y - b.y);
for (let i = 0; i < placed.length; i += 1) {
for (let j = i + 1; j < placed.length && placed[j].y - placed[i].y < 28; j += 1) {
if (placed[i].x1 < placed[j].x2 && placed[j].x1 < placed[i].x2) {
problems.push(`Messages "${placed[i].label}" and "${placed[j].label}" are less than 28px apart and share horizontal space — spread their y values.`);
}
}
}
// Label masks can extend well past the arrow span, so check the actual
// label rectangles too — tangent arrows with long labels still collide.
const labelRects = asArray(sequence.messages)
.map((m, messageIndex) => messageLabelBox(m, messageIndex))
.filter(Boolean);
for (let i = 0; i < labelRects.length; i += 1) {
for (let j = i + 1; j < labelRects.length; j += 1) {
if (rectsOverlap(labelRects[i], labelRects[j], -2)) {
problems.push(`Labels "${labelRects[i].label}" and "${labelRects[j].label}" overlap — spread their message y values or shorten the labels.`);
}
}
}
problems.push(...cleanLabelRouteClearanceProblems({
relations: sequence.messages,
labels: labelRects,
endpointIds: new Set(participants.keys()),
pathFor: messagePath,
diagramType: 'sequence',
relationCollection: 'messages',
profile: sequence.meta?.quality_profile,
routeHint: 'spread the message y values, shorten the label, or reorder participants so the adjacent route stays visible'
}));
problems.push(...cleanLabelCanvasContainmentProblems({
labels: labelRects,
viewBox,
diagramType: 'sequence',
relationCollection: 'messages',
profile: sequence.meta?.quality_profile,
routeHint: 'shorten the label, reorder participants, or enlarge meta.viewBox',
}));
for (const segment of asArray(sequence.segments)) {
if (segment.to <= segment.from) {
problems.push(`Segment "${segment.label}" has invalid y range (from ${segment.from} to ${segment.to}) — "to" must be greater than "from".`);
}
if (segment.from < layout.topY || segment.to > layout.lifelineBottom + 20) {
problems.push(`Segment "${segment.label}" extends outside the canvas — keep its y range between ${layout.topY} and ${layout.lifelineBottom + 20}.`);
}
const labelBox = segmentLabelBox(segment);
const availableWidth = Math.max(0, viewBox[0] - 48 - labelBox.x);
if (labelBox.x + labelBox.width > viewBox[0] - 48) {
const requiredWidth = Math.ceil(labelBox.x + labelBox.width + 48);
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}.`);
}
}
for (const activation of asArray(sequence.activations)) {
if (!participants.has(activation.participant)) problems.push(`Activation references unknown participant "${activation.participant}".`);
if (activation.to <= activation.from) problems.push(`Activation for "${activation.participant}" has invalid time range — "to" must be greater than "from".`);
}
const lastParticipant = asArray(sequence.participants)[asArray(sequence.participants).length - 1];
if (lastParticipant && participants.get(lastParticipant.id).cx + layout.participantW / 2 > viewBox[0] - 40) {
const requiredWidth = Math.ceil(participants.get(lastParticipant.id).cx + layout.participantW / 2 + 40);
problems.push(`Participants exceed viewBox width — set meta.viewBox[0] to at least ${requiredWidth} or remove a participant.`);
}
// Showcase must not silently drop the implicit legend because late content
// leaves no room for it; give the exact canvas height instead.
const legendHeight = legendRequiredHeight(viewBox[0]);
if (sequence.meta?.quality_profile === 'showcase' && sequence.meta?.legend === undefined
&& legendHeight > viewBox[1] && !measureLegend(legendEntries(), legendLayout())) {
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.`);
}
if (problems.length) {
throwDiagnosticProblems('Sequence layout validation failed', problems, {
subject: { diagramType: 'sequence' },
});
}
}
function renderParticipant(participant) {
const fill = componentFill[participant.type] || 'c-external';
const hasSub = participant.sublabel != null && participant.sublabel !== '';
const sub = hasSub
? `\n <text data-detail="context" x="${participant.cx}" y="${layout.topY + layout.participantSublabelY}" class="t-muted" font-size="${fittedNodeFontSize(participant.sublabel, layout.participantW, participantTextFit.sublabelPreferred, participantTextFit.sublabelMinimum)}" text-anchor="middle">${esc(participant.sublabel)}</text>`
: '';
const brand = renderBrandMark(participant, { x: participant.x + layout.participantW - 22, y: layout.topY + 6 });
const labelFontSize = fittedNodeFontSize(participant.label, brandLabelFitWidth(participant, layout.participantW), 11, 8);
const passport = {
kind: participant.type,
sublabel: participant.sublabel,
context: i18nText(sequence.meta.locale, 'node.context.sequence'),
...brandMetadataFor(participant),
};
return ` <g ${focusNodeAttrs(participant.id, participant.label, passport, sequence.meta.locale)}>
${focusNodeTitle(participant.label, passport)}
<rect x="${participant.x}" y="${layout.topY}" width="${layout.participantW}" height="${layout.participantH}" rx="6" class="c-mask"/>
<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"/>
${renderSemanticSigil(participant.type, { icon: participant.icon, x: participant.x + 6, y: layout.topY + 6 })}${brand ? `\n ${brand}` : ''}
<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}
</g>`;
}
// Lifelines never enter the legend band. The legend is placed below all
// timeline content, so stopping above its title still reaches every message.
function lifelineEnd() {
const legend = measureLegend(legendEntries(), legendLayout());
return legend?.titleY == null ? layout.lifelineBottom : Math.min(layout.lifelineBottom, legend.titleY - 22);
}
function renderLifeline(participant, end) {
return ` <path d="M ${participant.cx} ${layout.lifelineTop} L ${participant.cx} ${end}" class="a-default" stroke-width="0.8" stroke-dasharray="3,7"/>`;
}
function renderSegment(segment, index) {
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"/>`;
}
function renderSegmentLabel(segment, index) {
const label = segmentLabelBox(segment);
return ` <g data-graph-role="segment-label" data-segment-id="${index}">
<rect x="${label.x}" y="${label.y}" width="${label.width}" height="${label.height}" rx="3" class="c-mask"/>
<text x="${label.x + 6}" y="${label.y + 13}" class="t-dim" font-size="9" font-weight="600">${esc(segment.label)}</text>
</g>`;
}
function renderActivation(activation) {
const participant = participants.get(activation.participant);
const fill = componentFill[activation.type] || componentFill[participant.type] || 'c-external';
const x = participant.cx - 5;
const height = activation.to - activation.from;
return ` <rect x="${x}" y="${activation.from}" width="10" height="${height}" rx="3" class="c-mask"/>
<rect x="${x}" y="${activation.from}" width="10" height="${height}" rx="3" class="${fill}" stroke-width="1"/>`;
}
function messageLabel(message, x1, x2) {
const box = messageLabelBox(message);
const center = box ? box.x + box.width / 2 : (x1 + x2) / 2;
const y = message.y - 10;
const labelW = box?.width || Math.max(34, textUnits(message.label) * messageUnitWidth + 12);
// A colored line gets a label in the same color, as in the legend swatches.
// Gray lines (default and return) keep the readable muted text color.
const accent = ['emphasis', 'security', 'dashed'].includes(message.variant) ? edgeLabelAccent(message.variant) : 't-muted';
return ` <g data-detail="context">
<rect x="${center - labelW / 2}" y="${y - 10}" width="${labelW}" height="${layout.labelH}" rx="3" class="c-mask"/>
<text x="${center}" y="${y}" class="${accent}" font-size="${messageFontSize}" text-anchor="middle">${esc(message.label)}</text>
</g>`;
}
function renderMessage(message, index) {
const { start, end } = messageGeometry(message);
const [cls, marker] = arrowClass[message.variant || 'default'] || arrowClass.default;
const strokeWidth = message.variant === 'emphasis' ? 1.8 : 1.4;
const dash = message.variant === 'return' ? ' stroke-dasharray="3,5"' : '';
const note = message.note
? `\n <text data-detail="fine" x="${Math.min(start, end) + 12}" y="${message.y + 18}" class="t-dim" font-size="7">${esc(message.note)}</text>`
: '';
return ` <g ${focusEdgeAttrs(message.from, message.to, message.label, index, message.id)}>
<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})"/>
${messageLabel(message, start, end)}${note}
</g>`;
}
function legendLayout() {
return {
x: 40,
baselineY: layout.legendY,
width: viewBox[0] - 80,
// The same content-based budget as legendRequiredHeight(): a wrapped
// legend may use any rows it needs as long as it stays below the content.
minTitleY: Math.max(layout.lifelineTop, contentBottom + LEGEND_CONTENT_GAP),
unfit: sequence.meta?.legend === undefined ? 'hide' : 'error',
diagramType: 'sequence',
};
}
function renderLegend() {
return renderResolvedLegend({
entries: legendEntries(),
locale: sequence.meta.locale,
layout: legendLayout(),
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})"/>`,
});
}
function renderSvg() {
const participantList = [...participants.values()];
// Same default-canvas contract as lifecycle: 920x760 is below the 1.55 wide
// ratio, so without intrinsic-height the desktop Reader can neither narrow
// nor scroll it and every default sequence fails the browser gate.
const readerFit = sequence.meta?.viewBox ? '' : ' data-reader-fit="intrinsic-height"';
return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}" data-sequence-column-fit="${columnFit}"${readerFit} ${svgRootAttrs(sequence.meta)}>
${svgAccessibleText(sequence.meta, 'sequence')}
${renderDefinitions()}
<!-- Background Grid -->
<rect width="100%" height="100%" fill="url(#grid)" />
<!-- Time Segments -->
${asArray(sequence.segments).map(renderSegment).join('\n\n')}
<!-- Lifelines -->
${participantList.map((participant) => renderLifeline(participant, lifelineEnd())).join('\n')}
<!-- Activations -->
${asArray(sequence.activations).map(renderActivation).join('\n')}
<!-- Messages -->
${asArray(sequence.messages).map(renderMessage).join('\n\n')}
<!-- Segment Labels -->
${asArray(sequence.segments).map(renderSegmentLabel).join('\n')}
<!-- Participants -->
${participantList.map(renderParticipant).join('\n\n')}
<!-- Legend -->
${renderLegend()}
</svg>`;
}
validateSequence();
writeDiagram({
outPath,
template,
diagramType: 'sequence',
meta: sequence.meta,
svg: renderSvg(),
cards: sequence.cards,
sourceEvidence,
});