jacquardSnapshot

← snapshot

4460 bytes
"use client";

import { useEffect, useRef } from "react";
import type { LabState } from "./lab-types";

/**
 * Plain 2D canvas with a hand-rolled projection. No GPU, no library — the
 * cheapest option here, and on a low-end machine the only one guaranteed to
 * run at all.
 */
export type CanvasVariantId = "canvas-pointcloud" | "canvas-lissajous";

export function CanvasTile({
  variant,
  size,
  state,
  active,
}: {
  variant: CanvasVariantId;
  size: number;
  state: React.RefObject<LabState>;
  active: boolean;
}) {
  const ref = useRef<HTMLCanvasElement | null>(null);
  const activeRef = useRef(active);
  activeRef.current = active;

  useEffect(() => {
    const canvas = ref.current;
    if (!canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    canvas.width = size * dpr;
    canvas.height = size * dpr;
    ctx.scale(dpr, dpr);

    // A Fibonacci sphere, reused by both variants.
    const N = variant === "canvas-pointcloud" ? 900 : 1400;
    const pts: [number, number, number][] = [];
    const golden = Math.PI * (3 - Math.sqrt(5));
    for (let i = 0; i < N; i++) {
      const y = 1 - (i / (N - 1)) * 2;
      const r = Math.sqrt(Math.max(0, 1 - y * y));
      const th = golden * i;
      pts.push([Math.cos(th) * r, y, Math.sin(th) * r]);
    }

    let raf = 0;
    let clock = 0;
    let last = performance.now();

    const frame = (now: number) => {
      const dt = Math.min(0.05, (now - last) / 1000);
      last = now;
      if (activeRef.current) {
        const s = state.current;
        clock += dt * (s?.tempo ?? 1);
        const level = s?.level ?? 0;
        const [tr, tg, tb] = s?.thread ?? [0.54, 0.65, 0.88];
        const rgb = `${Math.round(tr * 255)}, ${Math.round(tg * 255)}, ${Math.round(tb * 255)}`;

        ctx.clearRect(0, 0, size, size);
        const cx = size / 2 + (s?.leanX ?? 0) * 10;
        const cy = size / 2 + (s?.leanY ?? 0) * 8;
        const R = size * 0.30;
        const ry = clock * 0.4;
        const rx = Math.sin(clock * 0.23) * 0.5;

        const project = (p: [number, number, number], k: number) => {
          let [x, y, z] = p;
          x *= k;
          y *= k;
          z *= k;
          // rotate Y then X
          const x1 = x * Math.cos(ry) - z * Math.sin(ry);
          const z1 = x * Math.sin(ry) + z * Math.cos(ry);
          const y2 = y * Math.cos(rx) - z1 * Math.sin(rx);
          const z2 = y * Math.sin(rx) + z1 * Math.cos(rx);
          const persp = 3.2 / (3.2 + z2);
          return [cx + x1 * R * persp, cy + y2 * R * persp, persp] as const;
        };

        if (variant === "canvas-pointcloud") {
          ctx.globalCompositeOperation = "lighter";
          for (const p of pts) {
            const w =
              Math.sin(p[0] * 3 + clock) *
              Math.sin(p[1] * 3 + clock * 0.8) *
              Math.sin(p[2] * 3);
            const k = 1 + (0.1 + level * 0.3) * w;
            const [x, y, persp] = project(p, k);
            const a = Math.max(0, (persp - 0.62) * 1.5);
            ctx.fillStyle = `rgba(${rgb}, ${(a * 0.75).toFixed(3)})`;
            ctx.beginPath();
            ctx.arc(x, y, Math.max(0.4, persp * 1.5), 0, Math.PI * 2);
            ctx.fill();
          }
          ctx.globalCompositeOperation = "source-over";
        } else {
          // A 3D Lissajous knot traced as a continuous ribbon.
          ctx.globalCompositeOperation = "lighter";
          ctx.lineWidth = 1.1;
          ctx.beginPath();
          const a = 3;
          const b = 4 + Math.sin(clock * 0.15);
          const c = 5;
          for (let i = 0; i <= 900; i++) {
            const t = (i / 900) * Math.PI * 2;
            const p: [number, number, number] = [
              Math.sin(a * t + clock * 0.4),
              Math.sin(b * t),
              Math.sin(c * t + clock * 0.2),
            ];
            const [x, y] = project(p, 0.95 + level * 0.2);
            if (i === 0) ctx.moveTo(x, y);
            else ctx.lineTo(x, y);
          }
          ctx.strokeStyle = `rgba(${rgb}, 0.55)`;
          ctx.stroke();
          ctx.globalCompositeOperation = "source-over";
        }
      }
      raf = requestAnimationFrame(frame);
    };
    raf = requestAnimationFrame(frame);
    return () => cancelAnimationFrame(raf);
  }, [variant, size, state]);

  return <canvas ref={ref} style={{ width: size, height: size, display: "block" }} />;
}