← Mike Higgins Home
Custom Code Experiment •

Dragon Curve

Source Code View HTML / CSS / JavaScript
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dragon Curve</title>
<style>
  @import url('https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&display=swap');

  * { margin: 0; padding: 0; box-sizing: border-box; }

  body {
    background: #05050f;
    overflow: hidden;
    width: 100vw;
    height: 100vh;
    font-family: 'Space Mono', monospace;
    cursor: crosshair;
  }

  canvas {
    display: block;
    width: 100vw;
    height: 100vh;
  }

  #ui {
    position: fixed;
    top: 0; left: 0; right: 0; bottom: 0;
    pointer-events: none;
  }

  #title {
    position: fixed;
    top: 32px;
    left: 40px;
    pointer-events: none;
  }

  #title h1 {
    font-size: 11px;
    font-weight: 700;
    letter-spacing: 0.35em;
    text-transform: uppercase;
    color: rgba(180, 200, 255, 0.9);
  }

  #title p {
    font-size: 10px;
    letter-spacing: 0.2em;
    color: rgba(100, 130, 200, 0.55);
    margin-top: 4px;
  }

  #depth-ctrl {
    position: fixed;
    bottom: 32px;
    left: 40px;
    pointer-events: all;
    display: flex;
    flex-direction: column;
    gap: 8px;
  }

  #depth-ctrl label {
    font-size: 10px;
    letter-spacing: 0.2em;
    color: rgba(100, 130, 200, 0.6);
    text-transform: uppercase;
  }

  #depth-ctrl .controls {
    display: flex;
    align-items: center;
    gap: 12px;
  }

  #depth-ctrl button {
    width: 28px;
    height: 28px;
    background: transparent;
    border: 1px solid rgba(100, 140, 255, 0.3);
    color: rgba(160, 190, 255, 0.8);
    font-family: 'Space Mono', monospace;
    font-size: 16px;
    cursor: pointer;
    display: flex;
    align-items: center;
    justify-content: center;
    transition: border-color 0.2s, background 0.2s;
    line-height: 1;
  }

  #depth-ctrl button:hover {
    border-color: rgba(100, 160, 255, 0.8);
    background: rgba(100, 140, 255, 0.1);
  }

  #depth-val {
    font-size: 18px;
    font-weight: 700;
    color: rgba(180, 210, 255, 0.9);
    min-width: 24px;
    text-align: center;
  }

  #status {
    position: fixed;
    bottom: 32px;
    right: 40px;
    text-align: right;
    pointer-events: none;
  }

  #status p {
    font-size: 10px;
    letter-spacing: 0.18em;
    color: rgba(100, 130, 200, 0.45);
    text-transform: uppercase;
    line-height: 1.9;
  }

  #status span {
    color: rgba(150, 180, 255, 0.7);
  }

  #hover-hint {
    position: fixed;
    top: 32px;
    right: 40px;
    font-size: 10px;
    letter-spacing: 0.2em;
    color: rgba(100, 130, 200, 0.45);
    text-transform: uppercase;
    pointer-events: none;
    transition: opacity 0.6s;
  }

  .dot {
    position: fixed;
    width: 4px;
    height: 4px;
    border-radius: 50%;
    background: rgba(120, 160, 255, 0.4);
    pointer-events: none;
  }
  .dot:nth-child(1) { top: 32px; left: 34px; }
  .dot:nth-child(2) { bottom: 32px; left: 34px; }
  .dot:nth-child(3) { bottom: 32px; right: 34px; }
  .dot:nth-child(4) { top: 32px; right: 34px; }
</style>
</head>
<body>

<canvas id="cv"></canvas>

<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>

<div id="title">
  <h1>Dragon Curve</h1>
  <p>Heighway fractal &mdash; paper folding sequence</p>
</div>

<div id="hover-hint">Move mouse to shift spectrum</div>

<div id="depth-ctrl">
  <label>Depth</label>
  <div class="controls">
    <button id="btn-dec">−</button>
    <span id="depth-val">14</span>
    <button id="btn-inc">+</button>
  </div>
</div>

<div id="status">
  <p>Segments <span id="seg-count">—</span></p>
  <p>Iterations <span id="iter-count">—</span></p>
</div>

