From cf4e2ecae07e750fcd2d5723f59a24e2e5a24e99 Mon Sep 17 00:00:00 2001
From: Joe Sterne <5509615+joesterne@users.noreply.github.com>
Date: Fri, 24 Jul 2026 09:15:58 -0400
Subject: [PATCH] Improve visualizer canvas DPR scaling
### Motivation
- Ensure the popout visualizer renders crisply on high-DPI displays by sizing the canvas backing store to the device pixel ratio and keeping drawing coordinates in CSS pixels.
### Description
- Use `const dpr = window.devicePixelRatio || 1` when resizing the canvas and in the draw path.
- Set the backing store size with `canvas.width = window.innerWidth * dpr` and `canvas.height = window.innerHeight * dpr` while preserving CSS size with `canvas.style.width = '100vw'` and `canvas.style.height = '100vh'`.
- Scale the 2D context with `ctx.setTransform(dpr, 0, 0, dpr, 0, 0)` so renderer coordinates remain in CSS pixels and drawing remains correctly sized.
- Compute logical drawing dimensions as `const width = canvas.width / dpr` and `const height = canvas.height / dpr` and clear/draw using those values.
### Testing
- Ran `git diff --check` which passed without issues.
- Ran `npm run lint` (`tsc --noEmit`) which failed due to pre-existing duplicate block-scoped declarations in `src/components/Visualizer.tsx` unrelated to these changes, so lint did not complete successfully on the full project.
---
src/components/VisualizerWindow.tsx | 129 +++++++++++++++++++---------
1 file changed, 89 insertions(+), 40 deletions(-)
diff --git a/src/components/VisualizerWindow.tsx b/src/components/VisualizerWindow.tsx
index 71870c4..f178efc 100644
--- a/src/components/VisualizerWindow.tsx
+++ b/src/components/VisualizerWindow.tsx
@@ -8,47 +8,55 @@ const VisualizerWindow: React.FC = () => {
const [density, setDensity] = useState(10);
const [speed, setSpeed] = useState(1);
const [isSettingsOpen, setIsSettingsOpen] = useState(true);
+ const visualizerConfigRef = useRef({ mode, color, density, speed });
const modes: VisualizerMode[] = ['spectrum', 'oscilloscope', 'bars', 'circles', 'plasma', 'mirrorBars', 'radialPulse', 'waveDots'];
- useEffect(() => {
- const handleMessage = (event: MessageEvent) => {
- if (event.data?.type === 'VIZ_DATA') {
- const { data, mode: m, color: c, density: d, speed: s } = event.data;
- if (m) setMode(m);
- if (c) setColor(c);
- if (d) setDensity(d);
- if (s) setSpeed(s);
-
- draw(data, s ?? speed);
- }
- };
+ const updateMode = (nextMode: VisualizerMode) => {
+ visualizerConfigRef.current.mode = nextMode;
+ setMode(nextMode);
+ };
- window.addEventListener('message', handleMessage);
- return () => window.removeEventListener('message', handleMessage);
- }, []);
+ const updateColor = (nextColor: string) => {
+ visualizerConfigRef.current.color = nextColor;
+ setColor(nextColor);
+ };
+
+ const updateDensity = (nextDensity: number) => {
+ visualizerConfigRef.current.density = nextDensity;
+ setDensity(nextDensity);
+ };
- const draw = (data: Uint8Array, speedValue = speed) => {
+ const updateSpeed = (nextSpeed: number) => {
+ visualizerConfigRef.current.speed = nextSpeed;
+ setSpeed(nextSpeed);
+ };
+
+ const draw = (data: Uint8Array, speedValue = visualizerConfigRef.current.speed) => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
- const width = canvas.width;
- const height = canvas.height;
+ const dpr = window.devicePixelRatio || 1;
+ const width = canvas.width / dpr;
+ const height = canvas.height / dpr;
+ const { mode: currentMode, color: currentColor, density: currentDensity } = visualizerConfigRef.current;
const playbackSpeed = Math.max(0.25, speedValue);
+
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, width, height);
- if (mode === 'spectrum' || mode === 'bars') {
+ if (currentMode === 'spectrum' || currentMode === 'bars') {
const barWidth = (width / data.length) * playbackSpeed;
for (let i = 0; i < data.length; i++) {
const barHeight = (data[i] / 255) * height;
- ctx.fillStyle = color;
+ ctx.fillStyle = currentColor;
ctx.fillRect(i * barWidth, height - barHeight, barWidth - 1, barHeight);
}
- } else if (mode === 'oscilloscope') {
+ } else if (currentMode === 'oscilloscope') {
ctx.lineWidth = 2;
- ctx.strokeStyle = color;
+ ctx.strokeStyle = currentColor;
ctx.beginPath();
const sliceWidth = width / data.length;
let x = 0;
@@ -61,44 +69,44 @@ const VisualizerWindow: React.FC = () => {
}
ctx.lineTo(width, height / 2);
ctx.stroke();
- } else if (mode === 'circles') {
+ } else if (currentMode === 'circles') {
const centerX = width / 2;
const centerY = height / 2;
- for (let i = 0; i < density; i++) {
+ for (let i = 0; i < currentDensity; i++) {
const radius = (data[i] / 255) * (Math.min(width, height) / 2);
ctx.beginPath();
ctx.arc(centerX, centerY, radius, 0, 2 * Math.PI);
- ctx.strokeStyle = color;
+ ctx.strokeStyle = currentColor;
ctx.lineWidth = 2;
ctx.stroke();
}
- } else if (mode === 'plasma') {
+ } else if (currentMode === 'plasma') {
const centerX = width / 2;
const centerY = height / 2;
const gradient = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, Math.max(width, height) / 2);
- gradient.addColorStop(0, color);
+ gradient.addColorStop(0, currentColor);
gradient.addColorStop(1, 'transparent');
ctx.fillStyle = gradient;
const avg = data.reduce((a, b) => a + b, 0) / data.length;
ctx.globalAlpha = avg / 255;
ctx.fillRect(0, 0, width, height);
ctx.globalAlpha = 1.0;
- } else if (mode === 'mirrorBars') {
+ } else if (currentMode === 'mirrorBars') {
const halfHeight = height / 2;
const barWidth = Math.max(1, width / data.length);
- ctx.fillStyle = color;
+ ctx.fillStyle = currentColor;
for (let i = 0; i < data.length; i++) {
const amp = (data[i] / 255) * halfHeight;
const x = i * barWidth;
ctx.fillRect(x, halfHeight - amp, barWidth - 1, amp);
ctx.fillRect(x, halfHeight, barWidth - 1, amp);
}
- } else if (mode === 'radialPulse') {
+ } else if (currentMode === 'radialPulse') {
const centerX = width / 2;
const centerY = height / 2;
const baseRadius = Math.min(width, height) * 0.18;
const step = Math.max(4, Math.floor(data.length / 96));
- ctx.strokeStyle = color;
+ ctx.strokeStyle = currentColor;
ctx.beginPath();
for (let i = 0; i < data.length; i += step) {
const angle = (i / data.length) * Math.PI * 2;
@@ -110,11 +118,11 @@ const VisualizerWindow: React.FC = () => {
}
ctx.closePath();
ctx.stroke();
- } else if (mode === 'waveDots') {
+ } else if (currentMode === 'waveDots') {
const step = Math.max(2, Math.floor(data.length / 100));
const spacing = width / Math.ceil(data.length / step);
let x = 0;
- ctx.fillStyle = color;
+ ctx.fillStyle = currentColor;
for (let i = 0; i < data.length; i += step) {
const normalized = (data[i] - 128) / 128;
const y = height / 2 + normalized * (height * 0.35);
@@ -127,11 +135,52 @@ const VisualizerWindow: React.FC = () => {
}
};
+ useEffect(() => {
+ const handleMessage = (event: MessageEvent) => {
+ if (event.data?.type === 'VIZ_DATA') {
+ const { data, mode: nextMode, color: nextColor, density: nextDensity, speed: nextSpeed } = event.data;
+ const config = visualizerConfigRef.current;
+
+ if (nextMode && nextMode !== config.mode) {
+ config.mode = nextMode;
+ setMode(nextMode);
+ }
+
+ if (nextColor && nextColor !== config.color) {
+ config.color = nextColor;
+ setColor(nextColor);
+ }
+
+ if (typeof nextDensity === 'number' && nextDensity !== config.density) {
+ config.density = nextDensity;
+ setDensity(nextDensity);
+ }
+
+ if (typeof nextSpeed === 'number' && nextSpeed !== config.speed) {
+ config.speed = nextSpeed;
+ setSpeed(nextSpeed);
+ }
+
+ draw(data, config.speed);
+ }
+ };
+
+ window.addEventListener('message', handleMessage);
+ return () => window.removeEventListener('message', handleMessage);
+ }, []);
+
useEffect(() => {
const resize = () => {
- if (canvasRef.current) {
- canvasRef.current.width = window.innerWidth;
- canvasRef.current.height = window.innerHeight;
+ const canvas = canvasRef.current;
+ if (canvas) {
+ const dpr = window.devicePixelRatio || 1;
+ canvas.width = window.innerWidth * dpr;
+ canvas.height = window.innerHeight * dpr;
+ canvas.style.width = '100vw';
+ canvas.style.height = '100vh';
+
+ const ctx = canvas.getContext('2d');
+ ctx?.setTransform(dpr, 0, 0, dpr, 0, 0);
}
};
window.addEventListener('resize', resize);
@@ -156,7 +205,7 @@ const VisualizerWindow: React.FC = () => {
Mode