Interaction
Pluggable canvas visualizer
One canvas, a registry of draw modes, and the rule that each mode owns its entire frame — including the background — so a persistence mode can wash instead of clear.
- canvas
- visualization
- registry
- animation
- reduced-motion
<div class="pcv-panel">
<div class="pcv-controls" role="group" aria-label="Visualization mode">
<button type="button" class="pcv-btn is-on" data-pcv-mode="bars">Bars + peak hold</button>
<button type="button" class="pcv-btn" data-pcv-mode="scope">Oscilloscope</button>
<button type="button" class="pcv-btn" data-pcv-mode="vu">VU needles</button>
<button type="button" class="pcv-btn" data-pcv-mode="matrix">Dot matrix</button>
<button type="button" class="pcv-btn" data-pcv-mode="radial">Radial</button>
<button type="button" class="pcv-btn" data-pcv-mode="ambience">Ambience</button>
<button type="button" class="pcv-btn" id="pcv-pause">Pause</button>
</div>
<div class="pcv-wrap"><canvas id="pcv-canvas"></canvas></div>
</div>Colors come from shared theme tokens — --surface, --ink, --border, --accent and friends — so this CSS carries no palette
of its own. Use Runnable file to copy the tokens along with it.
.pcv-panel { display: flex; flex-direction: column; gap: 0.75rem; }
.pcv-controls { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.pcv-btn {
font: inherit;
font-size: 0.9375rem;
padding: 0.4rem 0.8rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface);
color: var(--ink);
cursor: pointer;
}
.pcv-btn.is-on {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-ink);
}
.pcv-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.pcv-wrap {
background: var(--ground);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
#pcv-canvas { display: block; width: 100%; height: 240px; }(function () {
var canvas = document.getElementById('pcv-canvas');
var context = canvas.getContext('2d');
var root = document.documentElement;
var reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var binCount = 64;
var spectrum = new Uint8Array(binCount);
var waveform = new Uint8Array(256);
var peaks = new Float32Array(binCount);
var needle = { position: 0, velocity: 0 };
var mode = 'bars';
var running = true;
var clock = 0;
var frameCount = 0;
// Deterministic value noise. A real analyser's hiss is genuinely random, but
// a seeded hash keeps a screenshot of frame N identical between runs, which
// is what makes this reproducible in a visual test.
function hashNoise(first, second) {
var mixed = Math.sin(first * 127.1 + second * 311.7) * 43758.5453;
return mixed - Math.floor(mixed);
}
// A synthetic signal generator so the pattern needs no audio permission and
// no media file. It has the same shape a real analyser node would hand back:
// a frequency-magnitude array and a time-domain waveform array, both 0-255.
function synthesize() {
clock += 0.016;
frameCount += 1;
for (var bin = 0; bin < binCount; bin += 1) {
var tilt = Math.pow(1 - bin / binCount, 1.6);
var beat = 0.55 + 0.45 * Math.sin(clock * 3.1 + bin * 0.08);
var slow = 0.6 + 0.4 * Math.sin(clock * 0.7 + bin * 0.02);
var noise = hashNoise(bin, frameCount) * 0.22;
spectrum[bin] = Math.max(0, Math.min(255, (tilt * beat * slow + noise) * 235));
}
for (var sample = 0; sample < waveform.length; sample += 1) {
var phase = (sample / waveform.length) * Math.PI * 2;
var value =
Math.sin(phase * 3 + clock * 5) * 0.5 +
Math.sin(phase * 7 + clock * 2) * 0.25 +
Math.sin(phase * 13 + clock * 9) * 0.12 +
(hashNoise(sample, frameCount) - 0.5) * 0.06;
waveform[sample] = Math.max(0, Math.min(255, 128 + value * 110));
}
}
// Read the palette from the live custom properties, not a fixed table, so
// every mode follows a theme switch without the visualizer knowing it happened.
function palette() {
var styles = getComputedStyle(root);
return {
ground: styles.getPropertyValue('--ground').trim() || '#10141a',
accent: styles.getPropertyValue('--accent').trim() || '#6f92bd',
dim: styles.getPropertyValue('--dim').trim() || '#97a4b4',
ink: styles.getPropertyValue('--ink').trim() || '#e6ecf3'
};
}
function barHeights(count) {
var out = new Float32Array(count);
for (var index = 0; index < count; index += 1) {
var from = Math.floor((index * binCount) / count);
var to = Math.max(from + 1, Math.floor(((index + 1) * binCount) / count));
var total = 0;
for (var bin = from; bin < to; bin += 1) total += spectrum[bin];
out[index] = total / (to - from) / 255;
}
return out;
}
// Every mode is a plain (frame) => void that paints the *whole* canvas,
// background included. The host never clears between frames -- that single
// rule is what makes a persistence mode (see "ambience") possible at all,
// and it lets a consuming app register a replacement mode under an existing
// key without forking this file.
var modes = {
bars: function (frame) {
var colors = frame.colors,
width = frame.width,
height = frame.height;
context.fillStyle = colors.ground;
context.fillRect(0, 0, width, height);
var count = 48,
gap = 3;
var barWidth = (width - gap * (count - 1)) / count;
var heights = barHeights(count);
for (var index = 0; index < count; index += 1) {
var value = heights[index];
// Peak hold: the marker only falls, and only at a fixed decay rate.
peaks[index] = Math.max(value, Math.max(peaks[index] - 0.015, 0));
var x = index * (barWidth + gap);
context.fillStyle = colors.accent;
context.fillRect(x, height - value * height, barWidth, value * height);
context.fillStyle = colors.ink;
context.fillRect(x, height - peaks[index] * height - 2, barWidth, 2);
}
},
scope: function (frame) {
var colors = frame.colors,
width = frame.width,
height = frame.height;
context.fillStyle = colors.ground;
context.fillRect(0, 0, width, height);
context.strokeStyle = colors.accent;
context.lineWidth = 2;
context.beginPath();
for (var sample = 0; sample < waveform.length; sample += 1) {
var x = (sample / (waveform.length - 1)) * width;
var y = height / 2 + ((waveform[sample] - 128) / 128) * (height / 2 - 8);
if (sample === 0) context.moveTo(x, y);
else context.lineTo(x, y);
}
context.stroke();
},
vu: function (frame) {
var colors = frame.colors,
width = frame.width,
height = frame.height;
context.fillStyle = colors.ground;
context.fillRect(0, 0, width, height);
var total = 0;
for (var sample = 0; sample < waveform.length; sample += 1) {
var normalized = (waveform[sample] - 128) / 128;
total += normalized * normalized;
}
var rootMeanSquare = Math.sqrt(total / waveform.length);
// Spring-damper integrated once per frame, clamped just above 1 so the
// needle can overshoot into the red without leaving the dial.
var stiffness = 0.18,
damping = 0.3;
var acceleration = stiffness * (rootMeanSquare - needle.position) - damping * needle.velocity;
needle.velocity += acceleration;
needle.position = Math.max(0, Math.min(1.05, needle.position + needle.velocity));
[0.28, 0.72].forEach(function (share, dialIndex) {
var centerX = width * share;
var centerY = height * 0.86;
var radius = Math.min(width * 0.2, height * 0.72);
context.strokeStyle = colors.dim;
context.lineWidth = 2;
context.beginPath();
context.arc(centerX, centerY, radius, Math.PI * 1.17, Math.PI * 1.83);
context.stroke();
context.strokeStyle = colors.accent;
context.lineWidth = 4;
context.beginPath();
context.arc(centerX, centerY, radius, Math.PI * 1.66, Math.PI * 1.83);
context.stroke();
var wobble = dialIndex === 0 ? 1 : 0.88;
var angle = Math.PI * 1.17 + Math.PI * 0.66 * Math.min(1, needle.position * wobble);
context.strokeStyle = colors.ink;
context.lineWidth = 2.5;
context.beginPath();
context.moveTo(centerX, centerY);
context.lineTo(
centerX + Math.cos(angle) * radius * 0.94,
centerY + Math.sin(angle) * radius * 0.94
);
context.stroke();
});
},
matrix: function (frame) {
var colors = frame.colors,
width = frame.width,
height = frame.height;
context.fillStyle = colors.ground;
context.fillRect(0, 0, width, height);
var columns = 40,
rows = 14;
var heights = barHeights(columns);
var cellWidth = width / columns,
cellHeight = height / rows;
var dot = Math.min(cellWidth, cellHeight) * 0.42;
for (var column = 0; column < columns; column += 1) {
var lit = heights[column] * rows;
for (var row = 0; row < rows; row += 1) {
var fromBottom = rows - 1 - row;
var intensity =
fromBottom < Math.floor(lit) ? 1 : fromBottom === Math.floor(lit) ? lit % 1 : 0;
context.globalAlpha = intensity > 0 ? 0.25 + intensity * 0.75 : 0.18;
context.fillStyle = intensity > 0 ? colors.accent : colors.dim;
context.beginPath();
context.arc(
column * cellWidth + cellWidth / 2,
row * cellHeight + cellHeight / 2,
dot,
0,
Math.PI * 2
);
context.fill();
}
}
context.globalAlpha = 1;
},
radial: function (frame) {
var colors = frame.colors,
width = frame.width,
height = frame.height;
context.fillStyle = colors.ground;
context.fillRect(0, 0, width, height);
var centerX = width / 2,
centerY = height / 2;
var radius = Math.min(width, height) * 0.46;
var inner = 0.42;
var count = 72;
var heights = barHeights(count);
context.strokeStyle = colors.accent;
context.lineWidth = Math.max(2, (Math.PI * 2 * radius * inner) / count - 2);
for (var index = 0; index < count; index += 1) {
var angle = -Math.PI / 2 + (index / count) * Math.PI * 2;
var reach = inner + heights[index] * (1 - inner);
context.beginPath();
context.moveTo(centerX + Math.cos(angle) * radius * inner, centerY + Math.sin(angle) * radius * inner);
context.lineTo(centerX + Math.cos(angle) * radius * reach, centerY + Math.sin(angle) * radius * reach);
context.stroke();
}
},
ambience: function (frame) {
var colors = frame.colors,
width = frame.width,
height = frame.height;
// Persistence: a near-opaque wash over the previous frame instead of a
// clear, so the trail decays instead of vanishing. globalAlpha is used
// rather than an rgba() string built from the token, because a palette
// entry is as likely to be a CSS custom property as a hex value, and
// splicing an alpha channel into an arbitrary custom property silently
// parses to nothing.
context.globalAlpha = 0.06;
context.fillStyle = colors.ground;
context.fillRect(0, 0, width, height);
context.globalAlpha = 1;
var hue = (clock * 45) % 360;
context.strokeStyle = 'hsl(' + hue + ', 85%, 60%)';
context.shadowBlur = 12;
context.shadowColor = 'hsl(' + hue + ', 85%, 60%)';
context.lineWidth = 2;
context.beginPath();
for (var sample = 0; sample < waveform.length; sample += 1) {
var x = (sample / (waveform.length - 1)) * width;
var y = height / 2 + ((waveform[sample] - 128) / 128) * (height / 2 - 10);
if (sample === 0) context.moveTo(x, y);
else context.lineTo(x, y);
}
context.stroke();
context.shadowBlur = 0;
}
};
// Size the backing buffer to the element's CSS size times the device pixel
// ratio, then remap the context so every draw function above still works in
// plain CSS pixels.
function sizeCanvas() {
var ratio = window.devicePixelRatio || 1;
var rect = canvas.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return false;
canvas.width = Math.round(rect.width * ratio);
canvas.height = Math.round(rect.height * ratio);
context.setTransform(ratio, 0, 0, ratio, 0, 0);
return true;
}
function paintFrame() {
// An element with no layout yet (display: none, not yet attached) reports
// a zero-size rect. Painting into that would divide by zero downstream in
// every mode above, so the frame is skipped rather than guarded per mode.
if (!sizeCanvas()) return;
synthesize();
var rect = canvas.getBoundingClientRect();
modes[mode]({ width: rect.width, height: rect.height, colors: palette() });
}
function tick() {
if (running) paintFrame();
requestAnimationFrame(tick);
}
function resetScratchState() {
// Scratch state belongs to the mode that wrote it. Wiping it on every
// switch is what stops a peak-hold trail or a needle's velocity from
// bleeding into a mode that never produced it.
peaks.fill(0);
needle.position = 0;
needle.velocity = 0;
context.clearRect(0, 0, canvas.width, canvas.height);
}
Array.prototype.forEach.call(document.querySelectorAll('[data-pcv-mode]'), function (button) {
button.addEventListener('click', function () {
mode = button.getAttribute('data-pcv-mode');
resetScratchState();
Array.prototype.forEach.call(document.querySelectorAll('[data-pcv-mode]'), function (other) {
other.classList.toggle('is-on', other === button);
});
if (reducedMotion) paintFrame();
});
});
var pauseButton = document.getElementById('pcv-pause');
if (reducedMotion) {
// A single static frame stands in for the animation loop entirely --
// there is nothing running for a pause control to pause.
pauseButton.disabled = true;
pauseButton.textContent = 'Static frame';
paintFrame();
} else {
pauseButton.addEventListener('click', function () {
running = !running;
pauseButton.textContent = running ? 'Pause' : 'Resume';
});
requestAnimationFrame(tick);
}
})();Paste this into an agent to rebuild the pattern from scratch.
Build a canvas visualizer that renders live data through a swappable set of draw modes, chosen from a registry rather than an if/else ladder, so a consuming app can add or override a mode by name without forking the component.
Reach for this whenever a canvas needs more than one look at the same underlying signal — an audio meter with a bars view and a needle view, a metrics sparkline that can switch to a heatmap, anything where "visualization type" is a setting rather than a fixed decision. Skip it for a single fixed chart with no mode switch in its future; the registry indirection buys nothing until there is a second mode.
The core rule is the whole pattern: a draw mode is a plain function of shape `(frame) => void` that paints the *entire* canvas on every call, including the background. The host component never clears between frames. That single constraint is what makes a persistence mode possible — a mode can wash the previous frame with a near-transparent fill instead of erasing it, producing a fading trail, which a "the host clears, then the mode draws" architecture cannot do without a special case. It also means a consuming app can register a replacement under an existing mode's key and nothing else has to change.
Each mode receives a frame object carrying the canvas's CSS width and height and a palette read fresh from `getComputedStyle(document.documentElement)` on every frame, not cached at startup, so the visualizer tracks a live theme toggle. Size the backing buffer to `clientWidth/clientHeight * devicePixelRatio`, then call `context.setTransform(dpr, 0, 0, dpr, 0, 0)` once so every mode's drawing code can work in plain CSS pixels. Skip the frame entirely, rather than guarding every mode individually, when the element reports a zero-size layout box — that happens whenever the canvas isn't attached or visible yet, and drawing into it divides by zero downstream.
Any mode that keeps scratch state between frames — a peak-hold array, needle velocity — must have that state wiped on every mode switch, or a trail from the previous mode bleeds into the new one's first few frames. A peak-hold marker follows `peak = max(current, max(previousPeak - decay, 0))`: it snaps up instantly and falls at a fixed rate. A needle-style meter is a spring-damper integrated once per frame — `acceleration = stiffness * (target - position) - damping * velocity` — with the position clamped just past 1 so it can overshoot into a redline without ever leaving the dial. A persistence/trail mode fades the previous frame with `context.globalAlpha` rather than building an rgba() string from a palette value, because a theme token is as likely to be a CSS custom property as a literal hex color, and splicing an alpha channel into an arbitrary custom-property string silently produces nothing.
Drive the demo from a synthetic signal generator rather than a live audio graph, so the pattern can be evaluated with no permission prompt and no media file — a few layered sine waves with noise stand in for a frequency spectrum and a waveform convincingly enough to exercise every mode. Wrap the whole animation loop in a `prefers-reduced-motion` check and render one static frame instead of starting `requestAnimationFrame`.