Pixel molecule
- Source
- arlan.me/vault ↗
- Author
- Arlan
- Licence
- MIT
Loading…
'Arlan Marat' in a white pixel face on red drifts apart into molecule diagrams bonded by stair-stepped pixels, then sets again.
Code
"use client";
import { CanvasStudy, clamp, easeInOut, hash, rng, type Frame, type Painter } from "./stage";
export const BG = "#f5333f";
const INK = "#ffffff";
// 5×7 pixel glyphs
const GLYPHS: Record<string, string[]> = {
A: [".###.", "#...#", "#...#", "#####", "#...#", "#...#", "#...#"],
M: ["#...#", "##.##", "#.#.#", "#.#.#", "#...#", "#...#", "#...#"],
r: [".....", ".....", "#.##.", "##..#", "#....", "#....", "#...."],
l: [".##..", "..#..", "..#..", "..#..", "..#..", "..#..", ".###."],
a: [".....", ".....", ".###.", "....#", ".####", "#...#", ".####"],
n: [".....", ".....", "#.##.", "##..#", "#...#", "#...#", "#...#"],
t: ["..#..", "..#..", "#####", "..#..", "..#..", "..#.#", "...#."],
};
const NAME = ["Arlan", "Marat"];
const LETTERS = NAME.flatMap((word, wi) => [...word].map((ch, i) => ({ ch, word: wi, i })));
const COLS = 5 * 10 + 9 + 3; // glyph columns, letter gaps, word gap
type Pt = [number, number];
const HOLD = 1.0;
const TRAVEL = 1.3;
const SHAPES = 3; // molecule shapes per sequence
function typeset(P: number, w: number, h: number): Pt[] {
let x = (w - COLS * P) / 2;
return LETTERS.map((l, k) => {
if (k > 0 && l.i === 0) x += 3 * P;
const c: Pt = [x + 2.5 * P, h / 2];
x += 6 * P;
return c;
});
}
function molecule(seed: number, P: number, w: number, h: number): Pt[] {
const r = rng(seed);
const L = P * 12;
const pts: Pt[] = [];
const free = (x: number, y: number, min: number) => pts.every(([px, py]) => Math.hypot(px - x, py - y) > min);
let x = 0;
let y = 0;
let dir = Math.floor(r() * 6);
LETTERS.forEach((l) => {
if (l.i === 0 && l.word > 0) {
// the second word starts clear of the first chain
for (let tries = 0; tries < 40; tries++) {
const a = r() * Math.PI * 2;
const nx = x + Math.cos(a) * L * (1.4 + r());
const ny = y + Math.sin(a) * L * (1.4 + r());
if (free(nx, ny, L * 1.3) || tries === 39) {
x = nx;
y = ny;
break;
}
}
dir = Math.floor(r() * 6);
} else if (l.i > 0) {
// hexagonal turns that avoid landing on another atom
const turns = r() < 0.5 ? [1, 5, 0, 2, 4] : [5, 1, 0, 4, 2];
for (const tr of turns) {
const d = (dir + tr) % 6;
const nx = x + Math.cos((d * Math.PI) / 3) * L;
const ny = y + Math.sin((d * Math.PI) / 3) * L;
if (free(nx, ny, L * 0.8) || tr === turns[turns.length - 1]) {
dir = d;
x = nx;
y = ny;
break;
}
}
}
pts.push([x, y]);
});
const xs = pts.map((p) => p[0]);
const ys = pts.map((p) => p[1]);
const bw = Math.max(...xs) - Math.min(...xs) || 1;
const bh = Math.max(...ys) - Math.min(...ys) || 1;
const s = Math.min(1, (w * 0.84) / bw, (h * 0.72) / bh);
const cx = (Math.max(...xs) + Math.min(...xs)) / 2;
const cy = (Math.max(...ys) + Math.min(...ys)) / 2;
return pts.map(([px, py]) => [w / 2 + (px - cx) * s, h / 2 + (py - cy) * s]);
}
export function create(): Painter {
return ({ ctx, w, h, t }: Frame) => {
ctx.fillStyle = BG;
ctx.fillRect(0, 0, w, h);
const P = Math.max(2, Math.floor(Math.min((w * 0.7) / COLS, (h * 0.42) / 7)));
const stages = SHAPES + 1;
const seqLen = stages * (HOLD + TRAVEL);
const seq = Math.floor(t / seqLen);
const u = t - seq * seqLen;
const layouts: Pt[][] = [typeset(P, w, h)];
for (let s = 0; s < SHAPES; s++) layouts.push(molecule(seq * 31 + s * 7 + 1, P, w, h));
const stage = Math.floor(u / (HOLD + TRAVEL));
const within = u - stage * (HOLD + TRAVEL);
const from = layouts[stage];
const to = layouts[(stage + 1) % layouts.length];
const k = easeInOut(clamp((within - HOLD) / TRAVEL));
const pos: Pt[] = from.map((a, i) => [a[0] + (to[i][0] - a[0]) * k, a[1] + (to[i][1] - a[1]) * k]);
const home = layouts[0];
ctx.fillStyle = INK;
// bonds: fine stair-stepped runs of square pixels
const q = Math.max(1, Math.round(P / 2));
for (let i = 1; i < LETTERS.length; i++) {
if (LETTERS[i].word !== LETTERS[i - 1].word) continue;
const [ax, ay] = pos[i - 1];
const [bx, by] = pos[i];
const d = Math.hypot(bx - ax, by - ay);
const rest = Math.hypot(home[i][0] - home[i - 1][0], home[i][1] - home[i - 1][1]);
const show = clamp((d - rest * 1.05) / (P * 3));
if (show <= 0) continue;
const ux = (bx - ax) / d;
const uy = (by - ay) / d;
// leave the letter boxes clear
const pad = Math.min(3.1 * P / Math.max(Math.abs(ux), 1e-3), 4.1 * P / Math.max(Math.abs(uy), 1e-3));
const len = (d - pad * 2) * show;
if (len <= 0) continue;
const lines = hash(i, seq) > 0.6 ? [-1, 1] : [0];
for (const o of lines) {
const ox = -uy * o * q * 1.6;
const oy = ux * o * q * 1.6;
const steps = Math.ceil(len / q);
for (let s = 0; s <= steps; s++) {
const px = ax + ux * (pad + s * q) + ox;
const py = ay + uy * (pad + s * q) + oy;
ctx.fillRect(Math.round(px / q) * q, Math.round(py / q) * q, q, q);
}
}
}
// letters
LETTERS.forEach((l, i) => {
const g = GLYPHS[l.ch];
const x0 = Math.round(pos[i][0] - 2.5 * P);
const y0 = Math.round(pos[i][1] - 3.5 * P);
for (let r = 0; r < 7; r++)
for (let c = 0; c < 5; c++) if (g[r][c] === "#") ctx.fillRect(x0 + c * P, y0 + r * P, P, P);
});
};
}
export default function PixelMolecule() {
return (
<CanvasStudy
create={create}
background={BG}
still={3}
label="'Arlan Marat' in a white pixel face on red drifts apart into a molecule diagram bonded by stair-stepped pixel runs, re-forms in new shapes and sets again."
/>
);
}
Prompt
Build "Pixel molecule", a text animation. What it does: 'Arlan Marat' in a white pixel face on red drifts apart into molecule diagrams bonded by stair-stepped pixels, then sets again. 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).