shadimage
spaceui
componentcomponents-orb-thinkingsource: ok · hit

Thinking Orb

Interactive Canvas 3D particle loader orbs featuring 9 procedural cognitive states.

npx shadcn add "https://www.spaceui.one/r/components-orb-thinking.json"
free

Where it goes

statsblog-list

Visual style

3dparticle

Interaction

animated-counter

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Files16 · source cached 10/15/2026, 1:35:38 AM

src/registry/components/orb/thinking/index.tsx
'use client'

import * as React from 'react'
import { BaseThinkingOrb } from './base-orb'
import { OrbBreathing } from './orb-breathing'
import { OrbComposing } from './orb-composing'
import { OrbConnecting } from './orb-connecting'
import { OrbListening } from './orb-listening'
import { OrbSearching } from './orb-searching'
import { OrbShaping } from './orb-shaping'
import { OrbSolving } from './orb-solving'
import { OrbWeaving } from './orb-weaving'
import { OrbWorking } from './orb-working'
import type { ThinkingOrbDescriptor, ThinkingOrbMode, ThinkingOrbProps, ThinkingOrbVariant } from './types'

export * from './types'
export {
  BaseThinkingOrb,
  OrbBreathing,
  OrbComposing,
  OrbConnecting,
  OrbListening,
  OrbSearching,
  OrbShaping,
  OrbSolving,
  OrbWeaving,
  OrbWorking,
}

export const THINKING_ORBS: ThinkingOrbDescriptor[] = [
  {
    id: 'orb-working',
    name: 'Working Orb',
    variant: 'working',
    mode: 'orbits',
    caption: 'Working',
    frame: [240, 240],
    summary: 'Particles tracing tilted orbital rings around a ghosted sphere.',
  },
  {
    id: 'orb-searching',
    name: 'Searching Orb',
    variant: 'searching',
    mode: 'globe',
    caption: 'Searching',
    frame: [240, 240],
    summary: 'A dotted globe swept by a rotating scan band.',
  },
  {
    id: 'orb-solving',
    name: 'Solving Orb',
    variant: 'solving',
    mode: 'rubik',
    caption: 'Solving',
    frame: [240, 240],
    summary: 'A point-cloud sphere permuted by successive quarter-turn slice rotations.',
  },
  {
    id: 'orb-listening',
    name: 'Listening Orb',
    variant: 'listening',
    mode: 'wave',
    caption: 'Listening',
    frame: [240, 240],
    summary: 'Latitude rings breathing in and out on two detuned sine waves.',
  },
  {
    id: 'orb-connecting',
    name: 'Connecting Orb',
    variant: 'connecting',
    mode: 'web',
    caption: 'Connecting',
    frame: [240, 240],
    summary: 'A drifting node graph with proximity links and travelling signals.',
  },
  {
    id: 'orb-weaving',
    name: 'Weaving Orb',
    variant: 'weaving',
    mode: 'braid',
    caption: 'Weaving',
    frame: [240, 240],
    summary: 'Three helical strands braiding around a ghosted sphere.',
  },
  {
    id: 'orb-composing',
    name: 'Composing Orb',
    variant: 'composing',
    mode: 'ribbon',
    caption: 'Composing',
    frame: [240, 240],
    summary: 'A rippling multi-lane ribbon wrapped over a sphere.',
  },
  {
    id: 'orb-breathing',
    name: 'Breathing Orb',
    variant: 'breathing',
    mode: 'ring',
    caption: 'Thinking',
    frame: [240, 240],
    summary: 'A face-on ring pulsing with a slow radial ripple.',
  },
  {
    id: 'orb-shaping',
    name: 'Shaping Orb',
    variant: 'shaping',
    mode: 'morph',
    caption: 'Shaping',
    frame: [240, 240],
    summary: 'A dotted outline morphing between circle, triangle and square.',
  },
]

export const VARIANT_TO_MODE_MAP: Record<ThinkingOrbVariant | ThinkingOrbMode, ThinkingOrbMode> = {
  working: 'orbits',
  orbits: 'orbits',
  searching: 'globe',
  globe: 'globe',
  solving: 'rubik',
  rubik: 'rubik',
  listening: 'wave',
  wave: 'wave',
  connecting: 'web',
  web: 'web',
  weaving: 'braid',
  braid: 'braid',
  composing: 'ribbon',
  ribbon: 'ribbon',
  breathing: 'ring',
  ring: 'ring',
  shaping: 'morph',
  morph: 'morph',
}

function getDescriptor(modeOrVariant: ThinkingOrbVariant | ThinkingOrbMode): ThinkingOrbDescriptor {
  const mode = VARIANT_TO_MODE_MAP[modeOrVariant] ?? 'orbits'
  return THINKING_ORBS.find((item) => item.mode === mode) ?? THINKING_ORBS[0]
}

export function ThinkingOrb({ variant = 'working', ...props }: ThinkingOrbProps) {
  const desc = getDescriptor(variant)
  return (
    <BaseThinkingOrb mode={desc.mode} caption={desc.caption} summary={desc.summary} frame={desc.frame} {...props} />
  )
}

export default ThinkingOrb
src/registry/components/orb/thinking/base-orb.tsx
'use client'

import * as React from 'react'
import { renderOrbScene, setupCanvasDpr } from './canvas-utils'
import { clamp } from './math'
import { presetFor } from './presets'
import { RENDERERS } from './renderers'
import type { ThinkingOrbMode, ThinkingOrbProps } from './types'

export interface BaseThinkingOrbProps extends Omit<ThinkingOrbProps, 'variant'> {
  mode: ThinkingOrbMode
  caption?: string
  summary?: string
  frame?: [number, number]
}

