Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions packages/core/src/samples/gallery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,86 @@ export const eventStream: DiagramDraft = {
],
};

export const cacheMiss: DiagramDraft = {
kind: 'sequence',
title: 'Cache miss request',
subtitle: 'Dashboard load when the cache is cold',
phases: [
{ id: 'request', label: 'Request', edges: ['open', 'get', 'verify', 'claims'] },
{ id: 'fallback', label: 'Fallback', edges: ['read', 'miss', 'query', 'rows'] },
{ id: 'respond', label: 'Response and trace', edges: ['set', 'emit', 'json', 'render'] },
],
nodes: [
{ id: 'user', type: 'client', card: { title: 'User', subtitle: 'Browser session' } },
{ id: 'web', type: 'client', card: { title: 'Web app', subtitle: 'React UI', brand: 'react' } },
{ id: 'api', type: 'service', card: { title: 'API', subtitle: 'Request handler' } },
{ id: 'auth', type: 'security', card: { title: 'Auth', subtitle: 'JWT verify' } },
{ id: 'redis', type: 'cache', card: { title: 'Redis', subtitle: 'Cache', brand: 'redis' } },
{ id: 'db', type: 'database', card: { title: 'Postgres', subtitle: 'Source of truth', brand: 'postgresql' } },
{ id: 'trace', type: 'queue', card: { title: 'Trace', subtitle: 'Async event' } },
],
edges: [
{ id: 'open', from: 'user', to: 'web', label: 'open page' },
{ id: 'get', from: 'web', to: 'api', label: 'GET /dashboard', tone: 'main' },
{ id: 'verify', from: 'api', to: 'auth', label: 'verify JWT', tone: 'security' },
{ id: 'claims', from: 'auth', to: 'api', label: 'claims ok', kind: 'return' },
{ id: 'read', from: 'api', to: 'redis', label: 'read cache' },
{ id: 'miss', from: 'redis', to: 'api', label: 'miss', kind: 'return' },
{ id: 'query', from: 'api', to: 'db', label: 'query profile', tone: 'main' },
{ id: 'rows', from: 'db', to: 'api', label: 'rows', kind: 'return' },
{ id: 'set', from: 'api', to: 'redis', label: 'set cache', kind: 'async' },
{ id: 'emit', from: 'api', to: 'trace', label: 'emit trace', kind: 'async' },
{ id: 'json', from: 'api', to: 'web', label: '200 JSON', kind: 'return' },
{ id: 'render', from: 'web', to: 'user', label: 'render', kind: 'return' },
],
views: [{ id: 'fallback', label: 'Fallback', caption: 'The database is read only after the cache misses.', nodes: ['api', 'redis', 'db'] }],
notes: [
{ title: 'Happy path', items: ['Web app → API → data source → response', 'Replies are quieter than forward calls', 'Activation bars show how long each part is busy'] },
{ title: 'Policy and fallback', items: ['JWT verification is a security interaction', 'The cache miss is visible without overpowering the main path'] },
{ title: 'Async trace', items: ['Trace emission is fire-and-forget', 'It never blocks the response'] },
],
};

