7
Newton's Color-Disk Spin
arrows or mouse-Y to spin faster/slower
idle
234 lines · vanilla
view source
// Newton's Color-Disk Spin.
// Seven ROYGBIV wedges on a spinning disc. As angular velocity climbs, the
// eye's persistence of vision smears the sectors into a creamy off-white.
// We fake POV by drawing the disc N times per frame, each rotated by a slice
// of the angular distance swept over dt, with alpha=1/N on a fresh buffer.
// That's a true temporal average of where each wedge was during the frame.
// A small instrument samples the mean RGB of the disc area each frame.
//
// Controls: ↑/↓ spin up/down. W/S as aliases. Mouse-Y maps to ω when held.
const TAU = Math.PI * 2;
// Realistic-ish spectral RGB approximations for ROYGBIV (sRGB 8-bit).
// Slightly desaturated versus pure RGB so the fused gray is convincingly
// creamy rather than greenish (which is what pure 0,255,0 would produce).
const WEDGE_COLORS = [
[220, 40, 40], // red
[240, 130, 30], // orange
[240, 220, 50], // yellow
[ 60, 190, 70], // green
[ 40, 110, 220], // blue
[ 70, 50, 180], // indigo
[150, 60, 200], // violet
];
const N_WEDGES = WEDGE_COLORS.length;
let W = 0, H = 0;
let cx = 0, cy = 0, R = 0;
// Pre-rendered disc at angle 0. We rotate it via setTransform+drawImage so
// each sub-sample is a single bitmap blit, not 7 wedge fills.
let stampCanvas = null;
let stampCtx = null;
let stampSize = 0;
// Compositing buffer that accumulates the N rotated copies for the current
// frame. Drawn onto the main canvas in one go.
let smear = null;
let smearCtx = null;
// Tiny readback target for the mean-color instrument. Downsampling the disc
// to ~64x64 before getImageData keeps the per-frame cost cheap.
const READBACK = 64;
let readback = null;
let readbackCtx = null;
let theta = 0; // current rotation angle
let omega = 0; // angular velocity, rad/s
let omegaTarget = 0; // smoothed target (keyboard nudges it)
let lastMeanR = 128, lastMeanG = 128, lastMeanB = 128;
// Smoothed display values so the readout doesn't jitter at low ω.
let displayMean = [128, 128, 128];
function rebuildStamps(width, height) {
W = width; H = height;
cx = W / 2;
cy = H / 2;
R = Math.min(W, H) * 0.36;
// Stamp at the maximum dimension that fits, with a small margin so
// antialiased pixels at the rim aren't clipped during rotation.
stampSize = Math.ceil(R * 2 + 8);
stampCanvas = new OffscreenCanvas(stampSize, stampSize);
stampCtx = stampCanvas.getContext('2d');
drawStamp();
smear = new OffscreenCanvas(stampSize, stampSize);
smearCtx = smear.getContext('2d');
readback = new OffscreenCanvas(READBACK, READBACK);
readbackCtx = readback.getContext('2d');
}
function drawStamp() {
const s = stampCtx;
const r = R;
const c = stampSize / 2;
s.clearRect(0, 0, stampSize, stampSize);
// Wedge fills. Start at -π/2 so the seam between the first and last wedge
// points up, which makes the rotation visually readable when ω is low.
const slice = TAU / N_WEDGES;
for (let i = 0; i < N_WEDGES; i++) {
const a0 = -Math.PI / 2 + i * slice;
const a1 = a0 + slice;
const [r8, g8, b8] = WEDGE_COLORS[i];
s.beginPath();
s.moveTo(c, c);
s.arc(c, c, r, a0, a1);
s.closePath();
s.fillStyle = `rgb(${r8},${g8},${b8})`;
s.fill();
}
// Subtle hub + rim so the spin is visible at intermediate ω before it
// fully fuses. A pure tone-only disc looks frozen until the mean averages.
s.strokeStyle = 'rgba(20,20,20,0.85)';
s.lineWidth = 2;
s.beginPath();
s.arc(c, c, r, 0, TAU);
s.stroke();
s.fillStyle = '#1a1a1a';
s.beginPath();
s.arc(c, c, r * 0.06, 0, TAU);
s.fill();
// A single radial fiducial — a thin black tick on the red wedge — gives
// the eye a phase reference at low ω. Once ω rises the tick blurs into
// a faint ring, which is the demonstration in microcosm.
s.strokeStyle = 'rgba(10,10,10,0.9)';
s.lineWidth = 2;
s.beginPath();
s.moveTo(c, c - r * 0.92);
s.lineTo(c, c - r * 0.20);
s.stroke();
}
function init({ width, height }) {
rebuildStamps(width, height);
}
// Number of sub-samples this frame. Driven by the angular distance the disc
// will sweep over dt: 1 sub-sample per ~6°, clamped to [1, 24].
function pickSubsamples(dOmega, dt) {
const sweep = Math.abs(dOmega) * dt; // radians swept this frame
const perSample = (6 * Math.PI) / 180; // 6° per sample
const n = Math.ceil(sweep / perSample);
if (n < 1) return 1;
if (n > 24) return 24;
return n;
}
function drawSmearedDisc(ctx, dt) {
const N = pickSubsamples(omega, dt);
const sweep = omega * dt;
const c = stampSize / 2;
const alpha = 1 / N;
// Fresh buffer, transparent. We draw N rotated copies with equal alpha so
// a pixel covered by k of the N wedge orientations ends up at (k/N) of
// that color — exactly the time-average of what that pixel "saw".
smearCtx.clearRect(0, 0, stampSize, stampSize);
smearCtx.globalAlpha = alpha;
for (let i = 0; i < N; i++) {
// Spread sub-samples uniformly across the frame. The (i + 0.5)/N midpoint
// gives an unbiased trapezoid-like estimate of the sweep interval.
const phase = (i + 0.5) / N;
const a = theta + sweep * phase;
smearCtx.setTransform(
Math.cos(a), Math.sin(a),
-Math.sin(a), Math.cos(a),
c, c
);
smearCtx.drawImage(stampCanvas, -c, -c);
}
smearCtx.setTransform(1, 0, 0, 1, 0, 0);
smearCtx.globalAlpha = 1;
// Composite onto main canvas, centered.
ctx.drawImage(smear, Math.round(cx - c), Math.round(cy - c));
}
function sampleMeanColor() {
// Pull the smeared disc into a 64×64 buffer, then average. Restricting
// the read to a circular mask inside the buffer keeps black margin pixels
// from dragging the mean toward zero.
readbackCtx.clearRect(0, 0, READBACK, READBACK);
readbackCtx.drawImage(smear, 0, 0, stampSize, stampSize, 0, 0, READBACK, READBACK);
const data = readbackCtx.getImageData(0, 0, READBACK, READBACK).data;
let rSum = 0, gSum = 0, bSum = 0, n = 0;
const mid = READBACK / 2;
const rr = mid * 0.95;
const rr2 = rr * rr;
for (let y = 0; y < READBACK; y++) {
const dy = y + 0.5 - mid;
for (let x = 0; x < READBACK; x++) {
const dx = x + 0.5 - mid;
if (dx * dx + dy * dy > rr2) continue;
const idx = (y * READBACK + x) * 4;
const a = data[idx + 3];
if (a < 8) continue;
// Un-premultiply (canvas stores premultiplied alpha). Disc pixels at
// low ω have a=255 anyway; at high ω each sub-sample contributes ~a/N.
const aa = a / 255;
rSum += data[idx] / aa;
gSum += data[idx + 1] / aa;
bSum += data[idx + 2] / aa;
n++;
}
}
if (n === 0) return [0, 0, 0];
return [rSum / n, gSum / n, bSum / n];
}
function drawBackground(ctx) {
ctx.fillStyle = '#0a0a0a';
ctx.fillRect(0, 0, W, H);
}
function drawInstrument(ctx, rgb) {
const pad = 14;
const w = Math.min(220, W * 0.34);
const h = 96;
const x = W - w - pad;
const y = H - h - pad;
ctx.fillStyle = 'rgba(0,0,0,0.55)';
ctx.strokeStyle = 'rgba(255,255,255,0.18)';
ctx.lineWidth = 1;
ctx.fillRect(x, y, w, h);
ctx.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1);
// Swatch of the measured mean.
const sw = 56;
const sx = x + 10;
const sy = y + 10;
ctx.fillStyle = `rgb(${rgb[0] | 0},${rgb[1] | 0},${rgb[2] | 0})`;
ctx.fillRect(sx, sy, sw, sw);
ctx.strokeStyle = 'rgba(255,255,255,0.25)';
ctx.strokeRect(sx + 0.5, sy + 0.5, sw - 1, sw - 1);
ctx.fillStyle = '#cfd2d6';
ctx.font = '11px ui-monospace, monospace';
ctx.textBaseline = 'top';
ctx.fillText('measured mean RGB', sx + sw + 10, sy);
ctx.fillStyle = '#9aa0a6';
ctx.fillText(`R ${rgb[0].toFixed(0).padStart(3, ' ')}`, sx + sw + 10, sy + 18);
ctx.fillText(`G ${rgb[1].toFixed(0).padStart(3, ' ')}`, sx + sw + 10, sy + 32);
ctx.fillText(`B ${rgb[2].toFixed(0).padStart(3, ' ')}`, sx + sw + 10, sy + 46);
ctx.fillStyle = '#6b7178';
ctx.fillText('disc area, per frame', sx, sy + sw + 6);
}
function drawSpeedBar(ctx) {
const pad = 14;
const w = Math.min(260, W * 0.4);
const h = 56;
const x = pad;
const y = H - h - pad;
ctx.fillStyle = 'rgba(0,0,0,0.55)';
ctx.strokeStyle = 'rgba(255,255,255,0.18)';
ctx.fillRect(x, y, w, h);
ctx.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1);
const omegaMax = 100;
const frac = Math.max(0, Math.min(1, omega / omegaMax));
const barX = x + 10, barY = y + 30, barW = w - 20, barH = 8;
ctx.fillStyle = 'rgba(255,255,255,0.10)';
ctx.fillRect(barX, barY, barW, barH);
// Color the fill by a gradient that goes from spectral red to white as ω
// climbs — visually echoes the fusion happening on the disc.
const grd = ctx.createLinearGradient(barX, barY, barX + barW, barY);
grd.addColorStop(0.0, '#dc2828');
grd.addColorStop(0.5, '#d6a83a');
grd.addColorStop(1.0, '#f3f0e6');
ctx.fillStyle = grd;
ctx.fillRect(barX, barY, barW * frac, barH);
const rpm = (omega * 60) / TAU;
ctx.fillStyle = '#cfd2d6';
ctx.font = '12px ui-monospace, monospace';
ctx.textBaseline = 'top';
ctx.fillText(`ω = ${omega.toFixed(2)} rad/s ${rpm.toFixed(1)} rpm`, x + 10, y + 8);
ctx.fillStyle = '#6b7178';
ctx.font = '10px ui-monospace, monospace';
ctx.fillText('↑/↓ or mouse-Y', x + 10, y + 42);
}
function drawHeader(ctx) {
ctx.fillStyle = '#cfd2d6';
ctx.font = '13px ui-sans-serif, system-ui';
ctx.textBaseline = 'top';
ctx.fillText("Newton's Color-Disk Spin", 14, 12);
ctx.fillStyle = '#7e848b';
ctx.font = '11px ui-sans-serif, system-ui';
ctx.fillText('persistence of vision averages the wedges into gray-white', 14, 30);
}
function handleInput(input, dt) {
// Keyboard target with a smooth approach so spamming arrows doesn't
// overshoot. Mouse-Y, when the cursor sits over the canvas, blends in as
// a second source so it Just Works on touch / hover.
const KEY_RATE = 18; // rad/s² ish when held
let kb = 0;
if (input.keyDown('ArrowUp') || input.keyDown('w') || input.keyDown('W')) kb += KEY_RATE * dt;
if (input.keyDown('ArrowDown') || input.keyDown('s') || input.keyDown('S')) kb -= KEY_RATE * dt;
omegaTarget += kb;
// Mouse-Y override: only when cursor is inside the canvas. Top = fast,
// bottom = slow. Acts as a coarse setter, not a delta.
const mx = input.mouseX, my = input.mouseY;
if (mx > 0 && mx < W && my > 0 && my < H && input.mouseDown) {
const t = 1 - my / H;
omegaTarget = t * 100;
}
omegaTarget = Math.max(0, Math.min(100, omegaTarget));
// First-order glide toward the target. Time-constant ~0.25s feels punchy
// without being instant.
const k = 1 - Math.exp(-dt / 0.25);
omega = omega + (omegaTarget - omega) * k;
}
function tick({ dt, ctx, width, height, input }) {
if (width !== W || height !== H) rebuildStamps(width, height);
// Cap dt: a tab-restore producing dt=2.0 would smear the disc into a
// single uniform circle on the first frame back.
const dtc = Math.min(dt, 1 / 30);
handleInput(input, dtc);
theta = (theta + omega * dtc) % TAU;
drawBackground(ctx);
drawSmearedDisc(ctx, dtc);
// Sample the just-drawn smear. Smooth the readout so the digits don't
// flicker on the last bit at low rpm.
const m = sampleMeanColor();
const a = 0.25;
displayMean[0] = displayMean[0] * (1 - a) + m[0] * a;
displayMean[1] = displayMean[1] * (1 - a) + m[1] * a;
displayMean[2] = displayMean[2] * (1 - a) + m[2] * a;
drawHeader(ctx);
drawSpeedBar(ctx);
drawInstrument(ctx, displayMean);
}
Comments (0)
Log in to comment.