NoteRunes.tsx

3.9 KB · raw

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  // Edge-biased placement: runes stay in margins34  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        // Move slowly92        r.x += r.vx * dt;93        r.y += r.vy * dt;9495        // Clamp to canvas (bounce off edges)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        // Fade in over first FADE_MS103        if (r.elapsed < FADE_MS) {104          alpha *= r.elapsed / FADE_MS;105        }106        // Fade out over last FADE_MS107        if (remaining < FADE_MS && remaining > 0) {108          alpha *= remaining / FADE_MS;109        }110111        // Replace rune when dwell completes112        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}