export const asyncJob: DiagramDraft = {
kind: 'sequence',
title: 'Async job roundtrip',
subtitle: 'Accept now, work in the background, notify later',
phases: [
{ id: 'accept', label: 'Accept', edges: ['post', 'enqueue', 'accepted'] },
{ id: 'work', label: 'Background work', edges: ['deliver', 'perform', 'result', 'retry', 'persist'] },
{ id: 'notify', label: 'Notify and reconcile', edges: ['completed', 'webhook', 'poll', 'status', 'state', 'final'] },
],
nodes: [
{ id: 'client', type: 'client', card: { title: 'Client', subtitle: 'Mobile app' } },
{ id: 'api', type: 'service', card: { title: 'Jobs API', subtitle: 'Request edge' } },
{ id: 'queue', type: 'queue', card: { title: 'Queue', subtitle: 'Durable work', brand: 'rabbitmq' } },
{ id: 'worker', type: 'service', card: { title: 'Worker', subtitle: 'Background' } },
{ id: 'provider', type: 'external', card: { title: 'Provider', subtitle: 'External API' } },
{ id: 'store', type: 'database', card: { title: 'Job store', subtitle: 'Source of truth', brand: 'postgresql' } },
{ id: 'notifier', type: 'queue', card: { title: 'Notifier', subtitle: 'Webhook' } },
],
edges: [
{ id: 'post', from: 'client', to: 'api', label: 'POST /jobs', tone: 'main' },
{ id: 'enqueue', from: 'api', to: 'queue', label: 'enqueue job', kind: 'async', tone: 'main' },
{ id: 'accepted', from: 'api', to: 'client', label: '202 + job id', kind: 'return' },
{ id: 'deliver', from: 'queue', to: 'worker', label: 'deliver', tone: 'main' },
{ id: 'perform', from: 'worker', to: 'provider', label: 'perform work' },
{ id: 'result', from: 'provider', to: 'worker', label: 'result or timeout', kind: 'return' },
{ id: 'retry', from: 'worker', to: 'queue', label: 'retry if timeout', kind: 'async', tone: 'error' },
{ id: 'persist', from: 'worker', to: 'store', label: 'persist final state', tone: 'main' },
{ id: 'completed', from: 'worker', to: 'notifier', label: 'job.completed', kind: 'async' },
{ id: 'webhook', from: 'notifier', to: 'client', label: 'signed webhook', kind: 'async', tone: 'security' },
{ id: 'poll', from: 'client', to: 'api', label: 'GET /jobs/:id' },
{ id: 'status', from: 'api', to: 'store', label: 'read status' },
{ id: 'state', from: 'store', to: 'api', label: 'completed', kind: 'return' },
{ id: 'final', from: 'api', to: 'client', label: '200 final result', kind: 'return' },
],
notes: [
{ title: 'Latency contract', items: ['The client gets 202 before any work starts', 'Retries stay inside the background phase'] },
{ title: 'Reconciliation', items: ['The webhook is signed', 'Polling reads the job store, the source of truth'] },
],
};

