"use client"; import type { CSSProperties } from "react"; import styles from "./Orb.module.css"; /** The stage the geometry is tuned on; --orb-k scales it to `size`. */ const STAGE = 28; /** Default rendered size - 20×20 indicator box. */ const SIZE = 20; export type LatticeVariant = "S1" | "S2" | "S3" | "S4" | "S5"; export type LensVariant = "B1" | "B2" | "B3" | "B4" | "B5"; export type RingVariant = "C1" | "C2" | "C3" | "C4" | "C5"; export type HelixVariant = "G1" | "G2" | "G3" | "G4" | "G5"; export type MorphVariant = "M1" | "M2" | "M3" | "M4" | "M5"; export type OrbVariant = LatticeVariant | LensVariant | RingVariant | HelixVariant | MorphVariant; export const LATTICE_VARIANTS: LatticeVariant[] = ["S1", "S2", "S3", "S4", "S5"]; export const LENS_VARIANTS: LensVariant[] = [ "B1", "B2", "B3", "B4", "B5", ]; export const RING_VARIANTS: RingVariant[] = ["C1", "C2", "C3", "C4", "C5"]; export const HELIX_VARIANTS: HelixVariant[] = ["G1", "G2", "G3", "G4", "G5"]; export const MORPH_VARIANTS: MorphVariant[] = ["M1", "M2", "M3", "M4", "M5"]; export const ORB_TASKS: Record = { S1: "Thinking", S2: "Processing", S3: "Working", S4: "Searching", S5: "Finalizing", B1: "Thinking", B2: "Searching", B3: "Generating", B4: "Solving", B5: "Routing", C1: "Loading", C2: "Listening", C3: "Streaming", C4: "Analyzing", C5: "Compiling", G1: "Processing", G2: "Sequencing", G3: "Uploading", G4: "Syncing", G5: "Idling", M1: "Shaping", M2: "Expanding", M3: "Unfolding", M4: "Transforming", M5: "Dispersing", }; function isLattice(v: OrbVariant): v is LatticeVariant { return (LATTICE_VARIANTS as OrbVariant[]).includes(v); } function isRing(v: OrbVariant): v is RingVariant { return (RING_VARIANTS as OrbVariant[]).includes(v); } function isHelix(v: OrbVariant): v is HelixVariant { return (HELIX_VARIANTS as OrbVariant[]).includes(v); } function isMorph(v: OrbVariant): v is MorphVariant { return (MORPH_VARIANTS as OrbVariant[]).includes(v); } const N = 3; // lattice is N×N const PITCH = 6; // centre-to-centre spacing in stage px; the dot size is CSS const MID = (N - 1) / 2; /** Clockwise walk of the lattice perimeter - the track `orbit` runs on. */ const RING: [number, number][] = (() => { const ring: [number, number][] = []; for (let x = 0; x < N; x++) ring.push([x, 0]); for (let y = 1; y < N; y++) ring.push([N - 1, y]); for (let x = N - 2; x >= 0; x--) ring.push([x, N - 1]); for (let y = N - 2; y >= 1; y--) ring.push([0, y]); return ring; })(); const RING_INDEX = new Map(RING.map(([x, y], i) => [x + "," + y, i])); /** * Per-cell `animation-delay` in ms. Negative values seed a cell partway * into its cycle, which is what turns 8 identical animations into one * comet travelling the ring. */ function cellDelay(v: LatticeVariant, x: number, y: number): number { const dx = x - MID; const dy = y - MID; const ring = Math.max(Math.abs(dx), Math.abs(dy)); switch (v) { // Radiates from the centre on a round wavefront. Centre leads a beat // early so the next swell doesn't sit behind the outer fade. case "S1": return Math.hypot(dx, dy) * 700 - (dx === 0 && dy === 0 ? 180 : 0); // A broad band crosses the grid on the diagonal. The spread is close to // the wave duration, which both widens the band and makes the sweep // continuous - the far corner restarts as the near one does. case "S2": return ((x + y) / (2 * (N - 1))) * 1500; // One head with a decaying tail, running the perimeter clockwise. case "S3": { const i = RING_INDEX.get(x + "," + y); if (i === undefined) return 0; return -(((RING.length - i) % RING.length) / RING.length) * 1700; } // A soft column travels left to right. case "S4": return (x / (N - 1)) * 1100; // Like S3 but scrambled order - the pulse jumps pseudo-randomly. case "S5": { const i = RING_INDEX.get(x + "," + y); if (i === undefined) return 0; const scrambled = (i * 3) % RING.length; return -(scrambled / RING.length) * 1700; } } } /** * `settle` gathers each cell from a position rotated one way around the * centre and releases it to the mirror rotation, so the cycle keeps swirling * the same way instead of rewinding to where it came from. */ const SWIRL = 1.05; // radians of rotation at each end, ~60° const SPREAD = 1.6; // outward push, on top of the rotation /** Offset from a cell's own grid slot to its swirled position, in stage px. */ function swirl(x: number, y: number, angle: number): [number, number] { const dx = x - MID; const dy = y - MID; const cos = Math.cos(angle); const sin = Math.sin(angle); return [ ((dx * cos - dy * sin) * SPREAD - dx) * PITCH, ((dx * sin + dy * cos) * SPREAD - dy) * PITCH, ]; } interface Cell { key: string; left: number; top: number; delay: number; /** Where `settle` gathers this cell from, and releases it to. */ ax: number; ay: number; bx: number; by: number; /** Sits out the choreography (interior cells during `orbit`). */ still: boolean; /** Centre cell - the static frame under reduced motion. */ mid: boolean; } /** The 9 lattice cells, with position, phase and swirl vectors. */ function latticeCells(v: LatticeVariant): Cell[] { const cells: Cell[] = []; for (let y = 0; y < N; y++) { for (let x = 0; x < N; x++) { const [ax, ay] = swirl(x, y, -SWIRL); const [bx, by] = swirl(x, y, SWIRL); cells.push({ key: x + "," + y, left: x * PITCH, top: y * PITCH, delay: cellDelay(v, x, y), ax, ay, bx, by, still: (v === "S3" || v === "S5") && !RING_INDEX.has(x + "," + y), mid: x === MID && y === MID, }); } } return cells; } const RING_N = 8; const RING_R = 8; interface RingDot { key: number; rx: number; ry: number; delay: number; } function ringDuration(v: RingVariant): number { switch (v) { case "C1": return 1600; case "C2": return 2000; case "C3": return 1800; case "C4": return 1600; case "C5": return 2200; } } function ringDelay(v: RingVariant, i: number): number { const dur = ringDuration(v); switch (v) { case "C1": return -((RING_N - 1 - i) / RING_N) * dur; case "C2": case "C3": return -((RING_N - 1 - i) / RING_N) * dur; case "C4": return i % 2 === 0 ? 0 : -(dur / 2); case "C5": { const scrambled = (i * 3) % RING_N; return -(scrambled / RING_N) * dur; } default: return -(i / RING_N) * dur; } } function ringDots(v: RingVariant): RingDot[] { const dots: RingDot[] = []; for (let i = 0; i < RING_N; i++) { const angle = (i / RING_N) * Math.PI * 2 - Math.PI / 2; dots.push({ key: i, rx: Math.cos(angle) * RING_R, ry: Math.sin(angle) * RING_R, delay: ringDelay(v, i), }); } return dots; } const GLOBE_R = 8.5; const GLOBE_TILT = (14 * Math.PI) / 180; const GLOBE_STEPS = 8; const GLOBE_RINGS: { lat: number; count: number }[] = [ { lat: 52, count: 8 }, { lat: 26, count: 8 }, { lat: 0, count: 8 }, { lat: -26, count: 8 }, { lat: -52, count: 8 }, ]; interface GlobeDot { key: number; style: Record; css: string; } function projectGlobe(x: number, y: number, z: number, spin: number) { const cs = Math.cos(spin); const ss = Math.sin(spin); const x1 = x * cs - z * ss; const z1 = x * ss + z * cs; const y1 = y; const ct = Math.cos(GLOBE_TILT); const st = Math.sin(GLOBE_TILT); return { x: x1, y: y1 * ct - z1 * st, z: y1 * st + z1 * ct, }; } function globeOpacity(z: number) { const t = Math.max(0, Math.min(1, (z / GLOBE_R + 0.15) / 1.15)); return 0.12 + 0.88 * t * t; } type RingMove = { ring: number; angle: number }; const RING_HALF = Math.PI; const RING_ARC = 3; function ringDir(ring: number) { return ring % 2 === 0 ? -1 : 1; } const G3_MOVES: RingMove[] = (() => { const moves: RingMove[] = []; for (let pass = 0; pass < 2; pass++) { for (let r = 0; r < GLOBE_RINGS.length; r++) { moves.push({ ring: r, angle: ringDir(r) * RING_HALF }); } } return moves; })(); const G4_MOVES: RingMove[] = [2, 1, 3, 0, 4, 2, 1, 3, 0, 4].map((ring) => ({ ring, angle: ringDir(ring) * RING_HALF, })); function ringTurnPoses( x0: number, y0: number, z0: number, ringIndex: number, moves: RingMove[], ): [number, number, number][] { let x = x0; let y = y0; let z = z0; const poses: [number, number, number][] = [[x, y, z]]; for (let m = 0; m < moves.length; m++) { const move = moves[m]; const xS = x; const yS = y; const zS = z; for (let s = 1; s <= RING_ARC; s++) { if (ringIndex === move.ring) { const a = move.angle * (s / RING_ARC); const c = Math.cos(a); const sn = Math.sin(a); x = xS * c - zS * sn; y = yS; z = xS * sn + zS * c; } poses.push([x, y, z]); } } return poses; } const G5_SLOW = 0.4; const G5_BURST = (Math.PI * 2 - G5_SLOW * 4) / 4; const G5_POSES: { s: number; spin: number }[] = (() => { const poses: { s: number; spin: number }[] = [{ s: 1.0, spin: 0 }]; let spin = 0; const steps: { s: number; kind: "slow" | "burst" }[] = [ { s: 1.0, kind: "slow" }, { s: 0.9, kind: "burst" }, { s: 0.9, kind: "slow" }, { s: 0.8, kind: "burst" }, { s: 0.8, kind: "slow" }, { s: 0.9, kind: "burst" }, { s: 0.9, kind: "slow" }, { s: 1.0, kind: "burst" }, ]; for (const step of steps) { spin += step.kind === "slow" ? G5_SLOW : G5_BURST; poses.push({ s: step.s, spin }); } return poses; })(); function globeKeyframeStyle( x0: number, y0: number, z0: number, variant: HelixVariant, ringIndex: number, j = 0, ): Record { const style: Record = {}; if (variant === "G5") { for (let k = 0; k < G5_POSES.length; k++) { const sc = G5_POSES[k].s; const spin = G5_POSES[k].spin; const p = projectGlobe(x0 * sc, y0 * sc, z0 * sc, spin); style["--g" + k + "x"] = p.x.toFixed(2) + "px"; style["--g" + k + "y"] = (-p.y).toFixed(2) + "px"; style["--g" + k + "o"] = globeOpacity(p.z).toFixed(3); } return style; } if (variant === "G3" || variant === "G4") { const poses = ringTurnPoses( x0, y0, z0, ringIndex, variant === "G3" ? G3_MOVES : G4_MOVES, ); for (let k = 0; k < poses.length; k++) { const pos = poses[k]; const p = projectGlobe(pos[0], pos[1], pos[2], 0); style["--g" + k + "x"] = p.x.toFixed(2) + "px"; style["--g" + k + "y"] = (-p.y).toFixed(2) + "px"; style["--g" + k + "o"] = globeOpacity(p.z).toFixed(3); } return style; } const dir = variant === "G2" && ringIndex % 2 === 1 ? -1 : 1; for (let k = 0; k < GLOBE_STEPS; k++) { const phase = k / GLOBE_STEPS; const spin = dir * phase * Math.PI * 2; const p = projectGlobe(x0, y0, z0, spin); style["--g" + k + "x"] = p.x.toFixed(2) + "px"; style["--g" + k + "y"] = (-p.y).toFixed(2) + "px"; style["--g" + k + "o"] = globeOpacity(p.z).toFixed(3); } return style; } function globeDots(v: HelixVariant): GlobeDot[] { const dots: GlobeDot[] = []; let idx = 0; for (let ringIndex = 0; ringIndex < GLOBE_RINGS.length; ringIndex++) { const ring = GLOBE_RINGS[ringIndex]; const latRad = (ring.lat * Math.PI) / 180; const y0 = Math.sin(latRad) * GLOBE_R; const ringR = Math.cos(latRad) * GLOBE_R; for (let j = 0; j < ring.count; j++) { const lon = (j / ring.count) * Math.PI * 2; const style = globeKeyframeStyle( Math.cos(lon) * ringR, y0, Math.sin(lon) * ringR, v, ringIndex, j, ); dots.push({ key: idx, style, css: Object.keys(style) .map((k) => k + ":" + style[k]) .join(";"), }); idx++; } } return dots; } const MORPH_N = 8; const MORPH_R = 7; type ShapeFn = (i: number) => [number, number]; const shapeCircle: ShapeFn = (i) => { const a = (i / MORPH_N) * Math.PI * 2 - Math.PI / 2; return [Math.cos(a) * MORPH_R, Math.sin(a) * MORPH_R]; }; const shapeOctagon: ShapeFn = (i) => { const a = (i / MORPH_N) * Math.PI * 2 - Math.PI / 2; const r = MORPH_R * 0.92; const sector = Math.round(a / (Math.PI / 4)) * (Math.PI / 4); return [Math.cos(sector) * r, Math.sin(sector) * r]; }; const shapeSquare: ShapeFn = (i) => { const h = MORPH_R * 0.85; const corners: [number, number][] = [[-h, -h], [h, -h], [h, h], [-h, h]]; const t = ((i / MORPH_N) * 4 + 0.5) % 4; const side = Math.floor(t) % 4; const frac = t - Math.floor(t); const from = corners[side]; const to = corners[(side + 1) % 4]; return [from[0] + (to[0] - from[0]) * frac, from[1] + (to[1] - from[1]) * frac]; }; const shapeCircleAt = (turn: number): ShapeFn => (i) => { const a = (i / MORPH_N) * Math.PI * 2 - Math.PI / 2 + turn; return [Math.cos(a) * MORPH_R, Math.sin(a) * MORPH_R]; }; const SCATTER_TRAIL = 0.12; const shapeScatterA: ShapeFn = (i) => { const a = (i / MORPH_N) * Math.PI * 2 - Math.PI / 2; return [-Math.cos(a) * MORPH_R, Math.sin(a) * MORPH_R]; }; const shapeScatterB: ShapeFn = shapeCircle; const shapeScatterC: ShapeFn = shapeScatterA; const shapeDiamond: ShapeFn = (i) => { const corners: [number, number][] = [[0, -MORPH_R], [MORPH_R, 0], [0, MORPH_R], [-MORPH_R, 0]]; const t = (i / MORPH_N) * 4; const side = Math.floor(t) % 4; const frac = t - Math.floor(t); const from = corners[side]; const to = corners[(side + 1) % 4]; return [from[0] + (to[0] - from[0]) * frac, from[1] + (to[1] - from[1]) * frac]; }; const shapeCenter: ShapeFn = (i) => { const a = (i / MORPH_N) * Math.PI * 2 - Math.PI / 2; return [Math.cos(a) * 1.5, Math.sin(a) * 1.5]; }; function morphShapes(v: MorphVariant): [ShapeFn, ShapeFn, ShapeFn, ShapeFn] { switch (v) { case "M1": return [shapeCircle, shapeSquare, shapeDiamond, shapeSquare]; case "M2": return [shapeCenter, shapeCircle, shapeCenter, shapeCircle]; case "M3": return [ shapeCircleAt(0), shapeCircleAt(Math.PI / 2), shapeCircleAt(Math.PI), shapeCircleAt(Math.PI * 1.5), ]; case "M4": return [shapeCircle, shapeDiamond, shapeCircle, shapeDiamond]; case "M5": return [shapeCircle, shapeScatterA, shapeScatterB, shapeScatterC]; } } interface MorphDot { key: number; m1: string; m2: string; m3: string; m4: string; delay?: string; depth?: string; } function morphDots(v: MorphVariant): MorphDot[] { const [s1, s2, s3, s4] = morphShapes(v); const dots: MorphDot[] = []; for (let i = 0; i < MORPH_N; i++) { const [x1, y1] = s1(i); const [x2, y2] = s2(i); const [x3, y3] = s3(i); const [x4, y4] = s4(i); dots.push({ key: i, m1: x1.toFixed(1) + "px, " + y1.toFixed(1) + "px", m2: x2.toFixed(1) + "px, " + y2.toFixed(1) + "px", m3: x3.toFixed(1) + "px, " + y3.toFixed(1) + "px", m4: x4.toFixed(1) + "px, " + y4.toFixed(1) + "px", delay: v === "M5" ? -i * 10 + "ms" : undefined, depth: v === "M5" ? Math.abs(Math.cos((i / MORPH_N) * Math.PI * 2 - Math.PI / 2)).toFixed(2) : undefined, }); } return dots; } export interface OrbProps { variant?: OrbVariant; /** Rendered edge length in px. The 28px geometry scales to fit. */ size?: number; /** Accessible label, and the status text when `pill` is set. */ label?: string; /** Wraps the orb and its label in a status pill. */ pill?: boolean; className?: string; style?: CSSProperties; } export function Orb({ variant = "S1", size = SIZE, label, pill, className, style, }: OrbProps) { const text = label ?? ORB_TASKS[variant] + "…"; return ( {isLattice(variant) ? ( {latticeCells(variant).map((c) => ( ))} ) : isRing(variant) ? ( {ringDots(variant).map((d) => ( ))} ) : isHelix(variant) ? ( {globeDots(variant).map((d) => ( ))} ) : isMorph(variant) ? ( {morphDots(variant).map((d) => ( ))} ) : ( {/* focus is the one variant that needs a fourth circle: its cast sits on the corners of a square, and three corners do not make a square. */} {variant === "B1" && ( )} )} {pill && {text}} ); } /* Usage: */