From c7f25c05493034cb2c683117d7b9cc428b1f8400 Mon Sep 17 00:00:00 2001 From: Shreyan Chaubey Date: Fri, 18 Sep 2026 08:54:06 +0530 Subject: [PATCH] perf(geometry): flatten the layout grid, and compile the label stage's inner loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A grid query was the hottest thing in the app — ~31% of the layout pass at 500 states, ahead of every piece of geometry it exists to serve — and almost none of that was searching. It was building a string `${ix},${iy}` per cell per query and hashing it, and allocating a result array per query to spread buckets into. Three changes, in the order they had to happen: 1. The grid becomes a flat open-addressed table with a linked list per cell, answering into a buffer it reuses. A CSR layout is faster still but has to be built from a complete list in one pass, and two of the three grids here are filled *during* the pass that queries them. 2. Each grid carries its items' numbers in a `data` array, `stride` per item, and a query answers with indices into it. The inner loops then read f64s out of one contiguous buffer instead of chasing a pointer per item. A group's key becomes an integer, which retires a string compare from the innermost comparison in the pass. 3. labelPenalty and the grid it queries are ported to AssemblyScript (wasm/label-penalty.ts, `npm run wasm`, output committed the way build-glyphs.mjs already does it). Measured, Node, tests/harness.js: grid + flattening ~2.0x on the pass 10.0ms -> 5.1ms at 200 states 67 -> 32 at 1000 the kernel on top 1.09-1.15x 3.46 -> 3.00 at 200 27.3 -> 24.5 at 1000 drag frame 1.66 -> 1.46 Worth recording: against well-written typed-array JS the instruction set is worth about forty per cent. The 2.9x that was really available here was a data-layout problem wearing a wasm-shaped hat, which is why step 1 is the larger half and why it had to come first — WebAssembly cannot see a JS object. The kernel is instantiated synchronously, because buildLayoutContext runs inside a frame and can await nothing. Chrome refuses synchronous compilation past 4KB on the main thread; this is 3,440 bytes and scripts/build-wasm.mjs warns when a change crosses the line. But that limit is the browser's, so every way it can fail ends in null and the JS implementation stays as the fallback — not a degraded mode, the same diagram computed the other way. tests/label-penalty-wasm lays the same machines out both ways and compares every label position exactly, on a full pass and across eight frames of a drag; Math.hypot became sqrt(dx*dx+dy*dy) on both sides so that can be bit-identical. gridGrow's first draft read the arrays it had just replaced, which the incremental-layout invariant caught and nothing else would have. --- .gitignore | 5 +- CLAUDE.md | 29 +++ js/geometry.js | 393 ++++++++++++++++++++++++++----- js/label-wasm.js | 58 +++++ js/wasm/label-penalty-bytes.js | 4 + package-lock.json | 73 +++++- package.json | 2 + scripts/build-wasm.mjs | 42 ++++ tests/harness.js | 4 + tests/label-penalty-wasm.test.js | 130 ++++++++++ wasm/label-penalty.ts | 218 +++++++++++++++++ 11 files changed, 883 insertions(+), 75 deletions(-) create mode 100644 js/label-wasm.js create mode 100644 js/wasm/label-penalty-bytes.js create mode 100644 scripts/build-wasm.mjs create mode 100644 tests/label-penalty-wasm.test.js create mode 100644 wasm/label-penalty.ts diff --git a/.gitignore b/.gitignore index e1fc12d..4324b8e 100644 --- a/.gitignore +++ b/.gitignore @@ -222,4 +222,7 @@ release/ # contents can be: git will not descend into an excluded directory, so `!build/**` # on its own would have no effect. !build/ -!build/** \ No newline at end of file +!build/** +# Intermediate output of scripts/build-wasm.mjs; js/wasm/*-bytes.js is the +# committed artifact the app actually loads. +wasm/*.wasm diff --git a/CLAUDE.md b/CLAUDE.md index c8aecdb..8468fdc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,8 +14,11 @@ node --test --test-name-pattern "subset construction" # single test by name npm run electron:dev # vite + electron pointed at the dev server npm run electron:preview # production build, run in electron npm run electron:build # electron-builder -> release/ +npm run wasm # asc wasm/label-penalty.ts -> js/wasm/ (output committed) ``` +`npm run wasm` is run by hand after editing [wasm/label-penalty.ts](wasm/label-penalty.ts) and its output is committed, the way `npm run glyphs` and `npm run icons` already are — the build does not shell out to a compiler. See [The label kernel](#the-label-kernel). + CI: `.github/workflows/deploy.yml` publishes `dist/` to GitHub Pages on push to `main`. `.github/workflows/electron-build.yml` packages win/mac/linux installers on every push and publishes a GitHub Release for `v*` tags. The package is `"type": "module"`. The two Electron entry points are CommonJS and carry a `.cjs` extension for that reason ([electron/main.cjs](electron/main.cjs), [electron/preload.cjs](electron/preload.cjs)). @@ -249,6 +252,9 @@ It is one pass rather than one call per edge because avoidance is global: a labe Points worth keeping in mind: +- **The grid is flat, and what it cost was the key rather than the lookup.** A grid query was the single hottest thing in the app — ~31% of the layout pass at 500 states, ahead of every piece of geometry it exists to serve — and almost none of that was searching. It was building a *string* `${ix},${iy}` per cell per query and hashing it (~45%), and allocating a result array per query to spread the buckets into (~20%). Measured over 20k label-sized queries on 500 nodes: string key + fresh array **9.12ms**, the same writing into a reused array **7.26ms**, an integer key into a Map of arrays **3.96ms**, and the flat open-addressed table with a linked list per cell that is there now **2.02ms**. End to end that is **1.7–1.9×** across every size: the layout pass **10.3ms → 5.9ms** at 200 states, **28.7 → 15.3** at 500 and **66.1 → 39.4** at 1000, and a drag frame **4.05 → 2.9ms** at 200 and **6.2 → 3.7** at 500 (Node, `tests/harness.js`). That is more than the 31% alone predicts, because the allocation it removed was also most of the GC, and because the callers stopped walking a freshly built array per query. A callback in place of the result array was tried and is *worse* (4.6ms): the three call sites pass three different closures, so the call inside the loop goes polymorphic. +- **A query result is only valid until the next query on the same grid.** That is the price of not allocating: `gridQuery` fills a buffer the grid owns and leaves the count in `grid.n`, so a caller reads `grid.out[i]` for `i < grid.n` and must be done before it asks that grid again. It holds at every site today — `labelPenalty` asks all three grids in turn and consumes each before the next, and `chooseSelfLoopAngle` holds a node result across twelve `scoreLoopAngle` calls that query nothing — and a nested query on one grid would silently read the inner result's items under the outer one's count. +- **Two of the three grids are filled *during* the pass that queries them** — the edge samples as edges are routed, the labels as they are placed — which is why the cells are an open-addressed table with a head/tail linked list rather than the counting sort that would be faster. A CSR layout has to be built from a complete list in one pass, and one structure that serves all three beats a second implementation for the one grid that is static. Insertion order is preserved *within* a cell, because the geometry sums float penalties over the result and a reordering is visible in the last bits. - **Label sizes are estimated, not measured.** The box has to exist before the text is in the DOM, and measuring per edge per frame would force a layout flush on every drag frame. `render.js` and `geometry.js` therefore share the pill metrics (`pillPartWidth`, `PILL_ROW_H`) — two copies of that arithmetic would place the label clear of a box that is not the one drawn. - **Stages 2–4 are skipped past `COLLISION_BUDGET_STATES` / `COLLISION_BUDGET_TRANSITIONS`**, where a pass no longer fits a drag frame; the geometry degrades to the plain drawing rather than getting slow. The four `App.config.render` flags (`smartSelfLoops`, `autoRouteEdges`, `smartLabels`, `avoidNodeOverlap`) switch the stages off independently, and **absent means on** — an imported config predating them must not read as "all off". - `resolveNodeOverlaps()` separates state circles, and `canvas.js` calls it on drop with `movable` set to the dragged ids so the crowd stays put. Coincident centres have no direction to divide by; it builds the unit vector directly rather than standing in an epsilon distance, which scaled the push by a thousand and fired the state off the canvas. @@ -260,6 +266,29 @@ Points worth keeping in mind: Dividers and notes take their undo point on the first *movement* (`App.dragPendingSnapshot`), the way states always did; pressing one is a selection, not an edit. `removeNotes`/`removeDividers` delete without snapshotting so that Delete over a mixed selection costs exactly one history step. +### The label kernel + +**The innermost loop of the label stage is compiled, and the flattening above is what made that possible rather than the other way round.** `labelPenalty` answers how much trouble a candidate label box is in, by asking all three grids what is near it; after the grid work above it was still **33.6%** of the layout pass's JS time, with most of `gridQuery`'s remaining 15.9% called from inside it. [wasm/label-penalty.ts](wasm/label-penalty.ts) is that function and the grid it queries, in AssemblyScript; `npm run wasm` compiles it and writes it into [js/wasm/](js/wasm/) as base64, the committed-output arrangement [scripts/build-glyphs.mjs](scripts/build-glyphs.mjs) already uses. + +**WebAssembly cannot see a JS object**, so every number the kernel reads had to be in a flat buffer before a kernel could exist at all — which is why the typed-array work came first and is not a detour. It is also, by a wide margin, the larger half of the win, and the two were measured separately because only one of them can be A/B'd in a single process: + +| | | | +| --- | --- | --- | +| the grid and the flattening | **~2.0×** on the pass (10.0ms → 5.1ms at 200 states, 67 → 32 at 1000) | swapping the whole file, alternating runs | +| the kernel on top | **1.09–1.15×** (3.46 → 3.00ms at 200, 27.3 → 24.5 at 1000; a drag frame 1.66 → 1.46) | `setLabelKernel`, both kernels in one process, best of five | + +On the function alone wasm is **1.39×** (586ns → 423ns per call), and it is ~45% of the pass, which is where the ~1.13× comes from. The lesson is worth keeping: against *well-written typed-array* JS the instruction set is worth about forty per cent, and the 2.9× that was really available here was a data-layout problem wearing a wasm-shaped hat. A JS→wasm call costs **6.2ns**, so batching the candidates into one call per label — the obvious next move — would buy nothing; the 1.39× is the kernel, not the boundary. + +Points worth keeping in mind: + +- **It is instantiated synchronously, and that is what dictates the delivery.** `buildLayoutContext` runs inside a frame — `updateFastDOM` calls it sixty times a second — so it can await nothing, which rules out `instantiateStreaming` off a URL. The bytes are base64 in a module the bundler already has, and `new WebAssembly.Module(bytes)` compiles them at module scope. **Chrome refuses synchronous compilation past 4KB on the main thread**; the kernel is 3,440 bytes and `scripts/build-wasm.mjs` warns when a change crosses the line. But that limit belongs to the browser, so it is *handled* rather than relied on. +- **Every way it can fail ends in `null`**, and [js/label-wasm.js](js/label-wasm.js) is the only module that knows about any of them: the size limit, a CSP forbidding `wasm-eval`, an engine with no `WebAssembly`, a corrupt artifact. `geometry.js` keeps the JS implementation of all of it for that case. +- **So the JS twin is not dead code, and it is not a degraded mode — it is the same diagram computed the other way.** That is the one thing about this that must not rot, so `setLabelKernel('js')` is a real seam and [tests/label-penalty-wasm.test.js](tests/label-penalty-wasm.test.js) uses it to lay the same machines out both ways and compare **every label position exactly**, on the full pass and across eight frames of a drag. Exactly, not approximately: a position is a sum of f64 penalties, and two kernels that only nearly agreed would come apart over a gesture rather than at the first frame. The harness restores `'auto'` in `resetModuleState`, or one test forcing the fallback would quietly hand it to the rest of the suite. +- **`Math.hypot` became `Math.sqrt(dx*dx + dy*dy)` on both sides**, because there is no hypot instruction to match it with. What hypot buys is protection against overflow at magnitudes a canvas coordinate never reaches, at a cost of a couple of ulp on a value that is then weighted and summed — so the two can be bit-identical, which is the property the test rests on. +- **The kernel owns the sample grid outright and only mirrors the other two.** Samples are by far the most numerous thing in a pass, so building that grid on both sides would cost more than the kernel saves; nodes and labels are wanted in JS as well — `nodesNearChord` and `chooseSelfLoopAngle` query the node grid, and `relayout`'s dirty scan asks the label grid which edge each box belongs to — and both are small enough that a mirror beats an export to read them back through. +- **It holds one pass's grids at a time**, which is safe only because `labelPenalty` is called from inside the pass that filled them and never from a context handed back to a caller. In `relayout` the reset therefore lands *after* the dirty scan, which reads the previous pass's JS label grid. +- **A group's key is an integer here.** `p.key !== ownKey` was a string compare, and it is the innermost comparison in the whole pass — run per sample, per candidate, per label. The id is the group's index in `ctx.groups`, stamped onto the geo where it is sampled, so a geo carried forward from the previous pass is renumbered against the grouping in front of it. + ### The dialogs **Every `.overlay` is the same three-part shell**, and what a dialog declares about itself is separate from its markup. The markup is `.overlay` (the scrim, and its id is the handle) → `.modal` (the card) → `.modal-title` / body / `.modal-foot`; the behaviour is `registerModal(id, {onClose, submit, onEscape, dismissOnBackdrop, initialFocus})` in [js/modal.js](js/modal.js), which also owns the stack, the Tab trap, the topmost-wins Escape and Enter-to-submit. Adding a dialog is markup plus one registration — never a branch in `closeModal`. diff --git a/js/geometry.js b/js/geometry.js index 22ce87e..1c0c96a 100644 --- a/js/geometry.js +++ b/js/geometry.js @@ -1,4 +1,5 @@ import { App, COLLISION_BUDGET_STATES, COLLISION_BUDGET_TRANSITIONS, R } from './state.js'; +import { labelWasm } from './label-wasm.js'; import { viewGraph } from './view-graph.js'; import { rectHasSegment } from './viewport.js'; @@ -240,33 +241,224 @@ function quadPoint(sx, sy, mx, my, ex, ey, t) { // which is exactly what a uniform grid is for. Without one, routing is // O(edges × states) and label placement is O(labels²) — fine at ten states, // visibly not fine at two hundred. -function makeGrid(cell) { return { cell: Math.max(1, cell), map: new Map() }; } +// +// It is a flat one rather than a Map of arrays, and the reason is that a grid +// query is the single hottest thing in the app: at 500 states the layout pass +// spends ~31% of its time in gridQuery alone, ahead of every piece of geometry +// it exists to serve. Almost none of that was the lookup. Profiled against the +// Map version on 20k label-sized queries over 500 nodes: +// +// string key `${ix},${iy}` + fresh array + spread 9.12ms (what this was) +// the same, writing into a reused array 7.26ms +// an integer key, still a Map of arrays 3.96ms +// this: flat hash + linked list, reused array 2.02ms +// +// So the cost was building a *string* per cell per query and hashing it (~45%), +// and allocating a result array per query and spreading buckets into it (~20%). +// Neither is work the pass needed doing. A callback in place of the result array +// was tried and is worse (4.6ms): the three call sites pass three different +// closures, so the call inside the loop goes polymorphic. +// +// Cells are open-addressed with linear probing and each holds a singly linked +// list through `next`. A CSR layout (counting sort into one flat run) is faster +// still, but it has to be built from a complete list in one pass, and two of the +// three grids here are filled *during* the pass that queries them — the edge +// samples as edges are routed, the labels as they are placed. One structure +// that does both beats a second implementation for the one grid that is static. +const GRID_MIN_SLOTS = 32; + +// Each grid carries its items' numbers in a flat `data` array, `stride` of them +// per item, and a query answers with *indices* into it rather than with the +// objects. The inner loops that follow — three of them per label candidate — +// then read plain f64s out of one contiguous buffer instead of chasing a +// pointer per item and loading four or five properties off whatever shape the +// object turned out to have. `items` is kept alongside for the two callers that +// genuinely need the object back (an identity test, and a group key). +function makeGrid(cell, stride) { + return { + cell: Math.max(1, cell), + stride, + // Open-addressed cell table. `head` doubles as the occupancy flag: every + // live cell holds at least one item, so -1 means the slot is free and there + // is no sentinel key to collide with a real one. + slots: GRID_MIN_SLOTS, + mask: GRID_MIN_SLOTS - 1, + keys: new Int32Array(GRID_MIN_SLOTS), + head: new Int32Array(GRID_MIN_SLOTS).fill(-1), + tail: new Int32Array(GRID_MIN_SLOTS), + used: 0, + // Items, in insertion order, and the chain that threads each cell through + // them. Insertion order is preserved *within* a cell — the query used to + // walk buckets in push order and the geometry sums float penalties over the + // result, where a reordering is visible in the last bits. + items: [], + next: new Int32Array(64), + data: new Float64Array(64 * stride), + // The index buffer every query writes into, and `n` is how much of it is + // live. Reused rather than allocated, so a result is only valid until the + // next query *on the same grid*. That is true at every call site: + // labelPenalty asks all three grids in turn and consumes each before the + // next, and chooseSelfLoopAngle holds a node result across twelve scoring + // calls that query nothing. + out: new Int32Array(64), + n: 0 + }; +} + +// (ix, iy) as one integer. The pack wraps past ±32768 cells, which at any cell +// size this app builds is some millions of pixels out; and a wrapped key can +// only ever *add* a far-away item to a result that the caller then tests +// exactly, which is the same "query wide, test exact" rule nodesNearChord and +// labelPenalty already follow for node radii. +function gridKey(ix, iy) { return ((ix & 0xffff) << 16) | (iy & 0xffff); } + +// murmur3's finalizer. The keys are lattice points, so the low bits alone would +// put a whole row of cells in one probe chain — and this mixes across the whole +// word rather than into the top bits, so masking stays sound at any table size. +function gridHash(k) { + k = Math.imul(k ^ (k >>> 16), 0x45d9f3b); + k = Math.imul(k ^ (k >>> 16), 0x45d9f3b); + return (k ^ (k >>> 16)) >>> 0; +} + +function gridSlot(grid, k) { + let i = gridHash(k) & grid.mask; + while (grid.head[i] !== -1 && grid.keys[i] !== k) i = (i + 1) & grid.mask; + return i; +} +function gridGrow(grid) { + // The old tables have to be held in locals before the grid is pointed at the + // new ones: gridSlot below probes `grid`, so reading the source through the + // same object would be reading the empty table it is rehashing into. + const oldSlots = grid.slots, oldKeys = grid.keys, oldHead = grid.head, oldTail = grid.tail; + const slots = oldSlots * 2; + const keys = new Int32Array(slots), head = new Int32Array(slots).fill(-1), tail = new Int32Array(slots); + grid.slots = slots; grid.mask = slots - 1; + grid.keys = keys; grid.head = head; grid.tail = tail; + for (let i = 0; i < oldSlots; i++) { + if (oldHead[i] === -1) continue; + const s = gridSlot(grid, oldKeys[i]); + keys[s] = oldKeys[i]; head[s] = oldHead[i]; tail[s] = oldTail[i]; + } +} + +// Files `item` under the cell containing (x, y) and answers its index, which is +// where the caller writes its `stride` numbers into `grid.data`. The cell +// coordinates are deliberately not the payload: a label is filed by its centre +// and tested as a rect, and an edge sample carries the id of the edge it is on. function gridAdd(grid, x, y, item) { - const k = `${Math.floor(x / grid.cell)},${Math.floor(y / grid.cell)}`; - const bucket = grid.map.get(k); - if (bucket) bucket.push(item); else grid.map.set(k, [item]); + const id = grid.items.length; + grid.items.push(item); + if (id >= grid.next.length) { + const cap = grid.next.length * 2; + const next = new Int32Array(cap); next.set(grid.next); grid.next = next; + const data = new Float64Array(cap * grid.stride); data.set(grid.data); grid.data = data; + const out = new Int32Array(cap); grid.out = out; + } + grid.next[id] = -1; + + const k = gridKey(Math.floor(x / grid.cell), Math.floor(y / grid.cell)); + let s = gridSlot(grid, k); + if (grid.head[s] === -1) { + // Kept under half full: past that, linear probing's chains grow fast. + if ((grid.used + 1) * 2 > grid.slots) { gridGrow(grid); s = gridSlot(grid, k); } + grid.keys[s] = k; grid.head[s] = id; grid.used++; + } else { + grid.next[grid.tail[s]] = id; + } + grid.tail[s] = id; + return id; } -// Everything in the cells covering [x0,x1] × [y0,y1]. A very long edge can span +// The indices of everything in the cells covering [x0,x1] × [y0,y1], written +// into `grid.out` with the count left in `grid.n`. A very long edge can span // more cells than there are items in the whole grid, so past a cell budget the -// query degrades to "everything" — still correct, just unfiltered. -function gridQuery(grid, x0, y0, x1, y1, all) { +// query degrades to "everything" — still correct, just unfiltered. There is no +// `all` list to hand back any more: the grid holds its own items, so the +// degenerate answer is simply every index it has. +function gridQuery(grid, x0, y0, x1, y1) { const c = grid.cell; const cx0 = Math.floor(x0 / c), cx1 = Math.floor(x1 / c); const cy0 = Math.floor(y0 / c), cy1 = Math.floor(y1 / c); const cells = (cx1 - cx0 + 1) * (cy1 - cy0 + 1); - if (!Number.isFinite(cells) || cells > 512) return all; - const out = []; + const count = grid.items.length; + const out = grid.out; + if (!Number.isFinite(cells) || cells > 512) { + for (let i = 0; i < count; i++) out[i] = i; + grid.n = count; + return out; + } + const { mask, keys, head, next } = grid; + let n = 0; for (let ix = cx0; ix <= cx1; ix++) { for (let iy = cy0; iy <= cy1; iy++) { - const bucket = grid.map.get(`${ix},${iy}`); - if (bucket) out.push(...bucket); + const k = gridKey(ix, iy); + let s = gridHash(k) & mask; + while (head[s] !== -1 && keys[s] !== k) s = (s + 1) & mask; + if (head[s] === -1) continue; + for (let id = head[s]; id !== -1; id = next[id]) out[n++] = id; } } + grid.n = n; return out; } +// A node's radius is asked for once, here, rather than per candidate per label: +// nodeR branches on the node's kind, and the label stage reads it millions of +// times across a pass. +function addNode(ctx, s) { + const grid = ctx.nodeGrid; + const r = nodeR(s); + const b = gridAdd(grid, s.x, s.y, s) * NODE_STRIDE; + const data = grid.data; + data[b] = s.x; data[b + 1] = s.y; data[b + 2] = r; + if (useWasm) WASM.addNode(s.x, s.y, r); +} + +// Whether the compiled kernel is available, decided once at load. See +// js/label-wasm.js for every way it can be `null` and why the JS below stays. +// +// Where it is present it owns the *sample* grid outright — samples are by far +// the most numerous thing in a pass, and building that grid on both sides would +// cost more than the kernel saves — and mirrors the nodes and the labels, which +// are also wanted in JS: nodesNearChord and chooseSelfLoopAngle query the node +// grid, and relayout's dirty scan asks the label grid which edge each box +// belongs to. Those two are small enough that a mirror is cheaper than an +// export to read them back through. +// +// One pass's grids at a time. That is safe because labelPenalty is only ever +// called from inside the pass that filled them — never from a context handed +// back to a caller — so a second buildLayoutContext cannot pull the memory out +// from under a first. +const WASM = labelWasm; +let useWasm = !!WASM; + +/** + * Which implementation the label stage runs, and the seam that lets the other + * one be tested. `'js'` forces the fallback; anything else restores the kernel + * where there is one. + * + * This is not a debugging leftover. The JS path is what every reader whose + * browser refuses the kernel gets, and on a machine where it compiles — which + * is every machine the suite runs on — that path would otherwise never execute + * again, and would rot silently until the day someone needed it. Switching to + * it is how tests/label-penalty-wasm.test.js asserts the two lay out the same + * diagram. + */ +export function setLabelKernel(mode) { + useWasm = mode === 'js' ? false : !!WASM; + return useWasm ? 'wasm' : 'js'; +} + +/** Which one is in force. */ +export function labelKernel() { return useWasm ? 'wasm' : 'js'; } + +// What each grid's `data` holds, per item. +const NODE_STRIDE = 3; // x, y, radius +const LABEL_STRIDE = 5; // rect x, y, w, h, and the id of the edge it labels +const SAMPLE_STRIDE = 3; // x, y, and the id of the edge it is on + // ══════════════════════════════════════════════════════════════════ // LABEL SIZES // ══════════════════════════════════════════════════════════════════ @@ -430,7 +622,7 @@ function loopCandidateAngles() { // `near` is every state that could matter for any candidate angle, gathered once // by the caller — the loop sweeps a disc around the state, so one query covers // all twelve directions and the per-candidate work is pure arithmetic. -function scoreLoopAngle(s, angle, near, dirs, m, labelSize) { +function scoreLoopAngle(s, angle, near, nNear, grid, dirs, m, labelSize) { const ux = Math.cos(angle), uy = Math.sin(angle); const lcx = s.x + m.centreOut * ux, lcy = s.y + m.centreOut * uy; const clear = nodeClearance(); @@ -442,16 +634,20 @@ function scoreLoopAngle(s, angle, near, dirs, m, labelSize) { s.y + (m.extent + labelGap() + labelSize.h / 2) * uy, labelSize.w, labelSize.h) : null; - for (const o of near) { - if (o.id === s.id) continue; + const { items, data } = grid; + for (let i = 0; i < nNear; i++) { + const id = near[i]; + if (items[id] === s) continue; + const b = id * NODE_STRIDE; + const ox = data[b], oy = data[b + 1]; // Both distances are the *other* node's, not this one's — a loop on a small // state still has to clear a big block standing beside it. - const or = nodeR(o); - const overlap = (m.ss + or + clear) - Math.hypot(o.x - lcx, o.y - lcy); + const or = data[b + 2]; + const overlap = (m.ss + or + clear) - Math.hypot(ox - lcx, oy - lcy); if (overlap > 0) score += overlap * 4; // The label rides outside the arc, so a direction can be clear for the loop // and still be wrong for the text. - if (box) score += circleRectOverlap(o.x, o.y, or + gap, box) * 2; + if (box) score += circleRectOverlap(ox, oy, or + gap, box) * 2; } // An edge arriving where the loop wants to sit is not an overlap the way a @@ -477,12 +673,13 @@ export function chooseSelfLoopAngle(s, ts, ctx, m, labelSize) { // The widest node in play rather than this one's: the query has to reach // whatever could be near, and the per-candidate test above is exact. const reach = m.extent + (labelSize ? labelSize.w + labelSize.h : 0) + ctxMaxR(ctx) + nodeClearance(); - const near = gridQuery(ctx.nodeGrid, s.x - reach, s.y - reach, s.x + reach, s.y + reach, ctx.states); + const near = gridQuery(ctx.nodeGrid, s.x - reach, s.y - reach, s.x + reach, s.y + reach); + const nNear = ctx.nodeGrid.n; const dirs = ctx.incidentDirs.get(s.id) || []; let best = UP, bestScore = Infinity; for (const angle of loopCandidateAngles()) { - const score = scoreLoopAngle(s, angle, near, dirs, m, labelSize); + const score = scoreLoopAngle(s, angle, near, nNear, ctx.nodeGrid, dirs, m, labelSize); if (score < bestScore) { bestScore = score; best = angle; } } return best; @@ -511,15 +708,21 @@ function nodesNearChord(from, to, ctx, slack) { const queryPad = ctxMaxR(ctx) + clear + slack; const x0 = Math.min(from.x, to.x) - queryPad, x1 = Math.max(from.x, to.x) + queryPad; const y0 = Math.min(from.y, to.y) - queryPad, y1 = Math.max(from.y, to.y) + queryPad; - const near = gridQuery(ctx.nodeGrid, x0, y0, x1, y1, ctx.states); + const grid = ctx.nodeGrid; + const near = gridQuery(grid, x0, y0, x1, y1); + const nNear = grid.n; + const { items, data } = grid; const hits = []; - for (const o of near) { - if (o.id === from.id || o.id === to.id) continue; - const { dist, t } = segmentDistance(o.x, o.y, from.x, from.y, to.x, to.y); + for (let i = 0; i < nNear; i++) { + const id = near[i]; + const o = items[id]; + if (o === from || o === to) continue; + const b = id * NODE_STRIDE; + const { dist, t } = segmentDistance(data[b], data[b + 1], from.x, from.y, to.x, to.y); // Only the interior counts: a state overlapping an endpoint is a node-node // overlap, and bending the edge cannot fix it. if (t <= 0.02 || t >= 0.98) continue; - if (dist < nodeR(o) + clear + slack) hits.push({ node: o, dist, t }); + if (dist < data[b + 2] + clear + slack) hits.push({ node: o, dist, t }); } return hits; } @@ -628,27 +831,69 @@ const LABEL_PUSHES = [0, 1, 2]; // are, but the box is small enough to touch four cells while the union of every // candidate for one label is not — and the difference is between a handful of // obstacles per test and every edge sample within a hundred pixels. -function labelPenalty(box, ctx, ownKey) { +function labelPenalty(box, ctx, ownKeyId) { const gap = labelGap(); // Query for the widest node that could reach this box; charge each one its // own radius. Same rule as nodesNearChord: query wide, test exact. const pad = ctxMaxR(ctx) + gap; + if (useWasm) return WASM.labelPenalty(box.x, box.y, box.w, box.h, ownKeyId, gap, pad); + return labelPenaltyJS(box, ctx, ownKeyId, gap, pad); +} + +// The same function in JS, and the reference the wasm one is tested against. +// It is reached whenever the kernel could not be compiled — see js/label-wasm.js +// — and is also what makes that test possible at all, since a claim that the two +// agree needs both of them to be callable. +export function labelPenaltyJS(box, ctx, ownKeyId, gap = labelGap(), pad = ctxMaxR(ctx) + gap) { + const bx = box.x, by = box.y, bw = box.w, bh = box.h; + const bx1 = bx + bw, by1 = by + bh; let penalty = 0; - for (const o of gridQuery(ctx.nodeGrid, box.x - pad, box.y - pad, box.x + box.w + pad, box.y + box.h + pad, ctx.states)) { - penalty += circleRectOverlap(o.x, o.y, nodeR(o) + gap, box) * 3; + const nodes = ctx.nodeGrid; + const near = gridQuery(nodes, bx - pad, by - pad, bx1 + pad, by1 + pad); + const nodeData = nodes.data; + for (let i = 0, n = nodes.n; i < n; i++) { + const b = near[i] * NODE_STRIDE; + const cx = nodeData[b], cy = nodeData[b + 1]; + const nx = cx < bx ? bx : cx > bx1 ? bx1 : cx; + const ny = cy < by ? by : cy > by1 ? by1 : cy; + // sqrt rather than Math.hypot, so the wasm port of this loop can answer the + // same f64. hypot's extra care is against overflow at magnitudes a canvas + // coordinate never reaches, and there is no hypot instruction to match it + // with; the two differ by at most a couple of ulp on a value that is then + // weighted and summed. + const dx = cx - nx, dy = cy - ny; + const over = (nodeData[b + 2] + gap) - Math.sqrt(dx * dx + dy * dy); + if (over > 0) penalty += over * 3; } - const grown = { x: box.x - gap, y: box.y - gap, w: box.w + gap * 2, h: box.h + gap * 2 }; - const x1 = grown.x + grown.w, y1 = grown.y + grown.h; - for (const other of gridQuery(ctx.labelGrid, grown.x, grown.y, x1, y1, ctx.placedLabels)) { - penalty += rectOverlap(grown, other) * 2; + const gx = bx - gap, gy = by - gap; + const gx1 = bx1 + gap, gy1 = by1 + gap; + const boxes = ctx.labelGrid; + const labels = gridQuery(boxes, gx, gy, gx1, gy1); + const labelData = boxes.data; + for (let i = 0, n = boxes.n; i < n; i++) { + const b = labels[i] * LABEL_STRIDE; + const ox = Math.min(gx1, labelData[b] + labelData[b + 2]) - Math.max(gx, labelData[b]); + if (ox <= 0) continue; + const oy = Math.min(gy1, labelData[b + 1] + labelData[b + 3]) - Math.max(gy, labelData[b + 1]); + if (oy <= 0) continue; + penalty += (ox < oy ? ox : oy) * 2; } // Edges are sampled into a point cloud once per pass, so "does this box sit on // a path?" is a handful of point-in-rect tests instead of a curve intersection. - for (const p of gridQuery(ctx.edgeGrid, grown.x, grown.y, x1, y1, ctx.edgeSamples)) { - if (p.key !== ownKey && pointInRect(p.x, p.y, grown)) penalty += 5; + // The edge a sample belongs to is an index, not the "from|to" string it used + // to be: this is the innermost comparison in the pass and it ran per sample + // per candidate per label. + const edges = ctx.edgeGrid; + const samples = gridQuery(edges, gx, gy, gx1, gy1); + const sampleData = edges.data; + for (let i = 0, n = edges.n; i < n; i++) { + const b = samples[i] * SAMPLE_STRIDE; + if (sampleData[b + 2] === ownKeyId) continue; + const px = sampleData[b], py = sampleData[b + 1]; + if (px >= gx && px <= gx1 && py >= gy && py <= gy1) penalty += 5; } return penalty; @@ -705,7 +950,7 @@ function placeLabel(geo, ctx) { let best = null; for (const c of candidates) { - const collision = labelPenalty(rectAt(c.x, c.y, w, h), ctx, geo.key); + const collision = labelPenalty(rectAt(c.x, c.y, w, h), ctx, geo.keyId); if (collision === 0) return c; const total = collision + c.cost; if (!best || total < best.total) best = { x: c.x, y: c.y, total }; @@ -817,14 +1062,13 @@ export function buildLayoutContext(opts = {}) { } const cell = 2 * maxR + nodeClearance() * 2; + if (useWasm && collide) WASM.resetGrids(cell); const ctx = { stateById, tsByPair, groups, states, collide, view, maxR, - nodeGrid: makeGrid(cell), + nodeGrid: makeGrid(cell, NODE_STRIDE), incidentDirs: new Map(), - edgeGrid: makeGrid(cell), - edgeSamples: [], - labelGrid: makeGrid(cell), - placedLabels: [], + edgeGrid: makeGrid(cell, SAMPLE_STRIDE), + labelGrid: makeGrid(cell, LABEL_STRIDE), geo: new Map() }; @@ -833,7 +1077,7 @@ export function buildLayoutContext(opts = {}) { if (collide) { ctx.pos = new Map(); for (const s of states) { - gridAdd(ctx.nodeGrid, s.x, s.y, s); + addNode(ctx, s); // What the next pass diffs against to learn which states moved. Recorded // here rather than asked of the drag, so every mover is caught whoever // moved it — a pointer drag, an align snap, auto-pan, an undo. @@ -878,7 +1122,10 @@ export function buildLayoutContext(opts = {}) { if (!wants('smartLabels')) return ctx; // Sample every path first: a label needs to know where all the edges are, and // the edges are all final by now. - for (const geo of ctx.geo.values()) sampleEdge(ctx, geo); + for (let i = 0; i < groups.length; i++) { + const geo = ctx.geo.get(groups[i].key); + if (geo) { geo.keyId = i; sampleEdge(ctx, geo); } + } for (const g of groups) placeGroupLabel(g, ctx); ctx.labelled = true; @@ -1089,8 +1336,11 @@ function relayout(prev, { groups, stateById, states, labelSizeFor }) { // Labels a moved state could have pushed, even where its edge did not move. for (const { s, was } of moved) { for (const [x, y] of [[was.x, was.y], [s.x, s.y]]) { - for (const b of gridQuery(prev.labelGrid, x - labelReach, y - labelReach, x + labelReach, y + labelReach, prev.placedLabels)) { - if (b.key) dirty.add(b.key); + const found = gridQuery(prev.labelGrid, x - labelReach, y - labelReach, x + labelReach, y + labelReach); + const boxes = prev.labelGrid.items; + for (let i = 0, n = prev.labelGrid.n; i < n; i++) { + const b = boxes[found[i]]; + if (b && b.key) dirty.add(b.key); } } } @@ -1100,15 +1350,16 @@ function relayout(prev, { groups, stateById, states, labelSizeFor }) { // where maintaining them incrementally would mean removal from three grids // for a saving smaller than the bookkeeping. const cell = 2 * maxR + nodeClearance() * 2; + // After the dirty scan above, which reads prev's *JS* label grid — the reset + // is what makes the wasm side belong to the pass being built. + if (useWasm) WASM.resetGrids(cell); const ctx = { stateById, tsByPair: prev.tsByPair, groups, states, collide: true, view: null, maxR, - nodeGrid: makeGrid(cell), + nodeGrid: makeGrid(cell, NODE_STRIDE), incidentDirs: new Map(), - edgeGrid: makeGrid(cell), - edgeSamples: [], - labelGrid: makeGrid(cell), - placedLabels: [], + edgeGrid: makeGrid(cell, SAMPLE_STRIDE), + labelGrid: makeGrid(cell, LABEL_STRIDE), geo: new Map(), env: prev.env, pos: prev.pos, @@ -1116,7 +1367,7 @@ function relayout(prev, { groups, stateById, states, labelSizeFor }) { labelled: true }; for (const s of states) { - gridAdd(ctx.nodeGrid, s.x, s.y, s); + addNode(ctx, s); const was = ctx.pos.get(s.id); was.x = s.x; was.y = s.y; } @@ -1143,15 +1394,14 @@ function relayout(prev, { groups, stateById, states, labelSizeFor }) { // Seed the avoidance structures with what survived, so a re-placed label is // placed clear of the labels and edges that did not move. - for (const g of groups) { + for (let i = 0; i < groups.length; i++) { + const g = groups[i]; const geo = ctx.geo.get(g.key); if (!geo) continue; + geo.keyId = i; if (dirty.has(g.key)) { geo.samples = null; geo.box = null; } sampleEdge(ctx, geo); - if (geo.box) { - ctx.placedLabels.push(geo.box); - gridAdd(ctx.labelGrid, geo.box.x + geo.box.w / 2, geo.box.y + geo.box.h / 2, geo.box); - } + if (geo.box) { geo.box.keyId = i; addLabelBox(ctx, geo.box); } } for (const g of groups) { if (dirty.has(g.key)) placeGroupLabel(g, ctx); @@ -1171,9 +1421,20 @@ function placeGroupLabel(g, ctx) { // which edges' labels sit near a state that moved. const box = rectAt(spot.x, spot.y, geo.labelSize.w, geo.labelSize.h); box.key = g.key; + box.keyId = geo.keyId; geo.box = box; - ctx.placedLabels.push(box); - gridAdd(ctx.labelGrid, spot.x, spot.y, box); + addLabelBox(ctx, box); +} + +// Filed by its centre, tested as a rect — so the cell coordinates and the +// payload are different numbers, which is why gridAdd does not store them. +function addLabelBox(ctx, box) { + const grid = ctx.labelGrid; + const b = gridAdd(grid, box.x + box.w / 2, box.y + box.h / 2, box) * LABEL_STRIDE; + const data = grid.data; + data[b] = box.x; data[b + 1] = box.y; data[b + 2] = box.w; data[b + 3] = box.h; + data[b + 4] = box.keyId; + if (useWasm) WASM.addLabel(box.x, box.y, box.w, box.h, box.keyId); } function pushDir(map, id, angle) { @@ -1192,15 +1453,23 @@ function normalize(x, y, fx, fy) { // re-inserted into the grid rather than re-walked along its own path. function sampleEdge(ctx, geo) { if (!geo.samples) geo.samples = buildSamples(geo); - for (const p of geo.samples) { - ctx.edgeSamples.push(p); - gridAdd(ctx.edgeGrid, p.x, p.y, p); + const pts = geo.samples, keyId = geo.keyId; + if (useWasm) { + for (let i = 0; i < pts.length; i += 2) WASM.addSample(pts[i], pts[i + 1], keyId); + return; + } + const grid = ctx.edgeGrid; + for (let i = 0; i < pts.length; i += 2) { + const x = pts[i], y = pts[i + 1]; + const b = gridAdd(grid, x, y, null) * SAMPLE_STRIDE; + const data = grid.data; + data[b] = x; data[b + 1] = y; data[b + 2] = keyId; } } function buildSamples(geo) { const out = []; - const add = (x, y) => { out.push({ x, y, key: geo.key }); }; + const add = (x, y) => { out.push(x, y); }; if (geo.isSelf) { const m = geo.loop; const cx = geo.from.x + m.centreOut * Math.cos(geo.angle); @@ -1209,7 +1478,7 @@ function buildSamples(geo) { const a = (i / 8) * Math.PI * 2; add(cx + m.ss * Math.cos(a), cy + m.ss * Math.sin(a)); } - return out; + return Float64Array.from(out); } // Spaced by roughly a label's height rather than by a fixed count: a long edge // sampled six times leaves gaps a whole label fits through, and would be @@ -1226,7 +1495,7 @@ function buildSamples(geo) { const p = pathPoint(geo, i / n); add(p.x, p.y); } - return out; + return Float64Array.from(out); } // ══════════════════════════════════════════════════════════════════ diff --git a/js/label-wasm.js b/js/label-wasm.js new file mode 100644 index 0000000..17091bb --- /dev/null +++ b/js/label-wasm.js @@ -0,0 +1,58 @@ +// The compiled label-stage kernel, instantiated once at module scope. +// +// **Synchronously**, which is the whole reason this module looks the way it +// does. `buildLayoutContext` runs inside a frame — `updateFastDOM` calls it +// sixty times a second during a drag — so it cannot await anything, and the +// usual `WebAssembly.instantiateStreaming` off a URL is therefore unavailable +// to it. A module already in the bundle is not: the bytes arrive as base64 in +// js/wasm/, and `new WebAssembly.Module(bytes)` compiles them here and now. +// +// That path has a limit: Chrome refuses synchronous compilation of more than +// 4KB on the main thread. The kernel is 3,440 bytes and scripts/build-wasm.mjs +// warns when a change crosses the line — but the limit is a browser's to +// change, not ours, so **the failure is handled rather than prevented**. Every +// way this can go wrong (the size limit, a CSP that forbids `wasm-eval`, an +// engine with no WebAssembly at all, a corrupt artifact) ends in `null`, and +// js/geometry.js keeps the JS implementation of every function in here for +// exactly that case. +// +// Which is also why tests/label-penalty-wasm.test.js pins the two against each +// other on the same fixtures: the fallback is not a degraded mode, it is the +// same answer computed the other way, and a reader whose browser takes the +// JS path must get the identical diagram. +import { LABEL_PENALTY_WASM } from './wasm/label-penalty-bytes.js'; + +function decodeBase64(b64) { + if (typeof atob === 'function') { + const bin = atob(b64); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; + } + // Node, where the test suite runs. + if (typeof Buffer !== 'undefined') return Buffer.from(b64, 'base64'); + return null; +} + +function instantiate() { + if (typeof WebAssembly === 'undefined') return null; + try { + const bytes = decodeBase64(LABEL_PENALTY_WASM); + if (!bytes) return null; + const mod = new WebAssembly.Module(bytes); + // `--runtime stub` still emits an `abort` import for the bounds checks that + // survive --noAssert. Nothing here should reach it; if anything does, it + // must throw rather than return a wrong number quietly. + const inst = new WebAssembly.Instance(mod, { + env: { abort() { throw new Error('label-penalty.wasm aborted'); } } + }); + const e = inst.exports; + if (typeof e.labelPenalty !== 'function' || typeof e.resetGrids !== 'function') return null; + return e; + } catch { + return null; + } +} + +/** The kernel's exports, or `null` where it could not be compiled. */ +export const labelWasm = instantiate(); diff --git a/js/wasm/label-penalty-bytes.js b/js/wasm/label-penalty-bytes.js new file mode 100644 index 0000000..d6eeea5 --- /dev/null +++ b/js/wasm/label-penalty-bytes.js @@ -0,0 +1,4 @@ +// GENERATED by scripts/build-wasm.mjs from wasm/label-penalty.ts — do not edit. +// 3440 bytes of WebAssembly, base64. See js/label-wasm.js. +export const LABEL_PENALTY_WASM = + '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'; diff --git a/package-lock.json b/package-lock.json index ed4e7eb..8045041 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "solid-js": "^1.9.15" }, "devDependencies": { + "assemblyscript": "^0.28.20", "concurrently": "^10.0.4", "cross-env": "^10.1.0", "electron": "^43.4.0", @@ -1033,9 +1034,9 @@ } }, "node_modules/@xmldom/xmldom": { - "version": "0.8.14", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.14.tgz", - "integrity": "sha512-T4EDRUBVZYRldYApjEJiU0e1stYWaRAX7CuSnKzrpwdZKo53zGV8/pqfzV6FfwNl9YThD2OumQYvqtvjvgG7aQ==", + "version": "0.8.15", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz", + "integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==", "dev": true, "license": "MIT", "engines": { @@ -1328,6 +1329,29 @@ "node": ">=12.0.0" } }, + "node_modules/assemblyscript": { + "version": "0.28.20", + "resolved": "https://registry.npmjs.org/assemblyscript/-/assemblyscript-0.28.20.tgz", + "integrity": "sha512-5PM7GZpvcLypHcLmi30aP8mqm5HjU+LL3BdV1xDyA3USGtL2w+iMiUnhtH+vW8+2kOuHlAyPu7M+QgyEw5he1g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "binaryen": "131.0.0-nightly.20260721", + "long": "^5.2.4" + }, + "bin": { + "asc": "bin/asc.js", + "asinit": "bin/asinit.js" + }, + "engines": { + "node": ">=20", + "npm": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/assemblyscript" + } + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -1440,6 +1464,24 @@ ], "license": "MIT" }, + "node_modules/binaryen": { + "version": "131.0.0-nightly.20260721", + "resolved": "https://registry.npmjs.org/binaryen/-/binaryen-131.0.0-nightly.20260721.tgz", + "integrity": "sha512-AAQIkhfbYXh4FBObwBrlO+5L+6Rp6OOMOjh6AdT0M+GLNLyrwKWgtW+EzmizNgx9CWDfTJPfDtKoZi62QESbtg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "wasm-as": "bin/wasm-as", + "wasm-ctor-eval": "bin/wasm-ctor-eval", + "wasm-dis": "bin/wasm-dis", + "wasm-merge": "bin/wasm-merge", + "wasm-metadce": "bin/wasm-metadce", + "wasm-opt": "bin/wasm-opt", + "wasm-reduce": "bin/wasm-reduce", + "wasm-shell": "bin/wasm-shell", + "wasm2js": "bin/wasm2js" + } + }, "node_modules/bluebird": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", @@ -2571,9 +2613,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", - "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", "dev": true, "funding": [ { @@ -3155,9 +3197,9 @@ } }, "node_modules/joi": { - "version": "18.2.3", - "resolved": "https://registry.npmjs.org/joi/-/joi-18.2.3.tgz", - "integrity": "sha512-N5A3KTWQpPWT4ExxxPlUx7WmykGXRzhNidWhV41d6Abu9YfI2NyWCJuxdPnslJCPWtbRpSVOWSnSS6GakLM/Rg==", + "version": "18.2.9", + "resolved": "https://registry.npmjs.org/joi/-/joi-18.2.9.tgz", + "integrity": "sha512-2mD929bUVKUhOLQQEVhlf6EZ0Mlo0DeRb5MO7cViR9AXLtBauuccEtB1py9Ocxpo/P7ucnh442iY/iOwrh3IQw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -3174,9 +3216,9 @@ } }, "node_modules/js-yaml": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", - "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "funding": [ { "type": "github", @@ -3551,6 +3593,13 @@ "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", "license": "MIT" }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/lowercase-keys": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", diff --git a/package.json b/package.json index 1b5d280..b2ad44b 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "main": "electron/main.cjs", "type": "module", "devDependencies": { + "assemblyscript": "^0.28.20", "concurrently": "^10.0.4", "cross-env": "^10.1.0", "electron": "^43.4.0", @@ -23,6 +24,7 @@ "test": "node --conditions=browser --conditions=development --test", "icons": "node scripts/build-icons.mjs", "glyphs": "node scripts/build-glyphs.mjs", + "wasm": "node scripts/build-wasm.mjs", "electron:dev": "concurrently -k -s first \"npm:dev\" \"wait-on http://localhost:5173 && cross-env ELECTRON_DEV_SERVER_URL=http://localhost:5173 electron .\"", "electron:preview": "npm run build && electron .", "electron:build": "npm run build && electron-builder" diff --git a/scripts/build-wasm.mjs b/scripts/build-wasm.mjs new file mode 100644 index 0000000..49dd3a6 --- /dev/null +++ b/scripts/build-wasm.mjs @@ -0,0 +1,42 @@ +// Compiles wasm/label-penalty.ts and writes it into js/wasm/ as base64. +// +// Committed output, run by hand — the arrangement build-glyphs.mjs already +// uses, and for the same reason: the compiler is a development dependency and +// what ships is a small generated file. +// +// Base64 in a JS module rather than a .wasm file beside it, because the module +// has to instantiate *synchronously*. buildLayoutContext runs inside a frame +// and cannot await anything, so fetch/instantiateStreaming is not available to +// it; a module that is already in the bundle is. See js/label-wasm.js. +// +// npm run wasm +import { execFileSync } from 'node:child_process'; +import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const src = join(root, 'wasm', 'label-penalty.ts'); +const tmp = join(root, 'wasm', 'label-penalty.wasm'); +const out = join(root, 'js', 'wasm', 'label-penalty-bytes.js'); + +// -O3z, and no runtime export: the module allocates its grids once and never +// frees, so a garbage collector would be dead weight in a size budget that +// matters — see the note in label-wasm.js about the 4KB synchronous limit. +execFileSync(process.execPath, [ + join(root, 'node_modules', 'assemblyscript', 'bin', 'asc.js'), + src, '--outFile', tmp, '-O3z', '--runtime', 'stub', '--noAssert' +], { stdio: 'inherit' }); + +const bytes = readFileSync(tmp); +mkdirSync(dirname(out), { recursive: true }); +writeFileSync(out, `// GENERATED by scripts/build-wasm.mjs from wasm/label-penalty.ts — do not edit. +// ${bytes.length} bytes of WebAssembly, base64. See js/label-wasm.js. +export const LABEL_PENALTY_WASM = + '${bytes.toString('base64')}'; +`); +console.log(`wasm ${bytes.length} bytes -> ${(bytes.length * 4 / 3 / 1024).toFixed(1)}KB base64`); +if (bytes.length >= 4096) { + console.warn('\n WARNING: past 4096 bytes, Chrome refuses synchronous compilation on the\n' + + ' main thread and js/label-wasm.js will fall back to the JS path.\n'); +} diff --git a/tests/harness.js b/tests/harness.js index 297700a..09fc58b 100644 --- a/tests/harness.js +++ b/tests/harness.js @@ -184,6 +184,10 @@ function resetModuleState() { // validated rather than invalidated, and a test can replace the model in a way // the validators coincide on (an empty array for an empty array). geometry.invalidateLayoutGroups(); + // A test that forced the JS label kernel must not hand the next one a pass + // running the fallback — the two agree, so it would be slow rather than + // wrong, which is the kind of thing nobody notices. + geometry.setLabelKernel('auto'); viewport.invalidateCull(); // The block index validates itself the way the state index does, and a test // can replace App.blocks with an equal-looking array the validator coincides diff --git a/tests/label-penalty-wasm.test.js b/tests/label-penalty-wasm.test.js new file mode 100644 index 0000000..792c649 --- /dev/null +++ b/tests/label-penalty-wasm.test.js @@ -0,0 +1,130 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHarness, context } from './harness.js'; + +// The label stage exists twice — compiled in wasm/label-penalty.ts and in JS in +// geometry.js — because the kernel cannot always be compiled: Chrome refuses +// synchronous compilation past 4KB on the main thread, a CSP can forbid it +// outright, and an engine may have no WebAssembly at all. js/label-wasm.js +// answers `null` in every one of those cases and the pass falls back. +// +// So the fallback is not a degraded mode; it is the same diagram computed the +// other way, and that is what this file pins. Everything else in the suite runs +// the kernel, because on any machine that can compile it that is what is in +// force — which is exactly why the other branch needs a test of its own. + +const { App } = context; + +function build(nStates, nTrans, machine = 'DFA') { + createHarness(); + App.machine = machine; + App.sigma = new Set(['a', 'b']); + App.states = []; App.transitions = []; App.accepts = new Set(); + for (let i = 0; i < nStates; i++) { + App.states.push({ + id: 's' + i, name: 'q' + i, + // Deliberately irregular: a lattice puts every label in an identical + // neighbourhood, where a disagreement has nowhere to show itself. + x: 120 + ((i * 137) % 900) + (i % 7) * 11, + y: 120 + ((i * 89) % 600) + (i % 5) * 13 + }); + } + App.startId = 's0'; + App.accepts.add('s' + (nStates - 1)); + App.stateN = nStates; + for (let i = 0; i < nTrans; i++) { + App.transitions.push({ + id: 't' + i, + from: 's' + (i % nStates), + to: 's' + ((i * 7 + 3) % nStates), + symbol: i % 2 ? 'a' : 'b' + }); + } + App.transN = nTrans; +} + +// Everything the label stage decides, in a comparable form. +function labelLayout() { + context.invalidateLayoutGroups(); + const ctx = context.buildLayoutContext({ collide: true }); + const out = []; + for (const g of ctx.groups) { + const geo = ctx.geo.get(g.key); + out.push(geo ? `${g.key} ${geo.lx} ${geo.ly}` : `${g.key} -`); + } + return out; +} + +test('the kernel compiles here, so the suite is exercising it', () => { + build(10, 14); + assert.equal(context.labelKernel(), 'wasm', + 'wasm did not compile in this environment — every other assertion here is vacuous'); +}); + +test('wasm and JS place every label identically', () => { + for (const [n, t] of [[12, 18], [40, 80], [120, 260]]) { + build(n, t); + + assert.equal(context.setLabelKernel('auto'), 'wasm'); + const withWasm = labelLayout(); + + assert.equal(context.setLabelKernel('js'), 'js'); + const withJs = labelLayout(); + + context.setLabelKernel('auto'); + + assert.ok(withWasm.length > 0, 'the fixture produced no labels to compare'); + // Exact, not approximate: a label position is a sum of f64 penalties, and + // two implementations that only nearly agree would drift a diagram apart + // over a drag rather than at the first frame. + assert.deepEqual(withJs, withWasm, + `${n} states / ${t} transitions: the two kernels laid labels out differently`); + } +}); + +test('the two kernels agree on the incremental path as well', () => { + // relayout reuses the previous pass's boxes and re-places only the dirty + // ones, so it reaches addLabelBox and labelPenalty by a different route than + // a full pass does — and in wasm mode it is also where resetGrids has to land + // after the dirty scan has read the previous pass's JS label grid. + // + // What is compared is the two kernels against each other, drag for drag, and + // deliberately *not* the incremental result against a full pass: a label that + // did not move is not re-examined against one that did, which is the one + // approximation relayout is documented to make. Routes are exact and are + // pinned by tests/incremental-layout.test.js; labels are not, and asserting + // otherwise here would be testing the wrong thing in both kernels at once. + const drag = (mode) => { + build(60, 120); + context.setLabelKernel(mode); + context.invalidateLayoutGroups(); + let ctx = context.buildLayoutContext({ collide: true }); + const moved = App.states[17]; + const seen = []; + for (let f = 0; f < 8; f++) { + moved.x += 6; moved.y -= 4; + ctx = context.buildLayoutContext({ collide: true, since: ctx }); + for (const g of ctx.groups) { + const geo = ctx.geo.get(g.key); + seen.push(geo ? `${f} ${g.key} ${geo.lx} ${geo.ly}` : `${f} ${g.key} -`); + } + } + return seen; + }; + + const withWasm = drag('auto'); + const withJs = drag('js'); + context.setLabelKernel('auto'); + + assert.ok(withWasm.length > 0, 'the drag produced no labels to compare'); + assert.deepEqual(withJs, withWasm, + 'the kernels diverged part-way through a drag, which a single frame would not show'); +}); + +test('a forced-JS test does not leak the fallback into the next one', () => { + build(10, 14); + context.setLabelKernel('js'); + assert.equal(context.labelKernel(), 'js'); + createHarness(); // what every test starts with + assert.equal(context.labelKernel(), 'wasm'); +}); diff --git a/wasm/label-penalty.ts b/wasm/label-penalty.ts new file mode 100644 index 0000000..822ba0a --- /dev/null +++ b/wasm/label-penalty.ts @@ -0,0 +1,218 @@ +// The label stage's inner loop, compiled to WebAssembly. +// +// This is a port of labelPenalty and the grid it queries out of +// js/geometry.js, and it is deliberately a *port* rather than a second design: +// the two have to answer the same number for the same diagram, so the grid's +// probe order, its insertion order within a cell and the arithmetic of each +// predicate are all reproduced exactly. tests/label-penalty-wasm.test.js is +// what holds them together. +// +// Three grids live here rather than one because labelPenalty asks all three per +// candidate box, and the crossing back into JS between them was most of what +// this exists to remove. + +const NODE: i32 = 0; // stride 3: x, y, radius +const LABEL: i32 = 1; // stride 5: rect x, y, w, h, edge id +const SAMPLE: i32 = 2; // stride 3: x, y, edge id + +const STRIDES: StaticArray = [3, 5, 3]; + +class Grid { + cell: f64 = 1; + stride: i32 = 3; + slots: i32 = 32; + mask: i32 = 31; + used: i32 = 0; + count: i32 = 0; + keys: Int32Array = new Int32Array(32); + head: Int32Array = new Int32Array(32); + tail: Int32Array = new Int32Array(32); + next: Int32Array = new Int32Array(64); + data: Float64Array = new Float64Array(64 * 3); + out: Int32Array = new Int32Array(64); + + reset(cell: f64, stride: i32): void { + this.cell = cell < 1 ? 1 : cell; + this.stride = stride; + this.slots = 32; this.mask = 31; this.used = 0; this.count = 0; + this.keys = new Int32Array(32); + this.head = new Int32Array(32); + this.tail = new Int32Array(32); + for (let i = 0; i < 32; i++) unchecked(this.head[i] = -1); + if (this.next.length < 64) this.next = new Int32Array(64); + if (this.out.length < 64) this.out = new Int32Array(64); + if (this.data.length < 64 * stride) this.data = new Float64Array(64 * stride); + } + + @inline slot(k: i32): i32 { + let i = gridHash(k) & this.mask; + while (unchecked(this.head[i]) != -1 && unchecked(this.keys[i]) != k) i = (i + 1) & this.mask; + return i; + } + + grow(): void { + const oldSlots = this.slots; + const oldKeys = this.keys, oldHead = this.head, oldTail = this.tail; + const slots = oldSlots * 2; + const keys = new Int32Array(slots), head = new Int32Array(slots), tail = new Int32Array(slots); + for (let i = 0; i < slots; i++) unchecked(head[i] = -1); + this.slots = slots; this.mask = slots - 1; + this.keys = keys; this.head = head; this.tail = tail; + for (let i = 0; i < oldSlots; i++) { + if (unchecked(oldHead[i]) == -1) continue; + const k = unchecked(oldKeys[i]); + const s = this.slot(k); + unchecked(keys[s] = k); + unchecked(head[s] = unchecked(oldHead[i])); + unchecked(tail[s] = unchecked(oldTail[i])); + } + } + + // Returns the new item's index, which is where `stride` numbers go in `data`. + add(x: f64, y: f64): i32 { + const id = this.count++; + if (id >= this.next.length) { + const cap = this.next.length * 2; + const next = new Int32Array(cap); next.set(this.next); this.next = next; + const data = new Float64Array(cap * this.stride); data.set(this.data); this.data = data; + this.out = new Int32Array(cap); + } + unchecked(this.next[id] = -1); + + const k = gridKey(Math.floor(x / this.cell), Math.floor(y / this.cell)); + let s = this.slot(k); + if (unchecked(this.head[s]) == -1) { + if ((this.used + 1) * 2 > this.slots) { this.grow(); s = this.slot(k); } + unchecked(this.keys[s] = k); + unchecked(this.head[s] = id); + this.used++; + } else { + unchecked(this.next[unchecked(this.tail[s])] = id); + } + unchecked(this.tail[s] = id); + return id; + } + + // Fills `out` with the indices in the cells covering the box, and answers how + // many. Past the cell budget it degrades to every index, exactly as the JS + // does — an edge long enough to span the diagram is cheaper to scan whole. + query(x0: f64, y0: f64, x1: f64, y1: f64): i32 { + const c = this.cell; + const cx0 = Math.floor(x0 / c), cx1 = Math.floor(x1 / c); + const cy0 = Math.floor(y0 / c), cy1 = Math.floor(y1 / c); + const count = this.count; + const out = this.out; + const cells = (cx1 - cx0 + 1) * (cy1 - cy0 + 1); + if (cells > 512 || cells < 0) { + for (let i = 0; i < count; i++) unchecked(out[i] = i); + return count; + } + const mask = this.mask, keys = this.keys, head = this.head, next = this.next; + let n = 0; + for (let ix = cx0; ix <= cx1; ix++) { + for (let iy = cy0; iy <= cy1; iy++) { + const k = gridKey(ix, iy); + let s = gridHash(k) & mask; + while (unchecked(head[s]) != -1 && unchecked(keys[s]) != k) s = (s + 1) & mask; + if (unchecked(head[s]) == -1) continue; + for (let id = unchecked(head[s]); id != -1; id = unchecked(next[id])) unchecked(out[n++] = id); + } + } + return n; + } +} + +@inline function gridKey(ix: i32, iy: i32): i32 { return ((ix & 0xffff) << 16) | (iy & 0xffff); } + +@inline function gridHash(k: i32): i32 { + let h = k; + h = (h ^ (h >>> 16)) * 0x45d9f3b; + h = (h ^ (h >>> 16)) * 0x45d9f3b; + return (h ^ (h >>> 16)); +} + +const grids: StaticArray = [new Grid(), new Grid(), new Grid()]; + +export function resetGrids(cell: f64): void { + for (let i = 0; i < 3; i++) unchecked(grids[i]).reset(cell, unchecked(STRIDES[i])); +} + +export function addNode(x: f64, y: f64, r: f64): void { + const g = unchecked(grids[NODE]); + const b = g.add(x, y) * 3; + const d = g.data; + unchecked(d[b] = x); unchecked(d[b + 1] = y); unchecked(d[b + 2] = r); +} + +export function addSample(x: f64, y: f64, keyId: f64): void { + const g = unchecked(grids[SAMPLE]); + const b = g.add(x, y) * 3; + const d = g.data; + unchecked(d[b] = x); unchecked(d[b + 1] = y); unchecked(d[b + 2] = keyId); +} + +// Filed by its centre and tested as a rect, so the cell coordinates are not the +// payload — the same split the JS side makes. +export function addLabel(x: f64, y: f64, w: f64, h: f64, keyId: f64): void { + const g = unchecked(grids[LABEL]); + const b = g.add(x + w / 2, y + h / 2) * 5; + const d = g.data; + unchecked(d[b] = x); unchecked(d[b + 1] = y); + unchecked(d[b + 2] = w); unchecked(d[b + 3] = h); + unchecked(d[b + 4] = keyId); +} + +// How much trouble a label box is in where it is: nothing at all, or a weighted +// sum of how deep into each obstacle it sits. The three loops and their weights +// are the JS function's, line for line. +export function labelPenalty( + bx: f64, by: f64, bw: f64, bh: f64, ownKeyId: f64, gap: f64, pad: f64 +): f64 { + const bx1 = bx + bw, by1 = by + bh; + let penalty: f64 = 0; + + const nodes = unchecked(grids[NODE]); + let n = nodes.query(bx - pad, by - pad, bx1 + pad, by1 + pad); + let out = nodes.out; + let d = nodes.data; + for (let i = 0; i < n; i++) { + const b = unchecked(out[i]) * 3; + const cx = unchecked(d[b]), cy = unchecked(d[b + 1]); + const nx = cx < bx ? bx : (cx > bx1 ? bx1 : cx); + const ny = cy < by ? by : (cy > by1 ? by1 : cy); + const dx = cx - nx, dy = cy - ny; + const over = (unchecked(d[b + 2]) + gap) - Math.sqrt(dx * dx + dy * dy); + if (over > 0) penalty += over * 3; + } + + const gx = bx - gap, gy = by - gap; + const gx1 = bx1 + gap, gy1 = by1 + gap; + + const boxes = unchecked(grids[LABEL]); + n = boxes.query(gx, gy, gx1, gy1); + out = boxes.out; + d = boxes.data; + for (let i = 0; i < n; i++) { + const b = unchecked(out[i]) * 5; + const lx = unchecked(d[b]), ly = unchecked(d[b + 1]); + const rx = lx + unchecked(d[b + 2]), ry = ly + unchecked(d[b + 3]); + const ox = (gx1 < rx ? gx1 : rx) - (gx > lx ? gx : lx); + if (ox <= 0) continue; + const oy = (gy1 < ry ? gy1 : ry) - (gy > ly ? gy : ly); + if (oy <= 0) continue; + penalty += (ox < oy ? ox : oy) * 2; + } + + const edges = unchecked(grids[SAMPLE]); + n = edges.query(gx, gy, gx1, gy1); + out = edges.out; + d = edges.data; + for (let i = 0; i < n; i++) { + const b = unchecked(out[i]) * 3; + if (unchecked(d[b + 2]) == ownKeyId) continue; + const px = unchecked(d[b]), py = unchecked(d[b + 1]); + if (px >= gx && px <= gx1 && py >= gy && py <= gy1) penalty += 5; + } + + return penalty; +}