← snapshot
9720 bytes
"use client";
import Link from "next/link";
import { useCallback, useEffect, useRef, useState } from "react";
import { GLSL_VARIANTS } from "@/lib/lab/glsl-variants";
import { CanvasTile, type CanvasVariantId } from "./canvas-tile";
import { CssTile, type CssVariantId } from "./css-tile";
import { GlslTile } from "./glsl-tile";
import { LabStage } from "./lab-stage";
import type { LabEngine, LabState } from "./lab-types";
import { ThreeTile, type ThreeVariantId } from "./three-tile";
interface Entry {
id: string;
label: string;
blurb: string;
engine: LabEngine;
}
const NON_GLSL: Entry[] = [
{
id: "three-points",
label: "Point cloud",
blurb: "Three.js — 2,600 points on a Fibonacci sphere, displaced by voice.",
engine: "three",
},
{
id: "three-wire-ico",
label: "Wire icosahedron",
blurb: "Three.js — subdivided wireframe over a faint solid.",
engine: "three",
},
{
id: "three-ribbons",
label: "Orbiting tori",
blurb: "Three.js — four additive rings on independent axes.",
engine: "three",
},
{
id: "three-shell",
label: "Displaced shell",
blurb: "Three.js — lit, flat-shaded mesh deforming over a bright core.",
engine: "three",
},
{
id: "canvas-pointcloud",
label: "2D point cloud",
blurb: "Canvas 2D — hand-rolled projection, additive blending. No GPU.",
engine: "canvas",
},
{
id: "canvas-lissajous",
label: "Lissajous knot",
blurb: "Canvas 2D — a 3D knot traced as one continuous stroke.",
engine: "canvas",
},
{
id: "css-armillary",
label: "CSS armillary",
blurb: "Pure CSS 3D transforms — no canvas at all, free at any DPI.",
engine: "css",
},
];
const ENTRIES: Entry[] = [
...GLSL_VARIANTS.map((v) => ({
id: v.id,
label: v.label,
blurb: v.blurb,
engine: "glsl" as const,
})),
...NON_GLSL,
];
const ENGINE_LABEL: Record<LabEngine, string> = {
glsl: "WebGL2 · raymarched SDF",
three: "Three.js · meshes & points",
canvas: "Canvas 2D · CPU",
css: "CSS 3D · no canvas",
};
export function LabGallery() {
const [size, setSize] = useState(210);
const [speaking, setSpeaking] = useState(true);
const [listening, setListening] = useState(false);
const [focused, setFocused] = useState<string | null>(null);
const [visible, setVisible] = useState<ReadonlySet<string>>(() => new Set());
const onVisibility = useCallback((id: string, on: boolean) => {
setVisible((prev) => {
if (prev.has(id) === on) return prev;
const next = new Set(prev);
if (on) next.add(id);
else next.delete(id);
return next;
});
}, []);
// Every tile stays mounted — the shader pool means they are cheap to keep —
// but only the ones you can see are animated.
const isLive = useCallback((id: string) => visible.has(id), [visible]);
const state = useRef<LabState>({
level: 0,
attract: 0,
leanX: 0,
leanY: 0,
tempo: 1,
thread: [0.54, 0.65, 0.88],
});
// One simulated voice level drives every tile, so they can be compared
// under identical conditions rather than each doing its own thing.
useEffect(() => {
let raf = 0;
let t = 0;
let last = performance.now();
const loop = (now: number) => {
const dt = Math.min(0.05, (now - last) / 1000);
last = now;
t += dt;
const s = state.current;
const talking = speaking || listening;
const target = talking
? 0.45 + 0.45 * Math.abs(Math.sin(t * 2.1) * Math.sin(t * 0.7))
: 0;
s.level += (target - s.level) * Math.min(1, dt * 6);
s.tempo = listening ? 1.35 : speaking ? 1 : 0.35;
s.thread = listening ? [0.89, 0.75, 0.43] : [0.54, 0.65, 0.88];
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, [speaking, listening]);
// Pointer lean, shared by all tiles.
useEffect(() => {
const onMove = (e: PointerEvent) => {
const s = state.current;
const reach = Math.max(window.innerWidth, window.innerHeight) * 0.42;
s.leanX = Math.max(-1, Math.min(1, (e.clientX - window.innerWidth / 2) / reach));
s.leanY = Math.max(-1, Math.min(1, (e.clientY - window.innerHeight / 2) / reach));
const el = (e.target as Element | null)?.closest?.(".jac-lab-tile");
s.attract = el ? 1 : 0.3;
};
window.addEventListener("pointermove", onMove, { passive: true });
return () => window.removeEventListener("pointermove", onMove);
}, []);
const render = (e: Entry, px: number, active: boolean) => {
if (e.engine === "glsl") {
const v = GLSL_VARIANTS.find((x) => x.id === e.id);
if (!v) return null;
return <GlslTile variant={v} size={px} state={state} active={active} />;
}
if (e.engine === "three") {
return (
<ThreeTile
variant={e.id as ThreeVariantId}
size={px}
state={state}
active={active}
/>
);
}
if (e.engine === "canvas") {
return (
<CanvasTile
variant={e.id as CanvasVariantId}
size={px}
state={state}
active={active}
/>
);
}
return (
<CssTile
variant={e.id as CssVariantId}
size={px}
state={state}
active={active}
/>
);
};
const focusedEntry = ENTRIES.find((e) => e.id === focused) ?? null;
return (
<div className="jac-page jac-page--wide">
<div className="jac-row-between">
<div>
<p className="jac-eyebrow">Orb lab · {ENTRIES.length} options</p>
<h1 className="jac-h1">Pick a figure</h1>
<p className="jac-lede">
Every tile is live and driven by the same simulated voice, so they
can be compared under identical conditions. Four rendering
approaches are represented — raymarched shaders, Three.js meshes,
plain 2D canvas, and pure CSS — because the cheapest option that
looks right is the one worth shipping.
</p>
</div>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<div className="jac-seg">
<button
type="button"
data-on={speaking && !listening}
onClick={() => {
setSpeaking(true);
setListening(false);
}}
>
speaking
</button>
<button
type="button"
data-on={listening}
onClick={() => {
setListening(true);
setSpeaking(false);
}}
>
listening
</button>
<button
type="button"
data-on={!speaking && !listening}
onClick={() => {
setSpeaking(false);
setListening(false);
}}
>
idle
</button>
</div>
<div className="jac-seg">
{[170, 210, 280].map((n) => (
<button
key={n}
type="button"
data-on={size === n}
onClick={() => setSize(n)}
>
{n}px
</button>
))}
</div>
</div>
</div>
<p className="jac-small" style={{ marginBottom: 14 }}>
Colour is not decorative: indigo is the agent thread, gold the human
one. Switch to <em>listening</em> to see every figure change hands.
</p>
<div
className="jac-lab-grid"
style={{ gridTemplateColumns: `repeat(auto-fill, minmax(${size + 34}px, 1fr))` }}
>
{ENTRIES.map((e, i) => (
<button
key={e.id}
type="button"
className="jac-lab-tile"
data-focused={focused === e.id}
onClick={() => setFocused(focused === e.id ? null : e.id)}
>
<LabStage id={e.id} size={size} onVisibility={onVisibility}>
{render(e, size, isLive(e.id))}
</LabStage>
<div className="jac-lab-meta">
<span className="jac-lab-n">{String(i + 1).padStart(2, "0")}</span>
<span className="jac-lab-label">{e.label}</span>
<span className="jac-lab-engine">{ENGINE_LABEL[e.engine]}</span>
<span className="jac-lab-blurb">{e.blurb}</span>
</div>
</button>
))}
</div>
{focusedEntry ? (
<div className="jac-lab-focus">
<div className="jac-lab-focus-inner">
<div className="jac-row-between" style={{ marginBottom: 10 }}>
<div>
<p className="jac-eyebrow">{ENGINE_LABEL[focusedEntry.engine]}</p>
<h2 className="jac-h2">{focusedEntry.label}</h2>
<p className="jac-small" style={{ marginTop: 6 }}>
{focusedEntry.blurb}
</p>
</div>
<button
type="button"
className="jac-btn"
onClick={() => setFocused(null)}
>
Close
</button>
</div>
<div style={{ display: "grid", placeItems: "center", padding: "10px 0" }}>
{render(focusedEntry, 420, true)}
</div>
<p className="jac-small jac-mono">id: {focusedEntry.id}</p>
</div>
</div>
) : null}
<p className="jac-small" style={{ marginTop: 22 }}>
<Link href="/">← back to the floor</Link>
</p>
</div>
);
}