From 614bdcacc932101467825f29b68a2b8a5e20a4e9 Mon Sep 17 00:00:00 2001 From: Joseph Dale Banares Date: Sat, 3 Oct 2026 14:09:06 +0800 Subject: [PATCH 01/14] fix(layout): lay replies and loop-closing edges out against the flow, give labelled and distinct edges their own ports, and seat labels by the card they name --- packages/layout/src/index.ts | 187 ++++++++++++++++++---- packages/layout/src/labels.ts | 91 +++++++++-- packages/layout/src/lanes.ts | 2 +- packages/layout/test/corpus.ts | 23 +++ packages/layout/test/fuzz.test.ts | 84 ++++++++++ packages/layout/test/labels.test.ts | 87 ++++++++--- packages/layout/test/layout.test.ts | 71 +++++++++ packages/layout/test/quality.test.ts | 59 +++++++ packages/layout/test/stress.ts | 222 +++++++++++++++++++++++++++ 9 files changed, 761 insertions(+), 65 deletions(-) create mode 100644 packages/layout/test/corpus.ts create mode 100644 packages/layout/test/quality.test.ts create mode 100644 packages/layout/test/stress.ts diff --git a/packages/layout/src/index.ts b/packages/layout/src/index.ts index e6b291a..47b0ab2 100644 --- a/packages/layout/src/index.ts +++ b/packages/layout/src/index.ts @@ -1,7 +1,7 @@ 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 type { ElkExtendedEdge, ElkNode } from 'elkjs/lib/elk-api'; import { cardSize, isLaneKind, usesCompactCards, type DiagramDraft, type Direction, type LaidOutDiagram, type Point, type Rect } from '@stackmap/core'; import { placeLabels } from './labels'; import { layoutLanes } from './lanes'; @@ -16,6 +16,11 @@ export const GROUP_LABEL_BAND = 48; export const STAGE_LABEL_BAND = 44; const STAGE_PAD = 20; const COMPACT_GAP = { right: 72, max: 168 } as const; +/** Widest layer gap a full-card layout grows to so its labels fit. */ +const FULL_GAP_MAX = 280; +const GROUP_LAYER_GAP = 80; +const LAYER_GAP = 'elk.layered.spacing.nodeNodeBetweenLayers'; +const EDGE_RUN = 'elk.layered.spacing.edgeNodeBetweenLayers'; const GROUP_PREFIX = 'group:'; // elkjs's bundled build treats any runtime with a global `self` and no `document` as a web worker @@ -66,23 +71,54 @@ const wrapOptions: Record = { 'elk.layered.wrapping.additionalEdgeSpacing': '40', }; -// One fixed in/out port per node is what makes fan-in/fan-out collapse into shared trunks. -function ports(id: string, width: number, height: number, direction: Direction): ElkPort[] { - const horizontal = direction === 'RIGHT'; - return [ - { - id: `${id}:in`, - x: horizontal ? 0 : width / 2, - y: horizontal ? height / 2 : 0, - layoutOptions: { 'elk.port.side': horizontal ? 'WEST' : 'NORTH' }, - }, - { - id: `${id}:out`, - x: horizontal ? width : width / 2, - y: horizontal ? height / 2 : height, - layoutOptions: { 'elk.port.side': horizontal ? 'EAST' : 'SOUTH' }, - }, - ]; +/** + * Edges laid out against their drawn direction: every reply (`return`), then whatever still closes a cycle, found by + * a depth-first walk from the entry points in draft order. ELK would otherwise break cycles itself and may pick the + * entry call, pushing the caller below what it calls and sending that call round the whole diagram. + */ +export function reversedEdges(draft: DiagramDraft): Set { + const reversed = new Set(draft.edges.filter((e) => e.kind === 'return').map((e) => e.id)); + const out = new Map(draft.nodes.map((n) => [n.id, []])); + const fed = new Set(); + for (const e of draft.edges) { + if (e.from === e.to) continue; + const [a, b] = reversed.has(e.id) ? [e.to, e.from] : [e.from, e.to]; + out.get(a)?.push({ id: e.id, to: b }); + fed.add(b); + } + const state = new Map(); + const visit = (start: string) => { + const stack: { id: string; next: number }[] = [{ id: start, next: 0 }]; + state.set(start, 'open'); + while (stack.length) { + const top = stack.at(-1)!; + const edge = out.get(top.id)![top.next++]; + if (!edge) { + state.set(top.id, 'done'); + stack.pop(); + } else if (state.get(edge.to) === 'open') { + // Points back at a node on the current path: flipping it (twice for a reply) keeps the flow acyclic. + if (reversed.has(edge.id)) reversed.delete(edge.id); + else reversed.add(edge.id); + } else if (!state.has(edge.to)) { + state.set(edge.to, 'open'); + stack.push({ id: edge.to, next: 0 }); + } + } + }; + for (const n of draft.nodes) if (!fed.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 reversed; +} + +/** + * The port an edge end attaches to. Unlabelled edges of one style leaving (or entering) a card the same way share + * one, so fan-in and fan-out merge into a trunk; a labelled edge, or one of another tone or line style, gets its + * own: on a shared trunk a label can't say which branch it names, and colours would hide each other. + */ +function portKey(e: DiagramDraft['edges'][number], node: string, role: 'out' | 'in', flipped: boolean): string { + if (e.label) return `${node}:${role}:${e.id}`; + return `${node}:${role}:${flipped ? 'flipped' : 'flow'}:${e.tone ?? ''}:${e.kind ?? 'sync'}`; } const round = (n: number) => Math.round(n * 100) / 100; @@ -113,7 +149,7 @@ export async function layoutDiagram(draft: DiagramDraft): Promise(); + const index = (c: ElkNode) => c.children?.forEach((k) => (elkNodes.set(k.id, k), index(k))); + index(root); + const wire = (reversed: Set) => { + for (const n of elkNodes.values()) if (n.ports) n.ports = []; + root.edges = []; + const portOf = (node: string, role: 'out' | 'in', key: string) => { + const owner = elkNodes.get(node)!; + if (!owner.ports!.some((p) => p.id === key)) { + const side = direction === 'RIGHT' ? (role === 'out' ? 'EAST' : 'WEST') : role === 'out' ? 'SOUTH' : 'NORTH'; + owner.ports!.push({ id: key, width: 0, height: 0, layoutOptions: { 'elk.port.side': side } }); + } + return key; + }; + for (const e of draft.edges) { + const flipped = reversed.has(e.id); + // Laid out from its target to its source; collect() turns the route back round. + const [a, b] = flipped ? [e.to, e.from] : [e.from, e.to]; + root.edges.push({ id: e.id, sources: [portOf(a, 'out', portKey(e, a, 'out', flipped))], targets: [portOf(b, 'in', portKey(e, b, 'in', flipped))] }); + } + }; + let reversed = reversedEdges(draft); + wire(reversed); const { elk, dispose } = createElk(); try { - 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 }); + const run = async (room?: { gap: number; run: number }) => { + const graph = structuredClone(root); + if (room) { + // The straight run into (and out of) a card becomes long enough to hold a label by the arrowhead. + const set = (o: Record, gap: number) => { + o[LAYER_GAP] = String(gap); + o[EDGE_RUN] = String(room.run); + }; + set(graph.layoutOptions!, room.gap); + // Groups lay out their own layers, so the wider gap has to reach them too. + const widen = (c: ElkNode) => + c.children?.forEach((k) => { + if (k.id.startsWith(GROUP_PREFIX)) set(k.layoutOptions!, Math.max(GROUP_LAYER_GAP, room.gap)); + widen(k); + }); + widen(graph); + } + const opts = graph.layoutOptions!; + const flat = await elk.layout(structuredClone(graph)); // 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); + const wrap = (flat.width ?? 0) > MAX_UNWRAPPED_WIDTH && !stages; + const result = wrap ? await elk.layout({ ...structuredClone(graph), layoutOptions: { ...opts, ...wrapOptions } }) : flat; + // Flow order is read before wrapping, which moves later layers back to the start of a new row. + return { laid: collect(draft, result, direction, stages !== null, reversed), flow: collect(draft, flat, direction, false, reversed).nodes }; }; - let laid = await run(); - const place = (l: LaidOutDiagram) => placeLabels(draft.edges, l.edges, Object.values(l.nodes), !compact); + let { laid, flow } = await run(); + // A group that both feeds and is fed by another can only sit on one side of it, so some edge between them runs + // against the flow: entering its target from the far side, it wraps round the diagram. Lay such an edge out + // from its target (it then leaves and enters by the facing sides) and redo the layout once. + const flips = againstFlow(draft, flow, direction, reversed); + if (flips.length) { + reversed = new Set(reversed); + for (const id of flips) reversed.has(id) ? reversed.delete(id) : reversed.add(id); + wire(reversed); + ({ laid } = await run()); + } + const place = (l: LaidOutDiagram) => placeLabels(draft.edges, l.edges, Object.values(l.nodes), !compact, Object.values(l.groups).map((rect) => ({ rect, band: GROUP_LABEL_BAND }))); let spots = place(laid); - // Compact layouts start with tight layer gaps and widen them once if a label found no room to sit. - if (compact && spots.misfit > 0) { - laid = await run(Math.min(COMPACT_GAP.max, Math.max(Number(root.layoutOptions!['elk.layered.spacing.nodeNodeBetweenLayers']), spots.misfit + 24))); - spots = place(laid); + // A label that found no room to sit widens the layer gaps: compact layouts start tight on purpose, and a full + // layout's gap can still be narrower than a label beside a branch. Two ways to make room are tried: wider gaps, + // and wider gaps whose straight runs into and out of cards are themselves long enough to hold the label (left to + // right a label lies along the run, top-down beside it). The best of the three layouts is kept. + if (spots.misfit > 0) { + const cap = compact ? COMPACT_GAP.max : FULL_GAP_MAX; + const defaultRun = Number(root.layoutOptions![EDGE_RUN]); + const gap = Math.min(cap, Math.max(Number(root.layoutOptions![LAYER_GAP]), spots.misfit + (compact ? 24 : 48))); + const longRun = Math.min(direction === 'RIGHT' ? spots.misfit + 16 : 40, Math.floor((cap - 24) / 2)); + const tries = [ + { gap, run: defaultRun }, + { gap: Math.max(gap, 2 * longRun + 24), run: longRun }, + ]; + const worse = (a: typeof spots, b: typeof spots) => a.forced - b.forced || a.unseated - b.unseated || a.misfit - b.misfit; + for (const room of tries) { + const wider = (await run(room)).laid; + const retry = place(wider); + if (worse(retry, spots) < 0) [laid, spots] = [wider, retry]; + } } return { ...laid, labels: spots.labels }; } finally { @@ -171,7 +273,23 @@ export async function layoutDiagram(draft: DiagramDraft): Promise, direction: Direction, reversed: Set): string[] { + const span = (r: Rect): [number, number] => (direction === 'RIGHT' ? [r.x, r.x + r.width] : [r.y, r.y + r.height]); + return draft.edges.flatMap((e) => { + if (e.from === e.to) return []; + const [s0, s1] = span(nodes[e.from]!); + const [t0, t1] = span(nodes[e.to]!); + const before = t1 <= s0; + const after = t0 >= s1; + return (reversed.has(e.id) ? after : before) ? [e.id] : []; + }); +} + +function collect(draft: DiagramDraft, result: ElkNode, direction: Direction, staged: boolean, reversed: Set): LaidOutDiagram { const nodes: Record = {}; const groups: Record = {}; const edges: Record = {}; @@ -187,6 +305,7 @@ function collect(draft: DiagramDraft, result: ElkNode, direction: Direction, sta edges[e.id] = (e.sections ?? []) .flatMap((s) => [s.startPoint, ...(s.bendPoints ?? []), s.endPoint]) .map((p) => ({ x: round(p.x), y: round(p.y) })); + if (reversed.has(e.id)) edges[e.id]!.reverse(); } }; visit(result); diff --git a/packages/layout/src/labels.ts b/packages/layout/src/labels.ts index 71a3642..70d69bd 100644 --- a/packages/layout/src/labels.ts +++ b/packages/layout/src/labels.ts @@ -18,26 +18,77 @@ const crossed = (r: Rect, lines: Point[][]) => ); const pillAt = (p: Point, w: number): Rect => ({ x: p.x - w / 2, y: p.y - LABEL_H / 2, width: w, height: LABEL_H }); +/** A group frame: labels keep off its border and its title. */ +export interface LabelFrame { + rect: Rect; + /** height of the title band along the frame's top */ + band: number; +} +/** Where along its route a label should sit: by the target on a fan-out, by the source on a fan-in. */ +type Bias = 'start' | 'mid' | 'end'; +// The frame title's box: the viewer's title is short, so a fixed strip from the corner covers it. +const TITLE = { width: 160, height: 28 }; + +/** Frame borders as closed polylines, and the strips their titles take. */ +function frameObstacles(frames: LabelFrame[]): { lines: Point[][]; titles: Rect[] } { + return { + lines: frames.map(({ rect: r }) => [ + { x: r.x, y: r.y }, + { x: r.x + r.width, y: r.y }, + { x: r.x + r.width, y: r.y + r.height }, + { x: r.x, y: r.y + r.height }, + { x: r.x, y: r.y }, + ]), + titles: frames.map(({ rect: r, band }) => ({ x: r.x, y: r.y, width: Math.min(r.width, TITLE.width), height: Math.max(band, TITLE.height) })), + }; +} + +/** A card with three or more edges leaving (or entering) puts their labels by their other ends, where they differ. */ +function biases(edges: { id: string; from?: string; to?: string }[]): Map { + const outs = new Map(); + const ins = new Map(); + for (const e of edges) { + if (!e.from || !e.to || e.from === e.to) continue; + outs.set(e.from, (outs.get(e.from) ?? 0) + 1); + ins.set(e.to, (ins.get(e.to) ?? 0) + 1); + } + return new Map( + edges.map((e) => { + const fanOut = e.from ? (outs.get(e.from) ?? 0) : 0; + const fanIn = e.to ? (ins.get(e.to) ?? 0) : 0; + return [e.id, fanOut >= 3 && fanOut > fanIn ? 'end' : fanIn >= 3 && fanIn > fanOut ? 'start' : 'mid']; + }), + ); +} + /** * Every label of a laid-out diagram, in draft order. A label goes on the longest run of its route that holds its * pill clear of cards, other labels and other edges' lines; along a run it may slide off the midpoint to clear a * neighbour. Horizontal runs are preferred. Where no spot clears the lines, one that clears cards and labels does, * and failing that the middle of the longest run. With `keepMidpoints`, a label whose route midpoint is already * clear stays there (where the viewer drew it before full layouts placed labels), so only the crowded ones move. - * `misfit` is the widest label that found no fully clear spot, 0 when all did. + * Frames' borders count as lines and their titles as cards. On a fan-out or fan-in a label sits as near the far card + * as it fits, so it reads with the card it names instead of joining a cluster at the shared one. + * `misfit` is the widest label that found no fully clear spot, 0 when all did; `unseated` counts them, and + * `forced` counts those that could only go over a card or another label. */ export function placeLabels( - edges: { id: string; label?: string }[], + edges: { id: string; label?: string; from?: string; to?: string }[], routes: Record, - cards: Rect[], + cardRects: Rect[], keepMidpoints = false, -): { labels: Record; misfit: number } { + frames: LabelFrame[] = [], +): { labels: Record; misfit: number; unseated: number; forced: number } { const labelled = edges.filter((e) => e.label && routes[e.id]); - const others = (id: string) => Object.entries(routes).flatMap(([k, pts]) => (k === id ? [] : [pts])); + const { lines: borders, titles } = frameObstacles(frames); + const cards = [...cardRects, ...titles]; + const bias = biases(edges); + const others = (id: string) => [...Object.entries(routes).flatMap(([k, pts]) => (k === id ? [] : [pts])), ...borders]; const taken: Rect[] = []; const labels: Record = {}; if (keepMidpoints) { for (const e of labelled) { + if (bias.get(e.id) !== 'mid') continue; const p = polylineMidpoint(routes[e.id]!); const pill = pillAt(p, labelWidth(e.label!)); if (cards.some((r) => overlaps(pill, r)) || taken.some((r) => overlaps(pill, r)) || crossed(pill, others(e.id))) continue; @@ -47,25 +98,39 @@ export function placeLabels( } } let misfit = 0; + let unseated = 0; for (const e of labelled) { if (labels[e.id]) continue; - const spot = findLabelSpot(routes[e.id]!, e.label!, cards, taken, others(e.id)); + const spot = findLabelSpot(routes[e.id]!, e.label!, cards, taken, others(e.id), bias.get(e.id)); if (spot) labels[e.id] = spot; - else misfit = Math.max(misfit, labelWidth(e.label!)); + else { + misfit = Math.max(misfit, labelWidth(e.label!)); + unseated++; + } + } + let forced = 0; + for (const e of labelled) { + if (labels[e.id]) continue; + const spot = findLabelSpot(routes[e.id]!, e.label!, cards, taken, [], bias.get(e.id)); + if (!spot) forced++; + labels[e.id] = spot ?? claimFallback(routes[e.id]!, e.label!, taken); } - for (const e of labelled) if (!labels[e.id]) labels[e.id] = findLabelSpot(routes[e.id]!, e.label!, cards, taken) ?? claimFallback(routes[e.id]!, e.label!, taken); - return { labels, misfit }; + return { labels, misfit, unseated, forced }; } /** A spot that fits, claimed in `taken`; null when none does. */ -export function findLabelSpot(points: Point[], text: string, cards: Rect[], taken: Rect[], lines: Point[][] = []): Point | null { +export function findLabelSpot(points: Point[], text: string, cards: Rect[], taken: Rect[], lines: Point[][] = [], bias: Bias = 'mid'): Point | null { const w = labelWidth(text); let best: { p: Point; score: number } | null = null; + const total = points.slice(1).reduce((s, b, i) => s + Math.abs(b.x - points[i]!.x) + Math.abs(b.y - points[i]!.y), 0); + let walked = 0; 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 from = walked; + walked += len; 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 @@ -77,8 +142,10 @@ export function findLabelSpot(points: Point[], text: string, cards: Rect[], take 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, w); if (cards.some((r) => overlaps(pill, r)) || taken.some((r) => overlaps(pill, r)) || crossed(pill, lines)) 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); + // Longer and horizontal runs first; off-centre and off-line spots lose a little. A biased label goes as near + // its far end as it fits instead. + const reach = bias === 'end' ? total - (from + t * len) : bias === 'start' ? from + t * len : null; + const score = (reach === null ? (horizontal ? 2 : 1) * len - Math.abs(t - 0.5) * 40 : -reach + (horizontal ? 20 : 0)) - (dx || dy ? 30 : 0); if (!best || score > best.score) best = { p, score }; break search; } diff --git a/packages/layout/src/lanes.ts b/packages/layout/src/lanes.ts index 6b4544b..8b97147 100644 --- a/packages/layout/src/lanes.ts +++ b/packages/layout/src/lanes.ts @@ -182,7 +182,7 @@ export function layoutLanes(draft: DiagramDraft): LaidOutDiagram { if (routed[e.id]) edges[e.id] = routed[e.id]!.map((p) => ({ x: round(p.x), y: round(p.y) })); } - const { labels } = placeLabels(draft.edges, edges, Object.values(rects)); + const { labels } = placeLabels(draft.edges, edges, Object.values(rects), false, Object.values(groupRects).map((rect) => ({ rect, band: GROUP_LABEL }))); const height = y - LANE_GAP + PAD; return { diff --git a/packages/layout/test/corpus.ts b/packages/layout/test/corpus.ts new file mode 100644 index 0000000..dfb1f80 --- /dev/null +++ b/packages/layout/test/corpus.ts @@ -0,0 +1,23 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import type { DiagramDraft } from '@stackmap/core'; +import { GALLERY } from '@stackmap/core/gallery'; +import { commerceApi, groupedPlatform } from '@stackmap/core/samples'; +import { STRESS } from './stress'; + +const read = (dir: URL) => + readdirSync(dir) + .filter((f) => f.endsWith('.json')) + .map((f) => [f, JSON.parse(readFileSync(new URL(f, dir), 'utf8')) as DiagramDraft] as const); + +/** + * Everything stackmap ships a picture of (the samples, the archify gallery, the skill's examples and the site's + * gallery) plus the stress set, sequences aside: those lay out on their own and draw no ports. + */ +export const CORPUS: (readonly [string, DiagramDraft])[] = [ + ['commerceApi', commerceApi] as const, + ['groupedPlatform', groupedPlatform] as const, + ...Object.entries(GALLERY).map(([name, d]) => [name, d] as const), + ...read(new URL('../../../skill/examples/', import.meta.url)), + ...read(new URL('../../../site/content/examples/', import.meta.url)), + ...Object.entries(STRESS).map(([name, d]) => [`stress ${name}`, d] as const), +].filter(([, d]) => d.kind !== 'sequence'); diff --git a/packages/layout/test/fuzz.test.ts b/packages/layout/test/fuzz.test.ts index 09c8b58..b0fff40 100644 --- a/packages/layout/test/fuzz.test.ts +++ b/packages/layout/test/fuzz.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import type { DiagramDraft, Point, Rect } from '@stackmap/core'; +import { layoutDiagram } from '../src/index'; import { layoutLanes } from '../src/lanes'; // Property test: random workflows and lifecycles (tones, returns, async, labels, tags, phases, groups, self-loops, @@ -66,3 +67,86 @@ describe('lane layout properties', () => { 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); }); }); + +// The same for architecture and dataflow (ELK): random groups (nested too), directions, compact cards, stages, tones, +// replies, async edges, labels, self-loops and parallel edges. +function elkGenerator(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 n = 2 + Math.floor(rnd() * 13); + const groups = Array.from({ length: Math.floor(rnd() * 4) }, (_, i) => ({ id: `g${i}`, label: `Group ${i}`, ...(i > 0 && rnd() < 0.3 ? { parent: `g${i - 1}` } : {}) })); + const staged = !groups.length && rnd() < 0.3; + const nodes = Array.from({ length: n }, (_, i) => ({ + id: `n${i}`, + type: pick(['service', 'client', 'database', 'queue', 'external', 'security'] as const), + ...(groups.length && rnd() < 0.7 ? { group: pick(groups).id } : {}), + card: { title: `N${i}`, rows: Array.from({ length: Math.floor(rnd() * 4) }, (_, j) => ({ label: `k${j}`, value: 'v' })) }, + })); + const edges = Array.from({ length: Math.floor(rnd() * n * 1.8) }, (_, i) => { + const r = rnd(); + return { + id: `e${i}`, + from: pick(nodes).id, + to: pick(nodes).id, + ...(rnd() < 0.5 ? { label: pick(['reads', 'writes pin', 'stream + metadata', 'gRPC', 'enqueue']) } : {}), + ...(r < 0.15 ? { kind: 'return' as const } : r < 0.3 ? { kind: 'async' as const } : {}), + ...(rnd() < 0.3 ? { tone: pick(['main', 'security', 'error'] as const) } : {}), + }; + }); + const d: DiagramDraft = { kind: pick(['architecture', 'dataflow'] as const), title: 'fuzz', direction: pick(['RIGHT', 'DOWN'] as const), groups, nodes, edges }; + if (rnd() < 0.3) d.density = 'compact'; + if (staged) { + 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; + }; +} + +describe('ELK layout properties', () => { + const next = elkGenerator(20261003); + const cases = Array.from({ length: 200 }, next); + + it.each(cases.map((d, i) => [i, d] as const))('random diagram %i lays out soundly', async (_i, d) => { + const out = await layoutDiagram(d); + expect(Number.isFinite(out.bounds.width) && Number.isFinite(out.bounds.height)).toBe(true); + const ports = new Map>(); + for (const e of d.edges) { + const pts = out.edges[e.id]!; + expect(pts.length, e.id).toBeGreaterThanOrEqual(2); + for (let i = 1; i < pts.length; i++) expect(Math.abs(pts[i]!.x - pts[i - 1]!.x) < 0.5 || Math.abs(pts[i]!.y - pts[i - 1]!.y) < 0.5, `${e.id} is orthogonal`).toBe(true); + if (e.from === e.to) continue; + 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); + } + // A target wholly before or after its source along the flow is entered by the side facing it (same row only: + // a wrapped layout carries forward edges back to the start of the next row). + const [s, t] = [out.nodes[e.from]!, out.nodes[e.to]!]; + const end = pts.at(-1)!; + if (d.direction === 'DOWN') { + if (t.y + t.height <= s.y) expect(Math.abs(end.y - t.y - t.height) < 0.5, `${e.id} enters the bottom`).toBe(true); + if (t.y >= s.y + s.height) expect(Math.abs(end.y - t.y) < 0.5, `${e.id} enters the top`).toBe(true); + } else if (s.y < t.y + t.height && t.y < s.y + s.height) { + if (t.x + t.width <= s.x) expect(Math.abs(end.x - t.x - t.width) < 0.5, `${e.id} enters the right`).toBe(true); + if (t.x >= s.x + s.width) expect(Math.abs(end.x - t.x) < 0.5, `${e.id} enters the left`).toBe(true); + } + // Each end sits on its own card's border. + for (const [id, p] of [[e.from, pts[0]!], [e.to, pts.at(-1)!]] as const) { + const r = out.nodes[id]!; + const onBorder = (Math.abs(p.x - r.x) < 0.5 || Math.abs(p.x - r.x - r.width) < 0.5 ? p.y >= r.y - 0.5 && p.y <= r.y + r.height + 0.5 : false) || (Math.abs(p.y - r.y) < 0.5 || Math.abs(p.y - r.y - r.height) < 0.5 ? p.x >= r.x - 0.5 && p.x <= r.x + r.width + 0.5 : false); + expect(onBorder, `${e.id} ends on ${id}`).toBe(true); + } + const style = e.label ? `label:${e.id}` : `${e.tone ?? ''}|${e.kind ?? 'sync'}`; + for (const [role, node, p] of [['out', e.from, pts[0]!], ['in', e.to, pts.at(-1)!]] as const) { + const key = `${node}@${p.x},${p.y}`; + ports.set(key, (ports.get(key) ?? new Set()).add(`${role}:${style}`)); + } + } + // One look per port, and never an arrival where another edge leaves. + expect([...ports].filter(([, styles]) => styles.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/labels.test.ts b/packages/layout/test/labels.test.ts index c54d4c3..ae422b3 100644 --- a/packages/layout/test/labels.test.ts +++ b/packages/layout/test/labels.test.ts @@ -1,31 +1,16 @@ -import { readdirSync, readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; import { polylineMidpoint, type DiagramDraft, type LaidOutDiagram, type Point, type Rect } from '@stackmap/core'; -import { GALLERY } from '@stackmap/core/gallery'; -import { commerceApi, groupedPlatform } from '@stackmap/core/samples'; import { labelWidth } from '../src/labels'; import { layoutDiagram } from '../src/index'; +import { CORPUS } from './corpus'; +import { STRESS } from './stress'; 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; /** 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 && Math.min(a.x, b.x) < r.x + r.width && Math.max(a.y, b.y) > r.y && Math.min(a.y, b.y) < r.y + r.height; -const read = (dir: URL) => - readdirSync(dir) - .filter((f) => f.endsWith('.json')) - .map((f) => [f, JSON.parse(readFileSync(new URL(f, dir), 'utf8')) as DiagramDraft] as const); - -// Everything stackmap ships a picture of: the samples, the archify gallery, the skill's examples and the site's gallery. -const shipped: (readonly [string, DiagramDraft])[] = [ - ['commerceApi', commerceApi] as const, - ['groupedPlatform', groupedPlatform] as const, - ...Object.entries(GALLERY).map(([name, d]) => [name, d] as const), - ...read(new URL('../../../skill/examples/', import.meta.url)), - ...read(new URL('../../../site/content/examples/', import.meta.url)), -].filter(([, d]) => d.kind !== 'sequence'); - -const laid = await Promise.all(shipped.map(async ([name, d]) => [name, d, await layoutDiagram(d)] as const)); +const laid = await Promise.all(CORPUS.map(async ([name, d]) => [name, d, await layoutDiagram(d)] as const)); /** Each label's pill where the viewer draws it: the layout's spot, else the route's midpoint. */ function pills(draft: DiagramDraft, out: LaidOutDiagram): { id: string; rect: Rect }[] { @@ -62,3 +47,69 @@ describe.each(laid)('edge labels in %s', (_name, draft, out) => { } }); }); + +/** Distance from a point to a polyline. */ +const distance = (p: Point, pts: Point[]) => + Math.min( + ...pts.slice(1).map((b, i) => { + const a = pts[i]!; + const x = Math.max(Math.min(a.x, b.x), Math.min(p.x, Math.max(a.x, b.x))); + const y = Math.max(Math.min(a.y, b.y), Math.min(p.y, Math.max(a.y, b.y))); + return Math.hypot(p.x - x, p.y - y); + }), + ); +/** Length of a polyline up to the point on it nearest `p`, as a share of its whole length. */ +function along(p: Point, pts: Point[]): number { + let total = 0; + let best = { d: Infinity, at: 0 }; + for (let i = 1; i < pts.length; i++) { + const [a, b] = [pts[i - 1]!, pts[i]!]; + const len = Math.abs(a.x - b.x) + Math.abs(a.y - b.y); + const x = Math.max(Math.min(a.x, b.x), Math.min(p.x, Math.max(a.x, b.x))); + const y = Math.max(Math.min(a.y, b.y), Math.min(p.y, Math.max(a.y, b.y))); + const d = Math.hypot(p.x - x, p.y - y); + if (d < best.d) best = { d, at: total + Math.abs(x - a.x) + Math.abs(y - a.y) }; + total += len; + } + return best.at / total; +} + +describe.each(laid)('edge label legibility in %s', (_name, draft, out) => { + const all = pills(draft, out); + + it('keeps labels off group frames and their titles', () => { + for (const p of all) { + for (const [id, g] of Object.entries(out.groups)) { + const border = [ + { x: g.x, y: g.y }, + { x: g.x + g.width, y: g.y }, + { x: g.x + g.width, y: g.y + g.height }, + { x: g.x, y: g.y + g.height }, + { x: g.x, y: g.y }, + ]; + expect(border.slice(1).some((b, i) => crosses(border[i]!, b, p.rect)), `${p.id} on the frame of ${id}`).toBe(false); + expect(overlap(p.rect, { x: g.x, y: g.y, width: Math.min(g.width, 160), height: 28 }), `${p.id} on the title of ${id}`).toBe(false); + } + } + }); + + it('puts every label nearer its own edge than any other', () => { + for (const p of all) { + const c = { x: p.rect.x + p.rect.width / 2, y: p.rect.y + 10 }; + const own = distance(c, out.edges[p.id]!); + for (const [id, pts] of Object.entries(out.edges)) if (id !== p.id) expect(distance(c, pts), `${p.id} nearer ${id}`).toBeGreaterThanOrEqual(own); + } + }); +}); + +describe('labels on a fan-out', () => { + it('sit on the far half of each edge, by the card they name, not by the shared source', async () => { + for (const name of ['hub-down', 'hub-right'] as const) { + const d = STRESS[name]!; + const out = await layoutDiagram(d); + for (const e of d.edges.filter((e) => e.from === 'handler' && e.label && e.kind !== 'return')) { + expect(along(out.labels![e.id]!, out.edges[e.id]!), `${name} ${e.id}`).toBeGreaterThan(0.5); + } + } + }); +}); diff --git a/packages/layout/test/layout.test.ts b/packages/layout/test/layout.test.ts index 24c6704..e17aec4 100644 --- a/packages/layout/test/layout.test.ts +++ b/packages/layout/test/layout.test.ts @@ -121,6 +121,77 @@ describe('layoutDiagram', () => { } }); + it('lays a reply out against the flow, so the caller stays upstream of what it calls', async () => { + const out = await layoutDiagram( + draft({ + direction: 'DOWN', + groups: [{ id: 'ui', label: 'UI' }, { id: 'api', label: 'API' }], + nodes: [node('api', { group: 'api' }), node('feed', { type: 'client', group: 'ui' }), node('picker', { type: 'client', group: 'ui' })], + edges: [ + { id: 'send', from: 'picker', to: 'api' }, + { id: 'reply', from: 'api', to: 'feed', kind: 'return' }, + ], + }), + ); + expect(out.groups.ui!.y + out.groups.ui!.height).toBeLessThan(out.groups.api!.y); + // The call drops straight in; the reply climbs back up from the callee's top to the caller's bottom. + expect(out.edges.send!.length).toBeLessThanOrEqual(4); + const reply = out.edges.reply!; + expect(reply[0]!.y).toBeCloseTo(out.nodes.api!.y, 0); + expect(reply.at(-1)!.y).toBeCloseTo(out.nodes.feed!.y + out.nodes.feed!.height, 0); + }); + + it('breaks a cycle at the edge that points back to where the flow started', async () => { + const out = await layoutDiagram( + draft({ + direction: 'DOWN', + nodes: [node('a'), node('b'), node('c')], + edges: [ + { id: 'ab', from: 'a', to: 'b' }, + { id: 'bc', from: 'b', to: 'c' }, + { id: 'ca', from: 'c', to: 'a', label: 'retry' }, + ], + }), + ); + expect(out.nodes.a!.y).toBeLessThan(out.nodes.b!.y); + expect(out.nodes.b!.y).toBeLessThan(out.nodes.c!.y); + // The back edge leaves c by its top and enters a by its bottom instead of wrapping round the diagram. + const ca = out.edges.ca!; + expect(ca[0]!.y).toBeCloseTo(out.nodes.c!.y, 0); + expect(ca.at(-1)!.y).toBeCloseTo(out.nodes.a!.y + out.nodes.a!.height, 0); + }); + + it('gives labelled edges and edges of different styles their own port', async () => { + const targets = ['t1', 't2', 't3', 't4']; + for (const direction of ['RIGHT', 'DOWN'] as const) { + const out = await layoutDiagram( + draft({ + direction, + nodes: [node('hub'), ...targets.map((t) => node(t)), node('m'), node('s')], + edges: [ + ...targets.map((t) => ({ id: t, from: 'hub', to: t, label: `to ${t}` })), + { id: 'main', from: 'hub', to: 'm', tone: 'main' as const }, + { id: 'sec', from: 'hub', to: 's', tone: 'security' as const }, + ], + }), + ); + const starts = Object.values(out.edges).map((pts) => `${pts[0]!.x},${pts[0]!.y}`); + expect(new Set(starts).size, direction).toBe(starts.length); + } + }); + + it('still merges unlabelled edges of one style into a shared port', async () => { + const out = await layoutDiagram( + draft({ + direction: 'DOWN', + nodes: [node('hub'), node('a'), node('b'), node('c')], + edges: ['a', 'b', 'c'].map((t) => ({ id: t, from: 'hub', to: t })), + }), + ); + const starts = Object.values(out.edges).map((pts) => `${pts[0]!.x},${pts[0]!.y}`); + expect(new Set(starts).size).toBe(1); + }); + it('is deterministic', async () => { expect(await layoutDiagram(groupedPlatform)).toEqual(await layoutDiagram(groupedPlatform)); }); diff --git a/packages/layout/test/quality.test.ts b/packages/layout/test/quality.test.ts new file mode 100644 index 0000000..3e9a5e4 --- /dev/null +++ b/packages/layout/test/quality.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest'; +import { isLaneKind, type Point, type Rect } from '@stackmap/core'; +import { layoutDiagram } from '../src/index'; +import { CORPUS } from './corpus'; + +// Properties that keep a layout readable whatever the author draws: these failed on real diagrams before (a hub's +// calls merged into one unlabellable trunk, a reply pushing the client below the API, an entry call wrapping round +// the whole picture to come in from the far side). + +const laid = await Promise.all(CORPUS.filter(([, d]) => !isLaneKind(d.kind)).map(async ([name, d]) => [name, d, await layoutDiagram(d)] as const)); + +const length = (pts: Point[]) => pts.slice(1).reduce((s, b, i) => s + Math.abs(b.x - pts[i]!.x) + Math.abs(b.y - pts[i]!.y), 0); +const centre = (r: Rect) => ({ x: r.x + r.width / 2, y: r.y + r.height / 2 }); +const near = (a: number, b: number) => Math.abs(a - b) < 0.5; + +describe.each(laid)('layout quality of %s', (_name, draft, out) => { + it('never lets edges that look different, or a labelled edge, share a port', () => { + const at = new Map>(); + for (const e of draft.edges) { + const pts = out.edges[e.id]!; + const style = e.label ? `label:${e.id}` : `${e.tone ?? ''}|${e.kind ?? 'sync'}`; + for (const [node, p, role] of [ + [e.from, pts[0]!, 'out'], + [e.to, pts.at(-1)!, 'in'], + ] as const) { + const key = `${node}@${p.x},${p.y}`; + at.set(key, (at.get(key) ?? new Set()).add(`${role}:${style}`)); + } + } + for (const [port, styles] of at) expect([...styles], port).toHaveLength(1); + }); + + it('brings every edge into its target by the side that faces its source', () => { + const horizontal = (draft.direction ?? 'RIGHT') === 'RIGHT'; + for (const e of draft.edges) { + if (e.from === e.to) continue; + const [s, t] = [out.nodes[e.from]!, out.nodes[e.to]!]; + const end = out.edges[e.id]!.at(-1)!; + if (horizontal) { + // Same row only: a wrapped layout carries forward edges back to the start of the next row. + const sameRow = s.y < t.y + t.height && t.y < s.y + s.height; + if (sameRow && t.x + t.width <= s.x) expect(near(end.x, t.x + t.width), `${e.id} enters its right side`).toBe(true); + if (sameRow && t.x >= s.x + s.width) expect(near(end.x, t.x), `${e.id} enters its left side`).toBe(true); + } else { + if (t.y + t.height <= s.y) expect(near(end.y, t.y + t.height), `${e.id} enters its bottom`).toBe(true); + if (t.y >= s.y + s.height) expect(near(end.y, t.y), `${e.id} enters its top`).toBe(true); + } + } + }); + + it('takes no long detours', () => { + for (const e of draft.edges) { + if (e.from === e.to) continue; + const [a, b] = [centre(out.nodes[e.from]!), centre(out.nodes[e.to]!)]; + const direct = Math.abs(a.x - b.x) + Math.abs(a.y - b.y); + expect(length(out.edges[e.id]!), e.id).toBeLessThanOrEqual(2.5 * direct + 300); + } + }); +}); diff --git a/packages/layout/test/stress.ts b/packages/layout/test/stress.ts new file mode 100644 index 0000000..0419afc --- /dev/null +++ b/packages/layout/test/stress.ts @@ -0,0 +1,222 @@ +import type { DiagramDraft, DiagramEdge, DiagramNode, NodeType } from '@stackmap/core'; + +// Made-up diagrams for the shapes that strain a layout: a hub with many labelled calls, a client that both calls and +// receives the reply, cycles, heavy fan-in, nested groups, two-way pairs, a mesh, self-loops, stages and compact cards. + +const rows = (n: number) => Array.from({ length: n }, (_, i) => ({ label: `Fact ${i + 1}`, value: `value ${i + 1}` })); +const node = (id: string, type: NodeType = 'service', extra: Partial = {}, rowCount = 2): DiagramNode => ({ + id, + type, + card: { title: id, subtitle: `${type} node`, rows: rows(rowCount) }, + ...extra, +}); +const edge = (from: string, to: string, extra: Partial = {}): DiagramEdge => ({ id: `${from}-${to}`, from, to, ...extra }); + +const hubNodes = (): DiagramNode[] => [ + node('handler', 'service', { group: 'api' }, 3), + node('finisher', 'service', { group: 'api' }), + node('effort', 'service', { group: 'api' }), + node('pin', 'service', { group: 'api' }, 3), + node('classifier', 'service', { group: 'router' }, 4), + node('fallback', 'service', { group: 'router' }, 3), + node('resolver', 'service', { group: 'router' }, 4), + node('gate', 'security', { group: 'router' }, 4), + node('allowlist', 'database', { group: 'store' }), + node('conversation', 'database', { group: 'store' }, 3), + node('feed', 'client', { group: 'browser' }), + node('picker', 'client', { group: 'browser' }, 3), + node('model', 'external', { group: 'vendor' }, 3), + node('judge', 'external', { group: 'vendor' }, 3), + node('catalog', 'external', { group: 'vendor' }), +]; +const hubEdges = (): DiagramEdge[] => [ + edge('picker', 'handler', { label: 'send turn', tone: 'main' }), + edge('handler', 'conversation', { label: 'reads state' }), + edge('handler', 'allowlist', { label: 'reads' }), + edge('handler', 'classifier', { label: 'classify turn', tone: 'main' }), + edge('handler', 'catalog', { label: 'discover models' }), + edge('handler', 'model', { label: 'stream turn', tone: 'main' }), + edge('handler', 'resolver', { label: 'intent + depth' }), + edge('handler', 'gate', { label: 'backstop', tone: 'security' }), + edge('handler', 'finisher', { label: 'on finish' }), + edge('handler', 'pin', { label: 'pin if warm' }), + edge('handler', 'effort'), + edge('handler', 'feed', { label: 'stream + metadata', kind: 'return' }), + edge('classifier', 'fallback', { label: 'fallback', tone: 'error' }), + edge('classifier', 'judge', { label: 'evaluate()' }), + edge('resolver', 'gate', { label: 'per candidate' }), + edge('finisher', 'conversation', { label: 'writes pin' }), +]; +const hubGroups = [ + { id: 'api', label: 'POST /api/turn' }, + { id: 'router', label: 'Router' }, + { id: 'store', label: 'Database' }, + { id: 'browser', label: 'Browser' }, + { id: 'vendor', label: 'Model gateway' }, +]; + +export const STRESS: Record = { + // A request handler that calls nearly everything, inside groups, with its reply going back to the client group. + 'hub-down': { kind: 'architecture', title: 'Hub, top-down', direction: 'DOWN', groups: hubGroups, nodes: hubNodes(), edges: hubEdges() }, + 'hub-right': { kind: 'architecture', title: 'Hub, left to right', direction: 'RIGHT', groups: hubGroups, nodes: hubNodes(), edges: hubEdges() }, + + // Many callers into a few shared stores, some labelled, one async. + 'fan-in': { + kind: 'architecture', + title: 'Fan-in', + direction: 'DOWN', + nodes: [ + node('web', 'client'), + node('lb', 'gateway'), + ...['orders', 'billing', 'search', 'users', 'catalog'].map((s) => node(s)), + node('db', 'database', {}, 3), + node('cache', 'cache'), + node('bus', 'queue'), + ], + edges: [ + edge('web', 'lb', { label: 'HTTPS', tone: 'main' }), + ...['orders', 'billing', 'search', 'users', 'catalog'].map((s) => edge('lb', s)), + ...['orders', 'billing', 'users', 'catalog'].map((s) => edge(s, 'db')), + edge('search', 'cache', { label: 'warm reads' }), + edge('orders', 'cache'), + edge('users', 'cache'), + edge('orders', 'bus', { kind: 'async', label: 'order.created' }), + edge('billing', 'bus', { kind: 'async' }), + ], + }, + + // A retry loop and a reply, left to right, no groups. + cycle: { + kind: 'architecture', + title: 'Cycle', + direction: 'RIGHT', + nodes: [node('client', 'client'), node('api'), node('worker'), node('queue', 'queue'), node('store', 'database')], + edges: [ + edge('client', 'api', { label: 'submit', tone: 'main' }), + edge('api', 'queue', { label: 'enqueue', kind: 'async' }), + edge('queue', 'worker', { kind: 'async' }), + edge('worker', 'store', { label: 'write' }), + edge('worker', 'queue', { label: 'retry', tone: 'error' }), + edge('api', 'client', { label: '202 + job id', kind: 'return' }), + ], + }, + + // Nested groups with edges crossing every boundary. + nested: { + kind: 'architecture', + title: 'Nested groups', + direction: 'DOWN', + groups: [ + { id: 'cloud', label: 'Cloud account' }, + { id: 'vpc', label: 'VPC', parent: 'cloud' }, + { id: 'public', label: 'Public subnet', parent: 'vpc' }, + { id: 'private', label: 'Private subnet', parent: 'vpc' }, + { id: 'saas', label: 'Third parties' }, + ], + nodes: [ + node('browser', 'client'), + node('cdn', 'gateway', { group: 'cloud' }), + node('alb', 'gateway', { group: 'public' }), + node('app', 'service', { group: 'private' }, 3), + node('jobs', 'service', { group: 'private' }), + node('rds', 'database', { group: 'private' }), + node('s3', 'storage', { group: 'cloud' }), + node('stripe', 'external', { group: 'saas' }), + node('mail', 'external', { group: 'saas' }), + ], + edges: [ + edge('browser', 'cdn', { label: 'HTTPS', tone: 'main' }), + edge('cdn', 'alb', { tone: 'main' }), + edge('cdn', 's3', { label: 'static assets' }), + edge('alb', 'app', { tone: 'main' }), + edge('app', 'rds', { label: 'SQL' }), + edge('app', 'jobs', { kind: 'async', label: 'enqueue' }), + edge('jobs', 'rds'), + edge('jobs', 's3', { label: 'exports' }), + edge('app', 'stripe', { label: 'charge', tone: 'security' }), + edge('jobs', 'mail', { label: 'send receipt' }), + edge('stripe', 'app', { label: 'webhook', kind: 'async' }), + ], + }, + + // Two-way pairs: a call each way between the same cards, plus a reply. + 'two-way': { + kind: 'architecture', + title: 'Two-way pairs', + direction: 'RIGHT', + nodes: [node('a'), node('b'), node('c', 'database'), node('d', 'external')], + edges: [ + edge('a', 'b', { label: 'request' }), + edge('b', 'a', { label: 'callback', kind: 'async' }), + edge('b', 'c', { label: 'reads, writes' }), + edge('b', 'd', { label: 'verify' }), + edge('d', 'b', { label: 'result', kind: 'return' }), + ], + }, + + // Services calling each other in a partial mesh. + mesh: { + kind: 'architecture', + title: 'Mesh', + direction: 'DOWN', + nodes: ['gateway', 'auth', 'users', 'orders', 'payments', 'inventory', 'notify'].map((s, i) => node(s, i === 0 ? 'gateway' : i === 1 ? 'security' : 'service')), + edges: [ + edge('gateway', 'auth', { tone: 'security', label: 'verify token' }), + edge('gateway', 'users'), + edge('gateway', 'orders', { tone: 'main' }), + edge('orders', 'users', { label: 'lookup' }), + edge('orders', 'payments', { tone: 'main', label: 'charge' }), + edge('orders', 'inventory', { label: 'reserve' }), + edge('payments', 'notify', { kind: 'async' }), + edge('inventory', 'notify', { kind: 'async' }), + edge('users', 'auth'), + edge('payments', 'orders', { label: 'settled', kind: 'async' }), + ], + }, + + // A card that calls itself, among ordinary edges. + 'self-loop': { + kind: 'architecture', + title: 'Self-loop', + direction: 'RIGHT', + nodes: [node('scheduler'), node('worker'), node('db', 'database')], + edges: [edge('scheduler', 'scheduler', { label: 'tick' }), edge('scheduler', 'worker', { label: 'dispatch' }), edge('worker', 'db')], + }, + + // A staged pipeline with compact cards and a branch that rejoins. + 'staged-compact': { + kind: 'dataflow', + title: 'Staged pipeline', + density: 'compact', + phases: [ + { id: 'src', label: 'Sources', nodes: ['app-db', 'events'] }, + { id: 'ingest', label: 'Ingest', nodes: ['cdc', 'collector'] }, + { id: 'process', label: 'Process', nodes: ['stream', 'batch'] }, + { id: 'serve', label: 'Serve', nodes: ['warehouse', 'dash'] }, + ], + nodes: [ + node('app-db', 'database'), + node('events', 'client'), + node('cdc', 'service'), + node('collector', 'gateway'), + node('stream', 'queue'), + node('batch', 'service'), + node('warehouse', 'storage'), + node('dash', 'client'), + ], + edges: [ + edge('app-db', 'cdc', { label: 'binlog' }), + edge('events', 'collector', { label: 'HTTP batches' }), + edge('cdc', 'stream'), + edge('collector', 'stream'), + edge('stream', 'batch', { label: 'hourly' }), + edge('stream', 'warehouse', { label: 'append', tone: 'main' }), + edge('batch', 'warehouse', { label: 'compacted' }), + edge('warehouse', 'dash', { label: 'SQL' }), + ], + }, + + // The smallest layouts: one card, one edge. + single: { kind: 'architecture', title: 'Single', nodes: [node('only')], edges: [] }, + pair: { kind: 'architecture', title: 'Pair', direction: 'DOWN', nodes: [node('a', 'client'), node('b')], edges: [edge('a', 'b', { label: 'calls' })] }, +}; From 1bea327670502e5b1e9c7130c6559eea0cba137e Mon Sep 17 00:00:00 2001 From: Joseph Dale Banares Date: Sat, 3 Oct 2026 14:09:06 +0800 Subject: [PATCH 02/14] fix(viewer): draw a handle dot wherever an edge meets its card --- packages/viewer/e2e/look.spec.ts | 8 +- packages/viewer/src/canvas/SceneLayers.tsx | 25 +- packages/viewer/src/canvas/scene.test.ts | 20 +- packages/viewer/src/canvas/scene.ts | 24 +- packages/viewer/src/samples/gallery.layout.ts | 564 +++++++++--------- .../src/samples/grouped-platform.layout.ts | 72 ++- 6 files changed, 358 insertions(+), 355 deletions(-) diff --git a/packages/viewer/e2e/look.spec.ts b/packages/viewer/e2e/look.spec.ts index 8380df0..32ba285 100644 --- a/packages/viewer/e2e/look.spec.ts +++ b/packages/viewer/e2e/look.spec.ts @@ -182,7 +182,7 @@ for (const theme of ['light', 'dark'] as const) { const [edgeRgb, stageRgb] = [await tokenRgb(page, '--sm-edge'), await tokenRgb(page, '--sm-stage')]; for (const target of ['commerce-api-1', 'commerce-api-2', 'commerce-api-3', 'orders', 'sessions']) { - const handle = (await page.locator(`[data-card-id="${target}"] .sm-handle[data-handle="in"]`).boundingBox())!; + const handle = (await page.locator(`.sm-handle[data-handle-of="${target}"][data-handle="in"]`).boundingBox())!; const cy = handle.y + handle.height / 2; // Strip left of the dot: a bare 1.25px line has no edge-coloured pixels 1px+ off its axis, an arrowhead does. const m = await inkMask(page, { x: handle.x - 10, y: cy - 4, width: 10, height: 8 }, edgeRgb, stageRgb); @@ -228,9 +228,9 @@ for (const theme of ['light', 'dark'] as const) { test('handle dots are drawn only where an edge attaches', async ({ page }) => { await page.goto('/?page=sample'); - await expect(page.locator('[data-card-id="edge"] .sm-handle[data-handle="in"]')).toHaveCount(0); - await expect(page.locator('[data-card-id="orders"] .sm-handle[data-handle="out"]')).toHaveCount(0); - await expect(page.locator('[data-card-id="sessions"] .sm-handle[data-handle="out"]')).toHaveCount(0); + await expect(page.locator('.sm-handle[data-handle-of="edge"][data-handle="in"]')).toHaveCount(0); + await expect(page.locator('.sm-handle[data-handle-of="orders"][data-handle="out"]')).toHaveCount(0); + await expect(page.locator('.sm-handle[data-handle-of="sessions"][data-handle="out"]')).toHaveCount(0); // edge:out, three API in+out, orders:in, sessions:in await expect(page.locator('.sm-handle')).toHaveCount(9); }); diff --git a/packages/viewer/src/canvas/SceneLayers.tsx b/packages/viewer/src/canvas/SceneLayers.tsx index 6dba5d5..4fc653a 100644 --- a/packages/viewer/src/canvas/SceneLayers.tsx +++ b/packages/viewer/src/canvas/SceneLayers.tsx @@ -22,18 +22,6 @@ const place = ({ x, y, width, height }: { x: number; y: number; width: number; h height, }); -// Dot centred on the card edge where ELK put the port (mid-side, per layout's fixed ports). -function handleStyle(side: 'in' | 'out', horizontal: boolean): CSSProperties { - if (horizontal) { - return side === 'in' - ? { top: '50%', left: 0, transform: 'translate(-50%, -50%)' } - : { top: '50%', right: 0, transform: 'translate(50%, -50%)' }; - } - return side === 'in' - ? { left: '50%', top: 0, transform: 'translate(-50%, -50%)' } - : { left: '50%', bottom: 0, transform: 'translate(-50%, 50%)' }; -} - 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. @@ -134,26 +122,21 @@ function edgeLook(e: SceneEdge, tint: EdgeColor): { color: EdgeColor; width: num // 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, onFocused, }: { 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, final } = card; + const { node, rect, final } = card; const dispatch = useExploreDispatch(); const camera = useCamera(); const accent = { '--sm-handle': `var(--sm-${node.type}-accent)` } as CSSProperties; @@ -195,8 +178,6 @@ const Card = memo(function Card({ onKeyDown={onKeyDown} > {compact ? : } - {handles && hasIn &&