diff --git a/CHANGELOG.md b/CHANGELOG.md index 0998f4a..24d0f74 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 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. diff --git a/src/components/charts/webgl/glsl/__tests__/premultipliedAlpha.test.ts b/src/components/charts/webgl/glsl/__tests__/premultipliedAlpha.test.ts new file mode 100644 index 0000000..17ba328 --- /dev/null +++ b/src/components/charts/webgl/glsl/__tests__/premultipliedAlpha.test.ts @@ -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*;/); + }); +}); diff --git a/src/components/charts/webgl/glsl/envelope.ts b/src/components/charts/webgl/glsl/envelope.ts index 0a43106..162e9dc 100644 --- a/src/components/charts/webgl/glsl/envelope.ts +++ b/src/components/charts/webgl/glsl/envelope.ts @@ -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. @@ -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); } `; diff --git a/src/components/charts/webgl/glsl/line.ts b/src/components/charts/webgl/glsl/line.ts index 2e724f0..66735ae 100644 --- a/src/components/charts/webgl/glsl/line.ts +++ b/src/components/charts/webgl/glsl/line.ts @@ -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); } `; diff --git a/src/styles/tokens.css b/src/styles/tokens.css index e1aa193..51ebd68 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -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; @@ -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); diff --git a/src/views/Sessions/SignalViewer.module.css b/src/views/Sessions/SignalViewer.module.css index 98156d9..320c244 100644 --- a/src/views/Sessions/SignalViewer.module.css +++ b/src/views/Sessions/SignalViewer.module.css @@ -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%; @@ -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 { diff --git a/src/views/Sessions/SignalViewer.tsx b/src/views/Sessions/SignalViewer.tsx index 93d500b..0647a21 100644 --- a/src/views/Sessions/SignalViewer.tsx +++ b/src/views/Sessions/SignalViewer.tsx @@ -75,6 +75,11 @@ import { type LanePrefs, } from './laneState'; import { computeLaneDomain } from './signalDomain'; +import { + applyCursorAnchoredZoom, + pixelRangeToTimeRange, + wheelDeltaToZoomFactor, +} from './signalZoom'; import { buildWearableChannel, hypnogramBands, @@ -128,11 +133,13 @@ const ZOOM_PRESETS: readonly { label: string; ms: number | null }[] = [ { label: 'All', ms: null }, ]; -/** Zoom factor per wheel notch. */ -const ZOOM_FACTOR = 1.5; - -/** Minimum visible time window in ms (0.5 second). */ -const MIN_VIEWPORT_MS = 500; +/** + * Wheel-zoom sensitivity and the min/max zoom-span clamps now live in the pure + * {@link module:views/Sessions/signalZoom} helper (`WHEEL_ZOOM_RATE`, + * `MIN_VIEWPORT_MS`), so the sensitivity curve and the shift-drag pixel→time + * math are unit-testable without a browser. The product owner tunes feel via + * `WHEEL_ZOOM_RATE` there. + */ /** Default pixel height per CPAP channel strip. */ const CHANNEL_HEIGHT = 150; @@ -516,6 +523,36 @@ export default function SignalViewer() { const [isPanning, setIsPanning] = useState(false); const panStartRef = useRef<{ x: number; viewport: ViewportRange } | null>(null); + /** + * Active SHIFT-DRAG zoom-to-range selection (rubber-band), or `null`. + * `startX`/`currentX` are canvas-relative CSS px; `viewport` is the viewport + * the drag started over (the px→time mapping basis). A Shift+pointerdown starts + * this INSTEAD of a pan; releasing applies the selected time range as the new + * viewport. The visual band is a cheap positioned DOM element (no waveform + * repaint) driven by `selectionRect`. Mouse-only enhancement — keyboard users + * zoom via the existing preset buttons / wheel controls (unchanged). + */ + const selectionStartRef = useRef<{ + startX: number; + currentX: number; + viewport: ViewportRange; + } | null>(null); + + /** + * Whether Shift is currently held while the pointer is over the plot, so the + * cursor flips to a zoom/col-resize style affordance (discoverability). Updated + * by pointermove/keyboard listeners; independent of an in-flight selection. + */ + const [shiftZoomArmed, setShiftZoomArmed] = useState(false); + + /** + * Pixel rect of the live selection band ({left,width} in CSS px relative to the + * canvas wrapper), or `null` when no selection is in flight. Kept in React + * state because the band is a DOM element — it repaints only the band, never + * the waveform stack. + */ + const [selectionRect, setSelectionRect] = useState<{ left: number; width: number } | null>(null); + // Canvas dimensions const [canvasSize, setCanvasSize] = useState<{ width: number; height: number }>({ width: 0, @@ -1798,24 +1835,15 @@ export default function SignalViewer() { } : { startTime: viewportRef.current.startTime, endTime: viewportRef.current.endTime }); - const cursorTimeMs = prev.startTime + cursorFrac * (prev.endTime - prev.startTime); - const zoomIn = e.deltaY < 0; - const factor = zoomIn ? 1 / ZOOM_FACTOR : ZOOM_FACTOR; - const currentDuration = prev.endTime - prev.startTime; - let newDuration = currentDuration * factor; - newDuration = Math.max(MIN_VIEWPORT_MS, Math.min(totalDurationMs, newDuration)); - - let newStart = cursorTimeMs - cursorFrac * newDuration; - let newEnd = newStart + newDuration; - if (newStart < 0) { - newStart = 0; - newEnd = newDuration; - } - if (newEnd > totalDurationMs) { - newEnd = totalDurationMs; - newStart = Math.max(0, newEnd - newDuration); - } - const range = { startTime: newStart, endTime: newEnd }; + // Device-aware, GENTLE zoom factor: exp(-normalizedDelta * WHEEL_ZOOM_RATE) + // (see signalZoom). Mouse-wheel notches (DOM_DELTA_LINE) and trackpad pinch + // pixel streams (DOM_DELTA_PIXEL) are normalized to a common magnitude so + // neither feels jumpy, and the per-event delta is clamped so one fat delta + // can't teleport the zoom. The factor composes multiplicatively across the + // accumulating live viewport, and cursor-anchored zoom keeps the time under + // the pointer fixed. Sensitivity is the single WHEEL_ZOOM_RATE knob. + const factor = wheelDeltaToZoomFactor(e.deltaY, e.deltaMode); + const range = applyCursorAnchoredZoom(prev, factor, cursorFrac, totalDurationMs); liveViewportRef.current = range; // Coalesce paints to one per frame via the shared scheduler (same handle @@ -1852,6 +1880,21 @@ export default function SignalViewer() { }; }, [totalDurationMs, renderRangeDirect, commitLiveViewport, getPaintScheduler]); + // ── Shift-key cursor affordance (window-level keyup) ───────── + // + // Releasing Shift anywhere drops the "zoom-to-range" cursor affordance even if + // the pointer is parked over the plot (a pointermove may not follow). An + // IN-FLIGHT selection is intentionally NOT cancelled here — releasing Shift + // mid-drag still completes on pointerup (less surprising: the user already drew + // the band). The window-level keyup just keeps the cursor honest. + useEffect(() => { + const onKeyUp = (e: KeyboardEvent) => { + if (e.key === 'Shift') setShiftZoomArmed(false); + }; + window.addEventListener('keyup', onKeyUp); + return () => window.removeEventListener('keyup', onKeyUp); + }, []); + // ── Hovered-region hit-test (shared by pointer + keyboard) ─── /** @@ -1900,6 +1943,23 @@ export default function SignalViewer() { const handlePointerDown = useCallback( (e: React.PointerEvent) => { if (e.button !== 0) return; + + // SHIFT+drag starts a zoom-to-range SELECTION instead of a pan. The band is + // drawn as a cheap DOM element; on release the pixel range is converted to a + // time range and applied as the viewport. A plain drag (no Shift) pans. + if (e.shiftKey) { + const rect = canvasRef.current?.getBoundingClientRect(); + if (!rect) return; + const x = e.clientX - rect.left; + selectionStartRef.current = { startX: x, currentX: x, viewport: { ...viewport } }; + // Capture so the drag keeps tracking even when the pointer leaves the + // canvas (pointer-capture); see handlePointerMove/Up. + (e.target as HTMLElement).setPointerCapture(e.pointerId); + // Seed a zero-width band at the anchor; widened on move. + setSelectionRect(null); + return; + } + setIsPanning(true); panStartRef.current = { x: e.clientX, viewport: { ...viewport } }; panDxRef.current = 0; @@ -1911,6 +1971,34 @@ export default function SignalViewer() { [viewport], ); + /** + * Apply (or discard) the active shift-drag selection. Converts the pixel + * x-range to a clamped, min-span-floored time range via the pure + * {@link pixelRangeToTimeRange} helper and commits it to the viewport. A drag + * shorter than the helper's minimum (or a shift-click) is a no-op so the + * viewport never snaps to a sliver. Always clears the band + selection ref. + */ + const finishSelection = useCallback(() => { + const sel = selectionStartRef.current; + selectionStartRef.current = null; + setSelectionRect(null); + if (!sel) return; + const rect = canvasRef.current?.getBoundingClientRect(); + if (!rect) return; + const plotWidth = rect.width - PADDING.left - PADDING.right; + const result = pixelRangeToTimeRange( + sel.startX, + sel.currentX, + PADDING.left, + plotWidth, + sel.viewport, + totalDurationMs, + ); + if (result.kind === 'zoom') { + setViewport(result.range); + } + }, [totalDurationMs]); + const handlePointerMove = useCallback( (e: React.PointerEvent) => { const rect = canvasRef.current?.getBoundingClientRect(); @@ -1919,6 +2007,25 @@ export default function SignalViewer() { const x = e.clientX - rect.left; crosshairXRef.current = x; + // ACTIVE SHIFT-DRAG SELECTION: update the band's pixel rect (clamped to the + // plot band) and skip the crosshair/pan paths entirely. The band is a DOM + // element so this repaints only the band, never the waveform stack. + const sel = selectionStartRef.current; + if (sel) { + const plotLeft = PADDING.left; + const plotRight = rect.width - PADDING.right; + const clampedX = Math.max(plotLeft, Math.min(plotRight, x)); + sel.currentX = clampedX; + const left = Math.min(sel.startX, clampedX); + const width = Math.abs(clampedX - sel.startX); + setSelectionRect({ left, width }); + return; + } + + // Keep the shift-zoom cursor affordance in sync with the modifier state + // while hovering (no selection in flight). Only toggles state on change. + setShiftZoomArmed((armed) => (armed === e.shiftKey ? armed : e.shiftKey)); + // Non-obstructive hovered-region readout: hit-test the cursor time against // device events / detection episodes and surface the match in the sticky // legend bar. State only changes on region enter/exit/cross (guarded by @@ -1983,6 +2090,11 @@ export default function SignalViewer() { ); const handlePointerUp = useCallback(() => { + // A shift-drag selection takes precedence over a pan: apply (or discard) it. + if (selectionStartRef.current) { + finishSelection(); + return; + } setIsPanning(false); panStartRef.current = null; panDxRef.current = 0; @@ -1992,10 +2104,19 @@ export default function SignalViewer() { paintSchedulerRef.current?.cancel(); rendererRef.current?.endPan(); commitLiveViewport(); - }, [commitLiveViewport]); + }, [commitLiveViewport, finishSelection]); const handlePointerLeave = useCallback(() => { crosshairXRef.current = null; + // Drop the shift-cursor affordance once the pointer leaves the plot. + setShiftZoomArmed(false); + // A selection in flight when the pointer leaves WITHOUT pointer-capture (the + // capture normally keeps events flowing): apply what was selected so the + // gesture completes gracefully rather than silently vanishing. With capture + // active this rarely fires mid-drag — pointerup handles the common case. + if (selectionStartRef.current) { + finishSelection(); + } // Clear the hovered-region readout (only if it isn't already empty) and reset // the identity ref so the next hover re-enters cleanly. if (hoveredKeyRef.current !== '') { @@ -2023,7 +2144,7 @@ export default function SignalViewer() { crosshairX: null, }); } - }, [isPanning, commitLiveViewport]); + }, [isPanning, commitLiveViewport, finishSelection]); // ── Keyboard data cursor (arrow keys move crosshair) ───────── @@ -2463,6 +2584,7 @@ export default function SignalViewer() { ref={canvasWrapperRef} className={styles.canvasWrapper} data-panning={isPanning} + data-shiftzoom={shiftZoomArmed || selectionRect !== null} onPointerDown={handlePointerDown} onPointerMove={handlePointerMove} onPointerUp={handlePointerUp} @@ -2502,6 +2624,20 @@ export default function SignalViewer() { aria-hidden="true" /> + {/* Shift-drag zoom-to-range selection band. A cheap full-height DOM + element spanning the dragged x-range (theme-tokened, semi-transparent + with a subtle border); only the band repaints during the drag — never + the waveform stack. aria-hidden: this is a mouse-only enhancement; + keyboard users zoom via the preset buttons / wheel. Rendered only + while a selection is in flight. */} + {selectionRect !== null && ( +