export function BaseThinkingOrb({
  mode,
  surface = 'auto',
  scale = 0.72,
  speed = 1,
  size = 240,
  playback = 'play',
  caption = 'Thinking',
  summary = 'Interactive particle state',
  frame = [240, 240],
  className = '',
  style,
  ...props
}: BaseThinkingOrbProps) {
  const canvasRef = React.useRef<HTMLCanvasElement | null>(null)
  const containerRef = React.useRef<HTMLDivElement | null>(null)

  // Surface detection: ink = dark, paper = light
  const [isDark, setIsDark] = React.useState(true)

  React.useEffect(() => {
    if (surface === 'ink') {
      setIsDark(true)
      return
    }
    if (surface === 'paper') {
      setIsDark(false)
      return
    }

    const checkDark = () => {
      const hasDarkClass = document.documentElement.classList.contains('dark')
      const prefersDark = window.matchMedia?.('(prefers-color-scheme: dark)').matches
      setIsDark(hasDarkClass || (!document.documentElement.classList.contains('light') && prefersDark))
    }

    checkDark()
    const observer = new MutationObserver(checkDark)
    observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })

    const mediaQuery = window.matchMedia?.('(prefers-color-scheme: dark)')
    mediaQuery?.addEventListener('change', checkDark)

    return () => {
      observer.disconnect()
      mediaQuery?.removeEventListener('change', checkDark)
    }
  }, [surface])

  React.useEffect(() => {
    const canvas = canvasRef.current
    const container = containerRef.current
    if (!canvas || !container) return

    let animationFrameId = 0
    let isDestroyed = false
    let isIntersecting = true
    let lastRenderTime = 0
    let accumulatedTime = 0
    let lastStamp = performance.now()

    const mediaReducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)')

    const shouldAnimate = () =>
      !isDestroyed && isIntersecting && !document.hidden && playback === 'play' && mediaReducedMotion?.matches !== true

    const render = (now: number) => {
      animationFrameId = 0
      if (isDestroyed) return

      const delta = (now - lastStamp) * 0.001
      lastStamp = now

      if (shouldAnimate()) {
        accumulatedTime += delta * Math.max(Number(speed) || 1, 0.05)
      }

      // Throttle render to ~45-60fps
      if (!lastRenderTime || now - lastRenderTime >= 1000 / 45) {
        const rect = canvas.getBoundingClientRect()
        const { ctx, w, h } = setupCanvasDpr(canvas, rect.width || frame[0], rect.height || frame[1])
        ctx.clearRect(0, 0, w, h)

        const innerSize = Math.max(8, Math.min(w, h) * clamp(Number(scale) || 0.72))
        const { speed: presetSpeed, opts } = presetFor(mode, innerSize)

        ctx.save()
        ctx.translate((w - innerSize) / 2, (h - innerSize) / 2)
        const sceneTime = shouldAnimate() ? accumulatedTime * presetSpeed : 0.6 * presetSpeed
        const scene = RENDERERS[mode](innerSize, sceneTime, opts)
        renderOrbScene(ctx, scene, isDark)
        ctx.restore()

        lastRenderTime = now
      }

      if (shouldAnimate()) {
        animationFrameId = requestAnimationFrame(render)
      }
    }

    const triggerRender = () => {
      if (animationFrameId) cancelAnimationFrame(animationFrameId)
      lastStamp = performance.now()
      lastRenderTime = 0
      render(performance.now())
    }

    const resizeObserver = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(() => triggerRender()) : null
    resizeObserver?.observe(container)

    const intersectionObserver =
      typeof IntersectionObserver !== 'undefined'
        ? new IntersectionObserver(
            ([entry]) => {
              isIntersecting = entry?.isIntersecting !== false
              triggerRender()
            },
            { rootMargin: '100px', threshold: 0.01 },
          )
        : null
    intersectionObserver?.observe(container)

    const onVisibilityChange = () => triggerRender()
    document.addEventListener('visibilitychange', onVisibilityChange)

    const onReducedMotionChange = () => triggerRender()
    mediaReducedMotion?.addEventListener?.('change', onReducedMotionChange)

    triggerRender()

    return () => {
      isDestroyed = true
      if (animationFrameId) cancelAnimationFrame(animationFrameId)
      resizeObserver?.disconnect()
      intersectionObserver?.disconnect()
      document.removeEventListener('visibilitychange', onVisibilityChange)
      mediaReducedMotion?.removeEventListener?.('change', onReducedMotionChange)
    }
  }, [mode, scale, speed, playback, isDark, frame])

  const [frameW, frameH] = frame

  return (
    <figure
      ref={containerRef}
      className={`relative flex items-center justify-center overflow-hidden isolate select-none ${className}`}
      style={{
        width: typeof size === 'number' ? `${size}px` : size,
        height: typeof size === 'number' ? `${size}px` : size,
        maxWidth: '100%',
        margin: 0,
        ...style,
      }}
      data-playback={playback}
      {...props}
    >
      <canvas
        ref={canvasRef}
        className="block max-h-full max-w-full"
        style={{
          width: `min(100%, ${frameW}px)`,
          height: 'auto',
          aspectRatio: `${frameW} / ${frameH}`,
        }}
        role="img"
        aria-label={`${caption}. ${summary}`}
      />
      <figcaption className="sr-only">
        {caption}: {summary}
      </figcaption>
    </figure>
  )
}
src/registry/components/orb/thinking/orb-working.tsx
import * as React from 'react'
import { BaseThinkingOrb } from './base-orb'
import type { ThinkingOrbProps } from './types'

export function OrbWorking(props: Omit<ThinkingOrbProps, 'variant'>) {
  return (
    <BaseThinkingOrb
      mode="orbits"
      caption="Working"
      summary="Particles tracing tilted orbital rings around a ghosted sphere."
      {...props}
    />
  )
}

export default OrbWorking
src/registry/components/orb/thinking/orb-searching.tsx
import * as React from 'react'
import { BaseThinkingOrb } from './base-orb'
import type { ThinkingOrbProps } from './types'

export function OrbSearching(props: Omit<ThinkingOrbProps, 'variant'>) {
  return (
    <BaseThinkingOrb
      mode="globe"
      caption="Searching"
      summary="A dotted globe swept by a rotating scan band."
      {...props}
    />
  )
}

export default OrbSearching
src/registry/components/orb/thinking/orb-solving.tsx
import * as React from 'react'
import { BaseThinkingOrb } from './base-orb'
import type { ThinkingOrbProps } from './types'

export function OrbSolving(props: Omit<ThinkingOrbProps, 'variant'>) {
  return (
    <BaseThinkingOrb
      mode="rubik"
      caption="Solving"
      summary="A point-cloud sphere permuted by successive quarter-turn slice rotations."
      {...props}
    />
  )
}

export default OrbSolving
src/registry/components/orb/thinking/orb-listening.tsx
import * as React from 'react'
import { BaseThinkingOrb } from './base-orb'
import type { ThinkingOrbProps } from './types'

export function OrbListening(props: Omit<ThinkingOrbProps, 'variant'>) {
  return (
    <BaseThinkingOrb
      mode="wave"
      caption="Listening"
      summary="Latitude rings breathing in and out on two detuned sine waves."
      {...props}
    />
  )
}

export default OrbListening
src/registry/components/orb/thinking/orb-connecting.tsx
import * as React from 'react'
import { BaseThinkingOrb } from './base-orb'
import type { ThinkingOrbProps } from './types'

export function OrbConnecting(props: Omit<ThinkingOrbProps, 'variant'>) {
  return (
    <BaseThinkingOrb
      mode="web"
      caption="Connecting"
      summary="A drifting node graph with proximity links and travelling signals."
      {...props}
    />
  )
}

export default OrbConnecting
src/registry/components/orb/thinking/orb-weaving.tsx
import * as React from 'react'
import { BaseThinkingOrb } from './base-orb'
import type { ThinkingOrbProps } from './types'

export function OrbWeaving(props: Omit<ThinkingOrbProps, 'variant'>) {
  return (
    <BaseThinkingOrb
      mode="braid"
      caption="Weaving"
      summary="Three helical strands braiding around a ghosted sphere."
      {...props}
    />
  )
}

export default OrbWeaving
src/registry/components/orb/thinking/orb-composing.tsx
import * as React from 'react'
import { BaseThinkingOrb } from './base-orb'
import type { ThinkingOrbProps } from './types'

export function OrbComposing(props: Omit<ThinkingOrbProps, 'variant'>) {
  return (
    <BaseThinkingOrb
      mode="ribbon"
      caption="Composing"
      summary="A rippling multi-lane ribbon wrapped over a sphere."
      {...props}
    />
  )
}

export default OrbComposing
src/registry/components/orb/thinking/orb-breathing.tsx
import * as React from 'react'
import { BaseThinkingOrb } from './base-orb'
import type { ThinkingOrbProps } from './types'

