$sloprun.dev

Spirograph

demo sandbox: allow-scripts · csp: default-src 'none'

sandboxed and isolated in your browser · never enter a real password in a demo

▶ 6 ran · ✓ 0 worked
i ran it — no login needed:
share: preview embed ↗
post it anywhere:
email the card:

0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously

The plastic toy from a childhood drawer, rebuilt as arithmetic: pin a toothed ring, roll a smaller gear around the inside, drop a pen through one of the holes and watch the loops accumulate. What makes it more than nostalgia is that the whole drawing is decided before the pen moves — divide the two tooth counts by their largest common factor and you know exactly how many loops you'll get and how many turns it takes to close, both shown live as it draws. Start with the default 90/34 rosette, then drag the rolling gear one tooth at a time and watch the readout jump: 34 teeth shares a factor of 2 with 90 and closes in 17 turns, while 35 shares a factor of 5 and comes home in just 7. Hit Randomize until something stops you, then Save PNG.

Embed this demo — put a runnable demo in your blog post

Paste this where you write. It renders a live, runnable demo inline anywhere raw HTML / iframes are allowed — Ghost, WordPress, Notion, Discourse, your own site.

<iframe src="https://sloprun.dev/embed/spirograph" width="100%" height="640" loading="lazy" allow="" style="border:1px solid #E3E2DC;border-radius:10px" title="Spirograph — a runnable demo on sloprun.dev"></iframe>
preview ↗

On Medium and dev.to the plain link becomes a rich preview card that links back here — they don't run third-party iframes, so paste the URL there and the reader clicks through to run it. https://sloprun.dev/p/spirograph

Source

the code you see is the code that just ran raw ↗
Source — the code you see is the code that just ran 846 lines 37.8 KB index.html
demos/spirograph/index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Spirograph — sloprun.dev</title>
<style>
/* ── sloprun design tokens (verbatim from demos/tokens.css) ───────────────── */
:root {
  --bg: #F6F6F3;          /* neutral paper, not cream */
  --surface: #FFFFFF;
  --ink: #1A1D21;
  --muted: #5A6068;
  --line: #E3E2DC;
  --accent: #C05600;      /* phosphor amber, darkened for light bg */
  --accent-soft: #FFF3E6;
  --run: #1A7F37;         /* earned green */
  --run-soft: #E7F4EA;
  --danger: #C93C37;
  --radius: 10px;
  --font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
  --font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
    --line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
    --run: #3FB950; --run-soft: #16281B; --danger: #F47067;
  }
}
:root[data-theme="dark"] {
  --bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
  --line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
  --run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
body { background: var(--bg); color: var(--ink); font-family: var(--font-sans); }
/* ── end tokens ───────────────────────────────────────────────────────────── */

/* demo-local additions (scrims need alpha; tokens are opaque hex) */
:root { --scrim: rgba(246,246,243,.9); --shadow: 0 1px 2px rgba(26,29,33,.05); }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) { --scrim: rgba(20,22,26,.9); --shadow: 0 1px 2px rgba(0,0,0,.35); }
}
:root[data-theme="dark"] { --scrim: rgba(20,22,26,.9); --shadow: 0 1px 2px rgba(0,0,0,.35); }

* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
html { -webkit-text-size-adjust: 100%; }
body { min-height: 100%; overflow-x: hidden; }

.wrap {
  max-width: 1020px; margin: 0 auto; padding: 22px 16px 26px;
  display: flex; flex-direction: column; gap: 16px;
}

h1 { margin: 0 0 6px; font-size: clamp(21px, 3.6vw, 29px); letter-spacing: -.021em; line-height: 1.15; }
.lede { margin: 0; max-width: 68ch; color: var(--muted); font-size: 14.5px; line-height: 1.55; }
.lede b { color: var(--ink); font-weight: 600; }
.lede .num {
  font-family: var(--font-mono); font-size: .9em; color: var(--accent);
  background: var(--accent-soft); border-radius: 4px; padding: 1px 5px;
}

.stage { display: grid; grid-template-columns: minmax(0,1fr) 300px; gap: 16px; align-items: start; }
@media (max-width: 920px) { .stage { grid-template-columns: minmax(0,1fr); } }
.left { display: flex; flex-direction: column; gap: 12px; min-width: 0; }

.card {
  background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--radius); padding: 12px 13px; box-shadow: var(--shadow);
}
.card h2 {
  margin: 0 0 10px; font-family: var(--font-mono); font-size: 10.5px;
  letter-spacing: .1em; text-transform: uppercase; color: var(--muted); font-weight: 600;
}

/* the drawing field */
.field {
  position: relative; overflow: hidden;
  border: 1px solid var(--line); border-radius: var(--radius);
  background: var(--surface); box-shadow: var(--shadow);
  aspect-ratio: 1 / 1;
  height: clamp(300px, calc(100vh - 300px), 700px);
  width: auto; max-width: 100%; margin: 0 auto;
}
@media (max-width: 920px) { .field { height: auto; width: 100%; } }
.field canvas { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }

