Baktiyar Ablaikhan

Tiled Rocking Globes

Source
arlan.me/vault ↗
Author
Arlan
Licence
MIT

Loading…

Three tiled rounded-cube forms rock on their own rhythms while bright symbols pass along the row as waves across their faces, now and then one turning fully round; the pointer leans them and opens the tiles.

Code

"use client";

import { approach, clamp, hex, mixRGB, rgbStr, smooth, useStage, type Scene } from "./stage";

/*
 * Three rounded-cube solids (superquadrics) tiled with projected rounded quads,
 * each rocking on its own rhythm and carrying a bright 11x11 mark painted in
 * grid space. Marks pass left to right as a wave across each face; now and
 * then one solid turns all the way round and returns with the next mark.
 */

const N = 11;
const SPAN = (68.2 * Math.PI) / 180;
const CELL = (2 * SPAN) / N;
const GAP = 0.26;
const ROUND = 0.3;
const EXPS = [0.42, 0.66, 0.95];
const WOBBLE = (14 * Math.PI) / 180;
const LEAN = (2.5 * Math.PI) / 180;
const TILT_MAX = 0.32;
const HOLD = 4.2;
const SWAP = 1.1;
const HANDOFF = 0.35;
const TURN_EVERY = 26;
const TURN_S = 2.6;
const TRAIL = 0.45;
const TRAIL_REACH = 0.55;
const BANDS = 4;
const LIFT = 0.16;
const OPEN = 0.035;

const DUOS = [
  { off: "#6e4b78", near: "#553260", on: "#b6f042", hot: "#d3f68e" }, // lime on aubergine
  { off: "#4f8d96", near: "#33707a", on: "#f66751", hot: "#faaa9e" }, // coral on petrol teal
  { off: "#5f7ed6", near: "#3a5cc0", on: "#fadf2e", hot: "#fceb7e" }, // acid yellow on cobalt
];

const GLYPHS: Record<string, string[]> = {
  arrow: [
    "...........",
    ".....#.....",
    "......#....",
    ".......#...",
    "........#..",
    ".#########.",
    "........#..",
    ".......#...",
    "......#....",
    ".....#.....",
    "...........",
  ],
  diamond: [
    ".....#.....",
    "....###....",
    "...#####...",
    "..#######..",
    ".#########.",
    "###########",
    ".#########.",
    "..#######..",
    "...#####...",
    "....###....",
    ".....#.....",
  ],
  cross: [
    "##.......##",
    "###.....###",
    ".###...###.",
    "..###.###..",
    "...#####...",
    "....###....",
    "...#####...",
    "..###.###..",
    ".###...###.",
    "###.....###",
    "##.......##",
  ],
  chevron: [
    "...........",
    "..##.......",
    "...##......",
    "....##.....",
    ".....##....",
    "......##...",
    ".....##....",
    "....##.....",
    "...##......",
    "..##.......",
    "...........",
  ],
  slash: [
    "........###",
    ".......###.",
    "......###..",
    ".....###...",
    "....###....",
    "...###.....",
    "..###......",
    ".###.......",
    "###........",
    "##.........",
    "...........",
  ],
  square: [
    "...........",
    ".#########.",
    ".#.......#.",
    ".#.......#.",
    ".#.......#.",
    ".#.......#.",
    ".#.......#.",
    ".#.......#.",
    ".#.......#.",
    ".#########.",
    "...........",
  ],
};
const MARKS = Object.values(GLYPHS).map((rows) => rows.map((r) => [...r].map((c) => c === "#")));
const markAt = (i: number) => MARKS[((i % MARKS.length) + MARKS.length) % MARKS.length];

const sp = (v: number, e: number) => Math.sign(v) * Math.abs(v) ** e;

function turnsDone(g: number, t: number) {
  // turns happen at 10 + 26k for globe k % 3; count those past their midpoint
  const kMax = Math.floor((t - 10 - TURN_S / 2) / TURN_EVERY);
  if (kMax < g) return 0;
  return Math.floor((kMax - g) / 3) + 1;
}
function turnAngle(g: number, t: number) {
  const k = Math.floor((t - 10) / TURN_EVERY);
  if (k < 0 || k % 3 !== g) return 0;
  const local = t - 10 - k * TURN_EVERY;
  return local < TURN_S ? smooth(local / TURN_S) * Math.PI * 2 : 0;
}

