Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
f8a5dd2
docs(adr): record WebGL2 hybrid waveform rendering decision
claude Jun 16, 2026
2d84c2b
feat(charts): WebGL2 waveform geometry + transform helpers (unit-tested)
claude Jun 16, 2026
6229eef
feat(charts): WebGL2 waveform renderer core (context, programs, draw)
claude Jun 16, 2026
3317ba7
feat(charts): add chrome-only mode and canvas accessor to SignalRenderer
claude Jun 16, 2026
8df49b4
feat(charts): add pure hybrid-waveform planning logic and CSS colour …
claude Jun 16, 2026
51b6767
feat(charts): add HybridSignalRenderer WebGL2/Canvas2D compositor
claude Jun 16, 2026
3185c0f
feat(signal-viewer): integrate WebGL2 hybrid waveform renderer
claude Jun 16, 2026
5ae0d7f
test(e2e): WebGL/Canvas2D fidelity gate harness + pixel-diff/SSIM/spi…
claude Jun 16, 2026
3af5f09
refactor(charts): address QA punch-list + changelog for WebGL hybrid
claude Jun 16, 2026
91b124c
test(charts): cover HybridSignalRenderer WebGL orchestration with a m…
claude Jun 16, 2026
cff0cda
fix(hooks): invalidate in-flight wearable-summary request on unmount
claude Jun 16, 2026
18074a7
feat(charts): add dev/test-only preserveDrawingBuffer renderer option
claude Jun 16, 2026
bd7c0e8
test(e2e): fix WebGL fidelity-gate blank readback + scope project
claude Jun 16, 2026
f539708
fix(charts): preserve spike extreme in WebGL envelope at "all" zoom
claude Jun 16, 2026
19a0a34
test(e2e): make fidelity-gate extreme check reference-relative (AA-ro…
claude Jun 16, 2026
35326b7
test(e2e): slim WebGL fidelity gate for CI viability (fewer views, sm…
claude Jun 16, 2026
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
24 changes: 23 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,32 @@ jobs:
path: playwright-report/
retention-days: 14

test-e2e-fidelity:
name: E2E WebGL Fidelity Gate (ADR 0019)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
# Software WebGL2 (ANGLE + SwiftShader) is configured in the
# `chromium-fidelity` Playwright project. RUN_FIDELITY=1 enables the gate;
# a missing WebGL2 context fails the gate LOUDLY (it never silently skips).
- run: RUN_FIDELITY=1 npx playwright test --project=chromium-fidelity
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report-fidelity
path: playwright-report/
retention-days: 14

build:
name: Build
runs-on: ubuntu-latest
needs: [audit, lint, test-unit, test-e2e]
needs: [audit, lint, test-unit, test-e2e, test-e2e-fidelity]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ and this project uses [Calendar Versioning](https://calver.org/) with the format

### Changed

- **Signal Viewer waveforms are now GPU-rendered for much smoother panning and zooming.** Scrolling and zooming a whole-night recording were limited not by computation but by the browser having to re-upload the entire waveform canvas to the GPU on every frame — so on long sessions the chart could only repaint a few times per second during a drag, regardless of how little had actually changed. The dense CPAP waveform lanes (flow, pressure, leak, …) now render through **WebGL2**: their geometry lives on the GPU, so panning and zooming become a lightweight transform rather than a full re-upload, and the frame rate during interaction is dramatically higher. The rest of the chart — axis labels, grid, event markers, the hypnogram, sparse/step lanes, and the crosshair — continues to render on Canvas2D, composited beneath the same crosshair overlay as before. **The displayed waveform is intended to be visually identical**, including the more-faithful zoomed-out min/max envelope and the exact zoomed-in per-sample line; this is enforced by an automated fidelity test that renders the same data through both paths and compares them pixel-for-pixel (with spike-survival and gap-break checks) at full device-pixel resolution. If a browser does not support WebGL2, or the GPU drops the rendering context, the viewer **automatically falls back** to the original Canvas2D renderer with no loss of function. Rendering remains entirely client-side; nothing leaves the browser. (See ADR 0019.)
- **Signal Viewer y-axes now use clinically sensible default ranges that expand to fit, never clip.** Each waveform lane previously scaled its vertical axis to the EDF file's declared physical range (`physicalMin`/`physicalMax`). Those are decode calibration anchors — the physical values that map to the digital encoding's extremes — not display bounds, so they were the wrong thing to scale to. Each lane now starts from a clinical default display range and **expands only outward** to cover whatever the session's data actually needs, but never shrinks below the default. This keeps axes stable and directly comparable from night to night while guaranteeing no data is drawn off-lane. Per-signal special cases: Flow stays symmetric about zero; SpO₂ pins its top at 100% and expands only downward to reveal desaturations; the flow-limitation index is fixed at 0–1. Extreme or corrupt samples are clamped to per-signal plausibility ceilings so one bad reading cannot blow out the axis. Wearable lanes (heart rate, SpO₂, HRV, snoring) get the same expand-only-against-a-sensible-floor treatment. Clinical default ranges now in use: Flow ±60 L/min; Pressure / EPAP / EPR 0–25 and IPAP 0–30 cmH₂O; Leak 0–60 L/min; respiratory rate 0–30 br/min; tidal volume 0–1000 mL; minute ventilation 0–20 L/min; SpO₂ 85–100%; pulse 40–120 bpm; snore 0–1; flow limitation 0–1 (fixed). Channels with no clinical entry (unknown or future-machine signals) keep the previous declared-range behaviour. The EDF decode path is unchanged and the plotted waveform itself is unchanged — only the vertical extent of each lane changes.
- **Leak rate displays consistently as a whole number.** The Session Detail Leak Rate card (median, 95th percentile, and max) now uses the same integer-L/min display precision as the dashboard, closing a small inconsistency where the session view showed one decimal. Stored values are unchanged; this is a presentation-only fix.
- **All in-app help raised to the same scholarly bar as the breathing-pattern articles.** Every help article and glossary entry that makes a clinical or statistical claim now carries primary-source citations (with DOIs) and a formal "References" section, and a fact-check against the literature corrected a number of overstatements. Help articles `clinical-reference`, `statistical-analysis`, `event-analysis`, `pressure-analysis`, and `cross-source-analysis` gained References sections (AASM Manual 2012, Epstein 2009, Kapur 2017, Weaver 2007, CMS LCD L33718, SERVE-HF/Cowie 2015, Kaplan-Meier 1958, Cleveland 1979, Killick 2012, Bland-Altman 1986/1999, Schober 2018, Fisher 1915, and others); the Granger article now cites Akaike (1974) for the AIC it relies on. The glossary gained an optional `references` field, rendered at the "Detailed" depth, populated for 49 clinical and statistical terms. Corrections of substance: the leak threshold (24 L/min) is now labelled a ResMed device convention rather than an AASM standard; CPAP-use dose-response figures are attributed to Weaver (2007) functional outcomes rather than a hard "cardiovascular" cutoff; the `statistical-analysis` article no longer claims a Mann-Kendall test the app does not run (it describes the OLS + LOESS it actually computes) and no longer claims slope confidence intervals or variance/trend change-point detection it does not produce; the `event-analysis` article no longer advertises a retired Kaplan-Meier survival-curve view or a non-existent hazard-rate plot; the ODI entry no longer claims a configurable 4% threshold the code does not implement; correlation-strength bands are presented as CPAP Analyzer's arbitrary rule-of-thumb (Schober 2018), not a "standard" or "Cohen" scheme; and the unsupported "device AHI differs from PSG by 10–30%" figure was replaced with a cited, accurate statement (Kapur 2017). The `central-ai` metric tooltip now carries the SERVE-HF / LVEF ≤ 45% ASV caveat.
Expand Down
240 changes: 240 additions & 0 deletions docs/decisions/0019-webgl2-hybrid-waveform-rendering.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
# 0019 — WebGL2 Hybrid Rendering for Dense Signal-Viewer Waveform Lanes

## Status

Accepted

## Context

The Signal Viewer (`src/views/Sessions/SignalViewer.tsx`, drawing via
`src/components/charts/canvas/SignalRenderer.ts`) renders stacked CPAP waveform
lanes as Canvas2D. Pan and continuous wheel-zoom over whole-night data feel
sluggish, and prior optimization work did not fix it.

A real-browser (Microsoft Edge) performance trace of a single **1,213 ms
drag-pan** localized the bottleneck precisely:

- **Renderer main thread busy: 74 ms.** The main thread is essentially idle.
- **GPU process main thread busy: 1,127 ms (~93% of the interaction).**
- **~9 fps**, **31 dropped frames**, **~266–333 MB GPU memory** during the pan.

So pan/zoom is **GPU-bound, not main-thread bound.** The root cause is how
Canvas2D reaches the screen: the signal canvas is sized to the **full stacked-lane
content height at `devicePixelRatio` 2**, and a 2D canvas **re-uploads its entire
backing texture to the GPU on every change**. Each pan frame we redraw the whole
canvas — and, since the crosshair was split onto its own full-size overlay canvas
in a prior PR, we now re-upload **two** full-size DPR-2 textures per frame. The
limiter is **GPU texture-upload bandwidth**, not drawing time.

This explains why earlier shipped work moved the needle on the wrong metric.
rAF-coalescing, the zero-allocation LTTB scratch-buffer reuse, the min/max
envelope, and the crosshair-overlay split all reduced **main-thread** cost — which
the trace shows was never the limiter (74 ms). Pan/zoom feel was unchanged because
none of them reduce per-frame texture upload. Two follow-on caveats define the
problem space:

- A pure transform-pan (CSS-translating an already-rasterized canvas during a
drag) can avoid re-upload **for panning**, but a CSS translate cannot fake a
**rescale** — so **continuous/live wheel-zoom** still re-renders and re-uploads
every step. Zoom is the case the cheap fix cannot fully solve.
- The extrema-preservation contract (the decimation pyramid in
`src/components/charts/canvas/decimationPyramid.ts` preserves min/max at every
level so a 1-sample spike or notch is never hidden), the gap/break semantics,
and the exact DPR-2 look (a ~1.2 px anti-aliased, round-joined line; thin
envelope ribbons) are **correctness requirements**, not aesthetics. Any new
renderer must reproduce them.

This is a rendering-architecture decision for the project's most data-dense
surface, building on [0006](0006-recharts-d3-visualization.md) (Recharts/D3 for
standard charts; the Signal Viewer is the bespoke Canvas exception) and
[0008](0008-web-workers-heavy-computation.md) (pyramid/envelope geometry is
prepared off the main thread).

## Decision Drivers

Resolved against the project priority order (Privacy > Correctness > Performance >
UX > Features):

- **Privacy.** WebGL executes entirely in the local browser/GPU; no data egress.
Trivially satisfied, same as Canvas2D.
- **Correctness / fidelity (dominant).** This is health data. A new renderer must
match the current look at **DPR 2**, preserve the **extrema-preservation
contract** and **gap/break semantics**, and match theme colors exactly
(sRGB-correct). Fidelity outranks performance — so the fast path may not ship as
default until it is objectively proven to match the reference.
- **Performance.** Target **60 fps for pan _and_ continuous zoom** on whole-night,
full-resolution data; the win must come from **eliminating per-frame texture
re-upload**, not from rendering fewer pixels.
- **UX.** Crisp, responsive interaction with no visible regression in line quality,
crosshair behavior, or accessibility overlays.
- **Minimal dependencies.** Prefer raw platform APIs over a new rendering library.

**Hard constraint:** keep `devicePixelRatio` 2 (full crispness). Lowering DPR was
ruled out by the product owner.

## Considered Options

These options were synthesized from two specialist design reports.

### A. Canvas2D structural fixes (transform-pan + viewport-sized canvas)

Keep Canvas2D but fix how it reaches the GPU: **transform-pan with overscan**
(CSS-translate the already-rasterized canvas during a drag, re-render only on
settle), a **viewport-sized canvas** (not full-content height), and **skip the
crosshair-overlay re-upload during drags**.

- **Pro.** Lowest risk; byte-exact fidelity (it _is_ the current renderer); zero
new dependencies; fast to ship; directly removes the per-frame upload for panning.
- **Con.** A CSS translate cannot fake a rescale, so **continuous/live wheel-zoom
still re-renders and re-uploads per step** — the single case it structurally
cannot solve. Leaves zoom GPU-bound.

### B. WebGL2 hybrid renderer (chosen)

Render the **dense waveform lanes** (the zoomed-out min/max envelope and the
zoomed-in per-sample line) in **WebGL2**, with the waveform geometry living in
**GPU vertex buffers**. Pan **and** zoom become a change to a transform uniform
plus a scissor rectangle — **no per-frame re-upload**. **Everything else stays on
Canvas2D**: axes, grid, tick/time labels, event-marker rectangles, detection
washes, the hypnogram ribbon, sparse/step lanes, and the crosshair overlay.

- **Pro.** Solves pan **and** continuous zoom in one architecture; eliminates the
measured 1,127 ms re-upload; lower GPU memory; the min/max envelope maps
naturally to **triangle strips** (GPU-friendly) and the existing pyramid keeps
vertex counts tiny. The pyramid/envelope/worker geometry is renderer-agnostic
and is reused unchanged — the extrema contract lives **outside** the renderer.
- **Con / risk.** **Fidelity risk at DPR 2:** matching the 1.2 px anti-aliased,
round-joined line requires **instanced-quad line expansion with shader
feathering**; thin envelope bands need a **min-thickness clamp**; theme-color /
sRGB must match exactly. **Robustness tax:** WebGL **context-loss handling**, a
permanent **Canvas2D fallback**, and a mandatory **objective fidelity gate**
before it can become default. Text stays on Canvas2D (WebGL text rendering is the
known pain point and offers no benefit here).

### C. OffscreenCanvas in a Web Worker

Move Canvas2D drawing off the main thread into a worker via OffscreenCanvas.

- **Con.** It relocates **drawing**, but the trace shows drawing (74 ms) is not the
limiter — the limiter is GPU compositing/upload, which OffscreenCanvas does not
address. **Rejected.**

### D. Reduce devicePixelRatio

Render the canvas at DPR 1 (or adaptively during interaction) to cut texture size.

- **Con.** Trades fidelity for speed on a health-data view; sacrifices crispness.
**Rejected by the product owner** on fidelity grounds.

## Decision Outcome

Adopt **Option B: a WebGL2 hybrid renderer.**

- **WebGL2 renders only the dense waveform lanes** — the zoomed-out min/max
envelope (triangle strips) and the zoomed-in per-sample line (instanced quads
with shader feathering). Pan and zoom are a transform-uniform + scissor change
with **no per-frame texture re-upload**.
- **Canvas2D renders everything else and remains the permanent fallback** — axes,
grid, tick/time labels, event-marker rectangles, detection washes, the
hypnogram ribbon, sparse/step lanes, and the crosshair overlay. When WebGL2 is
unavailable or the context is lost, the Signal Viewer renders entirely on
Canvas2D with no loss of function.
- **The implementation is raw WebGL2 — no new rendering dependency** — per the
minimal-dependencies driver.
- **The pyramid, envelope, and worker geometry are reused unchanged.** The
extrema-preservation contract and gap semantics live outside the renderer, so
both rendering paths consume the same geometry and inherit the same guarantees.

The choice is **conditioned**, because correctness/fidelity outranks performance:

1. **Canvas2D fallback is retained permanently** (not a transitional shim), and is
selected **automatically at runtime** when WebGL2 is unavailable or the GPU
context is lost — this is graceful degradation, **not** a feature flag.
2. **No build/user feature flag.** This is a two-user FOSS app; gating machinery is
unwarranted ceremony. WebGL2 is the default waveform renderer; if it ever
regresses in production it is reverted at the PR level, not toggled.
3. **A mandatory objective fidelity gate must pass in CI before merge:**
pixel-diff against the Canvas2D reference within a defined tolerance, **SSIM**,
a **spike-survival** check (the extrema contract holds end-to-end through the
GPU path), and **gap-break** tests — all at DPR 2 — plus production verification
by the owner (the sandbox cannot render WebGL).

The product owner selected WebGL2 hybrid over the cheaper Option A specifically
because Option A leaves **continuous zoom GPU-bound**, while WebGL2 solves pan and
zoom in a single architecture. The conditions above are what make that defensible
under the priority order.

## Consequences

### Positive

- **Removes the measured bottleneck.** Eliminates the per-frame texture re-upload
that consumed the 1,127 ms GPU time; pan and zoom become uniform/scissor changes.
- **60 fps pan _and_ continuous zoom** ceiling on whole-night, full-resolution data
at DPR 2 — without lowering DPR.
- **Lower GPU memory** and headroom to scale to many stacked lanes.
- **Reuses existing geometry.** The decimation pyramid, min/max envelope, and
worker preparation are renderer-agnostic and reused unchanged; the
extrema-preservation contract is preserved by construction because it lives
outside the renderer.
- **No privacy cost.** WebGL is local; nothing leaves the browser.
- **No new rendering dependency.** Raw WebGL2 keeps the dependency surface minimal.

### Negative

- **A new rendering primitive on health data.** WebGL drawing of clinical waveforms
requires a `security` sign-off and broadens what can go subtly wrong visually.
- **Robustness tax.** WebGL **context-loss handling** plus a maintained **Canvas2D
fallback** is real, permanent complexity (two paths to keep in sync).
- **Fidelity-matching effort at DPR 2.** Reproducing the 1.2 px anti-aliased,
round-joined line (instanced quads + shader feathering), the thin-band
min-thickness clamp, and exact sRGB theme colors is non-trivial shader work.
- **A permanent pixel-diff fidelity test suite** (pixel-diff + SSIM +
spike-survival + gap-break) must be authored and maintained as a standing gate.
- **Sandbox/CI caveat.** WebGL output cannot be visually verified in the headless
sandbox; correctness is validated via CI (with a GPU-capable runner where
available) and in production, and guarded by the fidelity gate.

### Neutral

- **The renderer is a hybrid, by design.** Text and chrome stay on Canvas2D
because WebGL text rendering offers no benefit here and is the known pain point;
the two surfaces are composited deliberately.
- **Canvas2D is the permanent fallback, not a deprecated path.** It remains a
first-class, fully functional renderer that the automatic runtime fallback
(unsupported WebGL2 / context loss) relies on indefinitely.
- **No feature flag; the fidelity gate guards merge, not a toggle.** WebGL2 is the
default once the CI fidelity gate passes and the owner has confirmed in
production; there is no opt-in flag to maintain.
- **Option A's transform-pan idea is not foreclosed.** Should it ever be needed as
a further Canvas2D fallback optimization, it remains compatible with this
architecture; it was set aside because it cannot solve live zoom, not because it
is wrong.

## Confirmation

How adherence to this decision is verified:

- **Objective fidelity gate (blocking, before default-on).** Automated pixel-diff
of the WebGL output against the Canvas2D reference within tolerance, plus SSIM,
at DPR 2, across themes.
- **Spike-survival test.** Asserts a 1-sample spike/notch survives end-to-end
through the WebGL path (the extrema-preservation contract holds in the GPU
renderer, not only in the pyramid).
- **Gap-break test.** Asserts gap/break semantics render identically on both paths.
- **Context-loss / fallback test.** Forces WebGL context loss and asserts the
Signal Viewer falls back to Canvas2D with no loss of function.
- **`security` sign-off.** Required because this introduces a new rendering
primitive over imported health data.
- **`qa` gate.** No merge to `main` until the fidelity gate and the automatic
Canvas2D fallback are verified; QA can block.

## Related Decisions

- [0006 — Recharts + D3 for Visualization](0006-recharts-d3-visualization.md) — the
Signal Viewer is the bespoke Canvas/WebGL exception to the Recharts default.
- [0008 — Web Workers for Heavy Computation](0008-web-workers-heavy-computation.md) —
the pyramid/envelope geometry consumed by both rendering paths is prepared off
the main thread.
- [0015 — Zero Telemetry and Analytics](0015-zero-telemetry-analytics.md) — WebGL
is local; no new data egress.
Loading
Loading