diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 155a2da..3783a65 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -75,7 +75,11 @@ async function run(argv: string[]): Promise { } 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}"`); diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index 43238f4..d35cd95 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -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'; @@ -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 { +export async function buildHtml( + path: string, + template: string, + progress?: (line: string) => void, +): Promise { 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)) }; } @@ -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; + /** a line to show the user now, not with the result */ + progress?: (line: string) => void; }, ): Promise { 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 `); - 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); diff --git a/packages/cli/test/commands.test.ts b/packages/cli/test/commands.test.ts index f4c8df0..084d7da 100644 --- a/packages/cli/test/commands.test.ts +++ b/packages/cli/test/commands.test.ts @@ -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) }); diff --git a/packages/layout/src/index.ts b/packages/layout/src/index.ts index ba3f31a..674a0a6 100644 --- a/packages/layout/src/index.ts +++ b/packages/layout/src/index.ts @@ -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'; @@ -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; dispose: () => void } { + const thread = new Thread(NODE_THREAD, { eval: true, workerData: workerUrl, resourceLimits: { stackSizeMb: ELK_STACK_MB } }); + const pending = new Set(); + 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 { + 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 { + 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((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; 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; 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() }; @@ -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', @@ -216,7 +316,7 @@ export async function layoutDiagram(draft: DiagramDraft): Promise { const graph = structuredClone(root); diff --git a/packages/layout/test/layout.test.ts b/packages/layout/test/layout.test.ts index 5394d71..ae1b839 100644 --- a/packages/layout/test/layout.test.ts +++ b/packages/layout/test/layout.test.ts @@ -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(); + 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' }] })), diff --git a/packages/viewer/src/canvas/DiagramCanvas.test.tsx b/packages/viewer/src/canvas/DiagramCanvas.test.tsx index b8275c9..3352e73 100644 --- a/packages/viewer/src/canvas/DiagramCanvas.test.tsx +++ b/packages/viewer/src/canvas/DiagramCanvas.test.tsx @@ -34,7 +34,7 @@ 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( @@ -42,8 +42,9 @@ describe('DiagramCanvas', () => { ); 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(); }); }); diff --git a/packages/viewer/src/canvas/DiagramCanvas.tsx b/packages/viewer/src/canvas/DiagramCanvas.tsx index 64ccb99..fdca39d 100644 --- a/packages/viewer/src/canvas/DiagramCanvas.tsx +++ b/packages/viewer/src/canvas/DiagramCanvas.tsx @@ -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; @@ -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})` }} > diff --git a/packages/viewer/src/canvas/FlowLayer.tsx b/packages/viewer/src/canvas/FlowLayer.tsx index 37a8fcc..b456179 100644 --- a/packages/viewer/src/canvas/FlowLayer.tsx +++ b/packages/viewer/src/canvas/FlowLayer.tsx @@ -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. */ @@ -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); @@ -121,7 +122,6 @@ export function FlowLayer({ flow, width, height, speed = 1, clock }: { flow: Flo ))} diff --git a/packages/viewer/src/canvas/SceneLayers.tsx b/packages/viewer/src/canvas/SceneLayers.tsx index ccf43ed..d190f4d 100644 --- a/packages/viewer/src/canvas/SceneLayers.tsx +++ b/packages/viewer/src/canvas/SceneLayers.tsx @@ -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 = { ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right' }; export const focusCard = (id: string) => document.querySelector(`.sm-card[data-card-id="${CSS.escape(id)}"]`)?.focus(); @@ -197,7 +201,7 @@ const Card = memo(function Card({ }} onKeyDown={onKeyDown} > - {compact ? : } + {compact ? : } ); }); diff --git a/packages/viewer/src/canvas/useZoom.ts b/packages/viewer/src/canvas/useZoom.ts index d997ebf..71e31d8 100644 --- a/packages/viewer/src/canvas/useZoom.ts +++ b/packages/viewer/src/canvas/useZoom.ts @@ -4,7 +4,7 @@ import { zoom, zoomIdentity, type D3ZoomEvent, type ZoomBehavior } from 'd3-zoom import { useCallback, useLayoutEffect, useMemo, useRef, useState, type RefObject } from 'react'; import type { Point, Rect } from '@stackmap/core'; import { clearStoredViewport, readStoredViewport } from '../live'; -import { CHROME_INSET, fitTransform, MAX_ZOOM, MIN_ZOOM, viewportRect, ZOOM_STEP, type Size, type Transform } from './viewport'; +import { CHROME_INSET, fitTransform, MAX_ZOOM, MIN_ZOOM, openingTransform, viewportRect, ZOOM_STEP, type Size, type Transform } from './viewport'; /** Camera moves. Stable across renders, so consumers don't re-render on every pan/zoom frame. */ /** `instant` skips the animation: drags and key auto-repeat would otherwise interrupt it every frame. */ @@ -68,6 +68,10 @@ export function useZoom(stageRef: RefObject, content: Rec const z = zoom() .scaleExtent([MIN_ZOOM, MAX_ZOOM]) .filter(zoomFilter(wheel)) + // While the camera moves the scene rides its own compositor layer, so a big diagram isn't repainted every + // frame; dropping it afterwards re-rasters the text sharp at the new zoom. + .on('start', () => el.setAttribute('data-moving', '')) + .on('end', () => el.removeAttribute('data-moving')) .on('zoom', (event: D3ZoomEvent) => { if (event.sourceEvent) pending.current = null; // the user took over const { x, y, k } = event.transform; @@ -81,7 +85,7 @@ export function useZoom(stageRef: RefObject, content: Rec const size = { width: el.clientWidth, height: el.clientHeight }; setStage(size); - sel.call(z.transform, toZoom(stored ?? fitTransform(content, size, { inset: CHROME_INSET }))); + sel.call(z.transform, toZoom(stored ?? openingTransform(content, size, CHROME_INSET))); if (stored) clearStoredViewport(); // A stage that mounts hidden or collapsed has no size to fit to; fit on its first real one instead. let fitted = stored !== null || (size.width > 0 && size.height > 0); @@ -94,7 +98,7 @@ export function useZoom(stageRef: RefObject, content: Rec setStage(next); if (!fitted && next.width > 0 && next.height > 0) { fitted = true; - sel.call(z.transform, toZoom(fitTransform(content, next, { inset: CHROME_INSET }))); + sel.call(z.transform, toZoom(openingTransform(content, next, CHROME_INSET))); } }); observer?.observe(el); diff --git a/packages/viewer/src/canvas/viewport.test.ts b/packages/viewer/src/canvas/viewport.test.ts index 165e834..04e0a1f 100644 --- a/packages/viewer/src/canvas/viewport.test.ts +++ b/packages/viewer/src/canvas/viewport.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { clampZoom, fitTransform, MAX_ZOOM, MIN_ZOOM, viewportRect } from './viewport'; +import { clampZoom, fitTransform, LABEL_MIN_ZOOM, MAX_ZOOM, MIN_ZOOM, openingTransform, viewportRect } from './viewport'; describe('viewport math', () => { it('clamps zoom to [0.2, 2]', () => { @@ -48,6 +48,25 @@ describe('viewport math', () => { expect(wide.y + (400 * MIN_ZOOM) / 2).toBeCloseTo((80 + 600 - 76) / 2, 6); }); + it('opens a big diagram on a phone at a readable zoom from its start, while desktop still fits it whole', () => { + const inset = { top: 80, bottom: 76 }; + const content = { x: 0, y: 0, width: 3000, height: 800 }; + const phone = openingTransform(content, { width: 390, height: 844 }, inset); + expect(fitTransform(content, { width: 390, height: 844 }, { inset }).k).toBe(MIN_ZOOM); + expect(phone.k).toBe(LABEL_MIN_ZOOM); + expect(phone.x).toBeCloseTo(24, 6); + expect(phone.y + (800 * LABEL_MIN_ZOOM) / 2).toBeCloseTo((80 + 844 - 76) / 2, 6); + const desktop = { width: 1440, height: 900 }; + expect(openingTransform(content, desktop, inset)).toEqual(fitTransform(content, desktop, { inset })); + }); + + it('leaves a phone opening alone when the diagram already fits readably', () => { + const inset = { top: 80, bottom: 76 }; + const content = { x: 0, y: 0, width: 300, height: 300 }; + const stage = { width: 390, height: 844 }; + expect(openingTransform(content, stage, inset)).toEqual(fitTransform(content, stage, { inset })); + }); + it('returns identity for a zero-size stage or empty content', () => { expect(fitTransform({ x: 0, y: 0, width: 500, height: 300 }, { width: 0, height: 0 })).toEqual({ x: 0, y: 0, k: 1 }); expect(fitTransform({ x: 0, y: 0, width: 0, height: 0 }, { width: 800, height: 600 })).toEqual({ x: 0, y: 0, k: 1 }); diff --git a/packages/viewer/src/canvas/viewport.ts b/packages/viewer/src/canvas/viewport.ts index 62c8428..d568a54 100644 --- a/packages/viewer/src/canvas/viewport.ts +++ b/packages/viewer/src/canvas/viewport.ts @@ -18,6 +18,8 @@ export const ZOOM_STEP = 1.2; export const LABEL_MIN_ZOOM = 0.5; export const FIT_PADDING = 0.15; const OVERFLOW_GUTTER = 24; +/** Phone-width stages: fitting a big diagram there lands on the zoom floor, a smear no one can read. */ +export const NARROW_STAGE = 640; export const clampZoom = (k: number) => Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, k)); @@ -37,24 +39,31 @@ export const CHROME_INSET: Inset = { top: 80, bottom: 76 }; export function fitTransform( content: Rect, stage: Size, - { inset = { top: 0, bottom: 0 }, padding = FIT_PADDING }: { inset?: Inset; padding?: number } = {}, + { + inset = { top: 0, bottom: 0 }, + padding = FIT_PADDING, + minZoom = MIN_ZOOM, + }: { inset?: Inset; padding?: number; minZoom?: number } = {}, ): Transform { if (stage.width <= 0 || stage.height <= 0 || content.width <= 0 || content.height <= 0) return { x: 0, y: 0, k: 1 }; const { width: W, height: H } = stage; const { width: w, height: h } = content; const half = padding / 2; const k = clampZoom( - Math.min( - W / (w * (1 + padding)), - H / (h * (1 + padding)), - (H - inset.top) / (h * (1 + half)), - (H - inset.bottom) / (h * (1 + half)), - (H - inset.top - inset.bottom) / h, + Math.max( + minZoom, + Math.min( + W / (w * (1 + padding)), + H / (h * (1 + padding)), + (H - inset.top) / (h * (1 + half)), + (H - inset.bottom) / (h * (1 + half)), + (H - inset.top - inset.bottom) / h, + ), ), ); const top = Math.max(inset.top, half * h * k); const bottom = Math.max(inset.bottom, half * h * k); - // Still too big at the zoom floor: centring would open mid-way, so start where the diagram is read from. + // Still too big at the zoom floor (or the opening floor): centring would open mid-way, so start where the diagram is read from. const x = w * k > W ? OVERFLOW_GUTTER : (W - w * k) / 2; const y = h * k > H - inset.top - inset.bottom ? inset.top : top + (H - top - bottom - h * k) / 2; return { x: x - content.x * k, y: y - content.y * k, k }; @@ -64,3 +73,11 @@ export function fitTransform( export function viewportRect(t: Transform, stage: Size): Rect { return { x: -t.x / t.k, y: -t.y / t.k, width: stage.width / t.k, height: stage.height / t.k }; } + +/** + * The first camera. On a narrow stage it opens no smaller than labels stay readable, from where the diagram is + * read; the Fit button still shows the whole thing. + */ +export function openingTransform(content: Rect, stage: Size, inset: Inset): Transform { + return fitTransform(content, stage, { inset, minZoom: stage.width < NARROW_STAGE ? LABEL_MIN_ZOOM : MIN_ZOOM }); +} diff --git a/packages/viewer/src/motion/flow.test.ts b/packages/viewer/src/motion/flow.test.ts index 0e3fd1b..85370d2 100644 --- a/packages/viewer/src/motion/flow.test.ts +++ b/packages/viewer/src/motion/flow.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { buildGraph } from '../explore/graph'; -import { FLOW, glowAt, hopFlow, hopRanks, pointAlong, pulseFrame, roundedPolyline, travelFor, waveFlow, type FlowEdge } from './flow'; +import { FLOW, glowAt, hopFlow, hopRanks, pointAlong, pulseFrame, roundedPolyline, sliceAt, travelFor, waveFlow, type FlowEdge } from './flow'; const edge = (id: string, from: string, to: string, length = 100): FlowEdge => ({ id, @@ -16,6 +16,17 @@ const edge = (id: string, from: string, to: string, length = 100): FlowEdge => ( }); describe('flow geometry', () => { + it('cuts the stretch of a polyline between two distances, keeping the bends inside it', () => { + const pts = [ + { x: 0, y: 0 }, + { x: 10, y: 0 }, + { x: 10, y: 30 }, + ]; + expect(sliceAt(pts, 5, 20)).toEqual([{ x: 5, y: 0 }, { x: 10, y: 0 }, { x: 10, y: 10 }]); + expect(sliceAt(pts, 12, 20)).toEqual([{ x: 10, y: 2 }, { x: 10, y: 10 }]); + expect(sliceAt(pts, 0, 99)).toEqual([{ x: 0, y: 0 }, { x: 10, y: 0 }, { x: 10, y: 30 }]); + }); + it('finds the point a fraction of the way along a polyline', () => { const pts = [ { x: 0, y: 0 }, diff --git a/packages/viewer/src/motion/flow.ts b/packages/viewer/src/motion/flow.ts index 6d98187..2a16bd5 100644 --- a/packages/viewer/src/motion/flow.ts +++ b/packages/viewer/src/motion/flow.ts @@ -86,6 +86,18 @@ export function pointAt(points: Point[], d: number): Point { return points[0]!; } +/** The stretch of a polyline from `from` to `to` px along it, its bends included. */ +export function sliceAt(points: Point[], from: number, to: number): Point[] { + const out = [pointAt(points, from)]; + let walked = 0; + for (let i = 1; i < points.length - 1; i++) { + walked += Math.hypot(points[i]!.x - points[i - 1]!.x, points[i]!.y - points[i - 1]!.y); + if (walked > from && walked < to) out.push(points[i]!); + } + out.push(pointAt(points, to)); + return out; +} + /** The point `t` (0–1) of the way along a polyline. */ export const pointAlong = (points: Point[], t: number) => pointAt(points, Math.max(0, Math.min(1, t)) * lengthOf(points)); diff --git a/packages/viewer/src/viewer.css b/packages/viewer/src/viewer.css index 64321b7..e3e51b6 100644 --- a/packages/viewer/src/viewer.css +++ b/packages/viewer/src/viewer.css @@ -30,6 +30,10 @@ touch-action: none; } +.sm-stage[data-moving] .sm-viewport { + will-change: transform; +} + /* Explorer emphasis (M3). Opacity only: layout never changes in the viewer. */ .sm-card, .sm-edge-label { @@ -48,6 +52,9 @@ .sm-route-handle { transition: opacity 150ms ease; } +.sm-viewport[data-large] :is(.sm-card, .sm-edge-label, .sm-edge-path, .sm-route-handle) { + transition: none; +} /* Zoomed out past reading size, labels are only noise; those on lit edges (a selection, a trace) stay. */ .sm-viewport[data-labels='hidden']:not(.sm-exporting) .sm-edge-label:not([data-lit]) { opacity: 0;