diff --git a/packages/cli/scripts/smoke.mjs b/packages/cli/scripts/smoke.mjs index 95fcb24..d1a26e5 100644 --- a/packages/cli/scripts/smoke.mjs +++ b/packages/cli/scripts/smoke.mjs @@ -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; @@ -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`); diff --git a/packages/core/package.json b/packages/core/package.json index 7df69be..27be58c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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", diff --git a/packages/core/src/card-metrics.ts b/packages/core/src/card-metrics.ts index 5f49982..69808fb 100644 --- a/packages/core/src/card-metrics.ts +++ b/packages/core/src/card-metrics.ts @@ -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) { diff --git a/packages/core/src/card-text.ts b/packages/core/src/card-text.ts index c60f5b2..a6fc2a2 100644 --- a/packages/core/src/card-text.ts +++ b/packages/core/src/card-text.ts @@ -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'; @@ -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); diff --git a/packages/core/src/diagram-stats.ts b/packages/core/src/diagram-stats.ts index c555069..048fad7 100644 --- a/packages/core/src/diagram-stats.ts +++ b/packages/core/src/diagram-stats.ts @@ -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 = { + architecture: 'Architecture', + dataflow: 'Dataflow', + workflow: 'Workflow', + lifecycle: 'Lifecycle', + sequence: 'Sequence', +}; export const TYPE_LABELS: Record = { client: 'Client', @@ -10,6 +18,13 @@ export const TYPE_LABELS: Record = { 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][] { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 847bc25..1f723ed 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -7,3 +7,4 @@ export * from './diagram-stats'; export * from './brands'; export * from './text-measure'; export * from './card-text'; +export * from './lane-columns'; diff --git a/packages/core/src/lane-columns.ts b/packages/core/src/lane-columns.ts new file mode 100644 index 0000000..2dc73a8 --- /dev/null +++ b/packages/core/src/lane-columns.ts @@ -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 { + const phaseOf = new Map(); + draft.phases?.forEach((p, i) => p.nodes?.forEach((n) => phaseOf.has(n) || phaseOf.set(n, i))); + const out = new Set(); + 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 { + 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(); + 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): Map { + 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(); + 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(); + 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)!])); +} + diff --git a/packages/core/src/samples/gallery.ts b/packages/core/src/samples/gallery.ts new file mode 100644 index 0000000..d248211 --- /dev/null +++ b/packages/core/src/samples/gallery.ts @@ -0,0 +1,439 @@ +import type { DiagramDraft } from '../types'; + +// The archify gallery (https://tt-a1i.github.io/archify/gallery.html, MIT) recreated in stackmap's schema: the +// acceptance set for workflow, lifecycle, sequence, architecture and dataflow support. Facts follow archify's +// examples; layout is stackmap's own. + +export const releaseDelivery: DiagramDraft = { + kind: 'workflow', + title: 'Release delivery', + subtitle: 'From commit to announced release', + lanes: [ + { id: 'dev', label: 'Developer' }, + { id: 'ci', label: 'Continuous integration' }, + { id: 'approval', label: 'Release governance' }, + { id: 'environment', label: 'Production environment' }, + { id: 'communication', label: 'Release communication' }, + { id: 'exceptions', label: 'Failure and rollback', tone: 'exception' }, + ], + phases: [ + { id: 'change', label: 'Change', nodes: ['commit', 'pull-request'] }, + { id: 'verify', label: 'Build and verify', nodes: ['build', 'checks', 'failed'] }, + { id: 'promote', label: 'Promote and observe', nodes: ['approve', 'deploy', 'verify-prod', 'announce', 'rollback'] }, + ], + groups: [ + { id: 'blocking-checks', label: 'Blocking checks' }, + { id: 'recovery', label: 'Recovery path', tone: 'security' }, + ], + nodes: [ + { id: 'commit', type: 'client', lane: 'dev', card: { title: 'Commit', subtitle: 'Signed change' } }, + { id: 'pull-request', type: 'client', lane: 'dev', card: { title: 'Pull request', subtitle: 'Reviewed diff' } }, + { id: 'build', type: 'service', lane: 'ci', group: 'blocking-checks', card: { title: 'Build', subtitle: 'Locked inputs', tag: 'reproducible', brand: 'githubactions' } }, + { id: 'checks', type: 'security', lane: 'ci', group: 'blocking-checks', card: { title: 'Quality gates', subtitle: 'Test and scan', tag: 'blocking' } }, + { id: 'approve', type: 'security', lane: 'approval', card: { title: 'Approve', subtitle: 'Release owner', tag: 'human gate' } }, + { id: 'deploy', type: 'gateway', lane: 'environment', card: { title: 'Deploy', subtitle: 'Canary 10%', tag: 'production', brand: 'kubernetes' } }, + { id: 'verify-prod', type: 'service', lane: 'environment', card: { title: 'Verify', subtitle: 'Smoke and SLO' } }, + { id: 'announce', type: 'external', lane: 'communication', card: { title: 'Announce', subtitle: 'Status and notes' } }, + { id: 'failed', type: 'security', lane: 'exceptions', card: { title: 'Stop release', subtitle: 'Gate failed' } }, + { id: 'rollback', type: 'queue', lane: 'exceptions', group: 'recovery', card: { title: 'Rollback', subtitle: 'Last good image', tag: 'owner: on-call' } }, + ], + edges: [ + { id: 'e1', from: 'commit', to: 'pull-request', tone: 'main' }, + { id: 'e2', from: 'pull-request', to: 'build', label: 'merge', tone: 'main' }, + { id: 'e3', from: 'build', to: 'checks', tone: 'main' }, + { id: 'e4', from: 'checks', to: 'approve', tone: 'main' }, + { id: 'e5', from: 'approve', to: 'deploy', tone: 'main' }, + { id: 'e6', from: 'deploy', to: 'verify-prod', tone: 'main' }, + { id: 'e7', from: 'verify-prod', to: 'announce', label: 'healthy', tone: 'main' }, + { id: 'e8', from: 'checks', to: 'failed', label: 'red', tone: 'error' }, + { id: 'e9', from: 'verify-prod', to: 'rollback', label: 'unhealthy', tone: 'error' }, + { id: 'e10', from: 'rollback', to: 'deploy', label: 'restore', kind: 'return' }, + ], + views: [ + { id: 'happy', label: 'Happy path', caption: 'Every change is reviewed, built once and verified in production before it is announced.', nodes: ['commit', 'pull-request', 'build', 'checks', 'approve', 'deploy', 'verify-prod', 'announce'] }, + { id: 'stops', label: 'Stop conditions', caption: 'A red gate stops promotion; an unhealthy release rolls back to the last good image.', nodes: ['checks', 'failed', 'verify-prod', 'rollback', 'deploy'] }, + ], + notes: [ + { title: 'One happy path', items: ['Every change is reviewed before a reproducible build', 'Blocking checks must be green before human approval', 'Production is complete only after smoke and SLO verification'] }, + { title: 'Stop conditions', items: ['Test or security failure stops promotion', 'Production health can reverse a release', 'Rollback ownership is visible before an incident'] }, + ], +}; + +export const incidentResponse: DiagramDraft = { + kind: 'workflow', + title: 'Incident response runbook', + subtitle: 'Detect, command, mitigate, verify', + lanes: [ + { id: 'signals', label: 'Signals' }, + { id: 'command', label: 'Incident command' }, + { id: 'mitigation', label: 'Service mitigation' }, + { id: 'recovery', label: 'Recovery evidence' }, + { id: 'communication', label: 'Stakeholder communication' }, + { id: 'exceptions', label: 'Escalation and rollback', tone: 'exception' }, + ], + phases: [ + { id: 'detect', label: 'Detect', nodes: ['alert', 'page'] }, + { id: 'triage', label: 'Triage and mitigate', nodes: ['triage', 'declare', 'contain', 'update', 'escalate'] }, + { id: 'close', label: 'Verify and close', nodes: ['recover', 'verify', 'resolve', 'rollback'] }, + ], + groups: [{ id: 'owners', label: 'Command owns the incident' }], + nodes: [ + { id: 'alert', type: 'queue', lane: 'signals', card: { title: 'SLO alert', subtitle: 'Burn rate', brand: 'pagerduty' } }, + { id: 'page', type: 'external', lane: 'command', group: 'owners', card: { title: 'Page on-call', subtitle: 'Acknowledge' } }, + { id: 'triage', type: 'service', lane: 'command', group: 'owners', card: { title: 'Triage', subtitle: 'Scope impact' } }, + { id: 'declare', type: 'security', lane: 'command', group: 'owners', card: { title: 'Declare', subtitle: 'Assign commander', tag: 'SEV-1/2' } }, + { id: 'contain', type: 'service', lane: 'mitigation', card: { title: 'Contain', subtitle: 'Stop growth' } }, + { id: 'recover', type: 'gateway', lane: 'mitigation', card: { title: 'Recover', subtitle: 'Restore service' } }, + { id: 'verify', type: 'database', lane: 'recovery', card: { title: 'Verify', subtitle: 'SLO and traces', tag: '15 min stable' } }, + { id: 'update', type: 'client', lane: 'communication', card: { title: 'Status update', subtitle: 'Impact and ETA' } }, + { id: 'resolve', type: 'external', lane: 'communication', card: { title: 'Resolve', subtitle: 'Final update' } }, + { id: 'escalate', type: 'security', lane: 'exceptions', card: { title: 'Escalate', subtitle: 'Specialist' } }, + { id: 'rollback', type: 'queue', lane: 'exceptions', card: { title: 'Rollback', subtitle: 'Last good' } }, + ], + edges: [ + { id: 'e1', from: 'alert', to: 'page', label: 'page', tone: 'main' }, + { id: 'e2', from: 'page', to: 'triage', tone: 'main' }, + { id: 'e3', from: 'triage', to: 'contain', tone: 'main' }, + { id: 'e4', from: 'contain', to: 'recover', tone: 'main' }, + { id: 'e5', from: 'recover', to: 'verify', tone: 'main' }, + { id: 'e6', from: 'verify', to: 'resolve', tone: 'main' }, + { id: 'e7', from: 'triage', to: 'declare', tone: 'security' }, + { id: 'e8', from: 'declare', to: 'update', kind: 'async' }, + { id: 'e9', from: 'update', to: 'escalate', label: 'still impacted', tone: 'security' }, + { id: 'e10', from: 'verify', to: 'rollback', label: 'regressed', tone: 'error' }, + ], + views: [ + { id: 'command', label: 'Command', caption: 'A page is not an incident until someone owns command.', nodes: ['alert', 'page', 'triage', 'declare', 'update'] }, + { id: 'recovery', label: 'Recovery', caption: 'Mitigation reduces impact; only a stable SLO window proves recovery.', nodes: ['contain', 'recover', 'verify', 'resolve', 'rollback'] }, + ], + notes: [ + { title: 'Ownership first', items: ['A page is not an incident until someone owns command', 'Severity and scope are explicit before mitigation spreads', 'Escalation names the missing expertise'] }, + { title: 'Recovery is evidence', items: ['Mitigation can reduce impact without proving recovery', 'SLOs and traces must stay healthy for a fixed window', 'The final update follows verification, not optimism'] }, + ], +}; + +export const agentToolCall: DiagramDraft = { + kind: 'workflow', + title: 'Agent tool call', + subtitle: 'Request, plan, approve, execute, reply', + lanes: [ + { id: 'ui', label: 'User interface' }, + { id: 'agent', label: 'Agent runtime' }, + { id: 'policy', label: 'Policy and recovery', tone: 'exception' }, + { id: 'tools', label: 'Tool execution and evidence' }, + ], + phases: [ + { id: 'intake', label: 'Intake', nodes: ['user', 'chat', 'store'] }, + { id: 'plan', label: 'Plan and route', nodes: ['planner', 'router', 'approval'] }, + { id: 'execute', label: 'Execute and report', nodes: ['tool', 'external', 'blocked', 'retry', 'final'] }, + ], + groups: [ + { id: 'loop', label: 'Planning loop' }, + { id: 'stop', label: 'Human or policy stop', tone: 'security' }, + ], + nodes: [ + { id: 'user', type: 'external', lane: 'ui', card: { title: 'User', subtitle: 'Asks for work' } }, + { id: 'chat', type: 'client', lane: 'ui', card: { title: 'Chat surface', subtitle: 'Thread and files' } }, + { id: 'final', type: 'service', lane: 'ui', card: { title: 'Final reply', subtitle: 'Answer and changes' } }, + { id: 'planner', type: 'service', lane: 'agent', group: 'loop', card: { title: 'Agent planner', subtitle: 'Plan next step', tag: 'context aware', brand: 'anthropic' } }, + { id: 'router', type: 'service', lane: 'agent', group: 'loop', card: { title: 'Tool router', subtitle: 'Choose capability' } }, + { id: 'approval', type: 'security', lane: 'policy', group: 'stop', card: { title: 'Approval gate', subtitle: 'Scope and consent', tag: 'blocks risky ops' } }, + { id: 'blocked', type: 'security', lane: 'policy', group: 'stop', card: { title: 'Blocked', subtitle: 'Wait or reject' } }, + { id: 'retry', type: 'queue', lane: 'policy', group: 'stop', card: { title: 'Retry path', subtitle: 'Revise request' } }, + { id: 'tool', type: 'queue', lane: 'tools', card: { title: 'Tool call', subtitle: 'Shell, browser, MCP', tag: 'structured result' } }, + { id: 'external', type: 'gateway', lane: 'tools', card: { title: 'External API', subtitle: 'Network service' } }, + { id: 'store', type: 'database', lane: 'tools', card: { title: 'Context store', subtitle: 'Repo and memory' } }, + { id: 'trace', type: 'database', lane: 'tools', card: { title: 'Trace log', subtitle: 'Events and output' } }, + ], + edges: [ + { id: 'e1', from: 'user', to: 'chat', tone: 'main' }, + { id: 'e2', from: 'chat', to: 'planner', label: 'plan', tone: 'main' }, + { id: 'e3', from: 'planner', to: 'router', tone: 'main' }, + { id: 'e4', from: 'router', to: 'approval', label: 'needs approval?', tone: 'security' }, + { id: 'e5', from: 'approval', to: 'tool', label: 'approved', tone: 'main' }, + { id: 'e6', from: 'approval', to: 'blocked', label: 'denied', tone: 'error' }, + { id: 'e7', from: 'blocked', to: 'retry', kind: 'async' }, + { id: 'e8', from: 'retry', to: 'planner', label: 'revise', kind: 'return' }, + { id: 'e9', from: 'tool', to: 'external', tone: 'main' }, + { id: 'e10', from: 'external', to: 'final', label: 'result', kind: 'return', tone: 'main' }, + { id: 'e11', from: 'external', to: 'trace', label: 'record', kind: 'async' }, + { id: 'e12', from: 'store', to: 'trace', kind: 'async' }, + { id: 'e13', from: 'store', to: 'planner', label: 'context', kind: 'async' }, + ], + views: [{ id: 'gate', label: 'Approval gate', caption: 'Risky work waits for scope and consent before any tool runs.', nodes: ['router', 'approval', 'tool', 'blocked', 'retry'] }], + notes: [ + { title: 'Runtime semantics', items: ['Approval gates risky work before tool execution', 'A denial loops back to planning instead of failing silently', 'Evidence returns through the trace log and memory'] }, + ], +}; + +export const agentRun: DiagramDraft = { + kind: 'lifecycle', + title: 'Agent run lifecycle', + subtitle: 'States of one agent run', + lanes: [ + { id: 'main', label: 'Lifecycle phases' }, + { id: 'waiting', label: 'Interruptions' }, + { id: 'recovery', label: 'Recovery loop', tone: 'exception' }, + { id: 'terminal', label: 'Terminal exits' }, + ], + nodes: [ + { id: 'queued', type: 'start', lane: 'main', card: { title: 'Queued', subtitle: 'Request accepted' } }, + { id: 'planning', type: 'active', lane: 'main', card: { title: 'Planning', subtitle: 'Build task graph' } }, + { id: 'executing', type: 'active', lane: 'main', card: { title: 'Executing', subtitle: 'Tool calls' } }, + { id: 'reviewing', type: 'decision', lane: 'main', card: { title: 'Reviewing', subtitle: 'Quality gate' } }, + { id: 'completed', type: 'success', lane: 'main', card: { title: 'Completed', subtitle: 'Final response' } }, + { id: 'approval', type: 'waiting', lane: 'waiting', card: { title: 'Needs approval', subtitle: 'Human gate', tag: 'pause' } }, + { id: 'blocked', type: 'waiting', lane: 'waiting', card: { title: 'Blocked', subtitle: 'Missing input' } }, + { id: 'failed', type: 'failure', lane: 'recovery', card: { title: 'Failed', subtitle: 'Recoverable error', tag: 'retryable' } }, + { id: 'cancelled', type: 'failure', lane: 'terminal', card: { title: 'Cancelled', subtitle: 'User stopped' } }, + { id: 'expired', type: 'failure', lane: 'terminal', card: { title: 'Expired', subtitle: 'Timeout' } }, + ], + edges: [ + { id: 'plan', from: 'queued', to: 'planning', tone: 'main' }, + { id: 'execute', from: 'planning', to: 'executing', tone: 'main' }, + { id: 'review', from: 'executing', to: 'reviewing', tone: 'main' }, + { id: 'complete', from: 'reviewing', to: 'completed', tone: 'main' }, + { id: 'needs-approval', from: 'executing', to: 'approval', label: 'risky op', tone: 'security' }, + { id: 'approved', from: 'approval', to: 'executing', label: 'approved', kind: 'return' }, + { id: 'review-blocked', from: 'reviewing', to: 'blocked', label: 'missing input' }, + { id: 'execution-failed', from: 'executing', to: 'failed', label: 'error', tone: 'error' }, + { id: 'retry', from: 'failed', to: 'executing', label: 'retry', kind: 'return' }, + { id: 'approval-cancelled', from: 'approval', to: 'cancelled', label: 'denied', tone: 'error' }, + { id: 'block-expired', from: 'blocked', to: 'expired', label: 'timeout', tone: 'error' }, + ], + notes: [ + { title: 'Main path and waits', items: ['The run has five ordered phases from queue to completion', 'Approval and missing input pause the run without ending it'] }, + { title: 'Recovery and terminal exits', items: ['Failed loops back while retry budget remains', 'Cancelled and Expired are terminal exits with no return path'] }, + ], +}; + +export const deploymentRelease: DiagramDraft = { + kind: 'lifecycle', + title: 'Deployment release lifecycle', + subtitle: 'States of one release object', + lanes: [ + { id: 'main', label: 'Release phases' }, + { id: 'waiting', label: 'Approval and health wait' }, + { id: 'recovery', label: 'Rollback controller', tone: 'exception' }, + { id: 'terminal', label: 'Terminal exits' }, + ], + nodes: [ + { id: 'queued', type: 'start', lane: 'main', card: { title: 'Queued', subtitle: 'Change accepted' } }, + { id: 'building', type: 'active', lane: 'main', card: { title: 'Building', subtitle: 'Immutable image' } }, + { id: 'verifying', type: 'decision', lane: 'main', card: { title: 'Verifying', subtitle: 'Tests and policy', tag: 'gate' } }, + { id: 'ready', type: 'waiting', lane: 'main', card: { title: 'Ready', subtitle: 'Promotion pending' } }, + { id: 'live', type: 'success', lane: 'main', card: { title: 'Live', subtitle: 'Production healthy' } }, + { id: 'approval', type: 'waiting', lane: 'waiting', card: { title: 'Needs approval', subtitle: 'Release owner', tag: 'pause' } }, + { id: 'paused', type: 'waiting', lane: 'waiting', card: { title: 'Health paused', subtitle: 'SLO regression' } }, + { id: 'rolling-back', type: 'active', lane: 'recovery', card: { title: 'Rolling back', subtitle: 'Last good image', tag: 'automatic' } }, + { id: 'cancelled', type: 'failure', lane: 'terminal', card: { title: 'Cancelled', subtitle: 'Approval denied' } }, + { id: 'failed', type: 'failure', lane: 'terminal', card: { title: 'Failed', subtitle: 'Rollback failed' } }, + { id: 'rolled-back', type: 'success', lane: 'terminal', card: { title: 'Rolled back', subtitle: 'Service restored' } }, + ], + edges: [ + { id: 'build', from: 'queued', to: 'building', tone: 'main' }, + { id: 'verify', from: 'building', to: 'verifying', tone: 'main' }, + { id: 'pass', from: 'verifying', to: 'ready', tone: 'main' }, + { id: 'promote', from: 'ready', to: 'live', tone: 'main' }, + { id: 'sign-off', from: 'verifying', to: 'approval', label: 'needs sign-off', tone: 'security' }, + { id: 'approved', from: 'approval', to: 'ready', label: 'approved' }, + { id: 'denied', from: 'approval', to: 'cancelled', label: 'denied', tone: 'error' }, + { id: 'abort', from: 'ready', to: 'rolling-back', label: 'abort', tone: 'error' }, + { id: 'rollback-failed', from: 'rolling-back', to: 'failed', tone: 'error' }, + { id: 'regress', from: 'live', to: 'paused', label: 'SLO regression', kind: 'async' }, + { id: 'rollback', from: 'paused', to: 'rolling-back', label: 'roll back' }, + { id: 'restored', from: 'rolling-back', to: 'rolled-back', label: 'restored', tone: 'main' }, + ], + notes: [ + { title: 'Promotion rail', items: ['The release moves through five ordered phases', 'Verification and approval remain distinct states', 'Live means production health is currently proven'] }, + { title: 'Explicit endings', items: ['Denied approval ends as Cancelled', 'A failed rollback ends as Failed', 'A successful rollback is a terminal restored outcome'] }, + ], +}; + +export const productionDeployment: DiagramDraft = { + kind: 'architecture', + density: 'compact', + title: 'Production deployment ownership', + subtitle: 'Who runs what, and where traffic crosses', + direction: 'RIGHT', + groups: [ + { id: 'us-east', label: 'AWS us-east-1 · production' }, + { id: 'private', label: 'Private application network', parent: 'us-east', tone: 'security' }, + { id: 'eu-west', label: 'AWS eu-west-1 · disaster recovery' }, + ], + nodes: [ + { id: 'clients', type: 'client', card: { title: 'Customers', subtitle: 'Web and mobile' } }, + { id: 'edge', type: 'gateway', group: 'us-east', card: { title: 'Global edge', subtitle: 'CDN and WAF', brand: 'cloudflare', tag: 'Platform' } }, + { id: 'gateway', type: 'security', group: 'us-east', card: { title: 'API gateway', subtitle: 'Public :443', tag: 'Platform' } }, + { id: 'api-a', type: 'service', group: 'private', card: { title: 'API pods · AZ-a', subtitle: 'Private subnet', brand: 'kubernetes', tag: 'Orders team' } }, + { id: 'api-b', type: 'service', group: 'private', card: { title: 'API pods · AZ-b', subtitle: 'Private subnet', brand: 'kubernetes', tag: 'Orders team' } }, + { id: 'redis', type: 'cache', group: 'private', card: { title: 'Redis', subtitle: 'Multi-AZ cache', brand: 'redis', tag: 'Platform' } }, + { id: 'postgres', type: 'database', group: 'private', card: { title: 'PostgreSQL', subtitle: 'Primary, encrypted', brand: 'postgresql', tag: 'Data' } }, + { id: 'events', type: 'queue', group: 'private', card: { title: 'Event bus', subtitle: 'orders.v1', tag: 'Platform' } }, + { id: 'workers', type: 'service', group: 'private', card: { title: 'Workers', subtitle: 'Private workload', tag: 'Orders team' } }, + { id: 'audit', type: 'storage', group: 'us-east', card: { title: 'Audit archive', subtitle: 'Immutable objects', tag: 'Security' } }, + { id: 'replica', type: 'database', group: 'eu-west', card: { title: 'DR replica', subtitle: 'eu-west-1', brand: 'postgresql', tag: 'Data' } }, + { id: 'observability', type: 'external', card: { title: 'Observability', subtitle: 'Metrics and traces', tag: 'SRE' } }, + ], + edges: [ + { id: 'e1', from: 'clients', to: 'edge', label: 'HTTPS', tone: 'main' }, + { id: 'e2', from: 'edge', to: 'gateway', label: 'mTLS', tone: 'security' }, + { id: 'e3', from: 'gateway', to: 'api-a', label: 'VPC route', tone: 'main' }, + { id: 'e4', from: 'gateway', to: 'api-b', label: 'VPC route', tone: 'main' }, + { id: 'e5', from: 'api-a', to: 'redis', label: 'cache' }, + { id: 'e6', from: 'api-b', to: 'postgres', label: 'SQL' }, + { id: 'e7', from: 'api-a', to: 'events', label: 'publish', kind: 'async' }, + { id: 'e8', from: 'api-b', to: 'events', kind: 'async' }, + { id: 'e9', from: 'events', to: 'workers', tone: 'main' }, + { id: 'e10', from: 'postgres', to: 'replica', label: 'cross-region WAL', tone: 'security' }, + { id: 'e11', from: 'workers', to: 'audit', label: 'evidence', kind: 'async' }, + { id: 'e12', from: 'workers', to: 'observability', label: 'OTLP', kind: 'async' }, + ], + views: [ + { id: 'crossings', label: 'Crossings', caption: 'Public HTTPS ends at the edge; mTLS crosses into the private network; WAL crosses regions.', nodes: ['clients', 'edge', 'gateway', 'postgres', 'replica'] }, + { id: 'async', label: 'Async work', caption: 'API pods publish to the event bus; workers drain it and leave evidence.', nodes: ['api-a', 'api-b', 'events', 'workers', 'audit', 'observability'] }, + ], + notes: [ + { title: 'Runtime ownership', items: ['Platform owns the edge, gateway, cache and event bus', 'Application teams own API pods and workers', 'Data owns primary and disaster-recovery state'] }, + { title: 'Named crossings', items: ['Public HTTPS terminates at the managed edge', 'mTLS crosses into the application network', 'Cross-region WAL is explicit and encrypted'] }, + ], +}; + +export const webApp: DiagramDraft = { + kind: 'architecture', + density: 'compact', + title: 'Sample web app', + subtitle: 'Three tiers on AWS', + direction: 'RIGHT', + groups: [ + { id: 'region', label: 'AWS us-west-2' }, + { id: 'sg-api', label: 'sg-api · :443 / :8000', parent: 'region', tone: 'security' }, + ], + nodes: [ + { id: 'users', type: 'client', card: { title: 'Users', subtitle: 'Browser and mobile' } }, + { id: 'auth', type: 'security', card: { title: 'Auth provider', subtitle: 'OAuth 2.0' } }, + { id: 'cdn', type: 'gateway', group: 'region', card: { title: 'CloudFront', subtitle: 'CDN' } }, + { id: 'lb', type: 'gateway', group: 'sg-api', card: { title: 'Load balancer', subtitle: 'HTTPS', tag: ':443' } }, + { id: 'api', type: 'service', group: 'sg-api', card: { title: 'API server', subtitle: 'FastAPI', brand: 'fastapi', tag: ':8000' } }, + { id: 'cache', type: 'cache', group: 'region', card: { title: 'Redis', subtitle: 'Read-through cache', brand: 'redis', tag: ':6379' } }, + { id: 'db', type: 'database', group: 'region', card: { title: 'PostgreSQL', subtitle: 'Primary', brand: 'postgresql', tag: ':5432' } }, + { id: 's3', type: 'storage', group: 'region', card: { title: 'S3', subtitle: 'Static assets' } }, + { id: 'queue', type: 'queue', group: 'region', card: { title: 'SQS', subtitle: 'Job queue' } }, + { id: 'worker', type: 'service', group: 'region', card: { title: 'Worker', subtitle: 'Async jobs' } }, + ], + edges: [ + { id: 'e1', from: 'users', to: 'cdn', label: 'HTTPS', tone: 'main' }, + { id: 'e2', from: 'cdn', to: 'lb', tone: 'main' }, + { id: 'e3', from: 'cdn', to: 's3', label: 'static', kind: 'async' }, + { id: 'e4', from: 'lb', to: 'api', tone: 'main' }, + { id: 'e5', from: 'auth', to: 'api', label: 'verify JWT', tone: 'security' }, + { id: 'e6', from: 'api', to: 'cache', label: 'read-through' }, + { id: 'e7', from: 'api', to: 'db', label: 'SQL', tone: 'main' }, + { id: 'e8', from: 'api', to: 'queue', label: 'enqueue', kind: 'async' }, + { id: 'e9', from: 'queue', to: 'worker' }, + ], + notes: [ + { title: 'Edge', items: ['CloudFront fronts all traffic', 'S3 serves static assets'] }, + { title: 'Security', items: ['OAuth 2.0 with JWT and PKCE', 'API and load balancer isolated in a security group'] }, + ], +}; + +export const productAnalytics: DiagramDraft = { + kind: 'dataflow', + density: 'compact', + title: 'Product analytics', + subtitle: 'From SDK events to dashboards and models', + direction: 'RIGHT', + phases: [ + { id: 'sources', label: 'Sources', nodes: ['web', 'mobile'] }, + { id: 'ingest', label: 'Ingest', nodes: ['edge'] }, + { id: 'process', label: 'Process', nodes: ['consent', 'stream'] }, + { id: 'store', label: 'Store', nodes: ['pii', 'warehouse', 'features'] }, + { id: 'consume', label: 'Consume', nodes: ['dashboards', 'model'] }, + ], + nodes: [ + { id: 'web', type: 'client', card: { title: 'Web app', subtitle: 'Browser SDK' } }, + { id: 'mobile', type: 'client', card: { title: 'Mobile', subtitle: 'iOS and Android' } }, + { id: 'edge', type: 'gateway', card: { title: 'Edge API', subtitle: 'Collector' } }, + { id: 'consent', type: 'security', card: { title: 'Consent gate', subtitle: 'Policy filter' } }, + { id: 'stream', type: 'queue', card: { title: 'Event stream', subtitle: 'Kafka topic' } }, + { id: 'pii', type: 'security', card: { title: 'PII vault', subtitle: 'Encrypted' } }, + { id: 'warehouse', type: 'database', card: { title: 'Warehouse', subtitle: 'Analytics tables', brand: 'snowflake' } }, + { id: 'features', type: 'database', card: { title: 'Feature store', subtitle: 'Daily batch' } }, + { id: 'dashboards', type: 'service', card: { title: 'Dashboards', subtitle: 'Product metrics' } }, + { id: 'model', type: 'service', card: { title: 'ML model', subtitle: 'Ranking job' } }, + ], + edges: [ + { id: 'e1', from: 'web', to: 'edge', label: 'clickstream', tone: 'main' }, + { id: 'e2', from: 'mobile', to: 'edge', label: 'app events' }, + { id: 'e3', from: 'edge', to: 'consent', label: 'identity', tone: 'security' }, + { id: 'e4', from: 'edge', to: 'stream', label: 'accepted events', tone: 'main' }, + { id: 'e5', from: 'consent', to: 'pii', label: 'identity map', tone: 'security' }, + { id: 'e6', from: 'stream', to: 'warehouse', label: 'normalized facts', tone: 'main' }, + { id: 'e7', from: 'warehouse', to: 'features', label: 'daily aggregates', kind: 'async' }, + { id: 'e8', from: 'warehouse', to: 'dashboards', label: 'metrics SQL' }, + { id: 'e9', from: 'features', to: 'model', label: 'feature vectors', kind: 'async' }, + { id: 'e10', from: 'pii', to: 'dashboards', label: 'restricted join', tone: 'security' }, + ], + notes: [ + { title: 'Sensitive boundary', items: ['Consent and PII paths are security flows', 'PII lands in a restricted vault, apart from the warehouse', 'Restricted joins are visible without implying default access'] }, + ], +}; + +export const eventStream: DiagramDraft = { + kind: 'dataflow', + density: 'compact', + title: 'Order event-stream topology', + subtitle: 'Topics, consumer groups and the dead-letter path', + direction: 'RIGHT', + phases: [ + { id: 'producers', label: 'Producers', nodes: ['checkout', 'billing'] }, + { id: 'transit', label: 'Transit', nodes: ['orders', 'payments'] }, + { id: 'processors', label: 'Processors', nodes: ['validate', 'enrich'] }, + { id: 'state', label: 'State and recovery', nodes: ['order-state', 'dlq'] }, + { id: 'consumers', label: 'Consumers', nodes: ['fulfillment', 'analytics', 'replay', 'ops'] }, + ], + nodes: [ + { id: 'checkout', type: 'service', card: { title: 'Checkout API', subtitle: 'Order producer' } }, + { id: 'billing', type: 'service', card: { title: 'Billing API', subtitle: 'Payment producer', brand: 'stripe' } }, + { id: 'orders', type: 'queue', card: { title: 'orders.v1', subtitle: 'Topic', tag: '12 partitions' } }, + { id: 'payments', type: 'queue', card: { title: 'payments.v2', subtitle: 'Topic', tag: '8 partitions' } }, + { id: 'validate', type: 'service', card: { title: 'Order validate', subtitle: 'Group fulfillment' } }, + { id: 'enrich', type: 'service', card: { title: 'Payment enrich', subtitle: 'Group analytics' } }, + { id: 'order-state', type: 'database', card: { title: 'Order state', subtitle: 'Materialized view' } }, + { id: 'dlq', type: 'queue', card: { title: 'events.dlq', subtitle: 'Poison events' } }, + { id: 'fulfillment', type: 'service', card: { title: 'Fulfillment', subtitle: 'Shipping workflow' } }, + { id: 'analytics', type: 'database', card: { title: 'Analytics', subtitle: 'Streaming facts' } }, + { id: 'replay', type: 'security', card: { title: 'Replay tool', subtitle: 'Approved batch' } }, + { id: 'ops', type: 'external', card: { title: 'On-call', subtitle: 'DLQ owner', brand: 'pagerduty' } }, + ], + edges: [ + { id: 'e1', from: 'checkout', to: 'orders', label: 'OrderPlaced', tone: 'main' }, + { id: 'e2', from: 'billing', to: 'payments', label: 'PaymentCaptured', tone: 'main' }, + { id: 'e3', from: 'orders', to: 'validate', label: 'ordered', tone: 'main' }, + { id: 'e4', from: 'payments', to: 'enrich', label: 'payment facts', tone: 'main' }, + { id: 'e5', from: 'validate', to: 'order-state', label: 'valid order', tone: 'main' }, + { id: 'e6', from: 'enrich', to: 'order-state', label: 'enriched' }, + { id: 'e7', from: 'order-state', to: 'fulfillment', label: 'ready orders', tone: 'main' }, + { id: 'e8', from: 'order-state', to: 'analytics', label: 'order facts' }, + { id: 'e9', from: 'validate', to: 'dlq', label: 'invalid', tone: 'error' }, + { id: 'e10', from: 'enrich', to: 'dlq', label: 'poison', tone: 'error' }, + { id: 'e11', from: 'dlq', to: 'ops', label: 'sample', tone: 'error' }, + { id: 'e12', from: 'dlq', to: 'replay', label: 'approved replay', kind: 'async' }, + ], + notes: [ + { title: 'Failure ownership', items: ['Poison events land in a retained dead-letter topic', 'On-call inspects samples before replay', 'Replay is gated, batched and auditable'] }, + ], +}; + +export const GALLERY = { + 'release-delivery': releaseDelivery, + 'incident-response': incidentResponse, + 'agent-tool-call': agentToolCall, + 'agent-run': agentRun, + 'deployment-release': deploymentRelease, + 'production-deployment': productionDeployment, + 'web-app': webApp, + 'product-analytics': productAnalytics, + 'event-stream': eventStream, +} as const satisfies Record; diff --git a/packages/core/src/tokens.ts b/packages/core/src/tokens.ts index 1ab0589..1dc9e46 100644 --- a/packages/core/src/tokens.ts +++ b/packages/core/src/tokens.ts @@ -1,4 +1,4 @@ -import type { NodeType } from './types'; +import type { InfraType, NodeType, StateType } from './types'; export type ThemeName = 'light' | 'dark'; @@ -30,6 +30,22 @@ export interface ThemeTokens { tints: Record; } +// Lifecycle states reuse the infra palette, picked for meaning: success green, failure red, waiting amber. +const STATE_TINT: Record = { + start: 'client', + active: 'database', + waiting: 'gateway', + decision: 'queue', + success: 'service', + failure: 'cache', + neutral: 'external', +}; + +const withStates = (t: Record): Record => ({ + ...t, + ...(Object.fromEntries(Object.entries(STATE_TINT).map(([s, i]) => [s, t[i]])) as Record), +}); + const light: ThemeTokens = { page: '#f4f4f3', stage: '#ffffff', @@ -44,7 +60,7 @@ const light: ThemeTokens = { primaryText: '#ffffff', groupFill: '#fafaf9', groupBorder: '#8f8f8a', - tints: { + tints: withStates({ client: { fill: '#e3f0ef', border: '#cfe4e2', tile: '#cfe4e2', accent: '#2a7f7b' }, service: { fill: '#e9f0e8', border: '#dce6da', tile: '#dce6da', accent: '#4e7f3f' }, gateway: { fill: '#f8ece2', border: '#efdccb', tile: '#f0dcca', accent: '#b25e14' }, @@ -54,7 +70,7 @@ const light: ThemeTokens = { storage: { fill: '#f4f0de', border: '#e6dfc2', tile: '#e7dfc0', accent: '#8a7417' }, external: { fill: '#efefed', border: '#e1e1de', tile: '#e1e1de', accent: '#5f5f5c' }, security: { fill: '#f8e6ef', border: '#edd0df', tile: '#efd1e0', accent: '#b83c74' }, - }, + }), }; const dark: ThemeTokens = { @@ -71,7 +87,7 @@ const dark: ThemeTokens = { primaryText: '#141414', groupFill: '#1b1b1b', groupBorder: '#686868', - tints: { + tints: withStates({ client: { fill: '#141c1c', border: '#28504d', tile: '#1b2d2c', accent: '#6fd1c9' }, service: { fill: '#181c16', border: '#33402b', tile: '#232b1e', accent: '#a6d97a' }, gateway: { fill: '#1d1914', border: '#4a3822', tile: '#2e2419', accent: '#f0b05a' }, @@ -81,7 +97,7 @@ const dark: ThemeTokens = { storage: { fill: '#1c1a13', border: '#4a4426', tile: '#2b2819', accent: '#dec96a' }, external: { fill: '#1a1a1a', border: '#383838', tile: '#262626', accent: '#b3b3b1' }, security: { fill: '#1f1419', border: '#55293f', tile: '#331d29', accent: '#f07db4' }, - }, + }), }; export const tokens: Record = { light, dark }; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index e144bea..25d6fb4 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1,4 +1,5 @@ -export const NODE_TYPES = [ +/** Components: every kind except lifecycle. */ +export const INFRA_TYPES = [ 'client', 'service', 'gateway', @@ -9,8 +10,27 @@ export const NODE_TYPES = [ 'external', 'security', ] as const; +export type InfraType = (typeof INFRA_TYPES)[number]; + +/** Lifecycle states. */ +export const STATE_TYPES = ['start', 'active', 'waiting', 'decision', 'success', 'failure', 'neutral'] as const; +export type StateType = (typeof STATE_TYPES)[number]; + +export const NODE_TYPES = [...INFRA_TYPES, ...STATE_TYPES] as const; export type NodeType = (typeof NODE_TYPES)[number]; +export const DIAGRAM_KINDS = ['architecture', 'dataflow', 'workflow', 'lifecycle', 'sequence'] as const; +export type DiagramKind = (typeof DIAGRAM_KINDS)[number]; + +/** Kinds drawn as swimlanes: nodes sit in `lanes`, columns follow the flow. */ +export const LANE_KINDS = ['workflow', 'lifecycle'] as const; +/** Kinds whose nodes render as compact step/state/participant cards. */ +export const COMPACT_KINDS = ['workflow', 'lifecycle', 'sequence'] as const; +export const isLaneKind = (k: DiagramKind): k is (typeof LANE_KINDS)[number] => (LANE_KINDS as readonly string[]).includes(k); +export const isCompactKind = (k: DiagramKind): k is (typeof COMPACT_KINDS)[number] => (COMPACT_KINDS as readonly string[]).includes(k); +/** Whether the diagram's nodes render as compact cards: always in compact kinds, on request (`density`) elsewhere. */ +export const usesCompactCards = (d: { kind: DiagramKind; density?: 'compact' }) => isCompactKind(d.kind) || d.density === 'compact'; + export type FooterIcon = 'region' | 'secure' | 'members'; export interface CardRow { @@ -40,6 +60,8 @@ export interface CardData { statsNote?: string; footer?: { left?: FooterItem; right?: FooterItem }; cta?: { label: string; href?: string }; + /** compact cards only (workflow, lifecycle, sequence): a short pill such as "human gate" */ + tag?: string; } /** Where in the codebase a node comes from; shown in the inspector, never on the card. */ @@ -54,6 +76,8 @@ export interface DiagramNode { id: string; type: NodeType; group?: string; + /** workflow and lifecycle: the lane the node sits in */ + lane?: string; card: CardData; evidence?: Evidence[]; } @@ -63,13 +87,45 @@ export interface DiagramEdge { from: string; to: string; label?: string; - kind?: 'sync' | 'async'; + /** async renders dashed; return (a reply, a roll back) renders dotted and muted */ + kind?: EdgeKind; + /** main = the happy path; security and error borrow those tints */ + tone?: EdgeTone; } +export type EdgeKind = 'sync' | 'async' | 'return'; +export type EdgeTone = 'main' | 'security' | 'error'; + export interface DiagramGroup { id: string; label: string; parent?: string; + /** a trust boundary: outline in the security tint */ + tone?: 'security'; +} + +export interface DiagramLane { + id: string; + label: string; + /** failure and recovery paths */ + tone?: 'exception'; +} + +/** + * Ordered stages. Lane kinds: a header over the columns `nodes` occupy. Architecture and dataflow: a band around + * `nodes`, in flow order. Sequence: a time band around the messages `edges`. + */ +export interface DiagramPhase { + id: string; + label: string; + nodes?: string[]; + edges?: string[]; +} + +/** A takeaway about the diagram, listed in the inspector. */ +export interface DiagramNote { + title: string; + items: string[]; } export interface DiagramView { @@ -82,16 +138,21 @@ export interface DiagramView { export type Direction = 'RIGHT' | 'DOWN'; export interface DiagramDraft { - kind: 'architecture' | 'dataflow'; + kind: DiagramKind; + /** architecture and dataflow: compact cards (title, subtitle, brand, tag) for wide or summary diagrams */ + density?: 'compact'; title: string; subtitle?: string; direction?: Direction; /** base URL evidence files are linked under, e.g. "https://github.com/acme/shop/blob/main" */ source?: { url: string }; groups?: DiagramGroup[]; + lanes?: DiagramLane[]; + phases?: DiagramPhase[]; nodes: DiagramNode[]; edges: DiagramEdge[]; views?: DiagramView[]; + notes?: DiagramNote[]; } export interface Point { @@ -113,4 +174,18 @@ export interface LaidOutDiagram { groups: Record; edges: Record; bounds: { width: number; height: number }; + /** lane kinds: full-width lane bands */ + lanes?: Record; + /** lane kinds: header bands; sequence: time bands */ + phases?: Record; + /** where an edge's label sits, when the layout places it (else the route's midpoint) */ + labels?: Record; + sequence?: SequenceLayout; +} + +export interface SequenceLayout { + /** per participant: the lifeline's x and vertical extent */ + lifelines: Record; + /** activation bars; depth > 0 is nested (self-calls) */ + activations: { participant: string; rect: Rect; depth: number }[]; } diff --git a/packages/layout/scripts/bun-smoke.ts b/packages/layout/scripts/bun-smoke.ts index a1bd467..eea1692 100644 --- a/packages/layout/scripts/bun-smoke.ts +++ b/packages/layout/scripts/bun-smoke.ts @@ -1,5 +1,7 @@ import { deepStrictEqual } from 'node:assert'; import { commerceApi, groupedPlatform } from '@stackmap/core/samples'; +import { GALLERY } from '@stackmap/core/gallery'; +import { galleryLayouts } from '../../viewer/src/samples/gallery.layout'; import { commerceApiLayout } from '../../viewer/src/samples/commerce-api.layout'; import { groupedPlatformLayout } from '../../viewer/src/samples/grouped-platform.layout'; import { layoutDiagram } from '../src/index'; @@ -7,4 +9,5 @@ import { layoutDiagram } from '../src/index'; // Runs under the Bun runtime (vitest runs under Node), and doubles as a drift check on the baked samples. deepStrictEqual(await layoutDiagram(commerceApi), commerceApiLayout); deepStrictEqual(await layoutDiagram(groupedPlatform), groupedPlatformLayout); +for (const [name, draft] of Object.entries(GALLERY)) deepStrictEqual(await layoutDiagram(draft), galleryLayouts[name]); console.log('✓ bun runtime: layouts match the baked samples'); diff --git a/packages/layout/scripts/layout-samples.ts b/packages/layout/scripts/layout-samples.ts index 2d062c8..994b380 100644 --- a/packages/layout/scripts/layout-samples.ts +++ b/packages/layout/scripts/layout-samples.ts @@ -1,5 +1,6 @@ import { mkdirSync, writeFileSync } from 'node:fs'; import { commerceApi, groupedPlatform } from '@stackmap/core/samples'; +import { GALLERY } from '@stackmap/core/gallery'; import { layoutDiagram } from '../src/index'; const outDir = new URL('../../viewer/src/samples/', import.meta.url); @@ -13,3 +14,12 @@ for (const [file, [exportName, draft]] of Object.entries(samples)) { writeFileSync(new URL(`${file}.layout.ts`, outDir), source); console.log(`wrote ${file}.layout.ts`); } + +// The gallery set (archify's examples in stackmap's schema), one record for the dev pages. +const gallery: Record = {}; +for (const [name, draft] of Object.entries(GALLERY)) gallery[name] = await layoutDiagram(draft); +writeFileSync( + new URL('gallery.layout.ts', outDir), + `// Generated by packages/layout/scripts/layout-samples.ts — do not edit.\nimport type { LaidOutDiagram } from '@stackmap/core';\n\nexport const galleryLayouts: Record = ${JSON.stringify(gallery, null, 2)};\n`, +); +console.log('wrote gallery.layout.ts'); diff --git a/packages/layout/src/index.ts b/packages/layout/src/index.ts index 67636ca..39daa18 100644 --- a/packages/layout/src/index.ts +++ b/packages/layout/src/index.ts @@ -2,10 +2,18 @@ import { createRequire } from 'node:module'; import ElkApi from 'elkjs/lib/elk-api.js'; import ElkBundled from 'elkjs/lib/elk.bundled.js'; import type { ElkExtendedEdge, ElkNode, ElkPort } from 'elkjs/lib/elk-api'; -import { cardSize, type DiagramDraft, type Direction, type LaidOutDiagram, type Point, type Rect } from '@stackmap/core'; +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'; + +export { layoutLanes } from './lanes'; /** Vertical space reserved at the top of a group for its label. */ export const GROUP_LABEL_BAND = 48; +/** Stage bands (architecture/dataflow phases): the label band and the padding around their nodes. */ +export const STAGE_LABEL_BAND = 44; +const STAGE_PAD = 20; +const COMPACT_GAP = { right: 72, max: 168 } as const; const GROUP_PREFIX = 'group:'; // elkjs's bundled build treats any runtime with a global `self` and no `document` as a web worker @@ -22,7 +30,7 @@ function createElk(): { elk: InstanceType; dispose: () => voi return { elk, dispose: () => elk.terminateWorker() }; } -const rootOptions = (direction: Direction): Record => ({ +const rootOptions = (direction: Direction, compact: boolean): Record => ({ 'elk.algorithm': 'layered', 'elk.direction': direction, 'elk.edgeRouting': 'ORTHOGONAL', @@ -32,7 +40,8 @@ const rootOptions = (direction: Direction): Record => ({ 'elk.spacing.nodeNode': '40', 'elk.spacing.edgeNode': '20', // The layer gap must fit an edge label pill, which is far wider than it is tall. - 'elk.layered.spacing.nodeNodeBetweenLayers': direction === 'RIGHT' ? '120' : '72', + // Full cards reserve a label pill in every gap; compact layouts start tight and widen for labels (compactLabels). + 'elk.layered.spacing.nodeNodeBetweenLayers': direction === 'RIGHT' ? String(compact ? COMPACT_GAP.right : 120) : '72', 'elk.layered.spacing.edgeNodeBetweenLayers': '24', 'elk.layered.nodePlacement.strategy': 'NETWORK_SIMPLEX', // Draft order is the author's intended stacking; ELK otherwise reorders ties freely. @@ -77,8 +86,20 @@ function ports(id: string, width: number, height: number, direction: Direction): const round = (n: number) => Math.round(n * 100) / 100; export async function layoutDiagram(draft: DiagramDraft): Promise { + if (isLaneKind(draft.kind)) return layoutLanes(draft); const direction = draft.direction ?? 'RIGHT'; - const root: ElkNode = { id: 'root', layoutOptions: rootOptions(direction), children: [], edges: [] }; + const compact = usesCompactCards(draft); + const variant = compact ? 'compact' : 'full'; + const stages = draft.phases?.length ? stagePartitions(draft) : null; + const root: ElkNode = { id: 'root', layoutOptions: rootOptions(direction, compact), children: [], edges: [] }; + if (stages) { + // Stages are ELK partitions: every node of stage i is laid out before (left of / above) stage i+1. + root.layoutOptions!['elk.partitioning.activate'] = 'true'; + // Room for the stage labels above (RIGHT) or beside (DOWN) the first node row. + root.layoutOptions!['elk.padding'] = direction === 'RIGHT' ? `[top=${40 + STAGE_LABEL_BAND},left=40,bottom=40,right=40]` : `[top=40,left=${40 + 160},bottom=40,right=40]`; + // A stage band pads its nodes on both sides of the gap: keep a visible gutter between neighbours. + root.layoutOptions!['elk.layered.spacing.nodeNodeBetweenLayers'] = String(Math.max(Number(root.layoutOptions!['elk.layered.spacing.nodeNodeBetweenLayers']), 2 * STAGE_PAD + 64)); + } const groupNodes = new Map(); for (const g of draft.groups ?? []) { @@ -106,12 +127,13 @@ export async function layoutDiagram(draft: DiagramDraft): Promise(); for (const n of draft.nodes) { nodeIds.add(n.id); - const { width, height } = cardSize(n.card); + const { width, height } = cardSize(n.card, variant); + const partition = stages?.get(n.id); containerOf(n.group, `Node '${n.id}'`).children!.push({ id: n.id, width, height, - layoutOptions: { 'elk.portConstraints': 'FIXED_POS' }, + layoutOptions: { 'elk.portConstraints': 'FIXED_POS', ...(partition === undefined ? {} : { 'elk.partitioning.partition': String(partition) }) }, ports: ports(n.id, width, height, direction), }); } @@ -124,16 +146,45 @@ export async function layoutDiagram(draft: DiagramDraft): Promise MAX_UNWRAPPED_WIDTH) { - result = await elk.layout({ ...structuredClone(root), layoutOptions: { ...root.layoutOptions, ...wrapOptions } }); + const run = async (gap?: number) => { + const opts = gap === undefined ? root.layoutOptions! : { ...root.layoutOptions, 'elk.layered.spacing.nodeNodeBetweenLayers': String(gap) }; + let result = await elk.layout({ ...structuredClone(root), layoutOptions: opts }); + // Wrapping would fold stages back onto each other. + if ((result.width ?? 0) > MAX_UNWRAPPED_WIDTH && !stages) result = await elk.layout({ ...structuredClone(root), layoutOptions: { ...opts, ...wrapOptions } }); + return collect(draft, result, direction, stages !== null); + }; + let laid = await run(); + if (!compact) return laid; + // Compact layouts start with tight layer gaps and widen them once if a label found no room to sit. + let spots = compactLabels(draft, laid); + if (spots.misfit > 0) { + laid = await run(Math.min(COMPACT_GAP.max, Math.max(Number(root.layoutOptions!['elk.layered.spacing.nodeNodeBetweenLayers']), spots.misfit + 24))); + spots = compactLabels(draft, laid); } + return { ...laid, labels: spots.labels }; } finally { dispose(); } +} +/** Widest label (px) that found no free spot on its route; 0 when all fit. Labels are placed either way. */ +function compactLabels(draft: DiagramDraft, laid: LaidOutDiagram): { labels: Record; misfit: number } { + const cards = Object.values(laid.nodes); + const taken: Rect[] = []; + const labels: Record = {}; + let misfit = 0; + const pending = draft.edges.filter((e) => e.label && laid.edges[e.id]); + for (const e of pending) { + const spot = findLabelSpot(laid.edges[e.id]!, e.label!, cards, taken); + if (spot) labels[e.id] = spot; + else misfit = Math.max(misfit, labelWidth(e.label!)); + } + for (const e of pending) if (!labels[e.id]) labels[e.id] = placeLabel(laid.edges[e.id]!, e.label!, cards, taken); + return { labels, misfit }; +} + +function collect(draft: DiagramDraft, result: ElkNode, direction: Direction, staged: boolean): LaidOutDiagram { const nodes: Record = {}; const groups: Record = {}; const edges: Record = {}; @@ -153,5 +204,53 @@ export async function layoutDiagram(draft: DiagramDraft): Promise { + const part = new Map(); + draft.phases!.forEach((p, i) => p.nodes?.forEach((n) => part.has(n) || part.set(n, i))); + for (let pass = 0; pass < draft.nodes.length; pass++) { + let changed = false; + for (const e of draft.edges) { + if (part.has(e.from) && !part.has(e.to)) { + part.set(e.to, part.get(e.from)!); + changed = true; + } + } + if (!changed) break; + } + // Partitions only order the top level; grouped nodes are left to the group's own layout. + const grouped = new Set(draft.nodes.filter((n) => n.group !== undefined).map((n) => n.id)); + for (const n of draft.nodes) if (!part.has(n.id)) part.set(n.id, 0); + for (const id of grouped) part.delete(id); + return part; +} + +/** One band per stage: its nodes' extent along the flow, the whole diagram across it, plus the label band. */ +function stageBands(draft: DiagramDraft, nodes: Record, direction: Direction): Record { + const out: Record = {}; + const all = Object.values(nodes); + if (!all.length) return out; + const top = Math.min(...all.map((r) => r.y)); + const bottom = Math.max(...all.map((r) => r.y + r.height)); + const left = Math.min(...all.map((r) => r.x)); + const right = Math.max(...all.map((r) => r.x + r.width)); + for (const p of draft.phases ?? []) { + const members = (p.nodes ?? []).flatMap((id) => nodes[id] ?? []); + if (!members.length) continue; + if (direction === 'RIGHT') { + const x = Math.min(...members.map((r) => r.x)) - STAGE_PAD; + const x2 = Math.max(...members.map((r) => r.x + r.width)) + STAGE_PAD; + out[p.id] = { x: round(x), y: round(top - STAGE_PAD - STAGE_LABEL_BAND), width: round(x2 - x), height: round(bottom - top + 2 * STAGE_PAD + STAGE_LABEL_BAND) }; + } else { + const y = Math.min(...members.map((r) => r.y)) - STAGE_PAD; + const y2 = Math.max(...members.map((r) => r.y + r.height)) + STAGE_PAD; + out[p.id] = { x: round(left - STAGE_PAD - 160), y: round(y), width: round(right - left + 2 * STAGE_PAD + 160), height: round(y2 - y) }; + } + } + return out; } diff --git a/packages/layout/src/labels.ts b/packages/layout/src/labels.ts new file mode 100644 index 0000000..1d353df --- /dev/null +++ b/packages/layout/src/labels.ts @@ -0,0 +1,66 @@ +import { measureText, type Point, type Rect } from '@stackmap/core'; + +// Edge label placement shared by the lane and compact ELK layouts: the viewer's pill (11px Geist, 8px padding +// each side, 20px tall) on a run of the route, clear of cards and of other labels. + +const LABEL_H = 20; +export const labelWidth = (text: string) => Math.ceil(measureText(text, 'sans400', 11)) + 16; +const round = (n: number) => Math.round(n * 100) / 100; + +const overlaps = (a: Rect, b: Rect) => a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height; + +/** + * The label goes on the longest run that holds its pill without touching a card or another label; along a run + * it may slide off the midpoint to clear a neighbour's pill. Horizontal runs are preferred. + */ +export function placeLabel(points: Point[], text: string, cards: Rect[], taken: Rect[]): Point { + return findLabelSpot(points, text, cards, taken) ?? claimFallback(points, text, taken); +} + +/** A spot that fits, claimed in `taken`; null when none does. */ +export function findLabelSpot(points: Point[], text: string, cards: Rect[], taken: Rect[]): Point | null { + const w = labelWidth(text); + const pillAt = (p: Point): Rect => ({ x: p.x - w / 2, y: p.y - LABEL_H / 2, width: w, height: LABEL_H }); + let best: { p: Point; score: number } | null = null; + for (let i = 1; i < points.length; i++) { + const a = points[i - 1]!; + const b = points[i]!; + const horizontal = a.y === b.y; + const len = Math.abs(a.x - b.x) + Math.abs(a.y - b.y); + const room = horizontal ? len >= w + 16 : len >= LABEL_H + 16; + if (!room) continue; + // On the line first; else just beside it: right then left of a vertical run, above then below a horizontal one + // (a pause/resume pair on parallel runs gets one pill above and one below). + const beside = horizontal ? LABEL_H / 2 + 3 : w / 2 + 4; + const offsets: [number, number][] = horizontal ? [[0, 0], [0, -beside], [0, beside]] : [[0, 0], [beside, 0], [-beside, 0]]; + search: for (const [dx, dy] of offsets) { + for (const t of [0.5, 0.35, 0.65, 0.2, 0.8]) { + const p = { x: round(a.x + (b.x - a.x) * t + dx), y: round(a.y + (b.y - a.y) * t + dy) }; + const pill = pillAt(p); + if (cards.some((r) => overlaps(pill, r)) || taken.some((r) => overlaps(pill, r))) continue; + // Longer and horizontal runs first; off-centre and off-line spots lose a little. + const score = (horizontal ? 2 : 1) * len - Math.abs(t - 0.5) * 40 - (dx || dy ? 30 : 0); + if (!best || score > best.score) best = { p, score }; + break search; + } + } + } + if (!best) return null; + taken.push(pillAt(best.p)); + return best.p; +} + +/** No spot fits: the middle of the longest run, overlapping whatever is there. */ +function claimFallback(points: Point[], text: string, taken: Rect[]): Point { + let best = { p: points[0]!, len: -1 }; + for (let i = 1; i < points.length; i++) { + const a = points[i - 1]!; + const b = points[i]!; + const len = Math.abs(a.x - b.x) + Math.abs(a.y - b.y); + if (len > best.len) best = { p: { x: round((a.x + b.x) / 2), y: round((a.y + b.y) / 2) }, len }; + } + const w = labelWidth(text); + taken.push({ x: best.p.x - w / 2, y: best.p.y - LABEL_H / 2, width: w, height: LABEL_H }); + return best.p; +} + diff --git a/packages/layout/src/lanes.ts b/packages/layout/src/lanes.ts new file mode 100644 index 0000000..6be6431 --- /dev/null +++ b/packages/layout/src/lanes.ts @@ -0,0 +1,201 @@ +import { assignColumns, backEdges, cardSize, COMPACT, type DiagramDraft, type DiagramEdge, type LaidOutDiagram, type Point, type Rect } from '@stackmap/core'; +import { labelWidth, placeLabel } from './labels'; + +export { assignColumns, backEdges } from '@stackmap/core'; +import { nudge, routeEdges, STUB, type PortChoice, type RouteRequest } from './route'; + +// Swimlane layout for workflow and lifecycle diagrams: lanes are full-width rows in draft order, columns +// follow the flow (longest path), and stackmap routes the edges itself (./route.ts). + +const PAD = 40; +/** Left rail of every lane, holding its label (wrapped to the rail). */ +export const LANE_HEAD = 144; +const LANE_PAD_X = 32; +const LANE_PAD_TOP = 20; +/** Room under a lane's cards for the channel same-lane back edges take. */ +const LANE_PAD_BOTTOM = 28; +const LANE_GAP = 16; +/** Gap between stacked cards: two port stubs must fit between them, or the facing ports drop out of the grid. */ +const STACK_GAP = 2 * STUB + 4; +const MIN_GUTTER = 64; +const MAX_GUTTER = 176; +/** Header band above the lanes, when there are phases. */ +export const PHASE_BAND = 40; +const PHASE_GAP = 8; +/** Group frames inside a lane: padding around members and the label band above them. */ +export const GROUP_PAD = 12; +export const GROUP_LABEL = 28; +/** Room left of a lifecycle start state for its initial marker. */ +export const START_MARK = 28; + + +const round = (n: number) => Math.round(n * 100) / 100; +const roundRect = (r: Rect): Rect => ({ x: round(r.x), y: round(r.y), width: round(r.width), height: round(r.height) }); + +function portChoices(sameLane: boolean, dCol: number, dLane: number, isBack: boolean): { sources: PortChoice[]; targets: PortChoice[] } { + const c = (side: PortChoice['side'], cost: number): PortChoice => ({ side, cost }); + if (isBack) { + if (sameLane) return { sources: [c('bottom', 0), c('top', 30), c('left', 60)], targets: [c('bottom', 0), c('top', 30), c('right', 60)] }; + return dLane > 0 + ? { sources: [c('bottom', 0), c('left', 20)], targets: [c('right', 0), c('top', 20), c('left', 40)] } + : { sources: [c('top', 0), c('left', 20)], targets: [c('right', 0), c('bottom', 20), c('left', 40)] }; + } + if (sameLane) return { sources: [c('right', 0), c('bottom', 80), c('top', 80)], targets: [c('left', 0), c('top', 80), c('bottom', 80)] }; + if (dLane > 0) + return dCol === 0 + ? { sources: [c('bottom', 0)], targets: [c('top', 0)] } + : { sources: [c('bottom', 0), c('right', 30)], targets: [c('top', 0), c('left', 30)] }; + return dCol === 0 + ? { sources: [c('top', 0)], targets: [c('bottom', 0)] } + : { sources: [c('top', 0), c('right', 30)], targets: [c('bottom', 0), c('left', 30)] }; +} + +export function layoutLanes(draft: DiagramDraft): LaidOutDiagram { + const lanes = draft.lanes ?? []; + const laneIndex = new Map(lanes.map((l, i) => [l.id, i])); + const nodes = draft.nodes.filter((n) => n.lane !== undefined && laneIndex.has(n.lane)); + const back = backEdges(draft); + const col = assignColumns(draft, back); + const size = new Map(nodes.map((n) => [n.id, cardSize(n.card, 'compact')])); + const cols = Math.max(0, ...nodes.map((n) => col.get(n.id)!)) + 1; + const cardW = Math.max(COMPACT.width, ...[...size.values()].map((s) => s.width)); + + // A gutter wide enough for the labels on edges between neighbouring columns of one lane, either way; a + // pause/resume pair side by side needs room for both. + const laneOf = new Map(nodes.map((n) => [n.id, n.lane!])); + const pairKey = (e: DiagramEdge) => [e.from, e.to].sort().join('|'); + const neighbourLabels = new Map(); + for (const e of draft.edges) { + if (!e.label || laneOf.get(e.from) !== laneOf.get(e.to) || Math.abs(col.get(e.to)! - col.get(e.from)!) !== 1) continue; + neighbourLabels.set(pairKey(e), (neighbourLabels.get(pairKey(e)) ?? 8) + labelWidth(e.label) + 16); + } + const gutter = Math.min(MAX_GUTTER, Math.max(MIN_GUTTER, ...neighbourLabels.values())); + + const hasStart = nodes.some((n) => n.type === 'start' && col.get(n.id) === 0); + const x0 = PAD + LANE_HEAD + (hasStart ? START_MARK : 0); + const colX = (c: number) => x0 + c * (cardW + gutter); + const bandX = PAD; + const bandW = colX(cols - 1) + cardW + LANE_PAD_X - PAD; + + const top = PAD + (draft.phases?.length ? PHASE_BAND + PHASE_GAP : 0); + const rects: Record = {}; + const laneRects: Record = {}; + const groupRects: Record = {}; + let y = top; + for (const lane of lanes) { + const members = nodes.filter((n) => n.lane === lane.id); + const grouped = (draft.groups ?? []).some((g) => members.some((n) => n.group === g.id)); + const contentTop = y + LANE_PAD_TOP + (grouped ? GROUP_LABEL + GROUP_PAD : 0); + // Stack row k of every column shares one centre line, so neighbours in a lane line up. + const cells = new Map(); + for (const n of members) cells.set(col.get(n.id)!, [...(cells.get(col.get(n.id)!) ?? []), n]); + const depth = Math.max(0, ...[...cells.values()].map((c) => c.length)); + const rowH = Array.from({ length: depth }, (_, k) => Math.max(0, ...[...cells.values()].map((c) => (c[k] ? size.get(c[k].id)!.height : 0)))); + const rowCenter: number[] = []; + let cursor = contentTop; + for (let k = 0; k < depth; k++) { + rowCenter.push(cursor + rowH[k]! / 2); + cursor += rowH[k]! + STACK_GAP; + } + for (const [c, cell] of cells) { + cell.forEach((n, k) => { + const s = size.get(n.id)!; + rects[n.id] = roundRect({ x: colX(c) + (cardW - s.width) / 2, y: rowCenter[k]! - s.height / 2, width: s.width, height: s.height }); + }); + } + // The rail label may wrap: keep an empty or one-row lane tall enough for two lines. + const contentBottom = Math.max(depth ? cursor - STACK_GAP : contentTop + 60, y + 72 - LANE_PAD_BOTTOM); + const bottom = contentBottom + (grouped ? GROUP_PAD : 0) + LANE_PAD_BOTTOM; + laneRects[lane.id] = roundRect({ x: bandX, y, width: bandW, height: bottom - y }); + y = bottom + LANE_GAP; + } + + for (const g of draft.groups ?? []) { + const members = nodes.filter((n) => n.group === g.id).map((n) => rects[n.id]!); + if (!members.length) continue; + const x = Math.min(...members.map((r) => r.x)) - GROUP_PAD; + const yTop = Math.min(...members.map((r) => r.y)) - GROUP_PAD - GROUP_LABEL; + const right = Math.max(...members.map((r) => r.x + r.width)) + GROUP_PAD; + const bottom = Math.max(...members.map((r) => r.y + r.height)) + GROUP_PAD; + groupRects[g.id] = roundRect({ x, y: yTop, width: right - x, height: bottom - yTop }); + } + + const phaseRects: Record = {}; + for (const p of draft.phases ?? []) { + const cs = (p.nodes ?? []).filter((n) => col.has(n) && laneOf.has(n)).map((n) => col.get(n)!); + if (!cs.length) continue; + const left = colX(Math.min(...cs)) - gutter / 2 + 8; + const right = colX(Math.max(...cs)) + cardW + gutter / 2 - 8; + phaseRects[p.id] = roundRect({ x: Math.max(left, bandX + 8), y: PAD, width: Math.min(right, bandX + bandW - 8) - Math.max(left, bandX + 8), height: PHASE_BAND }); + } + + // Channels: gutter centres, the lane margins, and in every lane its label strip and bottom padding. + const xs = [x0 - (hasStart ? START_MARK : 0) - LANE_PAD_X / 2, bandX + bandW - LANE_PAD_X / 2]; + for (let c = 0; c < cols - 1; c++) xs.push(colX(c) + cardW + gutter / 2); + const ys: number[] = []; + for (const r of Object.values(laneRects)) ys.push(r.y + r.height - LANE_PAD_BOTTOM / 2, r.y - LANE_GAP / 2, r.y + r.height + LANE_GAP / 2); + // Lane rails, group labels and start markers are text and marks that edges must not run over. + const obstacles: Rect[] = []; + for (const lane of lanes) { + const r = laneRects[lane.id]!; + // The rail label (the viewer caps it at LANE_HEAD - 32) and no more: column 0's left stubs sit just past it. + obstacles.push({ x: r.x, y: r.y, width: LANE_HEAD - 28, height: r.height }); + } + for (const g of draft.groups ?? []) { + const r = groupRects[g.id]; + if (r) obstacles.push({ x: r.x, y: r.y + 4, width: labelWidth(g.label) + 24, height: GROUP_LABEL - 8 }); + } + for (const n of nodes) { + if (n.type !== 'start') continue; + const r = rects[n.id]!; + obstacles.push({ x: r.x - START_MARK, y: r.y + r.height / 2 - 8, width: START_MARK, height: 16 }); + } + + const requests: RouteRequest[] = draft.edges + .filter((e) => rects[e.from] && rects[e.to]) + .map((e) => { + // A self-transition leaves on the right and comes back in underneath (or on top), around the corner. + if (e.from === e.to) + return { id: e.id, from: e.from, to: e.to, tone: `${e.tone ?? ''}:self`, sources: [{ side: 'right' as const, cost: 0 }], targets: [{ side: 'bottom' as const, cost: 0 }, { side: 'top' as const, cost: 20 }], order: 2e6 }; + const dLane = laneIndex.get(laneOf.get(e.to)!)! - laneIndex.get(laneOf.get(e.from)!)!; + const dCol = col.get(e.to)! - col.get(e.from)!; + const isBack = back.has(e.id) || dCol < 0; + const choice = portChoices(dLane === 0, dCol, dLane, isBack); + // A start state's left side holds its initial marker. + const blockLeft = (id: string) => draft.nodes.find((n) => n.id === id)?.type === 'start'; + return { + id: e.id, + from: e.from, + to: e.to, + tone: `${e.tone ?? ''}${e.kind === 'return' ? ':return' : ''}`, + sources: choice.sources.filter((c) => !(c.side === 'left' && blockLeft(e.from))), + targets: choice.targets.filter((c) => !(c.side === 'left' && blockLeft(e.to))), + order: (isBack ? 1e6 : 0) + Math.abs(dCol) * 1000 + Math.abs(dLane), + }; + }) + .sort((a, b) => a.order - b.order); + const ends = Object.fromEntries(draft.edges.map((e) => [e.id, { from: e.from, to: e.to }])); + const routed = nudge(routeEdges(rects, requests, { xs, ys }, obstacles), ends); + + const edges: Record = {}; + for (const e of draft.edges) { + if (routed[e.id]) edges[e.id] = routed[e.id]!.map((p) => ({ x: round(p.x), y: round(p.y) })); + } + + const cards = Object.values(rects); + const taken: Rect[] = []; + const labels: Record = {}; + for (const e of draft.edges) if (e.label && edges[e.id]) labels[e.id] = placeLabel(edges[e.id]!, e.label, cards, taken); + + const height = y - LANE_GAP + PAD; + return { + draft, + nodes: rects, + groups: groupRects, + edges, + lanes: laneRects, + phases: phaseRects, + labels, + bounds: { width: round(bandX + bandW + PAD), height: round(height) }, + }; +} diff --git a/packages/layout/src/route.ts b/packages/layout/src/route.ts new file mode 100644 index 0000000..85f466d --- /dev/null +++ b/packages/layout/src/route.ts @@ -0,0 +1,445 @@ +import type { Point, Rect } from '@stackmap/core'; + +// Orthogonal connector routing for the lane layouts, where ELK doesn't place the nodes. A sparse visibility +// grid (the caller's channel lines plus every node's centre and port stubs), A* over (point, heading) with a +// bend cost, then nudging so unrelated edges sharing a channel run side by side instead of on top of each other. + +export type Side = 'left' | 'right' | 'top' | 'bottom'; +export interface PortChoice { + side: Side; + /** added to the route's cost when this side is used */ + cost: number; +} +export interface RouteRequest { + id: string; + from: string; + to: string; + /** edges of different tones don't share a port: a trunk would hide which colour goes where */ + tone?: string; + sources: PortChoice[]; + targets: PortChoice[]; +} + +/** Distance from a card edge to the first bend: arrowheads and handle dots need a straight run. */ +export const STUB = 20; +/** Clearance kept between a route and any card. */ +const MARGIN = 12; +const BEND = 48; +/** Extra cost per px for running along a channel another, unrelated edge already uses. */ +const SHARED = 1.5; +const TRACK = 8; +/** Cost of a side the request didn't ask for: a last resort, so every edge gets an orthogonal route. */ +const ANY_SIDE = 400; +/** Cost of leaving through a side an arrow already enters, or entering one an edge leaves: reads as two-way. */ +const OPPOSED = 5000; +const SIDES: Side[] = ['right', 'bottom', 'left', 'top']; +/** Cost of sharing a port slot with an edge of another tone: another slot is almost always better. */ +const MIXED_TONE = 300; +/** Shortest straight run a nudge may leave at a port (arrowhead + gap). */ +const MIN_RUN = 12; + +type Dir = 0 | 1 | 2 | 3; // right, down, left, up +const DX = [1, 0, -1, 0]; +const DY = [0, 1, 0, -1]; +const SIDE_DIR: Record = { right: 0, bottom: 1, left: 2, top: 3 }; + +const round = (n: number) => Math.round(n * 100) / 100; + +/** Port slots per side: the midpoint, then one either side, so edges of different tones leave separately. */ +const SLOTS = [0, -1, 1, -2, 2] as const; +type Slot = (typeof SLOTS)[number]; +// ±2 slots stay on the card: 2 × 28 < 176 / 2, and 2 × 12 < 60 / 2. +const SLOT_GAP = { horizontal: 28, vertical: 12 }; +/** Cost of an off-centre slot (per step out): used only when the midpoint is taken by an edge that can't share it. */ +const SLOT_COST = 24; + +export function portPoint(r: Rect, side: Side, slot: Slot = 0): Point { + const along = slot * (side === 'top' || side === 'bottom' ? SLOT_GAP.horizontal : SLOT_GAP.vertical); + switch (side) { + case 'left': + return { x: r.x, y: r.y + r.height / 2 + along }; + case 'right': + return { x: r.x + r.width, y: r.y + r.height / 2 + along }; + case 'top': + return { x: r.x + r.width / 2 + along, y: r.y }; + case 'bottom': + return { x: r.x + r.width / 2 + along, y: r.y + r.height }; + } +} +const stubPoint = (r: Rect, side: Side, slot: Slot = 0): Point => { + const p = portPoint(r, side, slot); + const d = SIDE_DIR[side]; + return { x: p.x + DX[d]! * STUB, y: p.y + DY[d]! * STUB }; +}; + +const inflate = (r: Rect, m: number): Rect => ({ x: r.x - m, y: r.y - m, width: r.width + 2 * m, height: r.height + 2 * m }); +const inside = (p: Point, r: Rect) => p.x > r.x && p.x < r.x + r.width && p.y > r.y && p.y < r.y + r.height; +/** Whether an axis-aligned segment passes through the rectangle's interior. */ +function crosses(a: Point, b: Point, r: Rect): boolean { + const x0 = Math.min(a.x, b.x); + const x1 = Math.max(a.x, b.x); + const y0 = Math.min(a.y, b.y); + const y1 = Math.max(a.y, b.y); + return x1 > r.x && x0 < r.x + r.width && y1 > r.y && y0 < r.y + r.height; +} + +class Heap { + private items: [number, number][] = []; + get size() { + return this.items.length; + } + push(cost: number, state: number) { + const a = this.items; + a.push([cost, state]); + let i = a.length - 1; + while (i > 0) { + const p = (i - 1) >> 1; + if (a[p]![0] <= a[i]![0]) break; + [a[p], a[i]] = [a[i]!, a[p]!]; + i = p; + } + } + pop(): [number, number] { + const a = this.items; + const top = a[0]!; + const last = a.pop()!; + if (a.length) { + a[0] = last; + let i = 0; + for (;;) { + const l = 2 * i + 1; + const r = l + 1; + let m = i; + if (l < a.length && a[l]![0] < a[m]![0]) m = l; + if (r < a.length && a[r]![0] < a[m]![0]) m = r; + if (m === i) break; + [a[m], a[i]] = [a[i]!, a[m]!]; + i = m; + } + } + return top; + } +} + +interface Grid { + pts: Point[]; + /** neighbour point index per direction, -1 when blocked */ + next: Int32Array; + /** search scratch, reused across searches: a state's dist/prev are valid when its stamp is the current search's */ + dist: Float64Array; + prev: Int32Array; + stamp: Int32Array; + generation: number; +} + +function buildGrid(xs: number[], ys: number[], blocked: Rect[]): Grid { + const X = [...new Set(xs.map(round))].sort((a, b) => a - b); + const Y = [...new Set(ys.map(round))].sort((a, b) => a - b); + // Per row and per column, only the obstacles that line crosses: keeps the build near-linear in grid size. + const rowHits = Y.map((y) => blocked.filter((r) => y > r.y && y < r.y + r.height)); + const colHits = X.map((x) => blocked.filter((r) => x > r.x && x < r.x + r.width)); + const pts: Point[] = []; + const index = new Int32Array(X.length * Y.length).fill(-1); + for (let j = 0; j < Y.length; j++) { + const hits = rowHits[j]!; + for (let i = 0; i < X.length; i++) { + const x = X[i]!; + if (hits.some((r) => x > r.x && x < r.x + r.width)) continue; + index[j * X.length + i] = pts.length; + pts.push({ x, y: Y[j]! }); + } + } + const next = new Int32Array(pts.length * 4).fill(-1); + const link = (a: number, b: number, dir: Dir, along: Rect[]) => { + const [p, q] = [pts[a]!, pts[b]!]; + if (along.some((r) => crosses(p, q, r))) return; + next[a * 4 + dir] = b; + next[b * 4 + ((dir + 2) % 4)] = a; + }; + for (let j = 0; j < Y.length; j++) { + let prev = -1; + for (let i = 0; i < X.length; i++) { + const k = index[j * X.length + i]!; + if (k < 0) { + prev = -1; + continue; + } + if (prev >= 0) link(prev, k, 0, rowHits[j]!); + prev = k; + } + } + for (let i = 0; i < X.length; i++) { + let prev = -1; + for (let j = 0; j < Y.length; j++) { + const k = index[j * X.length + i]!; + if (k < 0) { + prev = -1; + continue; + } + if (prev >= 0) link(prev, k, 1, colHits[i]!); + prev = k; + } + } + const states = pts.length * 4; + return { pts, next, dist: new Float64Array(states), prev: new Int32Array(states), stamp: new Int32Array(states), generation: 0 }; +} + +interface Used { + from: string; + to: string; +} +/** Numeric key of an undirected grid step (point indexes stay far below 2^26). */ +const stepKey = (a: number, b: number) => (a < b ? a * 67108864 + b : b * 67108864 + a); +/** Cost of touching a point another, unrelated edge already runs through (a crossing or a merge). */ +const CROSS = 40; +/** How far past its two cards a route's first search looks. */ +const REGION = 360; + +interface Port { + side: Side; + slot: Slot; + cost: number; +} + +/** + * Routes every request around `nodes` (and extra `obstacles`, e.g. labels), using `xs`/`ys` as channel lines. + * Returns each edge's polyline from port to port, first point on the source card, last on the target card. + */ +export function routeEdges( + nodes: Record, + requests: RouteRequest[], + channels: { xs: number[]; ys: number[] }, + obstacles: Rect[] = [], +): Record { + const xs = [...channels.xs]; + const ys = [...channels.ys]; + for (const r of Object.values(nodes)) { + for (const slot of SLOTS) { + const top = stubPoint(r, 'top', slot); + const left = stubPoint(r, 'left', slot); + xs.push(top.x); + ys.push(left.y); + } + xs.push(r.x - STUB, r.x + r.width + STUB); + ys.push(r.y - STUB, r.y + r.height + STUB); + } + const blocked = [...Object.values(nodes).map((r) => inflate(r, MARGIN)), ...obstacles]; + const grid = buildGrid(xs, ys, blocked); + const at = new Map(grid.pts.map((p, i) => [`${p.x},${p.y}`, i])); + const find = (p: Point) => at.get(`${round(p.x)},${round(p.y)}`); + const usedSteps = new Map(); + const usedPoints = new Map(); + const out: Record = {}; + + // Who uses each port slot: edges may share one only in the same direction and tone (a trunk). + // Two edges between the same two cards never share one (they would draw as one line with two labels). + const slotUse = new Map; pairs: Set }>(); + const ports = (choices: PortChoice[], node: string, role: 'in' | 'out', tone: string, pair: string): Port[] => { + const listed = new Map(choices.map((c) => [c.side, c.cost])); + return SIDES.flatMap((side) => + SLOTS.map((slot) => { + const use = slotUse.get(`${node}:${side}:${slot}`); + const clash = !use ? 0 : use.role !== role ? OPPOSED : use.pairs.has(pair) || !use.tones.has(tone) ? MIXED_TONE : 0; + return { side, slot, cost: (listed.get(side) ?? ANY_SIDE) + Math.abs(slot) * SLOT_COST + clash }; + }), + ); + }; + const claim = (node: string, port: { side: Side; slot: Slot }, role: 'in' | 'out', tone: string, pair: string) => { + const key = `${node}:${port.side}:${port.slot}`; + const use = slotUse.get(key) ?? { role, tones: new Set(), pairs: new Set() }; + use.tones.add(tone); + use.pairs.add(pair); + slotUse.set(key, use); + }; + + for (const req of requests) { + const src = nodes[req.from]!; + const dst = nodes[req.to]!; + const tone = req.tone ?? ''; + const pair = `${req.from}>${req.to}`; + // A self-loop leaves only by the sides it asks for, so the card's other sides remain to come back in by. + const self = req.from === req.to; + const leaveBy = self ? new Set(req.sources.map((c) => c.side)) : null; + const starts = ports(req.sources, req.from, 'out', tone, pair).filter((c) => !leaveBy || leaveBy.has(c.side)).flatMap((c) => { + const k = find(stubPoint(src, c.side, c.slot)); + return k === undefined ? [] : [{ ...c, k }]; + }); + const goals = new Map(); + // A goal on a start point would be a route of no length: only a self-loop can offer one, and it must go round. + const startAt = new Set(starts.map((c) => c.k)); + for (const c of ports(req.targets, req.to, 'in', tone, pair)) { + const k = find(stubPoint(dst, c.side, c.slot)); + if (k !== undefined && !startAt.has(k) && !leaveBy?.has(c.side)) goals.set(k, [...(goals.get(k) ?? []), c]); + } + // Fan-out from one card and fan-in to one card share a trunk on purpose; only unrelated edges pay. + const unrelated = (list: Used[] | undefined) => !!list?.some((u) => u.from !== req.from && u.to !== req.to); + const extra = (a: number, b: number, len: number) => (unrelated(usedSteps.get(stepKey(a, b))) ? SHARED * len : 0) + (unrelated(usedPoints.get(b)) ? CROSS : 0); + // Search near the two cards first (most routes stay local); only a route that needs more room gets the grid. + const box = { x0: Math.min(src.x, dst.x) - REGION, y0: Math.min(src.y, dst.y) - REGION, x1: Math.max(src.x + src.width, dst.x + dst.width) + REGION, y1: Math.max(src.y + src.height, dst.y + dst.height) + REGION }; + const path = search(grid, starts, goals, extra, box) ?? search(grid, starts, goals, extra); + if (!path) { + // Boxed in (every stub blocked): out of the right side, into the left, square at both ends. It may cross a + // card, but it is never diagonal and never meets a card along its side. + const a = portPoint(src, 'right'); + const b = portPoint(dst, 'left'); + out[req.id] = [a, { x: a.x + STUB, y: a.y }, { x: a.x + STUB, y: b.y }, { x: b.x - STUB, y: b.y }, b]; + continue; + } + const { points: gridPath, src: from, dst: to } = path; + claim(req.from, from, 'out', tone, pair); + claim(req.to, to, 'in', tone, pair); + const me = { from: req.from, to: req.to }; + gridPath.forEach((k, i) => { + usedPoints.set(k, [...(usedPoints.get(k) ?? []), me]); + if (i) usedSteps.set(stepKey(gridPath[i - 1]!, k), [...(usedSteps.get(stepKey(gridPath[i - 1]!, k)) ?? []), me]); + }); + out[req.id] = [portPoint(src, from.side, from.slot), ...gridPath.map((k) => grid.pts[k]!), portPoint(dst, to.side, to.slot)]; + } + return out; +} + +function search( + grid: Grid, + starts: (Port & { k: number })[], + goals: Map, + extra: (a: number, b: number, len: number) => number, + box?: { x0: number; y0: number; x1: number; y1: number }, +): { points: number[]; src: Port; dst: Port } | null { + if (!starts.length || !goals.size) return null; + const gen = ++grid.generation; + const { stamp } = grid; + const distOf = (st: number) => (stamp[st] === gen ? grid.dist[st]! : Infinity); + const set = (st: number, d: number, from: number) => { + stamp[st] = gen; + grid.dist[st] = d; + grid.prev[st] = from; + }; + const srcOf = new Map(); + const heap = new Heap(); + const goalPts = [...goals.keys()].map((k) => grid.pts[k]!); + const h = (k: number) => { + const p = grid.pts[k]!; + let best = Infinity; + for (const g of goalPts) best = Math.min(best, Math.abs(g.x - p.x) + Math.abs(g.y - p.y)); + return best; + }; + for (const s of starts) { + const state = s.k * 4 + SIDE_DIR[s.side]; + if (s.cost < distOf(state)) { + set(state, s.cost, -1); + srcOf.set(state, s); + heap.push(s.cost + h(s.k), state); + } + } + let best: { state: number; cost: number; port: Port } | null = null; + while (heap.size) { + const [f, state] = heap.pop(); + if (best && f >= best.cost) break; + const k = state >> 2; + const dir = (state & 3) as Dir; + const g = distOf(state); + if (f - h(k) > g + 1e-6) continue; + for (const c of goals.get(k) ?? []) { + // Arriving heading into the card costs nothing; any other heading needs one more bend. + const into = (SIDE_DIR[c.side] + 2) % 4; + const total = g + c.cost + (dir === into ? 0 : BEND); + if (!best || total < best.cost) best = { state, cost: total, port: c }; + } + for (let d = 0 as Dir; d < 4; d = (d + 1) as Dir) { + if (d === (dir + 2) % 4) continue; // no U-turns in place + const m = grid.next[k * 4 + d]!; + if (m < 0) continue; + const a = grid.pts[k]!; + const b = grid.pts[m]!; + if (box && (b.x < box.x0 || b.x > box.x1 || b.y < box.y0 || b.y > box.y1)) continue; + const len = Math.abs(a.x - b.x) + Math.abs(a.y - b.y); + const cost = g + len + extra(k, m, len) + (d === dir ? 0 : BEND); + const next = m * 4 + d; + if (cost < distOf(next) - 1e-9) { + set(next, cost, state); + heap.push(cost + h(m), next); + } + } + } + if (!best) return null; + const points: number[] = []; + let s = best.state; + let first = s; + while (s >= 0) { + points.push(s >> 2); + first = s; + s = grid.prev[s]!; + } + points.reverse(); + return { points, src: srcOf.get(first)!, dst: best.port }; +} + +/** Drops repeated points and the middle of straight runs. */ +export function simplify(points: Point[]): Point[] { + const pts = points.filter((p, i) => i === 0 || p.x !== points[i - 1]!.x || p.y !== points[i - 1]!.y); + return pts.filter((p, i) => { + if (i === 0 || i === pts.length - 1) return true; + const a = pts[i - 1]!; + const b = pts[i + 1]!; + return !((a.x === p.x && p.x === b.x) || (a.y === p.y && p.y === b.y)); + }); +} + +interface Seg { + edge: string; + i: number; + /** the fixed coordinate: x for a vertical segment, y for a horizontal one */ + at: number; + lo: number; + hi: number; + src: string; + dst: string; +} + +/** + * Spreads unrelated edges that run along the same line over parallel tracks, TRACK px apart. Segments of + * edges that share a source or a target stay merged (a trunk). Segments on a port never move, and a move + * never shortens a port's straight run below MIN_RUN, so edges still meet their cards square. + */ +export function nudge(routes: Record, ends: Record): Record { + const out: Record = Object.fromEntries(Object.entries(routes).map(([k, v]) => [k, simplify(v).map((p) => ({ ...p }))])); + for (const vertical of [true, false]) { + const lines = new Map(); + for (const [edge, pts] of Object.entries(out)) { + for (let i = 1; i + 1 < pts.length - 1; i++) { + const a = pts[i]!; + const b = pts[i + 1]!; + if (vertical ? a.x !== b.x : a.y !== b.y) continue; + const at = vertical ? a.x : a.y; + const [lo, hi] = vertical ? [Math.min(a.y, b.y), Math.max(a.y, b.y)] : [Math.min(a.x, b.x), Math.max(a.x, b.x)]; + const seg = { edge, i, at, lo, hi, src: ends[edge]!.from, dst: ends[edge]!.to }; + lines.set(at, [...(lines.get(at) ?? []), seg]); + } + } + for (const segs of lines.values()) { + if (segs.length < 2) continue; + // Two overlapping segments may share a track only as a trunk: the same edge, or one source (or one target) + // with different far ends. Tracks are handed out greedily, so sharing is never transitive. + const overlap = (a: Seg, b: Seg) => Math.min(a.hi, b.hi) - Math.max(a.lo, b.lo) > 0.5; + const mayShare = (a: Seg, b: Seg) => a.edge === b.edge || ((a.src === b.src) !== (a.dst === b.dst)); + const track = new Map(); + for (const seg of segs) { + let t = 0; + while (segs.some((o) => track.get(o) === t && overlap(o, seg) && !mayShare(o, seg))) t++; + track.set(seg, t); + } + for (const seg of segs) { + const t = track.get(seg)!; + if (!t) continue; + const off = (t % 2 ? 1 : -1) * Math.ceil(t / 2) * TRACK; + const pts = out[seg.edge]!; + const axis = vertical ? 'x' : 'y'; + // Moving a point next to a port stretches or shrinks that port's run; keep room for the arrowhead. + const run = (j: number, port: number) => Math.abs(pts[j]![axis] + off - pts[port]![axis]); + if ((seg.i === 1 && run(1, 0) < MIN_RUN) || (seg.i + 1 === pts.length - 2 && run(seg.i + 1, pts.length - 1) < MIN_RUN)) continue; + for (const j of [seg.i, seg.i + 1]) pts[j]![axis] = round(seg.at + off); + } + } + } + return Object.fromEntries(Object.entries(out).map(([k, v]) => [k, simplify(v)])); +} diff --git a/packages/layout/test/fuzz.test.ts b/packages/layout/test/fuzz.test.ts new file mode 100644 index 0000000..09c8b58 --- /dev/null +++ b/packages/layout/test/fuzz.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import type { DiagramDraft, Point, Rect } from '@stackmap/core'; +import { layoutLanes } from '../src/lanes'; + +// Property test: random workflows and lifecycles (tones, returns, async, labels, tags, phases, groups, self-loops, +// parallel edges) must always lay out into geometry the viewer can draw honestly. + +function generator(seed: number) { + const rnd = () => ((seed = (seed * 1103515245 + 12345) & 0x7fffffff), seed / 0x7fffffff); + const pick = (a: readonly T[]) => a[Math.floor(rnd() * a.length)]!; + return (): DiagramDraft => { + const lanes = Array.from({ length: 2 + Math.floor(rnd() * 4) }, (_, i) => ({ id: `l${i}`, label: `Lane ${i}` })); + const n = 3 + Math.floor(rnd() * 14); + const lifecycle = rnd() < 0.3; + const types = lifecycle ? (['start', 'active', 'waiting', 'decision', 'success', 'failure'] as const) : (['service', 'client', 'queue', 'security'] as const); + const nodes = Array.from({ length: n }, (_, i) => ({ id: `n${i}`, type: pick(types), lane: pick(lanes).id, card: { title: `N${i}`, ...(rnd() < 0.3 ? { tag: 'tag' } : {}) } })); + const edges = Array.from({ length: Math.floor(rnd() * n * 2) }, (_, i) => { + const r = rnd(); + return { + id: `e${i}`, + from: pick(nodes).id, + to: pick(nodes).id, + ...(rnd() < 0.3 ? { label: pick(['ok', 'retry', 'needs approval', 'timeout']) } : {}), + ...(r < 0.15 ? { kind: 'return' as const } : r < 0.3 ? { kind: 'async' as const } : {}), + ...(rnd() < 0.4 ? { tone: pick(['main', 'security', 'error'] as const) } : {}), + }; + }); + const d: DiagramDraft = { kind: lifecycle ? 'lifecycle' : 'workflow', title: 'fuzz', lanes, nodes, edges }; + if (rnd() < 0.4) { + const per = Math.ceil(n / 3); + d.phases = [0, 1, 2].map((i) => ({ id: `p${i}`, label: `P${i}`, nodes: nodes.slice(i * per, (i + 1) * per).map((x) => x.id) })).filter((p) => p.nodes.length); + } + return d; + }; +} + +const inside = (p: Point, r: Rect) => p.x > r.x + 0.5 && p.x < r.x + r.width - 0.5 && p.y > r.y + 0.5 && p.y < r.y + r.height - 0.5; +const crosses = (a: Point, b: Point, r: Rect) => + Math.max(a.x, b.x) > r.x + 0.5 && Math.min(a.x, b.x) < r.x + r.width - 0.5 && Math.max(a.y, b.y) > r.y + 0.5 && Math.min(a.y, b.y) < r.y + r.height - 0.5; + +describe('lane layout properties', () => { + const next = generator(20260930); + const cases = Array.from({ length: 150 }, next); + + it.each(cases.map((d, i) => [i, d] as const))('random diagram %i lays out soundly', (_i, d) => { + const out = layoutLanes(d); + expect(Number.isFinite(out.bounds.width) && Number.isFinite(out.bounds.height)).toBe(true); + // Columns stay dense: the diagram is never wider than one column per node. + expect(out.bounds.width).toBeLessThan(144 + d.nodes.length * (176 + 176) + 200); + const ports = new Map>(); + for (const e of d.edges) { + const pts = out.edges[e.id]!; + for (let i = 1; i < pts.length; i++) expect(pts[i]!.x === pts[i - 1]!.x || pts[i]!.y === pts[i - 1]!.y, `${e.id} is orthogonal`).toBe(true); + for (const [id, r] of Object.entries(out.nodes)) + for (let i = 1; i < pts.length; i++) { + const own = (i === 1 && id === e.from) || (i === pts.length - 1 && id === e.to); + if (!own) expect(crosses(pts[i - 1]!, pts[i]!, r), `${e.id} segment ${i} crosses ${id}`).toBe(false); + } + for (const [role, p] of [['out', pts[0]!], ['in', pts.at(-1)!]] as const) { + const key = `${p.x},${p.y}`; + ports.set(key, (ports.get(key) ?? new Set()).add(role)); + } + } + // No point is both where an arrow arrives and where another edge leaves: that would read as two-way. + expect([...ports].filter(([, roles]) => roles.size > 1).map(([k]) => k)).toEqual([]); + for (const [id, p] of Object.entries(out.labels ?? {})) for (const [n, r] of Object.entries(out.nodes)) expect(inside(p, r), `label ${id} on ${n}`).toBe(false); + }); +}); diff --git a/packages/layout/test/lanes.test.ts b/packages/layout/test/lanes.test.ts new file mode 100644 index 0000000..e5f8c66 --- /dev/null +++ b/packages/layout/test/lanes.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it } from 'vitest'; +import type { DiagramDraft, LaidOutDiagram, Point, Rect } from '@stackmap/core'; +import { GALLERY } from '@stackmap/core/gallery'; +import { assignColumns, backEdges } from '../src/lanes'; +import { labelWidth } from '../src/labels'; +import { layoutDiagram } from '../src/index'; + +const inside = (p: Point, r: Rect, pad = 0) => p.x > r.x + pad && p.x < r.x + r.width - pad && p.y > r.y + pad && p.y < r.y + r.height - pad; +const overlap = (a: Rect, b: Rect) => a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height; +const onBoundary = (p: Point, r: Rect) => + p.x >= r.x - 0.5 && p.x <= r.x + r.width + 0.5 && p.y >= r.y - 0.5 && p.y <= r.y + r.height + 0.5 && !inside(p, r, 0.5); +/** Whether an axis-aligned segment runs through the rectangle's interior. */ +const crosses = (a: Point, b: Point, r: Rect) => + Math.max(a.x, b.x) > r.x + 0.5 && Math.min(a.x, b.x) < r.x + r.width - 0.5 && Math.max(a.y, b.y) > r.y + 0.5 && Math.min(a.y, b.y) < r.y + r.height - 0.5; + +const laid = await Promise.all(Object.entries(GALLERY).map(async ([name, d]) => [name, d, await layoutDiagram(d)] as const)); + +describe.each(laid)('gallery sample %s', (_name, draft: DiagramDraft, out: LaidOutDiagram) => { + it('places no card on another', () => { + const rects = Object.entries(out.nodes); + for (let i = 0; i < rects.length; i++) + for (let j = i + 1; j < rects.length; j++) expect(overlap(rects[i]![1], rects[j]![1]), `${rects[i]![0]} × ${rects[j]![0]}`).toBe(false); + }); + + it('routes every edge orthogonally from its source card to its target card, through no card', () => { + for (const e of draft.edges) { + const pts = out.edges[e.id]!; + expect(pts.length, e.id).toBeGreaterThanOrEqual(2); + for (let i = 1; i < pts.length; i++) expect(pts[i]!.x === pts[i - 1]!.x || pts[i]!.y === pts[i - 1]!.y, `${e.id} segment ${i}`).toBe(true); + expect(onBoundary(pts[0]!, out.nodes[e.from]!), `${e.id} starts on ${e.from}`).toBe(true); + expect(onBoundary(pts.at(-1)!, out.nodes[e.to]!), `${e.id} ends on ${e.to}`).toBe(true); + for (const [id, r] of Object.entries(out.nodes)) { + // The first and last segments leave and enter their own cards; everything else stays outside every card. + for (let i = 1; i < pts.length; i++) { + const own = (i === 1 && id === e.from) || (i === pts.length - 1 && id === e.to); + if (!own) expect(crosses(pts[i - 1]!, pts[i]!, r), `${e.id} segment ${i} crosses ${id}`).toBe(false); + } + } + } + }); + + it('keeps edge labels off the cards', () => { + for (const [id, p] of Object.entries(out.labels ?? {})) for (const [n, r] of Object.entries(out.nodes)) expect(inside(p, r), `${id} label on ${n}`).toBe(false); + }); + + it('puts every lane node inside its lane, and lanes in draft order', () => { + if (!out.lanes) return; + for (const n of draft.nodes) { + const lane = out.lanes[n.lane!]!; + const r = out.nodes[n.id]!; + expect(r.y >= lane.y && r.y + r.height <= lane.y + lane.height, `${n.id} in ${n.lane}`).toBe(true); + } + const ys = draft.lanes!.map((l) => out.lanes![l.id]!.y); + expect([...ys].sort((a, b) => a - b)).toEqual(ys); + }); + + it('is deterministic', async () => { + expect(await layoutDiagram(draft)).toEqual(out); + }); +}); + +const lanes = (over: Partial): DiagramDraft => ({ + kind: 'workflow', + title: 't', + lanes: [ + { id: 'a', label: 'A' }, + { id: 'b', label: 'B' }, + { id: 'c', label: 'C' }, + ], + nodes: [], + edges: [], + ...over, +}); +const n = (id: string, lane: string) => ({ id, type: 'service' as const, lane, card: { title: id } }); + +describe('lane columns', () => { + it('steps right within a lane and drops straight down across lanes', () => { + const d = lanes({ + nodes: [n('x', 'a'), n('y', 'a'), n('z', 'b')], + edges: [ + { id: 'xy', from: 'x', to: 'y' }, + { id: 'yz', from: 'y', to: 'z' }, + ], + }); + expect(Object.fromEntries(assignColumns(d, backEdges(d)))).toEqual({ x: 0, y: 1, z: 1 }); + }); + + it('breaks cycles and ignores return edges', () => { + const d = lanes({ + nodes: [n('x', 'a'), n('y', 'a'), n('z', 'a')], + edges: [ + { id: 'xy', from: 'x', to: 'y' }, + { id: 'yz', from: 'y', to: 'z' }, + { id: 'zx', from: 'z', to: 'x' }, + { id: 'zy', from: 'z', to: 'y', kind: 'return' }, + ], + }); + expect([...backEdges(d)].sort()).toEqual(['zx', 'zy']); + expect(Object.fromEntries(assignColumns(d, backEdges(d)))).toEqual({ x: 0, y: 1, z: 2 }); + }); + + it('starts each phase after the previous one ends', () => { + const d = lanes({ + nodes: [n('x', 'a'), n('y', 'b'), n('z', 'c')], + phases: [ + { id: 'p', label: 'P', nodes: ['x'] }, + { id: 'q', label: 'Q', nodes: ['y', 'z'] }, + ], + edges: [{ id: 'xy', from: 'x', to: 'y' }], + }); + expect(Object.fromEntries(assignColumns(d, backEdges(d)))).toEqual({ x: 0, y: 1, z: 1 }); + }); + + it('steps a drop right when a card in a lane between would block it', () => { + const d = lanes({ + nodes: [n('x', 'a'), n('mid', 'b'), n('z', 'c'), n('w', 'a')], + edges: [ + { id: 'xw', from: 'x', to: 'w' }, + { id: 'xm', from: 'x', to: 'mid' }, + { id: 'xz', from: 'x', to: 'z' }, + ], + }); + // mid sits under x (column 0), so x → z steps into column 1, which w already opened. + expect(Object.fromEntries(assignColumns(d, backEdges(d)))).toEqual({ x: 0, mid: 0, z: 1, w: 1 }); + }); + + it('never splits a reply and its call onto one port (no two-way arrows)', async () => { + const d = lanes({ + nodes: [n('x', 'a'), n('y', 'b')], + edges: [ + { id: 'go', from: 'x', to: 'y' }, + { id: 'back', from: 'y', to: 'x', kind: 'return' }, + ], + }); + const out = await layoutDiagram(d); + const go = out.edges.go!; + const back = out.edges.back!; + expect(go[0]).not.toEqual(back.at(-1)); + expect(go.at(-1)).not.toEqual(back[0]); + }); + + it('a later phase reaching an earlier one through an unphased step still lays out compactly', () => { + const d: DiagramDraft = { + kind: 'workflow', + title: 'Agent', + lanes: [ + { id: 'agent', label: 'Agent' }, + { id: 'tools', label: 'Tools' }, + ], + phases: [ + { id: 'plan', label: 'Plan', nodes: ['policy'] }, + { id: 'exec', label: 'Execute', nodes: ['tool'] }, + ], + nodes: [n('tool', 'agent'), n('trace', 'tools'), n('policy', 'agent')], + edges: [ + { id: 'e1', from: 'tool', to: 'trace' }, + { id: 'e2', from: 'trace', to: 'policy' }, + ], + }; + const col = assignColumns(d, backEdges(d)); + expect(backEdges(d).has('e2')).toBe(true); + expect(Math.max(...col.values())).toBeLessThanOrEqual(2); + }); + + it('keeps the labels of a pause/resume pair and of parallel edges apart', async () => { + const d = lanes({ + nodes: [n('run', 'a'), n('pause', 'a')], + edges: [ + { id: 'p', from: 'run', to: 'pause', label: 'pause' }, + { id: 'r', from: 'pause', to: 'run', label: 'resume', kind: 'return' }, + { id: 'p2', from: 'run', to: 'pause', label: 'suspend job' }, + ], + }); + const out = await layoutDiagram(d); + const pill = (id: string, text: string) => { + const c = out.labels![id]!; + const w = labelWidth(text); + return { x: c.x - w / 2, y: c.y - 10, width: w, height: 20 }; + }; + const pills = [pill('p', 'pause'), pill('r', 'resume'), pill('p2', 'suspend job')]; + const hit = (a: Rect, b: Rect) => a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height; + for (let i = 0; i < pills.length; i++) for (let j = i + 1; j < pills.length; j++) expect(hit(pills[i]!, pills[j]!), `${i} × ${j}`).toBe(false); + expect(out.edges.p).not.toEqual(out.edges.p2); + }); +}); diff --git a/packages/schema/src/rules/card-fit.ts b/packages/schema/src/rules/card-fit.ts index 399d85f..59f9f9f 100644 --- a/packages/schema/src/rules/card-fit.ts +++ b/packages/schema/src/rules/card-fit.ts @@ -1,4 +1,4 @@ -import { cardTextSlots, measureText, type DiagramDraft } from '@stackmap/core'; +import { cardTextSlots, measureText, usesCompactCards, type DiagramDraft } from '@stackmap/core'; import type { Diagnostic } from '../diagnostics'; const round = (n: number) => Math.round(n * 10) / 10; @@ -6,8 +6,9 @@ const round = (n: number) => Math.round(n * 10) / 10; /** Text that the card would truncate with an ellipsis, measured headlessly with Geist metrics. */ export function cardFitDiagnostics(d: DiagramDraft): Diagnostic[] { const out: Diagnostic[] = []; + const variant = usesCompactCards(d) ? 'compact' : 'full'; d.nodes.forEach((n, i) => { - for (const slot of cardTextSlots(n.card)) { + for (const slot of cardTextSlots(n.card, variant)) { // Cards render with white-space: nowrap, which collapses runs of whitespace and trims the ends. const shown = slot.text.replace(/\s+/g, ' ').trim(); const width = measureText(shown, slot.face, slot.size); @@ -26,7 +27,8 @@ export function cardFitDiagnostics(d: DiagramDraft): Diagnostic[] { evidence: { text: slot.text, width: round(width), maxWidth: round(slot.maxWidth), maxChars }, allowedFixes: [ `shorten to at most ${maxChars} characters`, - ...(header ? ['move the detail into a card row'] : []), + ...(header && variant === 'full' ? ['move the detail into a card row'] : []), + ...(header && variant === 'compact' ? ['move the detail into an evidence note'] : []), ...(rowLabel ? [`shorten the value at /nodes/${i}/card${slot.path.replace(/label$/, 'value')} to give the label room`] : []), ], }); diff --git a/packages/schema/src/rules/kinds.ts b/packages/schema/src/rules/kinds.ts new file mode 100644 index 0000000..3de16f8 --- /dev/null +++ b/packages/schema/src/rules/kinds.ts @@ -0,0 +1,196 @@ +import { + assignColumns, + backEdges, + INFRA_TYPES, + isCompactKind, + isLaneKind, + phaseViolations, + STATE_TYPES, + usesCompactCards, + type DiagramDraft, + type NodeType, +} from '@stackmap/core'; +import type { Diagnostic } from '../diagnostics'; +import { LIMITS } from '../schema'; + +const error = (code: string, subject: string, message: string, evidence: Record, allowedFixes: string[]): Diagnostic => ({ + code, + severity: 'error', + subject, + message, + evidence, + allowedFixes, +}); + +// The state a component most often stands for, and back: the fix names a concrete type, not just the list. +const STATE_FOR: Partial> = { client: 'start', external: 'neutral', security: 'decision', queue: 'waiting' }; +const INFRA_FOR: Partial> = { start: 'client', active: 'service', waiting: 'queue', decision: 'security', success: 'service', failure: 'external', neutral: 'external' }; + +const COMPACT_ONLY_SECTIONS = ['rows', 'stats', 'statsNote', 'footer', 'cta'] as const; + +/** Rules that depend on the diagram kind: lanes, phases, compact cards and the node types each kind uses. */ +export function kindDiagnostics(d: DiagramDraft): Diagnostic[] { + const out: Diagnostic[] = []; + const lanes = isLaneKind(d.kind); + const compact = usesCompactCards(d); + const types: readonly NodeType[] = d.kind === 'lifecycle' ? STATE_TYPES : INFRA_TYPES; + const laneIds = (d.lanes ?? []).map((l) => l.id); + + d.nodes.forEach((n, i) => { + if (!types.includes(n.type)) { + const near = (d.kind === 'lifecycle' ? STATE_FOR : INFRA_FOR)[n.type]; + out.push( + error('semantics/type-for-kind', `/nodes/${i}/type`, `A ${d.kind} diagram has no "${n.type}" nodes`, { type: n.type, kind: d.kind, allowed: types }, [ + ...(near ? [`use "${near}"`] : []), + `use one of: ${types.join(', ')}`, + ]), + ); + } + if (lanes && n.lane === undefined && d.lanes?.length) + out.push(error('semantics/missing-lane', `/nodes/${i}`, `Node "${n.id}" has no lane`, { id: n.id, lanes: laneIds }, laneIds.slice(0, 3).map((l) => `set "lane": "${l}"`))); + if (!lanes && n.lane !== undefined) + out.push(error('semantics/lane-for-kind', `/nodes/${i}/lane`, `Only workflow and lifecycle nodes sit in lanes`, { kind: d.kind }, ['remove "lane"', ...(d.kind === 'sequence' ? [] : ['use "group" instead'])])); + if (compact) { + for (const section of COMPACT_ONLY_SECTIONS) { + if (n.card[section] !== undefined) + out.push( + error('semantics/card-section-for-kind', `/nodes/${i}/card/${section}`, `${isCompactKind(d.kind) ? d.kind : 'Compact'} cards show no ${section}`, { section, kind: d.kind }, [ + `remove "${section}"`, + 'move the detail into an evidence note', + ...(isCompactKind(d.kind) ? [] : ['remove "density" to use full cards']), + ]), + ); + } + } else if (n.card.tag !== undefined) { + out.push(error('semantics/tag-for-kind', `/nodes/${i}/card/tag`, 'Only compact cards show a tag', { kind: d.kind }, ['move it into a card row', 'remove "tag"', 'set "density": "compact"'])); + } + }); + + if (isCompactKind(d.kind) && d.density !== undefined) + out.push({ ...error('semantics/density-ignored', '/density', `${d.kind} cards are always compact`, { kind: d.kind }, ['remove "density"']), severity: 'warning' }); + + // Swimlanes are routed edge by edge; past this they're slow to lay out and too dense to read anyway. + if (lanes && d.nodes.length > LIMITS.laneNodes) + out.push(error('semantics/too-many-for-kind', '/nodes', `A ${d.kind} diagram holds at most ${LIMITS.laneNodes} nodes (this one has ${d.nodes.length})`, { count: d.nodes.length, max: LIMITS.laneNodes }, [ + 'split it into one diagram per phase or per lane group', + ])); + + if (lanes && !d.lanes?.length) + out.push(error('semantics/missing-lanes', '', `A ${d.kind} diagram needs "lanes"`, { kind: d.kind }, ['add "lanes": [{ "id": …, "label": … }] and set each node\'s "lane"'])); + if (!lanes && d.lanes) + out.push(error('semantics/lanes-for-kind', '/lanes', 'Only workflow and lifecycle diagrams have lanes', { kind: d.kind }, ['remove "lanes"', ...(d.kind === 'sequence' ? [] : ['use "groups" instead'])])); + if (lanes) { + const used = new Set(d.nodes.map((n) => n.lane)); + d.lanes?.forEach((l, i) => { + if (!used.has(l.id)) + out.push({ ...error('semantics/empty-lane', `/lanes/${i}`, `Lane "${l.id}" has no nodes`, { id: l.id }, [`set "lane": "${l.id}" on its nodes`, 'remove the lane']), severity: 'warning' }); + }); + } + + if ((lanes || d.kind === 'sequence') && d.direction !== undefined) + out.push({ + ...error('semantics/direction-ignored', '/direction', `${d.kind} diagrams always flow left to right${d.kind === 'sequence' ? ' and top to bottom' : ''}`, { kind: d.kind }, ['remove "direction"']), + severity: 'warning', + }); + + if (d.kind === 'sequence' && d.groups) + out.push(error('semantics/groups-for-kind', '/groups', 'Sequence diagrams have no groups', { kind: d.kind }, ['remove "groups"', 'use "phases" to band messages'])); + if (lanes) { + const laneOf = new Map(d.nodes.map((n) => [n.id, n.lane])); + d.groups?.forEach((g, i) => { + if (g.parent !== undefined) + out.push(error('semantics/nested-group-for-kind', `/groups/${i}/parent`, `Groups in a ${d.kind} diagram don't nest`, { id: g.id }, ['remove "parent"'])); + const members = d.nodes.filter((n) => n.group === g.id); + const memberLanes = [...new Set(members.map((n) => laneOf.get(n.id)))]; + if (memberLanes.length > 1) + out.push( + error('semantics/group-spans-lanes', `/groups/${i}`, `Group "${g.id}" spans lanes ${memberLanes.join(', ')}; a group sits inside one lane`, { id: g.id, lanes: memberLanes }, [ + 'split it into one group per lane', + 'move its nodes into one lane', + ]), + ); + }); + } + + out.push(...phaseDiagnostics(d)); + if (lanes && d.lanes?.length) out.push(...laneGroupDiagnostics(d)); + return out; +} + +function phaseDiagnostics(d: DiagramDraft): Diagnostic[] { + const out: Diagnostic[] = []; + if (!d.phases) return out; + const lanes = isLaneKind(d.kind); + const byNodes = d.kind !== 'sequence'; + const want = byNodes ? 'nodes' : 'edges'; + const other = byNodes ? 'edges' : 'nodes'; + const owner = new Map(); + d.phases.forEach((p, i) => { + if (p[other] !== undefined) + out.push(error('semantics/phase-members', `/phases/${i}/${other}`, `${d.kind} phases list ${want}, not ${other}`, { kind: d.kind }, [`replace "${other}" with the "${want}" this phase spans`])); + const members = p[want]; + if (!members?.length) { + out.push(error('semantics/phase-members', `/phases/${i}`, `Phase "${p.id}" lists no ${want}`, { id: p.id }, [`list the ${want} this phase spans in "${want}"`, 'remove the phase'])); + return; + } + members.forEach((m, j) => { + const first = owner.get(m); + if (first !== undefined && first !== i) + out.push(error('semantics/phase-overlap', `/phases/${i}/${want}/${j}`, `"${m}" is already in phase "${d.phases![first]!.id}"`, { id: m, phase: d.phases![first]!.id }, [`remove "${m}" from one of the two phases`])); + else owner.set(m, i); + }); + }); + + if (byNodes && !lanes) { + // Stages are laid out as ELK partitions of the top level: a grouped node can't take part. + d.nodes.forEach((n, i) => { + if (n.group !== undefined && owner.has(n.id)) + out.push(error('semantics/phase-member-in-group', `/nodes/${i}/group`, `Node "${n.id}" is in a phase and a group; phases hold ungrouped nodes`, { id: n.id }, ['remove "group"', `remove "${n.id}" from its phase`])); + else if (!owner.has(n.id) && n.group === undefined) + out.push({ ...error('semantics/unphased-node', `/nodes/${i}`, `Node "${n.id}" is in no phase; it is placed in its first predecessor's`, { id: n.id }, ['add it to a phase\'s "nodes"']), severity: 'warning' }); + }); + } + + if (byNodes) { + // A later phase flowing back into an earlier one (directly or through unphased steps) is laid out as a back + // edge; say so, it's usually a mistake. + const back = phaseViolations(d); + d.edges.forEach((e, i) => { + if (!back.has(e.id)) return; + const into = d.phases![owner.get(e.to)!]!.id; + out.push({ + ...error('semantics/phase-order', `/edges/${i}`, `Edge "${e.id}" leads back into phase "${into}" from a later phase`, { id: e.id, phase: into }, [ + 'mark it "kind": "return" if it loops back on purpose', + 'reorder the phases', + `put "${e.from}" in a phase`, + ]), + severity: 'warning', + }); + }); + } + return out; +} + +/** A lane group is drawn around its members: it must not end up enclosing another step of the same lane. */ +function laneGroupDiagnostics(d: DiagramDraft): Diagnostic[] { + const out: Diagnostic[] = []; + if (!d.groups?.length) return out; + const col = assignColumns(d, backEdges(d)); + d.groups.forEach((g, i) => { + const members = d.nodes.filter((n) => n.group === g.id && n.lane !== undefined); + const lanes = new Set(members.map((n) => n.lane)); + if (members.length < 2 || lanes.size !== 1) return; + const cs = members.map((n) => col.get(n.id)!); + const [lo, hi] = [Math.min(...cs), Math.max(...cs)]; + const inside = d.nodes.filter((n) => n.group !== g.id && lanes.has(n.lane) && col.get(n.id)! >= lo && col.get(n.id)! <= hi).map((n) => n.id); + if (inside.length) + out.push({ + ...error('semantics/group-not-contiguous', `/groups/${i}`, `Group "${g.id}" would also frame ${inside.map((x) => `"${x}"`).join(', ')}, which sit between its steps`, { id: g.id, inside }, [ + `add ${inside.map((x) => `"${x}"`).join(', ')} to the group`, + 'split the group into neighbouring steps', + ]), + severity: 'warning', + }); + }); + return out; +} diff --git a/packages/schema/src/rules/refs.ts b/packages/schema/src/rules/refs.ts index 5778e53..5a09aba 100644 --- a/packages/schema/src/rules/refs.ts +++ b/packages/schema/src/rules/refs.ts @@ -72,6 +72,24 @@ export function refsDiagnostics(d: DiagramDraft): Diagnostic[] { } }); + const laneIds = (d.lanes ?? []).map((l) => l.id); + const laneSet = new Set(laneIds); + d.nodes.forEach((n, i) => { + if (n.lane !== undefined && d.lanes && !laneSet.has(n.lane)) + out.push(unknownRef('refs/unknown-lane', `/nodes/${i}/lane`, 'lane', n.lane, laneIds, `add lane "${n.lane}"`)); + }); + + const edgeIds = d.edges.map((e) => e.id); + const edgeSet = new Set(edgeIds); + d.phases?.forEach((p, i) => { + p.nodes?.forEach((id, j) => { + if (!nodeSet.has(id)) out.push(unknownRef('refs/unknown-phase-node', `/phases/${i}/nodes/${j}`, 'node', id, nodeIds, `remove "${id}" from the phase`)); + }); + p.edges?.forEach((id, j) => { + if (!edgeSet.has(id)) out.push(unknownRef('refs/unknown-phase-edge', `/phases/${i}/edges/${j}`, 'edge', id, edgeIds, `remove "${id}" from the phase`)); + }); + }); + d.edges.forEach((e, i) => { for (const end of ['from', 'to'] as const) { if (!nodeSet.has(e[end])) diff --git a/packages/schema/src/rules/schema.ts b/packages/schema/src/rules/schema.ts index 63c15c7..5099a52 100644 --- a/packages/schema/src/rules/schema.ts +++ b/packages/schema/src/rules/schema.ts @@ -82,6 +82,9 @@ function fixes(issue: z.core.$ZodIssue, received: unknown): string[] { return issue.keys.flatMap((k) => { const alias = ALIASES[k]; if (alias && allowed.includes(alias)) return [`rename "${k}" to "${alias}"`]; + // A node's `label` means its card title, not the similarly spelt `lane`. + const aliasHome = alias && childAccepting(issue.path, alias); + if (aliasHome) return [`move "${k}" into "${aliasHome}" as "${alias}"`, `remove "${k}"`]; const near = closest(k, allowed, 1)[0]; if (near) return [`rename "${k}" to "${near}"`]; const home = childAccepting(issue.path, k); diff --git a/packages/schema/src/rules/semantics.ts b/packages/schema/src/rules/semantics.ts index 8a290f7..aec7b12 100644 --- a/packages/schema/src/rules/semantics.ts +++ b/packages/schema/src/rules/semantics.ts @@ -69,6 +69,8 @@ export function semanticsDiagnostics(d: DiagramDraft): Diagnostic[] { ...duplicates(d.edges, 'edges'), ...duplicates(d.groups, 'groups'), ...duplicates(d.views, 'views'), + ...duplicates(d.lanes, 'lanes'), + ...duplicates(d.phases, 'phases'), ]; d.nodes.forEach((n, i) => { @@ -98,8 +100,9 @@ export function semanticsDiagnostics(d: DiagramDraft): Diagnostic[] { }); } + // A sequence message to oneself is a self-call, drawn as a loop on the lifeline. d.edges.forEach((e, i) => { - if (e.from === e.to) + if (e.from === e.to && d.kind !== 'sequence') out.push({ code: 'semantics/self-loop', severity: 'warning', @@ -111,11 +114,12 @@ export function semanticsDiagnostics(d: DiagramDraft): Diagnostic[] { }); if (d.kind === 'dataflow') { - // An async edge is an explicit feedback path (retry, event back-channel), so it doesn't close a cycle. - const flow = d.edges.filter((e) => e.kind !== 'async'); + // An async or return edge is an explicit feedback path (retry, event back-channel), so it doesn't close a cycle. + const feedback = (e: { kind?: string }) => e.kind === 'async' || e.kind === 'return'; + const flow = d.edges.filter((e) => !feedback(e)); for (const comp of cycles(d.nodes.map((n) => n.id), flow.map((e) => [e.from, e.to]))) { const members = new Set(comp); - const i = d.edges.findIndex((e) => e.kind !== 'async' && members.has(e.from) && members.has(e.to) && e.from !== e.to); + const i = d.edges.findIndex((e) => !feedback(e) && members.has(e.from) && members.has(e.to) && e.from !== e.to); out.push({ code: 'semantics/dataflow-cycle', severity: 'warning', diff --git a/packages/schema/src/schema.ts b/packages/schema/src/schema.ts index b985686..3a0c22e 100644 --- a/packages/schema/src/schema.ts +++ b/packages/schema/src/schema.ts @@ -1,12 +1,12 @@ import * as z from 'zod'; -import { NODE_TYPES } from '@stackmap/core'; +import { DIAGRAM_KINDS, NODE_TYPES } from '@stackmap/core'; // Mirrors the hand-written types in @stackmap/core (a type test keeps them identical). Objects are strict // so a misspelt key is reported instead of silently dropped. export const ID_PATTERN = /^[a-z0-9][a-z0-9_-]*$/; // Size caps keep validation and layout fast and the diagram readable; beyond them, split into views. -export const LIMITS = { rows: 6, stats: 3, evidence: 8, edgeLabel: 24, nodes: 500, edges: 2000, groups: 200, views: 50 } as const; +export const LIMITS = { rows: 6, stats: 3, evidence: 8, edgeLabel: 24, nodes: 500, edges: 2000, groups: 200, views: 50, laneNodes: 200, lanes: 20, phases: 20, notes: 6, noteItems: 6 } as const; export const VISIBLE_TEXT = /\S/; const id = z.string().regex(ID_PATTERN); @@ -44,6 +44,7 @@ const Card = z.strictObject({ href: httpUrl.optional(), }) .optional(), + tag: text.optional().meta({ description: 'Compact cards only (workflow, lifecycle, sequence, or density "compact"): a short pill, e.g. "human gate".' }), }); const Evidence = z.strictObject({ @@ -54,8 +55,11 @@ const Evidence = z.strictObject({ const Node = z.strictObject({ id, - type: z.enum(NODE_TYPES).meta({ description: 'Sets the card color. Never color by brand.' }), + type: z.enum(NODE_TYPES).meta({ + description: 'Sets the card color. Never color by brand. Lifecycle diagrams use the state types (start, active, waiting, decision, success, failure, neutral); every other kind uses the component types.', + }), group: id.optional().meta({ description: 'Id of the group the node sits in.' }), + lane: id.optional().meta({ description: 'Workflow and lifecycle: id of the lane the node sits in (required there).' }), card: Card, evidence: z .array(Evidence) @@ -69,13 +73,39 @@ const Edge = z.strictObject({ from: id.meta({ description: 'Source node id.' }), to: id.meta({ description: 'Target node id.' }), label: text.max(LIMITS.edgeLabel).optional().meta({ description: 'Short label; use sparingly.' }), - kind: z.enum(['sync', 'async']).optional().meta({ description: 'async edges render dashed.' }), + kind: z.enum(['sync', 'async', 'return']).optional().meta({ description: 'async renders dashed; return (a reply, a roll back) renders dotted.' }), + tone: z + .enum(['main', 'security', 'error']) + .optional() + .meta({ description: 'main marks the happy path; security and error paths take those tints. Use sparingly.' }), }); const Group = z.strictObject({ id, - label: text.meta({ description: 'Shown small-caps above the frame.' }), + label: text.meta({ description: 'Shown above the frame.' }), parent: id.optional().meta({ description: 'Id of the enclosing group, for nesting.' }), + tone: z.enum(['security']).optional().meta({ description: 'A trust boundary (private network, PII zone).' }), +}); + +const Lane = z.strictObject({ + id, + label: text, + tone: z.enum(['exception']).optional().meta({ description: 'A lane for failure and recovery paths.' }), +}); + +const Phase = z.strictObject({ + id, + label: text, + nodes: z + .array(id) + .optional() + .meta({ description: 'Workflow and lifecycle: the nodes whose columns this phase spans. Architecture and dataflow: the nodes in this stage.' }), + edges: z.array(id).optional().meta({ description: 'Sequence: the messages this time band spans.' }), +}); + +const Note = z.strictObject({ + title: text, + items: z.array(text).min(1).max(LIMITS.noteItems), }); const View = z.strictObject({ @@ -88,7 +118,11 @@ const View = z.strictObject({ export const DiagramDraftSchema = z .strictObject({ $schema: z.string().optional().meta({ description: `Optional; set it to ${'`'}https://unpkg.com/@hyamero/stackmap@0.1.0/dist/stackmap.schema.json${'`'} for editor completion.` }), - kind: z.enum(['architecture', 'dataflow']), + kind: z.enum(DIAGRAM_KINDS), + density: z + .enum(['compact']) + .optional() + .meta({ description: 'Architecture and dataflow: compact cards (title, subtitle, brand, tag) for long chains or summaries.' }), title: text, subtitle: text.optional(), source: z @@ -97,9 +131,16 @@ export const DiagramDraftSchema = z .meta({ description: 'Base URL for evidence links: `/#L`.' }), direction: z.enum(['RIGHT', 'DOWN']).optional().meta({ description: 'Layout flow. Default RIGHT; prefer DOWN for tiered/grouped diagrams.' }), groups: z.array(Group).max(LIMITS.groups).optional(), + lanes: z.array(Lane).max(LIMITS.lanes).optional().meta({ description: 'Workflow and lifecycle: swimlanes, top to bottom.' }), + phases: z + .array(Phase) + .max(LIMITS.phases) + .optional() + .meta({ description: 'Ordered stages: header bands over columns (workflow, lifecycle), stage bands in flow order (architecture, dataflow) or time bands (sequence).' }), nodes: z.array(Node).min(1).max(LIMITS.nodes), - edges: z.array(Edge).max(LIMITS.edges), + edges: z.array(Edge).max(LIMITS.edges).meta({ description: 'Connections. In a sequence, the messages, in time order.' }), views: z.array(View).max(LIMITS.views).optional(), + notes: z.array(Note).max(LIMITS.notes).optional().meta({ description: 'Takeaways about the diagram, shown in the inspector.' }), }) .meta({ title: 'stackmap diagram', description: 'Agent-authored diagram. Layout is computed by stackmap; never give coordinates.' }); diff --git a/packages/schema/src/validate.ts b/packages/schema/src/validate.ts index 90ff0e3..bef043d 100644 --- a/packages/schema/src/validate.ts +++ b/packages/schema/src/validate.ts @@ -1,6 +1,7 @@ import type { DiagramDraft } from '@stackmap/core'; import type { ValidationResult } from './diagnostics'; import { cardFitDiagnostics } from './rules/card-fit'; +import { kindDiagnostics } from './rules/kinds'; import { refsDiagnostics } from './rules/refs'; import { schemaDiagnostics } from './rules/schema'; import { semanticsDiagnostics } from './rules/semantics'; @@ -8,13 +9,13 @@ import { DiagramDraftSchema } from './schema'; /** * Schema first: the other families need a typed diagram, so schema errors are returned alone. Otherwise - * refs, semantics and card-fit all run, in that order, each in document order. + * refs, semantics (general, then kind rules) and card-fit all run, in that order, each in document order. */ export function validateDiagram(input: unknown): ValidationResult { const parsed = DiagramDraftSchema.safeParse(input); if (!parsed.success) return { ok: false, diagnostics: schemaDiagnostics(parsed.error.issues, input) }; const { $schema: _, ...diagram } = parsed.data; const d: DiagramDraft = diagram; - const diagnostics = [...refsDiagnostics(d), ...semanticsDiagnostics(d), ...cardFitDiagnostics(d)]; + const diagnostics = [...refsDiagnostics(d), ...semanticsDiagnostics(d), ...kindDiagnostics(d), ...cardFitDiagnostics(d)]; return { ok: !diagnostics.some((x) => x.severity === 'error'), diagram: d, diagnostics }; } diff --git a/packages/schema/stackmap.schema.json b/packages/schema/stackmap.schema.json index 9d77861..d9f6384 100644 --- a/packages/schema/stackmap.schema.json +++ b/packages/schema/stackmap.schema.json @@ -10,7 +10,17 @@ "type": "string", "enum": [ "architecture", - "dataflow" + "dataflow", + "workflow", + "lifecycle", + "sequence" + ] + }, + "density": { + "description": "Architecture and dataflow: compact cards (title, subtitle, brand, tag) for long chains or summaries.", + "type": "string", + "enum": [ + "compact" ] }, "title": { @@ -56,12 +66,88 @@ "label": { "type": "string", "pattern": "\\S", - "description": "Shown small-caps above the frame." + "description": "Shown above the frame." }, "parent": { "description": "Id of the enclosing group, for nesting.", "type": "string", "pattern": "^[a-z0-9][a-z0-9_-]*$" + }, + "tone": { + "description": "A trust boundary (private network, PII zone).", + "type": "string", + "enum": [ + "security" + ] + } + }, + "required": [ + "id", + "label" + ], + "additionalProperties": false + } + }, + "lanes": { + "description": "Workflow and lifecycle: swimlanes, top to bottom.", + "maxItems": 20, + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9_-]*$" + }, + "label": { + "type": "string", + "pattern": "\\S" + }, + "tone": { + "description": "A lane for failure and recovery paths.", + "type": "string", + "enum": [ + "exception" + ] + } + }, + "required": [ + "id", + "label" + ], + "additionalProperties": false + } + }, + "phases": { + "description": "Ordered stages: header bands over columns (workflow, lifecycle), stage bands in flow order (architecture, dataflow) or time bands (sequence).", + "maxItems": 20, + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9_-]*$" + }, + "label": { + "type": "string", + "pattern": "\\S" + }, + "nodes": { + "description": "Workflow and lifecycle: the nodes whose columns this phase spans. Architecture and dataflow: the nodes in this stage.", + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9_-]*$" + } + }, + "edges": { + "description": "Sequence: the messages this time band spans.", + "type": "array", + "items": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9_-]*$" + } } }, "required": [ @@ -93,15 +179,27 @@ "queue", "storage", "external", - "security" + "security", + "start", + "active", + "waiting", + "decision", + "success", + "failure", + "neutral" ], - "description": "Sets the card color. Never color by brand." + "description": "Sets the card color. Never color by brand. Lifecycle diagrams use the state types (start, active, waiting, decision, success, failure, neutral); every other kind uses the component types." }, "group": { "description": "Id of the group the node sits in.", "type": "string", "pattern": "^[a-z0-9][a-z0-9_-]*$" }, + "lane": { + "description": "Workflow and lifecycle: id of the lane the node sits in (required there).", + "type": "string", + "pattern": "^[a-z0-9][a-z0-9_-]*$" + }, "card": { "type": "object", "properties": { @@ -236,6 +334,11 @@ "label" ], "additionalProperties": false + }, + "tag": { + "description": "Compact cards only (workflow, lifecycle, sequence, or density \"compact\"): a short pill, e.g. \"human gate\".", + "type": "string", + "pattern": "\\S" } }, "required": [ @@ -307,11 +410,21 @@ "pattern": "\\S" }, "kind": { - "description": "async edges render dashed.", + "description": "async renders dashed; return (a reply, a roll back) renders dotted.", "type": "string", "enum": [ "sync", - "async" + "async", + "return" + ] + }, + "tone": { + "description": "main marks the happy path; security and error paths take those tints. Use sparingly.", + "type": "string", + "enum": [ + "main", + "security", + "error" ] } }, @@ -321,7 +434,8 @@ "to" ], "additionalProperties": false - } + }, + "description": "Connections. In a sequence, the messages, in time order." }, "views": { "maxItems": 50, @@ -357,6 +471,34 @@ ], "additionalProperties": false } + }, + "notes": { + "description": "Takeaways about the diagram, shown in the inspector.", + "maxItems": 6, + "type": "array", + "items": { + "type": "object", + "properties": { + "title": { + "type": "string", + "pattern": "\\S" + }, + "items": { + "minItems": 1, + "maxItems": 6, + "type": "array", + "items": { + "type": "string", + "pattern": "\\S" + } + } + }, + "required": [ + "title", + "items" + ], + "additionalProperties": false + } } }, "required": [ diff --git a/packages/schema/test/kinds.test.ts b/packages/schema/test/kinds.test.ts new file mode 100644 index 0000000..d13491c --- /dev/null +++ b/packages/schema/test/kinds.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it } from 'vitest'; +import { GALLERY } from '@stackmap/core/gallery'; +import { validateDiagram } from '../src/validate'; + +const workflow = (over: Record = {}) => ({ + kind: 'workflow', + title: 'Release', + lanes: [ + { id: 'dev', label: 'Developer' }, + { id: 'ci', label: 'CI' }, + ], + nodes: [ + { id: 'commit', type: 'client', lane: 'dev', card: { title: 'Commit' } }, + { id: 'build', type: 'service', lane: 'ci', card: { title: 'Build', tag: 'reproducible' } }, + ], + edges: [{ id: 'e1', from: 'commit', to: 'build', tone: 'main' }], + ...over, +}); +const codes = (input: unknown) => validateDiagram(input).diagnostics.map((d) => `${d.severity}:${d.code}@${d.subject}`); + +describe('kind rules', () => { + it('accepts a workflow with lanes, phases, tags, tones and notes', () => { + const d = workflow({ + phases: [ + { id: 'change', label: 'Change', nodes: ['commit'] }, + { id: 'verify', label: 'Verify', nodes: ['build'] }, + ], + notes: [{ title: 'Happy path', items: ['Every change is built once'] }], + }); + expect(validateDiagram(d)).toMatchObject({ ok: true, diagnostics: [] }); + }); + + it('requires lanes and a lane per node in lane kinds', () => { + expect(codes(workflow({ lanes: undefined, nodes: [{ id: 'a', type: 'service', card: { title: 'A' } }], edges: [] }))).toEqual(['error:semantics/missing-lanes@']); + const noLane = workflow({ nodes: [{ id: 'a', type: 'service', card: { title: 'A' } }, { id: 'b', type: 'service', lane: 'dev', card: { title: 'B' } }], edges: [{ id: 'e', from: 'a', to: 'b' }] }); + expect(codes(noLane)).toEqual(['error:semantics/missing-lane@/nodes/0', 'warning:semantics/empty-lane@/lanes/1']); + expect(validateDiagram(noLane).diagnostics[0]!.allowedFixes).toEqual(['set "lane": "dev"', 'set "lane": "ci"']); + }); + + it('reports unknown lanes with the closest id', () => { + const d = workflow({ nodes: [{ id: 'commit', type: 'client', lane: 'devs', card: { title: 'Commit' } }, { id: 'build', type: 'service', lane: 'ci', card: { title: 'Build' } }] }); + const [diag] = validateDiagram(d).diagnostics; + expect(diag).toMatchObject({ code: 'refs/unknown-lane', subject: '/nodes/0/lane', allowedFixes: ['use "dev"', 'add lane "devs"'] }); + }); + + it('keeps lanes, tags and phases out of the kinds that have none', () => { + const arch = { + kind: 'architecture', + title: 'T', + lanes: [{ id: 'l', label: 'L' }], + phases: [{ id: 'p', label: 'P', nodes: ['a'] }], + nodes: [{ id: 'a', type: 'service', lane: 'l', card: { title: 'A', tag: 'x' } }], + edges: [], + }; + expect(codes(arch)).toEqual([ + 'error:semantics/lane-for-kind@/nodes/0/lane', + 'error:semantics/tag-for-kind@/nodes/0/card/tag', + 'error:semantics/lanes-for-kind@/lanes', + ]); + }); + + it('lifecycle nodes are states; other kinds use component types', () => { + const life = workflow({ kind: 'lifecycle', nodes: [{ id: 'commit', type: 'client', lane: 'dev', card: { title: 'Queued' } }, { id: 'build', type: 'active', lane: 'ci', card: { title: 'Running' } }] }); + const [diag] = validateDiagram(life).diagnostics; + expect(diag).toMatchObject({ code: 'semantics/type-for-kind', subject: '/nodes/0/type', allowedFixes: ['use "start"', 'use one of: start, active, waiting, decision, success, failure, neutral'] }); + expect(codes(workflow({ nodes: [{ id: 'commit', type: 'success', lane: 'dev', card: { title: 'C' } }, { id: 'build', type: 'service', lane: 'ci', card: { title: 'B' } }] }))).toEqual([ + 'error:semantics/type-for-kind@/nodes/0/type', + ]); + }); + + it('compact cards reject full-card sections', () => { + const d = workflow({ nodes: [{ id: 'commit', type: 'client', lane: 'dev', card: { title: 'C', rows: [{ label: 'k', value: 'v' }], cta: { label: 'Open' } } }, { id: 'build', type: 'service', lane: 'ci', card: { title: 'B' } }] }); + expect(codes(d)).toEqual(['error:semantics/card-section-for-kind@/nodes/0/card/rows', 'error:semantics/card-section-for-kind@/nodes/0/card/cta']); + }); + + it('checks phase members, overlaps and order', () => { + expect(codes(workflow({ phases: [{ id: 'p', label: 'P', edges: ['e1'] }] }))).toEqual(['error:semantics/phase-members@/phases/0/edges', 'error:semantics/phase-members@/phases/0']); + expect(codes(workflow({ phases: [{ id: 'p', label: 'P', nodes: ['commit'] }, { id: 'q', label: 'Q', nodes: ['commit', 'build'] }] }))).toEqual([ + 'error:semantics/phase-overlap@/phases/1/nodes/0', + ]); + expect(codes(workflow({ phases: [{ id: 'p', label: 'P', nodes: ['build'] }, { id: 'q', label: 'Q', nodes: ['commit'] }] }))).toEqual(['warning:semantics/phase-order@/edges/0']); + expect(codes(workflow({ phases: [{ id: 'p', label: 'P', nodes: ['ghost'] }] }))[0]).toBe('error:refs/unknown-phase-node@/phases/0/nodes/0'); + }); + + it('warns when a later phase reaches an earlier one through unphased steps', () => { + const d = { + kind: 'workflow', + title: 'Agent', + lanes: [ + { id: 'agent', label: 'Agent' }, + { id: 'tools', label: 'Tools' }, + ], + phases: [ + { id: 'plan', label: 'Plan', nodes: ['policy'] }, + { id: 'exec', label: 'Execute', nodes: ['tool'] }, + ], + nodes: [ + { id: 'tool', type: 'service', lane: 'agent', card: { title: 'tool' } }, + { id: 'trace', type: 'queue', lane: 'tools', card: { title: 'trace' } }, + { id: 'policy', type: 'security', lane: 'agent', card: { title: 'policy' } }, + ], + edges: [ + { id: 'e1', from: 'tool', to: 'trace' }, + { id: 'e2', from: 'trace', to: 'policy' }, + ], + }; + expect(codes(d)).toEqual(['warning:semantics/phase-order@/edges/1']); + }); + + it('warns when a lane group would frame a step that is not in it', () => { + const d = workflow({ + lanes: [{ id: 'dev', label: 'Dev' }], + groups: [{ id: 'g', label: 'G' }], + nodes: [ + { id: 'x', type: 'service', lane: 'dev', group: 'g', card: { title: 'X' } }, + { id: 'm', type: 'service', lane: 'dev', card: { title: 'M' } }, + { id: 'y', type: 'service', lane: 'dev', group: 'g', card: { title: 'Y' } }, + ], + edges: [ + { id: 'xm', from: 'x', to: 'm' }, + { id: 'my', from: 'm', to: 'y' }, + ], + }); + const [diag] = validateDiagram(d).diagnostics; + expect(diag).toMatchObject({ code: 'semantics/group-not-contiguous', severity: 'warning', subject: '/groups/0', evidence: { inside: ['m'] } }); + }); + + it('a group in a lane kind sits in one lane and does not nest', () => { + const d = workflow({ + groups: [{ id: 'g', label: 'G' }, { id: 'h', label: 'H', parent: 'g' }], + nodes: [ + { id: 'commit', type: 'client', lane: 'dev', group: 'g', card: { title: 'C' } }, + { id: 'build', type: 'service', lane: 'ci', group: 'g', card: { title: 'B' } }, + ], + }); + expect(codes(d)).toEqual(['warning:semantics/empty-group@/groups/1', 'error:semantics/group-spans-lanes@/groups/0', 'error:semantics/nested-group-for-kind@/groups/1/parent']); + }); + + it('architecture and dataflow take stage phases of ungrouped nodes, and compact cards on request', () => { + const flow = { + kind: 'dataflow', + title: 'T', + density: 'compact', + groups: [{ id: 'g', label: 'G' }], + phases: [{ id: 'src', label: 'Sources', nodes: ['a', 'b'] }], + nodes: [ + { id: 'a', type: 'client', card: { title: 'A', tag: 'SDK' } }, + { id: 'b', type: 'service', group: 'g', card: { title: 'B' } }, + { id: 'c', type: 'database', card: { title: 'C', rows: [{ label: 'k', value: 'v' }] } }, + ], + edges: [ + { id: 'e1', from: 'a', to: 'b' }, + { id: 'e2', from: 'b', to: 'c' }, + ], + }; + expect(codes(flow)).toEqual(['error:semantics/card-section-for-kind@/nodes/2/card/rows', 'error:semantics/phase-member-in-group@/nodes/1/group', 'warning:semantics/unphased-node@/nodes/2']); + expect(codes({ ...flow, kind: 'workflow', lanes: [{ id: 'l', label: 'L' }], groups: undefined, phases: undefined, nodes: flow.nodes.map((n) => ({ ...n, group: undefined, lane: 'l', card: { title: n.card.title } })) })).toEqual([ + 'warning:semantics/density-ignored@/density', + ]); + }); + + it('warns that lane kinds ignore direction', () => { + expect(codes(workflow({ direction: 'DOWN' }))).toEqual(['warning:semantics/direction-ignored@/direction']); + }); + + it('measures compact cards against the compact budget', () => { + const d = workflow({ nodes: [{ id: 'commit', type: 'client', lane: 'dev', card: { title: 'Production deployment approval' } }, { id: 'build', type: 'service', lane: 'ci', card: { title: 'B' } }] }); + const [diag] = validateDiagram(d).diagnostics; + expect(diag).toMatchObject({ code: 'card-fit/overflow', subject: '/nodes/0/card/title', evidence: { maxWidth: 114 } }); + expect(diag!.allowedFixes).toContain('move the detail into an evidence note'); + }); +}); + +describe('the gallery samples', () => { + it.each(Object.entries(GALLERY))('%s validates with no diagnostics', (_name, d) => { + expect(validateDiagram(d).diagnostics.map((x) => `${x.code}@${x.subject}: ${x.message}`)).toEqual([]); + }); +}); diff --git a/packages/schema/test/validate.test.ts b/packages/schema/test/validate.test.ts index 9f45c3a..63a44b3 100644 --- a/packages/schema/test/validate.test.ts +++ b/packages/schema/test/validate.test.ts @@ -69,9 +69,9 @@ describe('validateDiagram', () => { it('only suggests keys valid on the offending object, never the key itself', () => { const onNode = diags(base({ nodes: [node('a', { label: 'x' } as never), node('b')] }))[0]; - expect(onNode).toMatchObject({ subject: '/nodes/0', allowedFixes: ['remove "label" (not valid here; allowed: id, type, group, card, evidence)'] }); + expect(onNode).toMatchObject({ subject: '/nodes/0', allowedFixes: ['move "label" into "card" as "title"', 'remove "label"'] }); const onEdge = diags(base({ edges: [{ id: 'ab', from: 'a', to: 'b', subtitel: 'x' } as never] }))[0]; - expect(onEdge!.allowedFixes).toEqual(['remove "subtitel" (not valid here; allowed: id, from, to, label, kind)']); + expect(onEdge!.allowedFixes).toEqual(['remove "subtitel" (not valid here; allowed: id, from, to, label, kind, tone)']); const onRoot = diags({ ...base(), titel: 'x' })[0]; expect(onRoot).toMatchObject({ subject: '', allowedFixes: ['rename "titel" to "title"'] }); }); diff --git a/packages/viewer/e2e/export.spec.ts b/packages/viewer/e2e/export.spec.ts index f74efc4..a89ea11 100644 --- a/packages/viewer/e2e/export.spec.ts +++ b/packages/viewer/e2e/export.spec.ts @@ -129,3 +129,32 @@ test('edge routes that swing outside the cards are inside the export', async ({ height: Math.ceil(Math.max(...ys) - Math.min(...ys) + 64), }); }); + +test('toned edges keep their colour in the export (main path in ink)', async ({ page }) => { + await page.goto('/?page=release-delivery'); + await page.evaluate(() => document.fonts.ready); + const { bytes } = await exportAs(page, /^PNG\s*1×/); + // Sample the middle of the Commit → Pull request run (a main-path edge) in the exported image. + const layout = (await import('../src/samples/gallery.layout')).galleryLayouts['release-delivery']!; + const pts = layout.edges.e1!; + const xs = Object.values(layout.nodes).map((r) => r.x).concat(Object.values(layout.lanes!).map((r) => r.x), Object.values(layout.phases!).map((r) => r.x)); + const ys = Object.values(layout.nodes).map((r) => r.y).concat(Object.values(layout.lanes!).map((r) => r.y), Object.values(layout.phases!).map((r) => r.y)); + const at = { x: (pts[0]!.x + pts.at(-1)!.x) / 2 - Math.min(...xs) + 32, y: pts[0]!.y - Math.min(...ys) + 32 }; + const darkest = await page.evaluate( + async ({ b64, at }) => { + const img = new Image(); + img.src = `data:image/png;base64,${b64}`; + await img.decode(); + const c = document.createElement('canvas'); + [c.width, c.height] = [img.width, img.height]; + const ctx = c.getContext('2d')!; + ctx.drawImage(img, 0, 0); + const d = ctx.getImageData(Math.round(at.x) - 2, Math.round(at.y) - 3, 5, 7).data; + let min = 255; + for (let i = 0; i < d.length; i += 4) min = Math.min(min, d[i]!, d[i + 1]!, d[i + 2]!); + return min; + }, + { b64: bytes.toString('base64'), at }, + ); + expect(darkest).toBeLessThan(80); +}); diff --git a/packages/viewer/e2e/lanes.spec.ts b/packages/viewer/e2e/lanes.spec.ts new file mode 100644 index 0000000..c6f6921 --- /dev/null +++ b/packages/viewer/e2e/lanes.spec.ts @@ -0,0 +1,40 @@ +import { expect, test } from '@playwright/test'; + +test('a workflow draws its lanes, phase headers, groups and compact cards', async ({ page }) => { + await page.goto('/?page=release-delivery'); + await expect(page.locator('[data-lane-id]')).toHaveCount(6); + await expect(page.locator('[data-lane-id="exceptions"]')).toHaveAttribute('data-tone', 'exception'); + await expect(page.locator('[data-phase-id]')).toHaveCount(3); + await expect(page.locator('[data-frame-id="recovery"]')).toHaveAttribute('data-tone', 'security'); + await expect(page.locator('[data-testid="step-card"]')).toHaveCount(10); + await expect(page.locator('[data-testid="node-card"]')).toHaveCount(0); + await expect(page.getByText('Workflow', { exact: true })).toBeVisible(); + // Notes and the connection styles the diagram uses. + const inspector = page.getByRole('complementary', { name: 'Inspector' }); + await expect(inspector.getByRole('heading', { name: 'Notes' })).toBeVisible(); + await expect(inspector.getByRole('list', { name: 'Connection styles' })).toContainText('Main pathFailure pathReply or return'); +}); + +test('edge tones and kinds reach the SVG, and a selection tints over them', async ({ page }) => { + await page.goto('/?page=release-delivery'); + await expect(page.locator('path[data-edge-id="e8"]')).toHaveAttribute('data-tone', 'error'); + await expect(page.locator('path[data-edge-id="e10"]')).toHaveAttribute('data-kind', 'return'); + await page.locator('.sm-card[data-card-id="rollback"]').click(); + await expect(page.locator('path[data-edge-id="e10"]')).toHaveAttribute('data-tint', 'queue'); + // Dots sit where routes meet cards, and dim with their card: "failed" is neither up- nor downstream of rollback. + await page.getByRole('button', { name: /Trace/ }).click(); + await expect(page.locator('[data-handle-of="failed"][data-dim]').first()).toBeAttached(); +}); + +test('a lifecycle marks its start state and its end states', async ({ page }) => { + await page.goto('/?page=agent-run'); + await expect(page.locator('[data-start-mark="queued"]')).toBeAttached(); + await expect(page.locator('[data-testid="step-card"][data-final]')).toHaveCount(3); + await expect(page.getByText('Lifecycle', { exact: true })).toBeVisible(); +}); + +test('a staged dataflow draws its stages as bands', async ({ page }) => { + await page.goto('/?page=product-analytics'); + await expect(page.locator('[data-phase-id]')).toHaveCount(5); + await expect(page.locator('[data-testid="step-card"]')).toHaveCount(10); +}); diff --git a/packages/viewer/e2e/metrics.spec.ts b/packages/viewer/e2e/metrics.spec.ts index 56166da..c485244 100644 --- a/packages/viewer/e2e/metrics.spec.ts +++ b/packages/viewer/e2e/metrics.spec.ts @@ -1,7 +1,7 @@ import { expect, test } from '@playwright/test'; import { cardTextSlots, measureText, type FontFace } from '@stackmap/core'; -import { boundaryNodes } from '../src/pages/boundary-nodes'; -import { GALLERY_SECTIONS } from '../src/pages/gallery-nodes'; +import { boundaryNodes, compactBoundaryNodes } from '../src/pages/boundary-nodes'; +import { COMPACT_SECTIONS, GALLERY_SECTIONS } from '../src/pages/gallery-nodes'; // Browser ground truth for the headless card-fit measure (M1). The measure is the wider of the macOS/ // Windows layout (fractional, kerned) and the Linux one (whole-pixel advances, no kerning), so on any @@ -63,7 +63,7 @@ test('headless text measure matches the browser within a conservative margin', a test('a card slot never truncates text the measure said fits, and the measure stays close', async ({ page }) => { await page.goto('/?page=gallery'); await page.evaluate(() => document.fonts.ready); - const rendered = await page.$$eval('[data-testid="node-card"]', (cards) => + const rendered = await page.$$eval('[data-testid="node-card"], [data-testid="step-card"]', (cards) => Object.fromEntries( cards.map((card) => [ card.getAttribute('data-node-id')!, @@ -77,9 +77,13 @@ test('a card slot never truncates text the measure said fits, and the measure st ); const problems: string[] = []; let checked = 0; - for (const [, nodes] of [...GALLERY_SECTIONS, ['Boundary', boundaryNodes()] as const]) { + const sets = [ + ...[...GALLERY_SECTIONS, ['Boundary', boundaryNodes()] as const].map(([, nodes]) => ({ nodes, variant: 'full' as const })), + ...[...COMPACT_SECTIONS, ['Compact boundary', compactBoundaryNodes()] as const].map(([, nodes]) => ({ nodes, variant: 'compact' as const })), + ]; + for (const { nodes, variant } of sets) { for (const node of nodes) { - const slots = cardTextSlots(node.card); + const slots = cardTextSlots(node.card, variant); const dom = rendered[node.id]!; expect(dom.map((d) => d.text), node.id).toEqual(slots.map((s) => s.text)); slots.forEach((slot, i) => { @@ -92,7 +96,7 @@ test('a card slot never truncates text the measure said fits, and the measure st if (!dom[i]!.truncated && width > TOO_WIDE(dom[i]!.width)) problems.push(`${at}, real ${dom[i]!.width.toFixed(1)}: too conservative`); // Linux renders the snapped model to within ±2px (hinting), so text measured clearly over budget // must truncate there; this is what catches a budget that is set too small. - if (process.platform === 'linux' && node.id === 'boundary-over' && width > slot.maxWidth + 4 && !dom[i]!.truncated) + if (process.platform === 'linux' && node.id.endsWith('-over') && width > slot.maxWidth + 4 && !dom[i]!.truncated) problems.push(`${at}, but not truncated on Linux`); }); } diff --git a/packages/viewer/e2e/visual.spec.ts b/packages/viewer/e2e/visual.spec.ts index 832590f..5fb87bd 100644 --- a/packages/viewer/e2e/visual.spec.ts +++ b/packages/viewer/e2e/visual.spec.ts @@ -4,7 +4,14 @@ import { expect, test } from '@playwright/test'; // Linux only (CI, or `bun run visual:update`, which runs Playwright's Linux image in Docker). test.skip(process.platform !== 'linux' && !process.env.VISUAL, 'visual baselines are Linux-only'); -const PAGES = { sample: '/?page=sample', grouped: '/?page=grouped', gallery: '/?page=gallery' } as const; +const PAGES = { + sample: '/?page=sample', + grouped: '/?page=grouped', + gallery: '/?page=gallery', + workflow: '/?page=release-delivery', + lifecycle: '/?page=agent-run', + stages: '/?page=product-analytics', +} as const; for (const theme of ['light', 'dark'] as const) { for (const [name, url] of Object.entries(PAGES)) { diff --git a/packages/viewer/e2e/visual.spec.ts-snapshots/grouped-dark-linux.png b/packages/viewer/e2e/visual.spec.ts-snapshots/grouped-dark-linux.png index 9acf73f..05010b1 100644 Binary files a/packages/viewer/e2e/visual.spec.ts-snapshots/grouped-dark-linux.png and b/packages/viewer/e2e/visual.spec.ts-snapshots/grouped-dark-linux.png differ diff --git a/packages/viewer/e2e/visual.spec.ts-snapshots/grouped-light-linux.png b/packages/viewer/e2e/visual.spec.ts-snapshots/grouped-light-linux.png index d5df07a..ac9f728 100644 Binary files a/packages/viewer/e2e/visual.spec.ts-snapshots/grouped-light-linux.png and b/packages/viewer/e2e/visual.spec.ts-snapshots/grouped-light-linux.png differ diff --git a/packages/viewer/e2e/visual.spec.ts-snapshots/lifecycle-dark-linux.png b/packages/viewer/e2e/visual.spec.ts-snapshots/lifecycle-dark-linux.png new file mode 100644 index 0000000..423272f Binary files /dev/null and b/packages/viewer/e2e/visual.spec.ts-snapshots/lifecycle-dark-linux.png differ diff --git a/packages/viewer/e2e/visual.spec.ts-snapshots/lifecycle-light-linux.png b/packages/viewer/e2e/visual.spec.ts-snapshots/lifecycle-light-linux.png new file mode 100644 index 0000000..e7f43c3 Binary files /dev/null and b/packages/viewer/e2e/visual.spec.ts-snapshots/lifecycle-light-linux.png differ diff --git a/packages/viewer/e2e/visual.spec.ts-snapshots/sample-dark-linux.png b/packages/viewer/e2e/visual.spec.ts-snapshots/sample-dark-linux.png index f415768..994493e 100644 Binary files a/packages/viewer/e2e/visual.spec.ts-snapshots/sample-dark-linux.png and b/packages/viewer/e2e/visual.spec.ts-snapshots/sample-dark-linux.png differ diff --git a/packages/viewer/e2e/visual.spec.ts-snapshots/sample-light-linux.png b/packages/viewer/e2e/visual.spec.ts-snapshots/sample-light-linux.png index a2e233c..ddddda8 100644 Binary files a/packages/viewer/e2e/visual.spec.ts-snapshots/sample-light-linux.png and b/packages/viewer/e2e/visual.spec.ts-snapshots/sample-light-linux.png differ diff --git a/packages/viewer/e2e/visual.spec.ts-snapshots/stages-dark-linux.png b/packages/viewer/e2e/visual.spec.ts-snapshots/stages-dark-linux.png new file mode 100644 index 0000000..5c97d25 Binary files /dev/null and b/packages/viewer/e2e/visual.spec.ts-snapshots/stages-dark-linux.png differ diff --git a/packages/viewer/e2e/visual.spec.ts-snapshots/stages-light-linux.png b/packages/viewer/e2e/visual.spec.ts-snapshots/stages-light-linux.png new file mode 100644 index 0000000..8a3f26a Binary files /dev/null and b/packages/viewer/e2e/visual.spec.ts-snapshots/stages-light-linux.png differ diff --git a/packages/viewer/e2e/visual.spec.ts-snapshots/workflow-dark-linux.png b/packages/viewer/e2e/visual.spec.ts-snapshots/workflow-dark-linux.png new file mode 100644 index 0000000..29d9068 Binary files /dev/null and b/packages/viewer/e2e/visual.spec.ts-snapshots/workflow-dark-linux.png differ diff --git a/packages/viewer/e2e/visual.spec.ts-snapshots/workflow-light-linux.png b/packages/viewer/e2e/visual.spec.ts-snapshots/workflow-light-linux.png new file mode 100644 index 0000000..cdca049 Binary files /dev/null and b/packages/viewer/e2e/visual.spec.ts-snapshots/workflow-light-linux.png differ diff --git a/packages/viewer/src/App.tsx b/packages/viewer/src/App.tsx index 2ca5a6d..de66298 100644 --- a/packages/viewer/src/App.tsx +++ b/packages/viewer/src/App.tsx @@ -4,6 +4,7 @@ import { ViewerShell } from './chrome/ViewerShell'; import { readEmbeddedDiagram } from './data'; import { GalleryPage } from './pages/GalleryPage'; import { commerceApiLayout } from './samples/commerce-api.layout'; +import { galleryLayouts } from './samples/gallery.layout'; import { groupedPlatformLayout } from './samples/grouped-platform.layout'; import { useTheme } from './theme/theme'; @@ -33,7 +34,7 @@ function Notice({ children }: { children: string }) { function DevPages({ theme, toggle }: ReturnType) { const page = new URLSearchParams(location.search).get('page'); if (page === 'gallery') return ; - const diagram = page === 'grouped' ? groupedPlatformLayout : commerceApiLayout; + const diagram = page === 'grouped' ? groupedPlatformLayout : ((page ? galleryLayouts[page] : undefined) ?? commerceApiLayout); return ; } diff --git a/packages/viewer/src/canvas/ArrowMarker.tsx b/packages/viewer/src/canvas/ArrowMarker.tsx index 4848e64..fd42245 100644 --- a/packages/viewer/src/canvas/ArrowMarker.tsx +++ b/packages/viewer/src/canvas/ArrowMarker.tsx @@ -1,36 +1,44 @@ import { NODE_TYPES, type NodeType } from '@stackmap/core'; export const ARROW_MARKER_ID = 'sm-arrow'; -/** Marker for an edge tinted by its source type (selection / trace, Q26). */ -export const arrowMarkerId = (tint: NodeType | null) => (tint ? `${ARROW_MARKER_ID}-${tint}` : ARROW_MARKER_ID); + +/** An edge's colour: the default grey, ink for the happy path, or a node-type tint (trace, selection, tones). */ +export type EdgeColor = NodeType | 'ink' | null; +export const edgeStroke = (color: EdgeColor) => (color === null ? 'var(--sm-edge)' : color === 'ink' ? 'var(--sm-text)' : `var(--sm-${color}-accent)`); + +/** Marker for an edge of that colour; `open` is the reply arrowhead (a chevron, not a filled triangle). */ +export const arrowMarkerId = (color: EdgeColor, open = false) => `${ARROW_MARKER_ID}${color ? `-${color}` : ''}${open ? '-open' : ''}`; const LENGTH = 7; const HEIGHT = 6; // Handle dots are 7px and paint above edges; the tip stops 4px short of the port so the dot can't hide it. const TIP_GAP = 4; +const COLORS: EdgeColor[] = [null, 'ink', ...NODE_TYPES]; -// One marker per colour: `context-stroke` would be neater but isn't reliable in every engine yet. +// One marker per colour and shape: `context-stroke` would be neater but isn't reliable in every engine yet. export function ArrowMarkerDefs() { return ( - {[null, ...NODE_TYPES].map((tint) => ( - - - - ))} + {COLORS.flatMap((color) => + [false, true].map((open) => ( + + + + )), + )} ); } diff --git a/packages/viewer/src/canvas/Minimap.tsx b/packages/viewer/src/canvas/Minimap.tsx index 77eb416..6b1e690 100644 --- a/packages/viewer/src/canvas/Minimap.tsx +++ b/packages/viewer/src/canvas/Minimap.tsx @@ -60,6 +60,9 @@ export function Minimap({ scene }: { scene: Scene }) { onPointerUp={stop} onPointerCancel={stop} > + {[...scene.lanes, ...(scene.phaseStyle === 'band' ? scene.phases : [])].map((l) => ( + + ))} {scene.frames.map((f) => ( = { ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right' }; @@ -30,25 +32,97 @@ function handleStyle(side: 'in' | 'out', horizontal: boolean): CSSProperties { : { left: '50%', bottom: 0, transform: 'translate(-50%, 50%)' }; } -function Frame({ frame }: { frame: SceneFrame }) { - // Q18: groups aren't in the refs — thin dashed container, faint fill, sentence-case label in the 48px band. +function Frame({ frame, compact, onLane }: { frame: SceneFrame; compact: boolean; onLane: boolean }) { + // Q18: groups aren't in the refs — thin dashed container, faint fill, sentence-case label in the label band. + // A trust boundary (`tone: security`) takes the security tint and a shield. + const secure = frame.tone === 'security'; return (
-
+
+ {secure &&
); } +/** A swimlane: a full-width band with its label in the left rail (layout's LANE_HEAD). Exception lanes are dashed. */ +function Lane({ lane }: { lane: SceneLane }) { + const exception = lane.tone === 'exception'; + return ( +
+ {/* 128px: the layout keeps edges out of the rail's first 116px (LANE_HEAD - 28). */} +
+ {exception &&
+
+ ); +} + +/** A phase header over the columns it spans (label and hairline), or a stage band around its nodes. */ +function Phase({ phase, style, direction }: { phase: ScenePhase; style: Scene['phaseStyle']; direction: Scene['direction'] }) { + if (style === 'band') { + return ( +
+
+ {/* DOWN bands keep their label in the layout's 160px left gutter. */} + + {phase.label} + +
+
+ ); + } + return ( +
+ {phase.label} +
+ ); +} + +// What an edge looks like at rest: tones pick the colour and weight, kinds the dash (Q26 + gallery parity). +function edgeLook(e: SceneEdge, tint: EdgeColor): { color: EdgeColor; width: number; dash?: string } { + const color: EdgeColor = tint ?? (e.tone === 'main' ? 'ink' : e.tone === 'security' ? 'security' : e.tone === 'error' ? 'failure' : null); + const width = tint ? 1.75 : e.tone === 'main' ? 1.75 : e.tone ? 1.5 : 1.25; + const dash = e.kind === 'async' ? '5 4' : e.kind === 'return' ? '1 4' : undefined; + return { color, width, dash }; +} + // Memoised with stable props: an explorer change re-renders only the cards whose emphasis changed. const Card = memo(function Card({ card, horizontal, + compact, + handles, state, rects, tabbable, @@ -56,13 +130,16 @@ const Card = memo(function Card({ }: { card: SceneCard; horizontal: boolean; + compact: boolean; + /** false: the scene draws dots at the route ends instead (lane layouts) */ + handles: boolean; state: NodeEmphasis; rects: Record; /** roving tabindex: one card is the canvas's tab stop, arrows move between the rest */ tabbable: boolean; onFocused: (id: string) => void; }) { - const { node, rect, hasIn, hasOut } = card; + const { node, rect, hasIn, hasOut, final } = card; const dispatch = useExploreDispatch(); const camera = useCamera(); const accent = { '--sm-handle': `var(--sm-${node.type}-accent)` } as CSSProperties; @@ -103,9 +180,9 @@ const Card = memo(function Card({ }} onKeyDown={onKeyDown} > - - {hasIn &&
); }); @@ -123,13 +200,20 @@ export const SceneLayers = memo(function SceneLayers({ selected: string | null; }) { const horizontal = scene.direction === 'RIGHT'; + const typeOf = useMemo(() => new Map(scene.cards.map((c) => [c.node.id, c.node.type])), [scene]); const rects = useMemo(() => Object.fromEntries(scene.cards.map((c) => [c.node.id, c.rect])), [scene]); const [lastFocused, setLastFocused] = useState(null); const tabStop = selected ?? (lastFocused && rects[lastFocused] ? lastFocused : scene.cards[0]?.node.id); return ( <> + {scene.lanes.map((l) => ( + + ))} + {scene.phases.map((p) => ( + + ))} {scene.frames.map((f) => ( - + 0} /> ))} {scene.edges.map((e) => { const { dim, tint } = emphasis.edges.get(e.id) ?? { dim: false, tint: null }; + const look = edgeLook(e, tint); return ( ); })} + {/* Lifecycle: a start state's initial marker, a filled dot with a stub into the card (UML). */} + {scene.kind === 'lifecycle' && + scene.cards + .filter((c) => c.node.type === 'start') + .map((c) => { + const y = c.rect.y + c.rect.height / 2; + const dim = emphasis.nodes.get(c.node.id) === 'dim' || undefined; + return ( + + + + + ); + })} {scene.cards.map((c) => ( ))} + {scene.handles?.map((h) => ( +