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'
}