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
3 changes: 2 additions & 1 deletion packages/cli/scripts/smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { createHash } from 'node:crypto';
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { GALLERY } from '../../core/src/samples/gallery.ts';
import { commerceApi, groupedPlatform } from '../../core/src/samples/index.ts';

const cli = new URL('../dist/cli.js', import.meta.url).pathname;
Expand All @@ -14,7 +15,7 @@ const check = (ok, msg) => {
};

check(/^\d+\.\d+\.\d+\n$/.test(run('--version')), '--version');
for (const [name, draft] of Object.entries({ 'commerce-api': commerceApi, 'grouped-platform': groupedPlatform })) {
for (const [name, draft] of Object.entries({ 'commerce-api': commerceApi, 'grouped-platform': groupedPlatform, ...GALLERY })) {
const input = join(dir, `${name}.json`);
writeFileSync(input, JSON.stringify(draft, null, 2));
check(run('validate', input) === '✓ valid: no diagnostics\n', `${name} validates`);
Expand Down
3 changes: 2 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
"type": "module",
"exports": {
".": "./src/index.ts",
"./samples": "./src/samples/index.ts"
"./samples": "./src/samples/index.ts",
"./gallery": "./src/samples/gallery.ts"
},
"scripts": {
"test": "vitest run",
Expand Down
13 changes: 12 additions & 1 deletion packages/core/src/card-metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,18 @@ export const CARD = {
radius: 14,
} as const;

export function cardSize(card: CardData): { width: number; height: number } {
/** Step, state and participant cards (workflow, lifecycle, sequence): title, subtitle, brand, tag. */
export const COMPACT = {
width: 176,
header: 60,
tagRow: 24,
radius: 12,
} as const;

export type CardVariant = 'full' | 'compact';

export function cardSize(card: CardData, variant: CardVariant = 'full'): { width: number; height: number } {
if (variant === 'compact') return { width: COMPACT.width, height: COMPACT.header + (card.tag ? COMPACT.tagRow : 0) };
let height = CARD.header;
if (card.rows?.length) height += CARD.rowsPad * 2 + card.rows.length * CARD.row;
if (card.stats?.length) {
Expand Down
17 changes: 15 additions & 2 deletions packages/core/src/card-text.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CARD } from './card-metrics';
import { CARD, COMPACT, type CardVariant } from './card-metrics';
import { measureText, type FontFace } from './text-measure';
import type { CardData } from './types';

Expand All @@ -21,12 +21,25 @@ const GAP = 12; // gap-3
const PANEL = CARD.width - 2 * 12; // 256: stats and CTA sit in px-3
const ROW_VALUE_MAX = 0.55; // max-w-[55%]

export function cardTextSlots(card: CardData): TextSlot[] {
// StepCard: px-3, size-7 tile, gap-2.5; the tag pill (px-2) lines up with the title.
const C_PAD = 12;
const C_TILE = 28;
const C_GAP = 10;
const C_TEXT = COMPACT.width - 2 * C_PAD - C_TILE - C_GAP; // 114

export function cardTextSlots(card: CardData, variant: CardVariant = 'full'): TextSlot[] {
const slots: TextSlot[] = [];
const add = (path: string, text: string | undefined, face: FontFace, size: number, maxWidth: number) => {
if (text !== undefined) slots.push({ path, text, face, size, maxWidth });
};

if (variant === 'compact') {
add('/title', card.title, 'sans500', 13, C_TEXT);
add('/subtitle', card.subtitle, 'sans400', 11.5, C_TEXT);
add('/tag', card.tag, 'sans500', 11, C_TEXT - 2 * 8);
return slots;
}

const header = INNER - ICON_TILE - GAP;
add('/title', card.title, 'sans500', 13.5, header);
add('/subtitle', card.subtitle, 'sans400', 12, header);
Expand Down
17 changes: 16 additions & 1 deletion packages/core/src/diagram-stats.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import { NODE_TYPES, type DiagramNode, type NodeType } from './types';
import { NODE_TYPES, type DiagramKind, type DiagramNode, type NodeType } from './types';

export const KIND_LABELS: Record<DiagramKind, string> = {
architecture: 'Architecture',
dataflow: 'Dataflow',
workflow: 'Workflow',
lifecycle: 'Lifecycle',
sequence: 'Sequence',
};

export const TYPE_LABELS: Record<NodeType, string> = {
client: 'Client',
Expand All @@ -10,6 +18,13 @@ export const TYPE_LABELS: Record<NodeType, string> = {
storage: 'Storage',
external: 'External',
security: 'Security',
start: 'Start',
active: 'Active',
waiting: 'Waiting',
decision: 'Decision',
success: 'Success',
failure: 'Failure',
neutral: 'Neutral',
};

export function countByType(nodes: DiagramNode[]): [NodeType, number][] {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ export * from './diagram-stats';
export * from './brands';
export * from './text-measure';
export * from './card-text';
export * from './lane-columns';
116 changes: 116 additions & 0 deletions packages/core/src/lane-columns.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import type { DiagramDraft, DiagramEdge } from './types';

// Column assignment for the swimlane kinds (workflow, lifecycle). Pure, so layout places with it and validation
// can check what the placement implies (groups that would enclose other steps).

/**
* Edges that run from a later phase into an earlier one, directly or through unphased nodes: laid out as back
* edges (a phase can't start before the one it feeds), and reported by validation.
*/
export function phaseViolations(draft: DiagramDraft): Set<string> {
const phaseOf = new Map<string, number>();
draft.phases?.forEach((p, i) => p.nodes?.forEach((n) => phaseOf.has(n) || phaseOf.set(n, i)));
const out = new Set<string>();
if (!phaseOf.size) return out;
// The latest phase that reaches each node along forward edges; monotone and bounded by the phase count.
const reach = new Map(draft.nodes.map((n) => [n.id, phaseOf.get(n.id) ?? -1]));
const flow = draft.edges.filter((e) => e.kind !== 'return' && e.from !== e.to && reach.has(e.from) && reach.has(e.to));
for (let changed = true, guard = 0; changed && guard <= draft.nodes.length * (draft.phases!.length + 1); guard++) {
changed = false;
for (const e of flow) {
if (out.has(e.id)) continue;
const r = reach.get(e.from)!;
const target = phaseOf.get(e.to);
if (target !== undefined && r > target) {
out.add(e.id);
changed = true;
} else if (r > reach.get(e.to)!) {
reach.set(e.to, r);
changed = true;
}
}
}
return out;
}

/** Edges that don't push their target to a later column: replies, phase-order breaks, and what closes a cycle. */
export function backEdges(draft: DiagramDraft): Set<string> {
const back = new Set(draft.edges.filter((e) => e.kind === 'return' || e.from === e.to).map((e) => e.id));
for (const id of phaseViolations(draft)) back.add(id);
// DFS in draft order; an edge into a node still on the stack closes a cycle.
const out = new Map(draft.nodes.map((n) => [n.id, [] as DiagramEdge[]]));
for (const e of draft.edges) if (!back.has(e.id)) out.get(e.from)?.push(e);
const state = new Map<string, 1 | 2>();
const visit = (id: string) => {
state.set(id, 1);
for (const e of out.get(id) ?? []) {
const s = state.get(e.to);
if (s === 1) back.add(e.id);
else if (s === undefined && out.has(e.to)) visit(e.to);
}
state.set(id, 2);
};
const hasIn = new Set(draft.edges.filter((e) => !back.has(e.id)).map((e) => e.to));
for (const n of draft.nodes) if (!hasIn.has(n.id) && !state.has(n.id)) visit(n.id);
for (const n of draft.nodes) if (!state.has(n.id)) visit(n.id);
return back;
}

/**
* Column per node: longest path over forward edges. Within a lane a successor moves one column right; across
* lanes it may stay in the same column (a straight drop). A phase starts after the previous phase ends.
*/
export function assignColumns(draft: DiagramDraft, back: Set<string>): Map<string, number> {
const laneOf = new Map(draft.nodes.map((n) => [n.id, n.lane]));
const laneIndex = new Map((draft.lanes ?? []).map((l, i) => [l.id, i]));
const forward = draft.edges.filter((e) => !back.has(e.id) && laneOf.has(e.from) && laneOf.has(e.to));
const phases = (draft.phases ?? []).map((p) => (p.nodes ?? []).filter((n) => laneOf.has(n)));
const phaseOf = new Map<string, number>();
phases.forEach((ns, i) => ns.forEach((n) => phaseOf.has(n) || phaseOf.set(n, i)));
// Cross-lane edges that must still step right: their straight drop would run through a card.
const step = new Set<string>();
const settle = () => {
const col = new Map(draft.nodes.map((n) => [n.id, 0]));
const starts = phases.map(() => 0);
// Monotone: columns only grow, and the forward edges are acyclic, so this settles (bounded for safety).
for (let round = 0; round < draft.nodes.length * 4 + 8; round++) {
let changed = false;
const raise = (id: string, to: number) => {
if (to > col.get(id)!) {
col.set(id, to);
changed = true;
}
};
for (const [id, p] of phaseOf) raise(id, starts[p]!);
for (const e of forward) raise(e.to, col.get(e.from)! + (laneOf.get(e.from) === laneOf.get(e.to) || step.has(e.id) ? 1 : 0));
for (let p = 1; p < phases.length; p++) {
const prevEnd = Math.max(starts[p - 1]!, ...phases[p - 1]!.map((n) => col.get(n)!));
if (prevEnd + 1 > starts[p]!) {
starts[p] = prevEnd + 1;
changed = true;
}
}
if (!changed) break;
}
return col;
};
let col = settle();
// Each pass re-settles every column; a handful is plenty for real diagrams.
for (let pass = 0; pass < Math.min(forward.length, 24); pass++) {
// Stepping right is worth a column only inside the existing width; past it, routing around is cheaper.
const last = Math.max(...col.values());
const blocked = forward.find((e) => {
if (step.has(e.id) || col.get(e.from) !== col.get(e.to) || col.get(e.from)! + 1 > last) return false;
const [a, b] = [laneIndex.get(laneOf.get(e.from)!)!, laneIndex.get(laneOf.get(e.to)!)!].sort((x, y) => x - y);
return draft.nodes.some((n) => n.id !== e.from && n.id !== e.to && col.get(n.id) === col.get(e.from) && laneIndex.get(n.lane!)! > a! && laneIndex.get(n.lane!)! < b!);
});
if (!blocked) break;
step.add(blocked.id);
col = settle();
}
// Safety net: close any empty columns (order kept), so no input can spread the lanes over blank space.
const used = [...new Set(col.values())].sort((a, b) => a - b);
const dense = new Map(used.map((c, i) => [c, i]));
return new Map([...col].map(([id, c]) => [id, dense.get(c)!]));
}

Loading
Loading