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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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