diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f960f44..7ca26e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,10 +77,32 @@ jobs: path: playwright-report/ retention-days: 14 + test-e2e-fidelity: + name: E2E WebGL Fidelity Gate (ADR 0019) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + - run: npx playwright install --with-deps chromium + # Software WebGL2 (ANGLE + SwiftShader) is configured in the + # `chromium-fidelity` Playwright project. RUN_FIDELITY=1 enables the gate; + # a missing WebGL2 context fails the gate LOUDLY (it never silently skips). + - run: RUN_FIDELITY=1 npx playwright test --project=chromium-fidelity + - uses: actions/upload-artifact@v4 + if: ${{ !cancelled() }} + with: + name: playwright-report-fidelity + path: playwright-report/ + retention-days: 14 + build: name: Build runs-on: ubuntu-latest - needs: [audit, lint, test-unit, test-e2e] + needs: [audit, lint, test-unit, test-e2e, test-e2e-fidelity] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index a99e174..0998f4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ and this project uses [Calendar Versioning](https://calver.org/) with the format ### Changed +- **Signal Viewer waveforms are now GPU-rendered for much smoother panning and zooming.** Scrolling and zooming a whole-night recording were limited not by computation but by the browser having to re-upload the entire waveform canvas to the GPU on every frame — so on long sessions the chart could only repaint a few times per second during a drag, regardless of how little had actually changed. The dense CPAP waveform lanes (flow, pressure, leak, …) now render through **WebGL2**: their geometry lives on the GPU, so panning and zooming become a lightweight transform rather than a full re-upload, and the frame rate during interaction is dramatically higher. The rest of the chart — axis labels, grid, event markers, the hypnogram, sparse/step lanes, and the crosshair — continues to render on Canvas2D, composited beneath the same crosshair overlay as before. **The displayed waveform is intended to be visually identical**, including the more-faithful zoomed-out min/max envelope and the exact zoomed-in per-sample line; this is enforced by an automated fidelity test that renders the same data through both paths and compares them pixel-for-pixel (with spike-survival and gap-break checks) at full device-pixel resolution. If a browser does not support WebGL2, or the GPU drops the rendering context, the viewer **automatically falls back** to the original Canvas2D renderer with no loss of function. Rendering remains entirely client-side; nothing leaves the browser. (See ADR 0019.) - **Signal Viewer y-axes now use clinically sensible default ranges that expand to fit, never clip.** Each waveform lane previously scaled its vertical axis to the EDF file's declared physical range (`physicalMin`/`physicalMax`). Those are decode calibration anchors — the physical values that map to the digital encoding's extremes — not display bounds, so they were the wrong thing to scale to. Each lane now starts from a clinical default display range and **expands only outward** to cover whatever the session's data actually needs, but never shrinks below the default. This keeps axes stable and directly comparable from night to night while guaranteeing no data is drawn off-lane. Per-signal special cases: Flow stays symmetric about zero; SpO₂ pins its top at 100% and expands only downward to reveal desaturations; the flow-limitation index is fixed at 0–1. Extreme or corrupt samples are clamped to per-signal plausibility ceilings so one bad reading cannot blow out the axis. Wearable lanes (heart rate, SpO₂, HRV, snoring) get the same expand-only-against-a-sensible-floor treatment. Clinical default ranges now in use: Flow ±60 L/min; Pressure / EPAP / EPR 0–25 and IPAP 0–30 cmH₂O; Leak 0–60 L/min; respiratory rate 0–30 br/min; tidal volume 0–1000 mL; minute ventilation 0–20 L/min; SpO₂ 85–100%; pulse 40–120 bpm; snore 0–1; flow limitation 0–1 (fixed). Channels with no clinical entry (unknown or future-machine signals) keep the previous declared-range behaviour. The EDF decode path is unchanged and the plotted waveform itself is unchanged — only the vertical extent of each lane changes. - **Leak rate displays consistently as a whole number.** The Session Detail Leak Rate card (median, 95th percentile, and max) now uses the same integer-L/min display precision as the dashboard, closing a small inconsistency where the session view showed one decimal. Stored values are unchanged; this is a presentation-only fix. - **All in-app help raised to the same scholarly bar as the breathing-pattern articles.** Every help article and glossary entry that makes a clinical or statistical claim now carries primary-source citations (with DOIs) and a formal "References" section, and a fact-check against the literature corrected a number of overstatements. Help articles `clinical-reference`, `statistical-analysis`, `event-analysis`, `pressure-analysis`, and `cross-source-analysis` gained References sections (AASM Manual 2012, Epstein 2009, Kapur 2017, Weaver 2007, CMS LCD L33718, SERVE-HF/Cowie 2015, Kaplan-Meier 1958, Cleveland 1979, Killick 2012, Bland-Altman 1986/1999, Schober 2018, Fisher 1915, and others); the Granger article now cites Akaike (1974) for the AIC it relies on. The glossary gained an optional `references` field, rendered at the "Detailed" depth, populated for 49 clinical and statistical terms. Corrections of substance: the leak threshold (24 L/min) is now labelled a ResMed device convention rather than an AASM standard; CPAP-use dose-response figures are attributed to Weaver (2007) functional outcomes rather than a hard "cardiovascular" cutoff; the `statistical-analysis` article no longer claims a Mann-Kendall test the app does not run (it describes the OLS + LOESS it actually computes) and no longer claims slope confidence intervals or variance/trend change-point detection it does not produce; the `event-analysis` article no longer advertises a retired Kaplan-Meier survival-curve view or a non-existent hazard-rate plot; the ODI entry no longer claims a configurable 4% threshold the code does not implement; correlation-strength bands are presented as CPAP Analyzer's arbitrary rule-of-thumb (Schober 2018), not a "standard" or "Cohen" scheme; and the unsupported "device AHI differs from PSG by 10–30%" figure was replaced with a cited, accurate statement (Kapur 2017). The `central-ai` metric tooltip now carries the SERVE-HF / LVEF ≤ 45% ASV caveat. 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. diff --git a/playwright.config.ts b/playwright.config.ts index 2501b56..fa2b244 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,5 +1,12 @@ import { defineConfig, devices } from '@playwright/test'; +/** + * The WebGL fidelity gate spec. The `chromium-fidelity` project runs ONLY this + * file; the normal `chromium`/`firefox`/`webkit` projects run everything EXCEPT + * it. A glob (not a path) so it matches regardless of the project's working dir. + */ +const FIDELITY_SPEC = '**/webgl-fidelity-gate.spec.ts'; + export default defineConfig({ testDir: './tests/e2e', fullyParallel: true, @@ -12,18 +19,62 @@ export default defineConfig({ trace: 'on-first-retry', }, projects: [ + // ── Normal e2e matrix ──────────────────────────────────────────────────── + // These run the WHOLE suite EXCEPT the WebGL fidelity gate, which is GPU- + // dependent and lives in its own dedicated project/CI job below. We ignore it + // here so the normal matrix never tries to run it (it is also internally + // `RUN_FIDELITY`-gated via `test.skip`, so this is belt-and-suspenders). { name: 'chromium', + testIgnore: FIDELITY_SPEC, use: { ...devices['Desktop Chrome'] }, }, { name: 'firefox', + testIgnore: FIDELITY_SPEC, use: { ...devices['Desktop Firefox'] }, }, { name: 'webkit', + testIgnore: FIDELITY_SPEC, use: { ...devices['Desktop Safari'] }, }, + // ── WebGL fidelity gate (ADR 0019, Stage 3) ────────────────────────────── + // Headless Chromium with ANGLE + SwiftShader so a software WebGL2 context is + // available on GPU-less CI runners. This project is targeted explicitly by + // the `test-e2e-fidelity` CI job (RUN_FIDELITY=1) and is NOT part of the + // normal e2e matrix above. It runs ONLY the fidelity spec (via `testMatch`) + // so the gate is fast and unambiguous under the SwiftShader flags — it does + // not drag the whole 200+ test suite through software GL. The load-bearing + // flags are `--use-gl=angle --use-angle=swiftshader --enable-unsafe-swiftshader + // --ignore-gpu-blocklist`; the rest force WebGL on regardless of blocklists. + { + name: 'chromium-fidelity', + testMatch: FIDELITY_SPEC, + // A fidelity mismatch is DETERMINISTIC under the fixed synthetic dataset + + // software SwiftShader — retrying cannot change the pixels, it only burns + // another full (slow) run per view. We pin retries to 0 here (overriding the + // matrix-wide `retries: 2` above) so a failing gate fails ONCE and fast, + // instead of the 3× heavy re-runs that helped push the job past 25 min. + retries: 0, + // Per-test cap as a backstop to the in-spec `test.setTimeout(40_000)`; with + // the slimmed lane-band/column-strided reads, a view completes well inside it. + timeout: 60_000, + use: { + ...devices['Desktop Chrome'], + deviceScaleFactor: 2, + launchOptions: { + args: [ + '--use-gl=angle', + '--use-angle=swiftshader', + '--enable-unsafe-swiftshader', + '--ignore-gpu-blocklist', + '--enable-webgl', + '--enable-features=Vulkan', + ], + }, + }, + }, ], webServer: { command: 'npm run dev', diff --git a/src/components/charts/HybridSignalRenderer.ts b/src/components/charts/HybridSignalRenderer.ts new file mode 100644 index 0000000..3a752ba --- /dev/null +++ b/src/components/charts/HybridSignalRenderer.ts @@ -0,0 +1,488 @@ +/** + * 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; + +/** + * Optional construction-time options for {@link HybridSignalRenderer}. + * + * `preserveDrawingBuffer` is a DEV/TEST-ONLY escape hatch (default `false`, + * matching production): it makes the WebGL2 drawing buffer survive compositing so + * the fidelity-gate harness can read its pixels back deterministically under + * headless SwiftShader. The shipped Signal Viewer NEVER sets it — a preserved + * buffer costs per-frame performance, which ADR 0019 forbids. + */ +export interface HybridRendererOptions { + preserveDrawingBuffer?: boolean; +} + +/** 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, + options?: HybridRendererOptions, + ) { + this.chrome = new SignalRenderer(chromeCanvas); + this.resolveColor = resolveColor; + this.dpr = typeof devicePixelRatio === 'number' ? devicePixelRatio : 1; + + if (waveformCanvas) { + try { + const renderer = new WebGLWaveformRenderer( + waveformCanvas, + options?.preserveDrawingBuffer === undefined + ? undefined + : { preserveDrawingBuffer: options.preserveDrawingBuffer }, + ); + 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') { + // Reduce the whole level to a PER-PIXEL-COLUMN min/max band in the stable + // absolute ms domain, at the SAME column resolution the Canvas2D reference + // uses (`plotWidthColumns` ≈ one column per device pixel). This is the + // fidelity-critical step: pairing level elements 1:2 produced far-sub-pixel + // columns, so a 1-sample spike rendered as a sub-pixel triangle peak the + // rasterizer stepped over (reaching only ~38% of its true height). Matching + // the reference's column count makes the spike a ~1-px column that always + // rasterizes to its full extreme. See `levelToColumnEnvelope`. + const { min, max, columns } = levelToColumnEnvelope(g.levelData, g.plotWidthColumns); + // Each column spans the whole level evenly: wholeLevelSpanMs / columns, + // matching the reference's `plotWidth / columns` for the whole-session view. + const dataXPerColumn = columns > 0 ? (g.levelData.length * g.dataXPerElementMs) / columns : 0; + 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/__tests__/HybridSignalRenderer.webgl.test.ts b/src/components/charts/__tests__/HybridSignalRenderer.webgl.test.ts new file mode 100644 index 0000000..701420a --- /dev/null +++ b/src/components/charts/__tests__/HybridSignalRenderer.webgl.test.ts @@ -0,0 +1,487 @@ +/** + * Unit tests for the hybrid renderer's WebGL-ACTIVE orchestration (ADR 0019, + * Stage 2). + * + * jsdom has no WebGL2, so the sibling `HybridSignalRenderer.test.ts` can only + * exercise the Canvas2D FALLBACK path. The pure-TS orchestration that runs ONLY + * when WebGL is live — putting the inner Canvas2D renderer into `chromeOnly` + * mode, gating `uploadLanes` via the re-upload signature, and the + * context-lost/restored transitions — is therefore covered here by MOCKING the + * GL-context-bound {@link WebGLWaveformRenderer} with a pure stub. The real GL + * draw is validated by the CI pixel-diff fidelity gate, not here. + * + * The mock seam: `HybridSignalRenderer` obtains its renderer via a direct + * `new WebGLWaveformRenderer(canvas)` from `'../webgl'`. We `vi.mock` that module + * to swap ONLY the class for a controllable stub, keeping the REAL + * `WebGLUnavailableError` and the real `LANE_TOP_INSET`/`LANE_BOTTOM_INSET` + * constants so the orchestration runs against genuine collaborators. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { RenderOptions, SignalChannel, ViewportState } from '../canvas/SignalRenderer'; + +// ── Mock the WebGL renderer module ──────────────────────────────── +// We replace ONLY `WebGLWaveformRenderer` with a stub class whose methods are +// `vi.fn()`s, while re-exporting the real error class and layout constants from +// the actual module so the orchestration runs against the genuine collaborators. + +// `vi.mock` is hoisted above all top-level code, so the stub class and its +// control state must be created inside `vi.hoisted` (also hoisted) and shared +// via the returned object. We read those handles through `mockState`. + +const mockState = vi.hoisted(() => { + /** Controls whether the next mock construction throws (and with what). */ + const ctorControl: { throwError: Error | null } = { throwError: null }; + /** The most recently constructed mock instance (one hybrid → one renderer). */ + const ref: { last: MockWebGLWaveformRenderer | null } = { last: null }; + + class MockWebGLWaveformRenderer { + onContextLost: (() => void) | null = null; + onContextRestored: (() => void) | null = null; + + private contextLost = false; + + readonly resize = vi.fn<(cssW: number, cssH: number, dpr: number) => void>(); + readonly uploadLanes = + vi.fn<(lanes: readonly import('../webgl').WaveformLaneInput[]) => void>(); + readonly render = + vi.fn< + ( + viewport: import('../webgl').ViewportX, + laneStates: readonly import('../webgl').LaneFrameState[], + ) => void + >(); + readonly dispose = vi.fn<() => void>(); + + constructor(canvas: HTMLCanvasElement) { + void canvas; // signature parity with the real renderer; unused in the stub + if (ctorControl.throwError) throw ctorControl.throwError; + ref.last = this; + } + + isContextLost(): boolean { + return this.contextLost; + } + + /** Test helper: simulate the GL context being lost (fires the host callback). */ + simulateContextLost(): void { + this.contextLost = true; + this.onContextLost?.(); + } + + /** Test helper: simulate the GL context being restored (fires the host callback). */ + simulateContextRestored(): void { + this.contextLost = false; + this.onContextRestored?.(); + } + } + + return { ctorControl, ref, MockWebGLWaveformRenderer }; +}); + +type MockWebGLWaveformRenderer = InstanceType; +const ctorControl = mockState.ctorControl; + +vi.mock('../webgl', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + WebGLWaveformRenderer: mockState.MockWebGLWaveformRenderer, + }; +}); + +// Imported AFTER the mock declaration; `vi.mock` is hoisted so the SUT sees the +// stub. `WebGLUnavailableError` here is the REAL class (re-exported above). +import { HybridSignalRenderer } from '../HybridSignalRenderer'; +import { WebGLUnavailableError } from '../webgl'; + +// ── 2D context stub (jsdom returns null for both 'webgl2' and '2d') ── +// The inner SignalRenderer needs a working 2D context. The mock GL renderer no +// longer depends on the 'webgl2' branch (it never calls getContext), so we only +// need to satisfy '2d'. + +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(() => { + ctorControl.throwError = null; + mockState.ref.last = null; + HTMLCanvasElement.prototype.getContext = vi.fn(function (this: HTMLCanvasElement, type: string) { + if (type === '2d') return createMockContext2D(); + return null; + }) as unknown as typeof HTMLCanvasElement.prototype.getContext; + + return () => { + HTMLCanvasElement.prototype.getContext = originalGetContext; + }; +}); + +// ── Fixtures ────────────────────────────────────────────────────── + +type WebGLLane = NonNullable; + +function makeWebGLLane(over: Partial = {}): WebGLLane { + return { + mode: 'envelope', + levelData: new Float32Array([0, 10, -10, 5, -3, 8]), + levelIndex: 2, + dataXPerElementMs: 40, + dataXStartMs: 0, + plotWidthColumns: 920, + physRange: 120, + ...over, + }; +} + +/** A dense-CPAP lane equipped with WebGL geometry (the kind the GPU paints). */ +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', + webglLane: makeWebGLLane(), + ...over, + }; +} + +function makeViewport(over: Partial = {}): ViewportState { + return { startTime: 0, endTime: 1000, channels: [makeChannel()], ...over }; +} + +function makeOptions(over: Partial = {}): RenderOptions { + return { + showCrosshair: false, + crosshairX: null, + showGrid: true, + eventMarkers: [], + channelHeight: 150, + padding: { top: 20, right: 24, bottom: 28, left: 56 }, + ...over, + }; +} + +const resolveColor = () => ({ r: 0.2, g: 0.4, b: 0.8, a: 1 }); + +/** Construct a hybrid whose WebGL path is engaged, sized and rendered once. */ +function makeActiveHybrid(): { r: HybridSignalRenderer; gl: MockWebGLWaveformRenderer } { + const base = document.createElement('canvas'); + const waveform = document.createElement('canvas'); + const r = new HybridSignalRenderer(base, waveform, resolveColor); + r.resize(800, 400); + const gl = mockState.ref.last; + if (!gl) throw new Error('expected a mock WebGL renderer to be constructed'); + return { r, gl }; +} + +// ── 1. WebGL available → WebGL path engaged ─────────────────────── + +describe('HybridSignalRenderer — WebGL active orchestration', () => { + it('engages the WebGL path: inner renderer is chrome-only and the GL layer uploads + renders', () => { + const { r, gl } = makeActiveHybrid(); + expect(r.isWebGLActive()).toBe(true); + + r.render(makeViewport(), makeOptions()); + + // The dense-CPAP lane carries webglLane → the GPU paints it, so it uploaded + // geometry and issued a per-frame draw. + expect(gl.uploadLanes).toHaveBeenCalledTimes(1); + expect(gl.render).toHaveBeenCalledTimes(1); + + // The single uploaded lane is the equipped Flow lane. + const uploaded = gl.uploadLanes.mock.calls[0]?.[0]; + expect(uploaded?.length).toBe(1); + expect(uploaded?.[0]?.id).toBe('Flow'); + + r.dispose(); + }); + + it('puts the inner Canvas2D renderer into chrome-only mode (waveform skipped by 2D)', () => { + // We assert chrome-only indirectly via behaviour: a chrome-only inner renderer + // does NOT draw the dense polyline, but the resolveColor resolver IS invoked + // for the GPU lane. (The Canvas2D fallback suite asserts the inverse.) + const base = document.createElement('canvas'); + const waveform = document.createElement('canvas'); + const colorSpy = vi.fn(resolveColor); + const r = new HybridSignalRenderer(base, waveform, colorSpy); + r.resize(800, 400); + r.render(makeViewport(), makeOptions()); + // The GPU lane draw resolves a colour — only reachable on the WebGL path. + expect(colorSpy).toHaveBeenCalled(); + r.dispose(); + }); +}); + +// ── 2. Construction failure → Canvas2D fallback ─────────────────── + +describe('HybridSignalRenderer — construction failure falls back to Canvas2D', () => { + it('falls back (does not throw) when GL construction raises WebGLUnavailableError', () => { + ctorControl.throwError = new WebGLUnavailableError(); + const base = document.createElement('canvas'); + const waveform = document.createElement('canvas'); + + let r!: HybridSignalRenderer; + expect(() => { + r = new HybridSignalRenderer(base, waveform, resolveColor); + }).not.toThrow(); + expect(r.isWebGLActive()).toBe(false); + + // Full-draw mode: rendering does not throw and never touches a GL renderer. + r.resize(800, 400); + expect(() => r.render(makeViewport(), makeOptions())).not.toThrow(); + expect(mockState.ref.last).toBeNull(); + r.dispose(); + }); + + it('falls back (does not throw) when GL construction raises a generic Error', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + ctorControl.throwError = new Error('unexpected GL init explosion'); + const base = document.createElement('canvas'); + const waveform = document.createElement('canvas'); + + let r!: HybridSignalRenderer; + expect(() => { + r = new HybridSignalRenderer(base, waveform, resolveColor); + }).not.toThrow(); + expect(r.isWebGLActive()).toBe(false); + // A non-WebGLUnavailableError is surfaced as a dev warning before falling back. + expect(warn).toHaveBeenCalled(); + + r.resize(800, 400); + expect(() => r.render(makeViewport(), makeOptions())).not.toThrow(); + r.dispose(); + warn.mockRestore(); + }); + + it('runs the inner renderer in full-draw mode (draws the waveform itself) on fallback', () => { + ctorControl.throwError = new WebGLUnavailableError(); + const base = document.createElement('canvas'); + const waveform = document.createElement('canvas'); + const colorSpy = vi.fn(resolveColor); + const r = new HybridSignalRenderer(base, waveform, colorSpy); + r.resize(800, 400); + r.render(makeViewport(), makeOptions()); + // No GPU lane draw on the fallback → the colour resolver is never consulted. + expect(colorSpy).not.toHaveBeenCalled(); + r.dispose(); + }); +}); + +// ── 3 & 4. Context loss / restore transitions ───────────────────── + +describe('HybridSignalRenderer — context loss and restore', () => { + it('webglcontextlost → switches to full Canvas2D and repaints (chart never blank)', () => { + const { r, gl } = makeActiveHybrid(); + r.render(makeViewport(), makeOptions()); + expect(r.isWebGLActive()).toBe(true); + + gl.uploadLanes.mockClear(); + gl.render.mockClear(); + + gl.simulateContextLost(); + + // No longer active: the host now drives the full Canvas2D draw. + expect(r.isWebGLActive()).toBe(false); + // The lost handler repainted via the inner renderer, NOT via the GL layer. + expect(gl.render).not.toHaveBeenCalled(); + expect(gl.uploadLanes).not.toHaveBeenCalled(); + + // Subsequent frames stay on Canvas2D and do not touch the GL renderer. + r.render(makeViewport(), makeOptions()); + expect(gl.render).not.toHaveBeenCalled(); + r.dispose(); + }); + + it('webglcontextrestored → re-uploads retained lanes, re-enables chrome-only, redraws', () => { + const { r, gl } = makeActiveHybrid(); + r.render(makeViewport(), makeOptions()); + + gl.simulateContextLost(); + expect(r.isWebGLActive()).toBe(false); + + gl.uploadLanes.mockClear(); + gl.render.mockClear(); + + gl.simulateContextRestored(); + + // Resumed: active again, and the restore forced a re-upload + redraw of the + // retained lane (signatures were cleared so the next frame must re-upload). + expect(r.isWebGLActive()).toBe(true); + expect(gl.uploadLanes).toHaveBeenCalledTimes(1); + expect(gl.render).toHaveBeenCalledTimes(1); + expect(gl.uploadLanes.mock.calls[0]?.[0]?.[0]?.id).toBe('Flow'); + r.dispose(); + }); +}); + +// ── 5 & 6. Re-upload gating via the upload signature ────────────── + +describe('HybridSignalRenderer — uploadLanes gating (the load-bearing trap fix)', () => { + it('issues ZERO additional uploads on pan/zoom within a level (same signature)', () => { + const { r, gl } = makeActiveHybrid(); + const opts = makeOptions(); + + // First frame uploads once. + r.render(makeViewport({ startTime: 0, endTime: 1000 }), opts); + expect(gl.uploadLanes).toHaveBeenCalledTimes(1); + + // Pan (shifted window) and zoom (narrower window) WITHIN the same level: + // identical signature (mode/levelIndex/columns/physRange unchanged) → no + // further upload, only per-frame draws. + r.render(makeViewport({ startTime: 200, endTime: 1200 }), opts); // pan + r.render(makeViewport({ startTime: 300, endTime: 800 }), opts); // zoom in + r.render(makeViewport({ startTime: 100, endTime: 1100 }), opts); // pan back + + expect(gl.uploadLanes).toHaveBeenCalledTimes(1); + // ...but every frame issued a draw. + expect(gl.render).toHaveBeenCalledTimes(4); + r.dispose(); + }); + + it('re-uploads exactly once on an envelope↔line mode switch', () => { + const { r, gl } = makeActiveHybrid(); + const opts = makeOptions(); + + r.render( + makeViewport({ channels: [makeChannel({ webglLane: makeWebGLLane({ mode: 'envelope' }) })] }), + opts, + ); + expect(gl.uploadLanes).toHaveBeenCalledTimes(1); + + // Cross the samples-per-pixel threshold: envelope → line. + r.render( + makeViewport({ + channels: [makeChannel({ render: 'line', webglLane: makeWebGLLane({ mode: 'line' }) })], + }), + opts, + ); + expect(gl.uploadLanes).toHaveBeenCalledTimes(2); + r.dispose(); + }); + + it('re-uploads exactly once on an LOD-level change', () => { + const { r, gl } = makeActiveHybrid(); + const opts = makeOptions(); + + r.render( + makeViewport({ channels: [makeChannel({ webglLane: makeWebGLLane({ levelIndex: 2 }) })] }), + opts, + ); + expect(gl.uploadLanes).toHaveBeenCalledTimes(1); + + r.render( + makeViewport({ channels: [makeChannel({ webglLane: makeWebGLLane({ levelIndex: 3 }) })] }), + opts, + ); + expect(gl.uploadLanes).toHaveBeenCalledTimes(2); + r.dispose(); + }); + + it('re-uploads exactly once on a resize (plot width / column count change)', () => { + const { r, gl } = makeActiveHybrid(); + const opts = makeOptions(); + + r.render(makeViewport(), opts); + expect(gl.uploadLanes).toHaveBeenCalledTimes(1); + + // A resize clears the cached signatures → the next frame must re-upload once. + r.resize(1200, 400); + r.render(makeViewport(), opts); + expect(gl.uploadLanes).toHaveBeenCalledTimes(2); + + // ...and a further pan within the new size does NOT re-upload again. + r.render(makeViewport({ startTime: 50, endTime: 1050 }), opts); + expect(gl.uploadLanes).toHaveBeenCalledTimes(2); + r.dispose(); + }); + + it('re-uploads exactly once on a display-domain (physRange) change in envelope mode', () => { + const { r, gl } = makeActiveHybrid(); + const opts = makeOptions(); + + r.render( + makeViewport({ channels: [makeChannel({ webglLane: makeWebGLLane({ physRange: 120 }) })] }), + opts, + ); + expect(gl.uploadLanes).toHaveBeenCalledTimes(1); + + r.render( + makeViewport({ channels: [makeChannel({ webglLane: makeWebGLLane({ physRange: 240 }) })] }), + opts, + ); + expect(gl.uploadLanes).toHaveBeenCalledTimes(2); + r.dispose(); + }); +}); + +// ── 7. Hit-testing delegated identically on both paths ──────────── + +describe('HybridSignalRenderer — hit-testing delegates to the inner Canvas2D renderer', () => { + function assertHitTestingDelegates(r: HybridSignalRenderer): void { + 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'); + + // getValueAtPosition delegates too (may be null off any lane; just must not throw). + expect(() => r.getValueAtPosition(midX, opts.padding.top + 10, vp, opts)).not.toThrow(); + } + + it('delegates identically on the WebGL-active path', () => { + const { r } = makeActiveHybrid(); + assertHitTestingDelegates(r); + expect(r.isWebGLActive()).toBe(true); + r.dispose(); + }); + + it('delegates identically on the Canvas2D fallback path', () => { + ctorControl.throwError = new WebGLUnavailableError(); + const base = document.createElement('canvas'); + const waveform = document.createElement('canvas'); + const r = new HybridSignalRenderer(base, waveform, resolveColor); + r.resize(800, 400); + assertHitTestingDelegates(r); + expect(r.isWebGLActive()).toBe(false); + r.dispose(); + }); +}); 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..2691ec0 --- /dev/null +++ b/src/components/charts/__tests__/hybridWaveformPlan.test.ts @@ -0,0 +1,275 @@ +/** + * 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 → per-pixel-column band)', () => { + it('reduces a level to exactly the target column count via per-column min/max', () => { + // 6 elements → 3 columns: each column folds 2 consecutive elements. + const level = new Float32Array([-2, 5, 1, 3, -7, -1]); + const env = levelToColumnEnvelope(level, 3); + expect(env.columns).toBe(3); + expect(Array.from(env.min)).toEqual([-2, 1, -7]); + expect(Array.from(env.max)).toEqual([5, 3, -1]); + }); + + it('honours the requested column count regardless of level length', () => { + // 8 elements → 4 columns: each column folds 2 elements (min/max of the pair). + const level = new Float32Array([0, 10, -3, 4, 6, 6, -9, -1]); + const env = levelToColumnEnvelope(level, 4); + expect(env.columns).toBe(4); + expect(Array.from(env.max)).toEqual([10, 4, 6, -1]); + expect(Array.from(env.min)).toEqual([0, -3, 6, -9]); + }); + + it('folds MANY level elements into FEWER columns, keeping each column extreme', () => { + // 12 elements → 3 columns: each column folds 4 consecutive elements. The + // per-pixel-column reduction (not 1:2 pairing) is what preserves a spike that + // would otherwise become a sub-pixel triangle peak at "all" zoom. + const level = new Float32Array([0, 1, 2, 99, -1, 0, 1, 2, 3, 4, 5, -50]); + const env = levelToColumnEnvelope(level, 3); + expect(env.columns).toBe(3); + // Column 0 = elements 0..3 → max 99 (the spike survives the reduction). + expect(env.max[0]).toBe(99); + // Column 2 = elements 8..11 → min -50 (the notch survives too). + expect(env.min[2]).toBe(-50); + }); + + it('orders each column so min ≤ max regardless of temporal order', () => { + // decimateMinMax emits in temporal order, which can be [max, min]. + const env = levelToColumnEnvelope(new Float32Array([9, -9]), 1); + expect(env.min[0]).toBe(-9); + expect(env.max[0]).toBe(9); + }); + + it('a wholly-NaN column yields a gap column (breaks the band)', () => { + // 6 elements → 3 columns; column 1 = elements [NaN, NaN] → gap. + const level = new Float32Array([1, 2, NaN, NaN, 5, 6]); + const env = levelToColumnEnvelope(level, 3); + 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('a column straddling a gap edge keeps its real extrema', () => { + // 4 elements → 2 columns; column 0 = [10, NaN] keeps the real 10. + const env = levelToColumnEnvelope(new Float32Array([10, NaN, 3, 4]), 2); + expect(env.min[0]).toBe(10); + expect(env.max[0]).toBe(10); + }); + + it('returns no columns for a zero target and a NaN-filled band for an empty level', () => { + expect(levelToColumnEnvelope(new Float32Array([1, 2]), 0).columns).toBe(0); + const empty = levelToColumnEnvelope(new Float32Array([]), 3); + expect(empty.columns).toBe(3); + expect(empty.min.every((v) => Number.isNaN(v))).toBe(true); + }); +}); + +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 the whole level evenly (wholeLevelSpanMs / columns)', () => { + // factor 4, msPerSampleBase 40, level of 100 elements → span 16000 ms. + // Reduced to 50 columns → 320 ms per column. + expect(envelopeDataXPerColumnMs(4, 40, 100, 50)).toBe((100 * 4 * 40) / 50); + }); + + it('is 0 for a degenerate (zero-column) reduction', () => { + expect(envelopeDataXPerColumnMs(4, 40, 100, 0)).toBe(0); + }); +}); + +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/canvas/SignalRenderer.ts b/src/components/charts/canvas/SignalRenderer.ts index 66f3bdc..b6418f2 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. + // (Intentional no-op: the waveform for this lane is painted by WebGL.) } else { this.drawLine(ch, viewport, plotLeft, plotWidth, stripTop, stripHeight); } 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); + }); +}); 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..095acdf --- /dev/null +++ b/src/components/charts/hybridWaveformPlan.ts @@ -0,0 +1,358 @@ +/** + * 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; +} + +/** + * Reduce a whole pyramid LEVEL array to a per-PIXEL-COLUMN MIN/MAX envelope in the + * STABLE absolute domain, at a target column COUNT — mirroring the Canvas2D + * reference's `columnEnvelopeInto` exactly. + * + * WHY A TARGET COLUMN COUNT (the fidelity fix) + * -------------------------------------------- + * The earlier implementation paired consecutive level elements 1:2 into + * `floor(levelLen / 2)` columns. At the most-decimated ("all"/whole-night) zoom a + * level still holds ~`4 × plotWidthColumns` elements, so pairing produced + * ~`2 × plotWidthColumns` columns — each FAR narrower than one device pixel + * (~0.16 px). A 1-sample spike then survived in the DATA (its column carried the + * true max) but rendered as a sub-pixel-wide triangle peak that the GPU + * rasterizer's pixel-centre sampling stepped OVER: the topmost lit pixel only + * reached the envelope of the spike's neighbours (~38% of the spike's height), + * not the spike itself. The Canvas2D reference never had this problem because it + * reduces to exactly `plotWidthColumns` (~one column per device pixel), so the + * spike lands in a ~1-px-wide column that always rasterizes to its full extreme. + * + * The fix is to make the WebGL envelope resolution MATCH the reference: reduce the + * level to exactly `columns` per-pixel-columns via the SAME forward per-column + * min/max fold `columnEnvelopeInto` uses (source index `i` → column + * `floor(i / levelLen * columns)`). The most extreme sample in a column is, by + * definition, its min or max, so the spike's extreme reaches a column max → a + * vertex → its full pixel height. Extrema preservation is now a rasterized + * guarantee, not just a data-level one. + * + * Level 0 (raw, factor 1) is never reached here: envelope mode is only selected + * when the viewport holds > 1 sample/pixel (levelIndex ≥ 1), so the source is + * always a real min/max-preserving extrema level. + * + * NaN / gap handling matches `columnEnvelopeInto`: a column with only NaN (or no) + * source samples becomes a NaN gap column (breaking the band exactly as the + * polyline breaks), while a column straddling a gap edge keeps its real extrema. + * + * Pure and unit-tested. Returns arrays sized exactly `columns`. + * + * @param levelData - The whole chosen pyramid level (extrema-preserving). + * @param columns - Target output column count (≈ the plot width in device-aware + * CSS-px columns at upload time, i.e. `plotWidthColumns`). + */ +export function levelToColumnEnvelope( + levelData: Float32Array, + columns: number, +): { + min: Float32Array; + max: Float32Array; + columns: number; +} { + const len = levelData.length; + const cols = Math.max(0, Math.floor(columns)); + const min = new Float32Array(cols); + const max = new Float32Array(cols); + if (cols === 0) return { min, max, columns: 0 }; + if (len === 0) { + min.fill(NaN); + max.fill(NaN); + return { min, max, columns: cols }; + } + + // Forward per-column fold, identical in spirit to `columnEnvelopeInto`: each + // source element folds into column floor(i / len * cols); a column with no real + // sample (wholly NaN, or empty when len < cols) is a NaN gap break. + let col = 0; + let curMin = Infinity; + let curMax = -Infinity; + let sawReal = false; + + const flush = (c: number): void => { + if (sawReal) { + min[c] = curMin; + max[c] = curMax; + } else { + min[c] = NaN; + max[c] = NaN; + } + }; + + const scale = cols / len; + for (let i = 0; i < len; i++) { + let c = (i * scale) | 0; + if (c >= cols) c = cols - 1; + + if (c !== col) { + flush(col); + // Empty interior columns (only possible when len < cols) → NaN breaks. + for (let g = col + 1; g < c; g++) { + min[g] = NaN; + max[g] = NaN; + } + col = c; + curMin = Infinity; + curMax = -Infinity; + sawReal = false; + } + + const v = levelData[i] as number; + if (Number.isNaN(v)) continue; + sawReal = true; + if (v < curMin) curMin = v; + if (v > curMax) curMax = v; + } + flush(col); + for (let g = col + 1; g < cols; g++) { + min[g] = NaN; + max[g] = NaN; + } + + return { min, max, columns: cols }; +} + +/** + * 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: the whole level (spanning `levelLen * factor * msPerSampleBase` + * ms) is reduced to `columns` per-pixel-columns, so each column spans + * `wholeLevelSpanMs / columns` ms (see {@link envelopeDataXPerColumnMs}); its + * centre sits at `(c + 0.5) * dataXPerColumnMs`. + */ +export function levelDataXPerElementMs(factor: number, msPerSampleBase: number): number { + return factor * msPerSampleBase; +} + +/** + * Envelope column width in ms when a whole level of `levelLength` elements (each + * `factor * msPerSampleBase` ms apart, so the level spans + * `levelLength * factor * msPerSampleBase` ms) is reduced to `columns` + * per-pixel-columns: `wholeLevelSpanMs / columns`. + * + * This MUST match the spacing `levelToColumnEnvelope` implies (the whole session + * divided evenly into `columns`), so a column's clip-X lands exactly where the + * Canvas2D reference's `plotLeft + (c + 0.5) * (plotWidth / columns)` would for the + * whole-session viewport. Returns 0 for a degenerate (empty / zero-column) input. + */ +export function envelopeDataXPerColumnMs( + factor: number, + msPerSampleBase: number, + levelLength: number, + columns: number, +): number { + if (columns <= 0) return 0; + const wholeLevelSpanMs = levelLength * factor * msPerSampleBase; + return wholeLevelSpanMs / columns; +} + +/** + * 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); +} diff --git a/src/components/charts/webgl/WebGLWaveformRenderer.ts b/src/components/charts/webgl/WebGLWaveformRenderer.ts new file mode 100644 index 0000000..450226d --- /dev/null +++ b/src/components/charts/webgl/WebGLWaveformRenderer.ts @@ -0,0 +1,540 @@ +/** + * 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). + * @param options.preserveDrawingBuffer Whether the drawing buffer is preserved + * after compositing. **Production MUST leave this `false`** (the default): a + * preserved buffer disables the browser's swap-instead-of-copy fast path and + * costs per-frame performance, which ADR 0019 forbids. It exists ONLY so the + * dev/test fidelity harness can opt in (`true`) to make off-screen pixel + * read-back (`gl.readPixels` / `drawImage` onto a 2D canvas) deterministic in + * headless Chromium/SwiftShader, where reading a non-preserved buffer after + * the frame may return blank. Never set it `true` on the shipped render path. + */ + constructor( + canvas: HTMLCanvasElement, + options?: { premultipliedAlpha?: boolean; preserveDrawingBuffer?: boolean }, + ) { + this.canvas = canvas; + const gl = canvas.getContext('webgl2', { + antialias: true, + premultipliedAlpha: options?.premultipliedAlpha ?? true, + // Default false (production/perf). The fidelity harness opts in to true so + // its off-screen read-back is reliable under headless SwiftShader. + preserveDrawingBuffer: options?.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 { + // On a lost context the gl.* calls below are silent no-ops; the host + // (HybridSignalRenderer) re-drives uploadLanes from `onContextRestored`, so no + // explicit retain is needed here. + 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/__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__/envelopeSpikeIntegration.test.ts b/src/components/charts/webgl/__tests__/envelopeSpikeIntegration.test.ts new file mode 100644 index 0000000..5fc22b5 --- /dev/null +++ b/src/components/charts/webgl/__tests__/envelopeSpikeIntegration.test.ts @@ -0,0 +1,338 @@ +/** + * INTEGRATION regression test for the WebGL envelope spike-survival bug + * (ADR 0019, fidelity gate `view=all`). + * + * The bug: at the most-decimated ("all"/whole-night) zoom the WebGL envelope was + * built by pairing a whole pyramid level's elements 1:2 into ~`levelLen / 2` + * columns. That is ~`2 × plotWidthColumns` columns — each FAR narrower than one + * device pixel. A 1-sample +59.5 L/min spike survived in the DATA (its column + * carried the true max) but rendered as a sub-pixel-wide triangle peak the GPU + * rasterizer stepped over: the topmost lit pixel reached only the envelope of the + * spike's neighbours (~+37, ~38% of the spike's height), failing the gate's + * extreme-survival assertion (`spike lit extreme y=98 did not reach y≈49.3`). + * + * The `envelopeGeometry.test.ts` spike-survival test PASSED throughout because it + * exercises the geometry BUILDER in isolation with a handful of well-resolved + * columns — it never reproduced the level→pixel-column collapse the integrated + * path produces. This file closes that seam: it drives the SAME pipeline the + * fidelity harness does (synthetic dataset → decimation pyramid → level selection + * → `levelToColumnEnvelope` → `buildEnvelopeGeometry` → vertex Y → device px) and + * asserts the spike's max vertex reaches the true +59.5 extreme AND maps to the + * expected device-Y, matching the Canvas2D reference's `columnEnvelopeInto` path. + * + * Pure (jsdom, no GL): the geometry/transform are exactly what the GPU consumes, + * so a vertex reaching the extreme value at the spike's device-X column is the + * data-level proof that the rasterized waveform now reaches its full height. + * + * @module components/charts/webgl/__tests__/envelopeSpikeIntegration.test + */ + +import { describe, it, expect } from 'vitest'; + +import { buildDecimationPyramid, selectPyramidLevel } from '../../canvas/decimationPyramid'; +import { levelToColumnEnvelope } from '../../hybridWaveformPlan'; +import { columnEnvelopeInto } from '@/services/workers/downsample.worker'; +import { buildEnvelopeGeometry, ENVELOPE_VERTEX_STRIDE } from '../envelopeGeometry'; +import { + computeWaveformClipTransform, + applyClipY, + LANE_TOP_INSET, + LANE_BOTTOM_INSET, +} from '../waveformTransform'; + +// ── Harness-mirrored constants (kept in sync with FidelityHarness.tsx) ────── +const SAMPLE_RATE_HZ = 25; +const SESSION_SECONDS = 3600; +const BASE_SAMPLES = SAMPLE_RATE_HZ * SESSION_SECONDS; // 90_000 +const TOTAL_DURATION_MS = SESSION_SECONDS * 1000; +const MS_PER_SAMPLE = TOTAL_DURATION_MS / BASE_SAMPLES; + +const SPIKE_BASE_INDEX = 30_000; +const NOTCH_BASE_INDEX = 60_000; +const GAP_START_BASE_INDEX = 45_000; +const GAP_LENGTH = 200; + +const FLOW_MIN = -60; +const FLOW_MAX = 60; +const SPIKE_VALUE = FLOW_MAX - 0.5; // +59.5 +const NOTCH_VALUE = FLOW_MIN + 0.5; // -59.5 + +// Harness canvas geometry (CSS px) and DPR. +const CANVAS_WIDTH = 1000; +const CANVAS_HEIGHT = 600; +const CHANNEL_HEIGHT = 180; +const PADDING = { top: 8, right: 16, bottom: 28, left: 56 } as const; +const DPR = 2; +const PLOT_WIDTH = CANVAS_WIDTH - PADDING.left - PADDING.right; // 928 +const ENVELOPE_SOURCE_OVERSCAN = 4; + +/** mulberry32 PRNG — byte-identical to the harness so the dataset matches. */ +function mulberry32(seed: number): () => number { + let a = seed >>> 0; + return () => { + a |= 0; + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** Build the deterministic Flow lane exactly as the harness does. */ +function buildFlowData(): Float32Array { + const out = new Float32Array(BASE_SAMPLES); + const rng = mulberry32(1); // Flow seed + const breathHz = 0.25; + for (let i = 0; i < BASE_SAMPLES; i++) { + const tSec = i / SAMPLE_RATE_HZ; + const wander = 0.85 + 0.15 * Math.sin(tSec * 0.013 + (rng() - 0.5) * 0.02); + const phase = 2 * Math.PI * breathHz * tSec; + out[i] = 45 * wander * Math.sin(phase); + } + out[SPIKE_BASE_INDEX] = SPIKE_VALUE; + out[NOTCH_BASE_INDEX] = NOTCH_VALUE; + for (let i = GAP_START_BASE_INDEX; i < GAP_START_BASE_INDEX + GAP_LENGTH; i++) out[i] = NaN; + return out; +} + +/** The Flow lane rect (lane 0) and its inner Y band in CSS px. */ +function flowLaneRect(): { + plotLeft: number; + plotWidth: number; + stripTop: number; + stripHeight: number; +} { + return { + plotLeft: PADDING.left, + plotWidth: PLOT_WIDTH, + stripTop: PADDING.top, + stripHeight: CHANNEL_HEIGHT, + }; +} + +/** Expected device-Y for a Flow physical value (mirrors the gate's physToDeviceY). */ +function expectedDeviceY(value: number): number { + const innerTopCss = PADDING.top + LANE_TOP_INSET; + const innerBottomCss = PADDING.top + CHANNEL_HEIGHT - LANE_BOTTOM_INSET; + const norm = (value - FLOW_MIN) / (FLOW_MAX - FLOW_MIN); + const cssY = innerBottomCss - norm * (innerBottomCss - innerTopCss); + return cssY * DPR; +} + +/** Convert a clip-Y (−1..+1, +Y up) to device-Y in the drawing buffer. */ +function clipYToDeviceY(clipY: number): number { + // clipY = 1 - (cssY / cssHeight) * 2 ⇒ cssY = (1 - clipY)/2 * cssHeight + const cssY = ((1 - clipY) / 2) * CANVAS_HEIGHT; + return cssY * DPR; +} + +/** + * Run the WebGL envelope pipeline for the whole-night ("all") viewport and return + * the geometry, transform, and the resolved per-column envelope — exactly the + * objects the GPU consumes. + */ +function buildAllViewWebglEnvelope(full: Float32Array): { + vertices: Float32Array; + columns: number; + envMax: Float32Array; + envMin: Float32Array; + transform: ReturnType; + dataXStart: number; + dataXPerColumn: number; +} { + const pyramid = buildDecimationPyramid(full); + const startSample = 0; + const endSample = BASE_SAMPLES; + const columns = Math.max(1, Math.round(PLOT_WIDTH)); + const envTarget = columns * ENVELOPE_SOURCE_OVERSCAN; + const esel = selectPyramidLevel(pyramid, startSample, endSample, envTarget); + const level = pyramid.levels[esel.levelIndex]; + if (!level || esel.levelIndex < 1) throw new Error('expected a decimated level (≥1) for "all"'); + + const msPerSampleBase = TOTAL_DURATION_MS / BASE_SAMPLES; + const dataXPerElementMs = level.factor * msPerSampleBase; + + const { min, max, columns: cols } = levelToColumnEnvelope(level.data, columns); + const dataXPerColumn = (level.data.length * dataXPerElementMs) / cols; + const dataXStart = 0; + + const lane = flowLaneRect(); + const innerHeight = lane.stripHeight - LANE_TOP_INSET - LANE_BOTTOM_INSET; + const valuePerPx = Math.abs((FLOW_MAX - FLOW_MIN) / innerHeight); + + const geo = buildEnvelopeGeometry( + { min, max, columns: cols }, + { dataXStart, dataXPerColumn, valuePerPx }, + ); + + const transform = computeWaveformClipTransform( + { viewStart: 0, viewSpan: TOTAL_DURATION_MS }, + { physicalMin: FLOW_MIN, physicalMax: FLOW_MAX }, + lane, + CANVAS_WIDTH, + CANVAS_HEIGHT, + ); + + return { + vertices: geo.vertices, + columns: cols, + envMax: max, + envMin: min, + transform, + dataXStart, + dataXPerColumn, + }; +} + +/** The Canvas2D reference per-column envelope for "all" (columnEnvelopeInto). */ +function buildAllViewReferenceEnvelope(full: Float32Array): { + min: Float32Array; + max: Float32Array; + columns: number; +} { + const pyramid = buildDecimationPyramid(full); + const columns = Math.max(1, Math.round(PLOT_WIDTH)); + const envTarget = columns * ENVELOPE_SOURCE_OVERSCAN; + const eslice = selectPyramidLevel(pyramid, 0, BASE_SAMPLES, envTarget); + const envSource = eslice.data.subarray(eslice.startIndex, eslice.endIndex); + const outMin = new Float32Array(columns); + const outMax = new Float32Array(columns); + return columnEnvelopeInto(envSource, columns, outMin, outMax); +} + +describe('WebGL envelope spike survival at "all" zoom (integration regression)', () => { + const full = buildFlowData(); + + it('the WebGL envelope is built at the reference column resolution (≈plot width), NOT 2× sub-pixel columns', () => { + const webgl = buildAllViewWebglEnvelope(full); + const ref = buildAllViewReferenceEnvelope(full); + // The fix: WebGL must use the SAME column count as the Canvas2D reference, so + // columns are ~1 device px wide — not the ~2× count the 1:2 pairing produced. + expect(webgl.columns).toBe(ref.columns); + expect(webgl.columns).toBe(Math.round(PLOT_WIDTH)); + }); + + it('the spike +59.5 reaches a max vertex at the expected device-Y (matches the reference)', () => { + const webgl = buildAllViewWebglEnvelope(full); + + // Find the column whose max is the global maximum (the spike). + let spikeCol = -1; + let spikeMax = -Infinity; + for (let c = 0; c < webgl.columns; c++) { + const m = webgl.envMax[c] as number; + if (!Number.isNaN(m) && m > spikeMax) { + spikeMax = m; + spikeCol = c; + } + } + // 1. The data-level extreme survives the per-pixel reduction. + expect(spikeMax).toBeCloseTo(SPIKE_VALUE, 5); + + // 2. The spike column's UPPER vertex carries the true +59.5 (extrema preserved + // through buildEnvelopeGeometry's min-thickness clamp untouched). + // Upper vertex of column `spikeCol` among non-gap columns: count non-gap + // columns before it (gaps emit no vertices). + let vertexCol = 0; + for (let c = 0; c < spikeCol; c++) { + const isGap = + Number.isNaN(webgl.envMin[c] as number) || Number.isNaN(webgl.envMax[c] as number); + if (!isGap) vertexCol++; + } + const upperVertexIndex = vertexCol * 2; // upper then lower per column + const upperY = webgl.vertices[upperVertexIndex * ENVELOPE_VERTEX_STRIDE + 1] as number; + expect(upperY).toBeCloseTo(SPIKE_VALUE, 5); + + // 3. That vertex maps through the (Canvas2D-pinned) transform to the device-Y + // the fidelity gate expects for +59.5 (~49.3 device px) — i.e. the spike + // now rasterizes to its full height, not the ~+37 the bug produced. + const clipY = applyClipY(webgl.transform, upperY); + const deviceY = clipYToDeviceY(clipY); + expect(deviceY).toBeCloseTo(expectedDeviceY(SPIKE_VALUE), 3); + }); + + it('the notch -59.5 reaches a min vertex at the expected device-Y', () => { + const webgl = buildAllViewWebglEnvelope(full); + + let notchCol = -1; + let notchMin = Infinity; + for (let c = 0; c < webgl.columns; c++) { + const m = webgl.envMin[c] as number; + if (!Number.isNaN(m) && m < notchMin) { + notchMin = m; + notchCol = c; + } + } + expect(notchMin).toBeCloseTo(NOTCH_VALUE, 5); + + let vertexCol = 0; + for (let c = 0; c < notchCol; c++) { + const isGap = + Number.isNaN(webgl.envMin[c] as number) || Number.isNaN(webgl.envMax[c] as number); + if (!isGap) vertexCol++; + } + const lowerVertexIndex = vertexCol * 2 + 1; // lower vertex of the column + const lowerY = webgl.vertices[lowerVertexIndex * ENVELOPE_VERTEX_STRIDE + 1] as number; + expect(lowerY).toBeCloseTo(NOTCH_VALUE, 5); + + const clipY = applyClipY(webgl.transform, lowerY); + const deviceY = clipYToDeviceY(clipY); + expect(deviceY).toBeCloseTo(expectedDeviceY(NOTCH_VALUE), 3); + }); + + it('the spike column centre lands within ±1 device px of the spike sample (rasterizable)', () => { + const webgl = buildAllViewWebglEnvelope(full); + + let spikeCol = -1; + let spikeMax = -Infinity; + for (let c = 0; c < webgl.columns; c++) { + const m = webgl.envMax[c] as number; + if (!Number.isNaN(m) && m > spikeMax) { + spikeMax = m; + spikeCol = c; + } + } + + // The spike's true device-X (from its sample ms), and the WebGL column centre. + const spikeMs = SPIKE_BASE_INDEX * MS_PER_SAMPLE; + const spikeCssX = PADDING.left + (spikeMs / TOTAL_DURATION_MS) * PLOT_WIDTH; + const spikeDeviceX = spikeCssX * DPR; + + const colCentreMs = webgl.dataXStart + (spikeCol + 0.5) * webgl.dataXPerColumn; + const colCssX = PADDING.left + (colCentreMs / TOTAL_DURATION_MS) * PLOT_WIDTH; + const colDeviceX = colCssX * DPR; + + // Within a column width (~2 device px) — comfortably inside the gate's probe + // window (deviceX ± 1·dpr). The column is ~1 device px wide → rasterizes. + expect(Math.abs(colDeviceX - spikeDeviceX)).toBeLessThanOrEqual(DPR); + const columnWidthDevicePx = (webgl.dataXPerColumn / TOTAL_DURATION_MS) * PLOT_WIDTH * DPR; + expect(columnWidthDevicePx).toBeGreaterThanOrEqual(1.5); + expect(columnWidthDevicePx).toBeLessThanOrEqual(3); + }); + + it('the WebGL spike/notch device-Y match the Canvas2D reference within sub-pixel', () => { + const webgl = buildAllViewWebglEnvelope(full); + const ref = buildAllViewReferenceEnvelope(full); + + let refMax = -Infinity; + let refMin = Infinity; + for (let c = 0; c < ref.columns; c++) { + const mx = ref.max[c] as number; + const mn = ref.min[c] as number; + if (!Number.isNaN(mx) && mx > refMax) refMax = mx; + if (!Number.isNaN(mn) && mn < refMin) refMin = mn; + } + let wMax = -Infinity; + let wMin = Infinity; + for (let c = 0; c < webgl.columns; c++) { + const mx = webgl.envMax[c] as number; + const mn = webgl.envMin[c] as number; + if (!Number.isNaN(mx) && mx > wMax) wMax = mx; + if (!Number.isNaN(mn) && mn < wMin) wMin = mn; + } + // Both paths reach the same extreme values → the same device-Y. + expect(wMax).toBeCloseTo(refMax, 5); + expect(wMin).toBeCloseTo(refMin, 5); + expect(wMax).toBeCloseTo(SPIKE_VALUE, 5); + expect(wMin).toBeCloseTo(NOTCH_VALUE, 5); + }); +}); 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..0a43106 --- /dev/null +++ b/src/components/charts/webgl/glsl/envelope.ts @@ -0,0 +1,83 @@ +/** + * 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. The band interior is fully opaque (matching the Canvas2D `fill()`), + * and GPU MSAA (`antialias: true`) anti-aliases the band's silhouette — there is + * no explicit fragment-shader edge feather. The min-thickness clamp that makes a + * flat band read as a ~1.2 px line (matching the Canvas2D `stroke(1.2px)` + * perceived weight) 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. Currently feeds the + * `v_devicePos` varying only; reserved for an optional explicit edge feather, + * so the renderer's uniform wiring stays stable. Unused by the fragment stage. + * + * @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 opaque fill in the lane colour. The band interior matches + * the Canvas2D `fill()`, and GPU MSAA (`antialias: true`) anti-aliases the + * silhouette — there is no explicit fragment feather. Thin bands keep their + * line-like weight via the CPU-side min-thickness clamp in the geometry, not here. + */ +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/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'; 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; +} diff --git a/src/hooks/useWearableSummary.ts b/src/hooks/useWearableSummary.ts index 340f62b..1545a92 100644 --- a/src/hooks/useWearableSummary.ts +++ b/src/hooks/useWearableSummary.ts @@ -149,6 +149,15 @@ export function useWearableSummary(): UseWearableSummaryResult { } } })(); + + // On unmount (or before the next run), invalidate this request so the async + // IIFE above never calls setState after the component is gone. Every setState + // is already gated on `requestId === requestIdRef.current`; bumping the ref + // here makes those guards bail, preventing a "window is not defined" + // setState-after-teardown rejection (surfaced as a flaky unit-test error). + return () => { + requestIdRef.current++; + }; }, [lastImportAt, cpapDateRange]); return { summary, loading, error }; diff --git a/src/router.tsx b/src/router.tsx index a4083ee..12ed469 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -1,5 +1,5 @@ import { createBrowserRouter, Navigate } from 'react-router-dom'; -import { lazy } from 'react'; +import { lazy, type ReactElement } from 'react'; import RootLayout from '@/components/layouts/RootLayout'; import { SuspenseWrapper } from '@/components/SuspenseWrapper'; @@ -29,6 +29,29 @@ const HelpHome = lazy(() => import('@/views/Help/HelpHome')); const HelpArticle = lazy(() => import('@/views/Help/HelpArticle')); const KeyboardShortcutsPage = lazy(() => import('@/views/Help/KeyboardShortcutsPage')); +// DEV/TEST-ONLY route list, spread into the route tree only in development. The +// `lazy(() => import(...))` for the WebGL fidelity-gate harness (ADR 0019, Stage +// 3) lives INSIDE the `import.meta.env.DEV` guard so the bundler drops BOTH the +// route AND the dynamic-import chunk from production builds — the harness is +// never present in `npm run build` output (verified: no FidelityHarness chunk +// and no `__fidelity__` route string in dist/). +function buildDevOnlyRoutes(): { path: string; element: ReactElement }[] { + if (!import.meta.env.DEV) return []; + const FidelityHarness = lazy(() => import('@/views/_dev/FidelityHarness')); + return [ + { + path: '__fidelity__', + element: ( + + + + ), + }, + ]; +} + +const devOnlyRoutes = buildDevOnlyRoutes(); + export const router = createBrowserRouter( [ { @@ -225,6 +248,7 @@ export const router = createBrowserRouter( }, ], }, + ...devOnlyRoutes, ], }, ], 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. */} +