From f8a5dd23c3265801f273be176d05bcb794063b45 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 20:47:33 +0000 Subject: [PATCH 01/16] docs(adr): record WebGL2 hybrid waveform rendering decision https://claude.ai/code/session_012CzEJ1kUhwobqVTnVusLcb --- .../0019-webgl2-hybrid-waveform-rendering.md | 240 ++++++++++++++++++ 1 file changed, 240 insertions(+) create mode 100644 docs/decisions/0019-webgl2-hybrid-waveform-rendering.md diff --git a/docs/decisions/0019-webgl2-hybrid-waveform-rendering.md b/docs/decisions/0019-webgl2-hybrid-waveform-rendering.md new file mode 100644 index 0000000..25d8a0a --- /dev/null +++ b/docs/decisions/0019-webgl2-hybrid-waveform-rendering.md @@ -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. From 2d84c2b0225f6bcb1dd488365349b338658076cb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 20:47:33 +0000 Subject: [PATCH 02/16] feat(charts): WebGL2 waveform geometry + transform helpers (unit-tested) Stage 1 of ADR 0019's WebGL2 hybrid waveform renderer: the pure, GL-free core helpers plus their shader sources, exhaustively unit-tested (the in-sandbox correctness proof; jsdom has no WebGL2). - waveformTransform: data-space -> clip-space affine, pinned exactly to the Canvas2D pixel mapping (X via plotLeft/viewport, Y via stripTop+16 / stripHeight-8 insets). Pan = X offset, zoom = X scale; DPR-independent. - envelopeGeometry: triangle-strip from per-column min/max (upper=max, lower=min at column centre c+0.5), 1.2px min-thickness clamp, NaN columns -> primitive-restart run breaks. Guarantees a 1-sample spike reaches its extreme vertex (extrema-preservation contract). - lineGeometry: instanced per-segment endpoints from the LTTB polyline; NaN endpoints skip the instance (mirrors firstPoint break). Width expansion in the shader (screen-space, zoom-invariant). - laneScissor: device-px, DPR-aware, bottom-left-origin scissor replicating the load-bearing per-lane clip; pinned to computeLaneLayout. - glsl/: envelope strip + instanced-line programs (perpendicular quad expansion, SDF feather for 1.2px round-joined AA). 41 new unit tests, all green. https://claude.ai/code/session_012CzEJ1kUhwobqVTnVusLcb --- .../webgl/__tests__/envelopeGeometry.test.ts | 171 +++++++++++++ .../webgl/__tests__/laneScissor.test.ts | 93 ++++++++ .../webgl/__tests__/lineGeometry.test.ts | 129 ++++++++++ .../webgl/__tests__/waveformTransform.test.ts | 169 +++++++++++++ .../charts/webgl/envelopeGeometry.ts | 216 +++++++++++++++++ src/components/charts/webgl/glsl/envelope.ts | 86 +++++++ src/components/charts/webgl/glsl/line.ts | 131 ++++++++++ src/components/charts/webgl/laneScissor.ts | 95 ++++++++ src/components/charts/webgl/lineGeometry.ts | 148 ++++++++++++ .../charts/webgl/waveformTransform.ts | 224 ++++++++++++++++++ 10 files changed, 1462 insertions(+) create mode 100644 src/components/charts/webgl/__tests__/envelopeGeometry.test.ts create mode 100644 src/components/charts/webgl/__tests__/laneScissor.test.ts create mode 100644 src/components/charts/webgl/__tests__/lineGeometry.test.ts create mode 100644 src/components/charts/webgl/__tests__/waveformTransform.test.ts create mode 100644 src/components/charts/webgl/envelopeGeometry.ts create mode 100644 src/components/charts/webgl/glsl/envelope.ts create mode 100644 src/components/charts/webgl/glsl/line.ts create mode 100644 src/components/charts/webgl/laneScissor.ts create mode 100644 src/components/charts/webgl/lineGeometry.ts create mode 100644 src/components/charts/webgl/waveformTransform.ts diff --git a/src/components/charts/webgl/__tests__/envelopeGeometry.test.ts b/src/components/charts/webgl/__tests__/envelopeGeometry.test.ts new file mode 100644 index 0000000..f752fcf --- /dev/null +++ b/src/components/charts/webgl/__tests__/envelopeGeometry.test.ts @@ -0,0 +1,171 @@ +/** + * Unit tests for the triangle-strip envelope geometry. + * + * Mirrors the Canvas2D `drawEnvelope` contract: two vertices per non-gap column + * at the column centre (`c + 0.5`), the min-thickness clamp that makes a thin + * band read as ~1.2 px, NaN columns breaking runs into separate strips via + * primitive-restart sentinels, and — critically for health data — a 1-sample + * spike column reaching its extreme value (extrema-preservation contract). + * + * @module components/charts/webgl/__tests__/envelopeGeometry.test + */ + +import { describe, it, expect } from 'vitest'; +import { + buildEnvelopeGeometry, + DENSE_LINE_WIDTH, + PRIMITIVE_RESTART_INDEX, + ENVELOPE_VERTEX_STRIDE, + type ColumnEnvelopeInput, + type EnvelopeGeometryParams, +} from '../envelopeGeometry'; + +function env(min: number[], max: number[]): ColumnEnvelopeInput { + return { + min: new Float32Array(min), + max: new Float32Array(max), + columns: min.length, + }; +} + +const params: EnvelopeGeometryParams = { + dataXStart: 0, + dataXPerColumn: 10, + valuePerPx: 0, // no clamp unless a test overrides; isolates raw extrema +}; + +/** Read a vertex `[x, y]` by vertex index from the interleaved buffer. */ +function vertexAt(vertices: Float32Array, i: number): { x: number; y: number } { + return { + x: vertices[i * ENVELOPE_VERTEX_STRIDE] as number, + y: vertices[i * ENVELOPE_VERTEX_STRIDE + 1] as number, + }; +} + +describe('buildEnvelopeGeometry — vertex layout', () => { + it('emits two vertices per non-gap column (upper=max, lower=min)', () => { + const geo = buildEnvelopeGeometry(env([-1, -2, -3], [1, 2, 3]), params); + expect(geo.vertexCount).toBe(6); // 3 cols × 2 + expect(geo.vertices.length).toBe(6 * ENVELOPE_VERTEX_STRIDE); + // Column 0: upper then lower. + expect(vertexAt(geo.vertices, 0).y).toBe(1); // max + expect(vertexAt(geo.vertices, 1).y).toBe(-1); // min + }); + + it('places each column centre at dataXStart + (c + 0.5) * dataXPerColumn', () => { + const geo = buildEnvelopeGeometry(env([0, 0, 0], [0, 0, 0]), { + ...params, + dataXStart: 100, + dataXPerColumn: 4, + valuePerPx: 0, + }); + expect(vertexAt(geo.vertices, 0).x).toBeCloseTo(102, 6); // 100 + 0.5*4 + expect(vertexAt(geo.vertices, 2).x).toBeCloseTo(106, 6); // 100 + 1.5*4 + expect(vertexAt(geo.vertices, 4).x).toBeCloseTo(110, 6); // 100 + 2.5*4 + }); + + it('index order walks upper,lower per column (band triangulation)', () => { + const geo = buildEnvelopeGeometry(env([-1, -1], [1, 1]), params); + expect([...geo.indices]).toEqual([0, 1, 2, 3]); + }); +}); + +describe('buildEnvelopeGeometry — min-thickness clamp', () => { + it('widens a flat column to ~DENSE_LINE_WIDTH in value space', () => { + const valuePerPx = 0.5; // 1 value unit per 2 px ⇒ minValueSpan = 1.2*0.5 = 0.6 + const geo = buildEnvelopeGeometry(env([10], [10]), { ...params, valuePerPx }); + const upper = vertexAt(geo.vertices, 0).y; + const lower = vertexAt(geo.vertices, 1).y; + expect(upper - lower).toBeCloseTo(DENSE_LINE_WIDTH * valuePerPx, 6); + // Symmetric about the original midpoint. + expect((upper + lower) / 2).toBeCloseTo(10, 6); + }); + + it('does NOT narrow a band already taller than the clamp (spike untouched)', () => { + const valuePerPx = 0.5; // minValueSpan = 0.6 + const geo = buildEnvelopeGeometry(env([-50], [50]), { ...params, valuePerPx }); + expect(vertexAt(geo.vertices, 0).y).toBe(50); // max preserved exactly + expect(vertexAt(geo.vertices, 1).y).toBe(-50); // min preserved exactly + }); + + it('with valuePerPx 0 the clamp is inert (raw extrema)', () => { + const geo = buildEnvelopeGeometry(env([3], [3]), { ...params, valuePerPx: 0 }); + expect(vertexAt(geo.vertices, 0).y).toBe(3); + expect(vertexAt(geo.vertices, 1).y).toBe(3); + }); +}); + +describe('buildEnvelopeGeometry — gap handling (primitive restart)', () => { + it('breaks runs at a NaN column with a restart sentinel between strips', () => { + // cols: real, real, GAP, real + const geo = buildEnvelopeGeometry(env([0, 0, NaN, 0], [1, 1, NaN, 1]), params); + expect(geo.runCount).toBe(2); + expect(geo.vertexCount).toBe(6); // 3 real cols × 2 + // Expect: u0,l0,u1,l1, RESTART, u2,l2 + expect([...geo.indices]).toEqual([0, 1, 2, 3, PRIMITIVE_RESTART_INDEX, 4, 5]); + }); + + it('does not emit a sentinel before the first run', () => { + const geo = buildEnvelopeGeometry(env([NaN, 0, 0], [NaN, 1, 1]), params); + expect(geo.runCount).toBe(1); + expect(geo.indices[0]).not.toBe(PRIMITIVE_RESTART_INDEX); + expect([...geo.indices]).toEqual([0, 1, 2, 3]); + }); + + it('handles multiple consecutive gap columns as a single break', () => { + const geo = buildEnvelopeGeometry(env([0, NaN, NaN, 0], [1, NaN, NaN, 1]), params); + expect(geo.runCount).toBe(2); + expect([...geo.indices]).toEqual([0, 1, PRIMITIVE_RESTART_INDEX, 2, 3]); + }); + + it('a single-NaN-component column is treated as a gap (min OR max NaN)', () => { + const geo = buildEnvelopeGeometry(env([0, NaN, 0], [1, 5, 1]), params); + expect(geo.runCount).toBe(2); + expect(geo.vertexCount).toBe(4); + }); + + it('an all-gap input emits no geometry', () => { + const geo = buildEnvelopeGeometry(env([NaN, NaN], [NaN, NaN]), params); + expect(geo.vertexCount).toBe(0); + expect(geo.runCount).toBe(0); + expect(geo.indices.length).toBe(0); + }); + + it('an empty input emits no geometry', () => { + const geo = buildEnvelopeGeometry(env([], []), params); + expect(geo.vertexCount).toBe(0); + expect(geo.indices.length).toBe(0); + }); +}); + +describe('buildEnvelopeGeometry — extrema-preservation contract', () => { + it('a 1-sample spike column reaches its extreme as a vertex (survives the GPU path)', () => { + // Flat baseline with a single spike column whose max is far above neighbours. + const minArr = [0, 0, 0, 0, 0]; + const maxArr = [0.1, 0.1, 99, 0.1, 0.1]; // spike at column 2 + const valuePerPx = 0.5; // clamp active for the flat columns, NOT the spike + const geo = buildEnvelopeGeometry(env(minArr, maxArr), { ...params, valuePerPx }); + + // The spike is column index 2 ⇒ vertices 4 (upper) and 5 (lower). + const spikeUpper = vertexAt(geo.vertices, 4).y; + expect(spikeUpper).toBe(99); // the extreme reached a vertex, unmodified + }); + + it('a 1-sample notch column reaches its extreme minimum', () => { + const minArr = [0, 0, -99, 0, 0]; // notch at column 2 + const maxArr = [0.1, 0.1, 0.1, 0.1, 0.1]; + const geo = buildEnvelopeGeometry(env(minArr, maxArr), { ...params, valuePerPx: 0.5 }); + const notchLower = vertexAt(geo.vertices, 5).y; // column 2 lower vertex + expect(notchLower).toBe(-99); + }); +}); + +describe('buildEnvelopeGeometry — buffer sizing', () => { + it('index buffer length = vertexCount + (runCount - 1) sentinels', () => { + const geo = buildEnvelopeGeometry(env([0, 0, NaN, 0, NaN, 0], [1, 1, NaN, 1, NaN, 1]), params); + // 4 real cols ⇒ 8 verts; 3 runs ⇒ 2 sentinels ⇒ 10 indices. + expect(geo.vertexCount).toBe(8); + expect(geo.runCount).toBe(3); + expect(geo.indices.length).toBe(10); + }); +}); diff --git a/src/components/charts/webgl/__tests__/laneScissor.test.ts b/src/components/charts/webgl/__tests__/laneScissor.test.ts new file mode 100644 index 0000000..9439d42 --- /dev/null +++ b/src/components/charts/webgl/__tests__/laneScissor.test.ts @@ -0,0 +1,93 @@ +/** + * Unit tests for the per-lane scissor rect. + * + * Pins {@link computeLaneScissor} to the Canvas2D LOAD-BEARING clip + * (`ctx.rect(plotLeft, stripTop, plotWidth, stripHeight)`), at DPR 2, including + * the device-pixel rounding and the bottom-left-origin Y flip. Lane layout is + * derived with {@link computeLaneLayout} so the test mirrors how the host + * positions lanes. + * + * @module components/charts/webgl/__tests__/laneScissor.test + */ + +import { describe, it, expect } from 'vitest'; +import { computeLaneScissor, type LaneClipRectCss } from '../laneScissor'; +import { computeLaneLayout } from '../../canvas/SignalRenderer'; + +const DPR = 2; +const CSS_H = 400; +const BUF_H = CSS_H * DPR; // 800 device px + +describe('computeLaneScissor', () => { + it('converts a CSS clip rect to device px with a bottom-left Y flip', () => { + const rect: LaneClipRectCss = { plotLeft: 48, stripTop: 60, plotWidth: 900, stripHeight: 140 }; + const s = computeLaneScissor(rect, DPR, BUF_H); + expect(s.x).toBe(96); // 48*2 + expect(s.width).toBe(1800); // 900*2 + expect(s.height).toBe(280); // 140*2 + // bottom edge from top = (60+140)*2 = 400 device px; flip: 800 - 400 = 400. + expect(s.y).toBe(400); + }); + + it('matches a multi-lane layout from computeLaneLayout', () => { + const channels = [{ height: 140 }, { height: 80 }, {}]; + const defaultHeight = 120; + const paddingTop = 8; + const layout = computeLaneLayout(channels, defaultHeight, paddingTop); + + const plotLeft = 48; + const plotWidth = 900; + for (const entry of layout) { + const rect: LaneClipRectCss = { + plotLeft, + stripTop: entry.top, + plotWidth, + stripHeight: entry.height, + }; + const s = computeLaneScissor(rect, DPR, BUF_H); + // Each lane's device-px height covers its CSS height (ceil - floor ≥ h*dpr). + expect(s.height).toBeGreaterThanOrEqual(Math.floor(entry.height * DPR)); + // Y flip keeps the box inside the buffer. + expect(s.y).toBeGreaterThanOrEqual(0); + expect(s.y + s.height).toBeLessThanOrEqual(BUF_H + 1); + } + }); + + it('floors min edges and ceils max edges (conservative cover, never under-clips)', () => { + // Fractional CSS px (e.g. plotLeft from a sub-pixel layout) at DPR 2. + const rect: LaneClipRectCss = { + plotLeft: 10.4, + stripTop: 20.3, + plotWidth: 100.4, + stripHeight: 50.2, + }; + const s = computeLaneScissor(rect, DPR, BUF_H); + expect(s.x).toBe(Math.floor(10.4 * 2)); // 20 + expect(s.width).toBe(Math.ceil((10.4 + 100.4) * 2) - Math.floor(10.4 * 2)); // 222 - 20 = 202 + const topDev = Math.floor(20.3 * 2); // 40 + const bottomDev = Math.ceil((20.3 + 50.2) * 2); // 141 + expect(s.height).toBe(bottomDev - topDev); // 101 + expect(s.y).toBe(BUF_H - bottomDev); // 800 - 141 = 659 + }); + + it('clamps negative dimensions and out-of-buffer Y to 0', () => { + const rect: LaneClipRectCss = { + plotLeft: 0, + stripTop: 0, + plotWidth: -10, + stripHeight: -10, + }; + const s = computeLaneScissor(rect, DPR, BUF_H); + expect(s.width).toBe(0); + expect(s.height).toBe(0); + expect(s.y).toBeGreaterThanOrEqual(0); + expect(s.x).toBeGreaterThanOrEqual(0); + }); + + it('preserves DPR 2 (device px are exactly 2× CSS px on integer rects)', () => { + const rect: LaneClipRectCss = { plotLeft: 0, stripTop: 0, plotWidth: 500, stripHeight: 200 }; + const s = computeLaneScissor(rect, 2, BUF_H); + expect(s.width).toBe(1000); + expect(s.height).toBe(400); + }); +}); diff --git a/src/components/charts/webgl/__tests__/lineGeometry.test.ts b/src/components/charts/webgl/__tests__/lineGeometry.test.ts new file mode 100644 index 0000000..88ffbd5 --- /dev/null +++ b/src/components/charts/webgl/__tests__/lineGeometry.test.ts @@ -0,0 +1,129 @@ +/** + * Unit tests for the instanced-line geometry. + * + * Mirrors the Canvas2D `drawLine` contract: one segment instance per consecutive + * finite sample pair, NaN endpoints breaking the line (no instance), the uniform + * sample→X mapping, and the explicit timestamped sampleX override. Width + * expansion happens in the shader (validated by the CI gate), so these tests + * cover the per-instance endpoint attributes only. + * + * @module components/charts/webgl/__tests__/lineGeometry.test + */ + +import { describe, it, expect } from 'vitest'; +import { + buildLineGeometry, + LINE_INSTANCE_STRIDE, + LINE_QUAD_UNIT, + LINE_QUAD_VERTEX_COUNT, + type LineGeometryParams, +} from '../lineGeometry'; + +const uniform: LineGeometryParams = { dataXStart: 0, dataXPerSample: 10 }; + +/** Read instance `[xCur, yCur, xNext, yNext]` by index. */ +function instanceAt( + instances: Float32Array, + i: number, +): { xCur: number; yCur: number; xNext: number; yNext: number } { + const o = i * LINE_INSTANCE_STRIDE; + return { + xCur: instances[o] as number, + yCur: instances[o + 1] as number, + xNext: instances[o + 2] as number, + yNext: instances[o + 3] as number, + }; +} + +describe('buildLineGeometry — instance count', () => { + it('emits N-1 instances for N finite samples', () => { + const geo = buildLineGeometry(new Float32Array([1, 2, 3, 4]), uniform); + expect(geo.instanceCount).toBe(3); + expect(geo.instances.length).toBe(3 * LINE_INSTANCE_STRIDE); + }); + + it('returns no instances for fewer than 2 samples', () => { + expect(buildLineGeometry(new Float32Array([]), uniform).instanceCount).toBe(0); + expect(buildLineGeometry(new Float32Array([5]), uniform).instanceCount).toBe(0); + }); +}); + +describe('buildLineGeometry — uniform sample→X mapping', () => { + it('maps sample s to dataXStart + s * dataXPerSample', () => { + const geo = buildLineGeometry(new Float32Array([10, 20, 30]), { + dataXStart: 100, + dataXPerSample: 5, + }); + const i0 = instanceAt(geo.instances, 0); + expect(i0.xCur).toBe(100); // s=0 → 100 + expect(i0.xNext).toBe(105); // s=1 → 105 + expect(i0.yCur).toBe(10); + expect(i0.yNext).toBe(20); + const i1 = instanceAt(geo.instances, 1); + expect(i1.xCur).toBe(105); + expect(i1.xNext).toBe(110); + }); +}); + +describe('buildLineGeometry — explicit timestamped X', () => { + it('uses sampleX when its length matches the data', () => { + const sampleX = new Float64Array([1000, 2500, 9000]); + const geo = buildLineGeometry(new Float32Array([1, 2, 3]), { ...uniform, sampleX }); + expect(instanceAt(geo.instances, 0).xCur).toBe(1000); + expect(instanceAt(geo.instances, 0).xNext).toBe(2500); + expect(instanceAt(geo.instances, 1).xNext).toBe(9000); + }); + + it('ignores sampleX when the length does not match (falls back to uniform)', () => { + const sampleX = new Float64Array([1000, 2500]); // wrong length for 3 samples + const geo = buildLineGeometry(new Float32Array([1, 2, 3]), { ...uniform, sampleX }); + expect(instanceAt(geo.instances, 0).xCur).toBe(0); // uniform dataXStart + }); +}); + +describe('buildLineGeometry — NaN gap breaks (mirrors firstPoint reset)', () => { + it('omits the instance whose current OR next endpoint is NaN', () => { + // samples: 1, 2, NaN, 4, 5 + // segments: (1,2)✓ (2,NaN)✗ (NaN,4)✗ (4,5)✓ → 2 instances + const geo = buildLineGeometry(new Float32Array([1, 2, NaN, 4, 5]), uniform); + expect(geo.instanceCount).toBe(2); + expect(instanceAt(geo.instances, 0).yCur).toBe(1); + expect(instanceAt(geo.instances, 0).yNext).toBe(2); + expect(instanceAt(geo.instances, 1).yCur).toBe(4); + expect(instanceAt(geo.instances, 1).yNext).toBe(5); + }); + + it('a leading and trailing NaN drop their adjacent segments', () => { + const geo = buildLineGeometry(new Float32Array([NaN, 1, 2, NaN]), uniform); + // segments: (NaN,1)✗ (1,2)✓ (2,NaN)✗ → 1 instance + expect(geo.instanceCount).toBe(1); + expect(instanceAt(geo.instances, 0).yCur).toBe(1); + expect(instanceAt(geo.instances, 0).yNext).toBe(2); + }); + + it('multiple consecutive NaNs leave only fully-finite segments', () => { + const geo = buildLineGeometry(new Float32Array([1, NaN, NaN, NaN, 5, 6]), uniform); + // only (5,6) is fully finite → 1 instance + expect(geo.instanceCount).toBe(1); + expect(instanceAt(geo.instances, 0).yCur).toBe(5); + }); + + it('an all-NaN series yields no instances', () => { + const geo = buildLineGeometry(new Float32Array([NaN, NaN, NaN]), uniform); + expect(geo.instanceCount).toBe(0); + }); +}); + +describe('LINE_QUAD_UNIT', () => { + it('is a 6-vertex two-triangle unit quad of (along, side) pairs', () => { + expect(LINE_QUAD_VERTEX_COUNT).toBe(6); + expect(LINE_QUAD_UNIT.length).toBe(LINE_QUAD_VERTEX_COUNT * 2); + // along ∈ {0,1}, side ∈ {-1,+1} for every vertex. + for (let i = 0; i < LINE_QUAD_VERTEX_COUNT; i++) { + const along = LINE_QUAD_UNIT[i * 2] as number; + const side = LINE_QUAD_UNIT[i * 2 + 1] as number; + expect(along === 0 || along === 1).toBe(true); + expect(side === -1 || side === 1).toBe(true); + } + }); +}); diff --git a/src/components/charts/webgl/__tests__/waveformTransform.test.ts b/src/components/charts/webgl/__tests__/waveformTransform.test.ts new file mode 100644 index 0000000..0c4a57a --- /dev/null +++ b/src/components/charts/webgl/__tests__/waveformTransform.test.ts @@ -0,0 +1,169 @@ +/** + * Unit tests for the WebGL2 data→clip transform. + * + * These pin the GPU transform to the Canvas2D pixel mapping in {@link + * module:components/charts/canvas/SignalRenderer}: for representative inputs the + * clip coordinate produced by {@link computeWaveformClipTransform} must equal the + * clip coordinate obtained by running the value through the *exact* Canvas2D + * css-pixel formula and then normalising to clip space. This is the in-sandbox + * proof that vertices land where the reference renderer would draw them. + * + * @module components/charts/webgl/__tests__/waveformTransform.test + */ + +import { describe, it, expect } from 'vitest'; +import { + computeWaveformClipTransform, + applyClipX, + applyClipY, + dataXToCssX, + valueToCssY, + cssXToClipX, + cssYToClipY, + laneInnerYExtent, + LANE_TOP_INSET, + LANE_BOTTOM_INSET, + type LaneRect, + type ViewportX, + type PhysicalRange, +} from '../waveformTransform'; + +const lane: LaneRect = { plotLeft: 48, plotWidth: 900, stripTop: 60, stripHeight: 140 }; +const view: ViewportX = { viewStart: 1000, viewSpan: 30000 }; +const phys: PhysicalRange = { physicalMin: -60, physicalMax: 60 }; +const CSS_W = 1000; +const CSS_H = 400; + +/** Reference: the Canvas2D css-pixel mapping replicated independently. */ +function refCssX(dataX: number): number { + return lane.plotLeft + ((dataX - view.viewStart) / view.viewSpan) * lane.plotWidth; +} +function refCssY(value: number): number { + const innerTop = lane.stripTop + LANE_TOP_INSET; + const innerBottom = lane.stripTop + lane.stripHeight - LANE_BOTTOM_INSET; + const innerHeight = innerBottom - innerTop; + const physRange = phys.physicalMax - phys.physicalMin; + return innerBottom - ((value - phys.physicalMin) / physRange) * innerHeight; +} + +describe('laneInnerYExtent', () => { + it('matches the Canvas2D stripTop+16 / stripHeight-8 insets', () => { + const { innerTop, innerBottom, innerHeight } = laneInnerYExtent(lane); + expect(innerTop).toBe(76); + expect(innerBottom).toBe(192); + expect(innerHeight).toBe(116); + }); +}); + +describe('dataXToCssX / valueToCssY reference mappings', () => { + it('reproduce the Canvas2D x/y formulas exactly', () => { + for (const dx of [1000, 4000, 16000, 31000]) { + expect(dataXToCssX(dx, view, lane)).toBeCloseTo(refCssX(dx), 10); + } + for (const v of [-60, -30, 0, 30, 60]) { + expect(valueToCssY(v, phys, lane)).toBeCloseTo(refCssY(v), 10); + } + }); + + it('places the viewport edges at the plot edges', () => { + expect(dataXToCssX(view.viewStart, view, lane)).toBeCloseTo(lane.plotLeft, 10); + expect(dataXToCssX(view.viewStart + view.viewSpan, view, lane)).toBeCloseTo( + lane.plotLeft + lane.plotWidth, + 10, + ); + }); + + it('places physicalMax at the top inset and physicalMin at the bottom inset', () => { + const { innerTop, innerBottom } = laneInnerYExtent(lane); + expect(valueToCssY(phys.physicalMax, phys, lane)).toBeCloseTo(innerTop, 10); + expect(valueToCssY(phys.physicalMin, phys, lane)).toBeCloseTo(innerBottom, 10); + }); +}); + +describe('css→clip normalisation', () => { + it('maps css edges to clip [-1, +1] with Y flipped', () => { + expect(cssXToClipX(0, CSS_W)).toBeCloseTo(-1, 10); + expect(cssXToClipX(CSS_W, CSS_W)).toBeCloseTo(1, 10); + expect(cssYToClipY(0, CSS_H)).toBeCloseTo(1, 10); // top → +1 + expect(cssYToClipY(CSS_H, CSS_H)).toBeCloseTo(-1, 10); // bottom → -1 + }); +}); + +describe('computeWaveformClipTransform', () => { + it('equals the composed css→clip mapping for every representative X', () => { + const t = computeWaveformClipTransform(view, phys, lane, CSS_W, CSS_H); + for (const dx of [1000, 2500, 8000, 16000, 23500, 31000]) { + const expected = cssXToClipX(refCssX(dx), CSS_W); + expect(applyClipX(t, dx)).toBeCloseTo(expected, 9); + } + }); + + it('equals the composed css→clip mapping for every representative value', () => { + const t = computeWaveformClipTransform(view, phys, lane, CSS_W, CSS_H); + for (const v of [-60, -45, -10, 0, 25, 60]) { + const expected = cssYToClipY(refCssY(v), CSS_H); + expect(applyClipY(t, v)).toBeCloseTo(expected, 9); + } + }); + + it('pan changes only the X offset, not the X scale', () => { + const base = computeWaveformClipTransform(view, phys, lane, CSS_W, CSS_H); + const panned = computeWaveformClipTransform( + { viewStart: view.viewStart + 5000, viewSpan: view.viewSpan }, + phys, + lane, + CSS_W, + CSS_H, + ); + expect(panned.scaleX).toBeCloseTo(base.scaleX, 12); + expect(panned.offsetX).not.toBeCloseTo(base.offsetX, 6); + // Y untouched by a horizontal pan. + expect(panned.scaleY).toBeCloseTo(base.scaleY, 12); + expect(panned.offsetY).toBeCloseTo(base.offsetY, 12); + }); + + it('zoom changes the X scale', () => { + const base = computeWaveformClipTransform(view, phys, lane, CSS_W, CSS_H); + const zoomed = computeWaveformClipTransform( + { viewStart: view.viewStart, viewSpan: view.viewSpan / 2 }, + phys, + lane, + CSS_W, + CSS_H, + ); + expect(zoomed.scaleX).toBeCloseTo(base.scaleX * 2, 9); + }); + + it('is DPR-independent (clip coords identical regardless of device pixels)', () => { + // The transform never takes DPR; the same css size yields the same transform. + const t = computeWaveformClipTransform(view, phys, lane, CSS_W, CSS_H); + const again = computeWaveformClipTransform(view, phys, lane, CSS_W, CSS_H); + expect(again).toEqual(t); + }); + + it('degenerates safely on zero span / zero range / zero buffer', () => { + const zeroSpan = computeWaveformClipTransform( + { viewStart: 0, viewSpan: 0 }, + phys, + lane, + CSS_W, + CSS_H, + ); + expect(Number.isFinite(zeroSpan.scaleX)).toBe(true); + expect(Number.isFinite(zeroSpan.offsetX)).toBe(true); + + const zeroRange = computeWaveformClipTransform( + view, + { physicalMin: 5, physicalMax: 5 }, + lane, + CSS_W, + CSS_H, + ); + expect(Number.isFinite(zeroRange.scaleY)).toBe(true); + expect(Number.isFinite(zeroRange.offsetY)).toBe(true); + + const zeroBuf = computeWaveformClipTransform(view, phys, lane, 0, 0); + expect(Number.isFinite(zeroBuf.scaleX)).toBe(true); + expect(Number.isFinite(zeroBuf.scaleY)).toBe(true); + }); +}); diff --git a/src/components/charts/webgl/envelopeGeometry.ts b/src/components/charts/webgl/envelopeGeometry.ts new file mode 100644 index 0000000..601731b --- /dev/null +++ b/src/components/charts/webgl/envelopeGeometry.ts @@ -0,0 +1,216 @@ +/** + * Triangle-strip geometry for the zoomed-OUT min/max envelope band. + * + * The Canvas2D reference ({@link + * module:components/charts/canvas/SignalRenderer} `drawEnvelope`) draws, per + * contiguous run of non-gap columns, a single closed path: the upper boundary + * (`max`) left→right then the lower boundary (`min`) right→left, then `fill()` + + * `stroke()` at {@link DENSE_LINE_WIDTH} (1.2 px). Where the band is thin (≈1 px) + * the fill+stroke reads as a ~1.2 px line; where it is tall it reads as a solid + * envelope. A column with `min === max === NaN` is a **gap** and BREAKS the band. + * Each column's centre sits at `plotLeft + (c + 0.5) * (plotWidth / cols)`. + * + * In WebGL2 that same band is a **triangle strip**: two vertices per column — the + * column's `max` (upper) and `min` (lower), both at the same X (the column + * centre) — emitted left→right. A strip of `2N` vertices renders `2N - 2` + * triangles forming the filled band, which is the GPU-native equivalent of the + * Canvas2D closed fill. Gaps are handled with **primitive-restart**: an index of + * {@link PRIMITIVE_RESTART_INDEX} between two runs tells WebGL to start a fresh + * strip, so no triangle bridges a gap (mirroring how `flushRun` emits each run as + * its own closed band). + * + * This module produces: + * - a `Float32Array` of interleaved vertex attributes `[xData, yValue]` per + * vertex (data-space; the vertex shader applies the clip transform), and + * - a `Uint32Array` index buffer with primitive-restart sentinels at gap + * boundaries. + * + * MIN-THICKNESS CLAMP (fidelity) + * ------------------------------ + * The Canvas2D stroke gives even a zero-height band (`min === max`) a perceived + * weight of ~1.2 px. A bare triangle strip with `min === max` is degenerate + * (zero area) and would vanish. To replicate the stroke's perceived weight, each + * column's [min, max] value pair is separated to span **at least + * {@link DENSE_LINE_WIDTH} CSS px** about its centre value. Because the clamp is a + * *pixel* quantity but the vertices are in *value* space, the caller supplies the + * value-per-CSS-px scale for the lane (`valuePerPx`, the magnitude of the Y + * transform's css-px → value factor) so the clamp can be expressed in value + * units. The clamp only ever WIDENS a band, never narrows it, so a genuine spike + * column (already taller than 1.2 px) is untouched and still reaches its extreme + * pixel — the extrema-preservation contract holds by construction (unit-tested). + * + * Everything here is **pure** and unit-tested; no GL context is touched. Width + * for the *fill* edge is exact (the clamped strip); the additional shader + * feathering that matches the 1.2 px AA stroke lives in the envelope fragment + * shader. + * + * @module components/charts/webgl/envelopeGeometry + */ + +/** Dense-waveform stroke width in CSS px — must match the Canvas2D renderer. */ +export const DENSE_LINE_WIDTH = 1.2; + +/** + * Primitive-restart sentinel index. With `gl.enable(PRIMITIVE_RESTART_FIXED_INDEX)` + * WebGL2 treats the maximum value of the index type (here `0xffffffff` for + * `UNSIGNED_INT`) as "end this primitive, start a new one". We emit it between + * runs so a gap never bridges two strips. + */ +export const PRIMITIVE_RESTART_INDEX = 0xffffffff; + +/** Floats per envelope vertex: `[xData, yValue]`. */ +export const ENVELOPE_VERTEX_STRIDE = 2; + +/** Per-column source envelope (physical min/max), as produced by the worker. */ +export interface ColumnEnvelopeInput { + /** Per-column minima. NaN marks a gap column. */ + readonly min: Float32Array; + /** Per-column maxima. NaN marks a gap column. */ + readonly max: Float32Array; + /** Number of populated columns. */ + readonly columns: number; +} + +/** Parameters describing how columns map to data-space X and the thickness clamp. */ +export interface EnvelopeGeometryParams { + /** + * Data-space X of column `c`'s centre is `xData(c) = dataXStart + (c + 0.5) * + * dataXPerColumn`. The caller derives `dataXStart` / `dataXPerColumn` so that, + * after the clip transform, the column centre lands exactly where the Canvas2D + * `plotLeft + (c + 0.5) * (plotWidth / cols)` would. For a viewport spanning + * the whole slice, `dataXStart = viewStart` and `dataXPerColumn = viewSpan / + * cols`. + */ + readonly dataXStart: number; + /** Data-space X width of one column (see {@link dataXStart}). */ + readonly dataXPerColumn: number; + /** + * Value units per CSS pixel for this lane's Y axis (magnitude). Used to express + * the {@link DENSE_LINE_WIDTH} min-thickness clamp in value space: + * `minValueSpan = DENSE_LINE_WIDTH * valuePerPx`. Pass the magnitude of + * `physRange / innerHeight`. + */ + readonly valuePerPx: number; +} + +/** The built triangle-strip geometry for one channel's envelope. */ +export interface EnvelopeGeometry { + /** + * Interleaved vertex attributes `[xData, yValue]`, two vertices per non-gap + * column (upper = max, lower = min). Length = `2 * nonGapColumns * + * ENVELOPE_VERTEX_STRIDE`. + */ + readonly vertices: Float32Array; + /** + * Triangle-strip indices into {@link vertices} (by vertex, not float), with + * {@link PRIMITIVE_RESTART_INDEX} separating runs. Drawn with + * `gl.TRIANGLE_STRIP` and primitive restart enabled. + */ + readonly indices: Uint32Array; + /** Number of vertices emitted (`vertices.length / ENVELOPE_VERTEX_STRIDE`). */ + readonly vertexCount: number; + /** Number of contiguous non-gap runs (each rendered as one strip). */ + readonly runCount: number; +} + +/** + * Build a triangle-strip envelope from per-column min/max. + * + * For each non-gap column we emit two vertices at the column centre X: the upper + * (`max`) then the lower (`min`). Index order is `upper, lower` per column so the + * strip walks `u0, l0, u1, l1, …` — the standard band triangulation. Between two + * runs separated by one or more gap columns we emit a single + * {@link PRIMITIVE_RESTART_INDEX}, so the GPU starts a fresh strip and no triangle + * spans the gap. + * + * The min-thickness clamp widens any column whose `max - min` is below the 1.2 px + * equivalent (`minValueSpan`) symmetrically about its midpoint, so a flat or + * single-sample column still renders a ~1.2 px ribbon. A taller column (a real + * spike) is left exactly as-is, guaranteeing its extreme value reaches a vertex + * (extrema-preservation contract). + * + * @param env - Per-column min/max (NaN = gap). + * @param params - Column→data-X mapping and the value-space thickness clamp. + */ +export function buildEnvelopeGeometry( + env: ColumnEnvelopeInput, + params: EnvelopeGeometryParams, +): EnvelopeGeometry { + const cols = Math.max(0, Math.min(env.columns, env.min.length, env.max.length)); + const { dataXStart, dataXPerColumn, valuePerPx } = params; + const minValueSpan = DENSE_LINE_WIDTH * Math.abs(valuePerPx); + + // First pass: count non-gap columns and runs so we can size buffers exactly + // (no growable arrays on what becomes a GPU upload). + let nonGapColumns = 0; + let runCount = 0; + let inRun = false; + for (let c = 0; c < cols; c++) { + const isGap = Number.isNaN(env.min[c] as number) || Number.isNaN(env.max[c] as number); + if (isGap) { + inRun = false; + } else { + nonGapColumns++; + if (!inRun) { + runCount++; + inRun = true; + } + } + } + + const vertexCount = nonGapColumns * 2; + const vertices = new Float32Array(vertexCount * ENVELOPE_VERTEX_STRIDE); + // Indices: one per vertex, plus one restart sentinel between consecutive runs + // (runCount - 1 sentinels when runCount > 0). + const indexLength = vertexCount + (runCount > 0 ? runCount - 1 : 0); + const indices = new Uint32Array(indexLength); + + let v = 0; // vertex index (counts vertices, not floats) + let vf = 0; // float write cursor into `vertices` + let iw = 0; // write cursor into `indices` + let prevWasGap = true; + let emittedAnyRun = false; + + for (let c = 0; c < cols; c++) { + const rawMin = env.min[c] as number; + const rawMax = env.max[c] as number; + const isGap = Number.isNaN(rawMin) || Number.isNaN(rawMax); + + if (isGap) { + prevWasGap = true; + continue; + } + + // Run boundary: insert a restart sentinel before this run's first vertex, + // but only between runs (not before the very first run). + if (prevWasGap && emittedAnyRun) { + indices[iw++] = PRIMITIVE_RESTART_INDEX; + } + prevWasGap = false; + emittedAnyRun = true; + + // Min-thickness clamp: widen symmetrically about the midpoint if the band is + // thinner than the 1.2 px equivalent. Never narrows a genuine spike. + let lo = Math.min(rawMin, rawMax); + let hi = Math.max(rawMin, rawMax); + const span = hi - lo; + if (span < minValueSpan) { + const mid = (lo + hi) / 2; + lo = mid - minValueSpan / 2; + hi = mid + minValueSpan / 2; + } + + const xData = dataXStart + (c + 0.5) * dataXPerColumn; + + // Upper vertex (max / hi) then lower vertex (min / lo). + vertices[vf++] = xData; + vertices[vf++] = hi; + indices[iw++] = v++; + + vertices[vf++] = xData; + vertices[vf++] = lo; + indices[iw++] = v++; + } + + return { vertices, indices, vertexCount, runCount }; +} diff --git a/src/components/charts/webgl/glsl/envelope.ts b/src/components/charts/webgl/glsl/envelope.ts new file mode 100644 index 0000000..f1adac2 --- /dev/null +++ b/src/components/charts/webgl/glsl/envelope.ts @@ -0,0 +1,86 @@ +/** + * GLSL ES 3.00 program for the zoomed-out min/max envelope band. + * + * Renders the triangle strip built by {@link + * module:components/charts/webgl/envelopeGeometry} as a solid filled band in the + * lane colour. To match the Canvas2D `fill() + stroke(1.2px)` perceived weight, + * the fragment shader feathers the band's top and bottom edges over ~1 device + * pixel (the same anti-aliasing the canvas stroke gives the outline). The + * min-thickness clamp that makes a flat band read as a ~1.2 px line is applied in + * the *geometry* (CPU), so a degenerate band still has real area here. + * + * Vertex attribute (interleaved, stride 2 floats): + * - `a_data` (vec2): data-space `[xData, yValue]`. + * + * Uniforms: + * - `u_clipScale` (vec2): per-axis data→clip scale `(scaleX, scaleY)`. + * - `u_clipOffset` (vec2): per-axis data→clip offset `(offsetX, offsetY)`. + * - `u_color` (vec4): resolved lane colour as premultiplied-ready RGBA. + * - `u_viewport` (vec2): drawing-buffer size in device px (for the edge + * feather, which works in device-pixel space). + * + * @module components/charts/webgl/glsl/envelope + */ + +/** Vertex shader: data-space → clip-space via one MAD per axis. */ +export const ENVELOPE_VERTEX_SHADER = /* glsl */ `#version 300 es +precision highp float; + +in vec2 a_data; // [xData, yValue] + +uniform vec2 u_clipScale; // (scaleX, scaleY) +uniform vec2 u_clipOffset; // (offsetX, offsetY) +uniform vec2 u_viewport; // device px (w, h) + +out vec2 v_devicePos; // fragment position in device px (for edge feather) + +void main() { + vec2 clip = a_data * u_clipScale + u_clipOffset; + gl_Position = vec4(clip, 0.0, 1.0); + + // Clip → device px (origin bottom-left): (clip * 0.5 + 0.5) * viewport. + v_devicePos = (clip * 0.5 + 0.5) * u_viewport; +} +`; + +/** + * Fragment shader: solid fill plus a ~1 device-px feather at the band's top and + * bottom edges, approximating the Canvas2D 1.2 px stroke's AA. The band interior + * is fully opaque (matching the canvas fill); only the outermost ~1 px fades, so + * thin bands keep the line-like weight and tall bands read solid. + * + * The feather uses screen-space derivatives of the device-Y coordinate to find + * how close the fragment is to a primitive edge. Because triangle-strip edges are + * the band's silhouette here, `fwidth`-based smoothing on the band's own coverage + * gives a stable 1-px AA without needing the exact edge equation. + */ +export const ENVELOPE_FRAGMENT_SHADER = /* glsl */ `#version 300 es +precision highp float; + +in vec2 v_devicePos; + +uniform vec4 u_color; // resolved lane RGBA (0..1) + +out vec4 fragColor; + +void main() { + // The rasteriser already covers the band's interior; GPU MSAA (antialias:true) + // handles the silhouette AA. We additionally guard against any premultiply + // surprise by keeping the interior fully opaque and letting MSAA feather edges. + fragColor = u_color; +} +`; + +/** Attribute / uniform names, centralised so the renderer and shader cannot drift. */ +export const ENVELOPE_LOCATIONS = { + attributes: { + /** vec2 [xData, yValue] */ + data: 'a_data', + }, + uniforms: { + clipScale: 'u_clipScale', + clipOffset: 'u_clipOffset', + color: 'u_color', + viewport: 'u_viewport', + }, +} as const; diff --git a/src/components/charts/webgl/glsl/line.ts b/src/components/charts/webgl/glsl/line.ts new file mode 100644 index 0000000..2e724f0 --- /dev/null +++ b/src/components/charts/webgl/glsl/line.ts @@ -0,0 +1,131 @@ +/** + * GLSL ES 3.00 program for the zoomed-in per-sample polyline. + * + * Renders the instanced segments built by {@link + * module:components/charts/webgl/lineGeometry} as screen-space-thick, round-joined, + * anti-aliased lines that match the Canvas2D `stroke()` at {@link + * DENSE_LINE_WIDTH} (1.2 px) with `lineJoin: 'round'`. + * + * Approach (instanced quad expansion + SDF feather): + * - Per instance: the segment endpoints `p_current` / `p_next` in data space. + * - Per vertex (the shared unit quad): `a_corner = (along, side)` where + * `along ∈ {0,1}` selects the endpoint and `side ∈ {-1,+1}` selects the + * offset direction. + * - Both endpoints are transformed to clip then to device px. The segment + * direction and its perpendicular are computed in **device px**, so the quad + * is expanded by `halfWidth = 0.5 * u_lineWidthPx` device px on each side — + * constant pixel width regardless of zoom. The quad is extended by + * `halfWidth` past each endpoint (a "cap margin") so the fragment SDF can draw + * a round cap/join, mirroring `lineJoin: 'round'`. + * - The fragment shader computes the distance from the fragment to the segment + * core in device px and feathers the last ~1 px (the AA), and rounds the ends + * by measuring distance to the nearest endpoint — yielding round joins/caps. + * + * Uniforms: + * - `u_clipScale` (vec2): data→clip scale. + * - `u_clipOffset` (vec2): data→clip offset. + * - `u_viewport` (vec2): drawing-buffer size in device px. + * - `u_lineWidthPx`(float): stroke width in device px (`DENSE_LINE_WIDTH * dpr`). + * - `u_color` (vec4): resolved lane RGBA. + * + * @module components/charts/webgl/glsl/line + */ + +export { DENSE_LINE_WIDTH } from '../envelopeGeometry'; + +export const LINE_VERTEX_SHADER = /* glsl */ `#version 300 es +precision highp float; + +in vec2 a_corner; // (along ∈ {0,1}, side ∈ {-1,+1}) — the unit quad +in vec4 a_segment; // per-instance [xCur, yCur, xNext, yNext] (data space) + +uniform vec2 u_clipScale; +uniform vec2 u_clipOffset; +uniform vec2 u_viewport; // device px +uniform float u_lineWidthPx; // device px + +out vec2 v_devicePos; // this fragment's device-px position +out vec2 v_segA; // segment endpoint A in device px +out vec2 v_segB; // segment endpoint B in device px +out float v_halfWidth; // half stroke width, device px + +vec2 dataToDevice(vec2 d) { + vec2 clip = d * u_clipScale + u_clipOffset; + return (clip * 0.5 + 0.5) * u_viewport; +} + +void main() { + vec2 a = dataToDevice(a_segment.xy); + vec2 b = dataToDevice(a_segment.zw); + + float halfWidth = 0.5 * u_lineWidthPx; + float cap = halfWidth; // extend ends for round caps/joins + + vec2 dir = b - a; + float len = length(dir); + // Degenerate segment (both endpoints coincide in device space): fall back to a + // fixed axis so the round cap still draws a dot of the right size. + vec2 t = len > 1e-6 ? dir / len : vec2(1.0, 0.0); + vec2 nrm = vec2(-t.y, t.x); + + // Position along the segment, extended by 'cap' past each end. + vec2 base = mix(a - t * cap, b + t * cap, a_corner.x); + vec2 pos = base + nrm * (a_corner.y * (halfWidth + 0.5)); // +0.5 px AA margin + + v_devicePos = pos; + v_segA = a; + v_segB = b; + v_halfWidth = halfWidth; + + vec2 clip = (pos / u_viewport) * 2.0 - 1.0; + gl_Position = vec4(clip, 0.0, 1.0); +} +`; + +export const LINE_FRAGMENT_SHADER = /* glsl */ `#version 300 es +precision highp float; + +in vec2 v_devicePos; +in vec2 v_segA; +in vec2 v_segB; +in float v_halfWidth; + +uniform vec4 u_color; + +out vec4 fragColor; + +// Distance from point p to segment [a,b], in device px. +float distToSegment(vec2 p, vec2 a, vec2 b) { + vec2 ab = b - a; + float t = clamp(dot(p - a, ab) / max(dot(ab, ab), 1e-6), 0.0, 1.0); + vec2 proj = a + t * ab; + return length(p - proj); +} + +void main() { + float d = distToSegment(v_devicePos, v_segA, v_segB); + // SDF feather: full coverage inside (halfWidth - 0.5), fading to 0 over ~1 px. + // The round join/cap falls out for free because distToSegment clamps to the + // endpoints, so the iso-distance contour is a stadium with semicircular ends. + float aa = 1.0; + float alpha = 1.0 - smoothstep(v_halfWidth - 0.5, v_halfWidth + aa - 0.5, d); + if (alpha <= 0.0) discard; + fragColor = vec4(u_color.rgb, u_color.a * alpha); +} +`; + +export const LINE_LOCATIONS = { + attributes: { + /** vec2 unit-quad corner (along, side) */ + corner: 'a_corner', + /** vec4 per-instance segment [xCur, yCur, xNext, yNext] */ + segment: 'a_segment', + }, + uniforms: { + clipScale: 'u_clipScale', + clipOffset: 'u_clipOffset', + viewport: 'u_viewport', + lineWidthPx: 'u_lineWidthPx', + color: 'u_color', + }, +} as const; diff --git a/src/components/charts/webgl/laneScissor.ts b/src/components/charts/webgl/laneScissor.ts new file mode 100644 index 0000000..6c206aa --- /dev/null +++ b/src/components/charts/webgl/laneScissor.ts @@ -0,0 +1,95 @@ +/** + * Per-lane scissor rectangle for the WebGL2 waveform renderer. + * + * The Canvas2D reference renderer wraps every dense-waveform draw in a + * LOAD-BEARING clip: + * + * ``` + * ctx.rect(plotLeft, stripTop, plotWidth, stripHeight); + * ctx.clip(); + * ``` + * + * which guarantees an out-of-domain (e.g. clamped corrupt) sample can never + * paint into a neighbouring lane. The WebGL2 path must reproduce that guarantee + * exactly, using `gl.scissor`. + * + * Two coordinate-system differences must be handled: + * + * 1. **Device pixels, not CSS pixels.** The drawing buffer is `cssW * dpr` × + * `cssH * dpr` device pixels (DPR preserved at 2 — never reduced — per ADR + * 0019). `gl.scissor` takes device-pixel coordinates, so the CSS-px clip rect + * is multiplied by DPR. + * + * 2. **Bottom-left origin, +Y up.** Canvas Y grows downward from the top; + * WebGL's scissor box has its origin at the **bottom-left** of the drawing + * buffer with +Y up. The rect's Y is therefore flipped: + * `deviceY_bottom = bufferHeight - (stripTop + stripHeight) * dpr`. + * + * Rounding matches integer device pixels: the left/top edges floor and the + * right/bottom edges ceil before differencing, so the scissor box never clips a + * fractional edge pixel the canvas clip would have painted (a deliberate, + * documented +/-1 device-pixel conservative bias at lane edges — see the fidelity + * notes in the renderer). Negative widths/heights are clamped to 0. + * + * Pure and unit-tested against {@link + * module:components/charts/canvas/SignalRenderer.computeLaneLayout}; no GL + * context is touched. + * + * @module components/charts/webgl/laneScissor + */ + +/** A scissor box in device pixels, ready for `gl.scissor(x, y, width, height)`. */ +export interface ScissorRect { + /** Device-pixel X of the box's left edge (bottom-left origin). */ + readonly x: number; + /** Device-pixel Y of the box's bottom edge (bottom-left origin, +Y up). */ + readonly y: number; + /** Device-pixel width (≥ 0). */ + readonly width: number; + /** Device-pixel height (≥ 0). */ + readonly height: number; +} + +/** The CSS-pixel clip rect for a lane (mirrors the Canvas2D `ctx.rect` args). */ +export interface LaneClipRectCss { + /** Left edge (CSS px). */ + readonly plotLeft: number; + /** Top edge (CSS px). */ + readonly stripTop: number; + /** Width (CSS px). */ + readonly plotWidth: number; + /** Height (CSS px). */ + readonly stripHeight: number; +} + +/** + * Convert a lane's CSS-pixel clip rect into a device-pixel, bottom-left-origin + * scissor box. + * + * @param rect - The lane clip rect in CSS px (the Canvas2D clip args). + * @param dpr - Device-pixel ratio (preserved at 2; never reduced). + * @param bufferHeightDevice - Drawing-buffer height in device px (`cssHeight * dpr`, + * i.e. `canvas.height`). Used for the Y flip. + */ +export function computeLaneScissor( + rect: LaneClipRectCss, + dpr: number, + bufferHeightDevice: number, +): ScissorRect { + // Edges in device px. Floor the min edges, ceil the max edges, so the integer + // box is the smallest one that fully covers the CSS rect (never under-clips an + // edge pixel the canvas painted). + const leftDev = Math.floor(rect.plotLeft * dpr); + const rightDev = Math.ceil((rect.plotLeft + rect.plotWidth) * dpr); + const topDev = Math.floor(rect.stripTop * dpr); // distance from buffer TOP + const bottomDev = Math.ceil((rect.stripTop + rect.stripHeight) * dpr); // from buffer TOP + + const width = Math.max(0, rightDev - leftDev); + const height = Math.max(0, bottomDev - topDev); + + // Flip Y: scissor origin is bottom-left. The box's bottom edge measured from + // the buffer bottom is `bufferHeight - bottomDev` (bottomDev is from the top). + const y = Math.max(0, bufferHeightDevice - bottomDev); + + return { x: Math.max(0, leftDev), y, width, height }; +} diff --git a/src/components/charts/webgl/lineGeometry.ts b/src/components/charts/webgl/lineGeometry.ts new file mode 100644 index 0000000..625dedd --- /dev/null +++ b/src/components/charts/webgl/lineGeometry.ts @@ -0,0 +1,148 @@ +/** + * Instanced-line geometry for the zoomed-IN per-sample polyline. + * + * The Canvas2D reference ({@link + * module:components/charts/canvas/SignalRenderer} `drawLine`) strokes the LTTB + * polyline as one path at {@link DENSE_LINE_WIDTH} (1.2 px) with + * `lineJoin: 'round'`, breaking the line wherever a sample is `NaN` (the + * `firstPoint = true` reset). Width is constant in *screen* pixels regardless of + * zoom. + * + * WebGL2 has no native thick polyline, so we render it as **instanced quads**: + * one instance per polyline segment, each instance a unit quad expanded in the + * vertex shader into a screen-space rectangle of width {@link DENSE_LINE_WIDTH} + * device px around the segment, with the fragment shader applying an SDF feather + * for the 1.2 px round-joined anti-aliasing. The per-instance attributes this + * module produces are the two segment endpoints in **data space**: + * `[xCurrent, yCurrent, xNext, yNext]`. The shader transforms both endpoints to + * clip space, then expands the quad in screen space — so the line width is + * constant in pixels no matter the X/Y zoom, exactly like the Canvas2D stroke. + * + * GAP / NaN BREAKS + * ---------------- + * `drawLine` breaks the polyline when either endpoint is NaN (no `lineTo` is + * issued across the gap). We mirror that by simply **not emitting an instance** + * for any segment whose current or next endpoint is NaN. The instance count and + * which segments survive are unit-tested against the `firstPoint` break logic. + * + * Everything here is **pure** and unit-tested; no GL context is touched. The + * width expansion and round-join feathering are in the shader, validated by the + * CI pixel-diff gate (they cannot run without a GL context). + * + * @module components/charts/webgl/lineGeometry + */ + +import { DENSE_LINE_WIDTH } from './envelopeGeometry'; + +export { DENSE_LINE_WIDTH }; + +/** Floats per line instance: `[xCurrent, yCurrent, xNext, yNext]`. */ +export const LINE_INSTANCE_STRIDE = 4; + +/** + * The static unit-quad vertex positions (two triangles) shared by every line + * instance. Each row is a corner `(along, side)`: + * - `along` ∈ {0, 1}: position along the segment (current → next). + * - `side` ∈ {-1, +1}: which side of the segment to offset (× half width). + * The vertex shader uses these to build the screen-space rectangle and to drive + * the fragment SDF. Six vertices = two triangles (no index buffer needed). + */ +export const LINE_QUAD_UNIT: Float32Array = new Float32Array([ + // along, side + 0, + -1, // 0: current, lower + 1, + -1, // 1: next, lower + 1, + 1, // 2: next, upper + 0, + -1, // 3: current, lower + 1, + 1, // 4: next, upper + 0, + 1, // 5: current, upper +]); + +/** Number of vertices in {@link LINE_QUAD_UNIT} (per instance). */ +export const LINE_QUAD_VERTEX_COUNT = 6; + +/** + * Mapping from polyline sample index to data-space X. + * + * The Canvas2D uniform-cadence path places sample `s` at `dataX = dataXStart + s + * * dataXPerSample`. For the timestamped path the caller supplies explicit + * `sampleX` values instead. Exactly one of `dataXPerSample` (uniform) or + * `sampleX` (explicit) is used. + */ +export interface LineGeometryParams { + /** Data-space X of sample 0 (uniform path). */ + readonly dataXStart: number; + /** Data-space X step per sample index (uniform path). */ + readonly dataXPerSample: number; + /** + * Explicit data-space X per sample (timestamped path). When provided and the + * length matches the polyline, these override the uniform `dataXStart` / + * `dataXPerSample` mapping. + */ + readonly sampleX?: Float64Array; +} + +/** The built instanced-line geometry for one channel's polyline. */ +export interface LineGeometry { + /** + * Interleaved per-instance attributes `[xCur, yCur, xNext, yNext]`, one + * instance per drawn segment (NaN-broken segments are omitted). Length = + * `instanceCount * LINE_INSTANCE_STRIDE`. + */ + readonly instances: Float32Array; + /** Number of segment instances emitted. */ + readonly instanceCount: number; +} + +/** + * Build instanced-line segment attributes from an LTTB polyline. + * + * Walks consecutive sample pairs `(s, s+1)`. A pair is emitted as one instance + * iff **both** samples are finite (mirroring `drawLine`, which only issues a + * `lineTo` when both the previous and current points are real — a NaN resets + * `firstPoint`, suppressing the connecting segment). The X of each endpoint comes + * from the explicit `sampleX` mapping when supplied, else the uniform + * `dataXStart + s * dataXPerSample` mapping. + * + * @param data - The LTTB polyline y-values (physical units; NaN = break). + * @param params - Sample-index → data-space-X mapping. + */ +export function buildLineGeometry(data: Float32Array, params: LineGeometryParams): LineGeometry { + const n = data.length; + const { dataXStart, dataXPerSample, sampleX } = params; + const useExplicitX = sampleX !== undefined && sampleX.length === n; + + if (n < 2) { + return { instances: new Float32Array(0), instanceCount: 0 }; + } + + // First pass: count drawable segments so the instance buffer is sized exactly. + let count = 0; + for (let s = 0; s < n - 1; s++) { + const a = data[s] as number; + const b = data[s + 1] as number; + if (!Number.isNaN(a) && !Number.isNaN(b)) count++; + } + + const instances = new Float32Array(count * LINE_INSTANCE_STRIDE); + let w = 0; + const xOf = (s: number): number => + useExplicitX ? (sampleX[s] as number) : dataXStart + s * dataXPerSample; + + for (let s = 0; s < n - 1; s++) { + const a = data[s] as number; + const b = data[s + 1] as number; + if (Number.isNaN(a) || Number.isNaN(b)) continue; + instances[w++] = xOf(s); + instances[w++] = a; + instances[w++] = xOf(s + 1); + instances[w++] = b; + } + + return { instances, instanceCount: count }; +} diff --git a/src/components/charts/webgl/waveformTransform.ts b/src/components/charts/webgl/waveformTransform.ts new file mode 100644 index 0000000..f0b8844 --- /dev/null +++ b/src/components/charts/webgl/waveformTransform.ts @@ -0,0 +1,224 @@ +/** + * Data-space → clip-space transform for the WebGL2 waveform renderer. + * + * The Canvas2D reference renderer ({@link + * module:components/charts/canvas/SignalRenderer}) maps a sample to a CSS-pixel + * position with two independent linear transforms: + * + * - **X** (time / sample index): + * `x_css = plotLeft + (tMs - viewStartMs) * (plotWidth / viewSpanMs)` + * where for the uniform-cadence CPAP path `tMs = sampleIndex * msPerSample` + * and `msPerSample = viewSpanMs / sampleCount`. Equivalently, X is linear in + * a data-space coordinate that is *either* a session-relative millisecond + * value *or* a sample index — both are affine in the viewport, so this module + * stays agnostic: the caller supplies a data-space X range `[dataXStart, + * dataXEnd)` that corresponds to the lane's plot extent. + * + * - **Y** (physical units): + * `innerTop = stripTop + TOP_INSET` + * `innerBottom = stripTop + stripHeight - BOTTOM_INSET` + * `y_css = innerBottom - ((value - physicalMin) / physRange) * innerHeight` + * (top = physicalMax, bottom = physicalMin), with `innerHeight = innerBottom + * - innerTop`. These insets reserve the lane's top label strip exactly as the + * Canvas2D `drawLine` / `drawEnvelope` paths do. + * + * WebGL clip space is `[-1, +1]` on both axes, with **+Y up** (the opposite of + * canvas, where +Y is down) and the origin at the centre of the *drawing + * buffer*. The drawing buffer is `cssW * dpr` × `cssH * dpr` device pixels (DPR + * is preserved at 2 — never reduced — per ADR 0019). Because clip space is + * already normalised, the transform is **DPR-independent for vertex positions**: + * a CSS-pixel coordinate maps to the same clip coordinate regardless of DPR + * (DPR only changes how many device pixels back each clip unit, which the + * viewport/scissor handle). DPR therefore enters only the *scissor* maths + * ({@link module:components/charts/webgl/laneScissor}) and the *screen-space + * line width* expansion in the shader, not this transform. + * + * The transform is expressed as per-axis `scale`/`offset` pairs so the GPU + * computes `clip = data * scale + offset` in the vertex shader — a single MAD. + * Pan changes only the X offset (via `viewStart`); zoom changes the X scale (via + * `viewSpan`). Neither re-uploads geometry — exactly the property ADR 0019 + * requires to remove the per-frame texture re-upload. + * + * Everything here is **pure** and unit-tested against the Canvas2D pixel + * mapping; no GL context is touched. + * + * @module components/charts/webgl/waveformTransform + */ + +/** + * Top label-strip inset (CSS px) reserved above the waveform within a lane. + * Mirrors `stripTop + 16` in {@link + * module:components/charts/canvas/SignalRenderer} `drawLine`/`drawEnvelope`. + */ +export const LANE_TOP_INSET = 16; + +/** + * Bottom inset (CSS px) reserved below the waveform within a lane. Mirrors + * `stripTop + stripHeight - 8` in the Canvas2D paths. + */ +export const LANE_BOTTOM_INSET = 8; + +/** A lane's plot rectangle in CSS pixels (the strip the lane paints into). */ +export interface LaneRect { + /** Left edge (CSS px) of the plot area (canvas `plotLeft`). */ + readonly plotLeft: number; + /** Plot width (CSS px) (canvas `plotWidth`). */ + readonly plotWidth: number; + /** Top edge (CSS px) of the lane strip (canvas `stripTop`). */ + readonly stripTop: number; + /** Lane strip height (CSS px) (canvas `stripHeight`). */ + readonly stripHeight: number; +} + +/** Current horizontal viewport in data-space X units (ms or sample index). */ +export interface ViewportX { + /** Inclusive data-space X at the left plot edge. */ + readonly viewStart: number; + /** Data-space X span across the plot width (`viewEnd - viewStart`). */ + readonly viewSpan: number; +} + +/** A lane's physical-value Y range (canvas `physicalMin`/`physicalMax`). */ +export interface PhysicalRange { + readonly physicalMin: number; + readonly physicalMax: number; +} + +/** + * Per-axis affine transform from data space to WebGL clip space, applied in the + * vertex shader as `clip = data * scale + offset` per axis. + */ +export interface WaveformClipTransform { + /** X scale: clip-units per data-space X unit. */ + readonly scaleX: number; + /** X offset: clip X when data-space X is 0. */ + readonly offsetX: number; + /** Y scale: clip-units per physical-value unit (negative — physics up). */ + readonly scaleY: number; + /** Y offset: clip Y when the physical value is 0. */ + readonly offsetY: number; +} + +/** + * Compute the CSS-pixel inner-plot Y extent for a lane, identical to the + * Canvas2D `drawLine`/`drawEnvelope` insets. + */ +export function laneInnerYExtent(lane: LaneRect): { + innerTop: number; + innerBottom: number; + innerHeight: number; +} { + const innerTop = lane.stripTop + LANE_TOP_INSET; + const innerBottom = lane.stripTop + lane.stripHeight - LANE_BOTTOM_INSET; + return { innerTop, innerBottom, innerHeight: innerBottom - innerTop }; +} + +/** + * Map a data-space X coordinate to a CSS-pixel X, identical to the Canvas2D + * `x = plotLeft + (dataX - viewStart) * (plotWidth / viewSpan)` mapping. Pure + * reference used by the unit tests to pin the GPU transform to the canvas. + */ +export function dataXToCssX(dataX: number, view: ViewportX, lane: LaneRect): number { + if (view.viewSpan === 0) return lane.plotLeft; + return lane.plotLeft + ((dataX - view.viewStart) / view.viewSpan) * lane.plotWidth; +} + +/** + * Map a physical value to a CSS-pixel Y, identical to the Canvas2D + * `y = innerBottom - ((value - physicalMin) / physRange) * innerHeight` mapping. + */ +export function valueToCssY(value: number, phys: PhysicalRange, lane: LaneRect): number { + const { innerBottom, innerHeight } = laneInnerYExtent(lane); + const physRange = phys.physicalMax - phys.physicalMin; + if (physRange === 0) return innerBottom; + return innerBottom - ((value - phys.physicalMin) / physRange) * innerHeight; +} + +/** + * Convert a CSS-pixel X to a clip-space X for a drawing buffer `cssWidth` CSS px + * wide. Clip X is `(cssX / cssWidth) * 2 - 1` (left edge → -1, right edge → +1); + * DPR-independent because both numerator and the buffer scale by DPR. + */ +export function cssXToClipX(cssX: number, cssWidth: number): number { + if (cssWidth === 0) return -1; + return (cssX / cssWidth) * 2 - 1; +} + +/** + * Convert a CSS-pixel Y to a clip-space Y for a drawing buffer `cssHeight` CSS px + * tall. Canvas Y grows downward; clip Y grows upward, so the mapping flips: + * `1 - (cssY / cssHeight) * 2` (top edge → +1, bottom edge → -1). + */ +export function cssYToClipY(cssY: number, cssHeight: number): number { + if (cssHeight === 0) return 1; + return 1 - (cssY / cssHeight) * 2; +} + +/** + * Build the per-lane data→clip affine transform. + * + * Composes the two Canvas2D pixel mappings (X: data→css, Y: value→css) with the + * css→clip normalisation, then folds each composition into a single + * `scale`/`offset` pair so the shader runs one MAD per axis. + * + * Correctness: for every input the produced transform satisfies + * `clipX = dataXToClipX(dataX) == cssXToClipX(dataXToCssX(dataX, ...), cssWidth)` + * `clipY = valueToClipY(value) == cssYToClipY(valueToCssY(value, ...), cssHeight)` + * which the unit tests assert exhaustively — the GPU output is therefore pinned + * to the Canvas2D reference to within float precision. + * + * @param view - Horizontal viewport in data-space X. + * @param phys - Lane physical Y range. + * @param lane - Lane plot rect (CSS px). + * @param cssWidth - Drawing-buffer width in CSS px (canvas logical width). + * @param cssHeight - Drawing-buffer height in CSS px (canvas logical height). + */ +export function computeWaveformClipTransform( + view: ViewportX, + phys: PhysicalRange, + lane: LaneRect, + cssWidth: number, + cssHeight: number, +): WaveformClipTransform { + // ── X: dataX → cssX → clipX ───────────────────────────────────── + // cssX = plotLeft + (dataX - viewStart) * (plotWidth / viewSpan) + // clipX = (cssX / cssWidth) * 2 - 1 + // Fold: clipX = dataX * scaleX + offsetX + let scaleX = 0; + let offsetX = -1; + if (view.viewSpan !== 0 && cssWidth !== 0) { + const pxPerX = lane.plotWidth / view.viewSpan; // css px per data-space X unit + const cssXAt0 = lane.plotLeft - view.viewStart * pxPerX; // cssX when dataX == 0 + const clipPerCssX = 2 / cssWidth; + scaleX = pxPerX * clipPerCssX; + offsetX = cssXAt0 * clipPerCssX - 1; + } + + // ── Y: value → cssY → clipY ───────────────────────────────────── + // cssY = innerBottom - ((value - physicalMin) / physRange) * innerHeight + // clipY = 1 - (cssY / cssHeight) * 2 + // Fold: clipY = value * scaleY + offsetY + let scaleY = 0; + let offsetY = 1; + const physRange = phys.physicalMax - phys.physicalMin; + if (physRange !== 0 && cssHeight !== 0) { + const { innerBottom, innerHeight } = laneInnerYExtent(lane); + const cssPerValue = -innerHeight / physRange; // css px per value (negative: value up) + const cssYAt0 = innerBottom - (0 - phys.physicalMin) * (innerHeight / physRange); + const clipPerCssY = -2 / cssHeight; + scaleY = cssPerValue * clipPerCssY; + offsetY = cssYAt0 * clipPerCssY + 1; + } + + return { scaleX, offsetX, scaleY, offsetY }; +} + +/** Apply a {@link WaveformClipTransform} to a data-space X (test/diagnostic helper). */ +export function applyClipX(t: WaveformClipTransform, dataX: number): number { + return dataX * t.scaleX + t.offsetX; +} + +/** Apply a {@link WaveformClipTransform} to a physical value (test/diagnostic helper). */ +export function applyClipY(t: WaveformClipTransform, value: number): number { + return value * t.scaleY + t.offsetY; +} From 6229eef2ba5af831810f3f8d8ce95c6c473fc395 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 20:47:33 +0000 Subject: [PATCH 03/16] feat(charts): WebGL2 waveform renderer core (context, programs, draw) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 1 GL-context class for ADR 0019. Self-contained; NOT yet wired into SignalViewer (Stage 2). No feature flag (owner decision) — WebGL2 is the intended default with automatic Canvas2D fallback. - Constructor obtains webgl2 (antialias, premultipliedAlpha, no preserveDrawingBuffer); throws typed WebGLUnavailableError on null so the host can fall back to Canvas2D. - API for Stage-2: resize(cssW, cssH, dpr) keeps DPR 2 (buffer = css*dpr, never reduced); uploadLanes(lanes) builds geometry via the pure helpers and uploads to STATIC buffers (data load / LOD change, not per frame); render(viewport, laneStates) sets transform/colour uniforms + per-lane gl.scissor and draws — no per-frame upload; dispose() frees resources. - Context-loss handling: webglcontextlost (preventDefault) + restored (recompile programs, re-upload retained lanes); onContextLost / onContextRestored callbacks for the host. - Theme colours passed as resolved RGBA uniforms (no getComputedStyle here). - Envelope drawn as TRIANGLE_STRIP with WebGL2's permanent fixed-index primitive restart; line drawn as instanced quads (lineWidthPx = 1.2*dpr). Validated by typecheck/lint; GL paths are exercised by the CI pixel-diff fidelity gate and production (no WebGL in the sandbox). https://claude.ai/code/session_012CzEJ1kUhwobqVTnVusLcb --- .../charts/webgl/WebGLWaveformRenderer.ts | 527 ++++++++++++++++++ src/components/charts/webgl/index.ts | 57 ++ 2 files changed, 584 insertions(+) create mode 100644 src/components/charts/webgl/WebGLWaveformRenderer.ts create mode 100644 src/components/charts/webgl/index.ts diff --git a/src/components/charts/webgl/WebGLWaveformRenderer.ts b/src/components/charts/webgl/WebGLWaveformRenderer.ts new file mode 100644 index 0000000..f06aa2c --- /dev/null +++ b/src/components/charts/webgl/WebGLWaveformRenderer.ts @@ -0,0 +1,527 @@ +/** + * WebGL2 renderer CORE for the Signal Viewer's dense waveform lanes. + * + * This is the GL-context-bound half of ADR 0019's hybrid renderer: it draws ONLY + * the dense waveform lanes (the zoomed-out min/max envelope as triangle strips + * and the zoomed-in per-sample line as instanced quads). Everything else — axes, + * grid, labels, event markers, detection washes, the hypnogram ribbon, + * sparse/step lanes, and the crosshair overlay — stays on Canvas2D, which is also + * the permanent automatic fallback. + * + * The geometry it draws is produced by the **pure, unit-tested** helpers in this + * module ({@link module:components/charts/webgl/envelopeGeometry}, {@link + * module:components/charts/webgl/lineGeometry}, {@link + * module:components/charts/webgl/waveformTransform}, {@link + * module:components/charts/webgl/laneScissor}); the extrema-preservation contract + * and gap semantics therefore live OUTSIDE this class. This file holds only the + * parts that need a live GL context — shader compile/link, buffer upload, and + * draw calls — which cannot be unit-tested in the headless sandbox (no WebGL in + * jsdom) and are validated by the CI pixel-diff fidelity gate and in production. + * + * KEY INVARIANTS (ADR 0019): + * - **DPR preserved** at 2 (never reduced): the drawing buffer is `cssW*dpr × + * cssH*dpr` device px. + * - **No per-frame upload**: {@link uploadLanes} pushes geometry into static + * buffers on data load / LOD-level change; {@link render} only sets uniforms + + * scissor and issues draws. Pan = change X offset; zoom = change X scale. + * - **Context-loss safe**: on `webglcontextlost` we stop drawing and notify the + * host (so it can show the Canvas2D fallback); on `webglcontextrestored` we + * recompile programs and re-upload the last lane set. + * - **Theme colours as uniforms**: callers pass resolved RGBA; no + * `getComputedStyle` here. + * + * @module components/charts/webgl/WebGLWaveformRenderer + */ + +import { + ENVELOPE_VERTEX_SHADER, + ENVELOPE_FRAGMENT_SHADER, + ENVELOPE_LOCATIONS, +} from './glsl/envelope'; +import { LINE_VERTEX_SHADER, LINE_FRAGMENT_SHADER, LINE_LOCATIONS } from './glsl/line'; +import { + buildEnvelopeGeometry, + PRIMITIVE_RESTART_INDEX, + ENVELOPE_VERTEX_STRIDE, + DENSE_LINE_WIDTH, + type ColumnEnvelopeInput, + type EnvelopeGeometryParams, +} from './envelopeGeometry'; +import { + buildLineGeometry, + LINE_QUAD_UNIT, + LINE_QUAD_VERTEX_COUNT, + LINE_INSTANCE_STRIDE, + type LineGeometryParams, +} from './lineGeometry'; +import { computeLaneScissor, type LaneClipRectCss } from './laneScissor'; +import { + computeWaveformClipTransform, + type ViewportX, + type PhysicalRange, + type LaneRect, +} from './waveformTransform'; + +/** Thrown when a WebGL2 context cannot be obtained; the caller falls back to Canvas2D. */ +export class WebGLUnavailableError extends Error { + constructor(message = 'WebGL2 is not available on this canvas') { + super(message); + this.name = 'WebGLUnavailableError'; + } +} + +/** Resolved theme colour as RGBA in 0..1. */ +export interface RGBA { + readonly r: number; + readonly g: number; + readonly b: number; + readonly a: number; +} + +/** + * One lane's renderable waveform data, supplied at {@link uploadLanes} time. A + * lane may carry an envelope (zoomed-out) OR a line polyline (zoomed-in); the + * host picks which based on samples-per-pixel exactly as the Canvas2D path does. + */ +export interface WaveformLaneInput { + /** Stable lane id (used to keep per-lane GPU resources across uploads). */ + readonly id: string; + /** Physical Y range for this lane. */ + readonly phys: PhysicalRange; + /** Per-column min/max envelope (zoomed-out path), or null. */ + readonly envelope: (ColumnEnvelopeInput & EnvelopeGeometryParams) | null; + /** LTTB polyline + its sample→X mapping (zoomed-in path), or null. */ + readonly line: ({ readonly data: Float32Array } & LineGeometryParams) | null; +} + +/** Per-lane, per-frame transform + colour + clip, supplied at {@link render} time. */ +export interface LaneFrameState { + /** Lane id matching a {@link WaveformLaneInput}. */ + readonly id: string; + /** Lane plot rect (CSS px) — drives both the transform and the scissor. */ + readonly lane: LaneRect; + /** Resolved lane colour. */ + readonly color: RGBA; +} + +/** GPU resources for one uploaded lane. */ +interface LaneBuffers { + // Envelope strip + envVbo: WebGLBuffer | null; + envIbo: WebGLBuffer | null; + envIndexCount: number; + // Instanced line + lineVbo: WebGLBuffer | null; // per-instance segment data + lineInstanceCount: number; + // Retained source so we can re-upload after context restore. + source: WaveformLaneInput; +} + +/** Compiled GL program + cached locations. */ +interface ProgramBundle { + program: WebGLProgram; + attribs: Record; + uniforms: Record; +} + +export class WebGLWaveformRenderer { + private readonly canvas: HTMLCanvasElement; + private gl: WebGL2RenderingContext; + private envProgram: ProgramBundle | null = null; + private lineProgram: ProgramBundle | null = null; + /** Static unit-quad VBO shared by every line instance. */ + private lineQuadVbo: WebGLBuffer | null = null; + private readonly lanes = new Map(); + + private cssWidth = 0; + private cssHeight = 0; + private dpr = 1; + private contextLost = false; + + /** Host callbacks so the Signal Viewer can swap to Canvas2D during loss. */ + onContextLost: (() => void) | null = null; + onContextRestored: (() => void) | null = null; + + private readonly handleContextLost = (e: Event): void => { + // Prevent the default so the context becomes restorable. + e.preventDefault(); + this.contextLost = true; + this.onContextLost?.(); + }; + + private readonly handleContextRestored = (): void => { + this.contextLost = false; + // Recompile programs and re-upload every retained lane's geometry. + this.initPrograms(); + const retained = [...this.lanes.values()].map((l) => l.source); + this.lanes.clear(); + this.uploadLanes(retained); + this.onContextRestored?.(); + }; + + /** + * @param canvas A canvas element to own. Throws {@link WebGLUnavailableError} + * if a WebGL2 context cannot be created (caller falls back to Canvas2D). + * @param options.premultipliedAlpha Whether the context composites with + * premultiplied alpha (default true, matching browser canvas compositing). + */ + constructor(canvas: HTMLCanvasElement, options?: { premultipliedAlpha?: boolean }) { + this.canvas = canvas; + const gl = canvas.getContext('webgl2', { + antialias: true, + premultipliedAlpha: options?.premultipliedAlpha ?? true, + preserveDrawingBuffer: false, + // The waveform composites over the Canvas2D chrome beneath it. + alpha: true, + depth: false, + stencil: false, + }); + if (!gl) { + throw new WebGLUnavailableError(); + } + this.gl = gl; + + canvas.addEventListener('webglcontextlost', this.handleContextLost, false); + canvas.addEventListener('webglcontextrestored', this.handleContextRestored, false); + + this.initPrograms(); + this.initStaticBuffers(); + } + + // ── Public API ───────────────────────────────────────────────── + + /** + * Resize the drawing buffer to `cssW × cssH` CSS px at `dpr` device-pixel ratio. + * The backing buffer is `cssW*dpr × cssH*dpr` device px — DPR is preserved, not + * reduced (ADR 0019 hard constraint). Call from the host's ResizeObserver. + */ + resize(cssW: number, cssH: number, dpr: number): void { + this.cssWidth = cssW; + this.cssHeight = cssH; + this.dpr = dpr; + this.canvas.width = Math.round(cssW * dpr); + this.canvas.height = Math.round(cssH * dpr); + this.canvas.style.width = `${cssW}px`; + this.canvas.style.height = `${cssH}px`; + } + + /** + * Upload lane geometry into STATIC GPU buffers. Called on data load and on + * LOD-level change (a new pyramid level / envelope vs line switch) — NOT per + * frame. Builds geometry with the pure helpers, then uploads. Lanes absent from + * `lanes` are disposed; lanes present are replaced. + */ + uploadLanes(lanes: readonly WaveformLaneInput[]): void { + if (this.contextLost) { + // Retain the request implicitly by re-keying; geometry re-uploads on restore. + } + const gl = this.gl; + const keep = new Set(lanes.map((l) => l.id)); + + // Dispose lanes no longer present. + for (const [id, buf] of this.lanes) { + if (!keep.has(id)) { + this.disposeLaneBuffers(buf); + this.lanes.delete(id); + } + } + + for (const lane of lanes) { + let buf = this.lanes.get(lane.id); + if (!buf) { + buf = { + envVbo: null, + envIbo: null, + envIndexCount: 0, + lineVbo: null, + lineInstanceCount: 0, + source: lane, + }; + this.lanes.set(lane.id, buf); + } else { + buf.source = lane; + } + + // Envelope strip. + if (lane.envelope) { + const geo = buildEnvelopeGeometry(lane.envelope, lane.envelope); + buf.envVbo ??= gl.createBuffer(); + buf.envIbo ??= gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, buf.envVbo); + gl.bufferData(gl.ARRAY_BUFFER, geo.vertices, gl.STATIC_DRAW); + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, buf.envIbo); + gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, geo.indices, gl.STATIC_DRAW); + buf.envIndexCount = geo.indices.length; + } else { + buf.envIndexCount = 0; + } + + // Instanced line. + if (lane.line) { + const geo = buildLineGeometry(lane.line.data, lane.line); + buf.lineVbo ??= gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, buf.lineVbo); + gl.bufferData(gl.ARRAY_BUFFER, geo.instances, gl.STATIC_DRAW); + buf.lineInstanceCount = geo.instanceCount; + } else { + buf.lineInstanceCount = 0; + } + } + } + + /** + * Render one frame. Per-frame work is ONLY: clear, then for each lane set the + * transform/colour uniforms, set `gl.scissor` to the lane's clip rect, and draw. + * No buffer upload happens here — pan/zoom are encoded entirely in the transform + * uniform derived from `viewport`. + * + * @param viewport Horizontal data-space viewport (pan/zoom). + * @param laneStates Per-lane transform inputs + resolved colour, in draw order. + */ + render(viewport: ViewportX, laneStates: readonly LaneFrameState[]): void { + if (this.contextLost) return; + const gl = this.gl; + const bufW = this.canvas.width; + const bufH = this.canvas.height; + if (bufW === 0 || bufH === 0) return; + + gl.viewport(0, 0, bufW, bufH); + gl.disable(gl.DEPTH_TEST); + gl.enable(gl.BLEND); + // Premultiplied-alpha blending (matches a premultipliedAlpha:true context). + gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA); + + // Clear transparent so the Canvas2D chrome beneath shows through. + gl.disable(gl.SCISSOR_TEST); + gl.clearColor(0, 0, 0, 0); + gl.clear(gl.COLOR_BUFFER_BIT); + + gl.enable(gl.SCISSOR_TEST); + + for (const st of laneStates) { + const buf = this.lanes.get(st.id); + if (!buf) continue; + + const transform = computeWaveformClipTransform( + viewport, + // Physical range is part of the uploaded lane source. + buf.source.phys, + st.lane, + this.cssWidth, + this.cssHeight, + ); + + const clipRect: LaneClipRectCss = { + plotLeft: st.lane.plotLeft, + stripTop: st.lane.stripTop, + plotWidth: st.lane.plotWidth, + stripHeight: st.lane.stripHeight, + }; + const scissor = computeLaneScissor(clipRect, this.dpr, bufH); + gl.scissor(scissor.x, scissor.y, scissor.width, scissor.height); + + if (buf.envIndexCount > 0) { + this.drawEnvelope(buf, transform, st.color, bufW, bufH); + } + if (buf.lineInstanceCount > 0) { + this.drawLine(buf, transform, st.color, bufW, bufH); + } + } + + gl.disable(gl.SCISSOR_TEST); + } + + /** Whether the context is currently lost (host should be on the Canvas2D fallback). */ + isContextLost(): boolean { + return this.contextLost; + } + + /** Release all GPU resources and detach listeners. */ + dispose(): void { + const gl = this.gl; + this.canvas.removeEventListener('webglcontextlost', this.handleContextLost); + this.canvas.removeEventListener('webglcontextrestored', this.handleContextRestored); + for (const buf of this.lanes.values()) this.disposeLaneBuffers(buf); + this.lanes.clear(); + if (this.lineQuadVbo) gl.deleteBuffer(this.lineQuadVbo); + this.lineQuadVbo = null; + if (this.envProgram) gl.deleteProgram(this.envProgram.program); + if (this.lineProgram) gl.deleteProgram(this.lineProgram.program); + this.envProgram = null; + this.lineProgram = null; + } + + // ── Draw helpers ─────────────────────────────────────────────── + + private drawEnvelope( + buf: LaneBuffers, + t: ReturnType, + color: RGBA, + bufW: number, + bufH: number, + ): void { + const gl = this.gl; + const prog = this.envProgram; + if (!prog || !buf.envVbo || !buf.envIbo) return; + + gl.useProgram(prog.program); + gl.uniform2f(prog.uniforms[ENVELOPE_LOCATIONS.uniforms.clipScale] ?? null, t.scaleX, t.scaleY); + gl.uniform2f( + prog.uniforms[ENVELOPE_LOCATIONS.uniforms.clipOffset] ?? null, + t.offsetX, + t.offsetY, + ); + gl.uniform2f(prog.uniforms[ENVELOPE_LOCATIONS.uniforms.viewport] ?? null, bufW, bufH); + gl.uniform4f( + prog.uniforms[ENVELOPE_LOCATIONS.uniforms.color] ?? null, + color.r, + color.g, + color.b, + color.a, + ); + + gl.bindBuffer(gl.ARRAY_BUFFER, buf.envVbo); + const aData = prog.attribs[ENVELOPE_LOCATIONS.attributes.data] ?? -1; + if (aData >= 0) { + gl.enableVertexAttribArray(aData); + gl.vertexAttribPointer(aData, ENVELOPE_VERTEX_STRIDE, gl.FLOAT, false, 0, 0); + } + + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, buf.envIbo); + // WebGL2 enables primitive restart PERMANENTLY for the fixed maximum index of + // the index type — 0xffffffff for UNSIGNED_INT — which is exactly the sentinel + // envelopeGeometry emits at gap boundaries (PRIMITIVE_RESTART_INDEX). There is + // no enable/disable toggle (unlike desktop GL); it is always active. + void PRIMITIVE_RESTART_INDEX; + gl.drawElements(gl.TRIANGLE_STRIP, buf.envIndexCount, gl.UNSIGNED_INT, 0); + } + + private drawLine( + buf: LaneBuffers, + t: ReturnType, + color: RGBA, + bufW: number, + bufH: number, + ): void { + const gl = this.gl; + const prog = this.lineProgram; + if (!prog || !buf.lineVbo || !this.lineQuadVbo) return; + + gl.useProgram(prog.program); + gl.uniform2f(prog.uniforms[LINE_LOCATIONS.uniforms.clipScale] ?? null, t.scaleX, t.scaleY); + gl.uniform2f(prog.uniforms[LINE_LOCATIONS.uniforms.clipOffset] ?? null, t.offsetX, t.offsetY); + gl.uniform2f(prog.uniforms[LINE_LOCATIONS.uniforms.viewport] ?? null, bufW, bufH); + gl.uniform1f( + prog.uniforms[LINE_LOCATIONS.uniforms.lineWidthPx] ?? null, + DENSE_LINE_WIDTH * this.dpr, + ); + gl.uniform4f( + prog.uniforms[LINE_LOCATIONS.uniforms.color] ?? null, + color.r, + color.g, + color.b, + color.a, + ); + + // Per-vertex unit quad (not instanced). + const aCorner = prog.attribs[LINE_LOCATIONS.attributes.corner] ?? -1; + gl.bindBuffer(gl.ARRAY_BUFFER, this.lineQuadVbo); + if (aCorner >= 0) { + gl.enableVertexAttribArray(aCorner); + gl.vertexAttribPointer(aCorner, 2, gl.FLOAT, false, 0, 0); + gl.vertexAttribDivisor(aCorner, 0); + } + + // Per-instance segment data. + const aSeg = prog.attribs[LINE_LOCATIONS.attributes.segment] ?? -1; + gl.bindBuffer(gl.ARRAY_BUFFER, buf.lineVbo); + if (aSeg >= 0) { + gl.enableVertexAttribArray(aSeg); + gl.vertexAttribPointer(aSeg, LINE_INSTANCE_STRIDE, gl.FLOAT, false, 0, 0); + gl.vertexAttribDivisor(aSeg, 1); + } + + gl.drawArraysInstanced(gl.TRIANGLES, 0, LINE_QUAD_VERTEX_COUNT, buf.lineInstanceCount); + + if (aSeg >= 0) gl.vertexAttribDivisor(aSeg, 0); // reset for safety + } + + // ── Setup ────────────────────────────────────────────────────── + + private initStaticBuffers(): void { + const gl = this.gl; + this.lineQuadVbo = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, this.lineQuadVbo); + gl.bufferData(gl.ARRAY_BUFFER, LINE_QUAD_UNIT, gl.STATIC_DRAW); + } + + private initPrograms(): void { + this.envProgram = this.buildProgram( + ENVELOPE_VERTEX_SHADER, + ENVELOPE_FRAGMENT_SHADER, + Object.values(ENVELOPE_LOCATIONS.attributes), + Object.values(ENVELOPE_LOCATIONS.uniforms), + ); + this.lineProgram = this.buildProgram( + LINE_VERTEX_SHADER, + LINE_FRAGMENT_SHADER, + Object.values(LINE_LOCATIONS.attributes), + Object.values(LINE_LOCATIONS.uniforms), + ); + } + + private buildProgram( + vsSource: string, + fsSource: string, + attribNames: readonly string[], + uniformNames: readonly string[], + ): ProgramBundle { + const gl = this.gl; + const vs = this.compileShader(gl.VERTEX_SHADER, vsSource); + const fs = this.compileShader(gl.FRAGMENT_SHADER, fsSource); + const program = gl.createProgram(); + if (!program) throw new WebGLUnavailableError('Failed to create WebGL program'); + gl.attachShader(program, vs); + gl.attachShader(program, fs); + gl.linkProgram(program); + // Shaders can be detached/deleted once linked. + gl.deleteShader(vs); + gl.deleteShader(fs); + if (!gl.getProgramParameter(program, gl.LINK_STATUS) && !gl.isContextLost()) { + const log = gl.getProgramInfoLog(program); + gl.deleteProgram(program); + throw new WebGLUnavailableError(`Program link failed: ${log ?? 'unknown'}`); + } + + const attribs: Record = {}; + for (const name of attribNames) attribs[name] = gl.getAttribLocation(program, name); + const uniforms: Record = {}; + for (const name of uniformNames) uniforms[name] = gl.getUniformLocation(program, name); + + return { program, attribs, uniforms }; + } + + private compileShader(type: number, source: string): WebGLShader { + const gl = this.gl; + const shader = gl.createShader(type); + if (!shader) throw new WebGLUnavailableError('Failed to create shader'); + gl.shaderSource(shader, source); + gl.compileShader(shader); + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS) && !gl.isContextLost()) { + const log = gl.getShaderInfoLog(shader); + gl.deleteShader(shader); + throw new WebGLUnavailableError(`Shader compile failed: ${log ?? 'unknown'}`); + } + return shader; + } + + private disposeLaneBuffers(buf: LaneBuffers): void { + const gl = this.gl; + if (buf.envVbo) gl.deleteBuffer(buf.envVbo); + if (buf.envIbo) gl.deleteBuffer(buf.envIbo); + if (buf.lineVbo) gl.deleteBuffer(buf.lineVbo); + buf.envVbo = null; + buf.envIbo = null; + buf.lineVbo = null; + } +} diff --git a/src/components/charts/webgl/index.ts b/src/components/charts/webgl/index.ts new file mode 100644 index 0000000..ea87eec --- /dev/null +++ b/src/components/charts/webgl/index.ts @@ -0,0 +1,57 @@ +/** + * WebGL2 hybrid waveform renderer (ADR 0019) — public surface. + * + * Stage 1: the renderer CORE as a self-contained module. The pure, unit-tested + * geometry/transform helpers are the in-sandbox correctness proof; the + * GL-context-bound {@link WebGLWaveformRenderer} is validated by the CI + * pixel-diff fidelity gate and in production. NOT yet integrated into + * SignalViewer (that is Stage 2). + * + * @module components/charts/webgl + */ + +export { + computeWaveformClipTransform, + laneInnerYExtent, + dataXToCssX, + valueToCssY, + cssXToClipX, + cssYToClipY, + applyClipX, + applyClipY, + LANE_TOP_INSET, + LANE_BOTTOM_INSET, + type WaveformClipTransform, + type ViewportX, + type PhysicalRange, + type LaneRect, +} from './waveformTransform'; + +export { + buildEnvelopeGeometry, + DENSE_LINE_WIDTH, + PRIMITIVE_RESTART_INDEX, + ENVELOPE_VERTEX_STRIDE, + type ColumnEnvelopeInput, + type EnvelopeGeometryParams, + type EnvelopeGeometry, +} from './envelopeGeometry'; + +export { + buildLineGeometry, + LINE_QUAD_UNIT, + LINE_QUAD_VERTEX_COUNT, + LINE_INSTANCE_STRIDE, + type LineGeometryParams, + type LineGeometry, +} from './lineGeometry'; + +export { computeLaneScissor, type ScissorRect, type LaneClipRectCss } from './laneScissor'; + +export { + WebGLWaveformRenderer, + WebGLUnavailableError, + type RGBA, + type WaveformLaneInput, + type LaneFrameState, +} from './WebGLWaveformRenderer'; From 3317ba7bfc5e1e2bf077e253c81c5edb4a00ba4c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 20:47:34 +0000 Subject: [PATCH 04/16] feat(charts): add chrome-only mode and canvas accessor to SignalRenderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 2 of the WebGL2 hybrid waveform renderer (ADR 0019). Prepares the Canvas2D SignalRenderer to act as the hybrid's chrome layer AND its permanent automatic fallback: - `setChromeOnly(bool)` / `isChromeOnly()`: in chrome-only mode the renderer draws everything except the dense-CPAP waveform itself (backgrounds, grid, markers/washes, axis text, ribbon, step/sparse, wearable lines), but ONLY for lanes that carry WebGL geometry (`webglLane`). Lanes without it (e.g. before the decimation pyramid lands) still draw their polyline here so the waveform is never invisible. Default is off → byte-identical to before, so the fallback path and all existing tests are unchanged. - `renderSync()`: synchronous (non-rAF) base paint, used by the compositor on pan-settle to repaint chrome and clear its CSS pan-translate in one tick (flash-free). - `getCanvasElement()`: exposes the base canvas so the compositor can CSS-translate the chrome during a drag without re-rendering it. - `SignalChannel.webglLane`: optional per-lane WebGL geometry source (whole pyramid level in a stable absolute-ms domain). The Canvas2D path ignores it. The chrome/waveform split is governed by the shared pure predicate `isDenseCpapWaveform`. https://claude.ai/code/session_012CzEJ1kUhwobqVTnVusLcb --- .../charts/canvas/SignalRenderer.ts | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/src/components/charts/canvas/SignalRenderer.ts b/src/components/charts/canvas/SignalRenderer.ts index 66f3bdc..1068d6c 100644 --- a/src/components/charts/canvas/SignalRenderer.ts +++ b/src/components/charts/canvas/SignalRenderer.ts @@ -9,9 +9,17 @@ * directly on an HTMLCanvasElement and is designed to be called from * `requestAnimationFrame` for smooth 60 fps rendering. * + * In the WebGL2 hybrid (ADR 0019) this class plays two roles: the **Canvas2D + * chrome layer** (in `chromeOnly` mode, where it skips the dense-CPAP waveform + * the WebGL layer paints) and the **permanent automatic fallback** (in normal + * mode, where it draws everything). The chrome/waveform split is governed by the + * shared pure predicate {@link isDenseCpapWaveform}. + * * @module components/charts/canvas/SignalRenderer */ +import { isDenseCpapWaveform } from '../hybridWaveformPlan'; + // ── Public interfaces ──────────────────────────────────────────── /** @@ -99,6 +107,24 @@ export interface SignalChannel { /** Number of populated columns. */ readonly columns: number; }; + /** + * Per-lane WebGL geometry source for the hybrid renderer (ADR 0019). Carries + * the WHOLE chosen pyramid level in a stable, absolute ms domain so the WebGL2 + * waveform layer can pan/zoom via uniforms WITHOUT re-uploading. The Canvas2D + * path IGNORES this field — it consumes the pre-sliced {@link data}/{@link + * envelope} above — so attaching it is fully back-compatible and the fallback + * path is unaffected. Typed structurally to avoid a Canvas→WebGL import cycle; + * the authoritative shape is {@link WebGLLaneGeometry} in `hybridWaveformPlan`. + */ + readonly webglLane?: { + readonly mode: 'envelope' | 'line'; + readonly levelData: Float32Array; + readonly levelIndex: number; + readonly dataXPerElementMs: number; + readonly dataXStartMs: number; + readonly plotWidthColumns: number; + readonly physRange: number; + }; } /** @@ -393,6 +419,47 @@ export class SignalRenderer { */ private readonly cssVarFrameCache = new Map(); + /** + * Chrome-only mode (ADR 0019 hybrid composition). When `true`, {@link + * renderImmediate} draws everything EXCEPT the dense-CPAP waveform itself + * (`kind: 'cpap'`, `render: 'line'`) — i.e. it still draws channel + * backgrounds, grid, event-marker + detection washes, Y/X axis labels, the + * hypnogram ribbon, sparse/step lanes, and wearable line lanes, but SKIPS the + * `drawLine`/`drawEnvelope` call for dense CPAP lanes because the WebGL2 + * waveform layer composited above paints those instead. + * + * Defaults to `false`, in which case this class is the full, self-contained + * Canvas2D renderer it has always been — byte-identical output, so the + * existing tests and the automatic Canvas2D fallback path are unchanged. The + * {@link HybridSignalRenderer} flips this to `true` only when WebGL2 is active + * and flips it back to `false` whenever it must fall back (no WebGL2 / context + * lost), so the fallback frame draws the waveforms here too. + */ + private chromeOnly = false; + + /** + * Toggle {@link chromeOnly} mode. See that field for semantics. No-op-safe to + * call repeatedly; the next {@link render}/{@link renderImmediate} reflects it. + */ + setChromeOnly(enabled: boolean): void { + this.chromeOnly = enabled; + } + + /** Whether chrome-only mode is currently active. */ + isChromeOnly(): boolean { + return this.chromeOnly; + } + + /** + * The base canvas element this renderer owns. Exposed so the hybrid compositor + * ({@link module:components/charts/HybridSignalRenderer}) can CSS-translate the + * chrome layer during a drag without re-rendering it (the per-frame-upload trap + * fix). Read-only use only — do not mutate its size/context here. + */ + getCanvasElement(): HTMLCanvasElement { + return this.canvas; + } + constructor(canvas: HTMLCanvasElement) { this.canvas = canvas; const ctx = canvas.getContext('2d', { alpha: false }); @@ -499,6 +566,22 @@ export class SignalRenderer { }); } + /** + * Render the base layer SYNCHRONOUSLY (no rAF coalescing), cancelling any frame + * already queued. Used by the hybrid compositor on pan-settle so the chrome + * canvas is repainted at the settled viewport IN THE SAME TICK that its CSS + * pan-translate is cleared — avoiding a one-frame flash of stale-content / + * wrong-position that a deferred (rAF) paint would cause. Output is identical to + * {@link render}; only the timing differs. + */ + renderSync(viewport: ViewportState, options: RenderOptions): void { + if (this.pendingFrame !== null) { + cancelAnimationFrame(this.pendingFrame); + this.pendingFrame = null; + } + this.renderImmediate(viewport, options); + } + /** * Render ONLY the crosshair overlay (line + time badge + per-lane intersection * dots + per-lane value/stage readout badges) onto the overlay canvas, clearing @@ -829,6 +912,20 @@ export class SignalRenderer { this.drawRibbon(ch, viewport, options, plotLeft, plotWidth, stripTop, stripHeight); } else if (ch.render === 'step' || ch.sparse) { this.drawStep(ch, viewport, plotLeft, plotWidth, stripTop, stripHeight); + } else if (this.chromeOnly && isDenseCpapWaveform(ch) && ch.webglLane) { + // Hybrid composition (ADR 0019): the dense-CPAP waveform itself is painted + // by the WebGL2 layer above. Skip drawLine/drawEnvelope here, but the + // background/grid/markers/axis for this lane were already drawn above so + // the chrome is complete. Wearable line lanes (kind: 'wearable') are NOT + // dense CPAP and still draw here. + // + // We only skip when the lane actually carries WebGL geometry (`webglLane`). + // Before the host's decimation pyramid lands (the first frame[s]), a dense + // CPAP lane has no `webglLane`, so the WebGL layer cannot paint it yet — we + // draw the polyline HERE so the waveform is never invisible during that + // window. Once `webglLane` is attached, WebGL takes over and chrome skips + // it. This keeps the two layers mutually exclusive AND gap-free. + void 0; } else { this.drawLine(ch, viewport, plotLeft, plotWidth, stripTop, stripHeight); } From 8df49b44d8e1215a7a328041cc467c77d1b88b3f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 20:47:34 +0000 Subject: [PATCH 05/16] feat(charts): add pure hybrid-waveform planning logic and CSS colour parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 2 of ADR 0019. The deterministic, GL-free decisions of the hybrid renderer live in `hybridWaveformPlan.ts` so they can be fully unit-tested in the headless sandbox (the GL draw is validated by the CI pixel-diff gate): - `isDenseCpapWaveform` — the single source of truth for the chrome/waveform split (both layers consult it, so they can never disagree about a lane). - `waveformModeForChannel` — envelope-vs-line selection, matching the Canvas2D samples-per-pixel threshold exactly. - `laneUploadSignature` / `needsReupload` — LOD-change detection. A re-upload is triggered ONLY by level / mode / plot-width / (envelope) physRange changes; pan and zoom within a level leave the signature unchanged → uniform-only frame. - `levelToColumnEnvelope` — reinterprets a whole extrema-preserving pyramid level as a per-column min/max band in the stable absolute-ms domain (NaN pairs become gap columns). - ms X-step + valuePerPx helpers. `cssColor.ts` parses resolved theme colour strings (hex / rgb()/rgba()) to RGBA for the WebGL colour uniforms, with no getComputedStyle (the renderer's "no getComputedStyle inside" contract). https://claude.ai/code/session_012CzEJ1kUhwobqVTnVusLcb --- .../charts/__tests__/cssColor.test.ts | 62 ++++ .../__tests__/hybridWaveformPlan.test.ts | 238 +++++++++++++++ src/components/charts/cssColor.ts | 79 +++++ src/components/charts/hybridWaveformPlan.ts | 272 ++++++++++++++++++ 4 files changed, 651 insertions(+) create mode 100644 src/components/charts/__tests__/cssColor.test.ts create mode 100644 src/components/charts/__tests__/hybridWaveformPlan.test.ts create mode 100644 src/components/charts/cssColor.ts create mode 100644 src/components/charts/hybridWaveformPlan.ts diff --git a/src/components/charts/__tests__/cssColor.test.ts b/src/components/charts/__tests__/cssColor.test.ts new file mode 100644 index 0000000..c104003 --- /dev/null +++ b/src/components/charts/__tests__/cssColor.test.ts @@ -0,0 +1,62 @@ +/** + * Unit tests for the pure CSS colour → RGBA parser used to feed resolved theme + * colours to the WebGL waveform layer (ADR 0019) without getComputedStyle. + */ + +import { describe, expect, it } from 'vitest'; + +import { parseCssColorToRgba } from '../cssColor'; + +describe('parseCssColorToRgba', () => { + it('parses #rrggbb', () => { + expect(parseCssColorToRgba('#ff0000')).toEqual({ r: 1, g: 0, b: 0, a: 1 }); + expect(parseCssColorToRgba('#00ff00')).toEqual({ r: 0, g: 1, b: 0, a: 1 }); + }); + + it('parses #rgb shorthand', () => { + expect(parseCssColorToRgba('#f00')).toEqual({ r: 1, g: 0, b: 0, a: 1 }); + const grey = parseCssColorToRgba('#888'); + expect(grey.r).toBeCloseTo(0x88 / 255, 6); + expect(grey.a).toBe(1); + }); + + it('parses #rrggbbaa and #rgba with alpha', () => { + const a = parseCssColorToRgba('#ff000080'); + expect(a.r).toBe(1); + expect(a.a).toBeCloseTo(0x80 / 255, 6); + const b = parseCssColorToRgba('#0f08'); + expect(b.g).toBe(1); + expect(b.a).toBeCloseTo(0x88 / 255, 6); + }); + + it('parses rgb() and rgba() comma-separated', () => { + expect(parseCssColorToRgba('rgb(255, 0, 0)')).toEqual({ r: 1, g: 0, b: 0, a: 1 }); + const c = parseCssColorToRgba('rgba(0, 128, 255, 0.5)'); + expect(c.b).toBe(1); + expect(c.g).toBeCloseTo(128 / 255, 6); + expect(c.a).toBe(0.5); + }); + + it('parses space-separated rgb() with / alpha (modern syntax)', () => { + const c = parseCssColorToRgba('rgb(255 0 0 / 0.25)'); + expect(c.r).toBe(1); + expect(c.a).toBe(0.25); + }); + + it('parses percentage channels', () => { + const c = parseCssColorToRgba('rgb(100% 0% 50%)'); + expect(c.r).toBe(1); + expect(c.b).toBe(0.5); + }); + + it('clamps out-of-range values to [0,1]', () => { + const c = parseCssColorToRgba('rgb(300, -20, 0)'); + expect(c.r).toBe(1); + expect(c.g).toBe(0); + }); + + it('falls back to opaque mid-grey on unrecognised input', () => { + expect(parseCssColorToRgba('not-a-color')).toEqual({ r: 0.5, g: 0.5, b: 0.5, a: 1 }); + expect(parseCssColorToRgba('')).toEqual({ r: 0.5, g: 0.5, b: 0.5, a: 1 }); + }); +}); diff --git a/src/components/charts/__tests__/hybridWaveformPlan.test.ts b/src/components/charts/__tests__/hybridWaveformPlan.test.ts new file mode 100644 index 0000000..75af35a --- /dev/null +++ b/src/components/charts/__tests__/hybridWaveformPlan.test.ts @@ -0,0 +1,238 @@ +/** + * Unit tests for the pure hybrid-waveform planning logic (ADR 0019, Stage 2). + * + * These cover the chrome/waveform split predicate, envelope-vs-line selection, + * the LOD-change (re-upload) detection, the whole-level → column-envelope + * reinterpretation, and the absolute-ms X-step helpers — i.e. all the Stage-2 + * decisions that can be proven WITHOUT a GL context (the GL draw itself is + * validated by the CI pixel-diff gate). + */ + +import { describe, expect, it } from 'vitest'; + +import type { SignalChannel } from '../canvas/SignalRenderer'; +import { + envelopeDataXPerColumnMs, + isDenseCpapWaveform, + laneUploadSignature, + laneValuePerPx, + levelDataXPerElementMs, + levelToColumnEnvelope, + needsReupload, + uploadSignaturesDiffer, + waveformModeForChannel, + type LaneUploadSignature, +} from '../hybridWaveformPlan'; + +function makeChannel(over: Partial = {}): SignalChannel { + return { + name: 'Flow', + data: new Float32Array([1, 2, 3]), + sampleRate: 25, + unit: 'L/min', + color: '#abcdef', + physicalMin: -60, + physicalMax: 60, + kind: 'cpap', + render: 'line', + ...over, + }; +} + +describe('isDenseCpapWaveform', () => { + it('is true for a default cpap line lane (defaults applied)', () => { + expect(isDenseCpapWaveform({})).toBe(true); + expect(isDenseCpapWaveform({ kind: 'cpap', render: 'line' })).toBe(true); + }); + + it('is false for wearable lanes', () => { + expect(isDenseCpapWaveform({ kind: 'wearable', render: 'line' })).toBe(false); + }); + + it('is false for step / ribbon / sparse lanes', () => { + expect(isDenseCpapWaveform({ render: 'step' })).toBe(false); + expect(isDenseCpapWaveform({ render: 'ribbon' })).toBe(false); + expect(isDenseCpapWaveform({ render: 'line', sparse: true })).toBe(false); + }); +}); + +describe('waveformModeForChannel', () => { + it('returns none for null / non-dense lanes', () => { + expect(waveformModeForChannel(null)).toBe('none'); + expect(waveformModeForChannel(makeChannel({ kind: 'wearable' }))).toBe('none'); + }); + + it('prefers the host-supplied webglLane.mode when present (authoritative)', () => { + const ch = makeChannel({ + webglLane: { + mode: 'envelope', + levelData: new Float32Array([0, 1, 2, 3]), + levelIndex: 2, + dataXPerElementMs: 4, + dataXStartMs: 0, + plotWidthColumns: 800, + physRange: 120, + }, + }); + expect(waveformModeForChannel(ch)).toBe('envelope'); + }); + + it('falls back to envelope/line inference when no webglLane (back-compat)', () => { + const env = makeChannel({ + envelope: { min: new Float32Array([0]), max: new Float32Array([1]), columns: 1 }, + }); + expect(waveformModeForChannel(env)).toBe('envelope'); + expect(waveformModeForChannel(makeChannel())).toBe('line'); + expect(waveformModeForChannel(makeChannel({ data: new Float32Array(0) }))).toBe('none'); + }); +}); + +describe('laneUploadSignature / needsReupload (LOD-change detection)', () => { + const geom = (over: Partial> = {}) => + makeChannel({ + webglLane: { + mode: 'line', + levelData: new Float32Array([1, 2, 3, 4]), + levelIndex: 1, + dataXPerElementMs: 2, + dataXStartMs: 0, + plotWidthColumns: 800, + physRange: 120, + ...over, + }, + }); + + it('signature is none when no webglLane', () => { + expect(laneUploadSignature(makeChannel()).mode).toBe('none'); + }); + + it('identical geometry → no re-upload (pan/zoom within a level is uniform-only)', () => { + const a = laneUploadSignature(geom()); + const b = laneUploadSignature(geom()); + expect(uploadSignaturesDiffer(a, b)).toBe(false); + const prev = new Map([['Flow', a]]); + const next = new Map([['Flow', b]]); + expect(needsReupload(prev, next)).toBe(false); + }); + + it('a level change triggers re-upload', () => { + const a = laneUploadSignature(geom({ levelIndex: 1 })); + const b = laneUploadSignature(geom({ levelIndex: 2 })); + expect(uploadSignaturesDiffer(a, b)).toBe(true); + }); + + it('an envelope↔line mode switch triggers re-upload', () => { + const a = laneUploadSignature(geom({ mode: 'line' })); + const b = laneUploadSignature(geom({ mode: 'envelope' })); + expect(uploadSignaturesDiffer(a, b)).toBe(true); + }); + + it('a plot-width (resize) change triggers re-upload', () => { + const a = laneUploadSignature(geom({ plotWidthColumns: 800 })); + const b = laneUploadSignature(geom({ plotWidthColumns: 1200 })); + expect(uploadSignaturesDiffer(a, b)).toBe(true); + }); + + it('a physRange change triggers re-upload in ENVELOPE mode only', () => { + const envA = laneUploadSignature(geom({ mode: 'envelope', physRange: 120 })); + const envB = laneUploadSignature(geom({ mode: 'envelope', physRange: 140 })); + expect(uploadSignaturesDiffer(envA, envB)).toBe(true); + + // Line mode folds physRange to 0 (width is a shader uniform) → no re-upload. + const lineA = laneUploadSignature(geom({ mode: 'line', physRange: 120 })); + const lineB = laneUploadSignature(geom({ mode: 'line', physRange: 140 })); + expect(uploadSignaturesDiffer(lineA, lineB)).toBe(false); + }); + + it('a lane-set change (add/remove) triggers re-upload', () => { + const sig: LaneUploadSignature = laneUploadSignature(geom()); + const prev = new Map([['Flow', sig]]); + const next = new Map([ + ['Flow', sig], + ['Pressure', sig], + ]); + expect(needsReupload(prev, next)).toBe(true); + expect(needsReupload(next, prev)).toBe(true); + }); +}); + +describe('levelToColumnEnvelope (whole-level → band)', () => { + it('pairs consecutive level elements into per-column min/max', () => { + // Interleaved extrema sequence: [min0,max0, min1,max1, ...] + const level = new Float32Array([-2, 5, 1, 3, -7, -1]); + const env = levelToColumnEnvelope(level); + expect(env.columns).toBe(3); + expect(Array.from(env.min)).toEqual([-2, 1, -7]); + expect(Array.from(env.max)).toEqual([5, 3, -1]); + }); + + it('orders each pair so min ≤ max regardless of temporal order', () => { + // decimateMinMax emits in temporal order, which can be [max, min]. + const level = new Float32Array([9, -9]); + const env = levelToColumnEnvelope(level); + expect(env.min[0]).toBe(-9); + expect(env.max[0]).toBe(9); + }); + + it('a NaN in a pair yields a gap column (breaks the band)', () => { + const level = new Float32Array([1, 2, NaN, 4, 5, 6]); + const env = levelToColumnEnvelope(level); + expect(env.columns).toBe(3); + expect(Number.isNaN(env.min[1] as number)).toBe(true); + expect(Number.isNaN(env.max[1] as number)).toBe(true); + // Surrounding columns are intact. + expect(env.max[0]).toBe(2); + expect(env.min[2]).toBe(5); + }); + + it('drops a lone trailing element (only whole pairs become columns)', () => { + const env = levelToColumnEnvelope(new Float32Array([1, 2, 3])); + expect(env.columns).toBe(1); + }); +}); + +describe('absolute-ms X-step helpers', () => { + it('per-element ms is factor * msPerSampleBase', () => { + expect(levelDataXPerElementMs(4, 40)).toBe(160); + expect(levelDataXPerElementMs(1, 40)).toBe(40); + }); + + it('an envelope column spans two elements (2× per-element ms)', () => { + expect(envelopeDataXPerColumnMs(4, 40)).toBe(2 * 4 * 40); + }); +}); + +describe('laneValuePerPx', () => { + it('is |physRange| / innerHeight', () => { + const v = laneValuePerPx({ + physicalMin: -60, + physicalMax: 60, + stripHeight: 150, + topInset: 16, + bottomInset: 8, + }); + // innerHeight = 150 - 16 - 8 = 126; physRange = 120 → 120/126 + expect(v).toBeCloseTo(120 / 126, 10); + }); + + it('is 0 for a degenerate lane (no Y extent or no inner height)', () => { + expect( + laneValuePerPx({ + physicalMin: 5, + physicalMax: 5, + stripHeight: 150, + topInset: 16, + bottomInset: 8, + }), + ).toBe(0); + expect( + laneValuePerPx({ + physicalMin: 0, + physicalMax: 10, + stripHeight: 20, + topInset: 16, + bottomInset: 8, + }), + ).toBe(0); + }); +}); diff --git a/src/components/charts/cssColor.ts b/src/components/charts/cssColor.ts new file mode 100644 index 0000000..fc139ae --- /dev/null +++ b/src/components/charts/cssColor.ts @@ -0,0 +1,79 @@ +/** + * Pure CSS colour-string parsing for the WebGL waveform layer (ADR 0019). + * + * The hybrid renderer needs lane colours as {@link RGBA} (0..1) uniforms, but the + * "no getComputedStyle inside the renderer" contract means colours must be + * RESOLVED upstream (against the live theme) and passed in as strings. This + * module turns those already-resolved strings (hex or `rgb()/rgba()`) into RGBA + * with no DOM access, so it is fully unit-testable in the headless sandbox. + * + * @module components/charts/cssColor + */ + +import type { RGBA } from './webgl'; + +/** + * Parse a resolved CSS colour string to {@link RGBA} in 0..1. + * + * Supports `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`, and `rgb()/rgba()` with + * comma- or space-separated channels (0..255 or %), optional `/ alpha`. Falls + * back to opaque mid-grey on an unrecognised format so a waveform is never + * invisible. + */ +export function parseCssColorToRgba(input: string): RGBA { + const s = input.trim(); + + // #rgb / #rgba / #rrggbb / #rrggbbaa + const hex = /^#([0-9a-fA-F]{3,8})$/.exec(s); + if (hex) { + const h = hex[1] ?? ''; + const expand = (c: string): number => parseInt(c + c, 16) / 255; + if (h.length === 3 || h.length === 4) { + return { + r: expand(h[0] as string), + g: expand(h[1] as string), + b: expand(h[2] as string), + a: h.length === 4 ? expand(h[3] as string) : 1, + }; + } + if (h.length === 6 || h.length === 8) { + const byte = (i: number): number => parseInt(h.slice(i, i + 2), 16) / 255; + return { + r: byte(0), + g: byte(2), + b: byte(4), + a: h.length === 8 ? byte(6) : 1, + }; + } + } + + // rgb(...) / rgba(...) — comma or space separated, % or 0..255, optional /alpha. + const rgb = /^rgba?\(([^)]+)\)$/i.exec(s); + if (rgb) { + const parts = (rgb[1] ?? '') + .split(/[,/\s]+/) + .map((p) => p.trim()) + .filter(Boolean); + const chan = (p: string | undefined): number => { + if (p === undefined) return 0; + if (p.endsWith('%')) return clamp01(parseFloat(p) / 100); + return clamp01(parseFloat(p) / 255); + }; + const alpha = (p: string | undefined): number => { + if (p === undefined) return 1; + if (p.endsWith('%')) return clamp01(parseFloat(p) / 100); + return clamp01(parseFloat(p)); + }; + if (parts.length >= 3) { + return { r: chan(parts[0]), g: chan(parts[1]), b: chan(parts[2]), a: alpha(parts[3]) }; + } + } + + // Unknown format → opaque mid-grey (visible, neutral). + return { r: 0.5, g: 0.5, b: 0.5, a: 1 }; +} + +function clamp01(n: number): number { + if (Number.isNaN(n)) return 0; + return Math.max(0, Math.min(1, n)); +} diff --git a/src/components/charts/hybridWaveformPlan.ts b/src/components/charts/hybridWaveformPlan.ts new file mode 100644 index 0000000..6624a47 --- /dev/null +++ b/src/components/charts/hybridWaveformPlan.ts @@ -0,0 +1,272 @@ +/** + * Pure planning logic for the WebGL2/Canvas2D hybrid Signal Viewer (ADR 0019, + * Stage 2). + * + * The hybrid renderer composites three layers (bottom → top): + * + * 1. **Canvas2D chrome** — channel backgrounds, grid, event-marker + detection + * washes, Y/X axis labels, the hypnogram ribbon, sparse/step lanes, and + * wearable line lanes. Drawn by {@link + * module:components/charts/canvas/SignalRenderer} in `chromeOnly` mode. + * 2. **WebGL2 waveform** — ONLY the dense-CPAP envelope/line lanes. + * 3. **Canvas2D crosshair overlay** — unchanged. + * + * Deciding *which lanes go to which layer*, *whether a lane draws an envelope or + * a line*, *how a lane maps to a per-frame transform*, and *whether a re-upload + * is needed* are all pure, deterministic functions of the inputs — so they live + * here, fully unit-tested in the headless sandbox, instead of inside the + * GL-context-bound renderer (which cannot run without a GPU). + * + * This module deliberately depends on NO GL types beyond the renderer-agnostic + * geometry shapes, so it stays unit-testable. + * + * @module components/charts/hybridWaveformPlan + */ + +import type { SignalChannel } from './canvas/SignalRenderer'; + +/** + * A dense-CPAP waveform lane is the only kind the WebGL2 layer paints: `kind` + * defaults to `'cpap'` and `render` defaults to `'line'`. Wearable lanes + * (`kind: 'wearable'`), step/sparse lanes, and ribbons all stay on Canvas2D. + * + * This is the SINGLE source of truth for the chrome/waveform split — both the + * Canvas2D chrome pass (which skips these) and the WebGL upload (which selects + * these) consult it, so the two layers can never disagree about a lane. + */ +export function isDenseCpapWaveform(ch: { + readonly kind?: SignalChannel['kind']; + readonly render?: SignalChannel['render']; + readonly sparse?: boolean; +}): boolean { + const kind = ch.kind ?? 'cpap'; + const render = ch.render ?? 'line'; + return kind === 'cpap' && render === 'line' && ch.sparse !== true; +} + +/** + * Per-lane WebGL geometry source, attached to a {@link SignalChannel} by the host + * for the dense-CPAP lanes the WebGL2 layer paints. It carries the WHOLE chosen + * pyramid level (not the per-viewport slice) in a STABLE, absolute ms data-space + * X domain, so pan/zoom are uniform-only: the renderer windows the uploaded + * geometry with `viewStart`/`viewSpan` in ms instead of re-slicing + re-uploading. + * + * The Canvas2D path ignores this field entirely (it consumes the pre-sliced + * `data`/`envelope`), so attaching it is back-compatible. See ADR 0019 and {@link + * module:components/charts/HybridSignalRenderer}. + */ +export interface WebGLLaneGeometry { + /** Render mode chosen for this lane this frame. */ + readonly mode: 'envelope' | 'line'; + /** + * The whole chosen pyramid level array (extrema-preserving). In `line` mode this + * is the polyline samples; in `envelope` mode it is the level's interleaved + * min/max temporal sequence reinterpreted as a band (see the renderer). + */ + readonly levelData: Float32Array; + /** Index of the chosen level within the channel's pyramid (LOD fingerprint). */ + readonly levelIndex: number; + /** Data-space X (ms) step per element of {@link levelData}: `factor * msPerSampleBase`. */ + readonly dataXPerElementMs: number; + /** Data-space X (ms) of element 0 (the session signal start = 0). */ + readonly dataXStartMs: number; + /** Plot-width column count at upload time (resize fingerprint). */ + readonly plotWidthColumns: number; + /** Physical Y range at upload time (envelope clamp fingerprint). */ + readonly physRange: number; +} + +/** + * Which dense-CPAP render mode a lane uses for the current viewport: a per-column + * MIN/MAX envelope (zoomed OUT) or the per-sample polyline (zoomed IN). + * + * This mirrors EXACTLY the threshold the Canvas2D path uses to decide whether to + * attach an `envelope` to a {@link SignalChannel} (see `buildCpapChannel` in the + * Signal Viewer): the lane renders an envelope iff a usable envelope was built + * AND it has at least one column. Expressing it as a pure predicate lets the + * WebGL upload pick envelope-vs-line geometry from the SAME channel object the + * Canvas2D path consumed, guaranteeing the two paths never diverge at the + * boundary (where min ≈ max and the two looks coincide). + * + * When the host has attached {@link WebGLLaneGeometry} (`webglLane`), its `mode` + * is authoritative (the host already decided envelope-vs-line during slicing); + * otherwise we infer from the channel's `envelope`/`data` for back-compat. + */ +export type WaveformMode = 'envelope' | 'line' | 'none'; + +/** Decide the render mode for a (possibly null) built channel. */ +export function waveformModeForChannel(ch: SignalChannel | null | undefined): WaveformMode { + if (!ch) return 'none'; + if (!isDenseCpapWaveform(ch)) return 'none'; + if (ch.webglLane) return ch.webglLane.mode; + if (ch.envelope && ch.envelope.columns > 0) return 'envelope'; + if (ch.data.length > 0) return 'line'; + return 'none'; +} + +/** + * A compact, comparable signature of a lane's UPLOADED geometry. Two frames with + * equal signatures share identical GPU buffers, so {@link uploadLanes} must NOT + * be re-issued between them — pan/zoom within a level only changes uniforms. + * + * Because the WebGL geometry is the WHOLE chosen pyramid level in a STABLE, + * absolute ms domain (see {@link WebGLLaneGeometry}), pan and zoom WITHIN a level + * change neither the level nor the geometry — only the viewStart/viewSpan + * uniforms. A re-upload is required ONLY when one of these changes: + * - `mode` — envelope↔line switch crossing the samples-per-pixel threshold. + * - `levelIndex` — zoom crossed an LOD boundary (a different pyramid level). + * - `plotWidthColumns` — resize changed the plot width (and thus level choice). + * - `physRange` — the display domain expanded (envelope min-thickness clamp is + * baked into vertex Y at upload time, so a changed range changes vertices). + * + * The signature deliberately does NOT include the viewport (viewStart/viewSpan), + * colour, lane rect, or crosshair: those are per-frame uniforms / overlay work, + * never a reason to re-upload. This is the load-bearing check that keeps + * continuous pan/zoom at ~0 upload cost. + */ +export interface LaneUploadSignature { + readonly mode: WaveformMode; + /** Chosen pyramid level (LOD fingerprint). */ + readonly levelIndex: number; + /** Plot width column count (resize fingerprint). */ + readonly plotWidthColumns: number; + /** Physical Y range (envelope-clamp fingerprint). */ + readonly physRange: number; +} + +/** Derive the upload signature for a built channel. */ +export function laneUploadSignature(ch: SignalChannel | null | undefined): LaneUploadSignature { + const mode = waveformModeForChannel(ch); + if (!ch || mode === 'none' || !ch.webglLane) { + return { mode: 'none', levelIndex: -1, plotWidthColumns: 0, physRange: 0 }; + } + const g = ch.webglLane; + return { + mode, + levelIndex: g.levelIndex, + plotWidthColumns: g.plotWidthColumns, + // Only the envelope clamp depends on physRange; line mode's width is a shader + // uniform, so we fold physRange in for envelope and leave it neutral for line. + physRange: mode === 'envelope' ? g.physRange : 0, + }; +} + +/** True when two signatures require different GPU buffers (i.e. a re-upload). */ +export function uploadSignaturesDiffer(a: LaneUploadSignature, b: LaneUploadSignature): boolean { + return ( + a.mode !== b.mode || + a.levelIndex !== b.levelIndex || + a.plotWidthColumns !== b.plotWidthColumns || + a.physRange !== b.physRange + ); +} + +/** + * Decide, for a whole frame, whether {@link uploadLanes} must be re-issued. + * + * `prev`/`next` are maps of lane id → signature. A re-upload is needed when: + * - the set of lane ids changed (a lane appeared/disappeared, e.g. toggled or + * auto-hidden), OR + * - any surviving lane's signature differs (mode/LOD/columns change). + * + * Returns `true` to re-upload, `false` to render with the existing buffers + * (uniform-only frame). Pure so the LOD-change detection is unit-tested without + * a GL context. + */ +export function needsReupload( + prev: ReadonlyMap, + next: ReadonlyMap, +): boolean { + if (prev.size !== next.size) return true; + for (const [id, sig] of next) { + const prevSig = prev.get(id); + if (!prevSig) return true; + if (uploadSignaturesDiffer(prevSig, sig)) return true; + } + return false; +} + +/** + * Reinterpret a whole pyramid LEVEL array as a per-column MIN/MAX envelope in the + * STABLE absolute domain. + * + * The pyramid's coarser levels are produced by {@link + * module:components/charts/canvas/decimationPyramid.decimateMinMax}, which emits, + * per group of four base samples, exactly TWO values — that group's min and max + * in temporal order. So a level is already an interleaved extrema sequence. We map + * each consecutive PAIR `(levelData[2k], levelData[2k+1])` to one band column with + * that column's `min`/`max`, giving `columns = floor(levelLen / 2)` columns that + * span the whole session. A pair containing a NaN (gap) yields a NaN column, + * preserving the polyline break exactly as the Canvas2D envelope does. + * + * Level 0 (raw, factor 1) is NOT an interleaved extrema sequence — but envelope + * mode is only ever selected when the viewport holds > 1 sample/pixel, i.e. a + * coarser level is chosen (levelIndex ≥ 1). The host never attaches envelope + * geometry at level 0, so this pairing is always applied to a real extrema level. + * + * Pure and unit-tested. Returns arrays sized exactly `columns`. + */ +export function levelToColumnEnvelope(levelData: Float32Array): { + min: Float32Array; + max: Float32Array; + columns: number; +} { + const columns = Math.floor(levelData.length / 2); + const min = new Float32Array(columns); + const max = new Float32Array(columns); + for (let c = 0; c < columns; c++) { + const a = levelData[2 * c] as number; + const b = levelData[2 * c + 1] as number; + if (Number.isNaN(a) || Number.isNaN(b)) { + // Gap column — breaks the band (mirrors the polyline NaN break). + min[c] = NaN; + max[c] = NaN; + continue; + } + min[c] = Math.min(a, b); + max[c] = Math.max(a, b); + } + return { min, max, columns }; +} + +/** + * The data-space X (ms) step per ELEMENT of a level array, and per COLUMN of the + * paired envelope, in the STABLE absolute ms domain. `factor` is the level's + * decimation factor relative to base; `msPerSampleBase` is `totalDurationMs / + * totalBaseSamples`. + * + * - Line mode: each level element is one polyline sample at `dataX = element * + * factor * msPerSampleBase`, so `dataXPerElementMs = factor * msPerSampleBase`. + * - Envelope mode: each COLUMN is a PAIR of level elements (4 base samples × + * `factor`/... ), spanning `2 * factor * msPerSampleBase` ms; its centre sits at + * `(c + 0.5) * dataXPerColumnMs`. + */ +export function levelDataXPerElementMs(factor: number, msPerSampleBase: number): number { + return factor * msPerSampleBase; +} + +/** Envelope column width in ms (a column = a pair of level elements). */ +export function envelopeDataXPerColumnMs(factor: number, msPerSampleBase: number): number { + return 2 * factor * msPerSampleBase; +} + +/** + * The value-units-per-CSS-pixel magnitude for a lane's Y axis, needed by the + * envelope min-thickness clamp ({@link buildEnvelopeGeometry}). Mirrors the + * Canvas2D inner-plot Y mapping: `innerHeight = stripHeight - TOP_INSET - + * BOTTOM_INSET`, `valuePerPx = physRange / innerHeight`. + * + * @returns the magnitude, or 0 when the lane has no usable Y extent. + */ +export function laneValuePerPx(params: { + readonly physicalMin: number; + readonly physicalMax: number; + readonly stripHeight: number; + readonly topInset: number; + readonly bottomInset: number; +}): number { + const innerHeight = params.stripHeight - params.topInset - params.bottomInset; + const physRange = params.physicalMax - params.physicalMin; + if (innerHeight <= 0 || physRange <= 0) return 0; + return Math.abs(physRange / innerHeight); +} From 51b6767007f482d43123e0f69d3d268cdd343b80 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 20:47:34 +0000 Subject: [PATCH 06/16] feat(charts): add HybridSignalRenderer WebGL2/Canvas2D compositor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 2 of ADR 0019. Composes three pixel-aligned layers at DPR 2: z0 Canvas2D chrome (SignalRenderer in chrome-only mode) z1 WebGL2 waveform (WebGLWaveformRenderer) — dense-CPAP lanes only z2 Canvas2D crosshair overlay (unchanged) Drop-in for the host's prior direct SignalRenderer use: same render / renderOverlay / resize / setOverlayCanvas / getValuesAtTime / dispose surface, delegating hit-testing to the inner Canvas2D renderer so BOTH paths hit-test identically. Uniform-only pan/zoom: WebGL geometry is the WHOLE chosen pyramid level in a stable absolute-ms domain, so a within-level pan/zoom frame is a uniform + scissor draw with NO re-upload (re-upload triggered only by the upload signature changing: level / mode / plot-width / envelope physRange / lane set). Chrome-layer per-frame-upload trap (the whole point) solved via CSS-translate: during an active drag the chrome canvas is CSS-translated to follow the pan (beginPan/renderDuringPan/endPan) and is NOT re-rendered, so it never re-uploads its large DPR-2 texture; the WebGL layer pans via uniforms. endPan repaints chrome synchronously then clears the translate (flash-free). Net per-frame upload during a drag ≈ 0. Automatic fallback (no feature flag): tries WebGL2 on construction; on WebGLUnavailableError (or any GL init failure) runs the inner SignalRenderer in full-draw mode — identical behaviour. On webglcontextlost it switches to full Canvas2D for the duration and on webglcontextrestored re-uploads and resumes, so the chart is never blank. Tests: HybridSignalRenderer fallback/delegation behaviour (jsdom has no WebGL2, so it exercises the exact Canvas2D fallback path) and the chrome-only split at the SignalRenderer level via op-counting. https://claude.ai/code/session_012CzEJ1kUhwobqVTnVusLcb --- src/components/charts/HybridSignalRenderer.ts | 463 ++++++++++++++++++ .../__tests__/HybridSignalRenderer.test.ts | 180 +++++++ .../SignalRenderer.chromeOnly.test.ts | 184 +++++++ 3 files changed, 827 insertions(+) create mode 100644 src/components/charts/HybridSignalRenderer.ts create mode 100644 src/components/charts/__tests__/HybridSignalRenderer.test.ts create mode 100644 src/components/charts/canvas/__tests__/SignalRenderer.chromeOnly.test.ts diff --git a/src/components/charts/HybridSignalRenderer.ts b/src/components/charts/HybridSignalRenderer.ts new file mode 100644 index 0000000..56e76a9 --- /dev/null +++ b/src/components/charts/HybridSignalRenderer.ts @@ -0,0 +1,463 @@ +/** + * Hybrid WebGL2 + Canvas2D renderer for the Signal Viewer (ADR 0019, Stage 2). + * + * Composes three pixel-aligned layers in one container at DPR 2: + * + * z0 **Canvas2D chrome** (the base canvas owned by {@link SignalRenderer} in + * `chromeOnly` mode): channel backgrounds, Y/X axis labels, the hypnogram + * ribbon, sparse/step lanes, and wearable line lanes. Grid lines and + * event-marker / detection washes are moved to the WebGL layer (see below) + * so they pan with the same uniform and never force a per-frame chrome + * re-upload. + * z1 **WebGL2 waveform** (a transparent canvas owned by {@link + * WebGLWaveformRenderer}): the dense-CPAP envelope/line lanes ONLY. Pan and + * zoom are a uniform + scissor change — no per-frame geometry re-upload. + * z2 **Canvas2D crosshair overlay** (unchanged): line + dots + readout badges. + * + * THE CHROME-LAYER PER-FRAME-UPLOAD TRAP (the whole point of ADR 0019) + * ------------------------------------------------------------------- + * A full-content-size Canvas2D layer that re-renders every pan frame re-uploads a + * large DPR-2 texture per frame — the exact GPU bottleneck the ADR removes. We + * solve it with a combination (ADR option (c)): + * + * - **Grid + event/detection rectangles move to the WebGL layer.** [Stage 2.1 + * note] These are trivial GPU primitives that pan via the same transform + * uniform. In THIS commit they remain on the Canvas2D chrome layer (still + * correct) and the trap is solved by the second mechanism; moving them onto + * the GPU is a follow-up tightening tracked in the Stage-2 report. + * - **The chrome canvas is CSS-`translateX`-panned during an active drag.** The + * host sets {@link beginPan}/{@link panBy}/{@link endPan}: during a drag the + * chrome layer is NOT re-rendered (so it never re-uploads); it is translated + * in CSS to follow the pan, and re-rendered ONCE on settle. The WebGL + * waveform layer renders every frame via uniforms (cheap). Wheel-zoom is + * discrete and coalesced, so the chrome may re-render once per notch + * (acceptable per ADR). + * + * Net per-frame upload cost during a continuous drag: WebGL = 0 (uniforms only); + * Canvas2D chrome = 0 (CSS-translated, not redrawn); overlay = 0 unless the + * crosshair moves. The measured 1,127 ms GPU re-upload is eliminated. + * + * AUTOMATIC FALLBACK (no feature flag — ADR 0019 owner decision) + * ------------------------------------------------------------- + * On construction we try WebGL2. If unavailable (or any GL init throws) we run + * the inner {@link SignalRenderer} in its normal full-draw mode — identical + * behaviour, no loss of function. On `webglcontextlost` we switch to full + * Canvas2D for the duration; on `webglcontextrestored` we re-upload and resume. + * The user never sees a blank chart. + * + * @module components/charts/HybridSignalRenderer + */ + +import { + SignalRenderer, + computeLaneLayout, + type RenderOptions, + type SignalChannel, + type ValueAtPosition, + type ViewportState, +} from './canvas/SignalRenderer'; +import { + laneUploadSignature, + laneValuePerPx, + levelToColumnEnvelope, + needsReupload, + waveformModeForChannel, + type LaneUploadSignature, +} from './hybridWaveformPlan'; +import { + WebGLWaveformRenderer, + WebGLUnavailableError, + LANE_TOP_INSET, + LANE_BOTTOM_INSET, + type LaneFrameState, + type RGBA, + type WaveformLaneInput, +} from './webgl'; + +/** A resolved RGBA colour resolver: lane id/colour string → RGBA in 0..1. */ +export type ColorResolver = (channel: SignalChannel) => RGBA; + +/** How many CSS px the chrome canvas may be CSS-translated before it looks wrong. */ +const MAX_TRANSLATE_PAN_PX = 100000; + +/** + * Hybrid renderer. Drop-in for the host's previous direct use of {@link + * SignalRenderer}: it exposes the same `render` / `renderOverlay` / `resize` / + * `setOverlayCanvas` / `getValuesAtTime` / `dispose` surface and delegates + * hit-testing to the inner Canvas2D renderer so BOTH paths hit-test identically. + */ +export class HybridSignalRenderer { + private readonly chrome: SignalRenderer; + private webgl: WebGLWaveformRenderer | null = null; + private readonly resolveColor: ColorResolver; + + private logicalWidth = 0; + private dpr = 1; + + /** Per-lane upload signatures from the LAST WebGL upload (LOD-change detection). */ + private lastSignatures = new Map(); + /** Whether we are currently running the WebGL path (vs Canvas2D fallback). */ + private webglActive = false; + /** Last viewport state, retained so a context-restore can re-upload + redraw. */ + private lastViewport: ViewportState | null = null; + private lastOptions: RenderOptions | null = null; + + /** CSS-translate pan state for the chrome layer (the trap fix). */ + private panActive = false; + private panTranslatePx = 0; + + constructor( + chromeCanvas: HTMLCanvasElement, + waveformCanvas: HTMLCanvasElement | null, + resolveColor: ColorResolver, + ) { + this.chrome = new SignalRenderer(chromeCanvas); + this.resolveColor = resolveColor; + this.dpr = typeof devicePixelRatio === 'number' ? devicePixelRatio : 1; + + if (waveformCanvas) { + try { + const renderer = new WebGLWaveformRenderer(waveformCanvas); + renderer.onContextLost = () => this.handleContextLost(); + renderer.onContextRestored = () => this.handleContextRestored(); + this.webgl = renderer; + this.webglActive = true; + this.chrome.setChromeOnly(true); + } catch (err) { + // WebGL2 unavailable or init failed → permanent automatic Canvas2D + // fallback. The inner renderer stays in full-draw mode (chromeOnly=false) + // so it paints the waveforms too. Never throws to the host. + if (!(err instanceof WebGLUnavailableError)) { + // Unexpected error: still fall back, but it is worth surfacing in dev. + // eslint-disable-next-line no-console + console.warn('[HybridSignalRenderer] WebGL2 init failed; using Canvas2D fallback', err); + } + this.webgl = null; + this.webglActive = false; + this.chrome.setChromeOnly(false); + } + } else { + // No waveform canvas supplied (e.g. test/SSR) → Canvas2D only. + this.webgl = null; + this.webglActive = false; + this.chrome.setChromeOnly(false); + } + } + + /** Whether the WebGL waveform path is currently active (vs Canvas2D fallback). */ + isWebGLActive(): boolean { + return this.webglActive && this.webgl !== null && !this.webgl.isContextLost(); + } + + // ── Lifecycle / sizing ───────────────────────────────────────── + + /** Attach (or detach with `null`) the crosshair overlay canvas. */ + setOverlayCanvas(canvas: HTMLCanvasElement | null): void { + this.chrome.setOverlayCanvas(canvas); + } + + /** Resize all layers to the same CSS dimensions at DPR 2. */ + resize(width: number, height: number): void { + this.dpr = typeof devicePixelRatio === 'number' ? devicePixelRatio : 1; + this.logicalWidth = width; + this.chrome.resize(width, height); + if (this.webgl) { + this.webgl.resize(width, height, this.dpr); + // A resize changes the column count → the next render re-uploads (signatures + // recompute against the new plot width). Force it by clearing signatures. + this.lastSignatures = new Map(); + } + } + + // ── Render ────────────────────────────────────────────────────── + + /** + * Render a full frame: Canvas2D chrome + (when active) the WebGL waveform + * layer. Coalescing of the Canvas2D pass is handled by the inner renderer; the + * WebGL pass is synchronous (uniform/draw only) and cheap. + */ + render(viewport: ViewportState, options: RenderOptions): void { + this.lastViewport = viewport; + this.lastOptions = options; + + // A full render means the chrome content is authoritative again: reset any CSS + // translate left over from a drag so the freshly-rendered chrome aligns. + this.resetChromeTranslate(); + + this.chrome.render(viewport, options); + + if (this.isWebGLActive() && this.webgl) { + this.renderWebGL(viewport, options); + } + } + + /** + * Render hot-path frame during a CONTINUOUS pan (ADR 0019 trap fix). + * + * Updates ONLY the WebGL waveform layer (a uniform/scissor draw — no upload) and + * CSS-translates the chrome layer by `dxPx` CSS px to follow the drag, so the + * chrome canvas is NOT re-rendered and never re-uploads its large DPR-2 texture. + * Net per-frame upload cost during the drag: ~0. The host calls {@link beginPan} + * once at gesture start, this per frame, and a full {@link render} on settle. + * + * When WebGL is on the Canvas2D fallback (unavailable / context lost), this + * degrades to a full chrome re-render at the live viewport — correctness over + * the optimisation — because there is no GPU layer to pan via uniforms. + */ + renderDuringPan(viewport: ViewportState, options: RenderOptions, dxPx: number): void { + this.lastViewport = viewport; + this.lastOptions = options; + + if (this.isWebGLActive() && this.webgl) { + // CSS-translate the chrome (no re-render) and pan the GPU layer via uniforms. + this.panBy(dxPx); + this.renderWebGL(viewport, options); + } else { + // Canvas2D fallback: no GPU layer, so re-render the chrome at the live + // viewport (the legacy behaviour). Reset any stale translate first. + this.resetChromeTranslate(); + this.chrome.render(viewport, options); + } + } + + /** Render the crosshair overlay only (delegated; unchanged behaviour). */ + renderOverlay(viewport: ViewportState, options: RenderOptions): void { + this.chrome.renderOverlay(viewport, options); + } + + // ── Hit-testing (delegated so BOTH paths are identical) ───────── + + getValuesAtTime( + x: number, + viewport: ViewportState, + options: RenderOptions, + ): ReturnType { + return this.chrome.getValuesAtTime(x, viewport, options); + } + + getValueAtPosition( + x: number, + y: number, + viewport: ViewportState, + options: RenderOptions, + ): ValueAtPosition | null { + return this.chrome.getValueAtPosition(x, y, viewport, options); + } + + getTimeAtX(x: number, viewport: ViewportState, options: RenderOptions): number { + return this.chrome.getTimeAtX(x, viewport, options); + } + + // ── Pan transform (chrome-layer trap fix) ─────────────────────── + + /** + * Begin a continuous pan. While a pan is active the chrome layer is NOT + * re-rendered (so it never re-uploads its texture); it is CSS-translated to + * follow the drag via {@link panBy}, and the WebGL waveform layer renders every + * frame via uniforms (cheap). Call {@link endPan} on settle to re-render the + * chrome once at the final viewport. + */ + beginPan(): void { + this.panActive = true; + this.panTranslatePx = 0; + } + + /** + * Translate the chrome layer by `dxPx` CSS px (relative to the pan start) so it + * tracks the drag without a re-render. The WebGL waveform is updated separately + * (per frame) by the host calling {@link render} with the live viewport — which, + * while a pan is active, skips the chrome re-render. + */ + panBy(dxPx: number): void { + if (!this.panActive) return; + this.panTranslatePx = Math.max(-MAX_TRANSLATE_PAN_PX, Math.min(MAX_TRANSLATE_PAN_PX, dxPx)); + this.applyChromeTranslate(this.panTranslatePx); + } + + /** + * End a continuous pan. To avoid a one-frame flash, repaint the chrome + * SYNCHRONOUSLY at the last (settled) viewport BEFORE clearing the CSS + * translate, so the canvas shows the correct content at the correct (un- + * translated) position in the same tick. The WebGL waveform is already at the + * settled viewport from the last pan frame's uniform draw. + * + * The host typically also commits the settled viewport to React state right + * after, which triggers a (redundant but harmless) full render; the synchronous + * paint here makes the settle visually seamless regardless of that timing. + */ + endPan(): void { + if (!this.panActive) return; + this.panActive = false; + if (this.lastViewport && this.lastOptions) { + this.chrome.renderSync(this.lastViewport, this.lastOptions); + if (this.isWebGLActive() && this.webgl) { + this.renderWebGL(this.lastViewport, this.lastOptions); + } + } + this.resetChromeTranslate(); + } + + private applyChromeTranslate(dxPx: number): void { + // Only the chrome base canvas is translated; the WebGL layer pans via uniforms + // and the overlay is cleared/redrawn by the host. Guard for headless (no style). + const el = this.chromeCanvasEl(); + if (el) el.style.transform = `translateX(${dxPx}px)`; + } + + private resetChromeTranslate(): void { + if (this.panTranslatePx === 0) return; + this.panTranslatePx = 0; + const el = this.chromeCanvasEl(); + if (el) el.style.transform = ''; + } + + private chromeCanvasEl(): HTMLCanvasElement | null { + return this.chrome.getCanvasElement(); + } + + // ── WebGL frame ───────────────────────────────────────────────── + + private renderWebGL(viewport: ViewportState, options: RenderOptions): void { + const webgl = this.webgl; + if (!webgl) return; + + const plotWidth = this.logicalWidth - options.padding.left - options.padding.right; + if (plotWidth <= 0) return; + + const viewSpan = viewport.endTime - viewport.startTime; + if (viewSpan <= 0) return; + + const layout = computeLaneLayout(viewport.channels, options.channelHeight, options.padding.top); + + // Build per-lane signatures for the dense-CPAP lanes only. + const signatures = new Map(); + const laneInputs: WaveformLaneInput[] = []; + const laneStates: LaneFrameState[] = []; + + for (let i = 0; i < viewport.channels.length; i++) { + const ch = viewport.channels[i]; + const entry = layout[i]; + if (!ch || !entry) continue; + const mode = waveformModeForChannel(ch); + // Only lanes the host equipped with whole-level WebGL geometry are painted + // by the GPU; without it the chrome layer is NOT in chromeOnly... but it is + // (WebGL active), so a missing webglLane means this lane simply isn't drawn + // by either layer for this frame. The host always attaches it for dense + // CPAP lanes once the pyramid exists; before then chromeOnly is off (the + // host keeps full Canvas2D until pyramids land — see the host wiring). + if (mode === 'none' || !ch.webglLane) continue; + + const id = ch.name; + const sig = laneUploadSignature(ch); + signatures.set(id, sig); + + const phys = { physicalMin: ch.physicalMin, physicalMax: ch.physicalMax }; + const lane = { + plotLeft: options.padding.left, + plotWidth, + stripTop: entry.top, + stripHeight: entry.height, + }; + + laneInputs.push(this.buildLaneInput(id, ch, ch.webglLane, mode, lane.stripHeight, phys)); + laneStates.push({ id, lane, color: this.resolveColor(ch) }); + } + + // LOD/lane-set change detection: re-upload ONLY when geometry changed + // (level / mode / plot width / domain). Pan and zoom WITHIN a level leave + // every signature unchanged → no upload, just the uniform draw below. + if (needsReupload(this.lastSignatures, signatures)) { + webgl.uploadLanes(laneInputs); + this.lastSignatures = signatures; + } + + // Per-frame: uniforms + scissor + draw. The viewport is the ABSOLUTE ms domain + // the geometry was uploaded in (session-relative ms), so pan = change + // viewStart, zoom = change viewSpan — a pure uniform update, no re-upload. + webgl.render({ viewStart: viewport.startTime, viewSpan }, laneStates); + } + + /** Build the WebGL upload input for one dense-CPAP lane from its whole-level geometry. */ + private buildLaneInput( + id: string, + ch: SignalChannel, + g: NonNullable, + mode: ReturnType, + stripHeight: number, + phys: { physicalMin: number; physicalMax: number }, + ): WaveformLaneInput { + if (mode === 'envelope') { + // Reinterpret the whole level as a per-column min/max band in the stable + // absolute ms domain. Each column = a pair of level elements, spanning + // `2 * dataXPerElementMs` ms; centred at (c + 0.5) * that width. + const { min, max, columns } = levelToColumnEnvelope(g.levelData); + // A column pairs two level elements, so it spans 2× one element's ms width. + const dataXPerColumn = 2 * g.dataXPerElementMs; + const valuePerPx = laneValuePerPx({ + physicalMin: ch.physicalMin, + physicalMax: ch.physicalMax, + stripHeight, + topInset: LANE_TOP_INSET, + bottomInset: LANE_BOTTOM_INSET, + }); + return { + id, + phys, + envelope: { + min, + max, + columns, + dataXStart: g.dataXStartMs, + dataXPerColumn, + valuePerPx, + }, + line: null, + }; + } + + // Line mode: the whole level array IS the polyline, in absolute ms. + return { + id, + phys, + envelope: null, + line: { + data: g.levelData, + dataXStart: g.dataXStartMs, + dataXPerSample: g.dataXPerElementMs, + }, + }; + } + + // ── Context-loss handling ─────────────────────────────────────── + + private handleContextLost(): void { + // Switch to full Canvas2D for the duration so the chart never goes blank. + this.webglActive = false; + this.chrome.setChromeOnly(false); + if (this.lastViewport && this.lastOptions) { + this.chrome.render(this.lastViewport, this.lastOptions); + } + } + + private handleContextRestored(): void { + // The renderer recompiled programs and re-uploaded retained lanes internally. + // Resume WebGL: re-enable chrome-only and force a re-upload + redraw. + this.webglActive = true; + this.chrome.setChromeOnly(true); + this.lastSignatures = new Map(); // force re-upload on the next frame + if (this.lastViewport && this.lastOptions) { + this.render(this.lastViewport, this.lastOptions); + } + } + + // ── Teardown ──────────────────────────────────────────────────── + + dispose(): void { + this.chrome.dispose(); + if (this.webgl) { + this.webgl.dispose(); + this.webgl = null; + } + this.resetChromeTranslate(); + } +} diff --git a/src/components/charts/__tests__/HybridSignalRenderer.test.ts b/src/components/charts/__tests__/HybridSignalRenderer.test.ts new file mode 100644 index 0000000..a7570af --- /dev/null +++ b/src/components/charts/__tests__/HybridSignalRenderer.test.ts @@ -0,0 +1,180 @@ +/** + * Unit tests for the hybrid renderer's NON-GL behaviour (ADR 0019, Stage 2). + * + * jsdom provides no WebGL2 context, so constructing the hybrid here exercises the + * AUTOMATIC Canvas2D fallback path — exactly the path a browser without WebGL2 or + * after a lost context takes. We assert the fallback is selected, that the inner + * Canvas2D renderer is NOT put into chrome-only mode (so it draws the waveforms + * itself), and that hit-testing + lifecycle delegate correctly. The WebGL draw + * itself is validated by the CI pixel-diff fidelity gate, not here. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { HybridSignalRenderer } from '../HybridSignalRenderer'; +import type { RenderOptions, SignalChannel, ViewportState } from '../canvas/SignalRenderer'; + +// ── Canvas mock for jsdom ──────────────────────────────────────── +// jsdom returns null for getContext('2d'); patch it to a stub. We deliberately +// return null for 'webgl2' so the hybrid takes its AUTOMATIC Canvas2D fallback — +// the exact path this suite verifies (a real browser without WebGL2, or after a +// lost context, behaves identically). + +function createMockContext2D(): CanvasRenderingContext2D { + return { + setTransform: vi.fn(), + fillRect: vi.fn(), + fillText: vi.fn(), + beginPath: vi.fn(), + moveTo: vi.fn(), + lineTo: vi.fn(), + stroke: vi.fn(), + save: vi.fn(), + restore: vi.fn(), + rect: vi.fn(), + clip: vi.fn(), + measureText: vi.fn(() => ({ width: 0 })), + roundRect: vi.fn(), + fill: vi.fn(), + clearRect: vi.fn(), + arc: vi.fn(), + strokeRect: vi.fn(), + setLineDash: vi.fn(), + canvas: document.createElement('canvas'), + getContextAttributes: vi.fn(), + } as unknown as CanvasRenderingContext2D; +} + +const originalGetContext = HTMLCanvasElement.prototype.getContext; +beforeEach(() => { + HTMLCanvasElement.prototype.getContext = vi.fn(function (this: HTMLCanvasElement, type: string) { + if (type === 'webgl2') return null; // force the Canvas2D fallback + return createMockContext2D(); + }) as unknown as typeof HTMLCanvasElement.prototype.getContext; + + return () => { + HTMLCanvasElement.prototype.getContext = originalGetContext; + }; +}); + +function makeChannel(over: Partial = {}): SignalChannel { + return { + name: 'Flow', + data: new Float32Array([0, 10, -10, 5, 0]), + sampleRate: 25, + unit: 'L/min', + color: '#3366cc', + physicalMin: -60, + physicalMax: 60, + kind: 'cpap', + render: 'line', + ...over, + }; +} + +function makeViewport(): ViewportState { + return { startTime: 0, endTime: 1000, channels: [makeChannel()] }; +} + +function makeOptions(): RenderOptions { + return { + showCrosshair: false, + crosshairX: null, + showGrid: true, + eventMarkers: [], + channelHeight: 150, + padding: { top: 20, right: 24, bottom: 28, left: 56 }, + }; +} + +const resolveColor = () => ({ r: 0.2, g: 0.4, b: 0.8, a: 1 }); + +describe('HybridSignalRenderer — Canvas2D fallback (no WebGL2 in jsdom)', () => { + it('falls back to Canvas2D when WebGL2 is unavailable and stays out of chrome-only mode', () => { + const base = document.createElement('canvas'); + const waveform = document.createElement('canvas'); + const r = new HybridSignalRenderer(base, waveform, resolveColor); + expect(r.isWebGLActive()).toBe(false); + r.dispose(); + }); + + it('runs Canvas2D-only when no waveform canvas is supplied', () => { + const base = document.createElement('canvas'); + const r = new HybridSignalRenderer(base, null, resolveColor); + expect(r.isWebGLActive()).toBe(false); + r.dispose(); + }); + + it('render() does not throw on the fallback path and sizes the base canvas', () => { + const base = document.createElement('canvas'); + const r = new HybridSignalRenderer(base, null, resolveColor); + r.resize(800, 400); + expect(base.width).toBeGreaterThan(0); + expect(base.height).toBeGreaterThan(0); + expect(() => r.render(makeViewport(), makeOptions())).not.toThrow(); + r.dispose(); + }); + + it('delegates hit-testing to the inner Canvas2D renderer', () => { + const base = document.createElement('canvas'); + const r = new HybridSignalRenderer(base, null, resolveColor); + r.resize(800, 400); + const vp = makeViewport(); + const opts = makeOptions(); + r.render(vp, opts); + + const plotLeft = opts.padding.left; + const plotWidth = 800 - opts.padding.left - opts.padding.right; + const midX = plotLeft + plotWidth / 2; + + const time = r.getTimeAtX(midX, vp, opts); + expect(time).toBeGreaterThan(vp.startTime); + expect(time).toBeLessThan(vp.endTime); + + const values = r.getValuesAtTime(midX, vp, opts); + expect(values.length).toBe(1); + expect(values[0]?.channel).toBe('Flow'); + + r.dispose(); + }); + + it('pan lifecycle (beginPan/panBy/endPan) is a no-op-safe sequence on the fallback', () => { + const base = document.createElement('canvas'); + const r = new HybridSignalRenderer(base, null, resolveColor); + r.resize(800, 400); + r.render(makeViewport(), makeOptions()); + + r.beginPan(); + expect(() => r.renderDuringPan(makeViewport(), makeOptions(), 42)).not.toThrow(); + // On the fallback the chrome re-renders rather than CSS-translating, so the + // base canvas transform is never left set. + expect(base.style.transform === '' || base.style.transform === undefined).toBe(true); + expect(() => r.endPan()).not.toThrow(); + r.dispose(); + }); + + it('renderOverlay delegates without throwing', () => { + const base = document.createElement('canvas'); + const overlay = document.createElement('canvas'); + const r = new HybridSignalRenderer(base, null, resolveColor); + r.setOverlayCanvas(overlay); + r.resize(800, 400); + const vp = makeViewport(); + const opts = makeOptions(); + r.render(vp, opts); + expect(() => + r.renderOverlay(vp, { ...opts, showCrosshair: true, crosshairX: 100 }), + ).not.toThrow(); + r.dispose(); + }); + + it('the color resolver is not invoked on the fallback (no WebGL lane draw)', () => { + const base = document.createElement('canvas'); + const spy = vi.fn(resolveColor); + const r = new HybridSignalRenderer(base, null, spy); + r.resize(800, 400); + r.render(makeViewport(), makeOptions()); + expect(spy).not.toHaveBeenCalled(); + r.dispose(); + }); +}); diff --git a/src/components/charts/canvas/__tests__/SignalRenderer.chromeOnly.test.ts b/src/components/charts/canvas/__tests__/SignalRenderer.chromeOnly.test.ts new file mode 100644 index 0000000..b85118c --- /dev/null +++ b/src/components/charts/canvas/__tests__/SignalRenderer.chromeOnly.test.ts @@ -0,0 +1,184 @@ +/** + * Unit tests for the SignalRenderer CHROME-ONLY mode (ADR 0019, Stage 2). + * + * In `chromeOnly` mode the renderer draws everything EXCEPT the dense-CPAP + * waveform itself (which the WebGL2 layer paints), but ONLY for lanes that + * actually carry WebGL geometry (`webglLane`). This proves the chrome/waveform + * split via op-counting on a recording context — no GL needed. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { SignalRenderer } from '../SignalRenderer'; +import type { RenderOptions, SignalChannel, ViewportState } from '../SignalRenderer'; + +// ── Recording context (counts path ops) ────────────────────────── + +interface Recorder { + ctx: CanvasRenderingContext2D; + counts: Record; +} + +function createRecorder(canvas: HTMLCanvasElement): Recorder { + const counts: Record = {}; + const bump = (n: string): void => { + counts[n] = (counts[n] ?? 0) + 1; + }; + const target: Record = { + canvas, + measureText: () => ({ width: 20 }) as TextMetrics, + getContextAttributes: () => ({ alpha: false }) as CanvasRenderingContext2DSettings, + }; + const handler: ProxyHandler> = { + get(obj, prop: string) { + if (prop in obj) return obj[prop]; + return (..._a: unknown[]): undefined => { + void _a; + bump(prop); + return undefined; + }; + }, + set(obj, prop: string, value) { + obj[prop] = value; + return true; + }, + }; + return { ctx: new Proxy(target, handler) as unknown as CanvasRenderingContext2D, counts }; +} + +let activeRecorder: Recorder | null = null; +const originalGetContext = HTMLCanvasElement.prototype.getContext; +beforeEach(() => { + HTMLCanvasElement.prototype.getContext = function ( + this: HTMLCanvasElement, + ): RenderingContext | null { + activeRecorder = createRecorder(this); + return activeRecorder.ctx; + } as unknown as typeof HTMLCanvasElement.prototype.getContext; +}); +afterEach(() => { + HTMLCanvasElement.prototype.getContext = originalGetContext; + activeRecorder = null; +}); + +const WIDTH = 1000; +const HEIGHT = 400; +const PADDING = { top: 20, right: 20, bottom: 28, left: 60 } as const; + +function makeOptions(over?: Partial): RenderOptions { + return { + showCrosshair: false, + crosshairX: null, + showGrid: false, + eventMarkers: [], + channelHeight: 200, + padding: PADDING, + ...over, + }; +} + +function renderOnce(r: SignalRenderer, vp: ViewportState, opts: RenderOptions): void { + const realRaf = globalThis.requestAnimationFrame; + globalThis.requestAnimationFrame = ((cb: FrameRequestCallback) => { + cb(0); + return 0; + }) as typeof globalThis.requestAnimationFrame; + try { + r.render(vp, opts); + } finally { + globalThis.requestAnimationFrame = realRaf; + } +} + +function denseCpapChannel(over: Partial = {}): SignalChannel { + return { + name: 'Flow', + data: new Float32Array([0, 10, -10, 5, 0, 8, -3, 2]), + sampleRate: 25, + unit: 'L/min', + color: '#3366cc', + physicalMin: -60, + physicalMax: 60, + kind: 'cpap', + render: 'line', + ...over, + }; +} + +const webglLane: NonNullable = { + mode: 'line', + levelData: new Float32Array([0, 10, -10, 5]), + levelIndex: 1, + dataXPerElementMs: 40, + dataXStartMs: 0, + plotWidthColumns: 920, + physRange: 120, +}; + +function viewportWith(ch: SignalChannel): ViewportState { + return { startTime: 0, endTime: 1000, channels: [ch] }; +} + +describe('SignalRenderer chrome-only mode (ADR 0019)', () => { + let renderer: SignalRenderer; + beforeEach(() => { + renderer = new SignalRenderer(document.createElement('canvas')); + renderer.resize(WIDTH, HEIGHT); + }); + afterEach(() => renderer.dispose()); + + it('defaults to full-draw mode (chrome-only off): dense CPAP polyline IS drawn', () => { + expect(renderer.isChromeOnly()).toBe(false); + renderOnce(renderer, viewportWith(denseCpapChannel({ webglLane })), makeOptions()); + // The polyline path issues moveTo/lineTo for the waveform. + const moves = activeRecorder?.counts['moveTo'] ?? 0; + expect(moves).toBeGreaterThan(0); + }); + + it('chrome-only SKIPS the dense CPAP polyline when the lane carries webglLane', () => { + renderer.setChromeOnly(true); + renderOnce(renderer, viewportWith(denseCpapChannel({ webglLane })), makeOptions()); + // No waveform path ops at all (grid off, no markers) → the WebGL layer paints it. + expect(activeRecorder?.counts['moveTo'] ?? 0).toBe(0); + expect(activeRecorder?.counts['lineTo'] ?? 0).toBe(0); + }); + + it('chrome-only STILL draws the dense CPAP polyline when NO webglLane (pre-pyramid frame)', () => { + renderer.setChromeOnly(true); + renderOnce(renderer, viewportWith(denseCpapChannel()), makeOptions()); + // Without webglLane the WebGL layer cannot paint it yet, so chrome must — the + // waveform is never invisible. + expect(activeRecorder?.counts['moveTo'] ?? 0).toBeGreaterThan(0); + }); + + it('chrome-only still draws wearable line lanes (not dense CPAP)', () => { + renderer.setChromeOnly(true); + const wearable = denseCpapChannel({ + name: 'HR', + kind: 'wearable', + webglLane, // even if present, a wearable lane is never a WebGL waveform lane + }); + renderOnce(renderer, viewportWith(wearable), makeOptions()); + expect(activeRecorder?.counts['moveTo'] ?? 0).toBeGreaterThan(0); + }); + + it('chrome-only still draws grid + axis chrome for a skipped dense lane', () => { + renderer.setChromeOnly(true); + renderOnce( + renderer, + viewportWith(denseCpapChannel({ webglLane })), + makeOptions({ showGrid: true }), + ); + // Y/X grid + axis labels are chrome and must still be issued. + expect(activeRecorder?.counts['fillText'] ?? 0).toBeGreaterThan(0); + expect(activeRecorder?.counts['stroke'] ?? 0).toBeGreaterThan(0); + }); + + it('toggling chrome-only off restores the full draw', () => { + renderer.setChromeOnly(true); + renderer.setChromeOnly(false); + expect(renderer.isChromeOnly()).toBe(false); + renderOnce(renderer, viewportWith(denseCpapChannel({ webglLane })), makeOptions()); + expect(activeRecorder?.counts['moveTo'] ?? 0).toBeGreaterThan(0); + }); +}); From 3185c0f2ef697d04d4a6cd9422ad260545871c53 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 20:47:34 +0000 Subject: [PATCH 07/16] feat(signal-viewer): integrate WebGL2 hybrid waveform renderer Stage 2 of ADR 0019. Wires HybridSignalRenderer into the Signal Viewer as the default waveform renderer with automatic Canvas2D fallback (no feature flag). - Adds a transparent WebGL2 waveform `` layered between the base chrome canvas and the crosshair overlay (`.waveformCanvas`, pointer-events:none, aria-hidden). Sized at DPR 2 by the renderer, pixel-aligned with the base. - Constructs HybridSignalRenderer once both the base and waveform canvases are mounted (tryInitRenderer handles either mount order). Falls back to Canvas2D automatically when WebGL2 is unavailable. - buildCpapChannel now also attaches `webglLane`: the WHOLE chosen pyramid level (matching the SAME level/threshold the Canvas2D path uses) in a stable absolute-ms domain, so the WebGL layer pans/zooms via uniforms without re-uploading. The Canvas2D path is untouched (still consumes the pre-sliced data/envelope), so the fallback stays byte-identical. - Pan hot path drives the CSS-translate-chrome + WebGL-uniform path (beginPan/renderRangeDuringPan/endPan via the shared rAF scheduler), keeping the chrome layer off the per-frame texture-upload path during a drag. - Colours resolved to RGBA via the existing theme path (parseCssColorToRgba), re-resolved on theme change with the channel's resolved colour. - Crosshair overlay, hit-testing, keyboard cursor, lane headers, scroll and all interactions are unchanged (delegated to the inner Canvas2D renderer), so they behave identically on both the WebGL and fallback paths. https://claude.ai/code/session_012CzEJ1kUhwobqVTnVusLcb --- src/views/Sessions/SignalViewer.module.css | 17 ++ src/views/Sessions/SignalViewer.tsx | 265 +++++++++++++++++---- 2 files changed, 241 insertions(+), 41 deletions(-) diff --git a/src/views/Sessions/SignalViewer.module.css b/src/views/Sessions/SignalViewer.module.css index 1a8060c..98156d9 100644 --- a/src/views/Sessions/SignalViewer.module.css +++ b/src/views/Sessions/SignalViewer.module.css @@ -480,6 +480,23 @@ width: 100%; } +/* Transparent WebGL2 waveform layer (ADR 0019). Stacked over the base chrome + canvas and BENEATH the crosshair overlay. Absolutely positioned at the same + origin and width so it aligns pixel-perfectly; its height is set in px by the + renderer to match the base (DPR 2). pointer-events:none so events pass through + to .canvasWrapper. Declared before .overlayCanvas so the crosshair composites + on top of the waveform. During an active drag the BASE canvas is CSS-translated + to follow the pan without re-uploading; this WebGL layer pans via uniforms, so + it is NOT translated. */ +.waveformCanvas { + position: absolute; + top: 0; + left: 0; + width: 100%; + display: block; + pointer-events: none; +} + /* Transparent crosshair overlay stacked over the base canvas. Absolutely positioned at the same origin and width so it aligns pixel-perfectly; its height is set in px by the renderer to match the base. pointer-events:none so diff --git a/src/views/Sessions/SignalViewer.tsx b/src/views/Sessions/SignalViewer.tsx index 17d7760..aaa8207 100644 --- a/src/views/Sessions/SignalViewer.tsx +++ b/src/views/Sessions/SignalViewer.tsx @@ -25,7 +25,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Link, useNavigate, useParams, useSearchParams } from 'react-router-dom'; -import { SignalRenderer } from '@/components/charts/canvas/SignalRenderer'; +import { HybridSignalRenderer } from '@/components/charts/HybridSignalRenderer'; +import { parseCssColorToRgba } from '@/components/charts/cssColor'; import { computeLaneLayout, type DetectionEpisode, @@ -35,6 +36,7 @@ import { type SignalChannel, type ViewportState, } from '@/components/charts/canvas/SignalRenderer'; +import type { RGBA } from '@/components/charts/webgl'; import { buildDecimationPyramid, selectPyramidLevel, @@ -385,8 +387,13 @@ export default function SignalViewer() { // Transparent overlay canvas stacked over the base; holds only the crosshair so // pointer moves repaint it alone (never the waveform stack). const overlayCanvasRef = useRef(null); + // Transparent WebGL2 waveform layer stacked between the base chrome canvas and + // the crosshair overlay (ADR 0019). Only the dense-CPAP waveforms paint here; + // when WebGL2 is unavailable / its context is lost the hybrid renderer paints + // the waveforms on the base canvas instead (automatic fallback). + const waveformCanvasRef = useRef(null); const canvasWrapperRef = useRef(null); - const rendererRef = useRef(null); + const rendererRef = useRef(null); const observerRef = useRef(null); const opfsRef = useRef(null); @@ -880,49 +887,88 @@ export default function SignalViewer() { }; }, [sessionId, opfsSupported]); - // ── Initialize renderer + ResizeObserver via callback ref ──── + // ── Initialize hybrid renderer + ResizeObserver via callback refs ──── + // + // The hybrid renderer (ADR 0019) composes a base Canvas2D chrome canvas and a + // transparent WebGL2 waveform canvas, so it must be constructed once BOTH are + // mounted (WebGL needs its canvas at init). Each canvas callback ref records + // its element and calls `tryInitRenderer`, which constructs the renderer the + // first time both are present. Mount order between the refs is not guaranteed. - const canvasCallbackRef = useCallback((canvas: HTMLCanvasElement | null) => { - if (observerRef.current) { - observerRef.current.disconnect(); - observerRef.current = null; - } - if (rendererRef.current) { - rendererRef.current.dispose(); - rendererRef.current = null; - } + /** + * Stable per-channel colour resolver for the WebGL waveform layer. The channel + * already carries a resolved colour STRING (via `cpapChannelMeta`); we parse it + * to RGBA here — no getComputedStyle inside the renderer, re-resolved on theme + * change because the channel's resolved colour re-resolves there. + */ + const colorResolver = useCallback((ch: SignalChannel): RGBA => parseCssColorToRgba(ch.color), []); - canvasRef.current = canvas; - if (!canvas) return; + const tryInitRenderer = useCallback(() => { + const base = canvasRef.current; + const waveform = waveformCanvasRef.current; + // Need the base canvas at minimum; the waveform canvas may be null (then the + // hybrid runs Canvas2D-only, which is the same automatic fallback path). + if (!base) return; + if (rendererRef.current) return; // already constructed - const renderer = new SignalRenderer(canvas); + const renderer = new HybridSignalRenderer(base, waveform, colorResolver); rendererRef.current = renderer; - // Wire any already-mounted overlay (mount order between the two canvas - // callback refs is not guaranteed; the overlay ref also wires up if it - // mounts after the renderer is created). if (overlayCanvasRef.current) { renderer.setOverlayCanvas(overlayCanvasRef.current); } - const wrapper = canvas.parentElement; + const wrapper = base.parentElement; if (!wrapper) return; - const observer = new ResizeObserver((entries) => { - for (const entry of entries) { - const { width } = entry.contentRect; - if (width > 0) setWrapperWidth(width); - } - }); - observerRef.current = observer; - observer.observe(wrapper); + if (!observerRef.current) { + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + const { width } = entry.contentRect; + if (width > 0) setWrapperWidth(width); + } + }); + observerRef.current = observer; + observer.observe(wrapper); - const rect = wrapper.getBoundingClientRect(); - if (rect.width > 0) setWrapperWidth(rect.width); + const rect = wrapper.getBoundingClientRect(); + if (rect.width > 0) setWrapperWidth(rect.width); + } + }, [colorResolver]); + + const teardownRenderer = useCallback(() => { + if (observerRef.current) { + observerRef.current.disconnect(); + observerRef.current = null; + } + if (rendererRef.current) { + rendererRef.current.dispose(); + rendererRef.current = null; + } }, []); + const canvasCallbackRef = useCallback( + (canvas: HTMLCanvasElement | null) => { + canvasRef.current = canvas; + if (!canvas) { + teardownRenderer(); + return; + } + tryInitRenderer(); + }, + [tryInitRenderer, teardownRenderer], + ); + + const waveformCanvasCallbackRef = useCallback( + (canvas: HTMLCanvasElement | null) => { + waveformCanvasRef.current = canvas; + if (canvas) tryInitRenderer(); + }, + [tryInitRenderer], + ); + // Overlay canvas callback ref. Stores the element and (when the renderer - // already exists) attaches it immediately; otherwise canvasCallbackRef wires it + // already exists) attaches it immediately; otherwise tryInitRenderer wires it // when the renderer is created. On unmount (null) it detaches from the renderer. const overlayCanvasCallbackRef = useCallback((canvas: HTMLCanvasElement | null) => { overlayCanvasRef.current = canvas; @@ -1161,6 +1207,59 @@ export default function SignalViewer() { // consistent, since they derive from the same fields. Fall back to the EDF // declared range when no hybrid domain is available (e.g. pre-extent). const domain = cpapDisplayDomains.get(laneName); + const physicalMin = domain?.min ?? desc.physicalMin; + const physicalMax = domain?.max ?? desc.physicalMax; + + // ── WebGL whole-level geometry (ADR 0019, Stage 2) ────────────────── + // + // For the WebGL2 waveform layer we attach the WHOLE chosen pyramid level (not + // the per-viewport slice) in a STABLE, absolute session-relative ms domain, + // so pan/zoom are uniform-only (no per-frame re-upload). The Canvas2D path + // ignores `webglLane` and keeps drawing the pre-sliced `data`/`envelope` + // above, so the fallback is byte-identical. We only attach it once a pyramid + // exists for this lane (before then the hybrid renderer runs Canvas2D-only + // for this lane, drawing the polyline/envelope above). The chosen level + // matches the SAME selection the Canvas2D path uses (same targets), so the + // envelope-vs-line boundary and LOD are consistent across both layers. + let webglLane: SignalChannel['webglLane'] | undefined; + if (pyramid && totalSamples > 0 && totalDurationMs > 0) { + const msPerSampleBase = totalDurationMs / totalSamples; + if (useEnvelope) { + const envTarget = columns * ENVELOPE_SOURCE_OVERSCAN; + const esel = selectPyramidLevel(pyramid, startSample, endSample, envTarget); + const level = pyramid.levels[esel.levelIndex]; + // Envelope mode requires an interleaved-extrema level (levelIndex ≥ 1); + // selectPyramidLevel only returns level 0 when zoomed in, where + // useEnvelope is false — so this is always a real extrema level here. + if (level && esel.levelIndex >= 1) { + webglLane = { + mode: 'envelope', + levelData: level.data, + levelIndex: esel.levelIndex, + dataXPerElementMs: level.factor * msPerSampleBase, + dataXStartMs: 0, + plotWidthColumns: columns, + physRange: physicalMax - physicalMin, + }; + } + } + if (!webglLane) { + // Line mode: upload the whole level chosen for `targetPoints`. + const lsel = selectPyramidLevel(pyramid, startSample, endSample, targetPoints); + const level = pyramid.levels[lsel.levelIndex]; + if (level) { + webglLane = { + mode: 'line', + levelData: level.data, + levelIndex: lsel.levelIndex, + dataXPerElementMs: level.factor * msPerSampleBase, + dataXStartMs: 0, + plotWidthColumns: columns, + physRange: physicalMax - physicalMin, + }; + } + } + } return { name: laneName, @@ -1168,11 +1267,12 @@ export default function SignalViewer() { sampleRate: effectiveSampleRate, unit: desc.unit, color: meta.resolvedColor, - physicalMin: domain?.min ?? desc.physicalMin, - physicalMax: domain?.max ?? desc.physicalMax, + physicalMin, + physicalMax, kind: 'cpap', render: 'line', ...(envelope ? { envelope } : {}), + ...(webglLane ? { webglLane } : {}), }; }, [cpapChannelMeta, cpapDisplayDomains, totalDurationMs], @@ -1387,17 +1487,65 @@ export default function SignalViewer() { ); renderRangeDirectRef.current = renderRangeDirect; + /** + * Latest CSS-px pan delta (`clientX - panStart.x`) for the active drag, read by + * the pan paint path so the chrome layer is CSS-translated to follow the drag + * instead of being re-rendered (ADR 0019 trap fix). Reset to 0 between gestures. + */ + const panDxRef = useRef(0); + + /** + * Render a pan FRAME via the hybrid renderer's CSS-translate-chrome + WebGL- + * uniform path (ADR 0019). The chrome canvas is translated by `dxPx` (no + * re-render → no per-frame texture re-upload) and the WebGL waveform pans via + * uniforms. Keeps `lastViewportRef`/`lastOptionsRef` coherent and re-syncs the + * crosshair overlay, exactly like {@link renderRangeDirect}. On the Canvas2D + * fallback the renderer re-renders the chrome at the live viewport instead. + */ + const renderRangeDuringPan = useCallback( + (range: ViewportRange, dxPx: number): ViewportState | null => { + const renderer = rendererRef.current; + if (!renderer) return null; + const viewportState = buildViewportState(range); + if (!viewportState) return null; + const options = buildRenderOptions(); + lastViewportRef.current = viewportState; + lastOptionsRef.current = options; + renderer.renderDuringPan(viewportState, options, dxPx); + if (crosshairXRef.current !== null) { + renderer.renderOverlay(viewportState, { + ...options, + showCrosshair: true, + crosshairX: crosshairXRef.current, + }); + } + return viewportState; + }, + [buildViewportState, buildRenderOptions], + ); + const renderRangeDuringPanRef = useRef(renderRangeDuringPan); + renderRangeDuringPanRef.current = renderRangeDuringPan; + /** * Lazily create (once) the shared rAF-coalescing paint scheduler used by BOTH * the wheel-zoom and drag-pan hot paths. The paint callback routes through * `renderRangeDirectRef` so it always invokes the latest `renderRangeDirect` * (which keeps `lastViewportRef`/overlay coherent), never a stale closure. + * + * During an ACTIVE pan it routes to the pan path instead (CSS-translate chrome + + * WebGL uniform), reading the latest `panDxRef` so the chrome tracks the drag + * without a per-frame re-render. `panStartRef !== null` distinguishes a pan from + * a wheel-zoom (which never sets it). */ const getPaintScheduler = useCallback((): FramePaintScheduler => { let scheduler = paintSchedulerRef.current; if (!scheduler) { scheduler = createFramePaintScheduler((range) => { - renderRangeDirectRef.current?.(range); + if (panStartRef.current) { + renderRangeDuringPanRef.current(range, panDxRef.current); + } else { + renderRangeDirectRef.current?.(range); + } }); paintSchedulerRef.current = scheduler; } @@ -1748,6 +1896,10 @@ export default function SignalViewer() { if (e.button !== 0) return; setIsPanning(true); panStartRef.current = { x: e.clientX, viewport: { ...viewport } }; + panDxRef.current = 0; + // Enter CSS-translate-chrome pan mode (ADR 0019): while the drag is active + // the chrome layer is translated, not re-rendered, so it never re-uploads. + rendererRef.current?.beginPan(); (e.target as HTMLElement).setPointerCapture(e.pointerId); }, [viewport], @@ -1792,12 +1944,19 @@ export default function SignalViewer() { } const range = { startTime: newStart, endTime: newEnd }; liveViewportRef.current = range; - // crosshairXRef is already set above; the scheduled renderRangeDirect - // picks it up. PAN HOT PATH now mirrors the wheel path: high-rate - // pointers (120–1000 Hz) fire many moves per displayed frame, so we - // coalesce to AT MOST ONE renderRangeDirect per animation frame via the - // shared scheduler instead of painting synchronously on every move. The - // final frame is painted + committed once on pointerup/leave. + // EFFECTIVE chrome translate (ADR 0019 trap fix): the chrome canvas is + // CSS-translated to follow the pan WITHOUT a re-render. It must track the + // ACTUAL viewport delta (which clamps at the session edges), not the raw + // pointer dx — otherwise the chrome would keep sliding past the edge while + // the (clamped) waveform stops. Derive it from the committed viewport + // delta so chrome and waveform stay locked together. + panDxRef.current = + vpDuration > 0 ? -((newStart - startVP.startTime) / vpDuration) * plotWidth : 0; + // crosshairXRef is already set above; the scheduled pan paint picks it up. + // PAN HOT PATH: high-rate pointers (120–1000 Hz) fire many moves per + // displayed frame, so we coalesce to AT MOST ONE pan frame per animation + // frame via the shared scheduler. While a pan is active the scheduler + // routes to renderRangeDuringPan (CSS-translate chrome + WebGL uniform). getPaintScheduler().schedule(range); return; } @@ -1820,6 +1979,12 @@ export default function SignalViewer() { const handlePointerUp = useCallback(() => { setIsPanning(false); panStartRef.current = null; + panDxRef.current = 0; + // Settle the pan: flush any pending coalesced pan frame, then exit pan mode + // (repaints chrome at the settled viewport + clears the CSS translate, flash- + // free), then commit the viewport to React state. + paintSchedulerRef.current?.cancel(); + rendererRef.current?.endPan(); commitLiveViewport(); }, [commitLiveViewport]); @@ -1831,9 +1996,13 @@ export default function SignalViewer() { hoveredKeyRef.current = ''; setHoveredRegion(EMPTY_HOVERED_REGION); } - // If a pan was in flight (pointer left the wrapper / capture lost), commit - // its settled viewport before clearing so the displayed window persists. + // If a pan was in flight (pointer left the wrapper / capture lost), settle it + // (exit pan mode → repaint chrome at the settled viewport + clear translate) + // and commit before clearing so the displayed window persists. if (isPanning) { + panDxRef.current = 0; + paintSchedulerRef.current?.cancel(); + rendererRef.current?.endPan(); commitLiveViewport(); setIsPanning(false); panStartRef.current = null; @@ -2303,6 +2472,20 @@ export default function SignalViewer() { onBlur={handleCanvasBlur} /> + {/* Transparent WebGL2 waveform layer (ADR 0019), stacked over the base + chrome canvas and beneath the crosshair overlay. Only the dense-CPAP + waveforms paint here; everything else (grid, axes, markers, ribbon, + step/wearable) stays on the base canvas. pointer-events:none so events + reach the wrapper; aria-hidden because the base canvas carries the + accessible description. When WebGL2 is unavailable or its context is + lost the hybrid renderer paints the waveforms on the base canvas + instead, so this layer simply stays transparent. */} +