function roundedQuad(ctx: CanvasPath, q: number[]) {
  // q = [x0,y0,x1,y1,x2,y2,x3,y3]
  let minEdge = Infinity;
  for (let i = 0; i < 4; i++) {
    const j = (i + 1) % 4;
    minEdge = Math.min(minEdge, Math.hypot(q[j * 2] - q[i * 2], q[j * 2 + 1] - q[i * 2 + 1]));
  }
  const r = minEdge * ROUND;
  if (r < 0.5) {
    ctx.moveTo(q[0], q[1]);
    ctx.lineTo(q[2], q[3]);
    ctx.lineTo(q[4], q[5]);
    ctx.lineTo(q[6], q[7]);
    ctx.closePath();
    return;
  }
  for (let i = 0; i < 4; i++) {
    const p = (i + 3) % 4;
    const n = (i + 1) % 4;
    const cx = q[i * 2];
    const cy = q[i * 2 + 1];
    const a = Math.hypot(q[p * 2] - cx, q[p * 2 + 1] - cy) || 1;
    const b = Math.hypot(q[n * 2] - cx, q[n * 2 + 1] - cy) || 1;
    const ax = cx + ((q[p * 2] - cx) / a) * r;
    const ay = cy + ((q[p * 2 + 1] - cy) / a) * r;
    const bx = cx + ((q[n * 2] - cx) / b) * r;
    const by = cy + ((q[n * 2 + 1] - cy) / b) * r;
    if (i === 0) ctx.moveTo(ax, ay);
    else ctx.lineTo(ax, ay);
    ctx.quadraticCurveTo(cx, cy, bx, by);
  }
  ctx.closePath();
}

