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).