592 lines
18 KiB
TypeScript
592 lines
18 KiB
TypeScript
|
|
'use client';
|
||
|
|
|
||
|
|
import { useEffect, useRef } from 'react';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* HeroAnimation — full-bleed Canvas 2D piece for the marketing hero.
|
||
|
|
*
|
||
|
|
* Four layers (back to front):
|
||
|
|
* 1. Drifting indigo particle dots (texture)
|
||
|
|
* 2. Code-block panel with typewriter cycling 3 snippets (centerpiece)
|
||
|
|
* 3. Mouse-tracking glow ring (interactive, additive)
|
||
|
|
* 4. Data packets shooting from the panel toward the canvas edges
|
||
|
|
*
|
||
|
|
* Hex colors are hardcoded here on purpose — Tailwind v4 beta oklch tokens
|
||
|
|
* don't round-trip cleanly when read from JS, and we want pixel-stable
|
||
|
|
* brand colors inside the canvas regardless of the design-token layer.
|
||
|
|
*/
|
||
|
|
|
||
|
|
const COLORS = {
|
||
|
|
bg: '#0a0a0b',
|
||
|
|
bgElevated: '#111114',
|
||
|
|
border: '#1f1f22',
|
||
|
|
borderStrong: '#2a2a2e',
|
||
|
|
fg: '#fafafa',
|
||
|
|
fgMuted: '#a1a1aa',
|
||
|
|
fgSubtle: '#71717a',
|
||
|
|
accent: '#6366f1',
|
||
|
|
success: '#22c55e',
|
||
|
|
} as const;
|
||
|
|
|
||
|
|
const SNIPPETS: ReadonlyArray<ReadonlyArray<string>> = [
|
||
|
|
[
|
||
|
|
'// MCP server — auto-generated',
|
||
|
|
'import { Server } from "@bmm/mcp";',
|
||
|
|
'',
|
||
|
|
'const server = new Server({',
|
||
|
|
' name: "data-bridge",',
|
||
|
|
' tools: [getRecord, search, write],',
|
||
|
|
'});',
|
||
|
|
'',
|
||
|
|
'await server.listen();',
|
||
|
|
],
|
||
|
|
[
|
||
|
|
'> Generating spec... OK',
|
||
|
|
'> Static checks OK',
|
||
|
|
'> Building image OK 17.2s',
|
||
|
|
'> Deploying container OK',
|
||
|
|
'> Live at https://...mcp.bmm.io',
|
||
|
|
],
|
||
|
|
[
|
||
|
|
'// claude_desktop_config.json',
|
||
|
|
'{',
|
||
|
|
' "mcpServers": {',
|
||
|
|
' "data-bridge": {',
|
||
|
|
' "url": "https://data.mcp.bmm.io",',
|
||
|
|
' "auth": "oauth2"',
|
||
|
|
' }',
|
||
|
|
' }',
|
||
|
|
'}',
|
||
|
|
],
|
||
|
|
];
|
||
|
|
|
||
|
|
const KEYWORDS = new Set([
|
||
|
|
'import',
|
||
|
|
'from',
|
||
|
|
'const',
|
||
|
|
'await',
|
||
|
|
'new',
|
||
|
|
'return',
|
||
|
|
'function',
|
||
|
|
'let',
|
||
|
|
'var',
|
||
|
|
'if',
|
||
|
|
'else',
|
||
|
|
'export',
|
||
|
|
'default',
|
||
|
|
]);
|
||
|
|
|
||
|
|
interface Particle {
|
||
|
|
x: number;
|
||
|
|
y: number;
|
||
|
|
vx: number;
|
||
|
|
vy: number;
|
||
|
|
r: number;
|
||
|
|
seed: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface Packet {
|
||
|
|
x: number;
|
||
|
|
y: number;
|
||
|
|
vx: number;
|
||
|
|
vy: number;
|
||
|
|
life: number; // 0..1, decreases
|
||
|
|
maxLife: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface HeroAnimationProps {
|
||
|
|
className?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function HeroAnimation({ className }: HeroAnimationProps) {
|
||
|
|
const wrapRef = useRef<HTMLDivElement | null>(null);
|
||
|
|
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
const wrap = wrapRef.current;
|
||
|
|
const canvas = canvasRef.current;
|
||
|
|
if (!wrap || !canvas) return;
|
||
|
|
|
||
|
|
const ctx = canvas.getContext('2d', { alpha: true });
|
||
|
|
if (!ctx) return;
|
||
|
|
|
||
|
|
const reducedMotion =
|
||
|
|
typeof window !== 'undefined' &&
|
||
|
|
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||
|
|
|
||
|
|
// ----- sizing (DPR-aware) -----
|
||
|
|
let cssW = 0;
|
||
|
|
let cssH = 0;
|
||
|
|
const setSize = () => {
|
||
|
|
const rect = wrap.getBoundingClientRect();
|
||
|
|
cssW = Math.max(1, Math.floor(rect.width));
|
||
|
|
cssH = Math.max(1, Math.floor(rect.height));
|
||
|
|
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||
|
|
canvas.width = cssW * dpr;
|
||
|
|
canvas.height = cssH * dpr;
|
||
|
|
canvas.style.width = `${cssW}px`;
|
||
|
|
canvas.style.height = `${cssH}px`;
|
||
|
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||
|
|
seedParticles();
|
||
|
|
};
|
||
|
|
|
||
|
|
// ----- particles -----
|
||
|
|
const particles: Particle[] = [];
|
||
|
|
const PARTICLE_COUNT = 60;
|
||
|
|
const seedParticles = () => {
|
||
|
|
particles.length = 0;
|
||
|
|
for (let i = 0; i < PARTICLE_COUNT; i++) {
|
||
|
|
const angle = Math.random() * Math.PI * 2;
|
||
|
|
const speed = (0.15 + Math.random() * 0.25) * (reducedMotion ? 0.4 : 1);
|
||
|
|
particles.push({
|
||
|
|
x: Math.random() * cssW,
|
||
|
|
y: Math.random() * cssH,
|
||
|
|
vx: Math.cos(angle) * speed,
|
||
|
|
vy: Math.sin(angle) * speed,
|
||
|
|
r: 0.7 + Math.random() * 1.3,
|
||
|
|
seed: Math.random() * 1000,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
// ----- pointer state -----
|
||
|
|
// Track in CSS pixels (post setTransform), -1,-1 means "not over".
|
||
|
|
const pointer = {
|
||
|
|
raw: { x: -1, y: -1 },
|
||
|
|
smooth: { x: cssW / 2, y: cssH / 2 },
|
||
|
|
inside: false,
|
||
|
|
ringAlpha: 0, // eased
|
||
|
|
};
|
||
|
|
|
||
|
|
const onPointerMove = (e: PointerEvent) => {
|
||
|
|
const rect = canvas.getBoundingClientRect();
|
||
|
|
pointer.raw.x = e.clientX - rect.left;
|
||
|
|
pointer.raw.y = e.clientY - rect.top;
|
||
|
|
pointer.inside = true;
|
||
|
|
};
|
||
|
|
const onPointerLeave = () => {
|
||
|
|
pointer.inside = false;
|
||
|
|
};
|
||
|
|
canvas.addEventListener('pointermove', onPointerMove, { passive: true });
|
||
|
|
canvas.addEventListener('pointerleave', onPointerLeave, { passive: true });
|
||
|
|
canvas.addEventListener('pointercancel', onPointerLeave, { passive: true });
|
||
|
|
|
||
|
|
// ----- packets -----
|
||
|
|
const packets: Packet[] = [];
|
||
|
|
let lastPacketSpawn = 0;
|
||
|
|
|
||
|
|
// ----- typewriter state -----
|
||
|
|
// Snippet cycle:
|
||
|
|
// typing -> holding -> erasing -> pausing -> next snippet
|
||
|
|
// Timings calibrated so each snippet runs ~4-5s.
|
||
|
|
const FORWARD_MS = reducedMotion ? 45 : 25;
|
||
|
|
const ERASE_MS = reducedMotion ? 28 : 15;
|
||
|
|
const HOLD_MS = 2500;
|
||
|
|
const PAUSE_MS = 500;
|
||
|
|
|
||
|
|
let snippetIndex = 0;
|
||
|
|
type Phase = 'typing' | 'holding' | 'erasing' | 'pausing';
|
||
|
|
let phase: Phase = 'typing';
|
||
|
|
let phaseStart = 0;
|
||
|
|
// Characters revealed across the FULL snippet text including newlines.
|
||
|
|
const fullText = (i: number): string =>
|
||
|
|
(SNIPPETS[i] ?? []).join('\n');
|
||
|
|
|
||
|
|
// ----- animation loop -----
|
||
|
|
let rafId = 0;
|
||
|
|
let startTime = performance.now();
|
||
|
|
let lastFrame = startTime;
|
||
|
|
let visible = !document.hidden;
|
||
|
|
|
||
|
|
const onVisibility = () => {
|
||
|
|
visible = !document.hidden;
|
||
|
|
if (visible) {
|
||
|
|
// Reset frame delta so paused time doesn't lurch animation.
|
||
|
|
lastFrame = performance.now();
|
||
|
|
rafId = requestAnimationFrame(frame);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
document.addEventListener('visibilitychange', onVisibility);
|
||
|
|
|
||
|
|
// ----- drawing helpers -----
|
||
|
|
const drawParticles = (elapsed: number, dt: number) => {
|
||
|
|
ctx.save();
|
||
|
|
ctx.fillStyle = COLORS.accent;
|
||
|
|
ctx.globalAlpha = 0.2;
|
||
|
|
const driftScale = reducedMotion ? 0.4 : 1;
|
||
|
|
for (const p of particles) {
|
||
|
|
// Cheap pseudo-simplex: layered sines indexed by seed + time.
|
||
|
|
const t = elapsed * 0.0004;
|
||
|
|
const nx = Math.sin(t + p.seed) * Math.cos(t * 0.7 + p.seed * 1.3);
|
||
|
|
const ny = Math.cos(t * 0.9 + p.seed * 0.6) * Math.sin(t * 0.5 + p.seed);
|
||
|
|
p.x += (p.vx + nx * 0.3) * dt * 0.06 * driftScale;
|
||
|
|
p.y += (p.vy + ny * 0.3) * dt * 0.06 * driftScale;
|
||
|
|
|
||
|
|
// Wrap around edges so density stays uniform.
|
||
|
|
if (p.x < -4) p.x = cssW + 4;
|
||
|
|
else if (p.x > cssW + 4) p.x = -4;
|
||
|
|
if (p.y < -4) p.y = cssH + 4;
|
||
|
|
else if (p.y > cssH + 4) p.y = -4;
|
||
|
|
|
||
|
|
ctx.beginPath();
|
||
|
|
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
|
||
|
|
ctx.fill();
|
||
|
|
}
|
||
|
|
ctx.restore();
|
||
|
|
};
|
||
|
|
|
||
|
|
// Returns { x, y, w, h } of the code panel in CSS pixels.
|
||
|
|
const panelRect = () => {
|
||
|
|
const w = cssW * 0.8;
|
||
|
|
// Sized to comfortably fit the longest snippet (~9 lines @ ~18px line-height).
|
||
|
|
const h = Math.min(cssH * 0.55, 240);
|
||
|
|
return {
|
||
|
|
x: (cssW - w) / 2,
|
||
|
|
y: (cssH - h) / 2,
|
||
|
|
w,
|
||
|
|
h,
|
||
|
|
};
|
||
|
|
};
|
||
|
|
|
||
|
|
const drawPanel = (rect: { x: number; y: number; w: number; h: number }) => {
|
||
|
|
ctx.save();
|
||
|
|
ctx.fillStyle = COLORS.bg;
|
||
|
|
ctx.strokeStyle = COLORS.border;
|
||
|
|
ctx.lineWidth = 1;
|
||
|
|
const r = 8;
|
||
|
|
const { x, y, w, h } = rect;
|
||
|
|
ctx.beginPath();
|
||
|
|
ctx.moveTo(x + r, y);
|
||
|
|
ctx.arcTo(x + w, y, x + w, y + h, r);
|
||
|
|
ctx.arcTo(x + w, y + h, x, y + h, r);
|
||
|
|
ctx.arcTo(x, y + h, x, y, r);
|
||
|
|
ctx.arcTo(x, y, x + w, y, r);
|
||
|
|
ctx.closePath();
|
||
|
|
ctx.fill();
|
||
|
|
ctx.stroke();
|
||
|
|
ctx.restore();
|
||
|
|
};
|
||
|
|
|
||
|
|
// Tokenize a line for syntax color. Cheap heuristic, not a real parser.
|
||
|
|
type Token = { text: string; color: string };
|
||
|
|
const tokenizeLine = (line: string, elapsed: number): Token[] => {
|
||
|
|
// Comment lines.
|
||
|
|
if (line.trim().startsWith('//')) {
|
||
|
|
return [{ text: line, color: COLORS.fgSubtle }];
|
||
|
|
}
|
||
|
|
// Log lines: ">" prefix style, flash "OK" green briefly when it appears.
|
||
|
|
if (line.startsWith('>')) {
|
||
|
|
const tokens: Token[] = [];
|
||
|
|
// Find OK occurrences and color them.
|
||
|
|
const parts = line.split(/(\bOK\b)/g);
|
||
|
|
for (const part of parts) {
|
||
|
|
if (part === 'OK') {
|
||
|
|
// Subtle flash effect: brighter for first ~500ms after fully typed.
|
||
|
|
const flash = 0.65 + 0.35 * (0.5 + 0.5 * Math.sin(elapsed * 0.005));
|
||
|
|
tokens.push({
|
||
|
|
text: part,
|
||
|
|
color: withAlpha(COLORS.success, flash),
|
||
|
|
});
|
||
|
|
} else {
|
||
|
|
tokens.push({ text: part, color: COLORS.fgMuted });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return tokens;
|
||
|
|
}
|
||
|
|
// Generic JS/JSON: split on word boundaries, color keywords + strings.
|
||
|
|
const tokens: Token[] = [];
|
||
|
|
// Match: string literals, words, or anything else.
|
||
|
|
const re = /("[^"]*"|\b[A-Za-z_$][\w$]*\b|[^"\w]+)/g;
|
||
|
|
let match: RegExpExecArray | null;
|
||
|
|
while ((match = re.exec(line)) !== null) {
|
||
|
|
const t = match[0];
|
||
|
|
if (t.startsWith('"')) {
|
||
|
|
tokens.push({ text: t, color: COLORS.fgMuted });
|
||
|
|
} else if (KEYWORDS.has(t)) {
|
||
|
|
tokens.push({ text: t, color: COLORS.accent });
|
||
|
|
} else {
|
||
|
|
tokens.push({ text: t, color: COLORS.fg });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return tokens;
|
||
|
|
};
|
||
|
|
|
||
|
|
const drawCode = (
|
||
|
|
rect: { x: number; y: number; w: number; h: number },
|
||
|
|
visibleText: string,
|
||
|
|
elapsed: number,
|
||
|
|
) => {
|
||
|
|
ctx.save();
|
||
|
|
// Clip to panel so text never bleeds over the rounded border.
|
||
|
|
ctx.beginPath();
|
||
|
|
ctx.rect(rect.x, rect.y, rect.w, rect.h);
|
||
|
|
ctx.clip();
|
||
|
|
|
||
|
|
const fontSize = 12.5;
|
||
|
|
const lineHeight = 18;
|
||
|
|
const padX = 16;
|
||
|
|
const padY = 14;
|
||
|
|
ctx.font = `${fontSize}px var(--font-geist-mono), ui-monospace, SFMono-Regular, Menlo, monospace`;
|
||
|
|
ctx.textBaseline = 'top';
|
||
|
|
|
||
|
|
const lines = visibleText.split('\n');
|
||
|
|
for (let i = 0; i < lines.length; i++) {
|
||
|
|
const line = lines[i] ?? '';
|
||
|
|
const y = rect.y + padY + i * lineHeight;
|
||
|
|
if (y > rect.y + rect.h - padY) break;
|
||
|
|
let x = rect.x + padX;
|
||
|
|
const tokens = tokenizeLine(line, elapsed);
|
||
|
|
for (const tok of tokens) {
|
||
|
|
ctx.fillStyle = tok.color;
|
||
|
|
ctx.fillText(tok.text, x, y);
|
||
|
|
x += ctx.measureText(tok.text).width;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Blinking caret at the end of the visible text.
|
||
|
|
const lastLine = lines[lines.length - 1] ?? '';
|
||
|
|
const caretX =
|
||
|
|
rect.x + padX + ctx.measureText(lastLine).width;
|
||
|
|
const caretY = rect.y + padY + (lines.length - 1) * lineHeight;
|
||
|
|
const blink = (elapsed % 1000) < 500;
|
||
|
|
if (blink && caretY + lineHeight <= rect.y + rect.h - padY + lineHeight) {
|
||
|
|
ctx.fillStyle = COLORS.accent;
|
||
|
|
ctx.fillRect(caretX + 1, caretY + 2, 6, fontSize);
|
||
|
|
}
|
||
|
|
ctx.restore();
|
||
|
|
};
|
||
|
|
|
||
|
|
const drawRing = (elapsed: number) => {
|
||
|
|
if (pointer.ringAlpha < 0.005) return;
|
||
|
|
ctx.save();
|
||
|
|
ctx.globalCompositeOperation = 'lighter';
|
||
|
|
const cx = pointer.smooth.x;
|
||
|
|
const cy = pointer.smooth.y;
|
||
|
|
const baseRadii: ReadonlyArray<{ r: number; w: number; a: number }> = [
|
||
|
|
{ r: 60, w: 2.4, a: 0.4 },
|
||
|
|
{ r: 90, w: 1.6, a: 0.28 },
|
||
|
|
{ r: 130, w: 1.0, a: 0.18 },
|
||
|
|
];
|
||
|
|
for (const band of baseRadii) {
|
||
|
|
ctx.beginPath();
|
||
|
|
const segments = 96;
|
||
|
|
for (let i = 0; i <= segments; i++) {
|
||
|
|
const theta = (i / segments) * Math.PI * 2;
|
||
|
|
// Breathing distortion around the circumference.
|
||
|
|
const wobble = Math.sin(theta * 3 + elapsed * 0.002) * 4;
|
||
|
|
const r = band.r + wobble;
|
||
|
|
const x = cx + Math.cos(theta) * r;
|
||
|
|
const y = cy + Math.sin(theta) * r;
|
||
|
|
if (i === 0) ctx.moveTo(x, y);
|
||
|
|
else ctx.lineTo(x, y);
|
||
|
|
}
|
||
|
|
ctx.strokeStyle = withAlpha(
|
||
|
|
COLORS.accent,
|
||
|
|
band.a * pointer.ringAlpha,
|
||
|
|
);
|
||
|
|
ctx.lineWidth = band.w;
|
||
|
|
ctx.stroke();
|
||
|
|
}
|
||
|
|
ctx.restore();
|
||
|
|
};
|
||
|
|
|
||
|
|
const spawnPacket = (rect: { x: number; y: number; w: number; h: number }) => {
|
||
|
|
// Pick an edge of the panel.
|
||
|
|
const edge = Math.floor(Math.random() * 4);
|
||
|
|
let x = 0;
|
||
|
|
let y = 0;
|
||
|
|
let vx = 0;
|
||
|
|
let vy = 0;
|
||
|
|
const speed = 0.06 + Math.random() * 0.04; // CSS px / ms
|
||
|
|
if (edge === 0) {
|
||
|
|
// top
|
||
|
|
x = rect.x + Math.random() * rect.w;
|
||
|
|
y = rect.y;
|
||
|
|
vx = (Math.random() - 0.5) * 0.02;
|
||
|
|
vy = -speed;
|
||
|
|
} else if (edge === 1) {
|
||
|
|
// right
|
||
|
|
x = rect.x + rect.w;
|
||
|
|
y = rect.y + Math.random() * rect.h;
|
||
|
|
vx = speed;
|
||
|
|
vy = (Math.random() - 0.5) * 0.02;
|
||
|
|
} else if (edge === 2) {
|
||
|
|
// bottom
|
||
|
|
x = rect.x + Math.random() * rect.w;
|
||
|
|
y = rect.y + rect.h;
|
||
|
|
vx = (Math.random() - 0.5) * 0.02;
|
||
|
|
vy = speed;
|
||
|
|
} else {
|
||
|
|
// left
|
||
|
|
x = rect.x;
|
||
|
|
y = rect.y + Math.random() * rect.h;
|
||
|
|
vx = -speed;
|
||
|
|
vy = (Math.random() - 0.5) * 0.02;
|
||
|
|
}
|
||
|
|
// Distance to nearest canvas edge along direction → travel time.
|
||
|
|
const distX = vx > 0 ? cssW - x : vx < 0 ? x : Infinity;
|
||
|
|
const distY = vy > 0 ? cssH - y : vy < 0 ? y : Infinity;
|
||
|
|
const tx = vx !== 0 ? distX / Math.abs(vx) : Infinity;
|
||
|
|
const ty = vy !== 0 ? distY / Math.abs(vy) : Infinity;
|
||
|
|
const maxLife = Math.min(tx, ty);
|
||
|
|
packets.push({ x, y, vx, vy, life: 1, maxLife });
|
||
|
|
};
|
||
|
|
|
||
|
|
const drawPackets = (dt: number) => {
|
||
|
|
ctx.save();
|
||
|
|
for (let i = packets.length - 1; i >= 0; i--) {
|
||
|
|
const p = packets[i];
|
||
|
|
if (!p) continue;
|
||
|
|
p.x += p.vx * dt;
|
||
|
|
p.y += p.vy * dt;
|
||
|
|
p.life -= dt / p.maxLife;
|
||
|
|
if (
|
||
|
|
p.life <= 0 ||
|
||
|
|
p.x < -4 ||
|
||
|
|
p.x > cssW + 4 ||
|
||
|
|
p.y < -4 ||
|
||
|
|
p.y > cssH + 4
|
||
|
|
) {
|
||
|
|
packets.splice(i, 1);
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
const alpha = Math.max(0, p.life) * 0.85;
|
||
|
|
ctx.fillStyle = withAlpha(COLORS.accent, alpha);
|
||
|
|
ctx.beginPath();
|
||
|
|
ctx.arc(p.x, p.y, 1.6, 0, Math.PI * 2);
|
||
|
|
ctx.fill();
|
||
|
|
}
|
||
|
|
ctx.restore();
|
||
|
|
};
|
||
|
|
|
||
|
|
// ----- main frame -----
|
||
|
|
const frame = (now: number) => {
|
||
|
|
if (!visible) return; // visibilitychange will restart us
|
||
|
|
const dt = Math.min(48, now - lastFrame); // cap to avoid lurches
|
||
|
|
lastFrame = now;
|
||
|
|
const elapsed = now - startTime;
|
||
|
|
|
||
|
|
// Pointer smoothing (EMA). When outside, ease ring alpha to 0 (~400ms).
|
||
|
|
if (pointer.inside && pointer.raw.x >= 0) {
|
||
|
|
pointer.smooth.x = 0.82 * pointer.smooth.x + 0.18 * pointer.raw.x;
|
||
|
|
pointer.smooth.y = 0.82 * pointer.smooth.y + 0.18 * pointer.raw.y;
|
||
|
|
// Ease alpha to 1 over ~250ms.
|
||
|
|
pointer.ringAlpha = Math.min(1, pointer.ringAlpha + dt / 250);
|
||
|
|
} else {
|
||
|
|
pointer.ringAlpha = Math.max(0, pointer.ringAlpha - dt / 400);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Clear (transparent — wrapper supplies bg color).
|
||
|
|
ctx.clearRect(0, 0, cssW, cssH);
|
||
|
|
|
||
|
|
// Layer 1: particles
|
||
|
|
drawParticles(elapsed, dt);
|
||
|
|
|
||
|
|
// Layer 2: panel + typewriter
|
||
|
|
const rect = panelRect();
|
||
|
|
drawPanel(rect);
|
||
|
|
|
||
|
|
// Compute visible characters for the current snippet.
|
||
|
|
const text = fullText(snippetIndex);
|
||
|
|
const totalChars = text.length;
|
||
|
|
let visibleChars = 0;
|
||
|
|
const sincePhase = now - phaseStart;
|
||
|
|
if (phase === 'typing') {
|
||
|
|
visibleChars = Math.min(totalChars, Math.floor(sincePhase / FORWARD_MS));
|
||
|
|
if (visibleChars >= totalChars) {
|
||
|
|
phase = 'holding';
|
||
|
|
phaseStart = now;
|
||
|
|
}
|
||
|
|
} else if (phase === 'holding') {
|
||
|
|
visibleChars = totalChars;
|
||
|
|
if (sincePhase >= HOLD_MS) {
|
||
|
|
phase = 'erasing';
|
||
|
|
phaseStart = now;
|
||
|
|
}
|
||
|
|
} else if (phase === 'erasing') {
|
||
|
|
visibleChars = Math.max(
|
||
|
|
0,
|
||
|
|
totalChars - Math.floor(sincePhase / ERASE_MS),
|
||
|
|
);
|
||
|
|
if (visibleChars <= 0) {
|
||
|
|
phase = 'pausing';
|
||
|
|
phaseStart = now;
|
||
|
|
}
|
||
|
|
} else {
|
||
|
|
visibleChars = 0;
|
||
|
|
if (sincePhase >= PAUSE_MS) {
|
||
|
|
snippetIndex = (snippetIndex + 1) % SNIPPETS.length;
|
||
|
|
phase = 'typing';
|
||
|
|
phaseStart = now;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
drawCode(rect, text.slice(0, visibleChars), elapsed);
|
||
|
|
|
||
|
|
// Layer 4 (drawn before ring so the glow sits on top): packets.
|
||
|
|
// Skipped under reduced-motion.
|
||
|
|
if (!reducedMotion) {
|
||
|
|
if (now - lastPacketSpawn > 600 && packets.length < 6) {
|
||
|
|
spawnPacket(rect);
|
||
|
|
lastPacketSpawn = now;
|
||
|
|
}
|
||
|
|
drawPackets(dt);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Layer 3: glow ring (additive, on top)
|
||
|
|
drawRing(elapsed);
|
||
|
|
|
||
|
|
rafId = requestAnimationFrame(frame);
|
||
|
|
};
|
||
|
|
|
||
|
|
// ----- resize observer -----
|
||
|
|
const ro = new ResizeObserver(() => {
|
||
|
|
setSize();
|
||
|
|
});
|
||
|
|
ro.observe(wrap);
|
||
|
|
setSize();
|
||
|
|
|
||
|
|
// Kick off.
|
||
|
|
startTime = performance.now();
|
||
|
|
lastFrame = startTime;
|
||
|
|
phaseStart = startTime;
|
||
|
|
rafId = requestAnimationFrame(frame);
|
||
|
|
|
||
|
|
return () => {
|
||
|
|
cancelAnimationFrame(rafId);
|
||
|
|
ro.disconnect();
|
||
|
|
document.removeEventListener('visibilitychange', onVisibility);
|
||
|
|
canvas.removeEventListener('pointermove', onPointerMove);
|
||
|
|
canvas.removeEventListener('pointerleave', onPointerLeave);
|
||
|
|
canvas.removeEventListener('pointercancel', onPointerLeave);
|
||
|
|
};
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
ref={wrapRef}
|
||
|
|
className={
|
||
|
|
className ??
|
||
|
|
'relative aspect-square w-full overflow-hidden rounded-lg border border-[--color-border-strong] bg-[--color-bg-elevated]'
|
||
|
|
}
|
||
|
|
>
|
||
|
|
<canvas
|
||
|
|
ref={canvasRef}
|
||
|
|
aria-hidden="true"
|
||
|
|
className="block h-full w-full"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Append an alpha channel to a 6-digit hex color.
|
||
|
|
* `withAlpha('#6366f1', 0.4)` -> 'rgba(99, 102, 241, 0.4)'
|
||
|
|
*/
|
||
|
|
function withAlpha(hex: string, alpha: number): string {
|
||
|
|
const h = hex.replace('#', '');
|
||
|
|
const r = parseInt(h.slice(0, 2), 16);
|
||
|
|
const g = parseInt(h.slice(2, 4), 16);
|
||
|
|
const b = parseInt(h.slice(4, 6), 16);
|
||
|
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||
|
|
}
|