.chip {
  position: absolute; left: 10px; bottom: 10px; pointer-events: none;
  display: inline-flex; align-items: center; gap: 8px;
  font-family: var(--font-mono); font-size: 11px; letter-spacing: .01em;
  color: var(--muted); background: var(--scrim);
  border: 1px solid var(--line); border-radius: 999px; padding: 5px 11px;
  max-width: calc(100% - 20px);
}
.chip b { color: var(--ink); font-weight: 600; }
.chip .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); flex: none; }
.chip.done { color: var(--run); background: var(--run-soft); border-color: transparent; }
.chip.done .dot { background: var(--run); }
.chip.done b { color: var(--run); }
.chip.idle .dot { background: var(--muted); }

/* hero strip */
.hero-top { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
.bignum {
  font-family: var(--font-mono); font-size: clamp(26px, 5.4vw, 34px); line-height: 1;
  font-variant-numeric: tabular-nums; letter-spacing: -.03em; color: var(--ink); white-space: nowrap;
}
.bignum .x { color: var(--muted); font-size: .55em; margin: 0 3px; }
.hero-meta { display: flex; gap: 16px; flex-wrap: wrap; }
.hero-meta .k {
  display: block; font-family: var(--font-mono); font-size: 9.5px;
  letter-spacing: .09em; text-transform: uppercase; color: var(--muted);
}
.hero-meta .v { font-family: var(--font-mono); font-size: 13px; font-variant-numeric: tabular-nums; color: var(--ink); }

.toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.tbsep { width: 1px; align-self: stretch; background: var(--line); margin: 0 2px; }
@media (max-width: 560px) { .tbsep { display: none; } .toolbar button { flex: 1 1 auto; } }
#playBtn { min-width: 116px; }

.saveline {
  font-family: var(--font-mono); font-size: 11px; color: var(--muted);
  margin-left: auto; font-variant-numeric: tabular-nums;
}
.saveline.ok { color: var(--run); }
.saveline.bad { color: var(--danger); }
@media (max-width: 560px) { .saveline { margin-left: 0; width: 100%; } }

/* panel */
.panel { display: flex; flex-direction: column; gap: 12px; }
.stats { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 12px 10px; }
.stat { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.stat .v {
  font-family: var(--font-mono); font-size: 20px; line-height: 1.05;
  font-variant-numeric: tabular-nums; letter-spacing: -.02em;
}
.stat .v.good { color: var(--run); }
.stat .v .u { font-size: 11px; color: var(--muted); margin-left: 2px; letter-spacing: 0; }
.stat .k {
  font-family: var(--font-mono); font-size: 9.5px;
  letter-spacing: .09em; text-transform: uppercase; color: var(--muted);
}
.bar { margin-top: 11px; height: 5px; border-radius: 3px; background: var(--line); overflow: hidden; }
.bar > i { display: block; height: 100%; width: 0%; background: var(--accent); border-radius: 3px; }
.bar.done > i { background: var(--run); }
.cap { margin: 10px 0 0; font-size: 11.5px; line-height: 1.45; color: var(--muted); }
.cap b { color: var(--ink); font-weight: 600; }
.cap .mono { font-family: var(--font-mono); font-size: .93em; }

/* controls */
.ctl { display: block; margin-bottom: 13px; }
.ctl:last-child { margin-bottom: 2px; }
.ctl-top { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; margin-bottom: 3px; }
.ctl-top label { font-size: 13px; }
.ctl-top .val { font-family: var(--font-mono); font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; }
.ctl .hint { display: block; margin-top: 2px; font-size: 11px; color: var(--muted); line-height: 1.4; }

input[type=range] {
  -webkit-appearance: none; appearance: none; display: block;
  width: 100%; height: 20px; margin: 0; background: transparent; cursor: pointer;
}
input[type=range]::-webkit-slider-runnable-track { height: 4px; border-radius: 2px; background: var(--line); }
input[type=range]::-webkit-slider-thumb {
  -webkit-appearance: none; width: 15px; height: 15px; margin-top: -5.5px;
  border-radius: 50%; background: var(--accent); border: 2px solid var(--surface);
  box-shadow: 0 0 0 1px var(--line);
}
input[type=range]::-moz-range-track { height: 4px; border-radius: 2px; background: var(--line); }
input[type=range]::-moz-range-thumb {
  width: 13px; height: 13px; border-radius: 50%; background: var(--accent);
  border: 2px solid var(--surface); box-shadow: 0 0 0 1px var(--line);
}
input[type=range]:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 6px; }

button {
  font: inherit; font-size: 13px; font-family: var(--font-sans);
  color: var(--ink); background: var(--surface);
  border: 1px solid var(--line); border-radius: 8px;
  padding: 7px 11px; cursor: pointer; line-height: 1.2;
  transition: border-color .12s ease, color .12s ease, background .12s ease;
}
button:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
button:disabled { opacity: .42; cursor: not-allowed; }
button.primary { background: var(--accent); color: var(--surface); border-color: var(--accent); font-weight: 600; }
button.primary:hover:not(:disabled) { color: var(--surface); filter: brightness(1.07); }

.presets { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 7px; }
.presets button { display: flex; flex-direction: column; gap: 2px; align-items: flex-start; text-align: left; padding: 7px 9px; }
.presets button .pn { font-size: 12.5px; }
.presets button .pa { font-family: var(--font-mono); font-size: 10.5px; color: var(--muted); font-variant-numeric: tabular-nums; }
.presets button[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); border-color: var(--accent); }
.presets button[aria-pressed="true"] .pa { color: var(--accent); }
.presets button:hover:not(:disabled) .pa { color: inherit; }