export function OrbBreathing(props: Omit<ThinkingOrbProps, 'variant'>) {
  return (
    <BaseThinkingOrb
      mode="ring"
      caption="Thinking"
      summary="A face-on ring pulsing with a slow radial ripple."
      {...props}
    />
  )
}

export default OrbBreathing
src/registry/components/orb/thinking/orb-shaping.tsx
import * as React from 'react'
import { BaseThinkingOrb } from './base-orb'
import type { ThinkingOrbProps } from './types'

export function OrbShaping(props: Omit<ThinkingOrbProps, 'variant'>) {
  return (
    <BaseThinkingOrb
      mode="morph"
      caption="Shaping"
      summary="A dotted outline morphing between circle, triangle and square."
      {...props}
    />
  )
}

export default OrbShaping
src/registry/components/orb/thinking/renderers.ts
import { type RenderDot, type RenderLine, type RenderPassData, filterAndSortElements } from './canvas-utils'
import {
  angleDelta,
  create3DRotation,
  fibonacciSphere,
  fract,
  hash,
  lerp,
  responsiveScale,
  smoothstep,
  valueNoise2D,
} from './math'
import type { BaseConfig } from './presets'
import type { ThinkingOrbMode } from './types'

// 1. Orbits (Working)
export function renderOrbits(size: number, time: number, opts: BaseConfig): RenderPassData {
  const cx = size / 2
  const cy = size / 2
  const radius = (size / 2) * 0.82
  const project = create3DRotation(time * 0.12, 0.3, cx, cy, 1)
  const scale = responsiveScale(size, opts.rsPow ?? 0.6)
  const dots: RenderDot[] = []
  const orbitCount = opts.orbitN ?? 12
  const ghostCount = opts.ghostN ?? 40
  const particleCount = opts.particles ?? 3

  for (let i = 0; i < orbitCount; i++) {
    const r1 = hash(i, 1.7)
    const r2 = hash(i, 5.2)
    const r3 = hash(i, 8.9)
    const orbitRadius = radius * (0.45 + 0.52 * r1)
    const phi = r1 * 2 * Math.PI
    const theta = Math.acos(2 * r2 - 1)
    const nx = Math.sin(theta) * Math.cos(phi)
    const ny = Math.cos(theta)
    const nz = Math.sin(theta) * Math.sin(phi)

    let ux = -ny
    let uy = nx
    const uLen = Math.max(1e-6, Math.sqrt(ux * ux + uy * uy))
    ux /= uLen
    uy /= uLen

    const vx = ny * 0 - nz * uy
    const vy = nz * ux - nx * 0
    const vz = nx * uy - ny * ux
    const speed = (0.25 + 0.55 * r3) * (r3 > 0.5 ? 1 : -1)

    for (let g = 0; g < ghostCount; g++) {
      const angle = (g / ghostCount) * 2 * Math.PI
      const [px, py, pz] = project(
        (ux * Math.cos(angle) + vx * Math.sin(angle)) * orbitRadius,
        (uy * Math.cos(angle) + vy * Math.sin(angle)) * orbitRadius,
        (0 * Math.cos(angle) + vz * Math.sin(angle)) * orbitRadius,
      )
      const depth = (pz / orbitRadius + 1) / 2
      dots.push({
        x: px,
        y: py,
        z: pz,
        r: (opts.ghostR ?? 0.9) * scale,
        white: 0.72,
        a: (opts.ghostA ?? 0.5) * (0.4 + 0.6 * depth),
      })
    }

    for (let p = 0; p < particleCount; p++) {
      const angle = time * speed + (p / particleCount) * 2 * Math.PI + r2 * 6
      const [px, py, pz] = project(
        (ux * Math.cos(angle) + vx * Math.sin(angle)) * orbitRadius,
        (uy * Math.cos(angle) + vy * Math.sin(angle)) * orbitRadius,
        (0 * Math.cos(angle) + vz * Math.sin(angle)) * orbitRadius,
      )
      const depth = (pz / orbitRadius + 1) / 2
      dots.push({
        x: px,
        y: py,
        z: pz,
        r: ((opts.partR ?? 1.2) + (opts.partRDepth ?? 1.6) * depth) * scale,
        white: 0.3 - 0.22 * depth,
      })
    }
  }

  return filterAndSortElements(dots, [], opts.rMin)
}

// 2. Globe (Searching)
export function renderGlobe(size: number, time: number, opts: BaseConfig): RenderPassData {
  const cx = size / 2
  const cy = size / 2
  const radius = (size / 2) * 0.82
  const rotX = 0.4 + 0.06 * Math.sin(time * 0.35)
  const project = create3DRotation(time * 0.5, rotX, cx, cy, radius)
  const scanSweep = time * (0.5 + 1.2 * (opts.scanMul ?? 1))
  const scale = responsiveScale(size, opts.rsPow ?? 0.6)
  const dimBase = opts.dimBase ?? 1
  const dots: RenderDot[] = []
  const latRings = opts.latRings ?? 17
  const lonDensity = opts.lonDensity ?? 44

  for (let ring = 0; ring <= latRings; ring++) {
    const lat = -Math.PI / 2 + (ring / latRings) * Math.PI
    const cosLat = Math.cos(lat)
    const sinLat = Math.sin(lat)
    const lonCount = Math.max(1, Math.round(Math.abs(cosLat) * lonDensity))

    for (let lon = 0; lon < lonCount; lon++) {
      const lonAngle = (lon / lonCount) * 2 * Math.PI
      const [px, py, pz] = project(cosLat * Math.cos(lonAngle), sinLat, cosLat * Math.sin(lonAngle))
      const depth = (pz + 1) / 2
      const delta = angleDelta(lonAngle + time * 0.5, scanSweep)
      const scanIntensity = Math.exp(-(delta * delta) / 0.18) * Math.max(0, pz)

      dots.push({
        x: px,
        y: py,
        z: pz,
        r: ((opts.rBase ?? 0.6) + (opts.rDepth ?? 1.7) * depth + (opts.rBoost ?? 1) * scanIntensity) * scale,
        white: (opts.inkFar ?? 0.62) - (opts.inkSpan ?? 0.54) * depth,
        a: dimBase + (1 - dimBase) * Math.min(1, scanIntensity),
      })
    }
  }

  return filterAndSortElements(dots, [], opts.rMin)
}

// 3. Rubik (Solving)
interface RubikMove {
  axis: number
  lo: number
  hi: number
  ang: number
}

function generateRubikMoves(count: number): RubikMove[] {
  const moves: RubikMove[] = []
  for (let i = 0; i < count; i++) {
    const axis = Math.min(2, Math.floor(hash(i, 2.3) * 3))
    const slice = -1 + 0.5 * Math.min(3, Math.floor(hash(i, 5.9) * 4))
    const dir = hash(i, 7.7) < 0.5 ? 1 : -1
    moves.push({
      axis,
      lo: slice,
      hi: slice + 0.5,
      ang: (dir * Math.PI) / 2,
    })
  }
  return moves
}

