0
N-Wheel Spirograph
drag sliders, space to randomize, keys 4-8 set wheel count
so each slider controls how fast wheel rotates relative to the others. When all are rational multiples of one another the pen retraces the same closed rose; when any one is irrational the curve never repeats and lays down lace forever. Defaults seed five wheels with the mix — three rationals and two irrationals — so the figure looks closed-ish but slowly drifts off-axis. Drag any slider to retune; press 4-8 to change wheel count; space to randomize; click the drawing area to clear the trail.
idle
320 lines · vanilla
view source
// N-Wheel Spirograph: a compound epicycle of N nested wheels (4..8).
// Each wheel contributes one term r_i * (cos(omega_i * t + phi_i), sin(...))
// to the pen position. Rational omega ratios -> closed roses; irrational
// ratios -> never-repeating lace. Sliders on the left set each omega.
//
// Worker contract: init({ canvas, ctx, width, height, input }),
// tick({ dt, frame, time, ctx, canvas, width, height, input }).
// ---------- constants ----------
const TRAIL_MAX = 5000;
const SUBSTEPS = 6; // pen advances per displayed frame
const DT_SIM = 0.012; // simulated time step per substep
const MIN_WHEELS = 4;
const MAX_WHEELS = 8;
const OMEGA_RANGE = 6; // sliders span omega in [-OMEGA_RANGE, +OMEGA_RANGE]
// A bank of interesting ratio values: a mix of rational and irrational.
// Sliders quantize to "near snap" values when released? — no, we let them
// be continuous; randomize() draws from this bank so the default look has
// the rational/irrational mix the spec calls for.
const RATIO_BANK = [
1, 2, 3, -1, -2,
Math.PI, Math.E, Math.SQRT2, Math.LN2 * 4,
(1 + Math.sqrt(5)) / 2, // golden
-0.4, 0.6, 1.5, -1.7, 2.3,
];
// ---------- state ----------
let W = 0, H = 0;
let N; // current wheel count
let radii; // length MAX_WHEELS+1, only first N used
let omegas; // angular velocities per wheel
let phases; // initial phase offsets
let trail; // x,y interleaved, length TRAIL_MAX*2
let head; // write index
let count; // populated entries
let simT; // accumulated sim time
let hueBase; // slowly drifting base hue
let dragK; // 1..N when dragging that slider, else 0
let dragOffset; // pixel offset between thumb center and mouse-y at grab
// ---------- helpers ----------
function rand(a, b) { return a + Math.random() * (b - a); }
function pick(arr) { return arr[(Math.random() * arr.length) | 0]; }
function clamp(v, a, b) { return v < a ? a : (v > b ? b : v); }
// Center + drawing scale on the right of the canvas (sliders eat the left).
function drawArea() {
const sliderColW = sliderColumnWidth();
const left = sliderColW;
const right = W;
const top = 8;
const bot = H - 8;
const cx = (left + right) * 0.5;
const cy = (top + bot) * 0.5;
// Sum of |r_i| is the worst-case pen radius. Scale so worst-case fits with
// a small margin. Compute against current radii.
let maxR = 0;
for (let i = 0; i < N; i++) maxR += Math.abs(radii[i]);
const halfW = (right - left) * 0.5 - 12;
const halfH = (bot - top) * 0.5 - 12;
const scale = Math.min(halfW, halfH) / Math.max(0.0001, maxR);
return { left, right, top, bot, cx, cy, scale };
}
function sliderColumnWidth() {
// Narrow on small canvases, roomy on large ones. Capped so the drawing area
// never gets squeezed too hard.
return Math.max(90, Math.min(140, Math.floor(W * 0.20)));
}
// Slider geometry. Sliders are HORIZONTAL bars stacked vertically in the
// left column. Each maps omega in [-OMEGA_RANGE, +OMEGA_RANGE] -> x in
// [track_x, track_x + track_w]. (Horizontal works better than vertical here:
// the column is narrower than it is tall, but a horizontal bar reads
// "this controls a number".)
function sliderRects() {
const colW = sliderColumnWidth();
const padX = 10;
const trackX = padX;
const trackW = colW - padX * 2;
// N sliders stacked. Total stack height fits the canvas with margins.
const topMargin = 28; // room for header
const botMargin = 28; // room for help text
const rowH = Math.max(36, Math.min(64, Math.floor((H - topMargin - botMargin) / N)));
const stackH = rowH * N;
const y0 = topMargin + Math.max(0, ((H - topMargin - botMargin) - stackH) / 2);
const rects = [];
for (let i = 0; i < N; i++) {
const cy = y0 + rowH * (i + 0.5);
rects.push({
i,
// Generous touch-friendly hit box covers the full row width.
x: 0, y: y0 + rowH * i, w: colW, h: rowH,
trackX, trackY: cy - 3, trackW, trackH: 6,
cx: trackX + trackW / 2, cy,
});
}
return rects;
}
function omegaToX(omega, sl) {
const t = (omega + OMEGA_RANGE) / (2 * OMEGA_RANGE);
return sl.trackX + clamp(t, 0, 1) * sl.trackW;
}
function xToOmega(x, sl) {
const t = (x - sl.trackX) / sl.trackW;
return clamp(t, 0, 1) * (2 * OMEGA_RANGE) - OMEGA_RANGE;
}
function pointIn(x, y, r) {
return x >= r.x && x <= r.x + r.w && y >= r.y && y <= r.y + r.h;
}
// ---------- parameter generation ----------
function randomizeRatios() {
// Mix: ensure at least one rational, at least one irrational so the default
// look has both. (Sliders are continuous anyway; this is just a seed.)
for (let i = 0; i < N; i++) {
omegas[i] = pick(RATIO_BANK) * (i === 0 ? 1 : (Math.random() < 0.4 ? -1 : 1));
// Light jitter so it's never two sims with identical default omegas.
omegas[i] += rand(-0.15, 0.15);
omegas[i] = clamp(omegas[i], -OMEGA_RANGE, OMEGA_RANGE);
phases[i] = rand(0, Math.PI * 2);
}
clearTrail();
}
function defaultRadii() {
// Decreasing radii: each wheel is a fraction smaller than the previous.
// Total sum ~ 1.0 in abstract units; drawArea() rescales to pixels.
// First wheel dominates; tail wheels add filigree.
const r = [];
let sum = 0;
for (let i = 0; i < MAX_WHEELS; i++) {
const v = Math.pow(0.62, i);
r.push(v);
if (i < N) sum += v;
}
// Normalize first N to sum 1.
for (let i = 0; i < MAX_WHEELS; i++) r[i] /= sum;
return r;
}
function setWheelCount(n) {
N = clamp(n | 0, MIN_WHEELS, MAX_WHEELS);
radii = defaultRadii();
if (!omegas) {
omegas = new Float32Array(MAX_WHEELS);
phases = new Float32Array(MAX_WHEELS);
}
// Spec default: 5 wheels with mixed rational/irrational frequencies.
// For the very first init we want the spec's example (1, π, 2.7, -0.4, sqrt(2));
// afterwards (a 4-8 key press) we randomize.
randomizeRatios();
}
function applySpecDefaults() {
// 1, π, 2.7, -0.4, sqrt(2) — extended/truncated to fit N.
const seed = [1, Math.PI, 2.7, -0.4, Math.SQRT2, 0.8, -1.3, 3.1];
for (let i = 0; i < N; i++) {
omegas[i] = clamp(seed[i % seed.length], -OMEGA_RANGE, OMEGA_RANGE);
phases[i] = 0;
}
clearTrail();
}
// ---------- trail ----------
function clearTrail() {
head = 0;
count = 0;
simT = 0;
}
function pushPoint(x, y) {
trail[head * 2] = x;
trail[head * 2 + 1] = y;
head = (head + 1) % TRAIL_MAX;
if (count < TRAIL_MAX) count++;
}
// Pen position in abstract units (sum of r_i * (cos(omega_i*t + phi_i), sin(...))).
function penAt(t) {
let x = 0, y = 0;
for (let i = 0; i < N; i++) {
const a = omegas[i] * t + phases[i];
x += radii[i] * Math.cos(a);
y += radii[i] * Math.sin(a);
}
return [x, y];
}
// Each wheel's center (cumulative sum up to wheel i) in abstract units.
// Used to draw the mechanism overlay.
function wheelCenters(t) {
const out = [];
let x = 0, y = 0;
out.push([0, 0]);
for (let i = 0; i < N - 1; i++) {
const a = omegas[i] * t + phases[i];
x += radii[i] * Math.cos(a);
y += radii[i] * Math.sin(a);
out.push([x, y]);
}
return out;
}
// ---------- init ----------
function init({ ctx, width, height }) {
W = width; H = height;
trail = new Float32Array(TRAIL_MAX * 2);
hueBase = Math.random() * 360;
dragK = 0;
dragOffset = 0;
setWheelCount(5);
applySpecDefaults();
ctx.fillStyle = '#06070d';
ctx.fillRect(0, 0, W, H);
}
// ---------- tick ----------
function tick({ ctx, dt, width, height, input }) {
if (width !== W || height !== H) { W = width; H = height; }
// ----- input: keyboard wheel-count select -----
for (let n = MIN_WHEELS; n <= MAX_WHEELS; n++) {
if (input.justPressed(String(n))) {
setWheelCount(n);
break;
}
}
if (input.justPressed(' ') || input.justPressed('Space') || input.justPressed('Spacebar')) {
randomizeRatios();
}
// ----- slider interaction -----
const sliders = sliderRects();
// Latch-on: when mouseDown, find the slider under the cursor; once latched,
// keep tracking even if the cursor leaves the row (this is the "feels
// responsive when mouse leaves the bar area" requirement).
if (input.mouseDown) {
if (!dragK) {
for (const sl of sliders) {
if (pointIn(input.mouseX, input.mouseY, sl)) {
dragK = sl.i + 1;
// Grab anywhere on the row; offset captures where on the bar the
// user pressed so the thumb doesn't jump to the cursor.
const thumbX = omegaToX(omegas[sl.i], sl);
dragOffset = input.mouseX - thumbX;
// If they grabbed off the thumb itself (anywhere on the row),
// teleport so the thumb meets the cursor — feels more direct.
const onThumb = Math.abs(input.mouseX - thumbX) <= 14;
if (!onThumb) dragOffset = 0;
break;
}
}
}
if (dragK) {
const sl = sliders[dragK - 1];
omegas[sl.i] = xToOmega(input.mouseX - dragOffset, sl);
}
} else {
dragK = 0;
}
// Click anywhere on the drawing area (not on a slider): clear trail.
for (const c of input.consumeClicks()) {
let onSlider = false;
for (const sl of sliders) {
if (pointIn(c.x, c.y, sl)) { onSlider = true; break; }
}
if (!onSlider) clearTrail();
}
// ----- advance pen -----
const steps = Math.max(1, Math.min(20, Math.round(SUBSTEPS * (dt / (1 / 60)))));
for (let i = 0; i < steps; i++) {
simT += DT_SIM;
const [px, py] = penAt(simT);
pushPoint(px, py);
}
hueBase = (hueBase + dt * 14) % 360;
// ----- render -----
// Fade prior frame slightly: gives motion blur without erasing the buffer.
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = 'rgba(6, 7, 13, 0.18)';
ctx.fillRect(0, 0, W, H);
const D = drawArea();
drawTrail(ctx, D);
drawWheelsOverlay(ctx, D);
drawSliders(ctx, sliders);
drawHeader(ctx);
}
function drawTrail(ctx, D) {
if (count < 2) return;
ctx.globalCompositeOperation = 'lighter';
ctx.lineCap = 'round';
ctx.lineWidth = 1.05;
const start = (head - count + TRAIL_MAX) % TRAIL_MAX;
// First point.
let px = D.cx + trail[start * 2] * D.scale;
let py = D.cy + trail[start * 2 + 1] * D.scale;
// Stroke a polyline; color shifts slowly through the trail so the most
// recent point sits at hueBase and the oldest sits behind it.
for (let i = 1; i < count; i++) {
const idx = (start + i) % TRAIL_MAX;
const x = D.cx + trail[idx * 2] * D.scale;
const y = D.cy + trail[idx * 2 + 1] * D.scale;
const u = i / count; // 0..1, 1 == newest
const hue = (hueBase + u * 80 + 200) % 360;
const alpha = 0.04 + Math.pow(u, 1.4) * 0.55;
ctx.strokeStyle = `hsla(${hue.toFixed(1)},90%,62%,${alpha.toFixed(3)})`;
ctx.beginPath();
ctx.moveTo(px, py);
ctx.lineTo(x, y);
ctx.stroke();
px = x; py = y;
}
// Bright pen tip on top of the latest segment.
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = `hsla(${hueBase.toFixed(1)},100%,84%,0.95)`;
ctx.beginPath();
ctx.arc(px, py, 2.6, 0, Math.PI * 2);
ctx.fill();
}
function drawWheelsOverlay(ctx, D) {
// Faint wheel outlines so the mechanism is visible. Each wheel is drawn
// centered at the cumulative sum of all previous arms; its radius is r_i
// and an "arm" line is drawn from its center to the next wheel's center
// (or the pen, for the final wheel).
ctx.globalCompositeOperation = 'source-over';
ctx.lineWidth = 1;
const centers = wheelCenters(simT);
for (let i = 0; i < N; i++) {
const [cxAb, cyAb] = centers[i];
const cx = D.cx + cxAb * D.scale;
const cy = D.cy + cyAb * D.scale;
const r = Math.abs(radii[i]) * D.scale;
ctx.strokeStyle = `rgba(170,190,230,${(0.10 + 0.04 * (N - i)).toFixed(2)})`;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.stroke();
// Arm from this wheel's center to the next wheel's center (or pen tip).
const aHere = omegas[i] * simT + phases[i];
const armX = cx + radii[i] * D.scale * Math.cos(aHere);
const armY = cy + radii[i] * D.scale * Math.sin(aHere);
ctx.strokeStyle = 'rgba(200,210,240,0.22)';
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.lineTo(armX, armY);
ctx.stroke();
// Small hub dot.
ctx.fillStyle = 'rgba(220,230,255,0.45)';
ctx.beginPath();
ctx.arc(cx, cy, 1.6, 0, Math.PI * 2);
ctx.fill();
}
}
function drawSliders(ctx, sliders) {
ctx.globalCompositeOperation = 'source-over';
// Panel backdrop so trails behind the sliders don't visually merge.
const colW = sliderColumnWidth();
ctx.fillStyle = 'rgba(8,10,18,0.78)';
ctx.fillRect(0, 0, colW, H);
ctx.strokeStyle = 'rgba(80,100,140,0.35)';
ctx.beginPath();
ctx.moveTo(colW + 0.5, 0); ctx.lineTo(colW + 0.5, H);
ctx.stroke();
ctx.font = '11px ui-monospace, Menlo, monospace';
ctx.textBaseline = 'alphabetic';
for (const sl of sliders) {
const i = sl.i;
// Track.
ctx.fillStyle = 'rgba(120,140,180,0.18)';
ctx.fillRect(sl.trackX, sl.trackY, sl.trackW, sl.trackH);
// Center tick (omega = 0).
ctx.fillStyle = 'rgba(180,200,240,0.35)';
ctx.fillRect(sl.cx - 0.5, sl.trackY - 3, 1, sl.trackH + 6);
// Filled portion from center to current omega.
const thumbX = omegaToX(omegas[i], sl);
const hue = (200 + i * 36) % 360;
ctx.fillStyle = `hsla(${hue},80%,60%,0.65)`;
if (thumbX >= sl.cx) ctx.fillRect(sl.cx, sl.trackY, thumbX - sl.cx, sl.trackH);
else ctx.fillRect(thumbX, sl.trackY, sl.cx - thumbX, sl.trackH);
// Thumb.
const active = (dragK === i + 1);
ctx.fillStyle = active ? `hsl(${hue},100%,80%)` : `hsl(${hue},85%,68%)`;
ctx.beginPath();
ctx.arc(thumbX, sl.cy, active ? 9 : 7, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = 'rgba(0,0,0,0.55)';
ctx.lineWidth = 1;
ctx.stroke();
// Labels: wheel index left of track, numeric value below thumb.
ctx.fillStyle = 'rgba(220,228,240,0.85)';
ctx.textAlign = 'left';
ctx.fillText(`ω${i + 1}`, sl.trackX, sl.trackY - 6);
ctx.textAlign = 'right';
ctx.fillText(omegas[i].toFixed(2), sl.trackX + sl.trackW, sl.trackY - 6);
ctx.textAlign = 'left';
}
// Footer hint inside slider column.
ctx.fillStyle = 'rgba(180,200,240,0.55)';
ctx.font = '10px ui-monospace, Menlo, monospace';
ctx.textAlign = 'center';
ctx.fillText('4-8 wheels · space', colW / 2, H - 14);
ctx.fillText('click=clear trail', colW / 2, H - 2);
ctx.textAlign = 'left';
}
function drawHeader(ctx) {
ctx.globalCompositeOperation = 'source-over';
const colW = sliderColumnWidth();
ctx.fillStyle = 'rgba(220,228,240,0.78)';
ctx.font = '11px ui-monospace, Menlo, monospace';
ctx.textAlign = 'left';
ctx.fillText(`N=${N} trail=${count}/${TRAIL_MAX}`, colW + 10, 16);
}
Comments (0)
Log in to comment.