jacquardSnapshot

← snapshot

7787 bytes
"use client";

import type * as THREE from "three";
import { useEffect, useRef } from "react";
import type { OrbState, OrbSubject } from "./jackie-orb-svg";
import {
  applyDrift,
  createAttention,
  readThread,
  SUBJECTS,
  TEMPO,
} from "./orb-figure";

/**
 * Jackie as a point cloud.
 *
 * Two and a half thousand points on a Fibonacci sphere, redistributed per
 * subject and displaced by a self-similar field that the voice drives. Points
 * rather than a solid because the figure reads as something assembling itself
 * out of many small facts — which is what she is doing — and because a cloud
 * degrades gracefully: at small sizes it thins rather than turning to mush.
 *
 * Three.js is loaded dynamically so it stays out of the initial bundle and
 * only lands when a figure is actually on screen.
 */

const COUNT = 2600;

/** How each subject redistributes the base sphere. Keeps the taxonomy legible. */
function reshape(
  shape: number,
  x: number,
  y: number,
  z: number,
): [number, number, number] {
  switch (shape) {
    case 1: {
      // The gate: stacked bands, like bars across an opening.
      const banded = Math.round(y * 4) / 4;
      const r = Math.sqrt(Math.max(0, 1 - banded * banded));
      const k = r / Math.max(1e-4, Math.hypot(x, z));
      return [x * k, banded, z * k];
    }
    case 2:
      // Decisions and drafts: two lobes, weight pushed off the equator.
      return [x, Math.sign(y) * Math.abs(y) ** 0.55, z];
    case 3:
      // A snapshot is a flat thing: a disc more than a ball.
      return [x, y * 0.35, z];
    case 4: {
      // The interview never quite closes — an open spiral.
      const a = y * 3.0;
      const c = Math.cos(a);
      const s = Math.sin(a);
      return [x * c - z * s, y, x * s + z * c];
    }
    default:
      return [x, y, z];
  }
}

export function JackieOrbPoints({
  state,
  subject,
  level,
  size,
  onFail,
}: {
  state: OrbState;
  subject: OrbSubject;
  level: number;
  size: number;
  onFail: () => void;
}) {
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  const stateRef = useRef({ state, subject, level });
  stateRef.current = { state, subject, level };

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;
    let cancelled = false;
    let dispose = () => {};

    (async () => {
      let THREE: typeof import("three");
      try {
        THREE = await import("three");
      } catch {
        onFail();
        return;
      }
      if (cancelled) return;

      let renderer: THREE.WebGLRenderer;
      try {
        renderer = new THREE.WebGLRenderer({
          canvas,
          alpha: true,
          antialias: true,
        });
      } catch {
        onFail();
        return;
      }
      renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
      renderer.setSize(size, size, false);

      const scene = new THREE.Scene();
      const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
      const group = new THREE.Group();
      scene.add(group);

      // The unit sphere every subject is a deformation of.
      const golden = Math.PI * (3 - Math.sqrt(5));
      const base = new Float32Array(COUNT * 3);
      for (let i = 0; i < COUNT; i++) {
        const y = 1 - (i / (COUNT - 1)) * 2;
        const r = Math.sqrt(Math.max(0, 1 - y * y));
        const th = golden * i;
        base[i * 3] = Math.cos(th) * r;
        base[i * 3 + 1] = y;
        base[i * 3 + 2] = Math.sin(th) * r;
      }

      const pos = new Float32Array(COUNT * 3);
      pos.set(base);
      const geo = new THREE.BufferGeometry();
      geo.setAttribute("position", new THREE.BufferAttribute(pos, 3));
      const color = new THREE.Color(0x8aa6e0);
      const mat = new THREE.PointsMaterial({
        size: Math.max(0.014, 0.022 * (132 / size)),
        color,
        transparent: true,
        opacity: 0.92,
        blending: THREE.AdditiveBlending,
        depthWrite: false,
      });
      group.add(new THREE.Points(geo, mat));

      const attention = createAttention(canvas);
      const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;

      let raf = 0;
      let clock = 0;
      let last = performance.now();
      let smoothLevel = 0;
      // Which subject the cloud is currently shaped as; re-laying out every
      // frame is wasted work when it only changes when Jackie moves rooms.
      let laidOut = -1;
      const shaped = new Float32Array(COUNT * 3);

      const frame = (now: number) => {
        const dt = Math.min(0.05, (now - last) / 1000);
        last = now;
        const cur = stateRef.current;
        const spec = SUBJECTS[cur.subject];

        if (spec.shape !== laidOut) {
          for (let i = 0; i < COUNT; i++) {
            const [x, y, z] = reshape(
              spec.shape,
              base[i * 3] as number,
              base[i * 3 + 1] as number,
              base[i * 3 + 2] as number,
            );
            shaped[i * 3] = x;
            shaped[i * 3 + 1] = y;
            shaped[i * 3 + 2] = z;
          }
          laidOut = spec.shape;
        }

        // A still frame still reads as a figure; it just stops turning.
        if (!reduced) clock += dt * TEMPO[cur.state];
        smoothLevel += (cur.level - smoothLevel) * Math.min(1, dt * 8);
        attention.step(dt, !!reduced);
        applyDrift(canvas.parentElement, attention.leanX, attention.leanY);

        const f = spec.scale;
        const amp = 0.09 + smoothLevel * 0.30 + attention.attract * 0.04;
        for (let i = 0; i < COUNT; i++) {
          const x = shaped[i * 3] as number;
          const y = shaped[i * 3 + 1] as number;
          const z = shaped[i * 3 + 2] as number;
          // Self-similar displacement, plus an angular fold so the subject's
          // symmetry is visible in the surface and not just its silhouette.
          const w =
            Math.sin(x * f + clock) *
              Math.sin(y * f + clock * 0.8) *
              Math.sin(z * f) +
            0.4 * Math.cos(Math.atan2(z, x) * spec.folds + clock * spec.spin);
          const k = 1 + amp * w;
          pos[i * 3] = x * k;
          pos[i * 3 + 1] = y * k;
          pos[i * 3 + 2] = z * k;
        }
        geo.attributes.position.needsUpdate = true;

        group.rotation.y = clock * spec.spin * 0.5;
        group.rotation.z = -attention.leanX * 0.18;

        // The eye shifts against the pointer and keeps looking at the centre,
        // so the figure turns toward you rather than sliding across the frame.
        const dist = 4.2 - smoothLevel * 0.15 - attention.attract * 0.35;
        camera.position.set(attention.leanX * 0.9, 0.5 - attention.leanY * 0.7, dist);
        camera.lookAt(0, 0, 0);

        const [r, g, b] = readThread(canvas, cur.state === "listening");
        color.setRGB(r, g, b);
        mat.color.copy(color);
        mat.opacity = 0.8 + attention.attract * 0.2;

        renderer.render(scene, camera);
        raf = requestAnimationFrame(frame);
      };
      raf = requestAnimationFrame(frame);

      const onLost = (e: Event) => {
        e.preventDefault();
        cancelAnimationFrame(raf);
        onFail();
      };
      canvas.addEventListener("webglcontextlost", onLost);

      dispose = () => {
        cancelAnimationFrame(raf);
        canvas.removeEventListener("webglcontextlost", onLost);
        attention.dispose();
        geo.dispose();
        mat.dispose();
        renderer.forceContextLoss();
        renderer.dispose();
      };
    })();

    return () => {
      cancelled = true;
      dispose();
    };
  }, [size, onFail]);

  return (
    <canvas
      ref={canvasRef}
      className="jac-orb-canvas"
      style={{ width: size, height: size }}
      aria-hidden="true"
    />
  );
}