function computeRubikAmounts(time: number, count: number, moveDur: number, pauseDur: number) {
  const totalCycle = 2 * count * moveDur + pauseDur
  const tMod = time % totalCycle
  const amounts = Array(count).fill(0)
  let activeIndex = -1

  if (tMod < 2 * count * moveDur) {
    const step = Math.floor(tMod / moveDur)
    const stepT = (tMod - step * moveDur) / moveDur
    const ease = 1 - Math.pow(1 - Math.min(1, stepT / 0.7), 3)

    if (step < count) {
      for (let i = 0; i < step; i++) amounts[i] = 1
      amounts[step] = ease
      activeIndex = step
    } else {
      const rewind = 2 * count - 1 - step
      for (let i = 0; i < rewind; i++) amounts[i] = 1
      amounts[rewind] = 1 - ease
      activeIndex = rewind
    }
  }

  return { amount: amounts, active: activeIndex }
}

function applyRubikTransform(
  point: [number, number, number],
  moves: RubikMove[],
  anim: { amount: number[]; active: number },
): [number, number, number, boolean] {
  let [x, y, z] = point
  let isActive = false

  for (let i = 0; i < moves.length; i++) {
    if (anim.amount[i] <= 0) continue
    const move = moves[i]
    const coord = move.axis === 0 ? x : move.axis === 1 ? y : z
    if (coord < move.lo || coord >= move.hi) continue

    if (i === anim.active) {
      isActive = true
    }

    const angle = move.ang * anim.amount[i]
    const cosA = Math.cos(angle)
    const sinA = Math.sin(angle)

    if (move.axis === 0) {
      const ny = y * cosA - z * sinA
      z = y * sinA + z * cosA
      y = ny
    } else if (move.axis === 1) {
      const nx = x * cosA + z * sinA
      z = -x * sinA + z * cosA
      x = nx
    } else {
      const nx = x * cosA - y * sinA
      y = x * sinA + y * cosA
      x = nx
    }
  }

  return [x, y, z, isActive]
}

export function renderRubik(size: number, time: number, opts: BaseConfig): RenderPassData {
  const cx = size / 2
  const cy = size / 2
  const radius = (size / 2) * 0.82
  const project = create3DRotation(time * 0.55, 0.35 + 0.1 * Math.sin(time * 0.9), cx, cy, radius)
  const scale = responsiveScale(size, opts.rsPow ?? 0.6)
  const moveCount = opts.moveCount ?? 14
  const moves = generateRubikMoves(moveCount)
  const anim = computeRubikAmounts(time, moveCount, 0.42, 1.2)
  const dots: RenderDot[] = []
  const latRings = opts.latRings ?? 15
  const lonDensity = opts.lonDensity ?? 40

  for (let ring = 0; ring <= latRings; ring++) {
    const lat = -Math.PI / 2 + (ring / latRings) * Math.PI
    const cosLat = Math.cos(lat)
    const sinLat = Math.sin(lat)
    const lonCount = Math.max(1, Math.round(Math.abs(cosLat) * lonDensity))

    for (let lon = 0; lon < lonCount; lon++) {
      const lonAngle = (lon / lonCount) * 2 * Math.PI
      const [rx, ry, rz, isActive] = applyRubikTransform(
        [cosLat * Math.cos(lonAngle), sinLat, cosLat * Math.sin(lonAngle)],
        moves,
        anim,
      )
      const [px, py, pz] = project(rx, ry, rz)
      const depth = (pz + 1) / 2

      dots.push({
        x: px,
        y: py,
        z: pz,
        r: ((opts.rBase ?? 0.6) + (opts.rDepth ?? 1.7) * depth + (isActive ? (opts.rActive ?? 0.3) : 0)) * scale,
        white: (opts.inkFar ?? 0.62) - (opts.inkSpan ?? 0.54) * depth - (isActive ? 0.14 : 0),
      })
    }
  }

  return filterAndSortElements(dots, [], opts.rMin)
}

// 4. Wave (Listening)
export function renderWave(size: number, time: number, opts: BaseConfig): RenderPassData {
  const cx = size / 2
  const cy = size / 2
  const radius = (size / 2) * 0.874
  const project = create3DRotation(time * 0.18, 0.38, cx, cy, 1)
  const scale = responsiveScale(size, opts.rsPow ?? 0.6)
  const dots: RenderDot[] = []
  const rings = opts.rings ?? 15
  const lonDensity = opts.lonDensity ?? 40

  for (let ring = 0; ring <= rings; ring++) {
    const lat = -Math.PI / 2 + (ring / rings) * Math.PI
    const cosLat = Math.cos(lat)
    const sinLat = Math.sin(lat)
    const wave = 0.62 * Math.sin(time * 2.1 - ring * 0.52) + 0.38 * Math.sin(time * 1.27 + ring * 0.83)
    const rCurrent = radius * (0.88 + 0.105 * wave)
    const lonCount = Math.max(1, Math.round(Math.abs(cosLat) * lonDensity))

    for (let lon = 0; lon < lonCount; lon++) {
      const lonAngle = (lon / lonCount) * 2 * Math.PI
      const [px, py, pz] = project(
        cosLat * Math.cos(lonAngle) * rCurrent,
        sinLat * rCurrent,
        cosLat * Math.sin(lonAngle) * rCurrent,
      )
      const depth = (pz / radius + 1) / 2
      const posWave = Math.max(0, wave)

      dots.push({
        x: px,
        y: py,
        z: pz,
        r: ((opts.rBase ?? 0.6) + (opts.rDepth ?? 1.7) * depth) * (1 + 0.4 * posWave) * scale,
        white: 0.66 - 0.56 * depth - 0.1 * posWave,
      })
    }
  }

  return filterAndSortElements(dots, [], opts.rMin)
}

