← back to Graphics Agentabrams

graphics/spirograph/Spirograph.aa

496 lines

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Spirograph Studio</title>
<style>
  :root{
    --bg:#0d0f1a; --panel:#161a2e; --panel2:#1e2340;
    --ink:#e8ecff; --muted:#8b91b8; --accent:#5eead4; --accent2:#a78bfa;
    --line:#2a3055;
  }
  *{box-sizing:border-box}
  [hidden]{display:none!important}
  html,body{height:100%}
  body{
    margin:0; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
    background:radial-gradient(1200px 800px at 70% -10%,#1b2145 0%,var(--bg) 55%);
    color:var(--ink); display:flex; min-height:100%; overflow:hidden;
  }
  .wrap{display:flex; width:100%; height:100vh}
  .stage{
    flex:1; display:flex; align-items:center; justify-content:center;
    position:relative; padding:24px; min-width:0;
  }
  #canvas{
    background:#07080f;
    border-radius:16px;
    box-shadow:0 20px 60px rgba(0,0,0,.55), inset 0 0 0 1px rgba(255,255,255,.04);
    max-width:100%; max-height:100%;
    touch-action:none;
  }
  .panel{
    width:320px; flex-shrink:0; background:linear-gradient(180deg,var(--panel),var(--panel2));
    border-left:1px solid var(--line);
    padding:22px 22px 26px; display:flex; flex-direction:column; gap:18px;
    overflow-y:auto;
  }
  .brand{display:flex; align-items:center; gap:11px}
  .brand .dot{
    width:30px;height:30px;border-radius:9px;
    background:conic-gradient(from 0deg,var(--accent),var(--accent2),#f472b6,var(--accent));
    box-shadow:0 0 18px rgba(94,234,212,.4);
  }
  .brand h1{font-size:17px; margin:0; letter-spacing:.4px; font-weight:650}
  .brand span{font-size:11px; color:var(--muted); display:block; margin-top:1px; letter-spacing:.5px}

  .ctrl{display:flex; flex-direction:column; gap:8px}
  .ctrl label{display:flex; justify-content:space-between; align-items:baseline; font-size:12.5px; color:var(--muted); letter-spacing:.3px}
  .ctrl label b{color:var(--ink); font-weight:600; font-variant-numeric:tabular-nums; font-size:13px}
  input[type=range]{
    -webkit-appearance:none; appearance:none; width:100%; height:6px; border-radius:6px;
    background:linear-gradient(90deg,var(--accent),var(--accent2)); outline:none; cursor:pointer;
  }
  input[type=range]::-webkit-slider-thumb{
    -webkit-appearance:none; width:18px;height:18px;border-radius:50%;
    background:#fff; border:3px solid var(--accent); box-shadow:0 2px 8px rgba(0,0,0,.5); cursor:pointer;
  }
  input[type=range]::-moz-range-thumb{
    width:16px;height:16px;border-radius:50%; background:#fff; border:3px solid var(--accent); cursor:pointer;
  }
  select{
    width:100%; font:inherit; font-size:12.5px; color:var(--ink);
    background:rgba(255,255,255,.04); border:1px solid var(--line); border-radius:9px;
    padding:8px 10px; cursor:pointer; outline:none;
  }
  select:focus{border-color:var(--accent2)}
  input[type=color]{
    -webkit-appearance:none; appearance:none; width:34px; height:26px; padding:0;
    border:1px solid var(--line); border-radius:7px; background:none; cursor:pointer;
  }
  input[type=color]::-webkit-color-swatch-wrapper{padding:2px}
  input[type=color]::-webkit-color-swatch{border:none; border-radius:5px}
  .colorwrap{display:flex; align-items:center; gap:10px}
  .row{display:flex; gap:10px}
  .toggle{
    display:flex; align-items:center; justify-content:space-between; gap:10px;
    font-size:12.5px; color:var(--muted); background:rgba(255,255,255,.03);
    padding:10px 12px; border-radius:10px; border:1px solid var(--line);
  }
  .switch{position:relative; width:42px; height:23px; flex-shrink:0}
  .switch input{opacity:0; width:0; height:0}
  .slider-sw{
    position:absolute; inset:0; background:#3a4066; border-radius:23px; transition:.2s; cursor:pointer;
  }
  .slider-sw:before{
    content:""; position:absolute; height:17px; width:17px; left:3px; top:3px;
    background:#fff; border-radius:50%; transition:.2s;
  }
  .switch input:checked + .slider-sw{background:linear-gradient(90deg,var(--accent),var(--accent2))}
  .switch input:checked + .slider-sw:before{transform:translateX(19px)}

  button{
    flex:1; font:inherit; font-size:13px; font-weight:600; letter-spacing:.3px;
    padding:12px 10px; border-radius:11px; border:1px solid var(--line);
    background:rgba(255,255,255,.04); color:var(--ink); cursor:pointer; transition:.15s;
  }
  button:hover{background:rgba(255,255,255,.09); transform:translateY(-1px)}
  button:active{transform:translateY(0)}
  button.primary{
    background:linear-gradient(90deg,var(--accent),var(--accent2)); color:#0b0d18; border:none;
  }
  button.primary:hover{filter:brightness(1.08)}
  .seg{display:flex; gap:6px; flex-wrap:wrap}
  .seg button{flex:1 1 auto; padding:8px 6px; font-size:11.5px; font-weight:600}

  .readout{
    font-size:11px; color:var(--muted); line-height:1.6; letter-spacing:.2px;
    background:rgba(255,255,255,.02); border:1px solid var(--line); border-radius:9px; padding:9px 11px;
    font-variant-numeric:tabular-nums;
  }
  .readout b{color:var(--accent)}
  .foot{margin-top:auto; font-size:11px; color:var(--muted); line-height:1.5; letter-spacing:.2px}
  .hint{font-size:11px; color:var(--muted); margin-top:-4px}
  kbd{
    font-family:ui-monospace,monospace; font-size:10px; background:rgba(255,255,255,.06);
    border:1px solid var(--line); border-bottom-width:2px; border-radius:5px; padding:1px 5px; color:var(--ink);
  }
  @media (max-width:760px){
    body{overflow:auto}
    .wrap{flex-direction:column; height:auto}
    .panel{width:100%; border-left:none; border-top:1px solid var(--line)}
    .stage{padding:16px; min-height:56vh}
  }
</style>
</head>
<body>
<div class="wrap">
  <div class="stage">
    <canvas id="canvas"></canvas>
  </div>

  <aside class="panel">
    <div class="brand">
      <div class="dot"></div>
      <div>
        <h1>Spirograph Studio</h1>
        <span>HYPOTROCHOID PLOTTER</span>
      </div>
    </div>

    <div class="ctrl">
      <label>Fixed gear radius <span>R</span> <b id="vR">140</b></label>
      <input type="range" id="R" min="40" max="230" value="140" step="1">
    </div>

    <div class="ctrl">
      <label>Rolling gear radius <span>r</span> <b id="vr">53</b></label>
      <input type="range" id="r" min="8" max="220" value="53" step="1">
    </div>

    <div class="ctrl">
      <label>Pen offset <span>d</span> <b id="vd">78</b></label>
      <input type="range" id="d" min="0" max="180" value="78" step="1">
    </div>

    <div class="ctrl">
      <label>Speed <b id="vs">6</b></label>
      <input type="range" id="speed" min="1" max="40" value="6" step="1">
    </div>

    <div class="ctrl">
      <label>Line width <b id="vw">1.6</b></label>
      <input type="range" id="lw" min="0.4" max="6" value="1.6" step="0.1">
    </div>

    <div class="ctrl">
      <label>Color mode</label>
      <select id="colormode">
        <option value="cycle">Rainbow — cycle</option>
        <option value="angle">Rainbow — by angle</option>
        <option value="solid">Solid</option>
      </select>
    </div>

    <div class="ctrl" id="solidRow" hidden>
      <label>Ink color</label>
      <div class="colorwrap">
        <input type="color" id="ink" value="#5eead4">
        <span class="hint" style="margin:0">used in Solid mode</span>
      </div>
    </div>

    <div class="toggle">
      <span>Show gears</span>
      <label class="switch"><input type="checkbox" id="gears" checked><span class="slider-sw"></span></label>
    </div>

    <div class="toggle">
      <span>Glow</span>
      <label class="switch"><input type="checkbox" id="glow"><span class="slider-sw"></span></label>
    </div>

    <div class="ctrl">
      <label>Presets</label>
      <div class="seg">
        <button data-preset="rose">Rose</button>
        <button data-preset="star">Star</button>
        <button data-preset="web">Web</button>
        <button data-preset="loop">Loops</button>
      </div>
    </div>

    <div class="row">
      <button id="play" class="primary">⏸ Pause</button>
      <button id="random">🎲 Random</button>
    </div>
    <div class="row">
      <button id="clear">Clear</button>
      <button id="save" class="primary">Save PNG</button>
    </div>

    <div class="readout" id="readout">—</div>

    <div class="hint">
      <kbd>Space</kbd> play/pause · <kbd>C</kbd> clear · <kbd>R</kbd> random · <kbd>S</kbd> save
    </div>

    <div class="foot">
      Curve: x = (R−r)·cos t + d·cos((R−r)/r·t)<br>
      &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;y = (R−r)·sin t − d·sin((R−r)/r·t)
    </div>
  </aside>
</div>

<script>
(function(){
  "use strict";
  const canvas = document.getElementById('canvas');
  const ctx = canvas.getContext('2d');

  // Logical drawing size (device-independent). Backing stores are scaled by DPR
  // so the curve is crisp on retina displays — the original blurred on hi-DPI.
  const W = 760, H = 760;
  const cx = W/2, cy = H/2;
  const DPR = Math.min(window.devicePixelRatio || 1, 2);

  canvas.width = W*DPR; canvas.height = H*DPR;
  canvas.style.width = W+'px'; canvas.style.height = H+'px';
  ctx.scale(DPR, DPR);

  // Offscreen trail layer (persists between frames; gear overlay is drawn fresh each frame)
  const trail = document.createElement('canvas');
  trail.width = W*DPR; trail.height = H*DPR;
  const tctx = trail.getContext('2d');
  tctx.scale(DPR, DPR);

  // Controls
  const el = id => document.getElementById(id);
  const R = el('R'), r = el('r'), d = el('d'), speed = el('speed'), lw = el('lw');
  const colormode = el('colormode'), ink = el('ink');
  const gears = el('gears'), glow = el('glow');
  const vR=el('vR'), vr=el('vr'), vd=el('vd'), vs=el('vs'), vw=el('vw');
  const readout = el('readout'), playBtn = el('play'), solidRow = el('solidRow');

  // Drawing state
  let t = 0;                    // parameter angle
  let hue = Math.random()*360;
  let lastPt = null;            // last plotted point on the persistent trail
  let period = 0;               // t value at which the curve closes
  let running = true;
  let laps = 0;                 // completed closed patterns since last clear

  const num  = inp => parseFloat(inp.value);
  const inti = inp => parseInt(inp.value,10);

  function gcd(a,b){ a=Math.round(a); b=Math.round(b); while(b){ const tmp=a%b; a=b; b=tmp; } return a||1; }

  function computePeriod(){
    const rr = Math.max(1, inti(r));
    const RR = inti(R);
    // Full pattern closes after r/gcd(R,r) revolutions of the driving angle.
    const revs = rr / gcd(RR, rr);
    period = 2*Math.PI*revs;
  }

  function point(tt){
    const RR = inti(R);
    const rr = Math.max(1, inti(r));
    const dd = inti(d);
    const k = RR - rr;
    const ratio = k / rr;
    return {
      x: cx + k*Math.cos(tt) + dd*Math.cos(ratio*tt),
      y: cy + k*Math.sin(tt) - dd*Math.sin(ratio*tt)
    };
  }

  function updateReadout(){
    const RR = inti(R), rr = Math.max(1, inti(r)), dd = inti(d);
    const g = gcd(RR, rr);
    const revs = rr / g;
    const pct = period ? Math.min(100, (t/period)*100) : 0;
    readout.innerHTML =
      'R/r ratio <b>'+ (RR/rr).toFixed(3) +'</b> · closes in <b>'+ revs +'</b> rev'+(revs===1?'':'s')+'<br>'+
      'petals <b>'+ (RR/g) +'</b> · pen d <b>'+ dd +'</b> · pass <b>'+ Math.round(pct) +'%</b> · laps <b>'+ laps +'</b>';
  }

  function strokeFor(){
    const mode = colormode.value;
    if(mode === 'solid') return ink.value;
    if(mode === 'angle'){
      const h = period ? (t/period*360*3) % 360 : hue;
      return 'hsl('+h.toFixed(1)+',85%,62%)';
    }
    return 'hsl('+hue.toFixed(1)+',85%,62%)'; // cycle
  }

  function resetTrail(){
    tctx.clearRect(0,0,W,H);
    t = 0; laps = 0;
    lastPt = point(0);
    computePeriod();
    updateReadout();
  }

  // Called when a shape parameter changes: fresh curve, new base hue.
  function restart(){
    hue = (hue + 47) % 360;
    resetTrail();
    render();
  }

  // Rolling-gear schematic for the current t (overlay only).
  function drawGears(octx, tt){
    const RR = inti(R), rr = Math.max(1, inti(r));
    const k = RR - rr;
    octx.save();
    octx.strokeStyle = 'rgba(255,255,255,.16)';
    octx.lineWidth = 1;
    octx.beginPath(); octx.arc(cx, cy, RR, 0, Math.PI*2); octx.stroke();
    const gx = cx + k*Math.cos(tt);
    const gy = cy + k*Math.sin(tt);
    octx.strokeStyle = 'rgba(167,139,250,.55)';
    octx.beginPath(); octx.arc(gx, gy, rr, 0, Math.PI*2); octx.stroke();
    const p = point(tt);
    octx.strokeStyle = 'rgba(94,234,212,.5)';
    octx.beginPath(); octx.moveTo(gx,gy); octx.lineTo(p.x,p.y); octx.stroke();
    octx.fillStyle = '#5eead4';
    octx.beginPath(); octx.arc(p.x,p.y,3.2,0,Math.PI*2); octx.fill();
    octx.fillStyle = 'rgba(167,139,250,.9)';
    octx.beginPath(); octx.arc(gx,gy,2.5,0,Math.PI*2); octx.fill();
    octx.restore();
  }

  function render(){
    ctx.clearRect(0,0,W,H);
    ctx.drawImage(trail,0,0,W,H);
    if(gears.checked && running) drawGears(ctx, t);
  }

  function frame(){
    if(running){
      const steps = inti(speed);
      const width = num(lw);
      const dt = 0.012; // base angular step per micro-segment
      tctx.lineCap = 'round';
      tctx.lineJoin = 'round';
      tctx.lineWidth = width;
      if(glow.checked){ tctx.shadowBlur = 8; } else { tctx.shadowBlur = 0; }

      for(let i=0;i<steps;i++){
        const nt = t + dt;
        const p = point(nt);
        if(colormode.value === 'cycle') hue = (hue + 0.35) % 360;
        const stroke = strokeFor();
        tctx.strokeStyle = stroke;
        if(glow.checked) tctx.shadowColor = stroke;
        tctx.beginPath();
        tctx.moveTo(lastPt.x,lastPt.y);
        tctx.lineTo(p.x,p.y);
        tctx.stroke();
        lastPt = p;
        t = nt;
        if(t >= period){
          // Pattern complete: keep it, start a fresh overlaid pass with a new base hue.
          t = 0; laps++;
          lastPt = point(0);
          hue = (hue + 63) % 360;
        }
      }
      tctx.shadowBlur = 0;
      updateReadout();
    }
    render();
    requestAnimationFrame(frame);
  }

  // ---- Value labels + persistence ----
  function bind(input, label, fmt){
    const upd = ()=> label.textContent = fmt ? fmt(input.value) : input.value;
    input.addEventListener('input', upd); upd();
  }
  bind(R, vR); bind(r, vr); bind(d, vd); bind(speed, vs); bind(lw, vw);

  const SETTINGS = ['R','r','d','speed','lw','colormode','ink'];
  const TOGGLES = ['gears','glow'];
  function saveSettings(){
    try{
      const s = {};
      SETTINGS.forEach(k=> s[k] = el(k).value);
      TOGGLES.forEach(k=> s[k] = el(k).checked);
      localStorage.setItem('spiro-settings', JSON.stringify(s));
    }catch(e){}
  }
  function loadSettings(){
    try{
      const s = JSON.parse(localStorage.getItem('spiro-settings')||'null');
      if(!s) return;
      SETTINGS.forEach(k=>{ if(s[k]!=null) el(k).value = s[k]; });
      TOGGLES.forEach(k=>{ if(s[k]!=null) el(k).checked = s[k]; });
      [vR,vr,vd,vs,vw].forEach(()=>{});
      vR.textContent=R.value; vr.textContent=r.value; vd.textContent=d.value;
      vs.textContent=speed.value; vw.textContent=lw.value;
    }catch(e){}
  }
  loadSettings();

  // Shape-changing sliders restart the curve; all controls persist.
  [R,r,d].forEach(inp => inp.addEventListener('input', restart));
  [R,r,d,speed,lw,colormode,ink,gears,glow].forEach(inp =>
    inp.addEventListener('input', saveSettings));

  colormode.addEventListener('input', ()=>{ solidRow.hidden = colormode.value !== 'solid'; });
  solidRow.hidden = colormode.value !== 'solid';

  // ---- Buttons ----
  function setPlaying(on){
    running = on;
    playBtn.textContent = on ? '⏸ Pause' : '▶ Play';
  }
  playBtn.addEventListener('click', ()=> setPlaying(!running));
  el('clear').addEventListener('click', restart);

  el('random').addEventListener('click', ()=>{
    const rnd = (min,max)=> Math.floor(min + Math.random()*(max-min+1));
    R.value = rnd(90, 230);
    r.value = rnd(20, Math.max(30, +R.value - 10)); // keep a real hypotrochoid (r < R mostly)
    d.value = rnd(20, 170);
    const modes = ['cycle','angle','solid'];
    colormode.value = modes[rnd(0,2)];
    solidRow.hidden = colormode.value !== 'solid';
    vR.textContent=R.value; vr.textContent=r.value; vd.textContent=d.value;
    saveSettings();
    restart();
    setPlaying(true);
  });

  const PRESETS = {
    rose: {R:200, r:60, d:120, mode:'cycle'},
    star: {R:150, r:37, d:90,  mode:'angle'},
    web:  {R:220, r:99, d:150, mode:'cycle'},
    loop: {R:120, r:29, d:170, mode:'solid'}
  };
  document.querySelectorAll('[data-preset]').forEach(btn=>{
    btn.addEventListener('click', ()=>{
      const p = PRESETS[btn.dataset.preset]; if(!p) return;
      R.value=p.R; r.value=p.r; d.value=p.d; colormode.value=p.mode;
      solidRow.hidden = colormode.value !== 'solid';
      vR.textContent=R.value; vr.textContent=r.value; vd.textContent=d.value;
      saveSettings(); restart(); setPlaying(true);
    });
  });

  el('save').addEventListener('click', ()=>{
    // Composite onto a dark background so the PNG isn't transparent.
    const out = document.createElement('canvas');
    out.width = trail.width; out.height = trail.height;
    const octx = out.getContext('2d');
    octx.fillStyle = '#07080f';
    octx.fillRect(0,0,out.width,out.height);
    octx.drawImage(trail,0,0);
    const link = document.createElement('a');
    link.download = 'spirograph-' + Date.now() + '.png';
    link.href = out.toDataURL('image/png');
    link.click();
  });

  // ---- Keyboard shortcuts ----
  addEventListener('keydown', e=>{
    if(e.target.matches('input,select,textarea')) return;
    const k = e.key.toLowerCase();
    if(k === ' '){ e.preventDefault(); setPlaying(!running); }
    else if(k === 'c'){ restart(); }
    else if(k === 'r'){ el('random').click(); }
    else if(k === 's'){ el('save').click(); }
  });

  // Init
  resetTrail();
  requestAnimationFrame(frame);
})();
</script>
</body>
</html>