Making the viewer fast, not just functional
Three rounds of foundation, touch polish, and UX cleanup have paid off: the viewer
looks great and handles every interaction cleanly. The one area that was still
noticeably rough was performance. Zoom out on a big trace and you could feel the
frame-rate wobble. Pan quickly with a finger on tablet and the position readout was
fighting the canvas for CPU time. Load a large .ab1 file and the whole
tab would freeze for a beat while the parser ran. This pass fixes all three.
Drawing fewer points
The original draw loop iterated over every sample in the viewport and emitted a
lineTo for each one. At maximum zoom-out that could be tens of thousands
of path segments for a single channel — far more than the canvas is wide. The GPU
never needed those points; at any zoom level you only have as many distinct x
columns as CSS pixels.
I added a new decimateSamples() helper in
src/render/decimation.ts. It uses min-max decimation: the sample range
visible in the viewport is divided into exactly pixelWidth buckets, and for
each bucket we record only the minimum and maximum value. The draw loop then emits
at most one short vertical segment per pixel column rather than one point per sample.
On a 100 k-sample trace at normal zoom this drops the path from ~5 000 segments to
~1 200 — the canvas width — with no visible difference in fidelity. Zoomed all the
way in the decimator falls back to the original one-point-per-sample path so there
is no loss of detail where detail matters.
The maxY normalisation scan got the same treatment. Previously it walked
every sample in all four channels to find the global peak. Now it walks only the
visible range, so the scan cost scales with the viewport width rather than the trace
length.
Both changes are unit-tested in tests/core/decimation.test.ts — pure
Node tests, no canvas, no browser. The performance smoke test also got a new
assertion: decimating a 100 k-sample channel to 1 200 points must complete in under
20 ms, and parsing the large fixture must complete in under 500 ms (tightened from
the very lenient 2 000 ms ceiling we shipped with).
Parsing off the main thread
The parser ran synchronously on the main thread inside the load()
handler. For a large .ab1 file the parser call itself is fast (well
under 100 ms on any modern machine) but the surrounding ArrayBuffer allocation,
typed-array construction, and GC pressure still briefly blocked the event loop and
prevented the loading spinner from painting.
I wrapped parseTrace in a dedicated Web Worker
(src/workers/parser.worker.ts). The load() and
loadSample() paths now post the raw ArrayBuffer to the
worker (transferred, not copied) and await the result. The four channel
Float32Array buffers come back as transferables too, so the handoff is
zero-copy in both directions. The main thread stays completely unblocked during the
parse: the spinner animates, interactions remain live, and the trace appears the
moment the worker posts its response.
The Vite ?worker URL qualifier handles bundling — Vite wraps the worker
entry in its own chunk with correct imports. The worker module is loaded lazily the
first time a file is opened so the main bundle stays lean.
Decoupling the position readout from event rate
The position readout (showing which sample range is visible) was updated synchronously
on every wheel and pointermove event. On a high-DPI display
those events can fire 120 times per second. Calling updatePositionReadout()
on every one of them is 120 DOM text-content writes per second — completely wasted
because the canvas itself was already batched behind requestAnimationFrame.
The fix is a one-liner pattern: a scheduleReadout() function that queues
the update behind requestAnimationFrame and short-circuits if a frame is
already pending. High-frequency event handlers now call scheduleReadout()
instead of refreshReadout(). The update still happens on the same frame
as the canvas redraw — you just never do it more than once per frame.
What's next
With parsing off-thread and rendering capped at display resolution, the viewer is now genuinely smooth on the large fixture. The final piece is true E2E coverage: Playwright tests that act like a real user — opening each fixture through the UI, asserting the canvas is non-blank via pixel sampling, then exercising zoom, pan, hover tooltips, file switching, and export across both desktop Chrome and emulated tablet. That lands in the next PR.