// 5. Web (Connecting)
export function renderWeb(size: number, time: number, opts: BaseConfig): RenderPassData {
  const cx = size / 2
  const cy = size / 2
  const radius = (size / 2) * 0.8 * (opts.spread ?? 1)
  const project = create3DRotation(time * 0.12, 0.32, cx, cy, radius)
  const scale = responsiveScale(size, opts.rsPow ?? 0.6)
  const nodeCount = opts.nodeN ?? 30
  const threshold = opts.thr ?? 0.72
  const nodeR = opts.nodeR ?? 1.4
  const nodeRDepth = opts.nodeRDepth ?? 1.8
  const nodes: [number, number, number][] = []

  for (let i = 0; i < nodeCount; i++) {
    const base = fibonacciSphere(i, nodeCount)
    const nx = base[0] + 0.3 * (valueNoise2D(i * 0.31 + 9, time * 0.24) - 0.5) * 2
    const ny = base[1] + 0.3 * (valueNoise2D(i * 0.53 + 27, time * 0.21) - 0.5) * 2
    const nz = base[2] + 0.3 * (valueNoise2D(i * 0.77 + 55, time * 0.27) - 0.5) * 2
    const len = Math.sqrt(nx * nx + ny * ny + nz * nz)
    nodes.push([nx / len, ny / len, nz / len])
  }

  const lines: RenderLine[] = []
  const dots: RenderDot[] = []

  for (let i = 0; i < nodeCount; i++) {
    for (let j = i + 1; j < nodeCount; j++) {
      const dx = nodes[i][0] - nodes[j][0]
      const dy = nodes[i][1] - nodes[j][1]
      const dz = nodes[i][2] - nodes[j][2]
      const dist = Math.sqrt(dx * dx + dy * dy + dz * dz)
      if (dist >= threshold) continue

      const [p1x, p1y, p1z] = project(nodes[i][0], nodes[i][1], nodes[i][2])
      const [p2x, p2y, p2z] = project(nodes[j][0], nodes[j][1], nodes[j][2])
      const avgDepth = ((p1z + p2z) / 2 + 1) / 2

      lines.push({
        x1: p1x,
        y1: p1y,
        x2: p2x,
        y2: p2y,
        white: 0.42,
        a: (1 - dist / threshold) * (0.3 + 0.55 * avgDepth),
        w: Math.max(0.6, (opts.lineW ?? 0.8) * scale),
      })
    }
  }

  for (let i = 0; i < nodeCount; i++) {
    const [px, py, pz] = project(nodes[i][0], nodes[i][1], nodes[i][2])
    const depth = (pz + 1) / 2
    const pulse = 1 + 0.25 * Math.sin(time * 1.4 + i * 2.7)

    dots.push({
      x: px,
      y: py,
      z: pz,
      r: (nodeR + nodeRDepth * depth) * pulse * scale,
      white: 0.55 - 0.45 * depth,
    })
  }

  const signalCount = opts.signals ?? 5
  for (let i = 0; i < signalCount; i++) {
    const cycle = Math.floor(time * 0.55 + i * 7.31)
    const from = Math.floor(hash(cycle, i * 3.1 + 1.7) * nodeCount)
    const to = Math.floor(hash(cycle, i * 5.7 + 4.2) * nodeCount)
    if (from === to) continue

    const tProg = fract(time * 0.55 + i * 7.31)
    const sx = lerp(nodes[from][0], nodes[to][0], tProg)
    const sy = lerp(nodes[from][1], nodes[to][1], tProg)
    const sz = lerp(nodes[from][2], nodes[to][2], tProg)
    const sLen = Math.max(1e-6, Math.sqrt(sx * sx + sy * sy + sz * sz))
    const [px, py, pz] = project(sx / sLen, sy / sLen, sz / sLen)
    const depth = (pz + 1) / 2

    dots.push({
      x: px,
      y: py,
      z: pz,
      r: (nodeR * 1.5 + nodeRDepth * depth) * scale,
      white: 0.05,
      a: 0.5 + 0.5 * depth,
    })
  }

  return filterAndSortElements(dots, lines, opts.rMin)
}

// 6. Braid (Weaving)
export function renderBraid(size: number, time: number, opts: BaseConfig): RenderPassData {
  const cx = size / 2
  const cy = size / 2
  const radius = (size / 2) * 0.76
  const project = create3DRotation(time * 0.4, 0.3, cx, cy, 1)
  const scale = responsiveScale(size, opts.rsPow ?? 0.6)
  const dots: RenderDot[] = []
  const ghostCount = opts.ghostN ?? 150

  for (let i = 0; i < ghostCount; i++) {
    const p = fibonacciSphere(i, ghostCount)
    const [px, py, pz] = project(p[0] * radius, p[1] * radius, p[2] * radius)
    const depth = (pz / radius + 1) / 2
    dots.push({
      x: px,
      y: py,
      z: pz,
      r: 0.8 * scale,
      white: 0.78,
      a: 0.1 + 0.22 * depth,
    })
  }

  const strandCount = opts.strandN ?? 52
  const turns = opts.turns ?? 3

  for (let s = 0; s < 3; s++) {
    const strandPhase = (s / 3) * 2 * Math.PI

    for (let i = 0; i < strandCount; i++) {
      const u = (fract(i / strandCount + time * 0.045) * 2 - 1) * 0.96
      const radAtU = Math.sqrt(Math.max(0, 1 - u * u))
      const fade = Math.min(1, (1 - Math.abs(u)) / 0.1)
      const angle = u * Math.PI * turns + strandPhase
      const rMod = 1 + 0.075 * Math.sin(u * Math.PI * turns * 2 + strandPhase * 2 + time * 0.8)
      const currentRadius = radAtU * radius * rMod
      const [px, py, pz] = project(Math.cos(angle) * currentRadius, u * radius * rMod, Math.sin(angle) * currentRadius)
      const depth = (pz / radius + 1) / 2

      dots.push({
        x: px,
        y: py,
        z: pz,
        r: ((opts.rBase ?? 1.2) + (opts.rDepth ?? 1.8) * depth) * scale,
        white: 0.55 - 0.45 * depth,
        a: fade * (0.45 + 0.55 * depth),
      })
    }
  }

  return filterAndSortElements(dots, [], opts.rMin)
}

// 7. Ribbon & Ring (Composing / Breathing)
export function renderRibbonOrRing(size: number, time: number, opts: BaseConfig): RenderPassData {
  const cx = size / 2
  const cy = size / 2
  const radius = (size / 2) * 0.78
  const spin = opts.spin ?? 1
  const project = create3DRotation(time * 0.1 * spin, 0.3, cx, cy, 1)
  const scale = responsiveScale(size, opts.rsPow ?? 0.6)
  const dots: RenderDot[] = []
  const ghostCount = opts.ghostN ?? 150

  for (let i = 0; i < ghostCount; i++) {
    const p = fibonacciSphere(i, ghostCount)
    const [px, py, pz] = project(p[0] * radius, p[1] * radius, p[2] * radius)
    const depth = (pz / radius + 1) / 2
    dots.push({
      x: px,
      y: py,
      z: pz,
      r: 0.8 * scale,
      white: 0.78,
      a: 0.1 + 0.22 * depth,
    })
  }

  const rotAngle = time * 0.24 * spin
  const tiltAngle = opts.faceOn ? -0.3 : 0.55 + 0.3 * Math.sin(time * 0.18) * spin
  const cosR = Math.cos(rotAngle)
  const sinR = Math.sin(rotAngle)
  const tx = -sinR * Math.sin(tiltAngle)
  const ty = Math.cos(tiltAngle)
  const tz = cosR * Math.sin(tiltAngle)
  const nx = 0 * tz - sinR * ty
  const ny = sinR * tx - cosR * tz
  const nz = cosR * ty - 0 * tx
  const wobMul = 0.23 * (opts.wobMul ?? 1)
  const effectiveRadius = opts.faceOn ? radius / (1 + 0.85 * wobMul) : radius
  const lanes = opts.lanes ?? 5
  const segs = opts.segs ?? 88
  const effectiveLanes = Math.max(1, Math.round(lanes * (opts.bandMul ?? 1)))

  for (let lane = 0; lane < effectiveLanes; lane++) {
    const laneOffset = (lane - (effectiveLanes - 1) / 2) * 0.075
    const edgeDistance = Math.abs(lane - (effectiveLanes - 1) / 2) / Math.max(1, (effectiveLanes - 1) / 2)

    for (let seg = 0; seg < segs; seg++) {
      const segAngle = (seg / segs) * 2 * Math.PI
      const ripple =
        (0.16 * Math.sin(segAngle * 3 - time * 1.7 + lane * 0.22) + 0.07 * Math.sin(segAngle * 5 + time * 1.1)) *
        (opts.wobMul ?? 1)
      const radialScale = opts.faceOn ? 1 + ripple : 1
      const normalOffset = opts.faceOn ? laneOffset : laneOffset + ripple

      const vx = cosR * Math.cos(segAngle) + tx * Math.sin(segAngle) + nx * normalOffset
      const vy = 0 * Math.cos(segAngle) + ty * Math.sin(segAngle) + ny * normalOffset
      const vz = sinR * Math.cos(segAngle) + tz * Math.sin(segAngle) + nz * normalOffset
      const vLen = Math.sqrt(vx * vx + vy * vy + vz * vz)
      const radScale = effectiveRadius * radialScale
      const [px, py, pz] = project((vx / vLen) * radScale, (vy / vLen) * radScale, (vz / vLen) * radScale)
      const depth = (pz / radius + 1) / 2

      dots.push({
        x: px,
        y: py,
        z: pz,
        r: ((opts.rBase ?? 1.1) + (opts.rDepth ?? 1.7) * depth) * (1 - 0.25 * edgeDistance) * scale,
        white: 0.52 - 0.44 * depth + 0.18 * edgeDistance,
        a: 0.4 + 0.6 * depth,
      })
    }
  }

  return filterAndSortElements(dots, [], opts.rMin)
}

