import { onCleanup, onMount } from "solid-js"; interface RuneCanvasProps { runeString: string; width: number; height: number; } const FONT_SIZE = 17; const LINE_HEIGHT = FONT_SIZE * 2.3; const LETTER_WIDTH = FONT_SIZE * 1.35; const LETTER_SPACING = FONT_SIZE * 0.18; const SPEEDS = [18, 24, 14]; const FALLBACK_ACCENT = "#F49C70"; function readAccentHex(): string { const raw = getComputedStyle(document.documentElement) .getPropertyValue("--peach-accent") .trim(); if (/^#[0-9a-fA-F]{6}$/.test(raw)) return raw; return FALLBACK_ACCENT; } export function RuneCanvas(props: RuneCanvasProps) { let canvas!: HTMLCanvasElement; let raf = 0; let running = false; let accentHex = FALLBACK_ACCENT; let accentRgb: [number, number, number] = [0xf4, 0x9c, 0x70]; function setAccent(hex: string) { accentHex = hex; accentRgb = [ parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16), ]; } onMount(() => { const ctx = canvas.getContext("2d"); if (!ctx) return; setAccent(readAccentHex()); const accentObserver = new MutationObserver(() => { const next = readAccentHex(); if (next !== accentHex) setAccent(next); }); accentObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["style"], }); const W = props.width; const H = props.height; const SYMBOLS_PER_ROW = Math.ceil(W / (LETTER_WIDTH + LETTER_SPACING)) + 4; const NUM_ROWS = Math.floor(H / LINE_HEIGHT) + 1; const TOTAL_VISIBLE = NUM_ROWS * SYMBOLS_PER_ROW; const GLOW_COUNT = Math.min(8, Math.max(3, Math.floor(TOTAL_VISIBLE * 0.03))); interface Glow { row: number; col: number; phase: number; speed: number; lifetime: number; age: number; } const spawn = (): Glow => ({ row: Math.floor(Math.random() * NUM_ROWS), col: Math.floor(Math.random() * SYMBOLS_PER_ROW), phase: Math.random(), speed: 0.4 + Math.random() * 0.4, lifetime: 2000 + Math.random() * 3000, age: 0, }); const glows: Glow[] = Array.from({ length: GLOW_COUNT }, spawn); const offsets: number[] = Array.from({ length: NUM_ROWS }, () => Math.random() * props.runeString.length * (LETTER_WIDTH + LETTER_SPACING), ); const TOTAL_STRING_WIDTH = props.runeString.length * (LETTER_WIDTH + LETTER_SPACING) || 1; let lastTime = performance.now(); const frame = (now: number) => { const dt = Math.min(now - lastTime, 64); // clamp huge dt after pause lastTime = now; ctx.clearRect(0, 0, W, H); ctx.fillStyle = "#1a1410"; ctx.fillRect(0, 0, W, H); ctx.font = `${FONT_SIZE}px PeachScript, monospace`; ctx.textBaseline = "alphabetic"; const glowMap = new Map(); for (const g of glows) { g.age += dt; g.phase = (g.phase + (g.speed * dt) / 1000) % 1; const lifetimeFraction = g.age / g.lifetime; const lifetimeEnvelope = Math.sin(lifetimeFraction * Math.PI); const pulseAlpha = Math.sin(g.phase * Math.PI * 2) * 0.5 + 0.5; const alpha = Math.max(0, lifetimeEnvelope) * pulseAlpha * 0.85; glowMap.set(`${g.row},${g.col}`, alpha); if (g.age >= g.lifetime) Object.assign(g, spawn()); } const [ar, ag, ab] = accentRgb; const runes = props.runeString; const runeLen = runes.length; if (runeLen === 0) { raf = requestAnimationFrame(frame); return; } for (let row = 0; row < NUM_ROWS; row++) { const speed = SPEEDS[row % SPEEDS.length]; offsets[row] = (offsets[row] + (speed * dt) / 1000) % TOTAL_STRING_WIDTH; const y = row * LINE_HEIGHT + FONT_SIZE + 12; for (let col = 0; col < SYMBOLS_PER_ROW; col++) { const x = col * (LETTER_WIDTH + LETTER_SPACING) - (offsets[row] % (LETTER_WIDTH + LETTER_SPACING)); if (x < -LETTER_WIDTH || x > W + LETTER_WIDTH) continue; const baseIdx = Math.floor(offsets[row] / (LETTER_WIDTH + LETTER_SPACING)); const strIdx = (((baseIdx + col) % runeLen) + runeLen) % runeLen; const char = runes[strIdx]; const glowAlpha = glowMap.get(`${row},${col}`) ?? 0; if (glowAlpha > 0.01) { ctx.save(); ctx.shadowColor = `rgba(${ar}, ${ag}, ${ab}, ${glowAlpha * 0.9})`; ctx.shadowBlur = 10; ctx.fillStyle = `rgba(${ar}, ${ag}, ${ab}, ${0.6 + glowAlpha * 0.4})`; ctx.fillText(char, x, y); ctx.restore(); } else { ctx.fillStyle = "rgba(245, 240, 234, 0.82)"; ctx.fillText(char, x, y); } } } raf = requestAnimationFrame(frame); }; const start = () => { if (running) return; running = true; lastTime = performance.now(); raf = requestAnimationFrame(frame); }; const stop = () => { if (!running) return; running = false; cancelAnimationFrame(raf); }; const onVisibility = () => { if (document.hidden) stop(); else start(); }; document.addEventListener("visibilitychange", onVisibility); // Font-load guard: render boxes if font isn't ready, so wait for it. document.fonts.load(`${FONT_SIZE}px PeachScript`).finally(start); onCleanup(() => { stop(); accentObserver.disconnect(); document.removeEventListener("visibilitychange", onVisibility); }); }); return ( ); }