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