feat: introduce archify skill for generating architecture diagrams
- Added a new Archify skill, enabling users to create polished architecture, workflow, sequence, data-flow, and lifecycle diagrams. - Implemented comprehensive functionality including rendering, validation, and delivery of diagrams in various formats. - Integrated a user-friendly command-line interface for generating and previewing diagrams. - Developed supporting files including package.json, LICENSE, and SKILL.md for documentation and licensing. - Added unit tests to ensure reliability and functionality of the new skill. These changes enhance the application by providing a structured approach to visualizing system architecture and workflows, improving user experience and data representation.
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
# 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 `meta.output` from the JSON file
|
||||
or falls back to `sequence.html` in the current working directory.
|
||||
|
||||
## Input
|
||||
|
||||
Sequence JSON files must set:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"diagram_type": "sequence",
|
||||
"meta": {
|
||||
"title": "Cache Miss Request Sequence",
|
||||
"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.
|
||||
|
||||
## Layout budget
|
||||
|
||||
| Constant | Value |
|
||||
|----------|-------|
|
||||
| viewBox | default `[920, 760]`; 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; 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 row | y = height − 54 |
|
||||
|
||||
`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.
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,453 @@
|
||||
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 { resolveLegend, renderLegend as renderResolvedLegend } from '../shared/legend.mjs';
|
||||
import { componentFill, arrowClassMap, rectsOverlap, cleanFlowProblems, cleanCrossingProblems, cleanAmbiguousCorridorProblems, cleanBorderRunProblems, cleanRouteRhythmProblems, cleanLabelRouteClearanceProblems, routePointsValue, asArray, isFinitePoint } 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 } = await loadDiagramWithBrandMarks({
|
||||
rendererDir: __dirname,
|
||||
diagramType: 'sequence',
|
||||
defaultExample: 'cache-miss-request.sequence.json'
|
||||
});
|
||||
|
||||
const viewBox = sequence.meta?.viewBox || [920, 760];
|
||||
// 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;
|
||||
|
||||
const layout = {
|
||||
topY: 72,
|
||||
participantW,
|
||||
participantH: 54,
|
||||
lifelineTop: 142,
|
||||
lifelineBottom: viewBox[1] - 65,
|
||||
legendY: viewBox[1] - 54,
|
||||
leftX: columnFit === 'spread' ? sideMargin + participantW / 2 : sideMargin,
|
||||
colGap,
|
||||
labelH: 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) * 5.2 + 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,
|
||||
};
|
||||
}
|
||||
|
||||
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'
|
||||
}));
|
||||
|
||||
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}.`);
|
||||
}
|
||||
}
|
||||
|
||||
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.`);
|
||||
}
|
||||
|
||||
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 + 39}" 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, { 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 + 22}" class="t-primary" font-size="${labelFontSize}" font-weight="600" text-anchor="middle">${esc(participant.label)}</text>${sub}
|
||||
</g>`;
|
||||
}
|
||||
|
||||
function renderLifeline(participant) {
|
||||
return ` <path d="M ${participant.cx} ${layout.lifelineTop} L ${participant.cx} ${layout.lifelineBottom}" 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 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 ` <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) * 5.2 + 12);
|
||||
const accent = message.variant === 'security'
|
||||
? 't-security'
|
||||
: message.variant === 'dashed'
|
||||
? 't-messagebus'
|
||||
: message.variant === 'return'
|
||||
? 't-muted'
|
||||
: 't-backend';
|
||||
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="9" 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>`;
|
||||
}
|
||||
|
||||
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 renderLegend() {
|
||||
const presentKinds = new Set(asArray(sequence.messages).map((message) => message.variant || 'default'));
|
||||
const entries = resolveLegend(sequence.meta?.legend, LEGEND_CATALOG, presentKinds);
|
||||
return renderResolvedLegend({
|
||||
entries,
|
||||
locale: sequence.meta.locale,
|
||||
layout: {
|
||||
x: 40,
|
||||
baselineY: layout.legendY,
|
||||
width: viewBox[0] - 80,
|
||||
minTitleY: layout.legendY - 30,
|
||||
unfit: sequence.meta?.legend === undefined ? 'hide' : 'error',
|
||||
diagramType: 'sequence',
|
||||
},
|
||||
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()];
|
||||
return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}" ${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(renderLifeline).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,
|
||||
});
|
||||
Reference in New Issue
Block a user