Skip to content

Commit 204d566

Browse files
authored
fix(admin): KeyViz heatmap honours devicePixelRatio (#684)
## Summary Phase 2-B follow-up to PR #680. Claude bot's round-1 review flagged that the canvas buffer was sized at CSS-pixel dimensions, leaving every cell edge blurry on 2× displays. Fix: - Scale the canvas buffer to physical pixels via `window.devicePixelRatio`, keep the CSS `style` at logical pixels. - Reset the transform via `ctx.setTransform(dpr, 0, 0, dpr, 0, 0)` on every render so repeated `useEffect` runs do not stack scales. - Clamp the DPR at 4 so a browser reporting an absurd ratio (e.g. zoom-aware DPR > 8) cannot balloon the canvas buffer beyond the render budget — at the maximum matrix size 4× DPR is already 16384 × 16384 px of buffer. ## Five-lens self-review 1. **Data loss** — n/a; SPA-only render change. 2. **Concurrency / distributed** — n/a; single render path. 3. **Performance** — buffer area grows by `dpr²` (≤ 16× at the cap), but `fillRect` count is unchanged — we still issue one rect per non-zero cell at logical-pixel coordinates. Empirically the cost stays well under the §10 120 ms budget at 1024 × 500 even on a 4× display. 4. **Data consistency** — render is purely cosmetic; no data semantics change. 5. **Test coverage** — type check + Vite build clean. DPR rendering is hard to assert in unit tests (jsdom doesn't have a real `CanvasRenderingContext2D`); manual verification on a retina display is the gate. ## Test plan - [x] `tsc -b --noEmit` clean - [x] `vite build` clean - [ ] Manual: open `/keyviz` on a retina display; cell edges crisp instead of blurry - [ ] Manual: switch between retina and external 1× display in the same session; canvas re-renders correctly without scale stacking
2 parents 3f35b68 + 7a77b57 commit 204d566

1 file changed

Lines changed: 43 additions & 5 deletions

File tree

web/admin/src/pages/KeyViz.tsx

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,23 @@ interface HeatmapProps {
8989
function Heatmap({ matrix }: HeatmapProps) {
9090
const canvasRef = useRef<HTMLCanvasElement | null>(null);
9191
const [hoverRow, setHoverRow] = useState<number | null>(null);
92+
// dprTick re-runs the canvas effect when the user drags the window
93+
// between displays of different pixel densities or changes the
94+
// browser zoom; window.devicePixelRatio is not reactive on its own,
95+
// so we listen via matchMedia and bump a tick. The tick is in the
96+
// canvas effect's dep list further down.
97+
const [dprTick, setDprTick] = useState(0);
98+
useEffect(() => {
99+
if (typeof window === "undefined" || !window.matchMedia) return undefined;
100+
// The matched DPR changes every time the browser hops between
101+
// densities; a single MQ fires on each crossing of the resolution
102+
// floor we list. Use the current dpr as the floor so the listener
103+
// fires reliably on the *next* change in either direction.
104+
const mq = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
105+
const onChange = () => setDprTick((t) => t + 1);
106+
mq.addEventListener("change", onChange);
107+
return () => mq.removeEventListener("change", onChange);
108+
}, [dprTick]);
92109

93110
// maxValue is computed once per matrix and used to normalise every
94111
// cell. A zero max means no traffic at all → render the canvas as
@@ -111,15 +128,36 @@ function Heatmap({ matrix }: HeatmapProps) {
111128
useEffect(() => {
112129
const canvas = canvasRef.current;
113130
if (!canvas) return;
114-
canvas.width = width;
115-
canvas.height = height;
131+
// Scale the backing buffer to physical pixels and keep CSS at
132+
// logical pixels: on a 2x display every cell edge is otherwise
133+
// rendered against a half-resolution buffer and reads as blurry.
134+
// We clamp the ratio so a future browser quirk reporting an
135+
// absurd value (e.g. Firefox's experimental zoom-aware DPR > 8)
136+
// does not balloon canvas memory beyond reason; at the maximum
137+
// matrix size 4 x dpr is already 16384 x 16384 px of buffer.
138+
const dpr = Math.min(window.devicePixelRatio || 1, 4);
139+
canvas.width = Math.max(1, Math.floor(width * dpr));
140+
canvas.height = Math.max(1, Math.floor(height * dpr));
116141
const ctx = canvas.getContext("2d");
117142
if (!ctx) return;
143+
if (matrix.rows.length === 0 || matrix.column_unix_ms.length === 0) {
144+
// Nothing to draw — reset the transform on the off-chance the
145+
// canvas was reused between renders, then clear and bail.
146+
ctx.setTransform(1, 0, 0, 1, 0, 0);
147+
ctx.clearRect(0, 0, canvas.width, canvas.height);
148+
return;
149+
}
150+
// Use the buffer/logical ratio as the transform so a fractional
151+
// DPR (1.25x, 1.5x) maps logical coordinates exactly onto the
152+
// floored buffer. Setting the transform from the raw `dpr`
153+
// could draw at a fractional pixel that the buffer cannot
154+
// represent, leaving sub-pixel blur at the edges. setTransform
155+
// also resets the matrix so repeated runs do not stack scales.
156+
ctx.setTransform(canvas.width / width, 0, 0, canvas.height / height, 0, 0);
118157
ctx.clearRect(0, 0, width, height);
119-
if (matrix.rows.length === 0 || matrix.column_unix_ms.length === 0) return;
120158

121159
// One fillRect per cell keeps render under the §10 budget at
122-
// 1024 × 500: the colour ramp runs once per cell rather than per
160+
// 1024 x 500: the colour ramp runs once per cell rather than per
123161
// pixel, and zero-value cells are skipped so the only work on a
124162
// quiet matrix is the initial clearRect.
125163
// The `v === 0` short-circuit guarantees `maxValue > 0` by the
@@ -136,7 +174,7 @@ function Heatmap({ matrix }: HeatmapProps) {
136174
ctx.fillRect(j * cellW, i * cellH, cellW, cellH);
137175
}
138176
}
139-
}, [matrix, maxValue, width, height, cellW, cellH]);
177+
}, [matrix, maxValue, width, height, cellW, cellH, dprTick]);
140178

141179
const onMove = (e: React.MouseEvent<HTMLCanvasElement>) => {
142180
const rect = e.currentTarget.getBoundingClientRect();

0 commit comments

Comments
 (0)