← 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" }} />;
}