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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ and this project uses [Calendar Versioning](https://calver.org/) with the format

### Changed

- **Signal Viewer zoom is gentler and gains drag-to-zoom-into-a-range.** Wheel/pinch zoom was far too sensitive — a single notch jumped the view span by 50%, so it was easy to overshoot and hunt. Zoom is now **device-aware and much gentler**: a mouse-wheel notch changes the span by only a few percent, and trackpad pinch (which streams many tiny deltas) is normalized to the same feel, with a per-event cap so one outsized delta can't teleport the zoom. The time under the cursor stays fixed while zooming. A new **Shift+drag rubber-band** lets you select a time range directly: hold Shift and drag horizontally to draw a semi-transparent selection band, then release to zoom the viewport to exactly that range (a Shift-click or a tiny drag is ignored, and the selection never zooms below the maximum zoom-in limit). While Shift is held over the plot the cursor switches to a horizontal-range affordance for discoverability. Keyboard users continue to zoom via the existing preset buttons. The wheel-zoom sensitivity lives in a single tunable constant (`WHEEL_ZOOM_RATE`).
- **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.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/**
* Regression guard for the WebGL waveform white-fringe bug.
*
* The WebGL2 context is created with `premultipliedAlpha: true` and the renderer
* blends with `gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA)`. With that setup the
* drawing buffer holds PREMULTIPLIED colour, so the fragment shaders MUST output
* premultiplied colour (`rgb * a, a`). If a shader instead leaves RGB at full
* brightness while only the alpha feathers (`vec4(u_color.rgb, ... * coverage)`),
* the compositor un-premultiplies the low edge-alpha and AA edges bloom toward
* white — the visible fringe the user reported. These string-level assertions are
* pure (no GPU) and fail fast if either shader drifts back to straight-alpha
* output. The actual rendered fidelity is validated by the CI pixel-diff gate.
*/

import { describe, expect, it } from 'vitest';

import { ENVELOPE_FRAGMENT_SHADER } from '../envelope';
import { LINE_FRAGMENT_SHADER } from '../line';

/** Strip GLSL line/block comments so we assert on real code, not prose. */
function stripComments(src: string): string {
return src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '');
}

describe('WebGL waveform fragment shaders (premultiplied alpha)', () => {
it('line shader premultiplies RGB by the same alpha that feathers the edge', () => {
const code = stripComments(LINE_FRAGMENT_SHADER);
// Edge factor is computed as a coverage term...
expect(code).toMatch(/coverage\s*=/);
// ...folded into the output alpha...
expect(code).toMatch(/float\s+a\s*=\s*u_color\.a\s*\*\s*coverage/);
// ...and RGB is premultiplied by that same alpha.
expect(code).toMatch(/fragColor\s*=\s*vec4\(\s*u_color\.rgb\s*\*\s*a\s*,\s*a\s*\)/);
// Guard against the bug: straight-alpha output (full-brightness RGB).
expect(code).not.toMatch(/fragColor\s*=\s*vec4\(\s*u_color\.rgb\s*,/);
});

it('envelope shader outputs premultiplied colour (rgb*a, a)', () => {
const code = stripComments(ENVELOPE_FRAGMENT_SHADER);
expect(code).toMatch(
/fragColor\s*=\s*vec4\(\s*u_color\.rgb\s*\*\s*u_color\.a\s*,\s*u_color\.a\s*\)/,
);
// Guard against the previous straight passthrough `fragColor = u_color;`.
expect(code).not.toMatch(/fragColor\s*=\s*u_color\s*;/);
});
});
18 changes: 13 additions & 5 deletions src/components/charts/webgl/glsl/envelope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@
* 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_color` (vec4): resolved lane colour as STRAIGHT (non-premultiplied)
* RGBA in 0..1. The fragment shader premultiplies it (`rgb*a, a`) before output
* to match the premultipliedAlpha:true context and (ONE, ONE_MINUS_SRC_ALPHA)
* blend, so silhouette/MSAA edges fade to transparent black, not white.
* - `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.
Expand Down Expand Up @@ -61,10 +64,15 @@ 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;
// The rasteriser covers the band's interior; GPU MSAA (antialias:true) feathers
// the silhouette by coverage-weighting this fragment's output against the
// destination. The context is premultipliedAlpha:true with (ONE, ONE_MINUS_SRC_ALPHA)
// blending, so the drawing buffer holds PREMULTIPLIED colour — output premultiplied.
// For an opaque lane (a == 1) this equals the straight colour, so the interior is
// unchanged; but at a partially-covered silhouette sample (or a translucent lane)
// premultiplying keeps the edge fading toward transparent black instead of blooming
// toward white, matching the line treatment and the Canvas2D fill AA.
fragColor = vec4(u_color.rgb * u_color.a, u_color.a);
}
`;

Expand Down
13 changes: 10 additions & 3 deletions src/components/charts/webgl/glsl/line.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,9 +108,16 @@ void main() {
// 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);
float coverage = 1.0 - smoothstep(v_halfWidth - 0.5, v_halfWidth + aa - 0.5, d);
if (coverage <= 0.0) discard;
// The context is premultipliedAlpha:true and blending is (ONE, ONE_MINUS_SRC_ALPHA),
// so the drawing buffer holds PREMULTIPLIED colour. Output premultiplied: scale RGB
// by the same alpha that feathers the edge. If we left RGB at full brightness while
// only alpha fell off, the compositor would (un)premultiply by the low edge alpha and
// the feathered pixels would bloom toward white — the classic AA halo. Premultiplying
// makes edges fade toward transparent black, revealing the dark chart cleanly.
float a = u_color.a * coverage;
fragColor = vec4(u_color.rgb * a, a);
}
`;

