1import { onCleanup, onMount } from "solid-js";23interface NoteRunesProps {4 mode: "toc" | "editor";5}67const FONT_SIZE = 13;8const RUNE_LEN = 120;910function buildRuneString(): string {11 const out: string[] = [];12 for (let i = 0; i < RUNE_LEN; i++) {13 out.push(String.fromCodePoint(0xe000 + ((i * 41 + 7) & 0xff)));14 }15 return out.join("");16}1718const RUNES = buildRuneString();1920interface RuneState {21 x: number;22 y: number;23 char: string;24 vx: number;25 vy: number;26 opacity: number;27 dwellMs: number;28 elapsed: number;29 fading: boolean;30}3132function makeRune(w: number, h: number, mode: "toc" | "editor"): RuneState {33 34 const MARGIN = 40;35 const side = Math.random() < 0.5 ? "left" : "right";36 const x = side === "left"37 ? Math.random() * MARGIN38 : w - MARGIN + Math.random() * MARGIN;39 const y = Math.random() * h;4041 const [minOp, maxOp] = mode === "toc" ? [0.03, 0.06] : [0.02, 0.04];42 const [minV, maxV] = mode === "toc" ? [0.04, 0.08] : [0.02, 0.05];43 const [minD, maxD] = mode === "toc" ? [20000, 40000] : [30000, 60000];4445 const speed = minV + Math.random() * (maxV - minV);46 const angle = Math.random() * Math.PI * 2;47 const charIdx = Math.floor(Math.random() * RUNES.length);4849 return {50 x,51 y,52 char: RUNES[charIdx]!,53 vx: Math.cos(angle) * speed,54 vy: Math.sin(angle) * speed,55 opacity: minOp + Math.random() * (maxOp - minOp),56 dwellMs: minD + Math.random() * (maxD - minD),57 elapsed: 0,58 fading: false,59 };60}6162export function NoteRunes(props: NoteRunesProps) {63 let canvas!: HTMLCanvasElement;64 let raf = 0;65 let running = false;6667 onMount(() => {68 const ctx = canvas.getContext("2d");69 if (!ctx) return;7071 const W = canvas.width;72 const H = canvas.height;73 const count = props.mode === "toc" ? 3 : 2;74 const runes: RuneState[] = Array.from({ length: count }, () => makeRune(W, H, props.mode));7576 let last = performance.now();77 const FADE_MS = 600;7879 const frame = (now: number) => {80 const dt = Math.min(now - last, 64);81 last = now;8283 ctx.clearRect(0, 0, W, H);84 ctx.font = `${FONT_SIZE}px PeachScript, monospace`;85 ctx.textBaseline = "alphabetic";8687 for (let i = 0; i < runes.length; i++) {88 const r = runes[i]!;89 r.elapsed += dt;9091 92 r.x += r.vx * dt;93 r.y += r.vy * dt;9495 96 if (r.x < 0 || r.x > W) r.vx = -r.vx;97 if (r.y < 0 || r.y > H) r.vy = -r.vy;9899 let alpha = r.opacity;100 const remaining = r.dwellMs - r.elapsed;101102 103 if (r.elapsed < FADE_MS) {104 alpha *= r.elapsed / FADE_MS;105 }106 107 if (remaining < FADE_MS && remaining > 0) {108 alpha *= remaining / FADE_MS;109 }110111 112 if (r.elapsed >= r.dwellMs) {113 runes[i] = makeRune(W, H, props.mode);114 continue;115 }116117 ctx.fillStyle = `rgba(245, 240, 234, ${alpha})`;118 ctx.fillText(r.char, r.x, r.y);119 }120121 raf = requestAnimationFrame(frame);122 };123124 const start = () => {125 if (running) return;126 running = true;127 last = performance.now();128 raf = requestAnimationFrame(frame);129 };130131 const stop = () => {132 if (!running) return;133 running = false;134 cancelAnimationFrame(raf);135 };136137 const onVisibility = () => {138 if (document.hidden) stop(); else start();139 };140 document.addEventListener("visibilitychange", onVisibility);141142 document.fonts.load(`${FONT_SIZE}px PeachScript`).finally(start);143144 onCleanup(() => {145 stop();146 document.removeEventListener("visibilitychange", onVisibility);147 });148 });149150 return (151 <canvas152 ref={canvas}153 width={400}154 height={700}155 class="peach-note-rune-canvas"156 aria-hidden="true"157 />158 );159}