Home

26. Penrose · 1974

Penrose tiling

Colors and knobs

These rewrite the live file. Copy HTML picks up whatever you set.

About

A pentagon of acute golden triangles, subdivided by Robinson's rule. Two rhombs, no period. Fat and thin take the two tweak colors. Drag to pan. Depth rebuilds the mesh.

Drag on the preview to pan.

Browser APIs

  • Canvas 2D
  • Pointer Events
  • requestAnimationFrame

If WebGL is missing, the demo draws a message on the canvas instead of a blank frame. prefers-reduced-motion freezes the first still.

Source

(function () {
  const PARAMS = {
    color: "#6ee7b7",
    accent: "#d2a85c",
    depth: 6
  };
  function hexToRgb(hex) {
    const n = parseInt(String(hex).replace("#", ""), 16);
    if (isNaN(n)) return [110, 231, 183];
    return [n >> 16 & 255, n >> 8 & 255, n & 255];
  }
  function css(rgb, a) {
    return "rgba(" + rgb[0] + "," + rgb[1] + "," + rgb[2] + "," + a + ")";
  }

  const canvas = document.getElementById("c");
  const fallback = document.getElementById("fallback");
  const ctx = canvas.getContext("2d");
  if (!ctx) {
    fallback.hidden = false;
    fallback.textContent = "Canvas 2D is not available in this browser.";
    return;
  }

  const PHI = (1 + Math.sqrt(5)) / 2;
  const INV = 1 / PHI;
  function lerp(a, b, t) {
    return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
  }

  function subdivide(tris) {
    const out = [];
    for (let i = 0; i < tris.length; i++) {
      const t = tris[i];
      const a = t.a, b = t.b, c = t.c;
      if (t.k === "A") {
        const p = lerp(a, b, INV);
        out.push({ k: "A", a: c, b: p, c: b });
        out.push({ k: "O", a: p, b: c, c: a });
      } else {
        const q = lerp(b, a, INV);
        out.push({ k: "O", a: q, b: c, c: a });
        out.push({ k: "A", a: q, b: c, c: b });
      }
    }
    return out;
  }

  function build(depth) {
    const seed = [];
    for (let i = 0; i < 5; i++) {
      const a0 = (i * 72 - 90) * Math.PI / 180;
      const a1 = ((i + 1) * 72 - 90) * Math.PI / 180;
      const b = [Math.cos(a0), Math.sin(a0)];
      const c = [Math.cos(a1), Math.sin(a1)];
      seed.push({ k: "A", a: [0, 0], b: b, c: c });
    }
    let t = seed;
    const d = Math.max(3, Math.min(7, depth | 0));
    for (let i = 0; i < d; i++) t = subdivide(t);
    return t;
  }

  let tiles = build(PARAMS.depth);
  let lastDepth = PARAMS.depth;
  let panX = 0, panY = 0, dragging = false, lastX = 0, lastY = 0;
  const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

  canvas.addEventListener("pointerdown", function (e) {
    dragging = true; lastX = e.clientX; lastY = e.clientY;
    canvas.setPointerCapture(e.pointerId);
  });
  canvas.addEventListener("pointerup", function () { dragging = false; });
  canvas.addEventListener("pointermove", function (e) {
    if (!dragging) return;
    panX += e.clientX - lastX;
    panY += e.clientY - lastY;
    lastX = e.clientX; lastY = e.clientY;
  });

  function draw(w, h) {
    if (PARAMS.depth !== lastDepth) {
      tiles = build(PARAMS.depth);
      lastDepth = PARAMS.depth;
    }
    ctx.fillStyle = "#0b0c0e";
    ctx.fillRect(0, 0, w, h);
    const fat = hexToRgb(PARAMS.color);
    const thin = hexToRgb(PARAMS.accent);
    const s = Math.min(w, h) * 0.46;
    const cx = w * 0.5 + panX;
    const cy = h * 0.5 + panY;
    ctx.lineJoin = "round";
    for (let i = 0; i < tiles.length; i++) {
      const t = tiles[i];
      ctx.beginPath();
      ctx.moveTo(cx + t.a[0] * s, cy + t.a[1] * s);
      ctx.lineTo(cx + t.b[0] * s, cy + t.b[1] * s);
      ctx.lineTo(cx + t.c[0] * s, cy + t.c[1] * s);
      ctx.closePath();
      ctx.fillStyle = t.k === "A" ? css(fat, 0.55) : css(thin, 0.55);
      ctx.fill();
      ctx.strokeStyle = css(fat, 0.35);
      ctx.lineWidth = 0.8;
      ctx.stroke();
    }
  }

  function resize() {
    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    const w = Math.max(1, Math.floor(canvas.clientWidth * dpr));
    const h = Math.max(1, Math.floor(canvas.clientHeight * dpr));
    if (canvas.width !== w || canvas.height !== h) { canvas.width = w; canvas.height = h; }
  }

  function frame() {
    resize();
    draw(canvas.width, canvas.height);
    if (!reduced) requestAnimationFrame(frame);
  }

  if (reduced) { resize(); draw(canvas.width, canvas.height); }
  else requestAnimationFrame(frame);
})();