Expand Down
11 changes: 11 additions & 0 deletions src/styles/tokens.css
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,12 @@
/* SIGNAL VIEWER CROSSHAIR */
--color-crosshair: rgba(82, 82, 82, 0.55);

/* SIGNAL VIEWER SHIFT-DRAG ZOOM-TO-RANGE SELECTION BAND
Semi-transparent fill + a subtle border, tinted with the primary accent so
it reads as an interactive selection in both light and dark themes. */
--color-selection-band-bg: rgba(37, 99, 235, 0.16);
--color-selection-band-border: rgba(37, 99, 235, 0.6);

/* SIGNAL VIEWER LANE METRICS */
--signal-lane-height: 150px;
--signal-lane-height-hero: 200px;
Expand Down Expand Up @@ -322,6 +328,11 @@
/* SIGNAL VIEWER CROSSHAIR */
--color-crosshair: rgba(220, 220, 220, 0.5);

/* SIGNAL VIEWER SHIFT-DRAG ZOOM-TO-RANGE SELECTION BAND (dark) — brighter
accent + slightly higher fill alpha so it stays visible over dark waveforms. */
--color-selection-band-bg: rgba(96, 165, 250, 0.2);
--color-selection-band-border: rgba(96, 165, 250, 0.7);

/* SIGNAL VIEWER LANE METRICS — dark-mode lane-name halo */
--signal-lane-name-shadow: rgba(0, 0, 0, 0.95);

Expand Down
26 changes: 26 additions & 0 deletions src/views/Sessions/SignalViewer.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,14 @@
cursor: grabbing;
}

/* Shift held over the plot (or an active shift-drag selection): show a horizontal
zoom/range affordance so zoom-to-range is discoverable. `col-resize` reads as
"drag a horizontal range" across browsers without a custom cursor asset. Wins
over the default `crosshair` (declared later / more specific). */
.canvasWrapper[data-shiftzoom='true'] {
cursor: col-resize;
}

.canvas {
display: block;
width: 100%;
Expand Down Expand Up @@ -512,6 +520,24 @@
pointer-events: none;
}

/* Shift-drag zoom-to-range selection band. A full-height, semi-transparent band
spanning the dragged x-range, positioned (left/width in px) inline by the JSX.
pointer-events:none so the pointer events still reach the wrapper underneath.
Sits above the waveform/overlay canvases but below the lane headers, and uses
theme tokens so it works in light and dark. Only painted while a selection is
in flight, so it never costs anything at rest. */
.selectionBand {
position: absolute;
top: 0;
height: 100%;
pointer-events: none;
background: var(--color-selection-band-bg);
border-left: 1px solid var(--color-selection-band-border);
border-right: 1px solid var(--color-selection-band-border);
box-sizing: border-box;
z-index: 1;
}

/* ── Status bar ───────────────────────────────────────────────── */

.statusBar {
Expand Down
Loading
Loading