jacquardSnapshot

← 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>
  );
}