// 8. Morph (Shaping)
const MORPH_POLYGONS: [number, number][][] = [
  [],
  [
    [0, -0.26],
    [0.24, 0.16],
    [-0.24, 0.16],
  ],
  [
    [0, -0.2],
    [0.2, -0.2],
    [0.2, 0.2],
    [-0.2, 0.2],
    [-0.2, -0.2],
  ],
]
const MORPH_SAMPLES = 160
const MORPH_HOLD_TIME = 1.4
const MORPH_TRANS_TIME = 0.9

function resamplePolygon(poly: [number, number][], samples: number): [number, number][] {
  const n = poly.length
  const segmentLengths: number[] = []
  let totalLen = 0
  for (let i = 0; i < n; i++) {
    const a = poly[i]
    const b = poly[(i + 1) % n]
    const len = Math.hypot(b[0] - a[0], b[1] - a[1])
    segmentLengths.push(len)
    totalLen += len
  }

  const out: [number, number][] = []
  for (let i = 0; i < samples; i++) {
    let targetDist = (i / samples) * totalLen
    let segIndex = 0
    while (targetDist > segmentLengths[segIndex] && segIndex < n - 1) {
      targetDist -= segmentLengths[segIndex]
      segIndex++
    }
    const a = poly[segIndex]
    const b = poly[(segIndex + 1) % n]
    const segT = segmentLengths[segIndex] ? Math.min(1, targetDist / segmentLengths[segIndex]) : 0
    out.push([a[0] + (b[0] - a[0]) * segT, a[1] + (b[1] - a[1]) * segT])
  }
  return out
}

function getShapeOutline(shapeIndex: number, samples: number): [number, number][] {
  const poly = MORPH_POLYGONS[shapeIndex]
  if (!poly || poly.length === 0) {
    const out: [number, number][] = []
    for (let i = 0; i < samples; i++) {
      const angle = -Math.PI / 2 + (i / samples) * 2 * Math.PI
      out.push([Math.cos(angle) * 0.24, Math.sin(angle) * 0.24])
    }
    return out
  }
  return resamplePolygon(poly, samples)
}

export function renderMorph(size: number, time: number, opts: BaseConfig): RenderPassData {
  const shapeCount = 3
  const shapeCycle = 2.3
  const cycleTime = time % (shapeCycle * shapeCount)
  const currentShape = Math.floor(cycleTime / shapeCycle)
  const shapeTime = cycleTime - currentShape * shapeCycle
  const morphProgress = shapeTime > MORPH_HOLD_TIME ? smoothstep((shapeTime - MORPH_HOLD_TIME) / MORPH_TRANS_TIME) : 0
  const spread = opts.spread ?? 1

  const s1 = getShapeOutline(currentShape, MORPH_SAMPLES)
  const s2 = getShapeOutline((currentShape + 1) % shapeCount, MORPH_SAMPLES)
  const blended: [number, number][] = []

  for (let i = 0; i < MORPH_SAMPLES; i++) {
    blended.push([
      (s1[i][0] + (s2[i][0] - s1[i][0]) * morphProgress) * spread,
      (s1[i][1] + (s2[i][1] - s1[i][1]) * morphProgress) * spread,
    ])
  }

  const segmentLengths: number[] = []
  let totalLen = 0
  for (let i = 0; i < MORPH_SAMPLES; i++) {
    const a = blended[i]
    const b = blended[(i + 1) % MORPH_SAMPLES]
    const len = Math.hypot(b[0] - a[0], b[1] - a[1])
    segmentLengths.push(len)
    totalLen += len
  }

  const dotCount = Math.max(6, Math.round(34 * (opts.iconD ?? 1)))
  const dotRadius = (opts.rDot ?? 0.021) * 1.35 * spread
  const pulse = 1 + 0.02 * Math.sin(shapeTime * 3.1)
  const center = size / 2
  const dots: RenderDot[] = []

  let segIndex = 0
  let accumulatedDist = 0

  for (let i = 0; i < dotCount; i++) {
    const targetDist = (i / dotCount) * totalLen
    while (accumulatedDist + segmentLengths[segIndex] < targetDist && segIndex < MORPH_SAMPLES - 1) {
      accumulatedDist += segmentLengths[segIndex]
      segIndex++
    }
    const a = blended[segIndex]
    const b = blended[(segIndex + 1) % MORPH_SAMPLES]
    const segT = segmentLengths[segIndex] ? Math.min(1, (targetDist - accumulatedDist) / segmentLengths[segIndex]) : 0
    const px = (a[0] + (b[0] - a[0]) * segT) * pulse
    const py = (a[1] + (b[1] - a[1]) * segT) * pulse

    dots.push({
      x: center + px * size,
      y: center + py * size,
      z: 0,
      r: Math.max(0.35, dotRadius * size),
      white: 0.1,
    })
  }

  return filterAndSortElements(dots, [], opts.rMin)
}

export const RENDERERS: Record<ThinkingOrbMode, (size: number, time: number, opts: BaseConfig) => RenderPassData> = {
  orbits: renderOrbits,
  globe: renderGlobe,
  rubik: renderRubik,
  wave: renderWave,
  web: renderWeb,
  braid: renderBraid,
  ribbon: renderRibbonOrRing,
  ring: renderRibbonOrRing,
  morph: renderMorph,
}
src/registry/components/orb/thinking/presets.ts
import { clamp, lerp } from './math'
import type { ThinkingOrbMode } from './types'

export interface BaseConfig {
  latRings?: number
  lonDensity?: number
  rBase?: number
  rDepth?: number
  rBoost?: number
  inkFar?: number
  inkSpan?: number
  rsPow?: number
  rMin?: number
  orbitN?: number
  ghostN?: number
  ghostR?: number
  ghostA?: number
  particles?: number
  partR?: number
  partRDepth?: number
  moveCount?: number
  rActive?: number
  rings?: number
  nodeN?: number
  thr?: number
  signals?: number
  nodeR?: number
  nodeRDepth?: number
  lineW?: number
  strandN?: number
  turns?: number
  lanes?: number
  segs?: number
  faceOn?: number
  rDot?: number
  iconD?: number
  spin?: number
  bandMul?: number
  wobMul?: number
  spread?: number
  scanMul?: number
  dimBase?: number
  [key: string]: any
}

