Files
paperclip/ui/src/components/AsciiArtAnimation.tsx
T
Forgotten ad19bc921d feat(ui): onboarding wizard, comment thread, markdown editor, and UX polish
Refactor onboarding wizard with ASCII art animation and expanded adapter
support. Enhance markdown editor with code block, table, and CodeMirror
plugins. Improve comment thread layout. Add activity charts to agent
detail page. Polish metric cards, issue detail reassignment, and new
issue dialog. Simplify agent detail page structure.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 16:33:48 -06:00

200 lines
5.6 KiB
TypeScript

import { useEffect, useRef } from "react";
const CHARS = "░▒▓█▄▀■□▪▫●○◆◇◈◉★☆✦✧·.";
interface Particle {
x: number;
y: number;
vx: number;
vy: number;
char: string;
life: number;
maxLife: number;
phase: number;
}
function measureChar(container: HTMLElement): { w: number; h: number } {
const span = document.createElement("span");
span.textContent = "M";
span.style.cssText =
"position:absolute;visibility:hidden;white-space:pre;font-size:11px;font-family:monospace;line-height:1;";
container.appendChild(span);
const rect = span.getBoundingClientRect();
container.removeChild(span);
return { w: rect.width, h: rect.height };
}
export function AsciiArtAnimation() {
const preRef = useRef<HTMLPreElement>(null);
const frameRef = useRef(0);
const particlesRef = useRef<Particle[]>([]);
useEffect(() => {
if (!preRef.current) return;
const preEl: HTMLPreElement = preRef.current;
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 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);
}
const maxLife = 60 + Math.random() * 120;
particles.push({
x, y, vx, vy,
char: CHARS[Math.floor(Math.random() * CHARS.length)],
life: 0,
maxLife,
phase: Math.random() * Math.PI * 2,
});
}
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);
}
}
}
// Update and render particles
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.life++;
// 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;
// Damping
p.vx *= 0.98;
p.vy *= 0.98;
p.x += p.vx;
p.y += p.vy;
// Life fade
const lifeFrac = p.life / p.maxLife;
const alpha = lifeFrac < 0.1
? lifeFrac / 0.1
: lifeFrac > 0.8
? (1 - lifeFrac) / 0.2
: 1;
// 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
) {
particles.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;
}
}
}
// Render to string
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 += " ";
}
}
if (r < rows - 1) output += "\n";
}
preEl.innerHTML = output;
frameRef.current = requestAnimationFrame(render);
}
// 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;
});
observer.observe(preEl);
frameRef.current = requestAnimationFrame(render);
return () => {
cancelAnimationFrame(frameRef.current);
observer.disconnect();
};
}, []);
return (
<pre
ref={preRef}
className="w-full h-full m-0 p-0 overflow-hidden text-muted-foreground/60 select-none leading-none"
style={{ fontSize: "11px", fontFamily: "monospace" }}
aria-hidden="true"
/>
);
}