Skip to content
Merged
6 changes: 5 additions & 1 deletion packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,11 @@ async function run(argv: string[]): Promise<CommandResult> {
} catch (e) {
return { code: 2, stdout: '', stderr: `stackmap: internal error: viewer template missing (${(e as Error).message})\n` };
}
if (command === 'deliver') return signed(command, await deliverCommand(file, { template, out: values.out, style, open: values.open ? openInBrowser : undefined }));
if (command === 'deliver') {
const open = values.open ? openInBrowser : undefined;
const progress = (line: string) => process.stderr.write(`${line}\n`);
return signed(command, await deliverCommand(file, { template, out: values.out, style, open, progress }));
}

const port = values.port === undefined ? 4400 : Number(values.port);
if (!Number.isInteger(port) || port < 0 || port > 65535) return usage(`--port must be 0-65535, got "${values.port}"`);
Expand Down
17 changes: 14 additions & 3 deletions packages/cli/src/commands.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createHash } from 'node:crypto';
import { realpathSync } from 'node:fs';
import { extname, resolve } from 'node:path';
import { layoutDiagram } from '@stackmap/layout';
import { isLarge, layoutDiagram } from '@stackmap/layout';
import { validateDiagram, type Diagnostic, type ValidationResult } from '@stackmap/schema';
import { embedDiagram } from './embed';
import { formatDiagnostic, summary } from './format';
Expand Down Expand Up @@ -63,9 +63,17 @@ export async function validateCommand(path: string, { json }: { json: boolean })
}

