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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/**
!build/**
# Intermediate output of scripts/build-wasm.mjs; js/wasm/*-bytes.js is the
# committed artifact the app actually loads.
wasm/*.wasm
29 changes: 29 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)).
Expand Down Expand Up @@ -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.
Expand All @@ -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`.
Expand Down
Loading
Loading