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

Filter by extension

Filter by extension

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