jacquardSnapshot

← snapshot

6123 bytes
/**
 * 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`);
}