export const DEFAULT_CONFIGS: Record<ThinkingOrbMode, BaseConfig> = {
  globe: {
    latRings: 17,
    lonDensity: 44,
    rBase: 0.6,
    rDepth: 1.7,
    rBoost: 1,
    inkFar: 0.62,
    inkSpan: 0.54,
    rsPow: 0.6,
    rMin: 0.3,
  },
  orbits: {
    orbitN: 12,
    ghostN: 40,
    ghostR: 0.9,
    ghostA: 0.5,
    particles: 3,
    partR: 1.2,
    partRDepth: 1.6,
    rsPow: 0.6,
    rMin: 0.3,
  },
  rubik: {
    latRings: 15,
    lonDensity: 40,
    moveCount: 14,
    rBase: 0.6,
    rDepth: 1.7,
    rActive: 0.3,
    inkFar: 0.62,
    inkSpan: 0.54,
    rsPow: 0.6,
    rMin: 0.3,
  },
  wave: {
    rings: 15,
    lonDensity: 40,
    rBase: 0.6,
    rDepth: 1.7,
    rsPow: 0.6,
    rMin: 0.3,
  },
  web: {
    nodeN: 30,
    thr: 0.72,
    signals: 5,
    nodeR: 1.4,
    nodeRDepth: 1.8,
    lineW: 0.8,
    rsPow: 0.6,
    rMin: 0.3,
  },
  braid: {
    strandN: 52,
    turns: 3,
    ghostN: 150,
    rBase: 1.2,
    rDepth: 1.8,
    rsPow: 0.6,
    rMin: 0.3,
  },
  ribbon: {
    lanes: 5,
    segs: 88,
    ghostN: 150,
    rBase: 1.1,
    rDepth: 1.7,
    rsPow: 0.6,
    rMin: 0.3,
  },
  ring: {
    lanes: 5,
    segs: 88,
    ghostN: 0,
    faceOn: 1,
    rBase: 1.1,
    rDepth: 1.7,
    rsPow: 0.6,
    rMin: 0.3,
  },
  morph: {
    rDot: 0.021,
    iconD: 1,
    rMin: 0.25,
  },
}

interface ScaleProfile {
  speed: number
  count: number
  size: number
  extra?: Record<string, number>
}

export const SCALE_PRESETS: Record<ThinkingOrbMode, { small: ScaleProfile; large: ScaleProfile }> = {
  orbits: {
    small: { speed: 3.9, count: 0.238, size: 2.4 },
    large: { speed: 1.885, count: 1, size: 1 },
  },
  globe: {
    small: { speed: 2.665, count: 0.105, size: 1.75, extra: { scanMul: 4.335, dimBase: 0.45 } },
    large: { speed: 2.015, count: 0.42, size: 1.15, extra: { scanMul: 4.08, dimBase: 0.45 } },
  },
  rubik: {
    small: { speed: 1.95, count: 0.088, size: 1.9 },
    large: { speed: 1.82, count: 0.35, size: 1.05 },
  },
  wave: {
    small: { speed: 3.998, count: 0.105, size: 1.6 },
    large: { speed: 4.388, count: 0.341, size: 1 },
  },
  web: {
    small: { speed: 6.63, count: 0.25, size: 1.52 },
    large: { speed: 3.315, count: 1.35, size: 0.95 },
  },
  braid: {
    small: { speed: 2.75, count: 0.1125, size: 1.36 },
    large: { speed: 1.625, count: 0.5, size: 1 },
  },
  ribbon: {
    small: { speed: 3.12, count: 0.051, size: 1.073, extra: { spin: 0, bandMul: 4.94, wobMul: 1 } },
    large: { speed: 2.34, count: 0.25, size: 0.85, extra: { spin: 0, bandMul: 3.9, wobMul: 1 } },
  },
  ring: {
    small: { speed: 3.78, count: 0.028, size: 1.622, extra: { spin: 0, bandMul: 3.968, wobMul: 0.565 } },
    large: { speed: 3.24, count: 0.25, size: 0.956, extra: { spin: 0, bandMul: 3.627, wobMul: 0.368 } },
  },
  morph: {
    small: { speed: 2.08, count: 0.53, size: 1.011, extra: { spread: 1.45 } },
    large: { speed: 2.405, count: 0.702, size: 0.395, extra: { spread: 1.45 } },
  },
}

const COUNT_COUPLES = [
  ['latRings', 'lonDensity'],
  ['rings', 'lonDensity'],
  ['lanes', 'segs'],
]
const COUNT_KEYS = ['orbitN', 'ghostN', 'nodeN', 'strandN', 'signals']
const DENSITY_KEYS = ['iconD']
const SIZE_KEYS = ['rBase', 'rDepth', 'rActive', 'rDot', 'ghostR', 'partR', 'partRDepth', 'nodeR', 'nodeRDepth']

function scaleCounts(config: BaseConfig, factor: number): BaseConfig {
  const result = { ...config }
  const visited = new Set<string>()
  const sqrtFactor = Math.sqrt(factor)

  for (const [k1, k2] of COUNT_COUPLES) {
    if (result[k1] != null && result[k2] != null && !visited.has(k1) && !visited.has(k2)) {
      result[k1] = Math.max(2, Math.round(result[k1] * sqrtFactor))
      result[k2] = Math.max(2, Math.round(result[k2] * sqrtFactor))
      visited.add(k1)
      visited.add(k2)
    }
  }

  for (const k of COUNT_KEYS) {
    if (result[k] != null && result[k] !== 0 && !visited.has(k)) {
      result[k] = Math.max(1, Math.round(result[k] * factor))
    }
  }

  for (const k of DENSITY_KEYS) {
    if (result[k] != null) {
      result[k] = Math.max(0.02, result[k] * factor)
    }
  }

  return result
}

function scaleSizes(config: BaseConfig, factor: number): BaseConfig {
  const result = { ...config }
  for (const k of SIZE_KEYS) {
    if (result[k] != null) {
      result[k] = result[k] * factor
    }
  }
  return result
}

export function presetFor(mode: ThinkingOrbMode, pixelSize: number): { speed: number; opts: BaseConfig } {
  const preset = SCALE_PRESETS[mode]
  const t = clamp((pixelSize - 20) / 44)
  const countScale = lerp(preset.small.count, preset.large.count, t)
  const sizeScale = lerp(preset.small.size, preset.large.size, t)

  let opts: BaseConfig = { ...DEFAULT_CONFIGS[mode] }
  if (countScale !== 1) opts = scaleCounts(opts, countScale)
  if (sizeScale !== 1) opts = scaleSizes(opts, sizeScale)

  if (preset.small.extra) {
    const extra: Record<string, number> = {}
    for (const key of Object.keys(preset.small.extra)) {
      extra[key] = lerp(preset.small.extra[key], preset.large.extra![key], t)
    }
    opts = { ...opts, ...extra }
  }

  return {
    speed: lerp(preset.small.speed, preset.large.speed, t),
    opts,
  }
}
src/registry/components/orb/thinking/math.ts
/**
 * Mathematical utilities and noise functions for procedural 3D particle rendering
 */

export function lerp(a: number, b: number, t: number): number {
  return a + (b - a) * t
}

export function fract(x: number): number {
  return x - Math.floor(x)
}

