Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
4fdcc22
chore(release): merge main into staging [skip ci]
github-actions[bot] Oct 3, 2026
810ea11
docs(site): land on the install section when arriving from another page
hyamero Oct 3, 2026
7ae888e
fix(layout): lay replies and loop-closing edges out against the flow,…
hyamero Oct 3, 2026
11bd18f
fix(viewer): draw a handle dot wherever an edge meets its card
hyamero Oct 3, 2026
5b120c7
feat(schema): warn when two rows on a card share a label
hyamero Oct 3, 2026
9c8f547
docs(skill): explain flow order, replies, busy cards and one row per …
hyamero Oct 3, 2026
be8be60
test(viewer): refresh the visual baselines for per-edge ports
hyamero Oct 3, 2026
47ca8b9
fix(layout): top-align the cards of a row in top-down layouts
hyamero Oct 3, 2026
c3f53e5
perf(layout): stop making room for labels once they all fit, and not …
hyamero Oct 3, 2026
429c7e9
fix(layout): merge a card's edges of one style into trunks and keep e…
hyamero Oct 3, 2026
0b91403
docs(skill): describe trunks per style with labels by the card they name
hyamero Oct 3, 2026
bb45cee
chore(schema): describe how parallel edges draw now that trunks follo…
hyamero Oct 3, 2026
6b97c96
fix(layout): label a trunk's edges on runs of their own before the ru…
hyamero Oct 3, 2026
89182fb
fix(layout): square up sub-pixel slants and draw shallow steps betwee…
hyamero Oct 3, 2026
939a416
feat(viewer): break the line that passes under at each crossing
hyamero Oct 3, 2026
0f3b912
feat(viewer): fade edge labels when zoomed out past reading size
hyamero Oct 3, 2026
cfc5551
docs(site): build when the last deploy's commit is missing from the s…
hyamero Oct 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
273 changes: 236 additions & 37 deletions packages/layout/src/index.ts

Large diffs are not rendered by default.

129 changes: 114 additions & 15 deletions packages/layout/src/labels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,28 +16,92 @@ const crossed = (r: Rect, lines: Point[][]) =>
return 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;
}),
);
/** Distance from a point to the nearest segment of a polyline. */
function distance(p: Point, pts: Point[]): number {
let best = Infinity;
for (let i = 1; i < pts.length; i++) {
const [a, b] = [pts[i - 1]!, 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)));
best = Math.min(best, Math.hypot(p.x - x, p.y - y));
}
return best;
}
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<string, Bias> {
const outs = new Map<string, number>();
const ins = new Map<string, number>();
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,
* pill clear of cards, other labels and other edges' lines, nearer its own edge than any other: on a run of its own
* if one has room, else beside a trunk it shares. 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<string, Point[]>,
cards: Rect[],
cardRects: Rect[],
keepMidpoints = false,
): { labels: Record<string, Point>; misfit: number } {
frames: LabelFrame[] = [],
): { labels: Record<string, Point>; 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 rivals = (id: string) => Object.entries(routes).flatMap(([k, pts]) => (k === id ? [] : [pts]));
const others = (id: string) => [...rivals(id), ...borders];
const taken: Rect[] = [];
const labels: Record<string, Point> = {};
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;
Expand All @@ -46,26 +110,57 @@ export function placeLabels(
labels[e.id] = { x: round(p.x), y: round(p.y) };
}
}
// Runs an edge has to itself first, for every label, before any label settles beside a trunk it shares.
for (const e of labelled) {
if (labels[e.id]) continue;
const spot = findLabelSpot(routes[e.id]!, e.label!, cards, taken, others(e.id), bias.get(e.id), rivals(e.id));
if (spot) labels[e.id] = spot;
}
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), rivals(e.id), true);
if (spot) labels[e.id] = spot;
else misfit = Math.max(misfit, labelWidth(e.label!));
else {
misfit = Math.max(misfit, labelWidth(e.label!));
unseated++;
}
}
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 };
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);
}
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',
/** other edges' routes: a spot nearer one of them than its own would read as naming that edge */
rivals: Point[][] = [],
/** whether the label may hang off a run a rival also takes: a trunk the edge shares */
shared = false,
): 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
Expand All @@ -74,11 +169,15 @@ export function findLabelSpot(points: Point[], text: string, cards: Rect[], take
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 at = { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
const p = { x: round(at.x + dx), y: round(at.y + 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);
if (rivals.some((r) => distance(p, r) < distance(p, points) || (!shared && distance(at, r) < 0.5))) continue;
// 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;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/layout/src/lanes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
23 changes: 23 additions & 0 deletions packages/layout/test/corpus.ts
Original file line number Diff line number Diff line change
@@ -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');
84 changes: 84 additions & 0 deletions packages/layout/test/fuzz.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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 = <T,>(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<string, Set<string>>();
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.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);
});
});
Loading
Loading