export const GALLERY = {
'release-delivery': releaseDelivery,
'incident-response': incidentResponse,
Expand All @@ -436,4 +516,6 @@ export const GALLERY = {
'web-app': webApp,
'product-analytics': productAnalytics,
'event-stream': eventStream,
'cache-miss': cacheMiss,
'async-job': asyncJob,
} as const satisfies Record<string, DiagramDraft>;
3 changes: 3 additions & 0 deletions packages/layout/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@ import type { ElkExtendedEdge, ElkNode, ElkPort } from 'elkjs/lib/elk-api';
import { cardSize, isLaneKind, usesCompactCards, type DiagramDraft, type Direction, type LaidOutDiagram, type Point, type Rect } from '@stackmap/core';
import { findLabelSpot, labelWidth, placeLabel } from './labels';
import { layoutLanes } from './lanes';
import { layoutSequence } from './sequence';

export { layoutLanes } from './lanes';
export { layoutSequence } from './sequence';

/** Vertical space reserved at the top of a group for its label. */
export const GROUP_LABEL_BAND = 48;
Expand Down Expand Up @@ -87,6 +89,7 @@ const round = (n: number) => Math.round(n * 100) / 100;

export async function layoutDiagram(draft: DiagramDraft): Promise<LaidOutDiagram> {
if (isLaneKind(draft.kind)) return layoutLanes(draft);
if (draft.kind === 'sequence') return layoutSequence(draft);
const direction = draft.direction ?? 'RIGHT';
const compact = usesCompactCards(draft);
const variant = compact ? 'compact' : 'full';
Expand Down
238 changes: 238 additions & 0 deletions packages/layout/src/sequence.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
import { cardSize, measureText, type DiagramDraft, type DiagramEdge, type LaidOutDiagram, type Point, type Rect, type SequenceLayout } from '@stackmap/core';

// Sequence layout: participants across the top in draft order, messages top to bottom in edge order. The agent
// gives only order; stackmap derives column spacing from the labels, rows from the messages and phases, and
// activation bars from calls and replies.

const PAD = 40;
/** Space between the participant cards and the first message. */
const HEAD_GAP = 36;
/** Minimum distance between neighbouring lifelines, beyond the card width. */
const COL_GAP = 40;
const ROW = 44;
const SELF_ROW = 60;
/** Width and height of a self-message's loop. */
const LOOP = { width: 36, height: 22 };
/** A phase's band: the label strip above its first message, and the room after its last. */
export const PHASE_HEAD = 28;
const PHASE_TAIL = 12;
const PHASE_PAD_X = 16;
/** Activation bar width, and the sideways step of each nested bar. */
export const ACTIVATION = { width: 10, nest: 5 };
/** Shortest bar: an async hit or a call answered on the next row still shows. */
const MIN_BAR = 24;
const LABEL = { face: 'sans400', size: 11.5, height: 16, gap: 6 } as const;
const labelWidth = (text: string) => Math.ceil(measureText(text, LABEL.face, LABEL.size)) + 12;

const round = (n: number) => Math.round(n * 100) / 100;

export interface Bar {
participant: string;
/** 0 = on the lifeline; a self-call nests one step right of what is open */
depth: number;
/** first and last message row it covers */
from: number;
to: number;
/** the self-message that drew it, for nested bars */
self?: string;
/** who called, and the last row the participant was busy (opened or sent) */
caller?: string;
busy?: number;
}

/**
* Activation bars by simulation over the messages in order, per participant a stack of open bars:
* - a call to an idle participant opens a bar; a call to one that is waiting on its own call (a callback)
* nests a bar inside; a call from someone new to a participant that is busy but waiting on nobody first ends
* the old bar where it was last busy;
* - a reply closes the replier's bar opened by the one it answers (else its innermost), with any nested inside;
* - a self-call nests a short bar; sending or receiving keeps a bar busy;
* - a bar still open at the end runs to its participant's last message.
*/
export function activations(edges: DiagramEdge[]): Bar[] {
const stacks = new Map<string, Bar[]>();
/** per participant, the callees it is waiting to hear back from */
const waiting = new Map<string, string[]>();
const last = new Map<string, number>();
const bars: Bar[] = [];
const stackOf = (p: string) => {
let st = stacks.get(p);
if (!st) stacks.set(p, (st = []));
return st;
};
const touch = (p: string, row: number) => {
const top = stacks.get(p)?.at(-1);
if (top) top.busy = row;
};
edges.forEach((e, row) => {
last.set(e.from, row);
last.set(e.to, row);
touch(e.from, row);
if (e.from === e.to) {
bars.push({ participant: e.from, depth: stackOf(e.from).length, from: row, to: row, self: e.id });
return;
}
if (e.kind === 'return') {
touch(e.to, row);
const st = stackOf(e.from);
let i = st.map((b) => b.caller).lastIndexOf(e.to);
if (i < 0) i = st.length - 1;
if (i >= 0) for (const bar of st.splice(i)) bar.to = row;
const w = waiting.get(e.to);
const k = w?.lastIndexOf(e.from) ?? -1;
if (k >= 0) w!.splice(k, 1);
return;
}
const st = stackOf(e.to);
const pending = (waiting.get(e.to)?.length ?? 0) > 0;
const top = st.at(-1);
if (top && !pending && top.caller !== e.from) {
// Busy with someone else and waiting on nobody: that earlier work is done.
for (const bar of st.splice(0)) bar.to = bar.busy ?? bar.from;
}
// Receiving counts as busy, but only for work this message belongs to (checked above).
touch(e.to, row);
if (!st.length || (pending && e.kind !== 'async')) {
const bar: Bar = { participant: e.to, depth: st.length, from: row, to: row, caller: e.from, busy: row };
st.push(bar);
bars.push(bar);
}
if (e.kind !== 'async') waiting.set(e.from, [...(waiting.get(e.from) ?? []), e.to]);
});
for (const st of stacks.values()) for (const bar of st) bar.to = Math.max(bar.to, last.get(bar.participant) ?? bar.to);
return bars.map(({ caller: _c, busy: _b, ...b }) => b);
}

export function layoutSequence(draft: DiagramDraft): LaidOutDiagram {
const people = draft.nodes;
const index = new Map(people.map((n, i) => [n.id, i]));
const size = new Map(people.map((n) => [n.id, cardSize(n.card, 'compact')]));
const cardW = Math.max(176, ...[...size.values()].map((s) => s.width));
const cardH = Math.max(...[...size.values()].map((s) => s.height));
const edges = draft.edges.filter((e) => index.has(e.from) && index.has(e.to));

// Lifeline spacing: every gap at least a card plus COL_GAP; a label that spans gaps widens them evenly.
const gaps = Array.from({ length: Math.max(0, people.length - 1) }, () => cardW + COL_GAP);
let rightExtra = 0;
const spans = edges
.map((e) => ({ e, a: Math.min(index.get(e.from)!, index.get(e.to)!), b: Math.max(index.get(e.from)!, index.get(e.to)!) }))
.sort((x, y) => x.b - x.a - (y.b - y.a));
for (const { e, a, b } of spans) {
if (!e.label) continue;
const w = labelWidth(e.label);
if (a === b) {
// A self-message's label sits right of its loop, before the next lifeline.
const need = LOOP.width + 8 + w + 16;
if (a < gaps.length) gaps[a] = Math.max(gaps[a]!, need + ACTIVATION.width);
else rightExtra = Math.max(rightExtra, need - cardW / 2);
continue;
}
const have = gaps.slice(a, b).reduce((s, g) => s + g, 0);
const need = w + 32;
if (need > have) for (let i = a; i < b; i++) gaps[i]! += (need - have) / (b - a);
}
const xs: number[] = [PAD + cardW / 2];
for (const g of gaps) xs.push(xs.at(-1)! + g);
const xOf = (id: string) => xs[index.get(id)!]!;

const nodes: Record<string, Rect> = {};
for (const n of people) {
const s = size.get(n.id)!;
nodes[n.id] = { x: round(xOf(n.id) - s.width / 2), y: PAD + (cardH - s.height), width: s.width, height: s.height };
}

// Rows: each message gets a row; a phase adds its label strip before its first message and room after its last.
const edgeIndex = new Map(edges.map((e, i) => [e.id, i]));
const phaseRange = (draft.phases ?? [])
.map((p) => {
const rows = (p.edges ?? []).flatMap((id) => (edgeIndex.has(id) ? [edgeIndex.get(id)!] : []));
return rows.length ? { id: p.id, first: Math.min(...rows), last: Math.max(...rows) } : null;
})
.filter((p): p is { id: string; first: number; last: number } => p !== null);
const rowY: number[] = [];
let cursor = PAD + cardH + HEAD_GAP;
const phaseTop = new Map<string, number>();
const phaseBottom = new Map<string, number>();
edges.forEach((e, i) => {
for (const p of phaseRange) if (p.first === i) {
phaseTop.set(p.id, cursor);
cursor += PHASE_HEAD;
}
const self = e.from === e.to;
// The arrow sits below its label.
rowY.push(cursor + LABEL.height + LABEL.gap);
cursor += self ? SELF_ROW : ROW;
for (const p of phaseRange) if (p.last === i) {
cursor += PHASE_TAIL;
phaseBottom.set(p.id, cursor);
}
});
const bottom = cursor + 12;

const bars = activations(edges);
const barRect = (b: Bar): Rect => {
const x = xOf(b.participant) - ACTIVATION.width / 2 + b.depth * ACTIVATION.nest;
// A self-call's bar starts where its loop comes back; a lifeline bar brackets its first and last rows.
const y0 = b.self ? rowY[b.from]! + LOOP.height - 6 : rowY[b.from]! - 6;
const y1 = Math.max(b.self ? y0 : rowY[b.to]! + 6, y0 + (b.self ? 18 : MIN_BAR));
return { x: round(x), y: round(y0), width: ACTIVATION.width, height: round(y1 - y0) };
};
// A message leaves and arrives at the edge of the innermost bar open on its row.
const reach = (participant: string, row: number) => {
const open = bars.filter((b) => !b.self && b.participant === participant && b.from <= row && b.to >= row);
return open.length ? ACTIVATION.width / 2 + Math.max(...open.map((b) => b.depth)) * ACTIVATION.nest : 0;
};

const out: Record<string, Point[]> = {};
const labels: Record<string, Point> = {};
edges.forEach((e, i) => {
const y = rowY[i]!;
const x = xOf(e.from);
if (e.from === e.to) {
const x0 = x + reach(e.from, i);
const bar = bars.find((b) => b.self === e.id)!;
out[e.id] = [
{ x: round(x0), y: round(y) },
{ x: round(x0 + LOOP.width), y: round(y) },
{ x: round(x0 + LOOP.width), y: round(y + LOOP.height) },
{ x: round(x - ACTIVATION.width / 2 + bar.depth * ACTIVATION.nest + ACTIVATION.width), y: round(y + LOOP.height) },
];
if (e.label) labels[e.id] = { x: round(x0 + LOOP.width + 8 + labelWidth(e.label) / 2), y: round(y + LOOP.height / 2) };
return;
}
const tx = xOf(e.to);
const dir = tx > x ? 1 : -1;
const a = x + dir * reach(e.from, i);
const b = tx - dir * reach(e.to, i);
out[e.id] = [
{ x: round(a), y: round(y) },
{ x: round(b), y: round(y) },
];
if (e.label) labels[e.id] = { x: round((a + b) / 2), y: round(y - LABEL.gap - LABEL.height / 2) };
});

const lifelines: SequenceLayout['lifelines'] = {};
for (const n of people) lifelines[n.id] = { x: round(xOf(n.id)), top: nodes[n.id]!.y + nodes[n.id]!.height, bottom: round(bottom) };

const left = xs[0]! - cardW / 2 - PHASE_PAD_X;
const right = xs.at(-1)! + cardW / 2 + PHASE_PAD_X + rightExtra;
const phases: Record<string, Rect> = {};
for (const p of phaseRange) {
const top = phaseTop.get(p.id)!;
phases[p.id] = { x: round(left), y: round(top), width: round(right - left), height: round(phaseBottom.get(p.id)! - top) };
}

return {
draft,
nodes,
groups: {},
edges: out,
labels,
phases,
sequence: {
lifelines,
activations: bars.map((b) => ({ participant: b.participant, rect: barRect(b), depth: b.depth })),
},
bounds: { width: round(right + PAD), height: round(bottom + PAD) },
};
}
2 changes: 2 additions & 0 deletions packages/layout/test/lanes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ describe.each(laid)('gallery sample %s', (_name, draft: DiagramDraft, out: LaidO
});

it('routes every edge orthogonally from its source card to its target card, through no card', () => {
// Sequence messages run between lifelines, not cards: checked in sequence.test.ts.
if (draft.kind === 'sequence') return;
for (const e of draft.edges) {
const pts = out.edges[e.id]!;
expect(pts.length, e.id).toBeGreaterThanOrEqual(2);
Expand Down
Loading
Loading