feat(ui): reconcile backup UI changes with current routing and interaction features

This commit is contained in:
Dotta
2026-03-02 16:44:03 -06:00
parent 83be94361c
commit 8ee063c4e5
69 changed files with 1591 additions and 666 deletions

View File

@@ -1,16 +1,45 @@
import { useEffect, useRef } from "react";
const CHARS = "░▒▓█▄▀■□▪▫●○◆◇◈◉★☆✦✧·.";
const CHARS = [" ", ".", "·", "▪", "▫", "○"] as const;
const TARGET_FPS = 24;
const FRAME_INTERVAL_MS = 1000 / TARGET_FPS;
interface Particle {
const PAPERCLIP_SPRITES = [
[
" ╭────╮ ",
" ╭╯╭──╮│ ",
" │ │ ││ ",
" │ │ ││ ",
" │ │ ││ ",
" │ │ ││ ",
" │ ╰──╯│ ",
" ╰─────╯ ",
],
[
" ╭─────╮ ",
" │╭──╮╰╮ ",
" ││ │ │ ",
" ││ │ │ ",
" ││ │ │ ",
" ││ │ │ ",
" │╰──╯ │ ",
" ╰────╯ ",
],
] as const;
type PaperclipSprite = (typeof PAPERCLIP_SPRITES)[number];
interface Clip {
x: number;
y: number;
vx: number;
vy: number;
char: string;
life: number;
maxLife: number;
phase: number;
drift: number;
sprite: PaperclipSprite;
width: number;
height: number;
}
function measureChar(container: HTMLElement): { w: number; h: number } {
@@ -24,167 +53,287 @@ function measureChar(container: HTMLElement): { w: number; h: number } {
return { w: rect.width, h: rect.height };
}
function spriteSize(sprite: PaperclipSprite): { width: number; height: number } {
let width = 0;
for (const row of sprite) width = Math.max(width, row.length);
return { width, height: sprite.length };
}
export function AsciiArtAnimation() {
const preRef = useRef<HTMLPreElement>(null);
const frameRef = useRef(0);
const particlesRef = useRef<Particle[]>([]);
const frameRef = useRef<number | null>(null);
useEffect(() => {
if (!preRef.current) return;
const preEl: HTMLPreElement = preRef.current;
const motionMedia = window.matchMedia("(prefers-reduced-motion: reduce)");
let isVisible = document.visibilityState !== "hidden";
let loopActive = false;
let lastRenderAt = 0;
let tick = 0;
let cols = 0;
let rows = 0;
let charW = 7;
let charH = 11;
let trail = new Float32Array(0);
let colWave = new Float32Array(0);
let rowWave = new Float32Array(0);
let clipMask = new Uint16Array(0);
let clips: Clip[] = [];
let lastOutput = "";
const charSize = measureChar(preEl);
let charW = charSize.w;
let charH = charSize.h;
let cols = Math.ceil(preEl.clientWidth / charW);
let rows = Math.ceil(preEl.clientHeight / charH);
let particles = particlesRef.current;
function toGlyph(value: number): string {
const clamped = Math.max(0, Math.min(0.999, value));
const idx = Math.floor(clamped * CHARS.length);
return CHARS[idx] ?? " ";
}
function spawnParticle() {
const edge = Math.random();
let x: number, y: number, vx: number, vy: number;
if (edge < 0.5) {
x = -1;
y = Math.random() * rows;
vx = 0.3 + Math.random() * 0.5;
vy = (Math.random() - 0.5) * 0.2;
} else {
x = Math.random() * cols;
y = rows + 1;
vx = (Math.random() - 0.5) * 0.2;
vy = -(0.2 + Math.random() * 0.4);
function rebuildGrid() {
const nextCols = Math.max(0, Math.ceil(preEl.clientWidth / Math.max(1, charW)));
const nextRows = Math.max(0, Math.ceil(preEl.clientHeight / Math.max(1, charH)));
if (nextCols === cols && nextRows === rows) return;
cols = nextCols;
rows = nextRows;
const cellCount = cols * rows;
trail = new Float32Array(cellCount);
colWave = new Float32Array(cols);
rowWave = new Float32Array(rows);
clipMask = new Uint16Array(cellCount);
clips = clips.filter((clip) => {
return (
clip.x > -clip.width - 2 &&
clip.x < cols + 2 &&
clip.y > -clip.height - 2 &&
clip.y < rows + 2
);
});
lastOutput = "";
}
function drawStaticFrame() {
if (cols <= 0 || rows <= 0) {
preEl.textContent = "";
return;
}
const maxLife = 60 + Math.random() * 120;
particles.push({
x, y, vx, vy,
char: CHARS[Math.floor(Math.random() * CHARS.length)],
const grid = Array.from({ length: rows }, () => Array.from({ length: cols }, () => " "));
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const ambient = (Math.sin(c * 0.11 + r * 0.04) + Math.cos(r * 0.08 - c * 0.02)) * 0.18 + 0.22;
grid[r][c] = toGlyph(ambient);
}
}
const gapX = 18;
const gapY = 13;
for (let baseRow = 1; baseRow < rows - 9; baseRow += gapY) {
const startX = Math.floor(baseRow / gapY) % 2 === 0 ? 2 : 10;
for (let baseCol = startX; baseCol < cols - 10; baseCol += gapX) {
const sprite = PAPERCLIP_SPRITES[(baseCol + baseRow) % PAPERCLIP_SPRITES.length]!;
for (let sr = 0; sr < sprite.length; sr++) {
const line = sprite[sr]!;
for (let sc = 0; sc < line.length; sc++) {
const ch = line[sc] ?? " ";
if (ch === " ") continue;
const row = baseRow + sr;
const col = baseCol + sc;
if (row < 0 || row >= rows || col < 0 || col >= cols) continue;
grid[row]![col] = ch;
}
}
}
}
const output = grid.map((line) => line.join("")).join("\n");
preEl.textContent = output;
lastOutput = output;
}
function spawnClip() {
const sprite = PAPERCLIP_SPRITES[Math.floor(Math.random() * PAPERCLIP_SPRITES.length)]!;
const size = spriteSize(sprite);
const edge = Math.random();
let x = 0;
let y = 0;
let vx = 0;
let vy = 0;
if (edge < 0.68) {
x = Math.random() < 0.5 ? -size.width - 1 : cols + 1;
y = Math.random() * Math.max(1, rows - size.height);
vx = x < 0 ? 0.04 + Math.random() * 0.05 : -(0.04 + Math.random() * 0.05);
vy = (Math.random() - 0.5) * 0.014;
} else {
x = Math.random() * Math.max(1, cols - size.width);
y = Math.random() < 0.5 ? -size.height - 1 : rows + 1;
vx = (Math.random() - 0.5) * 0.014;
vy = y < 0 ? 0.028 + Math.random() * 0.034 : -(0.028 + Math.random() * 0.034);
}
clips.push({
x,
y,
vx,
vy,
life: 0,
maxLife,
phase: Math.random() * Math.PI * 2,
maxLife: 260 + Math.random() * 220,
drift: (Math.random() - 0.5) * 1.2,
sprite,
width: size.width,
height: size.height,
});
}
function render(time: number) {
const t = time * 0.001;
// Spawn particles
const targetCount = Math.floor((cols * rows) / 12);
while (particles.length < targetCount) {
spawnParticle();
}
// Build grid
const grid: string[][] = Array.from({ length: rows }, () =>
Array.from({ length: cols }, () => " ")
);
const opacity: number[][] = Array.from({ length: rows }, () =>
Array.from({ length: cols }, () => 0)
);
// Background wave pattern
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const wave =
Math.sin(c * 0.08 + t * 0.7 + r * 0.04) *
Math.sin(r * 0.06 - t * 0.5) *
Math.cos((c + r) * 0.03 + t * 0.3);
if (wave > 0.65) {
grid[r][c] = wave > 0.85 ? "·" : ".";
opacity[r][c] = Math.min(1, (wave - 0.65) * 3);
}
function stampClip(clip: Clip, alpha: number) {
const baseCol = Math.round(clip.x);
const baseRow = Math.round(clip.y);
for (let sr = 0; sr < clip.sprite.length; sr++) {
const line = clip.sprite[sr]!;
const row = baseRow + sr;
if (row < 0 || row >= rows) continue;
for (let sc = 0; sc < line.length; sc++) {
const ch = line[sc] ?? " ";
if (ch === " ") continue;
const col = baseCol + sc;
if (col < 0 || col >= cols) continue;
const idx = row * cols + col;
const stroke = ch === "│" || ch === "─" ? 0.8 : 0.92;
trail[idx] = Math.max(trail[idx] ?? 0, alpha * stroke);
clipMask[idx] = ch.charCodeAt(0);
}
}
}
// Update and render particles
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.life++;
function step(time: number) {
if (!loopActive) return;
frameRef.current = requestAnimationFrame(step);
if (time - lastRenderAt < FRAME_INTERVAL_MS || cols <= 0 || rows <= 0) return;
// Flow field influence
const angle =
Math.sin(p.x * 0.05 + t * 0.3) * Math.cos(p.y * 0.07 - t * 0.2) *
Math.PI;
p.vx += Math.cos(angle) * 0.02;
p.vy += Math.sin(angle) * 0.02;
const delta = Math.min(2, lastRenderAt === 0 ? 1 : (time - lastRenderAt) / 16.6667);
lastRenderAt = time;
tick += delta;
// Damping
p.vx *= 0.98;
p.vy *= 0.98;
const cellCount = cols * rows;
const targetCount = Math.max(3, Math.floor(cellCount / 2200));
while (clips.length < targetCount) spawnClip();
p.x += p.vx;
p.y += p.vy;
for (let i = 0; i < trail.length; i++) trail[i] *= 0.92;
clipMask.fill(0);
// Life fade
const lifeFrac = p.life / p.maxLife;
const alpha = lifeFrac < 0.1
? lifeFrac / 0.1
: lifeFrac > 0.8
? (1 - lifeFrac) / 0.2
: 1;
for (let i = clips.length - 1; i >= 0; i--) {
const clip = clips[i]!;
clip.life += delta;
const wobbleX = Math.sin((clip.y + clip.drift + tick * 0.12) * 0.09) * 0.0018;
const wobbleY = Math.cos((clip.x - clip.drift - tick * 0.09) * 0.08) * 0.0014;
clip.vx = (clip.vx + wobbleX) * 0.998;
clip.vy = (clip.vy + wobbleY) * 0.998;
clip.x += clip.vx * delta;
clip.y += clip.vy * delta;
// Remove dead or out-of-bounds particles
if (
p.life >= p.maxLife ||
p.x < -2 || p.x > cols + 2 ||
p.y < -2 || p.y > rows + 2
clip.life >= clip.maxLife ||
clip.x < -clip.width - 2 ||
clip.x > cols + 2 ||
clip.y < -clip.height - 2 ||
clip.y > rows + 2
) {
particles.splice(i, 1);
clips.splice(i, 1);
continue;
}
const col = Math.round(p.x);
const row = Math.round(p.y);
if (row >= 0 && row < rows && col >= 0 && col < cols) {
if (alpha > opacity[row][col]) {
// Cycle through characters based on life
const charIdx = Math.floor(
(lifeFrac + Math.sin(p.phase + t)) * CHARS.length
) % CHARS.length;
grid[row][col] = CHARS[Math.abs(charIdx)];
opacity[row][col] = alpha;
}
}
const life = clip.life / clip.maxLife;
const alpha = life < 0.12 ? life / 0.12 : life > 0.88 ? (1 - life) / 0.12 : 1;
stampClip(clip, alpha);
}
// Render to string
for (let c = 0; c < cols; c++) colWave[c] = Math.sin(c * 0.08 + tick * 0.06);
for (let r = 0; r < rows; r++) rowWave[r] = Math.cos(r * 0.1 - tick * 0.05);
let output = "";
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const a = opacity[r][c];
if (a > 0 && grid[r][c] !== " ") {
const o = Math.round(a * 60 + 40);
output += `<span style="opacity:${o}%">${grid[r][c]}</span>`;
} else {
output += " ";
const idx = r * cols + c;
const clipChar = clipMask[idx];
if (clipChar > 0) {
output += String.fromCharCode(clipChar);
continue;
}
const ambient = (colWave[c] + rowWave[r]) * 0.08 + 0.1;
const intensity = Math.max(trail[idx] ?? 0, ambient * 0.45);
output += toGlyph(intensity);
}
if (r < rows - 1) output += "\n";
}
preEl.innerHTML = output;
frameRef.current = requestAnimationFrame(render);
if (output !== lastOutput) {
preEl.textContent = output;
lastOutput = output;
}
}
function syncLoop() {
const canRender = cols > 0 && rows > 0;
if (motionMedia.matches) {
if (loopActive) {
loopActive = false;
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}
if (canRender) drawStaticFrame();
return;
}
if (!isVisible || !canRender) {
if (loopActive) {
loopActive = false;
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}
return;
}
if (!loopActive) {
loopActive = true;
lastRenderAt = 0;
frameRef.current = requestAnimationFrame(step);
}
}
// Handle resize
const observer = new ResizeObserver(() => {
const size = measureChar(preEl);
charW = size.w;
charH = size.h;
cols = Math.ceil(preEl.clientWidth / charW);
rows = Math.ceil(preEl.clientHeight / charH);
// Cull out-of-bounds particles on resize
particles = particles.filter(
(p) => p.x >= -2 && p.x <= cols + 2 && p.y >= -2 && p.y <= rows + 2
);
particlesRef.current = particles;
rebuildGrid();
syncLoop();
});
observer.observe(preEl);
frameRef.current = requestAnimationFrame(render);
const onVisibilityChange = () => {
isVisible = document.visibilityState !== "hidden";
syncLoop();
};
document.addEventListener("visibilitychange", onVisibilityChange);
const onMotionChange = () => {
syncLoop();
};
motionMedia.addEventListener("change", onMotionChange);
const charSize = measureChar(preEl);
charW = charSize.w;
charH = charSize.h;
rebuildGrid();
syncLoop();
return () => {
cancelAnimationFrame(frameRef.current);
loopActive = false;
if (frameRef.current !== null) cancelAnimationFrame(frameRef.current);
observer.disconnect();
document.removeEventListener("visibilitychange", onVisibilityChange);
motionMedia.removeEventListener("change", onMotionChange);
};
}, []);