.switch { display: flex; align-items: center; gap: 9px; cursor: pointer; font-size: 13px; min-height: 30px; }
.switch input { position: absolute; opacity: 0; width: 0; height: 0; }
.track {
  width: 34px; height: 19px; border-radius: 999px; background: var(--line);
  border: 1px solid var(--line); position: relative; flex: none; transition: background .15s ease;
}
.track::after {
  content: ""; position: absolute; top: 1.5px; left: 2px; width: 14px; height: 14px;
  border-radius: 50%; background: var(--surface); transition: transform .15s ease;
  box-shadow: 0 1px 2px rgba(0,0,0,.25);
}
.switch input:checked + .track { background: var(--accent); border-color: var(--accent); }
.switch input:checked + .track::after { transform: translateX(15px); }
.switch input:focus-visible + .track { outline: 2px solid var(--accent); outline-offset: 2px; }

.keys {
  font-family: var(--font-mono); font-size: 10.5px; color: var(--muted);
  letter-spacing: .03em; display: flex; flex-wrap: wrap; gap: 4px 12px;
}
.keys kbd {
  font-family: inherit; font-size: 10px; color: var(--ink); background: var(--surface);
  border: 1px solid var(--line); border-bottom-width: 2px; border-radius: 4px; padding: 1px 5px;
}
.rm-note { display: none; }
footer { font-family: var(--font-mono); font-size: 11px; color: var(--muted); letter-spacing: .05em; padding-top: 2px; }
.sr-only {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { transition-duration: .001ms !important; animation-duration: .001ms !important; }
}
</style>
</head>
<body>
<div class="wrap">

  <header>
    <h1>Spirograph</h1>
    <p class="lede">
      Pin a toothed ring to the table, roll a smaller gear around the inside of it, and drop a pen
      through one of the little holes. The pen never lifts &mdash; it just gets carried around twice
      at once, and the loops it leaves behind are decided entirely by the two tooth counts.
      The drawing <b>closes back on itself</b> only when the gear returns to the tooth it started on.
    </p>
  </header>

  <main class="stage">

    <div class="left">
      <div class="field" id="field">
        <canvas id="path" role="img" aria-label="Spirograph drawing"></canvas>
        <canvas id="gear" aria-hidden="true"></canvas>
        <div class="chip" id="chip"><span class="dot"></span><span id="chipText">drawing</span></div>
      </div>

      <div class="card">
        <div class="hero-top">
          <div class="bignum"><span id="bigR">90</span><span class="x">/</span><span id="bigr">34</span><span class="x">·</span><span id="bigd">27</span></div>
          <div class="hero-meta">
            <div><span class="k">loops</span><span class="v" id="mLoops">45</span></div>
            <div><span class="k">turns to close</span><span class="v" id="mTurns">17</span></div>
            <div><span class="k">gear ratio</span><span class="v" id="mRatio">17:45</span></div>
          </div>
        </div>
      </div>

      <div class="card toolbar" role="group" aria-label="Drawing controls">
        <button type="button" class="primary" id="playBtn">Pause</button>
        <button type="button" id="randBtn">Randomize</button>
        <button type="button" id="clearBtn">Clear</button>
        <span class="tbsep" aria-hidden="true"></span>
        <button type="button" id="saveBtn">Save PNG</button>
        <span class="saveline" id="saveMsg" aria-hidden="true"></span>
      </div>

      <div class="keys" aria-hidden="true">
        <span><kbd>Space</kbd> pause</span>
        <span><kbd>R</kbd> randomize</span>
        <span><kbd>C</kbd> clear</span>
        <span><kbd>S</kbd> save</span>
      </div>
    </div>

    <div class="panel">

      <section class="card" aria-label="Gears">
        <h2>Gears</h2>
        <div class="ctl">
          <div class="ctl-top"><label for="R">Outer ring</label><span class="val" id="RV" aria-hidden="true">90 teeth</span></div>
          <input type="range" id="R" min="40" max="160" step="1" value="90">
        </div>
        <div class="ctl">
          <div class="ctl-top"><label for="r">Rolling gear</label><span class="val" id="rV" aria-hidden="true">34 teeth</span></div>
          <input type="range" id="r" min="5" max="155" step="1" value="34">
        </div>
        <div class="ctl">
          <div class="ctl-top"><label for="d">Pen hole</label><span class="val" id="dV" aria-hidden="true">27</span></div>
          <input type="range" id="d" min="0" max="34" step="1" value="27">
          <span class="hint" id="dHint">Distance from the gear's centre. At 0 the pen sits dead centre and draws a plain circle.</span>
        </div>
      </section>

      <section class="card" aria-label="Progress">
        <h2>Readout</h2>
        <div class="stats">
          <div class="stat"><span class="v" id="sProg">0<span class="u">%</span></span><span class="k">drawn</span></div>
          <div class="stat"><span class="v" id="sTurn">0<span class="u">/17</span></span><span class="k">turns done</span></div>
        </div>
        <div class="bar" id="bar"><i id="barFill"></i></div>
        <p class="cap">
          Loops and turns are worked out from the teeth, not guessed: divide both counts by their
          largest common factor and you get <span class="mono">turns : loops</span> exactly.
          Two gears that share no factor take the longest to come home.
        </p>
      </section>

      <section class="card" aria-label="Motion and ink">
        <h2>Motion &amp; ink</h2>
        <div class="ctl" id="speedCtl">
          <div class="ctl-top"><label for="speed">Speed</label><span class="val" id="speedV" aria-hidden="true">1.0&times;</span></div>
          <input type="range" id="speed" min="0.25" max="4" step="0.05" value="1">
        </div>
        <div class="ctl">
          <div class="ctl-top"><label for="weight">Pen weight</label><span class="val" id="weightV" aria-hidden="true">1.4 px</span></div>
          <input type="range" id="weight" min="0.6" max="3.2" step="0.1" value="1.4">
        </div>
        <label class="switch" for="gears">
          <input type="checkbox" id="gears" checked>
          <span class="track" aria-hidden="true"></span>
          <span>Show the gears</span>
        </label>
        <p class="cap rm-note" id="rmNote">Your system asks for reduced motion, so each pattern is
        drawn complete in one go instead of animating.</p>
      </section>

      <section class="card" aria-label="Preset gear pairs">
        <h2>Gear pairs</h2>
        <div class="presets" id="presets" role="group" aria-label="Preset gear pairs"></div>
      </section>

    </div>
  </main>

  <footer>demo · sloprun.dev</footer>
