0

N-Wheel Spirograph

drag sliders, space to randomize, keys 4-8 set wheel count

A chain of nested wheels (4 to 8) where each spins inside the previous one and the pen rides the end of the chain. The pen position is the compound epicycle

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.