diff --git a/packages/layout/src/index.ts b/packages/layout/src/index.ts index e6b291a..3ff05a9 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,14 @@ 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; +/** Past this many nodes (where validation warns to split) a layout isn't redone to make room for labels: each ELK run + * grows with the graph, and a diagram that size is read zoomed in anyway. */ +const RETRY_LIMIT = 60; +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 +74,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. Edges of one style leaving (or entering) a card the same way share one, so a + * hub's calls leave as a few trunks instead of a ribbon of parallel lines; their labels sit by the cards they name + * (see placeLabels). Edges of another tone or line style get their own: on a shared trunk colours would hide each + * other. + */ +function portKey(e: DiagramDraft['edges'][number], node: string, role: 'out' | 'in', flipped: boolean): string { + return `${node}:${role}:${flipped ? 'flipped' : 'flow'}:${e.tone ?? ''}:${e.kind ?? 'sync'}`; } const round = (n: number) => Math.round(n * 100) / 100; @@ -113,7 +152,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 && draft.nodes.length <= RETRY_LIMIT) { + 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]; + if (spots.unseated === 0) break; + } } return { ...laid, labels: spots.labels }; } finally { @@ -171,7 +278,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 = {}; @@ -184,18 +307,94 @@ function collect(draft: DiagramDraft, result: ElkNode, direction: Direction, sta } // ELK may re-home edges into the lowest common ancestor, so collect from every level. for (const e of (container.edges ?? []) as ElkExtendedEdge[]) { - edges[e.id] = (e.sections ?? []) - .flatMap((s) => [s.startPoint, ...(s.bendPoints ?? []), s.endPoint]) - .map((p) => ({ x: round(p.x), y: round(p.y) })); + edges[e.id] = squared( + (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); + straightenSteps(draft, nodes, edges); const bounds = { width: round(result.width ?? 0), height: round(result.height ?? 0) }; if (!staged) return { draft, nodes, groups, edges, bounds }; return { draft, nodes, groups, edges, bounds, phases: stageBands(draft, nodes, direction) }; } +/** + * ELK spreads ports at thirds of a side, so a run meant to be straight can drift by a third of a pixel and render + * as a soft, slanted hairline. Snap each such run to one whole coordinate (a run continuing a straight one keeps its + * line) and drop the bends that no longer turn. Edges sharing a trunk share its points, so they snap alike. + */ +function squared(pts: Point[]): Point[] { + for (let i = 1; i < pts.length; i++) { + const [a, b] = [pts[i - 1]!, pts[i]!]; + const [dx, dy] = [Math.abs(a.x - b.x), Math.abs(a.y - b.y)]; + if (dx > 0 && dx < 1 && dy >= 1) a.x = b.x = i > 1 && pts[i - 2]!.x === a.x ? a.x : Math.round((a.x + b.x) / 2); + else if (dy > 0 && dy < 1 && dx >= 1) a.y = b.y = i > 1 && pts[i - 2]!.y === a.y ? a.y : Math.round((a.y + b.y) / 2); + } + return pts.filter((p, i) => { + const [prev, next] = [pts[i - 1], pts[i + 1]]; + if (!prev || !next) return true; + return !((prev.x === p.x && p.x === next.x) || (prev.y === p.y && p.y === next.y)); + }); +} + +const STEP_MAX = 16; +// Clear of a card's rounded corner, and of its other ports. +const SIDE_INSET = 16; +const PORT_GAP = 8; + +/** + * Two facing ports a few pixels out of line come out of ELK as a shallow step, which reads as noise rather than a + * turn. Draw it as one straight line by sliding an end along its side: the source first, so the arrowhead keeps its + * place. Only an end no other edge shares moves (a trunk stays whole), and only where the line meets no card and + * runs along no other route. + */ +function straightenSteps(draft: DiagramDraft, nodes: Record, edges: Record): void { + const near = (p: Point, q: Point, d: number) => Math.abs(p.x - q.x) < d && Math.abs(p.y - q.y) < d; + const ends = () => Object.entries(edges).flatMap(([id, pts]) => [pts[0]!, pts.at(-1)!].map((p) => ({ id, p }))); + for (const e of draft.edges) { + const pts = edges[e.id]; + if (e.from === e.to || pts?.length !== 4) continue; + const [a, b, c, d] = pts as [Point, Point, Point, Point]; + const vertical = a.x === b.x; + const step = vertical ? Math.abs(c.x - b.x) : Math.abs(c.y - b.y); + // A Z, not a U: both ends run the same way, so the straight line still leaves and enters by the same sides. + const z = vertical ? Math.sign(b.y - a.y) === Math.sign(d.y - c.y) : Math.sign(b.x - a.x) === Math.sign(d.x - c.x); + if (!z || step === 0 || step >= STEP_MAX) continue; + for (const [end, card, other] of [ + [a, nodes[e.from]!, d], + [d, nodes[e.to]!, a], + ] as const) { + const moved = vertical ? { x: other.x, y: end.y } : { x: end.x, y: other.y }; + const along = vertical ? moved.x - card.x : moved.y - card.y; + if (along < SIDE_INSET || along > (vertical ? card.width : card.height) - SIDE_INSET) continue; + const rest = ends().filter((n) => n.id !== e.id); + if (rest.some((n) => near(n.p, end, 0.5) || near(n.p, moved, PORT_GAP))) continue; + const line = end === a ? [moved, d] : [a, moved]; + const [lo, hi] = vertical ? [Math.min(line[0]!.y, line[1]!.y), Math.max(line[0]!.y, line[1]!.y)] : [Math.min(line[0]!.x, line[1]!.x), Math.max(line[0]!.x, line[1]!.x)]; + const at = vertical ? moved.x : moved.y; + const meetsCard = Object.entries(nodes).some( + ([id, r]) => id !== e.from && id !== e.to && (vertical ? r.x < at && at < r.x + r.width && r.y < hi && lo < r.y + r.height : r.y < at && at < r.y + r.height && r.x < hi && lo < r.x + r.width), + ); + const runsAlong = Object.entries(edges).some( + ([id, q]) => + id !== e.id && + q.slice(1).some((v, i) => { + const u = q[i]!; + return vertical + ? u.x === at && v.x === at && Math.min(u.y, v.y) < hi && lo < Math.max(u.y, v.y) + : u.y === at && v.y === at && Math.min(u.x, v.x) < hi && lo < Math.max(u.x, v.x); + }), + ); + if (meetsCard || runsAlong) continue; + edges[e.id] = line; + break; + } + } +} + /** Partition per node: its phase, or for an unphased node the phase of its first placed predecessor (else 0). */ function stagePartitions(draft: DiagramDraft): Map { const part = new Map(); diff --git a/packages/layout/src/labels.ts b/packages/layout/src/labels.ts index 71a3642..9331e4f 100644 --- a/packages/layout/src/labels.ts +++ b/packages/layout/src/labels.ts @@ -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 { + 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, + * 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, - 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 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 = {}; 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; @@ -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 @@ -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; } 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..b0912f9 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.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..08aab8c 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 { labelWidth, placeLabels } 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,94 @@ 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 beside a trunk', () => { + // Two calls into one card at the right merge into a trunk along y=120; each comes down or up a run of its own first. + const b = [{ x: 100, y: 240 }, { x: 100, y: 120 }, { x: 800, y: 120 }]; + + it('go on a run the edge has to itself, where they name one edge, not beside the shared one', () => { + const a = [{ x: 0, y: 0 }, { x: 0, y: 120 }, { x: 800, y: 120 }]; + const { labels } = placeLabels([{ id: 'a', label: 'reads state' }], { a, b }, []); + expect(distance(labels.a!, b)).toBeGreaterThan(distance(labels.a!, a)); + }); + + it('stay off the shared run even where it turns out of a run of their own', () => { + const a = [{ x: 614, y: 384 }, { x: 614, y: 408 }, { x: 294, y: 408 }, { x: 294, y: 456 }]; + const trunk = [{ x: 294, y: 176 }, { x: 294, y: 456 }]; + const { labels } = placeLabels([{ id: 'a', label: 'lookup', from: 'x', to: 'z' }, { id: 'p', from: 'x', to: 'p' }, { id: 'q', from: 'x', to: 'q' }], { a, trunk }, []); + expect(labels.a!.y).toBe(408); + }); + + it('still sit beside the shared run when the edge has no other with room', () => { + const a = [{ x: 90, y: 120 }, { x: 800, y: 120 }]; + const { labels, unseated } = placeLabels([{ id: 'a', label: 'reads state' }], { a, b }, []); + expect(labels.a!.y).not.toBe(120); + expect(unseated).toBe(0); + }); +}); + +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}`).toBeGreaterThanOrEqual(0.5); + } + } + }); +}); diff --git a/packages/layout/test/layout.test.ts b/packages/layout/test/layout.test.ts index 24c6704..a3fa6f7 100644 --- a/packages/layout/test/layout.test.ts +++ b/packages/layout/test/layout.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { cardSize, type DiagramDraft, type DiagramNode, type Point, type Rect } from '@stackmap/core'; import { commerceApi, groupedPlatform } from '@stackmap/core/samples'; import { GROUP_LABEL_BAND, layoutDiagram } from '../src/index'; +import { STRESS } from './stress'; const node = (id: string, extra: Partial = {}): DiagramNode => ({ id, @@ -121,6 +122,94 @@ 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 edges of another tone or line style their own port', async () => { + for (const direction of ['RIGHT', 'DOWN'] as const) { + const out = await layoutDiagram( + draft({ + direction, + nodes: [node('hub'), node('plain'), node('m'), node('s'), node('q')], + edges: [ + { id: 'plain', from: 'hub', to: 'plain', label: 'reads' }, + { id: 'main', from: 'hub', to: 'm', tone: 'main' as const }, + { id: 'sec', from: 'hub', to: 's', tone: 'security' as const }, + { id: 'async', from: 'hub', to: 'q', kind: 'async' 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('merges edges of one style into a shared trunk, labelled or not, with each label by its target', async () => { + const targets = ['a', 'b', 'c', 'd']; + const out = await layoutDiagram( + draft({ + direction: 'DOWN', + nodes: [node('hub'), ...targets.map((t) => node(t))], + edges: targets.map((t, i) => ({ id: t, from: 'hub', to: t, ...(i % 2 ? { label: `to ${t}` } : {}) })), + }), + ); + const starts = Object.values(out.edges).map((pts) => `${pts[0]!.x},${pts[0]!.y}`); + expect(new Set(starts).size).toBe(1); + for (const t of ['b', 'd']) { + const label = out.labels![t]!; + const target = out.nodes[t]!; + // Above its own target, not out on the shared bus. + expect(label.x).toBeGreaterThan(target.x); + expect(label.x).toBeLessThan(target.x + target.width); + expect(label.y).toBeLessThan(target.y); + } + }); + + it('top-aligns the cards of a row, whatever their heights', async () => { + // In the hub, feed (a reply's target) and finisher are shorter than their row-mates and used to be centred on them. + const out = await layoutDiagram(STRESS['hub-down']!); + expect(out.nodes.feed!.y).toBe(out.nodes.picker!.y); + expect(out.nodes.finisher!.y).toBe(out.nodes.pin!.y); + expect(out.nodes.effort!.y).toBe(out.nodes.pin!.y); + }); + 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..164d3f7 --- /dev/null +++ b/packages/layout/test/quality.test.ts @@ -0,0 +1,74 @@ +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 of every colour merged into one 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 share a port', () => { + const at = new Map>(); + for (const e of draft.edges) { + const pts = out.edges[e.id]!; + const style = `${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('runs every segment along an axis', () => { + for (const [id, pts] of Object.entries(out.edges)) { + pts.slice(1).forEach((b, i) => expect(pts[i]!.x === b.x || pts[i]!.y === b.y, `${id} segment ${i}`).toBe(true)); + } + }); + + // Archetypal noise: two ports a few px out of line drawn as a step instead of one straight line. + it('draws no shallow step between ports that could line up', () => { + for (const [id, pts] of Object.entries(out.edges)) { + if (pts.length !== 4) continue; + const step = pts[1]!.x === pts[2]!.x ? Math.abs(pts[1]!.y - pts[2]!.y) : Math.abs(pts[1]!.x - pts[2]!.x); + expect(step, id).not.toBeLessThan(16); + } + }); + + 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' })] }, +}; diff --git a/packages/schema/src/rules/semantics.ts b/packages/schema/src/rules/semantics.ts index 53154f1..5940425 100644 --- a/packages/schema/src/rules/semantics.ts +++ b/packages/schema/src/rules/semantics.ts @@ -64,7 +64,7 @@ function cycles(nodes: string[], edges: [string, string][]): string[][] { const LARGE_DIAGRAM = 60; -/** Size, duplicate ids, orphans, self-loops, parallel edges, dataflow cycles, empty views and groups. */ +/** Size, duplicate ids and row labels, orphans, self-loops, parallel edges, dataflow cycles, empty views and groups. */ export function semanticsDiagnostics(d: DiagramDraft): Diagnostic[] { const out: Diagnostic[] = [ ...duplicates(d.nodes, 'nodes'), @@ -75,6 +75,24 @@ export function semanticsDiagnostics(d: DiagramDraft): Diagnostic[] { ...duplicates(d.phases, 'phases'), ]; + // Two rows under one label read as a typo, or as one fact split in two; the reader can't tell which. + d.nodes.forEach((n, i) => { + const first = new Map(); + n.card.rows?.forEach((r, j) => { + const key = r.label.trim().toLowerCase(); + const at = first.get(key); + if (at === undefined) return void first.set(key, j); + out.push({ + code: 'semantics/duplicate-row-label', + severity: 'warning', + subject: `/nodes/${i}/card/rows/${j}/label`, + message: `Two rows on "${n.id}" are both labelled "${r.label.trim()}"`, + evidence: { id: n.id, label: r.label, first: `/nodes/${i}/card/rows/${at}/label` }, + allowedFixes: ['merge the two rows into one (both values in one value)', 'give the second row its own label'], + }); + }); + }); + d.nodes.forEach((n, i) => { if (n.card.statsNote !== undefined && !n.card.stats?.length) out.push({ @@ -127,7 +145,8 @@ export function semanticsDiagnostics(d: DiagramDraft): Diagnostic[] { }); }); - // ELK gives each card one in and one out port, so two edges a → b share one route and one label spot. + // Two edges a → b of one style share their ports and draw as one line; of different styles they run side by side: + // either way, one relationship drawn twice. if (d.kind === 'architecture' || d.kind === 'dataflow') { const first = new Map(); d.edges.forEach((e, i) => { @@ -139,7 +158,7 @@ export function semanticsDiagnostics(d: DiagramDraft): Diagnostic[] { code: 'semantics/parallel-edge', severity: 'warning', subject: `/edges/${i}`, - message: `Edges "${seen}" and "${e.id}" both run from "${e.from}" to "${e.to}"; they draw as one line, labels on top of each other`, + message: `Edges "${seen}" and "${e.id}" both run from "${e.from}" to "${e.to}"; the reader sees one relationship drawn twice`, evidence: { id: e.id, first: seen, from: e.from, to: e.to }, allowedFixes: [`merge "${e.id}" into "${seen}" (one label for both)`, 'remove the edge'], }); diff --git a/packages/schema/test/validate.test.ts b/packages/schema/test/validate.test.ts index 51f8809..bcddece 100644 --- a/packages/schema/test/validate.test.ts +++ b/packages/schema/test/validate.test.ts @@ -268,6 +268,18 @@ describe('validateDiagram', () => { expect(only(base({ kind: 'sequence', edges }), 'semantics/parallel-edge')).toEqual([]); }); + it('two rows with the same label on one card warn at the second', () => { + const rows = [ + { label: 'Floor', value: 'file: hard' }, + { label: 'Timeout', value: '2.5 s' }, + { label: 'floor ', value: '16k: standard' }, + ]; + expect(only(base({ nodes: [node('a', { card: { title: 'a', rows } }), node('b')] }), 'semantics/duplicate-row-label')).toMatchObject([ + { subject: '/nodes/0/card/rows/2/label', severity: 'warning', evidence: { id: 'a', label: 'floor ', first: '/nodes/0/card/rows/0/label' } }, + ]); + expect(only(base(), 'semantics/duplicate-row-label')).toEqual([]); + }); + it('past 60 nodes the diagram warns once to split', () => { const nodes = (n: number) => Array.from({ length: n }, (_, i) => node(`n${i}`)); const chain = (n: number) => Array.from({ length: n - 1 }, (_, i) => ({ id: `e${i}`, from: `n${i}`, to: `n${i + 1}` })); diff --git a/packages/viewer/e2e/explore.spec.ts b/packages/viewer/e2e/explore.spec.ts index 37ae7b5..1e2ec60 100644 --- a/packages/viewer/e2e/explore.spec.ts +++ b/packages/viewer/e2e/explore.spec.ts @@ -390,3 +390,28 @@ test('a canvas that mounts at zero size fits once it gets one, without errors', } expect(errors).toEqual([]); // a zero-size zoom animation used to render NaN transforms }); + +test('zoomed out past reading size, edge labels fade, except on the edges a selection lights', async ({ page }) => { + await page.goto('/?page=event-stream'); + await expect(page.locator('.sm-card').first()).toBeVisible(); + await settle(page); + const label = (id: string) => page.locator(`[data-edge-label="${id}"]`); + await expect(label('e4')).toHaveCSS('opacity', '1'); + await page.locator('.sm-stage').focus(); + while ((await viewportOf(page)).k >= 0.5) { + await page.keyboard.press('-'); + await settle(page); + } + await expect(label('e4')).toHaveCSS('opacity', '0'); + await expect(label('e1')).toHaveCSS('opacity', '0'); + // Selecting payments lights e4, so its label comes back while the rest stay hidden. + await card(page, 'payments').click(); + await expect(label('e4')).toHaveCSS('opacity', '1'); + await expect(label('e1')).not.toHaveCSS('opacity', '1'); + await page.keyboard.press('Escape'); + await page.locator('.sm-stage').focus(); + await page.keyboard.press('+'); + await page.keyboard.press('+'); + await settle(page); + await expect(label('e1')).toHaveCSS('opacity', '1'); +}); diff --git a/packages/viewer/e2e/export.spec.ts b/packages/viewer/e2e/export.spec.ts index b92a2de..7d051f4 100644 --- a/packages/viewer/e2e/export.spec.ts +++ b/packages/viewer/e2e/export.spec.ts @@ -104,6 +104,20 @@ test('SVG is a foreignObject snapshot with the fonts embedded', async ({ page }) expect(bytes.length).toBeLessThan(600_000); }); +test('an export carries the edge labels even when the canvas is zoomed out past showing them', async ({ page }) => { + await page.goto('/?page=event-stream'); + await expect(page.locator('.sm-card').first()).toBeVisible(); + await page.locator('.sm-stage').focus(); + for (let i = 0; i < 6; i++) await page.keyboard.press('-'); + await page.waitForTimeout(250); + await expect(page.locator('[data-edge-label="e4"]')).toHaveCSS('opacity', '0'); + const raw = (await exportAs(page, /^SVG/)).bytes.toString('utf8'); + const svg = raw.startsWith('data:') ? decodeURIComponent(raw.slice(raw.indexOf(',') + 1)) : raw; + const e4 = svg.slice(svg.indexOf('data-edge-label="e4"')); + expect(e4.slice(0, e4.indexOf('payment facts'))).not.toMatch(/opacity: 0[;"]/); + await expect(page.locator('[data-edge-label="e4"]')).toHaveCSS('opacity', '0'); +}); + test('the export menu is keyboard operable and closes on Escape', async ({ page }) => { await page.getByRole('button', { name: 'Export' }).focus(); await page.keyboard.press('ArrowDown'); 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/e2e/visual.spec.ts-snapshots/grouped-dark-linux.png b/packages/viewer/e2e/visual.spec.ts-snapshots/grouped-dark-linux.png index 9810758..54aa442 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 3d17548..e7dcc9c 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/stages-dark-linux.png b/packages/viewer/e2e/visual.spec.ts-snapshots/stages-dark-linux.png index b85d599..620b3db 100644 Binary files a/packages/viewer/e2e/visual.spec.ts-snapshots/stages-dark-linux.png 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 index ddb35d0..b86a95a 100644 Binary files a/packages/viewer/e2e/visual.spec.ts-snapshots/stages-light-linux.png and b/packages/viewer/e2e/visual.spec.ts-snapshots/stages-light-linux.png differ diff --git a/packages/viewer/src/canvas/DiagramCanvas.tsx b/packages/viewer/src/canvas/DiagramCanvas.tsx index 5384f9a..eca9f77 100644 --- a/packages/viewer/src/canvas/DiagramCanvas.tsx +++ b/packages/viewer/src/canvas/DiagramCanvas.tsx @@ -14,7 +14,7 @@ import { toScene, type Scene } from './scene'; import { SceneLayers } from './SceneLayers'; import { useZoom, type WheelMode } from './useZoom'; import type { Camera } from './useZoom'; -import type { Transform } from './viewport'; +import { LABEL_MIN_ZOOM, type Transform } from './viewport'; import { CameraProvider, ContentProvider, SceneProvider, ViewportProvider } from './ViewportContext'; const PAN_STEP = 80; @@ -205,6 +205,7 @@ export function DiagramCanvas({
diff --git a/packages/viewer/src/canvas/SceneLayers.tsx b/packages/viewer/src/canvas/SceneLayers.tsx index 6dba5d5..ccf43ed 100644 --- a/packages/viewer/src/canvas/SceneLayers.tsx +++ b/packages/viewer/src/canvas/SceneLayers.tsx @@ -1,4 +1,4 @@ -import { memo, useMemo, useState, type CSSProperties, type KeyboardEvent } from 'react'; +import { Fragment, memo, useId, useMemo, useState, type CSSProperties, type KeyboardEvent } from 'react'; import { ShieldCheck, TriangleAlert } from 'lucide-react'; import { TYPE_LABELS, type Rect } from '@stackmap/core'; import { NodeCard } from '../card/NodeCard'; @@ -7,7 +7,7 @@ import type { Emphasis, NodeEmphasis } from '../explore/emphasis'; import { useExploreDispatch } from '../explore/ExploreContext'; import { neighbourInDirection, type Direction } from '../explore/graph'; import { arrowMarkerId, ArrowMarkerDefs, edgeStroke, type EdgeColor } from './ArrowMarker'; -import type { Scene, SceneCard, SceneEdge, SceneFrame, SceneLane, ScenePhase } from './scene'; +import type { Scene, SceneCard, SceneEdge, SceneFrame, SceneGap, SceneLane, ScenePhase } from './scene'; import { useCamera } from './ViewportContext'; import type { Flow } from '../motion/flow'; import { FlowLayer } from './FlowLayer'; @@ -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. @@ -131,29 +119,44 @@ function edgeLook(e: SceneEdge, tint: EdgeColor): { color: EdgeColor; width: num return { color, width, dash }; } +// The break a crossing leaves in the line passing under: the over line's width plus 4px either side, along the line. +const GAP = { along: 10, across: 8 }; + +/** Cuts the gaps out of one edge (and its arrowhead) whatever is behind it, frame fills included. */ +function GapMask({ id, edge, gaps }: { id: string; edge: SceneEdge; gaps: SceneGap[] }) { + // The route's own box (plus the arrowhead), not the diagram's: a mask is rasterised over its whole region. + const xs = edge.points.map((p) => p.x); + const ys = edge.points.map((p) => p.y); + const box = { x: Math.min(...xs) - 12, y: Math.min(...ys) - 12, width: Math.max(...xs) - Math.min(...xs) + 24, height: Math.max(...ys) - Math.min(...ys) + 24 }; + return ( + + + {gaps.map((g) => { + const [w, h] = g.vertical ? [GAP.across, GAP.along] : [GAP.along, GAP.across]; + return ; + })} + + ); +} + // 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 +198,6 @@ const Card = memo(function Card({ onKeyDown={onKeyDown} > {compact ? : } - {handles && hasIn &&
); }); @@ -219,10 +220,11 @@ export const SceneLayers = memo(function SceneLayers({ /** its playback speed */ speed?: number; }) { - 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); + // Mask ids are document-wide, and a page may embed several diagrams. + const maskPrefix = `sm-gaps${useId().replace(/[^\w-]/g, '')}`; const tabStop = selected ?? (lastFocused && rects[lastFocused] ? lastFocused : scene.cards[0]?.node.id); return ( <> @@ -270,28 +272,34 @@ export const SceneLayers = memo(function SceneLayers({ style={{ fill: `var(--sm-${a.type}-accent)`, fillOpacity: 0.16, stroke: `var(--sm-${a.type}-accent)`, strokeWidth: 1 }} /> ))} - {scene.edges.map((e) => { + {scene.edges.map((e, i) => { const { dim, tint } = emphasis.edges.get(e.id) ?? { dim: false, tint: null }; const look = edgeLook(e, tint); + // Faded lines crossing over would cut a gap into a lit one for nothing. + const gaps = e.gaps.filter((g) => g.over.some((o) => !emphasis.edges.get(o)?.dim)); + const mask = `${maskPrefix}-${i}`; return ( - + + {gaps.length > 0 && } + 0 ? `url(#${mask})` : undefined} + style={{ + stroke: edgeStroke(look.color), + strokeWidth: look.width, + strokeDasharray: look.dash, + strokeLinecap: e.kind === 'return' ? 'round' : undefined, + }} + /> + ); })} {/* Lifecycle: a start state's initial marker, a filled dot with a stub into the card (UML). */} @@ -314,10 +322,8 @@ export const SceneLayers = memo(function SceneLayers({