<script>
(function(){
  const canvas = document.getElementById('cv');
  const ctx = canvas.getContext('2d');
  let W, H;

  function resize() {
    W = canvas.width = window.innerWidth;
    H = canvas.height = window.innerHeight;
    draw();
  }

  let depth = 14;
  let mouseNX = 0.5, mouseNY = 0.5, hovered = false;
  let animFrame = null;

  function buildDragon(d) {
    let turns = [1];
    for (let i = 1; i < d; i++) {
      const len = turns.length;
      turns.push(1);
      for (let j = len - 1; j >= 0; j--) {
        turns.push(1 - turns[j]);
      }
    }
    return turns;
  }

  function computePoints(turns) {
    const stepMap = [[1,0],[0,1],[-1,0],[0,-1]];
    let px = 0, py = 0, facing = 0;
    const pts = [{x: px, y: py}];
    for (const t of turns) {
      facing = t === 1 ? (facing + 1) % 4 : (facing + 3) % 4;
      px += stepMap[facing][0];
      py += stepMap[facing][1];
      pts.push({x: px, y: py});
    }
    return pts;
  }

  function scalePoints(pts) {
    let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
    for (const p of pts) {
      if (p.x < minX) minX = p.x;
      if (p.x > maxX) maxX = p.x;
      if (p.y < minY) minY = p.y;
      if (p.y > maxY) maxY = p.y;
    }
    const pad = 0.1;
    const rangeX = maxX - minX;
    const rangeY = maxY - minY;
    const scaleX = W * (1 - 2 * pad) / rangeX;
    const scaleY = H * (1 - 2 * pad) / rangeY;
    const scale = Math.min(scaleX, scaleY);
    const ox = (W - rangeX * scale) / 2 - minX * scale;
    const oy = (H - rangeY * scale) / 2 - minY * scale;
    return pts.map(p => ({ x: p.x * scale + ox, y: p.y * scale + oy }));
  }

  let scaledPts = [];

  function rebuild() {
    const turns = buildDragon(depth);
    const raw = computePoints(turns);
    scaledPts = scalePoints(raw);
    document.getElementById('seg-count').textContent = (scaledPts.length - 1).toLocaleString();
    document.getElementById('iter-count').textContent = Math.pow(2, depth).toLocaleString();
    document.getElementById('depth-val').textContent = depth;
    draw();
  }

  function hsl(h, s, l) {
    h = ((h % 360) + 360) % 360;
    s = Math.max(0, Math.min(100, s));
    l = Math.max(0, Math.min(100, l));
    const sl = s / 100, ll = l / 100;
    const a = sl * Math.min(ll, 1 - ll);
    const f = n => {
      const k = (n + h / 30) % 12;
      return ll - a * Math.max(-1, Math.min(k - 3, Math.min(9 - k, 1)));
    };
    const r = Math.round(f(0) * 255);
    const g = Math.round(f(8) * 255);
    const b = Math.round(f(4) * 255);
    return `rgb(${r},${g},${b})`;
  }

  function getColor(t, hovered, mx, my) {
    if (!hovered) {
      return hsl(210 + t * 150, 70, 38 + t * 22);
    } else {
      const h = (mx * 300 + t * 280 + my * 80) % 360;
      return hsl(h, 88 + my * 12, 38 + t * 30);
    }
  }

  function draw() {
    ctx.clearRect(0, 0, W, H);
    ctx.fillStyle = '#05050f';
    ctx.fillRect(0, 0, W, H);

    const n = scaledPts.length - 1;
    if (n < 1) return;

    const lw = hovered ? 1.3 : 0.95;
    ctx.lineWidth = lw;

    const BATCH = Math.max(1, Math.floor(n / 1200));

    for (let i = 0; i < n; i += BATCH) {
      const t = i / n;
      ctx.beginPath();
      ctx.moveTo(scaledPts[i].x, scaledPts[i].y);
      const end = Math.min(i + BATCH, n);
      ctx.lineTo(scaledPts[end].x, scaledPts[end].y);
      ctx.strokeStyle = getColor(t, hovered, mouseNX, mouseNY);
      ctx.stroke();
    }
  }

  window.addEventListener('resize', resize);

  canvas.addEventListener('mousemove', e => {
    mouseNX = e.clientX / W;
    mouseNY = e.clientY / H;
    if (!hovered) {
      hovered = true;
      document.getElementById('hover-hint').style.opacity = '0';
    }
    if (animFrame) cancelAnimationFrame(animFrame);
    animFrame = requestAnimationFrame(draw);
  });

  canvas.addEventListener('mouseleave', () => {
    hovered = false;
    document.getElementById('hover-hint').style.opacity = '1';
    draw();
  });

  document.getElementById('btn-inc').addEventListener('click', () => {
    if (depth < 18) { depth++; rebuild(); }
  });

  document.getElementById('btn-dec').addEventListener('click', () => {
    if (depth > 1) { depth--; rebuild(); }
  });

  W = canvas.width = window.innerWidth;
  H = canvas.height = window.innerHeight;
  rebuild();
})();
</script>
</body>
</html>