import { onCleanup, onMount } from "solid-js"; interface NoteRunesProps { mode: "toc" | "editor"; } const FONT_SIZE = 13; const RUNE_LEN = 120; function buildRuneString(): string { const out: string[] = []; for (let i = 0; i < RUNE_LEN; i++) { out.push(String.fromCodePoint(0xe000 + ((i * 41 + 7) & 0xff))); } return out.join(""); } const RUNES = buildRuneString(); interface RuneState { x: number; y: number; char: string; vx: number; vy: number; opacity: number; dwellMs: number; elapsed: number; fading: boolean; } function makeRune(w: number, h: number, mode: "toc" | "editor"): RuneState { // Edge-biased placement: runes stay in margins const MARGIN = 40; const side = Math.random() < 0.5 ? "left" : "right"; const x = side === "left" ? Math.random() * MARGIN : w - MARGIN + Math.random() * MARGIN; const y = Math.random() * h; const [minOp, maxOp] = mode === "toc" ? [0.03, 0.06] : [0.02, 0.04]; const [minV, maxV] = mode === "toc" ? [0.04, 0.08] : [0.02, 0.05]; const [minD, maxD] = mode === "toc" ? [20000, 40000] : [30000, 60000]; const speed = minV + Math.random() * (maxV - minV); const angle = Math.random() * Math.PI * 2; const charIdx = Math.floor(Math.random() * RUNES.length); return { x, y, char: RUNES[charIdx]!, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed, opacity: minOp + Math.random() * (maxOp - minOp), dwellMs: minD + Math.random() * (maxD - minD), elapsed: 0, fading: false, }; } export function NoteRunes(props: NoteRunesProps) { let canvas!: HTMLCanvasElement; let raf = 0; let running = false; onMount(() => { const ctx = canvas.getContext("2d"); if (!ctx) return; const W = canvas.width; const H = canvas.height; const count = props.mode === "toc" ? 3 : 2; const runes: RuneState[] = Array.from({ length: count }, () => makeRune(W, H, props.mode)); let last = performance.now(); const FADE_MS = 600; const frame = (now: number) => { const dt = Math.min(now - last, 64); last = now; ctx.clearRect(0, 0, W, H); ctx.font = `${FONT_SIZE}px PeachScript, monospace`; ctx.textBaseline = "alphabetic"; for (let i = 0; i < runes.length; i++) { const r = runes[i]!; r.elapsed += dt; // Move slowly r.x += r.vx * dt; r.y += r.vy * dt; // Clamp to canvas (bounce off edges) if (r.x < 0 || r.x > W) r.vx = -r.vx; if (r.y < 0 || r.y > H) r.vy = -r.vy; let alpha = r.opacity; const remaining = r.dwellMs - r.elapsed; // Fade in over first FADE_MS if (r.elapsed < FADE_MS) { alpha *= r.elapsed / FADE_MS; } // Fade out over last FADE_MS if (remaining < FADE_MS && remaining > 0) { alpha *= remaining / FADE_MS; } // Replace rune when dwell completes if (r.elapsed >= r.dwellMs) { runes[i] = makeRune(W, H, props.mode); continue; } ctx.fillStyle = `rgba(245, 240, 234, ${alpha})`; ctx.fillText(r.char, r.x, r.y); } raf = requestAnimationFrame(frame); }; const start = () => { if (running) return; running = true; last = 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); document.fonts.load(`${FONT_SIZE}px PeachScript`).finally(start); onCleanup(() => { stop(); document.removeEventListener("visibilitychange", onVisibility); }); }); return (