</div>

<a id="dl" hidden aria-hidden="true" tabindex="-1"></a>
<div class="sr-only" role="status" aria-live="polite" id="live"></div>

<script>
(function () {
  "use strict";

  var TAU = Math.PI * 2;

  /* ── theme plumbing ─────────────────────────────────────────────────────── */
  var root = document.documentElement;
  var mqDark = window.matchMedia ? matchMedia("(prefers-color-scheme: dark)") : null;
  var mqRM   = window.matchMedia ? matchMedia("(prefers-reduced-motion: reduce)") : null;
  var reduced = !!(mqRM && mqRM.matches);

  function hexToRgb(h) {
    h = String(h).trim();
    if (h.charAt(0) === "#") h = h.slice(1);
    if (h.length === 3) h = h.charAt(0)+h.charAt(0)+h.charAt(1)+h.charAt(1)+h.charAt(2)+h.charAt(2);
    var n = parseInt(h, 16);
    if (h.length !== 6 || isNaN(n)) return null;
    return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
  }
  function tok(name, fb) { return hexToRgb(getComputedStyle(root).getPropertyValue(name)) || fb; }
  function rgba(c, a) { return "rgba(" + c[0] + "," + c[1] + "," + c[2] + "," + a + ")"; }
  function rgbToHsl(c) {
    var r = c[0]/255, g = c[1]/255, b = c[2]/255;
    var mx = Math.max(r,g,b), mn = Math.min(r,g,b), h = 0, s = 0, l = (mx+mn)/2, dd = mx-mn;
    if (dd) {
      s = l > 0.5 ? dd/(2-mx-mn) : dd/(mx+mn);
      if (mx === r) h = (g-b)/dd + (g < b ? 6 : 0);
      else if (mx === g) h = (b-r)/dd + 2;
      else h = (r-g)/dd + 4;
      h *= 60;
    }
    return [h, s*100, l*100];
  }

  /* palette is derived from the accent token itself: its saturation and lightness
     set the band, and only the hue travels. Both themes stay in family. */
  var COL = { surface: [255,255,255], ink: [26,29,33], muted: [90,96,104], accent: [192,86,0], line: [227,226,220] };
  var HUE0 = 27, SAT = 68, LIT = 42, isDark = false;
  /* The ink is allowed to travel a long way down-spectrum from the accent (amber →
     rose → violet) and only a little way up. It never reaches green: green in this
     design means "it worked", and the artwork must not borrow it. */
  var HUE_DOWN = 105, HUE_UP = 14;
  var hueShift = 0;

  function readTheme() {
    COL.surface = tok("--surface", COL.surface);
    COL.ink     = tok("--ink", COL.ink);
    COL.muted   = tok("--muted", COL.muted);
    COL.accent  = tok("--accent", COL.accent);
    COL.line    = tok("--line", COL.line);
    var bg = tok("--bg", [246,246,243]);
    isDark = (0.2126*bg[0] + 0.7152*bg[1] + 0.0722*bg[2]) < 128;
    var a = rgbToHsl(COL.accent);
    HUE0 = a[0];
    SAT = Math.max(52, Math.min(84, a[1]));
    LIT = Math.max(36, Math.min(70, a[2] + (isDark ? -2 : 4)));
  }
  /* The ink swings around the accent hue and comes back to it exactly as the curve
     closes — one sine over the whole pattern — so the colour family stays the demo's own. */
  function hueAt(u) {
    var s = Math.sin(TAU * u), e = s * Math.abs(s);   /* eased: lingers on the accent, visits the edges */
    var h = HUE0 + hueShift + (e < 0 ? HUE_DOWN * e : HUE_UP * e);
    var l = LIT + (isDark ? 6 : -5) * Math.abs(e);
    return "hsl(" + ((h % 360 + 360) % 360).toFixed(1) + ", " + SAT + "%, " + l.toFixed(1) + "%)";
  }

  window.addEventListener("message", function (e) {
    var d = e.data;
    if (d && d.type === "sloprun:theme" && (d.theme === "light" || d.theme === "dark")) {
      root.setAttribute("data-theme", d.theme);
      readTheme(); repaintAll(); paintGears();
    }
  });
  if (mqDark) {
    var onScheme = function () { readTheme(); repaintAll(); paintGears(); };
    if (mqDark.addEventListener) mqDark.addEventListener("change", onScheme);
    else if (mqDark.addListener) mqDark.addListener(onScheme);
  }
  if (mqRM) {
    var onRM = function () {
      reduced = mqRM.matches;
      document.getElementById("rmNote").style.display = reduced ? "block" : "none";
      document.getElementById("speedCtl").style.display = reduced ? "none" : "block";
      restart();
    };
    if (mqRM.addEventListener) mqRM.addEventListener("change", onRM);
    else if (mqRM.addListener) mqRM.addListener(onRM);
  }

  /* ── dom ────────────────────────────────────────────────────────────────── */
  function $(id) { return document.getElementById(id); }
  var field = $("field");
  var cvPath = $("path"), cvGear = $("gear");
  var ctx = cvPath.getContext("2d"), gtx = cvGear.getContext("2d");
  var chip = $("chip"), chipText = $("chipText");
  var elR = $("R"), elr = $("r"), eld = $("d"), elSpeed = $("speed"), elWeight = $("weight"), elGears = $("gears");
  var live = $("live"), saveMsg = $("saveMsg"), playBtn = $("playBtn");

  /* ── state ──────────────────────────────────────────────────────────────── */
  var P = { R: 90, r: 34, d: 27 };
  var G = { g: 2, loops: 45, turns: 17, N: 2000, bucket: 8, dur: 7 };
  var u = 0;                 /* fraction of the closed curve that is drawn */
  var mode = "drawing";      /* drawing | paused | done | blank */
  var raf = 0, lastT = 0;
  var W = 0, H = 0, cx = 0, cy = 0, scale = 1, dpr = 1;

  function gcd(a, b) { while (b) { var t = a % b; a = b; b = t; } return a; }

  function computeGeom() {
    var g = gcd(P.R, P.r) || 1;
    G.g = g; G.loops = P.R / g; G.turns = P.r / g;
    var k = (P.R - P.r) / P.r;
    var vmax = (P.R - P.r) + P.d * k;                 /* fastest the pen ever moves, in teeth */
    var spr = TAU * vmax * scale / 1.1;               /* samples per turn for ~1.1px chords */
    spr = Math.max(240, Math.min(3200, spr));
    G.N = Math.max(360, Math.min(160000, Math.round(spr * G.turns)));
    G.bucket = Math.max(3, Math.round(G.N / 640));
    G.dur = Math.max(3.5, Math.min(16, 3.5 + G.turns * 0.2));
  }

  var _px = 0, _py = 0;
  function pt(i) {
    var t = i * TAU * G.turns / G.N;
    var b = P.R - P.r, k = b / P.r;
    _px = cx + (b * Math.cos(t) + P.d * Math.cos(k * t)) * scale;
    _py = cy - (b * Math.sin(t) - P.d * Math.sin(k * t)) * scale;
  }

  /* ── canvas sizing ──────────────────────────────────────────────────────── */
  function resize() {
    var rect = field.getBoundingClientRect();
    var w = Math.max(140, Math.round(rect.width)), h = Math.max(140, Math.round(rect.height));
    dpr = Math.min(2, window.devicePixelRatio || 1);
    if (w === W && h === H && cvPath.width === Math.round(w * dpr)) return;
    W = w; H = h; cx = w / 2; cy = h / 2;
    [cvPath, cvGear].forEach(function (c) {
      c.width = Math.round(w * dpr); c.height = Math.round(h * dpr);
      c.style.width = w + "px"; c.style.height = h + "px";
    });
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    gtx.setTransform(dpr, 0, 0, dpr, 0, 0);
    scale = (Math.min(w, h) / 2 - 14) / P.R;
    computeGeom();
    repaintAll(); paintGears();
  }

  /* ── painting ───────────────────────────────────────────────────────────── */
  function strokeRange(from, to) {
    if (to <= from) return;
    ctx.lineCap = "round"; ctx.lineJoin = "round";
    ctx.lineWidth = parseFloat(elWeight.value);
    ctx.globalAlpha = 0.92;
    var i = from;
    while (i < to) {
      var end = Math.min(to, (Math.floor(i / G.bucket) + 1) * G.bucket);
      if (end <= i) end = Math.min(to, i + G.bucket);
      ctx.beginPath();
      pt(i); ctx.moveTo(_px, _py);
      for (var j = i + 1; j <= end; j++) { pt(j); ctx.lineTo(_px, _py); }
      ctx.strokeStyle = hueAt((i + end) / (2 * G.N));
      ctx.stroke();
      i = end;
    }
    ctx.globalAlpha = 1;
  }

  function clearPath() { ctx.clearRect(0, 0, W, H); }

  function repaintAll() {
    clearPath();
    if (mode === "blank") return;
    strokeRange(0, Math.round(u * G.N));
  }

  function paintGears() {
    gtx.clearRect(0, 0, W, H);
    if (!elGears.checked || mode === "blank") return;

    /* the fixed outer ring — always the frame the drawing lives in */
    gtx.save();
    gtx.lineWidth = 1;
    gtx.strokeStyle = rgba(COL.muted, isDark ? 0.32 : 0.28);
    gtx.setLineDash([3, 5]);
    gtx.beginPath(); gtx.arc(cx, cy, P.R * scale, 0, TAU); gtx.stroke();
    gtx.setLineDash([]);

    if (mode === "drawing" || mode === "paused") {
      var t = u * TAU * G.turns;
      var b = P.R - P.r, k = b / P.r;
      var gx = cx + b * Math.cos(t) * scale, gy = cy - b * Math.sin(t) * scale;
      pt(u * G.N);
      /* rolling gear */
      gtx.strokeStyle = rgba(COL.muted, isDark ? 0.55 : 0.45);
      gtx.lineWidth = 1.25;
      gtx.beginPath(); gtx.arc(gx, gy, P.r * scale, 0, TAU); gtx.stroke();
      /* the pen arm */
      gtx.strokeStyle = rgba(COL.accent, 0.85);
      gtx.lineWidth = 1.5;
      gtx.beginPath(); gtx.moveTo(gx, gy); gtx.lineTo(_px, _py); gtx.stroke();
      /* hub + pen */
      gtx.fillStyle = rgba(COL.muted, 0.75);
      gtx.beginPath(); gtx.arc(gx, gy, 2.4, 0, TAU); gtx.fill();
      gtx.fillStyle = hueAt(u);
      gtx.beginPath(); gtx.arc(_px, _py, 3.6, 0, TAU); gtx.fill();
      gtx.strokeStyle = rgba(COL.surface, 0.9); gtx.lineWidth = 1.2;
      gtx.beginPath(); gtx.arc(_px, _py, 3.6, 0, TAU); gtx.stroke();
    }
    gtx.restore();
  }

  /* ── animation ──────────────────────────────────────────────────────────── */
  function tick(now) {
    raf = 0;
    if (mode !== "drawing") return;
    var dt = lastT ? Math.min(0.05, (now - lastT) / 1000) : 0.016;
    lastT = now;
    var prev = Math.round(u * G.N);
    u += dt * parseFloat(elSpeed.value) / G.dur;
    if (u >= 1) { u = 1; }
    strokeRange(prev, Math.round(u * G.N));
    if (u >= 1) { finish(); return; }
    paintGears();
    updateProgress();
    raf = requestAnimationFrame(tick);
  }

  function start() {
    if (mode === "done") return;
    mode = "drawing"; lastT = 0;
    if (!raf) raf = requestAnimationFrame(tick);
    updateChip(); updateProgress();
  }
  function stopRaf() { if (raf) { cancelAnimationFrame(raf); raf = 0; } lastT = 0; }

  function finish() {
    stopRaf();
    mode = "done"; u = 1;
    paintGears(); updateChip(); updateProgress();
    say("Pattern closed: " + G.loops + " loops after " + G.turns + " turns.");
  }

  function restart() {
    stopRaf();
    u = 0;
    computeGeom();
    clearPath();
    if (reduced) {
      mode = "done";
      u = 1;
      strokeRange(0, G.N);
      paintGears(); updateChip(); updateProgress();
    } else {
      mode = "drawing";
      paintGears(); updateChip(); updateProgress();
      raf = requestAnimationFrame(tick);
    }
    clearSaveMsg();
  }

  /* ── readouts ───────────────────────────────────────────────────────────── */
  function updateChip() {
    chip.className = "chip" + (mode === "done" ? " done" : (mode === "drawing" ? "" : " idle"));
    if (mode === "done") chipText.innerHTML = "closed &middot; <b>" + G.loops + "</b> loops";
    else if (mode === "paused") chipText.textContent = "paused";
    else if (mode === "blank") chipText.textContent = "cleared";
    else chipText.innerHTML = "drawing &middot; <b>" + G.turns + "</b> turns to close";

    playBtn.textContent = mode === "drawing" ? "Pause" : (mode === "paused" ? "Resume" : "Draw again");
    /* The percentage moves every frame and updateChip only runs on state changes,
       so the label states the state, never a number that would go stale. */
    cvPath.setAttribute("aria-label",
      "Spirograph: outer ring " + P.R + " teeth, rolling gear " + P.r + " teeth, pen hole " + P.d +
      ". " + G.loops + " loops in " + G.turns + " turns, " +
      (mode === "done" ? "drawn complete" : mode === "blank" ? "canvas cleared" :
       mode === "paused" ? "part drawn, paused" : "drawing now") + ".");
  }

  function updateProgress() {
    var pct = Math.round(u * 100);
    $("sProg").innerHTML = pct + '<span class="u">%</span>';
    $("sProg").className = "v" + (mode === "done" ? " good" : "");
    $("sTurn").innerHTML = (u * G.turns).toFixed(1).replace(/\.0$/, "") + '<span class="u">/' + G.turns + "</span>";
    $("barFill").style.width = pct + "%";
    $("bar").className = "bar" + (mode === "done" ? " done" : "");
  }

  function updateFacts() {
    $("bigR").textContent = P.R; $("bigr").textContent = P.r; $("bigd").textContent = P.d;
    $("mLoops").textContent = G.loops;
    $("mTurns").textContent = G.turns;
    $("mRatio").textContent = G.turns + ":" + G.loops;
    $("RV").textContent = P.R + " teeth";
    $("rV").textContent = P.r + " teeth";
    $("dV").textContent = P.d === 0 ? "0 · centre" : String(P.d);
    $("speedV").textContent = parseFloat(elSpeed.value).toFixed(2).replace(/0$/, "") + "×";
    $("weightV").textContent = parseFloat(elWeight.value).toFixed(1) + " px";
    syncPresets();
  }

  function say(msg) { live.textContent = msg; }

  /* ── control wiring ─────────────────────────────────────────────────────── */
  function clampInputs() {
    P.R = parseInt(elR.value, 10);
    elr.max = String(P.R - 5);
    if (parseInt(elr.value, 10) > P.R - 5) elr.value = String(P.R - 5);
    P.r = parseInt(elr.value, 10);
    eld.max = String(P.r);
    if (parseInt(eld.value, 10) > P.r) eld.value = String(P.r);
    P.d = parseInt(eld.value, 10);
    scale = (Math.min(W, H) / 2 - 14) / P.R;
  }

  function paramsChanged() {
    var wasR = P.R, wasr = P.r;
    clampInputs();
    computeGeom();
    updateFacts();
    /* Moving the pen to a different hole keeps the same gear pair, so the loop
       count cannot change — the drawing morphs in place instead of starting over,
       which is what dragging that slider should feel like. Changing either tooth
       count is a genuinely different curve, so that one starts from scratch. */
    if (P.R === wasR && P.r === wasr && mode !== "blank") {
      repaintAll(); paintGears(); updateChip(); updateProgress(); clearSaveMsg();
    } else {
      restart();
    }
  }

  [elR, elr, eld].forEach(function (el) { el.addEventListener("input", paramsChanged); });

  elSpeed.addEventListener("input", function () { updateFacts(); });
  elWeight.addEventListener("input", function () { updateFacts(); repaintAll(); });
  elGears.addEventListener("change", paintGears);

  playBtn.addEventListener("click", function () {
    if (mode === "drawing") { stopRaf(); mode = "paused"; updateChip(); paintGears(); say("Paused."); }
    else if (mode === "paused") { start(); say("Resumed."); }
    else { restart(); say("Starting over: " + G.loops + " loops, " + G.turns + " turns to close."); }
  });

  $("clearBtn").addEventListener("click", function () {
    stopRaf();
    mode = "blank"; u = 0;
    clearPath(); gtx.clearRect(0, 0, W, H);
    updateChip(); updateProgress(); clearSaveMsg();
    say("Cleared. Press Draw again to start over.");
  });

  $("randBtn").addEventListener("click", function () { randomize(); });

  function randomize() {
    var R, r, d, g, turns, loops, tries = 0;
    do {
      R = 40 + Math.floor(Math.random() * 121);
      r = 5 + Math.floor(Math.random() * (R - 9));
      g = gcd(R, r); turns = r / g; loops = R / g;
      tries++;
    } while (tries < 200 && (turns < 5 || turns > 46 || loops < 5));
    d = Math.max(1, Math.round(r * (0.45 + Math.random() * 0.55)));
    elR.value = String(R); elr.max = String(R - 5); elr.value = String(r);
    eld.max = String(r); eld.value = String(d);
    hueShift = Math.round((Math.random() * 2 - 1) * 22);   /* nudge the anchor, stay in family */
    paramsChanged();
    say("New gears: ring " + R + ", gear " + r + ", pen hole " + d + ". " + loops + " loops.");
  }

  /* ── presets ────────────────────────────────────────────────────────────── */
  var PRESETS = [
    { n: "Rosette", R: 90,  r: 34, d: 27 },
    { n: "Star",    R: 100, r: 40, d: 38 },
    { n: "Ribbon",  R: 80,  r: 30, d: 12 },
    { n: "Clover",  R: 96,  r: 72, d: 40 },
    { n: "Lace",    R: 126, r: 52, d: 45 },
    { n: "Halo",    R: 48,  r: 43, d: 38 }
  ];
  (function buildPresets() {
    var host = $("presets"), html = "";
    for (var i = 0; i < PRESETS.length; i++) {
      var p = PRESETS[i], g;
      /* keep the table inside the same limits the sliders enforce, so the label,
         the highlight and the drawing can never disagree */
      p.r = Math.max(5, Math.min(p.r, p.R - 5));
      p.d = Math.max(0, Math.min(p.d, p.r));
      g = gcd(p.R, p.r);
      html += '<button type="button" data-i="' + i + '" aria-pressed="false">' +
              '<span class="pn">' + p.n + "</span>" +
              '<span class="pa">' + (p.R / g) + " loops</span></button>";
    }
    host.innerHTML = html;
    host.addEventListener("click", function (e) {
      var btn = e.target.closest ? e.target.closest("button[data-i]") : null;
      if (!btn) return;
      var p = PRESETS[parseInt(btn.getAttribute("data-i"), 10)];
      elR.value = String(p.R); elr.max = String(p.R - 5); elr.value = String(p.r);
      eld.max = String(p.r); eld.value = String(p.d);
      hueShift = 0;
      paramsChanged();
      say(p.n + ": ring " + p.R + ", gear " + p.r + ", pen hole " + p.d + ".");
    });
  })();

  function syncPresets() {
    var btns = $("presets").querySelectorAll("button[data-i]");
    for (var i = 0; i < btns.length; i++) {
      var p = PRESETS[parseInt(btns[i].getAttribute("data-i"), 10)];
      btns[i].setAttribute("aria-pressed", (p.R === P.R && p.r === P.r && p.d === P.d) ? "true" : "false");
    }
  }

  /* ── save PNG ───────────────────────────────────────────────────────────── */
  var msgTimer = 0;
  var framed = (function () { try { return window.top !== window.self; } catch (e) { return true; } })();
  function clearSaveMsg() { clearTimeout(msgTimer); saveMsg.className = "saveline"; saveMsg.textContent = ""; }
  function setSaveMsg(text, cls) {
    clearTimeout(msgTimer);
    saveMsg.className = "saveline" + (cls ? " " + cls : "");
    saveMsg.textContent = text;
    msgTimer = setTimeout(clearSaveMsg, 6000);
  }

  $("saveBtn").addEventListener("click", function () {
    if (mode === "blank" || u === 0) {
      setSaveMsg("nothing drawn yet", "bad");
      say("Nothing drawn yet — press Draw again first.");
      return;
    }
    try {
      var out = document.createElement("canvas");
      out.width = cvPath.width; out.height = cvPath.height;
      var o = out.getContext("2d");
      o.fillStyle = "rgb(" + COL.surface[0] + "," + COL.surface[1] + "," + COL.surface[2] + ")";
      o.fillRect(0, 0, out.width, out.height);
      o.drawImage(cvPath, 0, 0);
      o.setTransform(dpr, 0, 0, dpr, 0, 0);
      o.font = '11px ' + "ui-monospace, Menlo, Consolas, monospace";
      o.fillStyle = rgba(COL.muted, 0.85);
      o.textAlign = "right"; o.textBaseline = "alphabetic";
      o.fillText(P.R + "/" + P.r + "·" + P.d + "  ·  sloprun.dev", W - 12, H - 11);

      var url = out.toDataURL("image/png");
      var a = $("dl");
      a.href = url;
      a.download = "spirograph-" + P.R + "-" + P.r + "-" + P.d + ".png";
      a.click();
      /* A sandboxed frame can refuse the download without throwing, so only a
         top-level page is allowed to claim the file actually landed. */
      var kb = Math.round(url.length * 0.75 / 1024) + " KB";
      if (framed) {
        /* neutral, not green: green is reserved for things that demonstrably worked */
        setSaveMsg("built · " + kb + " · if no download, this frame blocked it");
        say("PNG built, " + kb + ". If no download appeared, this frame blocked it.");
      } else {
        setSaveMsg("saved · " + kb, "ok");
        say("Image saved, " + kb + ".");
      }
    } catch (err) {
      setSaveMsg("this frame blocks saving", "bad");
      say("Saving is blocked in this frame.");
    }
  });

  /* ── keyboard shortcuts ─────────────────────────────────────────────────── */
  document.addEventListener("keydown", function (e) {
    if (e.metaKey || e.ctrlKey || e.altKey || e.repeat) return;   /* held keys must not spam restarts */
    var t = e.target, tag = t && t.tagName ? t.tagName.toLowerCase() : "";
    if (tag === "input" || tag === "textarea" || tag === "select") {
      if (!(e.key === " " && t.type === "range")) return;
    }
    var k = e.key.toLowerCase();
    if (e.key === " " || k === "spacebar") {
      if (tag === "button" || tag === "a") return;   /* let the focused control take it */
      e.preventDefault(); playBtn.click();
    }
    else if (k === "r") { e.preventDefault(); randomize(); }
    else if (k === "c") { e.preventDefault(); $("clearBtn").click(); }
    else if (k === "s") { e.preventDefault(); $("saveBtn").click(); }
  });

  /* ── boot ───────────────────────────────────────────────────────────────── */
  readTheme();
  if (reduced) {
    $("rmNote").style.display = "block";
    $("speedCtl").style.display = "none";
  }
  clampInputs();
  resize();
  updateFacts();
  restart();

  window.addEventListener("resize", resize);
  if (window.ResizeObserver) { try { new ResizeObserver(resize).observe(field); } catch (err) {} }
  document.addEventListener("visibilitychange", function () { lastT = 0; });
})();
</script>
</body>
</html>

source-visible by construction · nothing is published here without its code

Comments

0 total

No comments yet. If you ran it, say what happened.

Report this post

Goes straight to the moderation queue. Enough independent reports and the post is suspended automatically until a human looks.

what is wrong