/** Validate, lay out and embed: the HTML is present exactly when the diagram has no errors. */
export async function buildHtml(path: string, template: string): Promise<ValidationResult & { html?: string }> {
export async function buildHtml(
path: string,
template: string,
progress?: (line: string) => void,
): Promise<ValidationResult & { html?: string }> {
const result = validateFile(path);
if (!result.ok || !result.diagram) return result;
const { nodes, edges } = result.diagram;
// The result only prints once the layout is done; without this a big diagram looks hung for minutes.
if (progress && isLarge(result.diagram))
progress(`laying out ${nodes.length} nodes and ${edges.length} connections; a diagram this size can take a while`);
return { ...result, html: embedDiagram(template, await layoutDiagram(result.diagram)) };
}

Expand All @@ -76,18 +84,21 @@ export async function deliverCommand(
out,
style = plain,
open,
progress,
}: {
template: string;
out?: string;
style?: Style;
/** opens the written file (deliver --open): null once it did, else why it couldn't */
open?: (file: string) => Promise<string | null>;
/** a line to show the user now, not with the result */
progress?: (line: string) => void;
},
): Promise<CommandResult> {
try {
const target = out ?? path.slice(0, path.length - extname(path).length) + '.html';
if (real(target) === real(path)) throw new CliError(`refusing to overwrite the input ${path}; pass -o <out.html>`);
const { html, diagnostics } = await buildHtml(path, template);
const { html, diagnostics } = await buildHtml(path, template, progress);
// Diagnostics go to stderr so stdout carries only the receipt.
if (html === undefined) return { code: 1, stdout: '', stderr: `${report(diagnostics)}${summary(diagnostics)}\n` };
writeAtomic(target, html);
Expand Down
12 changes: 12 additions & 0 deletions packages/cli/test/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,18 @@ describe('deliver', () => {
expect(failed.stderr).toBe(`warning: couldn't open ${join(dir, 'platform.html')} in a browser (xdg-open not found)\n`);
});

it('says up front when a layout will be slow, and stays quiet for a small one', async () => {
const lines: string[] = [];
await deliverCommand(file('small.json', groupedPlatform), { template: TEMPLATE, progress: (l) => lines.push(l) });
expect(lines).toEqual([]);
const nodes = Array.from({ length: 61 }, (_, i) => ({ id: `n${i}`, type: 'service', card: { title: `n${i}` } }));
const edges = nodes.slice(1).map((n, i) => ({ id: `e${i}`, from: `n${i}`, to: n.id }));
const big = { kind: 'architecture', title: 'Big', nodes, edges };
const r = await deliverCommand(file('big.json', big), { template: TEMPLATE, progress: (l) => lines.push(l) });
expect(r.code).toBe(0);
expect(lines).toEqual(['laying out 61 nodes and 60 connections; a diagram this size can take a while']);
}, 30_000);

it('opens nothing when validation fails', async () => {
let opened = false;
const r = await deliverCommand(file('broken.json', broken), { template: TEMPLATE, open: async () => ((opened = true), null) });
Expand Down
118 changes: 109 additions & 9 deletions packages/layout/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { spawn } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { Worker as Thread } from 'node:worker_threads';
import ElkApi from 'elkjs/lib/elk-api.js';
import ElkBundled from 'elkjs/lib/elk.bundled.js';
import type { ElkExtendedEdge, ElkNode } from 'elkjs/lib/elk-api';
Expand All @@ -24,20 +27,114 @@ const GROUP_LAYER_GAP = 80;
* graph, and a diagram that size is read zoomed in anyway. */
const RETRY_LIMIT = 60;
const RETRY_EDGE_LIMIT = 3 * RETRY_LIMIT;
const isLarge = (draft: DiagramDraft) => draft.nodes.length > RETRY_LIMIT || draft.edges.length > RETRY_EDGE_LIMIT;
/** A diagram the layout treats as large: the CLI says so before laying one out, since it can still take a while. */
export const isLarge = (draft: DiagramDraft) => draft.nodes.length > RETRY_LIMIT || draft.edges.length > RETRY_EDGE_LIMIT;
const LAYER_GAP = 'elk.layered.spacing.nodeNodeBetweenLayers';
const EDGE_RUN = 'elk.layered.spacing.edgeNodeBetweenLayers';
const GROUP_PREFIX = 'group:';

/** ELK recurses along each connected run of the graph: a few hundred connected nodes overflow Node's ~1 MB stack. */
const ELK_STACK_MB = 256;

// A thread for Node: without a global `self`, elkjs's worker script exports its in-process worker, bridged to the port.
const NODE_THREAD = `
const { parentPort, workerData } = require('node:worker_threads');
const elk = new (require(workerData).Worker)();
elk.onmessage = ({ data }) => {
try {
parentPort.postMessage(data);
} catch {
// A GWT exception doesn't survive the structured clone.
parentPort.postMessage({ id: data.id, error: { message: String((data.error && data.error.message) || data.error) } });
}
};
parentPort.on('message', (msg) => elk.postMessage(msg));
`;

function threadElk(workerUrl: string): { elk: InstanceType<typeof ElkBundled>; dispose: () => void } {
const thread = new Thread(NODE_THREAD, { eval: true, workerData: workerUrl, resourceLimits: { stackSizeMb: ELK_STACK_MB } });
const pending = new Set<number>();
const port = {
onmessage: null as ((answer: { data: unknown }) => void) | null,
postMessage(msg: { id: number }) {
pending.add(msg.id);
thread.postMessage(msg);
},
terminate: () => void thread.terminate(),
};
thread.on('message', (data: { id: number; error?: { message: string } }) => {
pending.delete(data.id);
port.onmessage?.({ data: data.error ? { ...data, error: new Error(data.error.message) } : data });
});
// A thread that dies (out of memory, say) answers nothing: fail what it was asked instead of hanging.
thread.on('error', (error) => {
for (const id of pending) port.onmessage?.({ data: { id, error } });
pending.clear();
});
const elk = new ElkApi({ workerFactory: () => port as never });
return { elk, dispose: () => elk.terminateWorker() };
}

/**
* ELK in-process where a global `self` exists: its worker script, run with `self` hidden, exports the in-process worker
* instead of taking over `self.onmessage`. Bun's fallback for a large graph where a child process can't be started.
*/
function mainThreadElk(workerUrl: string): InstanceType<typeof ElkBundled> {
const mod: { exports: { Worker?: new () => unknown } } = { exports: {} };
new Function('self', 'module', 'exports', readFileSync(workerUrl, 'utf8'))(undefined, mod, mod.exports);
return new ElkApi({ workerFactory: () => new mod.exports.Worker!() as never });
}

/** Bun's stack is fixed at startup by the OS limit and a JavaScriptCore option, and its workers ignore stack limits. */
const BUN_STACK_KB = 512 * 1024;
const BUN_CHILD = `
const { readFileSync } = require('node:fs');
const [workerUrl, apiUrl] = process.argv.slice(1);
const mod = { exports: {} };
new Function('self', 'module', 'exports', readFileSync(workerUrl, 'utf8'))(undefined, mod, mod.exports);
const elk = new (require(apiUrl))({ workerFactory: () => new mod.exports.Worker() });
let input = '';
process.stdin.on('data', (c) => (input += c)).on('end', () =>
elk.layout(JSON.parse(input)).then(
(out) => process.stdout.write(JSON.stringify(out)),
(e) => {
process.stderr.write(String((e && e.message) || e));
process.exit(1);
},
),
);
`;

/** For Bun's large graphs: ELK in a child Bun started with room for its recursion (the densest need ~100 MB). */
function childElk(workerUrl: string): InstanceType<typeof ElkBundled> {
const apiUrl = createRequire(import.meta.url).resolve('elkjs/lib/elk-api.js');
// The option only when the limit rose: telling JavaScriptCore the stack is bigger than it is crashes instead of throwing.
const script = `if ulimit -s ${BUN_STACK_KB} 2>/dev/null; then export BUN_JSC_maxPerThreadStackUsage=${BUN_STACK_KB * 1024}; fi; exec "$0" -e "$1" "$2" "$3"`;
const layout = (graph: ElkNode) =>
new Promise<ElkNode>((resolve, reject) => {
const child = spawn('/bin/sh', ['-c', script, process.execPath, BUN_CHILD, workerUrl, apiUrl]);
let out = '';
let err = '';
child.stdout.on('data', (c: Buffer) => (out += c));
child.stderr.on('data', (c: Buffer) => (err += c));
child.on('error', reject);
child.on('close', (code) => (code === 0 ? resolve(JSON.parse(out) as ElkNode) : reject(new Error(err.trim() || `ELK exited with code ${code}`))));
child.stdin.end(JSON.stringify(graph));
});
return { layout } as never;
}

// elkjs's bundled build treats any runtime with a global `self` and no `document` as a web worker
// (Bun, Deno), hijacks `self.onmessage` and never exports its in-process worker. Give those runtimes
// a real Worker instead; Node keeps the in-process one.
function createElk(): { elk: InstanceType<typeof ElkBundled>; dispose: () => void } {
// a real Worker instead (a roomier child process for a large graph, above). Node keeps the in-process one, except for a large graph: that gets a thread with room for
// ELK's recursion (starting one costs more than laying out a small graph).
function createElk(large: boolean): { elk: InstanceType<typeof ElkBundled>; dispose: () => void } {
const g = globalThis as { self?: unknown; document?: unknown; Worker?: unknown };
if (g.self === undefined || g.document !== undefined || g.Worker === undefined) {
return { elk: new ElkBundled(), dispose: () => {} };
}
const node = g.self === undefined && g.document === undefined;
if (node ? !large : g.document !== undefined || g.Worker === undefined) return { elk: new ElkBundled(), dispose: () => {} };
const workerUrl = createRequire(import.meta.url).resolve('elkjs/lib/elk-worker.min.js');
if (node) return threadElk(workerUrl);
if (large) return { elk: process.platform === 'win32' ? mainThreadElk(workerUrl) : childElk(workerUrl), dispose: () => {} };
const elk = new ElkApi({ workerUrl });
// A real Worker keeps the process alive until terminated; the in-process one has no terminate().
return { elk, dispose: () => elk.terminateWorker() };
Expand All @@ -58,8 +155,11 @@ const rootOptions = (direction: Direction, compact: boolean, large: boolean): Re
'elk.layered.spacing.edgeNodeBetweenLayers': '24',
// Network simplex straightens edges best but dominates the run time on large graphs.
'elk.layered.nodePlacement.strategy': large ? 'BRANDES_KOEPF' : 'NETWORK_SIMPLEX',
// Draft order is the author's intended stacking; ELK otherwise reorders ties freely.
'elk.layered.considerModelOrder.strategy': 'NODES_AND_EDGES',
// Draft order is the author's intended stacking; ELK otherwise reorders ties freely. On a large graph keeping it
// costs ELK more than everything else together (60s against 2s for 200 flat nodes), and so do its extra crossing
// sweeps; a diagram that size is read zoomed in, not by its stacking.
'elk.layered.considerModelOrder.strategy': large ? 'NONE' : 'NODES_AND_EDGES',
...(large && { 'elk.layered.thoroughness': '1' }),
// Absolute coordinates everywhere, so the viewer never walks the hierarchy.
'org.eclipse.elk.json.shapeCoords': 'ROOT',
'org.eclipse.elk.json.edgeCoords': 'ROOT',
Expand Down Expand Up @@ -216,7 +316,7 @@ export async function layoutDiagram(draft: DiagramDraft): Promise<LaidOutDiagram
let reversed = reversedEdges(draft);
wire(reversed);

const { elk, dispose } = createElk();
const { elk, dispose } = createElk(large);
try {
const run = async (room?: { gap: number; run: number }) => {
const graph = structuredClone(root);
Expand Down
26 changes: 26 additions & 0 deletions packages/layout/test/layout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,32 @@ describe('layoutDiagram', () => {
for (const e of edges) expect(out.edges[e.id]!.length).toBeGreaterThanOrEqual(2);
}, 30_000);

it('lays out the largest diagram the schema allows without overflowing the stack', async () => {
// 500 nodes and 2000 edges between random pairs, the schema's most: minutes with the small-diagram settings, and
// ELK on Node's main thread threw "Maximum call stack size exceeded".
// mulberry32: an LCG's low bits repeat too soon to make a graph this tangled.
let seed = 2;
const rand = (n: number) => {
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) % n;
};
const nodes = Array.from({ length: 500 }, (_, i) => node(`n${i}`, { group: `g${i % 50}` }));
const groups = Array.from({ length: 50 }, (_, i) => ({ id: `g${i}`, label: `G${i}` }));
const seen = new Set<string>();
const edges: { id: string; from: string; to: string; label?: string }[] = [];
while (edges.length < 2000) {
const [a, b] = [rand(500), rand(500)];
if (a === b || seen.has(`${a}>${b}`)) continue;
seen.add(`${a}>${b}`);
edges.push({ id: `e${edges.length}`, from: `n${a}`, to: `n${b}`, ...(edges.length % 3 === 0 && { label: 'calls' }) });
}
const out = await layoutDiagram(draft({ direction: 'RIGHT', groups, nodes, edges }));
expect(Object.keys(out.nodes)).toHaveLength(500);
for (const e of edges) expect(out.edges[e.id]!.length).toBeGreaterThanOrEqual(2);
}, 120_000);

it('rejects dangling references with a precise message', async () => {
await expect(
layoutDiagram(draft({ nodes: [node('a')], edges: [{ id: 'e1', from: 'a', to: 'ghost' }] })),
Expand Down
9 changes: 5 additions & 4 deletions packages/viewer/src/canvas/DiagramCanvas.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,17 @@ describe('DiagramCanvas', () => {
expect(nodeCardRenders.mock.calls.length).toBe(rendersBefore);
});

it('selecting a card re-renders only the cards whose emphasis or tab stop changed', () => {
it('selecting a card restyles the cards around it without re-rendering what is inside any of them', () => {
const { container } = render(
<ExploreProvider draft={commerceApiLayout.draft}>
<DiagramCanvas diagram={commerceApiLayout} />
</ExploreProvider>,
);
nodeCardRenders.mockClear();
fireEvent.click(container.querySelector('.sm-card[data-card-id="orders"]')!);
// orders gains focus + the tab stop; edge (the default tab stop) loses it; sessions, not a neighbour, dims.
// The other three stay put.
expect([...new Set(nodeCardRenders.mock.calls.map(([id]) => id))].sort()).toEqual(['edge', 'orders', 'sessions']);
// orders gains focus; sessions, not a neighbour, dims. Only their wrappers change: a card's body depends on its node.
const emphasis = (id: string) => container.querySelector(`.sm-card[data-card-id="${id}"]`)!.getAttribute('data-emphasis');
expect([emphasis('orders'), emphasis('sessions')]).toEqual(['focus', 'dim']);
expect(nodeCardRenders).not.toHaveBeenCalled();
});
});
3 changes: 3 additions & 0 deletions packages/viewer/src/canvas/DiagramCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import { LABEL_MIN_ZOOM, type Transform } from './viewport';
import { CameraProvider, ContentProvider, SceneProvider, ViewportProvider } from './ViewportContext';

const PAN_STEP = 80;
/** Past this many cards (where validation warns) emphasis changes without fading: a fade repaints them all every frame. */
const LARGE_SCENE = 60;

function union(rects: Rect[]): Rect | null {
if (!rects.length) return null;
Expand Down Expand Up @@ -209,6 +211,7 @@ export function DiagramCanvas({
ref={sceneRef}
className="sm-viewport absolute top-0 left-0 origin-top-left"
data-labels={k < LABEL_MIN_ZOOM ? 'hidden' : undefined}
data-large={scene.cards.length > LARGE_SCENE || undefined}
style={{ transform: `translate(${x}px, ${y}px) scale(${k})` }}
>
<SceneLayers scene={scene} emphasis={emphasis} selected={state.selected} flow={playing ? flow : null} speed={state.speed} />
Expand Down
10 changes: 5 additions & 5 deletions packages/viewer/src/canvas/FlowLayer.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { useEffect, useRef } from 'react';
import { FLOW, glowAt, pointAt, PULSE, pulseFrame, ringAt, type Flow, type FlowPulse } from '../motion/flow';
import type { Point } from '@stackmap/core';
import { glowAt, pointAt, PULSE, pulseFrame, ringAt, sliceAt, type Flow, type FlowPulse } from '../motion/flow';
import { setInFlight } from '../motion/inFlight';

const accent = (p: FlowPulse) => `var(--sm-${p.tint}-accent)`;
const polyline = (p: FlowPulse) => `M${p.path.map((q) => `${q.x} ${q.y}`).join('L')}`;
const polyline = (path: Point[]) => `M${path.map((q) => `${q.x} ${q.y}`).join('L')}`;
const PACKETS = [0, 1, 2];

/** Drives the layer: calls `draw` with ms into playback, or null to hide every pulse; returns a stop. */
Expand Down Expand Up @@ -80,8 +81,8 @@ export function FlowLayer({ flow, width, height, speed = 1, clock }: { flow: Flo
if (!show(t, !!f.trail)) return;
const [from, to] = f.trail!;
const start = Math.max(from, to - (to - from) * PULSE.trail[j]![0]);
t.style.strokeDasharray = `${to - start} ${p.length + FLOW.tail}`;
t.style.strokeDashoffset = `${-start}`;
// Only the lit stretch, not the whole edge dashed: a moving dash repaints the edge's whole box every frame.
t.setAttribute('d', polyline(sliceAt(p.path, start, to)));
});
if (show(el.flash, f.depart !== null)) {
const r = ringAt(f.depart!, 2, 9);
Expand Down Expand Up @@ -121,7 +122,6 @@ export function FlowLayer({ flow, width, height, speed = 1, clock }: { flow: Flo
<path
key={j}
data-part="trail"
d={polyline(p)}
style={{ ...hidden, fill: 'none', stroke: fill, strokeWidth: width, strokeOpacity: p.kind === 'sync' ? opacity : opacity * 0.4, strokeLinejoin: 'round' }}
/>
))}
Expand Down
6 changes: 5 additions & 1 deletion packages/viewer/src/canvas/SceneLayers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ import { useCamera } from './ViewportContext';
import type { Flow } from '../motion/flow';
import { FlowLayer } from './FlowLayer';

// A selection re-renders the wrapper of every card it dims; the card inside depends only on its node.
const NodeCardBody = memo(NodeCard);
const StepCardBody = memo(StepCard);

const ARROWS: Record<string, Direction> = { ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right' };
export const focusCard = (id: string) => document.querySelector<HTMLElement>(`.sm-card[data-card-id="${CSS.escape(id)}"]`)?.focus();

Expand Down Expand Up @@ -197,7 +201,7 @@ const Card = memo(function Card({
}}
onKeyDown={onKeyDown}
>
{compact ? <StepCard node={node} final={final} /> : <NodeCard node={node} />}
{compact ? <StepCardBody node={node} final={final} /> : <NodeCardBody node={node} />}
</div>
);
});
Expand Down
Loading
Loading