# Layered Plywood Relief Island Model with Walking Routes Lit Bulb by Bulb on a Walnut Plinth

> Relief model of an island cut from stacked plywood, drawn on a canvas, with walking routes that light up bulb by bulb. Free React code and HTML to copy.

A shadcn/ui block from the [@weinc](https://we.inc/components?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/weinc/layered-plywood-relief-island-model-with-walking-routes-lit-bulb-by-bulb-on-a-walnut-plinth).

- Page, with a live preview: https://shadcn.exchange/r/weinc/layered-plywood-relief-island-model-with-walking-routes-lit-bulb-by-bulb-on-a-walnut-plinth
- Screenshot: https://shadcn.exchange/shot/weinc/layered-plywood-relief-island-model-with-walking-routes-lit-bulb-by-bulb-on-a-walnut-plinth/light.png
- Registry item: https://we.inc/r/layered-plywood-relief-island-model-with-walking-routes-lit-bulb-by-bulb-on-a-walnut-plinth.json
- Listed: 2026-10-28, source last changed 2026-10-28

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/weinc/layered-plywood-relief-island-model-with-walking-routes-lit-bulb-by-bulb-on-a-walnut-plinth.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `framer-motion`

## Files

### `components/we-inc/layered-plywood-relief-island-model-with-walking-routes-lit-bulb-by-bulb-on-a-walnut-plinth.tsx`

```tsx
import { useEffect, useRef, useState } from "react";
import { motion, useMotionValue, useReducedMotion, useSpring } from "framer-motion";

/** The made-up island and what is engraved on its brass plate. */
const island = { name: "Skerrow", plate: "Relief model no. 12", scale: "1 : 25 000" };

/**
 * Walks that can be lit. `path` is a list of [u, v] points across the board, each from -1 to 1
 * (u left to right, v back to front). A smooth line is drawn through them and bulbs are set along it.
 */
const routes = [
  { id: "summit", name: "Summit path", detail: "Quay to Hollin Top, 6.4 km", flag: "#e2452c", path: [[0.08, 0.4], [0.2, 0.27], [0.05, 0.17], [-0.12, 0.2], [-0.3, 0.1], [-0.32, -0.05], [-0.13, -0.07]] },
  { id: "light", name: "Lighthouse walk", detail: "Quay to Gannet Light, 5.1 km", flag: "#f4f1e8", path: [[0.08, 0.4], [0.2, 0.27], [0.4, 0.3], [0.6, 0.22], [0.72, 0.05]] },
  { id: "shore", name: "West shore", detail: "Quay to Selkie Cove, 9.8 km", flag: "#2f9c95", path: [[0.08, 0.4], [-0.1, 0.38], [-0.35, 0.3], [-0.55, 0.2], [-0.66, 0], [-0.55, -0.3], [-0.3, -0.5]] },
];

// Design pixels.
const LW = 700; // drawing size
const LH = 500;
const BW = 520; // board, left to right
const BD = 400; // board, back to front
const ROT = -0.34; // how far the board is turned on the table, in radians
const CY = 0.6; // camera: how much the ground is squashed
const SZ = 0.8; // camera: how tall one pixel of height looks
const PLINTH = 46;
const STEP = 5.4; // thickness of one plywood sheet
const NL = 9; // number of sheets at the summit
const FRAME = 9;
const GAP = 13.5; // distance between bulbs
const BULB = 2.9;

const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
const cosR = Math.cos(ROT);
const sinR = Math.sin(ROT);
const GD = BW * Math.abs(sinR) + BD * cosR; // depth of the turned board on the ground
const GCY = GD / 2;
const TOP0 = 262 - GCY * CY; // screen y of the back of the ground plane

const hash = (x: number, y: number) => {
  let n = (x * 374761393 + y * 668265263) | 0;
  n = (n ^ (n >>> 13)) * 1274126177;
  n = n ^ (n >>> 16);
  return ((n >>> 0) % 100000) / 100000;
};
const vnoise = (x: number, y: number) => {
  const xi = Math.floor(x);
  const yi = Math.floor(y);
  const xf = x - xi;
  const yf = y - yi;
  const u = xf * xf * (3 - 2 * xf);
  const v = yf * yf * (3 - 2 * yf);
  const a = hash(xi, yi);
  const b = hash(xi + 1, yi);
  const c = hash(xi, yi + 1);
  const d = hash(xi + 1, yi + 1);
  return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
};
const fbm = (x: number, y: number) => {
  let s = 0;
  let a = 0.5;
  let f = 1;
  for (let i = 0; i < 4; i++) {
    s += a * vnoise(x * f, y * f);
    a *= 0.5;
    f *= 2.03;
  }
  return s / 0.9375;
};

/** Height of the land at a point on the board. Above zero is land, below is sea bed. */
function elev(u: number, v: number) {
  const wu = u + 0.22 * (fbm(u * 1.9 + 3, v * 1.9) - 0.5);
  const wv = v + 0.22 * (fbm(u * 1.9, v * 1.9 + 7) - 0.5);
  const a = (wu + 0.02) / 0.84;
  const b = wv / 0.72;
  let h = 0.72 * (1 - Math.sqrt(a * a + b * b));
  h += 0.74 * Math.exp(-((wu + 0.26) ** 2 + (wv + 0.1) ** 2) / 0.05); // Hollin Top
  h += 0.46 * Math.exp(-((wu - 0.4) ** 2 + (wv - 0.02) ** 2) / 0.022); // the east hill
  h -= 0.6 * Math.exp(-((wu - 0.1) ** 2 + (wv - 0.56) ** 2) / 0.03); // the harbour bay
  h -= 0.3 * Math.exp(-((wu - 0.1) ** 2 + (wv + 0.62) ** 2) / 0.03);
  h += 0.44 * (fbm(u * 3.3 + 11, v * 3.3 + 5) - 0.5);
  h += 0.5 * Math.exp(-((u + 0.76) ** 2 + (v - 0.6) ** 2) / 0.005); // the skerry
  h -= 3 * (Math.max(0, Math.abs(u) - 0.8) + Math.max(0, Math.abs(v) - 0.8));
  return h;
}
const sheets = (h: number) => (h > 0 ? Math.min(NL, 1 + Math.floor(h / 0.15)) : 0);

/** Board point (in pixels from its centre) and height to a point on the drawing. */
const project = (wx: number, wy: number, z: number) => ({ x: LW / 2 + wx * cosR - wy * sinR, y: TOP0 + (GCY + wx * sinR + wy * cosR) * CY - z * SZ });

/** A smooth line through the route's points, cut into equal steps. */
function along(path: number[][], gap: number) {
  const pts = path.map(([u, v]) => [(u * BW) / 2, (v * BD) / 2]);
  const fine: number[][] = [];
  for (let i = 0; i < pts.length - 1; i++) {
    const p0 = pts[Math.max(0, i - 1)];
    const p1 = pts[i];
    const p2 = pts[i + 1];
    const p3 = pts[Math.min(pts.length - 1, i + 2)];
    for (let k = 0; k < 24; k++) {
      const t = k / 24;
      const t2 = t * t;
      const t3 = t2 * t;
      fine.push([0, 1].map((d) => 0.5 * (2 * p1[d] + (p2[d] - p0[d]) * t + (2 * p0[d] - 5 * p1[d] + 4 * p2[d] - p3[d]) * t2 + (3 * p1[d] - p0[d] - 3 * p2[d] + p3[d]) * t3)));
    }
  }
  fine.push(pts[pts.length - 1]);
  const out = [fine[0]];
  let need = gap;
  for (let i = 1; i < fine.length; i++) {
    let seg = Math.hypot(fine[i][0] - fine[i - 1][0], fine[i][1] - fine[i - 1][1]);
    let from = fine[i - 1];
    while (seg >= need) {
      const t = need / seg;
      from = [from[0] + (fine[i][0] - from[0]) * t, from[1] + (fine[i][1] - from[1]) * t];
      out.push(from);
      seg -= need;
      need = gap;
    }
    need -= seg;
  }
  return { fine, stops: out };
}

type Bulb = { x: number; y: number };

/** Builds the whole model once: every pixel of plywood, sea, walnut and shadow. */
function buildModel(S: number) {
  const CW = Math.ceil(LW * S);
  const CH = Math.ceil(LH * S);
  const GH = Math.ceil(GD * S) + 2;
  const cx = (LW / 2) * S;
  const gcy = GCY * S;
  const PL = PLINTH * S;
  const ST = STEP * S;

  // heights are worked out on a half-size grid and smoothed up, which also rounds the cut edges
  const EW = (CW >> 1) + 2;
  const EH = (GH >> 1) + 2;
  const E = new Float32Array(EW * EH);
  for (let j = 0; j < EH; j++)
    for (let i = 0; i < EW; i++) {
      const dx = i * 2 - cx;
      const dy = j * 2 - gcy;
      E[j * EW + i] = elev((dx * cosR + dy * sinR) / ((BW / 2) * S), (-dx * sinR + dy * cosR) / ((BD / 2) * S));
    }
  const H = new Float32Array(CW * GH).fill(-1); // height above the table, -1 where there is no board
  const M = new Uint8Array(CW * GH); // 1 frame, 2 sea, 3 land
  const D = new Float32Array(CW * GH);
  for (let gy = 0; gy < GH; gy++)
    for (let gx = 0; gx < CW; gx++) {
      const dx = gx - cx;
      const dy = gy - gcy;
      const wx = dx * cosR + dy * sinR;
      const wy = -dx * sinR + dy * cosR;
      const ax = Math.abs(wx) / S;
      const ay = Math.abs(wy) / S;
      if (ax > BW / 2 || ay > BD / 2) continue;
      const i = gy * CW + gx;
      if (ax > BW / 2 - FRAME || ay > BD / 2 - FRAME) {
        H[i] = PL + 3 * S;
        M[i] = 1;
        continue;
      }
      const fx = gx / 2;
      const fy = gy / 2;
      const x0 = fx | 0;
      const y0 = fy | 0;
      const tx = fx - x0;
      const ty = fy - y0;
      const k = y0 * EW + x0;
      const e = (E[k] * (1 - tx) + E[k + 1] * tx) * (1 - ty) + (E[k + EW] * (1 - tx) + E[k + EW + 1] * tx) * ty;
      const n = sheets(e);
      D[i] = e;
      M[i] = n ? 3 : 2;
      H[i] = PL + n * ST;
    }

  // the three walks, scored into the wood
  const toGround = (p: number[]) => [cx + (p[0] * cosR - p[1] * sinR) * S, gcy + (p[0] * sinR + p[1] * cosR) * S];
  const laid = routes.map((r) => along(r.path, GAP));
  const score = document.createElement("canvas");
  score.width = CW;
  score.height = GH;
  const sc = score.getContext("2d")!;
  sc.strokeStyle = "#fff";
  sc.lineWidth = 1.5 * S;
  sc.lineJoin = "round";
  for (const l of laid) {
    sc.beginPath();
    l.fine.forEach((p, i) => {
      const g = toGround(p);
      i ? sc.lineTo(g[0], g[1]) : sc.moveTo(g[0], g[1]);
    });
    sc.stroke();
  }
  const scored = sc.getImageData(0, 0, CW, GH).data;

  const img = new ImageData(CW, CH);
  const px = img.data;
  const top0 = TOP0 * S;
  const LX = -0.6; // toward the lamp, on the ground
  const LY = -0.8;
  const at = (gx: number, gy: number) => (gx < 0 || gy < 0 || gx >= CW || gy >= GH ? -1 : H[(gy | 0) * CW + (gx | 0)]);
  const put = (x: number, y: number, r: number, g: number, b: number) => {
    if (y < 0 || y >= CH) return;
    const o = (y * CW + x) * 4;
    px[o] = r;
    px[o + 1] = g;
    px[o + 2] = b;
    px[o + 3] = 255;
  };
  for (let gx = 0; gx < CW; gx++) {
    for (let gy = 0; gy < GH; gy++) {
      const i = gy * CW + gx;
      const h = H[i];
      if (h < 0) continue;
      const m = M[i];
      const yTop = top0 + gy * CY - h * SZ;
      const y0 = Math.floor(yTop);
      const y1 = Math.floor(yTop + CY);
      const dx = gx - cx;
      const dy = gy - gcy;
      const wx = (dx * cosR + dy * sinR) / S;
      const wy = (-dx * sinR + dy * cosR) / S;
      if (y1 > y0) {
        // how much of the lamp is blocked by taller sheets, and whether this is a lit cut edge
        let block = 0;
        for (let k = 1; k <= 8; k++) {
          const d = k * 1.5 * S;
          if (at(gx + LX * d, gy + LY * d) - h > d * 0.5) block++;
        }
        const shade = 1 - 0.56 * Math.min(1, block / 2.6);
        const rim = at(gx + LX * 1.6 * S, gy + LY * 1.6 * S) < h - 1 ? 1 : 0;
        const snug = at(gx - LX * 1.6 * S, gy - LY * 1.6 * S) > h + 1 ? 0.86 : 1;
        let r = 0;
        let g = 0;
        let b = 0;
        if (m === 3) {
          const n = Math.round((h - PL) / ST);
          const t = Math.pow(n / NL, 0.8);
          const grain = 0.92 + 0.13 * vnoise(wx * 0.05 + n * 31, wy * 1.3 + n * 7) + 0.04 * (hash(gx, gy) - 0.5);
          const cut = 1 - 0.5 * (scored[i * 4 + 3] / 255);
          const k = grain * shade * snug * cut * (1 + rim * 0.07);
          r = (190 + 44 * t) * k;
          g = (156 + 58 * t) * k;
          b = (110 + 68 * t) * k;
        } else if (m === 2) {
          const e = D[i];
          const deep = clamp(-e / 0.55, 0, 1);
          const ring = e < -0.04 && (-e * 8) % 1 < 0.09 ? 1.16 : 1;
          const edge = e > -0.03 ? 1.25 : 1;
          const sx = gx / S;
          const sy = yTop / S;
          const gloss = Math.exp(-(((sx * 0.62 + sy - 470) / 46) ** 2)) * 30 + Math.exp(-(((sx * 0.62 + sy - 600) / 16) ** 2)) * 16;
          const k = shade * ring * edge;
          r = (26 - 18 * deep) * k + gloss * shade;
          g = (98 - 62 * deep) * k + gloss * shade;
          b = (108 - 52 * deep) * k + gloss * shade;
        } else {
          const k = (0.82 + 0.3 * vnoise((wx + wy) * 0.6, (wx - wy) * 0.03)) * shade * (1 + rim * 0.3);
          r = 104 * k;
          g = 66 * k;
          b = 40 * k;
        }
        for (let y = y0; y < y1; y++) put(gx, y, r, g, b);
      }
      // the cut face of this column, where the one in front is lower
      const hf = gy + 1 < GH ? Math.max(0, H[i + CW]) : 0;
      if (h > hf) {
        const yEnd = Math.floor(top0 + (gy + 1) * CY - hf * SZ);
        const turn = clamp((at(gx - 2 * S, gy) - at(gx + 2 * S, gy)) / ST, -1, 1);
        const u = wx / (BW / 2);
        const v = wy / (BD / 2);
        // which side of the plinth this is, for its share of the lamp
        const side = Math.abs(u) > Math.abs(v);
        const lit = side ? 1.25 : 0.86;
        const run = side ? wy : wx;
        for (let y = y1; y < yEnd; y++) {
          const z = h - (y - yTop - CY) / SZ;
          if (z > PL + 0.5 && m === 3) {
            // plywood edge: three plies to a sheet, browned by the laser
            const ply = (((z - PL) / ST) * 3) % 1;
            const k = (0.78 - 0.22 * turn) * (ply < 0.5 ? 1 : 0.66) * (0.9 + 0.2 * hash(gx, y));
            put(gx, y, 126 * k, 86 * k, 50 * k);
          } else {
            const zl = z / S;
            let k = lit * (0.78 + 0.34 * vnoise(run * 0.55, zl * 0.05 + (side ? 9 : 0))) * (0.62 + 0.38 * clamp(zl / 16, 0, 1));
            if (zl > PLINTH + 1.6) k *= 1.5;
            if (zl > 9 && zl < 11.4) put(gx, y, 214 * lit, 170 * lit, 92 * lit);
            else put(gx, y, 86 * k, 53 * k, 32 * k);
          }
        }
      }
    }
  }

  const model = document.createElement("canvas");
  model.width = CW;
  model.height = CH;
  model.getContext("2d")!.putImageData(img, 0, 0);

  const out = document.createElement("canvas");
  out.width = CW;
  out.height = CH;
  const ctx = out.getContext("2d")!;
  // shadow thrown onto the table
  const foot = [project(-BW / 2, -BD / 2, 0), project(BW / 2, -BD / 2, 0), project(BW / 2, BD / 2, 0), project(-BW / 2, BD / 2, 0)];
  for (const [blur, ox, oy, a] of [[34, 30, 20, 0.75], [9, 8, 5, 0.8]] as const) {
    ctx.save();
    ctx.shadowColor = `rgba(0,0,0,${a})`;
    ctx.shadowBlur = blur * S;
    ctx.shadowOffsetX = (ox + 3000) * S;
    ctx.shadowOffsetY = oy * S;
    ctx.beginPath();
    foot.forEach((p, i) => (i ? ctx.lineTo((p.x - 3000) * S, p.y * S) : ctx.moveTo((p.x - 3000) * S, p.y * S)));
    ctx.closePath();
    ctx.fill();
    ctx.restore();
  }
  ctx.drawImage(model, 0, 0);

  // lettering cut into the sea, lying flat on it
  const flat = project(0, 0, PLINTH);
  ctx.save();
  ctx.setTransform(cosR * S, sinR * CY * S, -sinR * S, cosR * CY * S, flat.x * S, flat.y * S);
  ctx.textAlign = "center";
  ctx.textBaseline = "middle";
  const cutText = (t: string, x: number, y: number, size: number, gap: number, a: number, italic = false) => {
    ctx.font = `${italic ? "italic " : "600 "}${size}px Georgia, "Times New Roman", serif`;
    (ctx as unknown as { letterSpacing: string }).letterSpacing = `${gap}px`;
    ctx.fillStyle = `rgba(0,10,16,${a * 0.9})`;
    ctx.fillText(t, x + 0.5, y + 0.9);
    ctx.fillStyle = `rgba(206,236,234,${a})`;
    ctx.fillText(t, x, y);
  };
  cutText(island.name.toUpperCase(), -128, 164, 19, 9, 0.62);
  cutText("Marl Deep", 176, 150, 10, 1.5, 0.5, true);
  cutText("Gannet Sound", 150, -158, 10, 1.5, 0.42, true);
  cutText("The Quay", 62, 128, 7.5, 0.6, 0.6, true);
  ctx.restore();

  // brass plate on the front of the plinth
  const plate = project(-98, BD / 2, 40);
  ctx.save();
  ctx.setTransform(cosR * S, sinR * CY * S, 0, SZ * S, plate.x * S, plate.y * S);
  ctx.scale(1.32, 1.32);
  const brass = ctx.createLinearGradient(0, 0, 0, 20);
  brass.addColorStop(0, "#f1d79a");
  brass.addColorStop(0.35, "#c9a05a");
  brass.addColorStop(0.7, "#a37a3a");
  brass.addColorStop(1, "#d6b06a");
  ctx.fillStyle = "rgba(0,0,0,0.5)";
  ctx.fillRect(0.6, 1.2, 148, 20);
  ctx.fillStyle = brass;
  ctx.fillRect(0, 0, 148, 20);
  ctx.fillStyle = "rgba(255,246,214,0.75)";
  ctx.fillRect(0, 0, 148, 0.8);
  for (const sxp of [5.5, 142.5]) {
    ctx.beginPath();
    ctx.arc(sxp, 10, 1.7, 0, Math.PI * 2);
    ctx.fillStyle = "#7a5a2a";
    ctx.fill();
    ctx.fillStyle = "#f6e2ae";
    ctx.fillRect(sxp - 1.3, 9.7, 2.6, 0.6);
  }
  ctx.textAlign = "center";
  ctx.textBaseline = "middle";
  ctx.fillStyle = "#3a2a10";
  ctx.font = '700 7.4px Georgia, "Times New Roman", serif';
  (ctx as unknown as { letterSpacing: string }).letterSpacing = "2.4px";
  ctx.fillText(island.name.toUpperCase(), 74, 7);
  ctx.font = '5px Georgia, "Times New Roman", serif';
  (ctx as unknown as { letterSpacing: string }).letterSpacing = "1px";
  ctx.fillText(`${island.plate.toUpperCase()}  /  ${island.scale}`, 74, 14.6);
  ctx.restore();

  // where each bulb sits, and a pennant at the far end of every walk
  const heightAt = (p: number[]) => PLINTH + Math.max(1, sheets(elev(p[0] / (BW / 2), p[1] / (BD / 2)))) * STEP;
  const bulbs: Bulb[][] = laid.map((l) => l.stops.map((p) => project(p[0], p[1], heightAt(p))));
  ctx.setTransform(S, 0, 0, S, 0, 0);
  laid.forEach((l, k) => {
    const end = l.fine[l.fine.length - 1];
    const p = project(end[0], end[1], heightAt(end));
    ctx.strokeStyle = "rgba(20,10,0,0.45)";
    ctx.lineWidth = 1.2;
    ctx.beginPath();
    ctx.moveTo(p.x, p.y);
    ctx.lineTo(p.x + 13, p.y + 6);
    ctx.stroke();
    const pole = ctx.createLinearGradient(p.x - 1, 0, p.x + 1, 0);
    pole.addColorStop(0, "#f4f0e6");
    pole.addColorStop(1, "#8d8a84");
    ctx.fillStyle = pole;
    ctx.fillRect(p.x - 0.7, p.y - 21, 1.4, 21);
    ctx.fillStyle = routes[k].flag;
    ctx.beginPath();
    ctx.moveTo(p.x + 0.7, p.y - 21);
    ctx.lineTo(p.x + 11, p.y - 17.6);
    ctx.lineTo(p.x + 0.7, p.y - 14.4);
    ctx.closePath();
    ctx.fill();
    ctx.fillStyle = "rgba(0,0,0,0.18)";
    ctx.beginPath();
    ctx.moveTo(p.x + 0.7, p.y - 17.6);
    ctx.lineTo(p.x + 11, p.y - 17.6);
    ctx.lineTo(p.x + 0.7, p.y - 14.4);
    ctx.closePath();
    ctx.fill();
  });
  return { canvas: out, bulbs };
}

/** Small pictures of one bulb, dark and lit, and of the light it throws. */
function buildSprites(S: number) {
  const make = (size: number, draw: (c: CanvasRenderingContext2D) => void) => {
    const cv = document.createElement("canvas");
    cv.width = cv.height = Math.ceil(size * S);
    const c = cv.getContext("2d")!;
    c.scale(S, S);
    c.translate(size / 2, size / 2);
    draw(c);
    return cv;
  };
  const dome = (c: CanvasRenderingContext2D, stops: [number, string][]) => {
    c.fillStyle = "rgba(40,20,0,0.4)";
    c.beginPath();
    c.ellipse(1.6, 1.4, BULB + 1.8, (BULB + 1.8) * CY, 0, 0, Math.PI * 2);
    c.fill();
    c.fillStyle = "#6f5222";
    c.beginPath();
    c.ellipse(0, 0.9, BULB + 1.4, (BULB + 1.4) * CY, 0, 0, Math.PI * 2);
    c.fill();
    c.fillStyle = "#d9b469";
    c.beginPath();
    c.ellipse(-0.3, 0.5, BULB + 1.1, (BULB + 1.1) * CY, 0, 0, Math.PI * 2);
    c.fill();
    const g = c.createRadialGradient(-1, -2.8, 0.2, 0, -1.4, BULB + 0.4);
    stops.forEach(([o, col]) => g.addColorStop(o, col));
    c.fillStyle = g;
    c.beginPath();
    c.arc(0, -1.4, BULB, 0, Math.PI * 2);
    c.fill();
  };
  const dark = make(20, (c) => {
    dome(c, [[0, "#e6d2ab"], [0.35, "#b08c5c"], [0.8, "#62431f"], [1, "#8a6638"]]);
    c.fillStyle = "rgba(255,244,220,0.85)";
    c.beginPath();
    c.ellipse(-1.2, -2.9, 0.9, 0.55, -0.6, 0, Math.PI * 2);
    c.fill();
  });
  const lit = make(20, (c) => dome(c, [[0, "#ffffff"], [0.55, "#fffbe8"], [0.85, "#ffd98a"], [1, "#ffa838"]]));
  const glow = make(96, (c) => {
    c.save();
    c.scale(1, CY);
    const spill = c.createRadialGradient(0, 2, 0, 0, 2, 46);
    spill.addColorStop(0, "rgba(255,190,100,0.42)");
    spill.addColorStop(0.3, "rgba(255,160,70,0.16)");
    spill.addColorStop(1, "rgba(255,130,40,0)");
    c.fillStyle = spill;
    c.fillRect(-48, -48, 96, 96);
    c.restore();
    const halo = c.createRadialGradient(0, -1.4, 0, 0, -1.4, 15);
    halo.addColorStop(0, "rgba(255,244,214,1)");
    halo.addColorStop(0.3, "rgba(255,206,120,0.5)");
    halo.addColorStop(1, "rgba(255,170,80,0)");
    c.fillStyle = halo;
    c.fillRect(-16, -18, 32, 32);
  });
  return { dark, lit, glow };
}

export default function ReliefIslandRouteModel() {
  const reduce = !!useReducedMotion();
  const wrapRef = useRef<HTMLDivElement>(null);
  const baseRef = useRef<HTMLCanvasElement>(null);
  const lampRef = useRef<HTMLCanvasElement>(null);
  const [route, setRoute] = useState(0);
  const live = useRef({ route: 0, since: 0, touched: false, paused: false, replay: 0 });
  const tiltX = useSpring(useMotionValue(0), { stiffness: 70, damping: 15 });
  const tiltY = useSpring(useMotionValue(0), { stiffness: 70, damping: 15 });

  const pick = (i: number, byHand: boolean) => {
    if (byHand) live.current.touched = true;
    live.current.route = i;
    live.current.replay++;
    setRoute(i);
  };

  useEffect(() => {
    const wrap = wrapRef.current;
    const base = baseRef.current;
    const lamp = lampRef.current;
    if (!wrap || !base || !lamp) return;
    const S = clamp((wrap.clientWidth / LW) * Math.min(2, window.devicePixelRatio || 1), 1, 2);
    let raf = 0;
    let seen = true;
    let stopped = false;
    let bulbs: Bulb[][] = [];
    let level: number[][] = [];
    const sprites = buildSprites(S);
    const lc = lamp.getContext("2d")!;
    const state = live.current;
    let replay = -1;
    let last = 0;

    const draw = (now: number) => {
      raf = 0;
      const dt = Math.min(0.05, (now - last) / 1000 || 0.016);
      last = now;
      if (replay !== state.replay) {
        replay = state.replay;
        state.since = now;
      }
      const t = (now - state.since) / 1000;
      lc.setTransform(1, 0, 0, 1, 0, 0);
      lc.clearRect(0, 0, lamp.width, lamp.height);
      lc.setTransform(S, 0, 0, S, 0, 0);
      bulbs.forEach((line, r) => {
        const on = r === state.route;
        const n = line.length;
        // the walk lights from the quay outward, then a brighter pulse keeps travelling along it
        const reach = reduce ? n : (t - 0.12) * 17;
        const pulse = ((t * 0.42) % 1.25) * n;
        line.forEach((b, i) => {
          let want = 0;
          if (on && i < reach) want = reduce ? 0.95 : 0.74 + 0.26 * Math.exp(-(((i - pulse) / 1.6) ** 2)) + 0.03 * Math.sin(now / 53 + i * 2.1);
          const cur = level[r][i];
          // filaments warm quickly and cool slowly
          level[r][i] = reduce ? want : cur + (want - cur) * (1 - Math.exp(-dt / (want > cur ? 0.07 : 0.3)));
        });
      });
      // light first, so each glass dome sits on top of the glow around it
      bulbs.forEach((line, r) =>
        line.forEach((b, i) => {
          const v = level[r][i];
          if (v < 0.01) return;
          lc.globalAlpha = Math.min(1, v);
          lc.drawImage(sprites.glow, b.x - 48, b.y - 48, 96, 96);
        }),
      );
      bulbs.forEach((line, r) =>
        line.forEach((b, i) => {
          const v = level[r][i];
          lc.globalAlpha = 1;
          lc.drawImage(sprites.dark, b.x - 10, b.y - 10, 20, 20);
          if (v > 0.01) {
            lc.globalAlpha = Math.min(1, v * 1.5);
            lc.drawImage(sprites.lit, b.x - 10, b.y - 10, 20, 20);
          }
        }),
      );
      lc.globalAlpha = 1;
      if (!reduce && seen && !document.hidden && !stopped) raf = requestAnimationFrame(draw);
    };
    const kick = () => {
      if (!raf && !stopped) raf = requestAnimationFrame(draw);
    };

    // build after the first paint so the stage shows straight away
    const start = window.setTimeout(() => {
      const built = buildModel(S);
      for (const cv of [base, lamp]) {
        cv.width = built.canvas.width;
        cv.height = built.canvas.height;
      }
      base.getContext("2d")!.drawImage(built.canvas, 0, 0);
      bulbs = built.bulbs;
      level = bulbs.map((l) => l.map(() => 0));
      state.replay++;
      kick();
    }, 30);

    const io = new IntersectionObserver(([e]) => {
      seen = e.isIntersecting;
      if (seen) kick();
    });
    io.observe(wrap);
    const onVis = () => !document.hidden && kick();
    document.addEventListener("visibilitychange", onVis);
    // with motion reduced there is no loop, so a change of route asks for one redraw
    const poll = reduce ? window.setInterval(() => replay !== state.replay && kick(), 120) : 0;
    const tour = window.setInterval(() => {
      if (reduce || state.touched || state.paused || document.hidden || !seen) return;
      state.route = (state.route + 1) % routes.length;
      state.replay++;
      setRoute(state.route);
    }, 6200);
    return () => {
      stopped = true;
      window.clearTimeout(start);
      window.clearInterval(tour);
      if (poll) window.clearInterval(poll);
      cancelAnimationFrame(raf);
      io.disconnect();
      document.removeEventListener("visibilitychange", onVis);
    };
  }, [reduce]);

  const onMove = (e: React.PointerEvent) => {
    if (reduce || e.pointerType === "touch") return;
    const r = e.currentTarget.getBoundingClientRect();
    tiltY.set((((e.clientX - r.left) / r.width) * 2 - 1) * 3.2);
    tiltX.set((((e.clientY - r.top) / r.height) * 2 - 1) * -2.6);
  };

  return (
    <section className="relative overflow-hidden bg-[#0b0a09]">
      <div aria-hidden="true" className="pointer-events-none absolute inset-0" style={{ background: "radial-gradient(58% 60% at 46% 44%, #2c2620 0%, #17130f 46%, #0b0a09 100%)" }} />
      <div
        onPointerMove={onMove}
        onPointerEnter={() => (live.current.paused = true)}
        onPointerLeave={() => {
          live.current.paused = false;
          tiltX.set(0);
          tiltY.set(0);
        }}
        className="relative mx-auto w-full max-w-[940px] px-2 pt-6 sm:pt-8"
        style={{ perspective: "1600px" }}
      >
        <motion.div ref={wrapRef} className="relative w-full" style={{ aspectRatio: `${LW} / ${LH}`, rotateX: tiltX, rotateY: tiltY }}>
          <canvas ref={baseRef} role="img" aria-label={`Relief model of the island of ${island.name}, built from stacked plywood sheets on a walnut plinth, with three walking routes marked by rows of small bulbs`} className="absolute inset-0 h-full w-full" />
          <canvas ref={lampRef} aria-hidden="true" className="pointer-events-none absolute inset-0 h-full w-full" />
        </motion.div>
      </div>

      <div className="relative flex flex-col gap-3 px-5 pb-6 pt-2 sm:flex-row sm:items-end sm:justify-between sm:px-8 sm:pb-8">
        <p className="font-mono text-[11px] uppercase leading-relaxed tracking-[0.18em] text-stone-400">
          <span className="text-stone-100">
            {island.name} / {island.plate}
          </span>
          <span className="block" aria-live="polite">
            {routes[route].name}: {routes[route].detail}
          </span>
        </p>
        <div className="flex flex-wrap gap-2">
          {routes.map((r, i) => (
            <button
              key={r.id}
              type="button"
              aria-pressed={route === i}
              onClick={() => pick(i, true)}
              className={`flex h-10 items-center gap-2 rounded-full px-4 font-mono text-[11px] font-semibold uppercase tracking-[0.14em] outline-none transition-colors focus-visible:ring-2 focus-visible:ring-amber-300 focus-visible:ring-offset-2 focus-visible:ring-offset-[#0b0a09] ${route === i ? "bg-amber-200 text-stone-900" : "bg-stone-800 text-stone-200 hover:bg-stone-700"}`}
            >
              <span aria-hidden="true" className="h-2 w-2 rounded-full" style={{ background: r.flag }} />
              {r.name}
            </button>
          ))}
        </div>
      </div>
    </section>
  );
}
```