export function clamp(x: number): number {
  return x < 0 ? 0 : x > 1 ? 1 : x
}

export function smoothstep(x: number): number {
  return x * x * (3 - 2 * x)
}

export function hash(x: number, y: number): number {
  const n = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453
  return n - Math.floor(n)
}

export function valueNoise2D(x: number, y: number): number {
  const xi = Math.floor(x)
  const yi = Math.floor(y)
  let xf = x - xi
  let yf = y - yi
  xf = xf * xf * (3 - 2 * xf)
  yf = yf * yf * (3 - 2 * yf)
  const bl = hash(xi, yi)
  const br = hash(xi + 1, yi)
  const tl = hash(xi, yi + 1)
  const tr = hash(xi + 1, yi + 1)
  return bl + (br - bl) * xf + (tl - bl) * yf + (bl - br - tl + tr) * xf * yf
}

/**
 * Distribute points evenly on a 3D sphere using the golden spiral (Fibonacci sphere)
 */
export function fibonacciSphere(i: number, n: number): [number, number, number] {
  const phi = Math.PI * (3 - Math.sqrt(5))
  const y = 1 - 2 * ((i + 0.5) / n)
  const radius = Math.sqrt(Math.max(0, 1 - y * y))
  const theta = i * phi
  return [radius * Math.cos(theta), y, radius * Math.sin(theta)]
}

export function angleDelta(a: number, b: number): number {
  return Math.atan2(Math.sin(a - b), Math.cos(a - b))
}

/**
 * Perspective-correct 3D rotation transform projector
 */
export function create3DRotation(rotY: number, rotX: number, cx: number, cy: number, scale: number) {
  const sinX = Math.sin(rotX)
  const cosX = Math.cos(rotX)
  const sinY = Math.sin(rotY)
  const cosY = Math.cos(rotY)

  return (x: number, y: number, z: number): [number, number, number] => {
    const xRotY = x * cosY + z * sinY
    const zRotY = -x * sinY + z * cosY
    const yRotX = y * cosX - zRotY * sinX
    const zRotX = y * sinX + zRotY * cosX
    return [cx + xRotY * scale, cy - yRotX * scale, zRotX]
  }
}

export function responsiveScale(px: number, power: number): number {
  return Math.pow(px / 300, power)
}
src/registry/components/orb/thinking/canvas-utils.ts
export interface RenderDot {
  x: number
  y: number
  z: number
  r: number
  white: number
  a?: number
}

export interface RenderLine {
  x1: number
  y1: number
  x2: number
  y2: number
  white: number
  a?: number
  w: number
}

export interface RenderPassData {
  dots: RenderDot[]
  lines: RenderLine[]
}

export function drawDots(ctx: CanvasRenderingContext2D, dots: RenderDot[], isDarkSurface: boolean) {
  for (const dot of dots) {
    const alpha = dot.a ?? 1
    const white = Math.min(1, Math.max(0, dot.white))
    // On dark surface (isDarkSurface = true), white value of 0.72 becomes (1 - 0.72)*255 = 71 (faint),
    // and white value of 0.05 becomes (1 - 0.05)*255 = 242 (bright white highlight).
    const gray = Math.round((isDarkSurface ? 1 - white : white) * 255)
    ctx.fillStyle = `rgba(${gray},${gray},${gray},${alpha})`
    ctx.beginPath()
    ctx.arc(dot.x, dot.y, dot.r, 0, Math.PI * 2)
    ctx.fill()
  }
}

export function drawLines(ctx: CanvasRenderingContext2D, lines: RenderLine[], isDarkSurface: boolean) {
  for (const line of lines) {
    const alpha = line.a ?? 1
    const white = Math.min(1, Math.max(0, line.white))
    const gray = Math.round((isDarkSurface ? 1 - white : white) * 255)
    ctx.strokeStyle = `rgba(${gray},${gray},${gray},${alpha})`
    ctx.lineWidth = line.w
    ctx.beginPath()
    ctx.moveTo(line.x1, line.y1)
    ctx.lineTo(line.x2, line.y2)
    ctx.stroke()
  }
}

export function filterAndSortElements(dots: RenderDot[], lines: RenderLine[], minRadius = 0.3): RenderPassData {
  const validDots: RenderDot[] = []
  for (const dot of dots) {
    if ((dot.a ?? 1) < 0.02) continue
    dot.r = Math.max(minRadius, dot.r)
    validDots.push(dot)
  }
  validDots.sort((a, b) => a.z - b.z)
  return {
    dots: validDots,
    lines: lines.filter((l) => (l.a ?? 1) >= 0.02),
  }
}

export function renderOrbScene(ctx: CanvasRenderingContext2D, scene: RenderPassData, isDarkSurface: boolean) {
  if (scene.lines.length) {
    drawLines(ctx, scene.lines, isDarkSurface)
  }
  drawDots(ctx, scene.dots, isDarkSurface)
}

export function setupCanvasDpr(
  canvas: HTMLCanvasElement,
  cssWidth: number,
  cssHeight: number,
): { ctx: CanvasRenderingContext2D; w: number; h: number } {
  const dpr = Math.min(2, (typeof window !== 'undefined' && window.devicePixelRatio) || 1)
  const w = Math.max(1, Math.round(cssWidth))
  const h = Math.max(1, Math.round(cssHeight))

  const physW = Math.round(w * dpr)
  const physH = Math.round(h * dpr)

  if (canvas.width !== physW || canvas.height !== physH) {
    canvas.width = physW
    canvas.height = physH
  }

  const ctx = canvas.getContext('2d')!
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
  return { ctx, w, h }
}
src/registry/components/orb/thinking/types.ts
import * as React from 'react'

export type ThinkingOrbVariant =
  'working' | 'searching' | 'solving' | 'listening' | 'connecting' | 'weaving' | 'composing' | 'breathing' | 'shaping'

export type ThinkingOrbMode = 'orbits' | 'globe' | 'rubik' | 'wave' | 'web' | 'braid' | 'ribbon' | 'ring' | 'morph'

export type ThinkingOrbSurface = 'ink' | 'paper' | 'auto'

export interface ThinkingOrbDescriptor {
  id: string
  name: string
  mode: ThinkingOrbMode
  variant: ThinkingOrbVariant
  caption: string
  frame: [number, number]
  summary: string
}

export interface ThinkingOrbProps extends React.HTMLAttributes<HTMLDivElement> {
  /**
   * The thinking orb variant (1 of 9 animated styles)
   * @default 'working'
   */
  variant?: ThinkingOrbVariant | ThinkingOrbMode

  /**
   * Tone / surface of the rendering.
   * 'ink' = light particles on dark background.
   * 'paper' = dark particles on light background.
   * 'auto' = syncs with current document dark/light class or system scheme.
   * @default 'auto'
   */
  surface?: ThinkingOrbSurface

  /**
   * Scaling factor for the orb inside the canvas bounds (0.2 to 1.0)
   * @default 0.72
   */
  scale?: number

  /**
   * Animation speed multiplier (0.1 to 3.0)
   * @default 1
   */
  speed?: number

  /**
   * Target pixel width & height (or specify via className)
   * @default 240
   */
  size?: number

  /**
   * Animation playback state
   * @default 'play'
   */
  playback?: 'play' | 'pause'
}

More from spaceui

Browse all components in this library →