/**
* What every one of Jackie's renderers agrees on.
*
* The figure can be drawn as a raymarched solid, a point cloud, or flat SVG,
* but the meaning has to survive the swap: the subject names the figure, the
* state names the colour and the tempo. Keeping the taxonomy and the attention
* model here — rather than once per renderer — is what makes a fallback lose
* depth without losing meaning.
*/
import type { OrbState, OrbSubject } from "./jackie-orb-svg";
export interface SubjectSpec {
/** Angular lobes — how many times the figure repeats around its axis. */
folds: number;
/** Which base solid the deformation is applied to. */
shape: number;
/** Frequency of the self-similar displacement. */
scale: number;
offset: [number, number, number];
/** Turn rate, before state tempo is applied. */
spin: number;
}
/**
* The same taxonomy as the SVG figures, expressed as fold parameters: the gate
* is four-fold and built from bars, the cloth eight-fold and dense, the
* interview an odd three so it never quite closes.
*
* Measured, not guessed: `node tour/shader-probe.mjs --tune` renders each
* subject headlessly and reports what fraction of the frame the raymarch
* actually hits. These are the parameters that land near 26% coverage — enough
* geometry to read as a figure, not so much it becomes a wall.
*/
export const SUBJECTS: Record<OrbSubject, SubjectSpec> = {
lobby: { folds: 3, shape: 0, scale: 2.1, offset: [0.0, 0.0, 0.0], spin: 0.55 },
gate: { folds: 4, shape: 1, scale: 2.6, offset: [1.3, 0.0, 0.6], spin: 0.30 },
file: { folds: 7, shape: 0, scale: 3.0, offset: [0.4, 1.1, 0.0], spin: 0.75 },
decision: { folds: 5, shape: 2, scale: 2.3, offset: [0.9, 0.5, 1.2], spin: 0.62 },
snapshot: { folds: 6, shape: 3, scale: 2.5, offset: [0.0, 0.8, 0.4], spin: 0.48 },
cloth: { folds: 8, shape: 0, scale: 3.4, offset: [1.6, 0.2, 0.9], spin: 0.85 },
interview: { folds: 3, shape: 4, scale: 2.2, offset: [0.7, 1.4, 0.3], spin: 0.40 },
draft: { folds: 5, shape: 2, scale: 2.4, offset: [1.1, 0.6, 0.8], spin: 0.58 },
};
/** Conversational state only changes tempo. */
export const TEMPO: Record<OrbState, number> = {
idle: 0.35,
speaking: 1.0,
listening: 1.35,
thinking: 2.2,
};
/** The provenance thread, read from the live theme rather than hard-coded. */
export function readThread(
el: HTMLElement,
listening: boolean,
): [number, number, number] {
const name = listening ? "--jac-human" : "--jac-agent";
const raw = getComputedStyle(el).getPropertyValue(name).trim();
// Tokens resolve to hex in both themes; fall back to the indigo default.
const m = /^#?([0-9a-f]{6})$/i.exec(raw.replace("#", ""));
const hex = m ? m[1] : "8aa6e0";
return [
Number.parseInt(hex.slice(0, 2), 16) / 255,
Number.parseInt(hex.slice(2, 4), 16) / 255,
Number.parseInt(hex.slice(4, 6), 16) / 255,
];
}
export interface Attention {
/** Advance the spring; call once per frame. */
step: (dt: number, reduced: boolean) => void;
/** -1..1 horizontal and vertical lean toward the current target. */
leanX: number;
leanY: number;
/** 0..1 — how strongly the figure is drawn to what is being hovered. */
attract: number;
dispose: () => void;
}
const INTERACTIVE =
"a, button, .jac-offer, .jac-seen-chip, textarea, select, input";
/**
* The figure watches the pointer and leans toward whatever is being hovered.
* Targets are spring-damped rather than followed directly, so it moves like
* something with mass instead of snapping.
*/
export function createAttention(anchor: HTMLElement): Attention {
let wantLeanX = 0;
let wantLeanY = 0;
let wantAttract = 0;
let leanVX = 0;
let leanVY = 0;
const aim = (x: number, y: number, pull: number) => {
const r = anchor.getBoundingClientRect();
const cx = r.left + r.width / 2;
const cy = r.top + r.height / 2;
// Normalised by a generous radius so distant targets still register a
// direction, just a gentler one.
const reach = Math.max(window.innerWidth, window.innerHeight) * 0.42;
wantLeanX = Math.max(-1, Math.min(1, (x - cx) / reach));
wantLeanY = Math.max(-1, Math.min(1, (y - cy) / reach));
wantAttract = pull;
};
const onPointerMove = (e: PointerEvent) => {
const el = (e.target as Element | null)?.closest?.(INTERACTIVE);
if (el) {
const r = el.getBoundingClientRect();
aim(r.left + r.width / 2, r.top + r.height / 2, 1);
} else {
aim(e.clientX, e.clientY, 0.34);
}
};
const onPointerLeave = () => {
wantLeanX = 0;
wantLeanY = 0;
wantAttract = 0;
};
window.addEventListener("pointermove", onPointerMove, { passive: true });
document.addEventListener("pointerleave", onPointerLeave);
window.addEventListener("blur", onPointerLeave);
const self: Attention = {
leanX: 0,
leanY: 0,
attract: 0,
step(dt, reduced) {
if (reduced) {
self.leanX = wantLeanX;
self.leanY = wantLeanY;
self.attract = wantAttract;
return;
}
// Critically-damped-ish spring toward the current attention target.
const K = 62;
const D = 13;
leanVX += ((wantLeanX - self.leanX) * K - leanVX * D) * dt;
leanVY += ((wantLeanY - self.leanY) * K - leanVY * D) * dt;
self.leanX += leanVX * dt;
self.leanY += leanVY * dt;
self.attract += (wantAttract - self.attract) * Math.min(1, dt * 5);
},
dispose() {
window.removeEventListener("pointermove", onPointerMove);
document.removeEventListener("pointerleave", onPointerLeave);
window.removeEventListener("blur", onPointerLeave);
},
};
return self;
}
/**
* The whole element drifts a few pixels with the lean — the figure gravitates
* bodily toward what you are reaching for, not just optically.
*/
export function applyDrift(host: HTMLElement | null, leanX: number, leanY: number) {
if (!host) return;
host.style.setProperty("--orb-drift-x", `${(leanX * 9).toFixed(2)}px`);
host.style.setProperty("--orb-drift-y", `${(leanY * 7).toFixed(2)}px`);
}