function createScene(): Scene {
  let tilt = 0;
  let lean = 0;
  const hotStr = DUOS.map((d) => {
    const bands: string[] = [];
    for (let b = 1; b <= BANDS; b++) bands.push(rgbStr(mixRGB(hex(d.off), hex(d.hot), (TRAIL * b) / BANDS)));
    return bands;
  });

  return {
    frame({ ctx, w, h, t, dt, p }) {
      ctx.fillStyle = "#f4f4f4";
      ctx.fillRect(0, 0, w, h);
      const R = Math.min(h * 0.3, (w / 6) * 0.84);
      const field = h * 0.3;
      tilt = approach(tilt, p.inside ? clamp((p.y / h - 0.5) * 2, -1, 1) * TILT_MAX : 0, 3.5, dt);
      lean = approach(lean, p.inside ? clamp((p.x / w - 0.5) * 2, -1, 1) * LEAN : 0, 3.5, dt);

      for (let g = 0; g < 3; g++) {
        const duo = DUOS[g];
        const e = EXPS[g];
        const cx = (w * (g + 0.5)) / 3;
        const cy = h / 2;
        const ph = g * 2.1;
        const yaw = WOBBLE * Math.sin(t * 0.7 + ph) + turnAngle(g, t) + lean;
        const pitch = WOBBLE * 0.55 * Math.sin(t * 0.7 * 0.618 + ph * 1.7) + tilt;
        const cyw = Math.cos(yaw);
        const syw = Math.sin(yaw);
        const cp = Math.cos(pitch);
        const spt = Math.sin(pitch);
        const proj = (lon: number, lat: number): [number, number, number] => {
          const cl = Math.cos(lat);
          const x0 = sp(cl, e) * sp(Math.sin(lon), e);
          const y0 = sp(Math.sin(lat), e);
          const z0 = sp(cl, e) * sp(Math.cos(lon), e);
          const x1 = x0 * cyw + z0 * syw;
          const z1 = -x0 * syw + z0 * cyw;
          const y2 = y0 * cp - z1 * spt;
          const z2 = y0 * spt + z1 * cp;
          return [cx + x1 * R, cy - y2 * R, z2];
        };

        // shared clock, handed down the row; index steps back per globe
        const local = t - g * HANDOFF * SWAP - 0.5;
        const period = HOLD + SWAP;
        const slot = Math.floor(local / period);
        const inSlot = local - slot * period;
        const idx = slot - g + turnsDone(g, t);
        const swapping = inSlot < SWAP;
        const front = swapping ? (inSlot / SWAP) * (Math.PI / 2 + TRAIL_REACH + 0.2) : 99;
        const cur = markAt(idx);
        const prev = markAt(idx - 1);

        const offNear = new Path2D();
        const offFar = new Path2D();
        const bandPaths = Array.from({ length: BANDS }, () => new Path2D());
        const litTiles: { q: number[]; lift: number; band: number }[] = [];
        const gapA = CELL * GAP * 0.5;

        for (let row = 0; row < N; row++) {
          for (let col = 0; col < N; col++) {
            const lonC = -SPAN + (col + 0.5) * CELL;
            const latC = SPAN - (row + 0.5) * CELL;
            const c = proj(lonC, latC);
            if (c[2] <= 0.02) continue; // cull by centre depth
            const l0 = lonC - CELL / 2 + gapA;
            const l1 = lonC + CELL / 2 - gapA;
            const a0 = latC + CELL / 2 - gapA;
            const a1 = latC - CELL / 2 + gapA;
            const q = [...proj(l0, a0).slice(0, 2), ...proj(l1, a0).slice(0, 2), ...proj(l1, a1).slice(0, 2), ...proj(l0, a1).slice(0, 2)];
            // pointer: lattice opens, lit tiles swell
            let near = 0;
            if (p.inside) {
              const d = Math.hypot(c[0] - p.x, c[1] - p.y);
              near = clamp(1 - d / field);
              if (near > 0) {
                const dx = c[0] - p.x;
                const dy = c[1] - p.y;
                const dl = Math.hypot(dx, dy) || 1;
                const push = OPEN * R * near;
                for (let i = 0; i < 4; i++) {
                  q[i * 2] += (dx / dl) * push;
                  q[i * 2 + 1] += (dy / dl) * push;
                }
              }
            }
            const ang = Math.acos(clamp(c[2], -1, 1));
            const isLit = (ang < front ? cur : prev)[row][col];
            const behind = front - ang;
            const band = behind > 0 && behind < TRAIL_REACH ? Math.min(BANDS - 1, Math.floor((1 - behind / TRAIL_REACH) * BANDS)) : -1;
            if (isLit) litTiles.push({ q, lift: near, band });
            else if (band >= 0) roundedQuad(bandPaths[band], q);
            else roundedQuad(c[2] > 0.82 ? offNear : offFar, q);
          }
        }
        ctx.fillStyle = duo.off;
        ctx.fill(offFar);
        ctx.fillStyle = duo.near;
        ctx.fill(offNear);
        for (let b = 0; b < BANDS; b++) {
          ctx.fillStyle = hotStr[g][b];
          ctx.fill(bandPaths[b]);
        }
        for (const tile of litTiles) {
          const q = tile.q;
          if (tile.lift > 0) {
            const mx = (q[0] + q[2] + q[4] + q[6]) / 4;
            const my = (q[1] + q[3] + q[5] + q[7]) / 4;
            const s = 1 + LIFT * tile.lift;
            for (let i = 0; i < 4; i++) {
              q[i * 2] = mx + (q[i * 2] - mx) * s;
              q[i * 2 + 1] = my + (q[i * 2 + 1] - my) * s;
            }
          }
          ctx.beginPath();
          roundedQuad(ctx, q);
          ctx.fillStyle = tile.band >= 0 ? duo.hot : duo.on;
          ctx.fill();
        }
      }
    },
  };
}

export default function TileGlobes() {
  const ref = useStage(createScene, 2);
  return <div ref={ref} className="relative h-full w-full overflow-hidden bg-[#f4f4f4]" />;
}
Prompt

Build "Tiled Rocking Globes", a canvas animation. What it does: Three tiled rounded-cube forms rock on their own rhythms while bright symbols pass along the row as waves across their faces, now and then one turning fully round; the pointer leans them and opens the tiles. Write it as a single self-contained React component in TypeScript that fills its parent (w-full h-full), styled with Tailwind CSS. Technique: an HTML canvas, a requestAnimationFrame loop, pointer tracking. It sits on a light background, in a 16:10 frame. Respect prefers-reduced-motion with a still state, pause work when the element is off screen, and keep it accessible. Original: https://www.arlan.me/vault (Arlan, MIT).