refactor(sphere): extract pure algorithm + add browser preview
Split MorphingSphere into a platform-agnostic algorithm core (MorphingSphereCore.kt — no Android, no Compose, just kotlin.math) and a Compose renderer that calls it. Swap Android-specific `Paint` + `Typeface` + `nativeCanvas.drawText` for Compose's `TextMeasurer` + `drawText` so the composable no longer depends on `android.graphics.*`. Add `preview/web/` — a zero-dependency HTML+JS port of the same algorithm that animates live in the browser. No Android Studio or emulator required; serve with `python3 -m http.server --directory preview/web`. The JS port mirrors MorphingSphereCore.kt line-for-line, including `Math.imul`-based 32-bit hash math to match Kotlin's `Int` overflow and a floored-positive modulo to match `.mod(n)`. Font rendering differs slightly (OS default mono vs Android's FontFamily.Monospace) — bundle JetBrains Mono later if pixel parity across surfaces is needed. Sets up the same core for future Compose Desktop hot-reload and a terminal TUI port for Hermes CLI.
This commit is contained in:
@@ -13,150 +13,33 @@ import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.drawText
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import android.graphics.Paint
|
||||
import android.graphics.Typeface
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* ASCII morphing sphere — the visual embodiment of the AI agent.
|
||||
*
|
||||
* Inspired by Amp Code's Supernova orb. Renders a sphere from monospace
|
||||
* characters with layered procedural effects driven by [SphereState]:
|
||||
* characters with layered procedural effects. Algorithm lives in
|
||||
* [forEachSphereCell] (see `MorphingSphereCore.kt`) so the same math powers
|
||||
* Android here, the JS browser preview in `preview/web/`, and any future
|
||||
* renderer (Compose Desktop, terminal TUI).
|
||||
*
|
||||
* - **Hybrid brightness**: concentric distance-based zones + orbiting directional
|
||||
* light that shifts the highlight across the surface ("the eye")
|
||||
* - **Dual noise**: structural FBM (slow undulation) + turbulence (fast shimmer)
|
||||
* - **Breathing radius**: slow expand/contract
|
||||
* - **Core heartbeat**: brightness throb near center
|
||||
* - **Radial flow**: outward energy drift
|
||||
* - **Ripple waves**: concentric brightness rings radiating outward
|
||||
* - **State-driven colors**: palette shifts per agent state
|
||||
*
|
||||
* Pure Compose Canvas — no OpenGL, no external libraries.
|
||||
* This file is the Android/Compose renderer only — it owns animation state
|
||||
* (`animateFloatAsState`, `rememberInfiniteTransition`) and text drawing.
|
||||
*/
|
||||
|
||||
/** Agent visual state — controls animation parameters and color palette. */
|
||||
enum class SphereState {
|
||||
/** Calm breathing, slow wandering eye, gentle ripples. Present, waiting. */
|
||||
Idle,
|
||||
/** Faster pulse, tighter core, rapid eye scanning. Processing. */
|
||||
Thinking,
|
||||
/** Energy radiates outward, strong ripples, focused eye. Speaking. */
|
||||
Streaming,
|
||||
/** Voice mode — listening to user. Cool palette, subtle amplitude-driven motion. */
|
||||
Listening,
|
||||
/** Voice mode — speaking to user. Warm core, dramatic amplitude-driven motion. */
|
||||
Speaking,
|
||||
/** Red shift, erratic motion. Something wrong. */
|
||||
Error
|
||||
}
|
||||
|
||||
// ── State parameter system ───────────────────────────────────────────
|
||||
|
||||
private data class SphereParams(
|
||||
val breatheSpeed: Float,
|
||||
val breatheAmp: Float,
|
||||
val lightSpeedX: Float,
|
||||
val lightSpeedY: Float,
|
||||
val lightInfluence: Float,
|
||||
val coreTightness: Float,
|
||||
val turbulenceAmp: Float,
|
||||
val rippleScale: Float,
|
||||
val heartbeatSpeed: Float,
|
||||
val radialFlowSpeed: Float
|
||||
)
|
||||
|
||||
private data class SphereColors(
|
||||
val r1: Float, val g1: Float, val b1: Float, // color pole 1
|
||||
val r2: Float, val g2: Float, val b2: Float // color pole 2
|
||||
)
|
||||
|
||||
private fun paramsFor(state: SphereState) = when (state) {
|
||||
SphereState.Idle -> SphereParams(
|
||||
breatheSpeed = 0.5f, breatheAmp = 0.04f,
|
||||
lightSpeedX = 0.25f, lightSpeedY = 0.18f, lightInfluence = 0.35f,
|
||||
coreTightness = 0.75f, turbulenceAmp = 0.06f,
|
||||
rippleScale = 1.0f, heartbeatSpeed = 1.0f, radialFlowSpeed = 0.2f
|
||||
)
|
||||
SphereState.Thinking -> SphereParams(
|
||||
breatheSpeed = 0.8f, breatheAmp = 0.02f,
|
||||
lightSpeedX = 0.5f, lightSpeedY = 0.35f, lightInfluence = 0.30f,
|
||||
coreTightness = 0.90f, turbulenceAmp = 0.12f,
|
||||
rippleScale = 1.5f, heartbeatSpeed = 4.0f, radialFlowSpeed = 0.1f
|
||||
)
|
||||
SphereState.Streaming -> SphereParams(
|
||||
breatheSpeed = 0.3f, breatheAmp = 0.06f,
|
||||
lightSpeedX = 0.15f, lightSpeedY = 0.10f, lightInfluence = 0.25f,
|
||||
coreTightness = 0.60f, turbulenceAmp = 0.08f,
|
||||
rippleScale = 2.0f, heartbeatSpeed = 1.5f, radialFlowSpeed = 0.5f
|
||||
)
|
||||
SphereState.Listening -> SphereParams(
|
||||
// Calm base — voiceAmplitude modulates on top (see render loop).
|
||||
breatheSpeed = 0.55f, breatheAmp = 0.035f,
|
||||
lightSpeedX = 0.22f, lightSpeedY = 0.16f, lightInfluence = 0.38f,
|
||||
coreTightness = 0.78f, turbulenceAmp = 0.05f,
|
||||
rippleScale = 0.9f, heartbeatSpeed = 1.2f, radialFlowSpeed = 0.18f
|
||||
)
|
||||
SphereState.Speaking -> SphereParams(
|
||||
// Assertive base — amplitude pushes it dramatically further.
|
||||
breatheSpeed = 0.45f, breatheAmp = 0.05f,
|
||||
lightSpeedX = 0.20f, lightSpeedY = 0.14f, lightInfluence = 0.30f,
|
||||
coreTightness = 0.55f, turbulenceAmp = 0.07f,
|
||||
rippleScale = 1.8f, heartbeatSpeed = 1.8f, radialFlowSpeed = 0.45f
|
||||
)
|
||||
SphereState.Error -> SphereParams(
|
||||
breatheSpeed = 1.2f, breatheAmp = 0.03f,
|
||||
lightSpeedX = 0.7f, lightSpeedY = 0.6f, lightInfluence = 0.40f,
|
||||
coreTightness = 0.80f, turbulenceAmp = 0.15f,
|
||||
rippleScale = 0.5f, heartbeatSpeed = 6.0f, radialFlowSpeed = 0.3f
|
||||
)
|
||||
}
|
||||
|
||||
private fun colorsFor(state: SphereState) = when (state) {
|
||||
SphereState.Idle -> SphereColors(
|
||||
0.25f, 0.85f, 0.40f, // green
|
||||
0.61f, 0.42f, 0.94f // purple
|
||||
)
|
||||
SphereState.Thinking -> SphereColors(
|
||||
0.30f, 0.55f, 0.95f, // blue
|
||||
0.55f, 0.35f, 0.90f // purple
|
||||
)
|
||||
SphereState.Streaming -> SphereColors(
|
||||
0.20f, 0.90f, 0.50f, // green
|
||||
0.25f, 0.80f, 0.85f // teal
|
||||
)
|
||||
SphereState.Listening -> SphereColors(
|
||||
// Cool soft blue/purple — cooler than Idle's green/purple.
|
||||
0.35f, 0.55f, 0.95f, // #597EF2 soft blue
|
||||
0.65f, 0.45f, 0.95f // #A573F2 soft purple
|
||||
)
|
||||
SphereState.Speaking -> SphereColors(
|
||||
// Vibrant green/teal — same family as Streaming but punchier.
|
||||
// The render loop pushes core toward white as amplitude peaks.
|
||||
0.25f, 0.92f, 0.55f, // #40EB8C vivid green
|
||||
0.30f, 0.85f, 0.88f // #4DD9E0 teal
|
||||
)
|
||||
SphereState.Error -> SphereColors(
|
||||
0.90f, 0.30f, 0.25f, // red
|
||||
0.85f, 0.50f, 0.20f // orange
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main composable ──────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
fun MorphingSphere(
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -168,66 +51,28 @@ fun MorphingSphere(
|
||||
fixedTime: Float? = null,
|
||||
fixedColorPhase: Float? = null
|
||||
) {
|
||||
// Clamp amplitude once — downstream math assumes 0..1.
|
||||
val amp = voiceAmplitude.coerceIn(0f, 1f)
|
||||
|
||||
// ── Animated state parameters (smooth 800ms transitions) ─────
|
||||
val targetP = remember(state) { paramsFor(state) }
|
||||
val targetC = remember(state) { colorsFor(state) }
|
||||
val spec = tween<Float>(800, easing = FastOutSlowInEasing)
|
||||
|
||||
// ── voiceMode expansion scalar ───────────────────────────────
|
||||
// 1.0 = normal, ~1.08 = expanded (bounded to avoid data-ring overflow).
|
||||
val voiceRadiusScale by animateFloatAsState(
|
||||
targetValue = if (voiceMode) 1.08f else 1.0f,
|
||||
animationSpec = tween(600, easing = FastOutSlowInEasing),
|
||||
label = "voiceExpand"
|
||||
)
|
||||
|
||||
val baseBreatheSpeed by animateFloatAsState(targetP.breatheSpeed, spec, label = "bSpd")
|
||||
val breatheSpeed by animateFloatAsState(targetP.breatheSpeed, spec, label = "bSpd")
|
||||
val breatheAmp by animateFloatAsState(targetP.breatheAmp, spec, label = "bAmp")
|
||||
val lightSpeedX by animateFloatAsState(targetP.lightSpeedX, spec, label = "lsX")
|
||||
val lightSpeedY by animateFloatAsState(targetP.lightSpeedY, spec, label = "lsY")
|
||||
val lightInfluence by animateFloatAsState(targetP.lightInfluence, spec, label = "lInf")
|
||||
val coreTightness by animateFloatAsState(targetP.coreTightness, spec, label = "core")
|
||||
val baseTurbulence by animateFloatAsState(targetP.turbulenceAmp, spec, label = "turb")
|
||||
val turbulenceAmp by animateFloatAsState(targetP.turbulenceAmp, spec, label = "turb")
|
||||
val rippleScale by animateFloatAsState(targetP.rippleScale, spec, label = "rip")
|
||||
val heartbeatSpeed by animateFloatAsState(targetP.heartbeatSpeed, spec, label = "hb")
|
||||
val radialFlowSpeed by animateFloatAsState(targetP.radialFlowSpeed, spec, label = "rf")
|
||||
|
||||
// ── Voice amplitude modulation ────────────────────────────────
|
||||
// Listening = subtle (≤30% boost); Speaking = dramatic (up to 3×).
|
||||
// Idle/Thinking/Streaming/Error ignore amplitude — existing behavior preserved.
|
||||
val breatheSpeed = when (state) {
|
||||
SphereState.Listening -> lerp(baseBreatheSpeed, baseBreatheSpeed * 1.3f, amp * 0.5f)
|
||||
SphereState.Speaking -> lerp(baseBreatheSpeed, baseBreatheSpeed * 2.0f, amp)
|
||||
else -> baseBreatheSpeed
|
||||
}
|
||||
val turbulenceAmp = when (state) {
|
||||
SphereState.Listening -> baseTurbulence + amp * 0.15f
|
||||
SphereState.Speaking -> baseTurbulence + amp * 0.5f
|
||||
else -> baseTurbulence
|
||||
}
|
||||
// Core warmth: 0.30 is the existing constant baked into the render loop's
|
||||
// warmth term (see line where `warmth = (1f - normDist^2) * 0.12f` is mixed).
|
||||
// Speaking pushes this multiplier from 0.3 → 1.0 as amplitude rises, driving
|
||||
// the core bright→white. Listening holds at 0.3 (no change vs. other states).
|
||||
val coreWarmth = when (state) {
|
||||
SphereState.Speaking -> lerp(0.30f, 1.0f, amp)
|
||||
else -> 0.30f
|
||||
}
|
||||
// Perimeter wobble — existing code uses a fixed 0.06 multiplier.
|
||||
val wobbleAmplitude = when (state) {
|
||||
SphereState.Listening -> 0.06f * (1f + amp * 0.3f)
|
||||
SphereState.Speaking -> 0.06f * (1f + amp * 0.8f)
|
||||
else -> 0.06f
|
||||
}
|
||||
// Data ring orbit speed — existing code uses `t * 0.4f`.
|
||||
val dataRingSpeed = when (state) {
|
||||
SphereState.Speaking -> 0.4f * (1f + amp * 3f)
|
||||
else -> 0.4f
|
||||
}
|
||||
|
||||
val cr1 by animateFloatAsState(targetC.r1, spec, label = "cr1")
|
||||
val cg1 by animateFloatAsState(targetC.g1, spec, label = "cg1")
|
||||
val cb1 by animateFloatAsState(targetC.b1, spec, label = "cb1")
|
||||
@@ -235,7 +80,6 @@ fun MorphingSphere(
|
||||
val cg2 by animateFloatAsState(targetC.g2, spec, label = "cg2")
|
||||
val cb2 by animateFloatAsState(targetC.b2, spec, label = "cb2")
|
||||
|
||||
// ── Continuous time animations ──────────────────────────────
|
||||
val transition = rememberInfiniteTransition(label = "sphere")
|
||||
val animatedTime by transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
@@ -259,25 +103,11 @@ fun MorphingSphere(
|
||||
val time = fixedTime ?: animatedTime
|
||||
val colorPhase = fixedColorPhase ?: animatedColorPhase
|
||||
|
||||
// Multiple character sets that rotate over time for surface "activity"
|
||||
val charSets = arrayOf(
|
||||
" ·:;=+*#%@", // technical dots
|
||||
" .:;=+*#%@", // classic with semicolons
|
||||
" ·;:+=*%#@", // shuffled mid-range
|
||||
" .:;+=*#@%" // variant ordering
|
||||
)
|
||||
// Data ring characters (orbit the sphere like processing data)
|
||||
val dataChars = "01<>[]{}|/\\~^"
|
||||
|
||||
val cols = 58
|
||||
val rows = 34
|
||||
|
||||
val paint = remember {
|
||||
Paint().apply {
|
||||
typeface = Typeface.MONOSPACE
|
||||
isAntiAlias = true
|
||||
}
|
||||
}
|
||||
// Cache covers the ~25 distinct glyphs across charSets/dataChars/debrisChars.
|
||||
val textMeasurer = rememberTextMeasurer(cacheSize = 64)
|
||||
|
||||
Canvas(modifier = modifier.fillMaxSize().clipToBounds()) {
|
||||
val canvasW = size.width
|
||||
@@ -285,277 +115,43 @@ fun MorphingSphere(
|
||||
val cellW = canvasW / cols
|
||||
val cellH = canvasH / rows
|
||||
val charSize = (cellW * 1.3f).coerceAtMost(cellH * 1.1f)
|
||||
paint.textSize = charSize
|
||||
|
||||
val cx = cols / 2f
|
||||
val cy = rows / 2f
|
||||
val charAspect = cellW / cellH
|
||||
|
||||
// Reduced from 0.72 so data ring (1.55x) fits within grid.
|
||||
// voiceRadiusScale is ~1.08 in voiceMode, 1.0 otherwise — bounded so the
|
||||
// data ring outer edge (1.55x) still stays within the drawable region.
|
||||
val maxRadiusFromRows = (rows / 2f) * 0.60f
|
||||
val maxRadiusFromCols = (cols / 2f) * charAspect * 0.60f
|
||||
val baseRadius = minOf(maxRadiusFromRows, maxRadiusFromCols) * voiceRadiusScale
|
||||
val t = time
|
||||
val style = TextStyle(
|
||||
fontSize = charSize.toSp(),
|
||||
fontFamily = FontFamily.Monospace
|
||||
)
|
||||
|
||||
// ── Breathing ────────────────────────────────────────────
|
||||
val breathe = sin(t * breatheSpeed) * breatheAmp
|
||||
val breathingRadius = baseRadius * (1f + breathe)
|
||||
val frame = SphereFrame(
|
||||
cols = cols, rows = rows, charAspect = charAspect,
|
||||
state = state, time = time, colorPhase = colorPhase,
|
||||
breatheSpeed = breatheSpeed, breatheAmp = breatheAmp,
|
||||
lightSpeedX = lightSpeedX, lightSpeedY = lightSpeedY,
|
||||
lightInfluence = lightInfluence, coreTightness = coreTightness,
|
||||
turbulenceAmp = turbulenceAmp, rippleScale = rippleScale,
|
||||
heartbeatSpeed = heartbeatSpeed, radialFlowSpeed = radialFlowSpeed,
|
||||
cr1 = cr1, cg1 = cg1, cb1 = cb1,
|
||||
cr2 = cr2, cg2 = cg2, cb2 = cb2,
|
||||
intensity = intensity, toolCallBurst = toolCallBurst,
|
||||
voiceAmplitude = amp, voiceMode = voiceMode,
|
||||
voiceRadiusScale = voiceRadiusScale
|
||||
)
|
||||
|
||||
// ── Orbiting directional light ("the eye") ──────────────
|
||||
// Lissajous orbit (different X/Y speeds) + noise jitter
|
||||
// for organic, non-repeating path. lx/ly at ±0.65 creates
|
||||
// strong enough asymmetry that the highlight visibly shifts.
|
||||
val noiseJitter1 = fbm(t * 0.05f + 7.3f, 1.7f) * 0.5f
|
||||
val noiseJitter2 = fbm(3.1f, t * 0.04f + 13.7f) * 0.5f
|
||||
val lightAngle1 = t * lightSpeedX + noiseJitter1
|
||||
val lightAngle2 = t * lightSpeedY + noiseJitter2
|
||||
val lx = sin(lightAngle1) * 0.65f
|
||||
val ly = cos(lightAngle2) * 0.65f
|
||||
val lz = sqrt((1f - lx * lx - ly * ly).coerceAtLeast(0.01f))
|
||||
|
||||
// ── Core heartbeat ──────────────────────────────────────
|
||||
val heartbeat = sin(t * heartbeatSpeed) * 0.5f + 0.5f
|
||||
|
||||
// ── Color palette (animated poles + phase oscillation) ───
|
||||
val pulse = sin(colorPhase) * 0.5f + 0.5f
|
||||
val colR = lerp(cr1, cr2, pulse)
|
||||
val colG = lerp(cg1, cg2, pulse)
|
||||
val colB = lerp(cb1, cb2, pulse)
|
||||
|
||||
val distWeight = 1f - lightInfluence
|
||||
|
||||
// Intensity/tool call modulation of state params
|
||||
val effTurbulence = turbulenceAmp + intensity * 0.04f + toolCallBurst * 0.15f
|
||||
val effRadialFlow = radialFlowSpeed + intensity * 0.3f
|
||||
val effRipple = rippleScale + intensity * 0.5f + toolCallBurst * 1.0f
|
||||
|
||||
for (row in 0 until rows) {
|
||||
for (col in 0 until cols) {
|
||||
val dx = (col - cx) * charAspect
|
||||
val dy = (row - cy)
|
||||
val dist = sqrt(dx * dx + dy * dy)
|
||||
val angle = atan2(dy, dx)
|
||||
|
||||
// ── Perimeter (subtle 6% wobble — amplified by voice) ───
|
||||
val perimeterNoise = fbm(
|
||||
angle * 1.8f + t * 0.08f,
|
||||
angle * 0.7f + t * 0.12f
|
||||
) * 2f - 1f
|
||||
val distortedRadius = breathingRadius * (1f + perimeterNoise * wobbleAmplitude)
|
||||
val glowRadius = distortedRadius * 1.35f
|
||||
val dataRingInner = distortedRadius * 1.40f
|
||||
val dataRingOuter = distortedRadius * 1.55f
|
||||
val normDist = dist / distortedRadius
|
||||
|
||||
if (dist > dataRingOuter) continue
|
||||
|
||||
val px = col * cellW
|
||||
val py = row * cellH + cellH * 0.8f
|
||||
|
||||
if (normDist <= 1f) {
|
||||
// ── INSIDE SPHERE ────────────────────────────
|
||||
|
||||
// Surface normal
|
||||
val nx = dx / distortedRadius
|
||||
val ny2 = dy / distortedRadius
|
||||
val nzSq = (1f - nx * nx - ny2 * ny2).coerceAtLeast(0f)
|
||||
val nz = sqrt(nzSq)
|
||||
|
||||
// Distance-based brightness (concentric zones)
|
||||
val distBrightness = (1f - normDist * normDist * coreTightness)
|
||||
.coerceAtLeast(0.15f)
|
||||
|
||||
// Directional light (shifts highlight across surface)
|
||||
val directionalLight = (nx * lx + ny2 * ly + nz * lz)
|
||||
.coerceIn(0f, 1f)
|
||||
|
||||
// Structural noise (slow undulation)
|
||||
val structural = fbm(
|
||||
col * 0.25f + t * 0.18f,
|
||||
row * 0.25f + t * 0.13f,
|
||||
octaves = 2
|
||||
) * 0.15f - 0.075f
|
||||
|
||||
// Turbulence (fast shimmer, boosted by intensity + tool calls)
|
||||
val turbulence = fbm(
|
||||
col * 0.8f + t * 0.6f,
|
||||
row * 0.8f + t * 0.45f,
|
||||
octaves = 2
|
||||
) * effTurbulence - effTurbulence * 0.5f
|
||||
|
||||
// Radial flow (outward energy drift, faster when streaming)
|
||||
val radialFlow = fbm(
|
||||
angle * 2f + t * 0.15f,
|
||||
dist * 0.3f - t * effRadialFlow,
|
||||
octaves = 2
|
||||
) * 0.06f - 0.03f
|
||||
|
||||
// Ripple waves (stronger during streaming/tool calls)
|
||||
val ripple = (
|
||||
sin(normDist * 8f - t * 1.2f) * 0.04f * (1f - normDist) +
|
||||
sin(normDist * 5f - t * 0.7f + 2f) * 0.03f * (1f - normDist)
|
||||
) * effRipple
|
||||
|
||||
// Core heartbeat (subtle glow, concentrated at center)
|
||||
val heartbeatFx = heartbeat * 0.05f * (1f - normDist * normDist)
|
||||
|
||||
// ── Hybrid brightness ────────────────────────
|
||||
val brightness = distWeight * distBrightness +
|
||||
lightInfluence * directionalLight +
|
||||
heartbeatFx
|
||||
val charNoise = structural + turbulence + radialFlow + ripple
|
||||
|
||||
// Character rotation: cycle through char sets over time
|
||||
// Each cell picks a set based on position + time, creating
|
||||
// surface "activity" where characters shift independently
|
||||
val rotationPhase = (t * 0.3f + col * 0.17f + row * 0.13f).toInt()
|
||||
val chars = charSets[rotationPhase.and(3)] // mod 4 via bitmask
|
||||
|
||||
val charIdx = ((brightness + charNoise) * (chars.length - 1))
|
||||
.toInt().coerceIn(1, chars.length - 1)
|
||||
val ch = chars[charIdx]
|
||||
|
||||
// Edge fade (quadratic, starts at 0.80)
|
||||
val edgeFade = when {
|
||||
normDist > 0.80f -> {
|
||||
val ef = (normDist - 0.80f) / 0.20f
|
||||
1f - ef * ef
|
||||
}
|
||||
else -> 1f
|
||||
}
|
||||
|
||||
// Scanline: dimming on odd rows (CRT/holographic feel)
|
||||
val scanline = if (row % 2 == 1) 0.82f else 1f
|
||||
|
||||
val alpha = ((brightness * 0.4f + 0.6f) * edgeFade * scanline)
|
||||
.coerceIn(0.1f, 1f)
|
||||
|
||||
// Core warmth (center bleeds towards white).
|
||||
// coreWarmth is 0.30 for all non-voice states (→ 0.12 multiplier,
|
||||
// the historical value) and scales up to 1.0 when Speaking peaks.
|
||||
val warmth = (1f - normDist * normDist) * (coreWarmth * 0.40f)
|
||||
val lightBoost = directionalLight * 0.08f
|
||||
|
||||
paint.color = android.graphics.Color.argb(
|
||||
(alpha * 255).toInt().coerceIn(0, 255),
|
||||
((colR + lightBoost + warmth) * 255).toInt().coerceIn(0, 255),
|
||||
((colG + lightBoost * 0.5f + warmth) * 255).toInt().coerceIn(0, 255),
|
||||
((colB + lightBoost + warmth) * 255).toInt().coerceIn(0, 255)
|
||||
)
|
||||
|
||||
drawContext.canvas.nativeCanvas.drawText(ch.toString(), px, py, paint)
|
||||
|
||||
} else if (dist <= glowRadius) {
|
||||
// ── GLOW / DEBRIS ZONE ───────────────────────
|
||||
|
||||
val glowT = (dist - distortedRadius) / (glowRadius - distortedRadius)
|
||||
val glowFalloff = (1f - glowT).coerceIn(0f, 1f)
|
||||
|
||||
val sparsityNoise = fbm(
|
||||
angle * 3.5f + t * 0.25f,
|
||||
dist * 0.4f + t * 0.08f,
|
||||
octaves = 2
|
||||
)
|
||||
val sparsityThreshold = 0.35f + glowT * 0.25f
|
||||
if (sparsityNoise < sparsityThreshold) continue
|
||||
|
||||
val debrisChars = "·:;- "
|
||||
val debrisIdx = ((1f - glowFalloff) * (debrisChars.length - 1))
|
||||
.toInt().coerceIn(0, debrisChars.length - 1)
|
||||
val ch = debrisChars[debrisIdx]
|
||||
if (ch == ' ') continue
|
||||
|
||||
val alpha = glowFalloff * 0.85f
|
||||
|
||||
paint.color = android.graphics.Color.argb(
|
||||
(alpha * 255).toInt().coerceIn(0, 255),
|
||||
(colR * 255).toInt().coerceIn(0, 255),
|
||||
(colG * 255).toInt().coerceIn(0, 255),
|
||||
(colB * 255).toInt().coerceIn(0, 255)
|
||||
)
|
||||
|
||||
drawContext.canvas.nativeCanvas.drawText(ch.toString(), px, py, paint)
|
||||
|
||||
} else if (dist >= dataRingInner) {
|
||||
// ── DATA RING ────────────────────────────────
|
||||
// Sparse orbiting characters like processing data.
|
||||
// Angle offset by time = characters appear to orbit.
|
||||
|
||||
val ringT = (dist - dataRingInner) / (dataRingOuter - dataRingInner)
|
||||
|
||||
// Orbiting: offset angle by time (different layers at different speeds).
|
||||
// dataRingSpeed is 0.4 default, spun up to ~1.6 at Speaking peak.
|
||||
val orbitAngle = angle - t * dataRingSpeed + ringT * 1.5f
|
||||
// Sparsity: only render ~15% of ring positions
|
||||
val ringNoise = fbm(
|
||||
orbitAngle * 4f + t * 0.3f,
|
||||
ringT * 3f + t * 0.15f,
|
||||
octaves = 2
|
||||
)
|
||||
if (ringNoise < 0.55f) continue
|
||||
|
||||
// Pick character from data set, cycling with orbit
|
||||
val dataIdx = ((orbitAngle * 2f + t * 0.5f) * dataChars.length)
|
||||
.toInt().mod(dataChars.length)
|
||||
val ch = dataChars[dataIdx]
|
||||
|
||||
// Fade: bright at inner edge, fading outward
|
||||
val ringFade = (1f - ringT).coerceIn(0f, 1f)
|
||||
val alpha = ringFade * 0.65f
|
||||
|
||||
paint.color = android.graphics.Color.argb(
|
||||
(alpha * 255).toInt().coerceIn(0, 255),
|
||||
(colR * 0.85f * 255).toInt().coerceIn(0, 255),
|
||||
(colG * 0.85f * 255).toInt().coerceIn(0, 255),
|
||||
(colB * 0.85f * 255).toInt().coerceIn(0, 255)
|
||||
)
|
||||
|
||||
drawContext.canvas.nativeCanvas.drawText(ch.toString(), px, py, paint)
|
||||
}
|
||||
}
|
||||
forEachSphereCell(frame) { cell ->
|
||||
val layout = textMeasurer.measure(cell.char.toString(), style)
|
||||
// Legacy Paint used y as baseline (`row*cellH + cellH*0.8f`).
|
||||
// Compose `drawText` uses top-left — offset by firstBaseline to match.
|
||||
val px = cell.col * cellW
|
||||
val py = cell.row * cellH + cellH * 0.8f - layout.firstBaseline
|
||||
drawText(
|
||||
textLayoutResult = layout,
|
||||
color = Color(cell.r, cell.g, cell.b, cell.alpha),
|
||||
topLeft = Offset(px, py)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Procedural noise ─────────────────────────────────────────────────
|
||||
|
||||
private fun hash(x: Int, y: Int): Float {
|
||||
var h = x * 374761393 + y * 668265263
|
||||
h = (h xor (h ushr 13)) * 1274126177
|
||||
h = h xor (h ushr 16)
|
||||
return (h and 0x7fffffff) / 2147483647f
|
||||
}
|
||||
|
||||
private fun smoothNoise(x: Float, y: Float): Float {
|
||||
val xi = floor(x).toInt()
|
||||
val yi = floor(y).toInt()
|
||||
val xf = x - xi
|
||||
val yf = y - yi
|
||||
val u = xf * xf * (3f - 2f * xf)
|
||||
val v = yf * yf * (3f - 2f * yf)
|
||||
val n00 = hash(xi, yi)
|
||||
val n10 = hash(xi + 1, yi)
|
||||
val n01 = hash(xi, yi + 1)
|
||||
val n11 = hash(xi + 1, yi + 1)
|
||||
return lerp(lerp(n00, n10, u), lerp(n01, n11, u), v)
|
||||
}
|
||||
|
||||
private fun fbm(x: Float, y: Float, octaves: Int = 3): Float {
|
||||
var value = 0f
|
||||
var amplitude = 0.5f
|
||||
var frequency = 1f
|
||||
for (i in 0 until octaves) {
|
||||
value += amplitude * smoothNoise(x * frequency, y * frequency)
|
||||
amplitude *= 0.5f
|
||||
frequency *= 2f
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
private fun lerp(a: Float, b: Float, t: Float): Float = a + (b - a) * t
|
||||
|
||||
// ── Previews ─────────────────────────────────────────────────────────
|
||||
|
||||
@Preview(name = "Idle", showBackground = true, backgroundColor = 0xFF0D0D0D, widthDp = 360, heightDp = 640)
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import kotlin.math.atan2
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.sin
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* Pure, platform-agnostic core of the ASCII morphing sphere.
|
||||
*
|
||||
* No Android, no Compose — only `kotlin.math`. Intended as the single source of
|
||||
* truth for the sphere algorithm. Android renders via Compose Canvas; a JS port
|
||||
* in `preview/web/` mirrors this file for browser iteration; future renderers
|
||||
* (Compose Desktop, terminal TUI) can call this same core.
|
||||
*/
|
||||
|
||||
/** Agent visual state — controls animation parameters and color palette. */
|
||||
enum class SphereState {
|
||||
Idle,
|
||||
Thinking,
|
||||
Streaming,
|
||||
Listening,
|
||||
Speaking,
|
||||
Error
|
||||
}
|
||||
|
||||
/** Animated parameter bundle — interpolated by the caller for smooth state transitions. */
|
||||
data class SphereParams(
|
||||
val breatheSpeed: Float,
|
||||
val breatheAmp: Float,
|
||||
val lightSpeedX: Float,
|
||||
val lightSpeedY: Float,
|
||||
val lightInfluence: Float,
|
||||
val coreTightness: Float,
|
||||
val turbulenceAmp: Float,
|
||||
val rippleScale: Float,
|
||||
val heartbeatSpeed: Float,
|
||||
val radialFlowSpeed: Float
|
||||
)
|
||||
|
||||
/** Two color poles mixed by a time-varying phase. */
|
||||
data class SphereColors(
|
||||
val r1: Float, val g1: Float, val b1: Float,
|
||||
val r2: Float, val g2: Float, val b2: Float
|
||||
)
|
||||
|
||||
fun paramsFor(state: SphereState): SphereParams = when (state) {
|
||||
SphereState.Idle -> SphereParams(
|
||||
breatheSpeed = 0.5f, breatheAmp = 0.04f,
|
||||
lightSpeedX = 0.25f, lightSpeedY = 0.18f, lightInfluence = 0.35f,
|
||||
coreTightness = 0.75f, turbulenceAmp = 0.06f,
|
||||
rippleScale = 1.0f, heartbeatSpeed = 1.0f, radialFlowSpeed = 0.2f
|
||||
)
|
||||
SphereState.Thinking -> SphereParams(
|
||||
breatheSpeed = 0.8f, breatheAmp = 0.02f,
|
||||
lightSpeedX = 0.5f, lightSpeedY = 0.35f, lightInfluence = 0.30f,
|
||||
coreTightness = 0.90f, turbulenceAmp = 0.12f,
|
||||
rippleScale = 1.5f, heartbeatSpeed = 4.0f, radialFlowSpeed = 0.1f
|
||||
)
|
||||
SphereState.Streaming -> SphereParams(
|
||||
breatheSpeed = 0.3f, breatheAmp = 0.06f,
|
||||
lightSpeedX = 0.15f, lightSpeedY = 0.10f, lightInfluence = 0.25f,
|
||||
coreTightness = 0.60f, turbulenceAmp = 0.08f,
|
||||
rippleScale = 2.0f, heartbeatSpeed = 1.5f, radialFlowSpeed = 0.5f
|
||||
)
|
||||
SphereState.Listening -> SphereParams(
|
||||
breatheSpeed = 0.55f, breatheAmp = 0.035f,
|
||||
lightSpeedX = 0.22f, lightSpeedY = 0.16f, lightInfluence = 0.38f,
|
||||
coreTightness = 0.78f, turbulenceAmp = 0.05f,
|
||||
rippleScale = 0.9f, heartbeatSpeed = 1.2f, radialFlowSpeed = 0.18f
|
||||
)
|
||||
SphereState.Speaking -> SphereParams(
|
||||
breatheSpeed = 0.45f, breatheAmp = 0.05f,
|
||||
lightSpeedX = 0.20f, lightSpeedY = 0.14f, lightInfluence = 0.30f,
|
||||
coreTightness = 0.55f, turbulenceAmp = 0.07f,
|
||||
rippleScale = 1.8f, heartbeatSpeed = 1.8f, radialFlowSpeed = 0.45f
|
||||
)
|
||||
SphereState.Error -> SphereParams(
|
||||
breatheSpeed = 1.2f, breatheAmp = 0.03f,
|
||||
lightSpeedX = 0.7f, lightSpeedY = 0.6f, lightInfluence = 0.40f,
|
||||
coreTightness = 0.80f, turbulenceAmp = 0.15f,
|
||||
rippleScale = 0.5f, heartbeatSpeed = 6.0f, radialFlowSpeed = 0.3f
|
||||
)
|
||||
}
|
||||
|
||||
fun colorsFor(state: SphereState): SphereColors = when (state) {
|
||||
SphereState.Idle -> SphereColors(
|
||||
0.25f, 0.85f, 0.40f,
|
||||
0.61f, 0.42f, 0.94f
|
||||
)
|
||||
SphereState.Thinking -> SphereColors(
|
||||
0.30f, 0.55f, 0.95f,
|
||||
0.55f, 0.35f, 0.90f
|
||||
)
|
||||
SphereState.Streaming -> SphereColors(
|
||||
0.20f, 0.90f, 0.50f,
|
||||
0.25f, 0.80f, 0.85f
|
||||
)
|
||||
SphereState.Listening -> SphereColors(
|
||||
0.35f, 0.55f, 0.95f,
|
||||
0.65f, 0.45f, 0.95f
|
||||
)
|
||||
SphereState.Speaking -> SphereColors(
|
||||
0.25f, 0.92f, 0.55f,
|
||||
0.30f, 0.85f, 0.88f
|
||||
)
|
||||
SphereState.Error -> SphereColors(
|
||||
0.90f, 0.30f, 0.25f,
|
||||
0.85f, 0.50f, 0.20f
|
||||
)
|
||||
}
|
||||
|
||||
/** All inputs needed to render one frame — populated by the caller from animation state. */
|
||||
data class SphereFrame(
|
||||
val cols: Int,
|
||||
val rows: Int,
|
||||
val charAspect: Float,
|
||||
val state: SphereState,
|
||||
val time: Float,
|
||||
val colorPhase: Float,
|
||||
// Animated base params (caller smooths transitions)
|
||||
val breatheSpeed: Float,
|
||||
val breatheAmp: Float,
|
||||
val lightSpeedX: Float,
|
||||
val lightSpeedY: Float,
|
||||
val lightInfluence: Float,
|
||||
val coreTightness: Float,
|
||||
val turbulenceAmp: Float,
|
||||
val rippleScale: Float,
|
||||
val heartbeatSpeed: Float,
|
||||
val radialFlowSpeed: Float,
|
||||
// Animated colors
|
||||
val cr1: Float, val cg1: Float, val cb1: Float,
|
||||
val cr2: Float, val cg2: Float, val cb2: Float,
|
||||
// Intensity + modulation
|
||||
val intensity: Float,
|
||||
val toolCallBurst: Float,
|
||||
val voiceAmplitude: Float,
|
||||
val voiceMode: Boolean,
|
||||
val voiceRadiusScale: Float
|
||||
)
|
||||
|
||||
/** What to draw at one grid cell. RGB + alpha in 0..1. */
|
||||
data class SphereCell(
|
||||
val col: Int,
|
||||
val row: Int,
|
||||
val char: Char,
|
||||
val r: Float,
|
||||
val g: Float,
|
||||
val b: Float,
|
||||
val alpha: Float
|
||||
)
|
||||
|
||||
private val charSets = arrayOf(
|
||||
" ·:;=+*#%@",
|
||||
" .:;=+*#%@",
|
||||
" ·;:+=*%#@",
|
||||
" .:;+=*#@%"
|
||||
)
|
||||
private const val dataChars = "01<>[]{}|/\\~^"
|
||||
|
||||
/**
|
||||
* Iterates the `rows × cols` grid for one frame and invokes `onCell` for every
|
||||
* cell that should be drawn. Cells outside the drawable region (or excluded by
|
||||
* sparsity) are silently skipped — the callback sees only visible glyphs.
|
||||
*/
|
||||
fun forEachSphereCell(frame: SphereFrame, onCell: (SphereCell) -> Unit) {
|
||||
val amp = frame.voiceAmplitude.coerceIn(0f, 1f)
|
||||
|
||||
// ── Voice modulation of animated base params ─────────────────
|
||||
val breatheSpeed = when (frame.state) {
|
||||
SphereState.Listening -> lerp(frame.breatheSpeed, frame.breatheSpeed * 1.3f, amp * 0.5f)
|
||||
SphereState.Speaking -> lerp(frame.breatheSpeed, frame.breatheSpeed * 2.0f, amp)
|
||||
else -> frame.breatheSpeed
|
||||
}
|
||||
val turbulenceAmp = when (frame.state) {
|
||||
SphereState.Listening -> frame.turbulenceAmp + amp * 0.15f
|
||||
SphereState.Speaking -> frame.turbulenceAmp + amp * 0.5f
|
||||
else -> frame.turbulenceAmp
|
||||
}
|
||||
val coreWarmth = when (frame.state) {
|
||||
SphereState.Speaking -> lerp(0.30f, 1.0f, amp)
|
||||
else -> 0.30f
|
||||
}
|
||||
val wobbleAmplitude = when (frame.state) {
|
||||
SphereState.Listening -> 0.06f * (1f + amp * 0.3f)
|
||||
SphereState.Speaking -> 0.06f * (1f + amp * 0.8f)
|
||||
else -> 0.06f
|
||||
}
|
||||
val dataRingSpeed = when (frame.state) {
|
||||
SphereState.Speaking -> 0.4f * (1f + amp * 3f)
|
||||
else -> 0.4f
|
||||
}
|
||||
|
||||
val cx = frame.cols / 2f
|
||||
val cy = frame.rows / 2f
|
||||
val charAspect = frame.charAspect
|
||||
|
||||
// Matches legacy 0.60 envelope so the data ring (1.55×) fits the grid.
|
||||
val maxRadiusFromRows = (frame.rows / 2f) * 0.60f
|
||||
val maxRadiusFromCols = (frame.cols / 2f) * charAspect * 0.60f
|
||||
val baseRadius = minOf(maxRadiusFromRows, maxRadiusFromCols) * frame.voiceRadiusScale
|
||||
val t = frame.time
|
||||
|
||||
val breathe = sin(t * breatheSpeed) * frame.breatheAmp
|
||||
val breathingRadius = baseRadius * (1f + breathe)
|
||||
|
||||
val noiseJitter1 = fbm(t * 0.05f + 7.3f, 1.7f) * 0.5f
|
||||
val noiseJitter2 = fbm(3.1f, t * 0.04f + 13.7f) * 0.5f
|
||||
val lightAngle1 = t * frame.lightSpeedX + noiseJitter1
|
||||
val lightAngle2 = t * frame.lightSpeedY + noiseJitter2
|
||||
val lx = sin(lightAngle1) * 0.65f
|
||||
val ly = cos(lightAngle2) * 0.65f
|
||||
val lz = sqrt((1f - lx * lx - ly * ly).coerceAtLeast(0.01f))
|
||||
|
||||
val heartbeat = sin(t * frame.heartbeatSpeed) * 0.5f + 0.5f
|
||||
|
||||
val pulse = sin(frame.colorPhase) * 0.5f + 0.5f
|
||||
val colR = lerp(frame.cr1, frame.cr2, pulse)
|
||||
val colG = lerp(frame.cg1, frame.cg2, pulse)
|
||||
val colB = lerp(frame.cb1, frame.cb2, pulse)
|
||||
|
||||
val distWeight = 1f - frame.lightInfluence
|
||||
|
||||
val effTurbulence = turbulenceAmp + frame.intensity * 0.04f + frame.toolCallBurst * 0.15f
|
||||
val effRadialFlow = frame.radialFlowSpeed + frame.intensity * 0.3f
|
||||
val effRipple = frame.rippleScale + frame.intensity * 0.5f + frame.toolCallBurst * 1.0f
|
||||
|
||||
for (row in 0 until frame.rows) {
|
||||
for (col in 0 until frame.cols) {
|
||||
val dx = (col - cx) * charAspect
|
||||
val dy = (row - cy)
|
||||
val dist = sqrt(dx * dx + dy * dy)
|
||||
val angle = atan2(dy, dx)
|
||||
|
||||
val perimeterNoise = fbm(
|
||||
angle * 1.8f + t * 0.08f,
|
||||
angle * 0.7f + t * 0.12f
|
||||
) * 2f - 1f
|
||||
val distortedRadius = breathingRadius * (1f + perimeterNoise * wobbleAmplitude)
|
||||
val glowRadius = distortedRadius * 1.35f
|
||||
val dataRingInner = distortedRadius * 1.40f
|
||||
val dataRingOuter = distortedRadius * 1.55f
|
||||
val normDist = dist / distortedRadius
|
||||
|
||||
if (dist > dataRingOuter) continue
|
||||
|
||||
if (normDist <= 1f) {
|
||||
// ── INSIDE SPHERE ────────────────────────────
|
||||
val nx = dx / distortedRadius
|
||||
val ny2 = dy / distortedRadius
|
||||
val nzSq = (1f - nx * nx - ny2 * ny2).coerceAtLeast(0f)
|
||||
val nz = sqrt(nzSq)
|
||||
|
||||
val distBrightness = (1f - normDist * normDist * frame.coreTightness)
|
||||
.coerceAtLeast(0.15f)
|
||||
|
||||
val directionalLight = (nx * lx + ny2 * ly + nz * lz)
|
||||
.coerceIn(0f, 1f)
|
||||
|
||||
val structural = fbm(
|
||||
col * 0.25f + t * 0.18f,
|
||||
row * 0.25f + t * 0.13f,
|
||||
octaves = 2
|
||||
) * 0.15f - 0.075f
|
||||
|
||||
val turbulence = fbm(
|
||||
col * 0.8f + t * 0.6f,
|
||||
row * 0.8f + t * 0.45f,
|
||||
octaves = 2
|
||||
) * effTurbulence - effTurbulence * 0.5f
|
||||
|
||||
val radialFlow = fbm(
|
||||
angle * 2f + t * 0.15f,
|
||||
dist * 0.3f - t * effRadialFlow,
|
||||
octaves = 2
|
||||
) * 0.06f - 0.03f
|
||||
|
||||
val ripple = (
|
||||
sin(normDist * 8f - t * 1.2f) * 0.04f * (1f - normDist) +
|
||||
sin(normDist * 5f - t * 0.7f + 2f) * 0.03f * (1f - normDist)
|
||||
) * effRipple
|
||||
|
||||
val heartbeatFx = heartbeat * 0.05f * (1f - normDist * normDist)
|
||||
|
||||
val brightness = distWeight * distBrightness +
|
||||
frame.lightInfluence * directionalLight +
|
||||
heartbeatFx
|
||||
val charNoise = structural + turbulence + radialFlow + ripple
|
||||
|
||||
val rotationPhase = (t * 0.3f + col * 0.17f + row * 0.13f).toInt()
|
||||
val chars = charSets[rotationPhase.and(3)]
|
||||
|
||||
val charIdx = ((brightness + charNoise) * (chars.length - 1))
|
||||
.toInt().coerceIn(1, chars.length - 1)
|
||||
val ch = chars[charIdx]
|
||||
|
||||
val edgeFade = when {
|
||||
normDist > 0.80f -> {
|
||||
val ef = (normDist - 0.80f) / 0.20f
|
||||
1f - ef * ef
|
||||
}
|
||||
else -> 1f
|
||||
}
|
||||
|
||||
val scanline = if (row % 2 == 1) 0.82f else 1f
|
||||
val alpha = ((brightness * 0.4f + 0.6f) * edgeFade * scanline)
|
||||
.coerceIn(0.1f, 1f)
|
||||
|
||||
val warmth = (1f - normDist * normDist) * (coreWarmth * 0.40f)
|
||||
val lightBoost = directionalLight * 0.08f
|
||||
|
||||
onCell(SphereCell(
|
||||
col = col, row = row, char = ch,
|
||||
r = (colR + lightBoost + warmth).coerceIn(0f, 1f),
|
||||
g = (colG + lightBoost * 0.5f + warmth).coerceIn(0f, 1f),
|
||||
b = (colB + lightBoost + warmth).coerceIn(0f, 1f),
|
||||
alpha = alpha
|
||||
))
|
||||
} else if (dist <= glowRadius) {
|
||||
// ── GLOW / DEBRIS ZONE ───────────────────────
|
||||
val glowT = (dist - distortedRadius) / (glowRadius - distortedRadius)
|
||||
val glowFalloff = (1f - glowT).coerceIn(0f, 1f)
|
||||
|
||||
val sparsityNoise = fbm(
|
||||
angle * 3.5f + t * 0.25f,
|
||||
dist * 0.4f + t * 0.08f,
|
||||
octaves = 2
|
||||
)
|
||||
val sparsityThreshold = 0.35f + glowT * 0.25f
|
||||
if (sparsityNoise < sparsityThreshold) continue
|
||||
|
||||
val debrisChars = "·:;- "
|
||||
val debrisIdx = ((1f - glowFalloff) * (debrisChars.length - 1))
|
||||
.toInt().coerceIn(0, debrisChars.length - 1)
|
||||
val ch = debrisChars[debrisIdx]
|
||||
if (ch == ' ') continue
|
||||
|
||||
val alpha = glowFalloff * 0.85f
|
||||
onCell(SphereCell(
|
||||
col = col, row = row, char = ch,
|
||||
r = colR.coerceIn(0f, 1f),
|
||||
g = colG.coerceIn(0f, 1f),
|
||||
b = colB.coerceIn(0f, 1f),
|
||||
alpha = alpha
|
||||
))
|
||||
} else if (dist >= dataRingInner) {
|
||||
// ── DATA RING ────────────────────────────────
|
||||
val ringT = (dist - dataRingInner) / (dataRingOuter - dataRingInner)
|
||||
val orbitAngle = angle - t * dataRingSpeed + ringT * 1.5f
|
||||
val ringNoise = fbm(
|
||||
orbitAngle * 4f + t * 0.3f,
|
||||
ringT * 3f + t * 0.15f,
|
||||
octaves = 2
|
||||
)
|
||||
if (ringNoise < 0.55f) continue
|
||||
|
||||
val dataIdx = ((orbitAngle * 2f + t * 0.5f) * dataChars.length)
|
||||
.toInt().mod(dataChars.length)
|
||||
val ch = dataChars[dataIdx]
|
||||
|
||||
val ringFade = (1f - ringT).coerceIn(0f, 1f)
|
||||
val alpha = ringFade * 0.65f
|
||||
onCell(SphereCell(
|
||||
col = col, row = row, char = ch,
|
||||
r = (colR * 0.85f).coerceIn(0f, 1f),
|
||||
g = (colG * 0.85f).coerceIn(0f, 1f),
|
||||
b = (colB * 0.85f).coerceIn(0f, 1f),
|
||||
alpha = alpha
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Procedural noise (public so renderers can share / verify behavior) ──
|
||||
|
||||
fun hash(x: Int, y: Int): Float {
|
||||
var h = x * 374761393 + y * 668265263
|
||||
h = (h xor (h ushr 13)) * 1274126177
|
||||
h = h xor (h ushr 16)
|
||||
return (h and 0x7fffffff) / 2147483647f
|
||||
}
|
||||
|
||||
fun smoothNoise(x: Float, y: Float): Float {
|
||||
val xi = floor(x).toInt()
|
||||
val yi = floor(y).toInt()
|
||||
val xf = x - xi
|
||||
val yf = y - yi
|
||||
val u = xf * xf * (3f - 2f * xf)
|
||||
val v = yf * yf * (3f - 2f * yf)
|
||||
val n00 = hash(xi, yi)
|
||||
val n10 = hash(xi + 1, yi)
|
||||
val n01 = hash(xi, yi + 1)
|
||||
val n11 = hash(xi + 1, yi + 1)
|
||||
return lerp(lerp(n00, n10, u), lerp(n01, n11, u), v)
|
||||
}
|
||||
|
||||
fun fbm(x: Float, y: Float, octaves: Int = 3): Float {
|
||||
var value = 0f
|
||||
var amplitude = 0.5f
|
||||
var frequency = 1f
|
||||
for (i in 0 until octaves) {
|
||||
value += amplitude * smoothNoise(x * frequency, y * frequency)
|
||||
amplitude *= 0.5f
|
||||
frequency *= 2f
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
fun lerp(a: Float, b: Float, t: Float): Float = a + (b - a) * t
|
||||
@@ -0,0 +1,49 @@
|
||||
# MorphingSphere — browser preview
|
||||
|
||||
A zero-dependency browser harness for iterating on the ASCII morphing sphere
|
||||
without Android Studio or an emulator.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
# From repo root:
|
||||
python3 -m http.server 8000 --directory preview/web
|
||||
# then open http://localhost:8000/
|
||||
```
|
||||
|
||||
Or open `preview/web/index.html` directly in a browser — the ES module import
|
||||
requires `file://` access (works in Firefox, blocked in Chrome); the local
|
||||
server path above works everywhere.
|
||||
|
||||
## Controls
|
||||
|
||||
- **State** — Idle · Thinking · Streaming · Listening · Speaking · Error
|
||||
- **Voice mode** — expands the sphere radius ~8%
|
||||
- **Voice amp** — 0..1, modulates Listening/Speaking behavior
|
||||
- **Intensity** — general activity boost
|
||||
- **Tool burst** — transient spike (ripples, turbulence)
|
||||
- **Pause** — freezes animation; <kbd>Space</kbd> toggles
|
||||
- **Reset t** — restart the time origin
|
||||
- Keyboard: <kbd>1</kbd>..<kbd>6</kbd> picks a state
|
||||
|
||||
## How this relates to the Kotlin source
|
||||
|
||||
`sphere.js` is a **line-for-line mirror** of
|
||||
`app/src/main/kotlin/com/hermesandroid/relay/ui/components/MorphingSphereCore.kt`.
|
||||
|
||||
When you change the algorithm:
|
||||
|
||||
1. Edit `MorphingSphereCore.kt` (the source of truth)
|
||||
2. Mirror the same change in `sphere.js`
|
||||
3. Reload the browser to verify
|
||||
|
||||
This keeps Android + browser visually consistent. The only deliberate
|
||||
differences are:
|
||||
|
||||
- **Font** — Android uses `FontFamily.Monospace`; browser uses the OS default
|
||||
mono. Glyph widths and antialiasing differ slightly. Bundle JetBrains Mono
|
||||
if you need pixel parity across surfaces.
|
||||
- **32-bit integer math** — the hash function uses `Math.imul` + `|0` to
|
||||
match Kotlin's `Int` overflow semantics exactly.
|
||||
- **`.mod(n)`** — Kotlin's floored-positive modulo vs JS `%` sign-preserving;
|
||||
the port handles this in the data ring code.
|
||||
@@ -0,0 +1,420 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>MorphingSphere — browser preview</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0a0a0a;
|
||||
--panel: rgba(20, 20, 20, 0.85);
|
||||
--border: rgba(255, 255, 255, 0.08);
|
||||
--text: rgba(255, 255, 255, 0.9);
|
||||
--muted: rgba(255, 255, 255, 0.55);
|
||||
--accent: #40eb8c;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body {
|
||||
margin: 0; padding: 0;
|
||||
width: 100%; height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
#stage {
|
||||
position: fixed; inset: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
canvas#sphere {
|
||||
background: var(--bg);
|
||||
display: block;
|
||||
}
|
||||
#panel {
|
||||
position: fixed;
|
||||
top: 16px; right: 16px;
|
||||
width: 280px;
|
||||
padding: 14px 16px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
font-size: 13px;
|
||||
user-select: none;
|
||||
}
|
||||
#panel h2 {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
.row {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
margin: 8px 0;
|
||||
gap: 10px;
|
||||
}
|
||||
.row label {
|
||||
flex: 0 0 auto;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.row .value {
|
||||
min-width: 36px;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
}
|
||||
input[type="range"] {
|
||||
flex: 1;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
select {
|
||||
flex: 1;
|
||||
background: #151515;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 4px 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
#fps {
|
||||
position: fixed;
|
||||
bottom: 12px; left: 12px;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
}
|
||||
#hint {
|
||||
position: fixed;
|
||||
bottom: 12px; right: 16px;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
#hint code {
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
background: rgba(255,255,255,0.06);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
button.mini {
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 3px 7px;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
button.mini:hover { color: var(--text); border-color: rgba(255,255,255,0.2); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="stage">
|
||||
<canvas id="sphere"></canvas>
|
||||
</div>
|
||||
|
||||
<div id="panel">
|
||||
<h2>MorphingSphere</h2>
|
||||
|
||||
<div class="row">
|
||||
<label>State</label>
|
||||
<select id="state">
|
||||
<option>Idle</option>
|
||||
<option>Thinking</option>
|
||||
<option>Streaming</option>
|
||||
<option>Listening</option>
|
||||
<option>Speaking</option>
|
||||
<option>Error</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label>Voice mode</label>
|
||||
<input type="checkbox" id="voiceMode">
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label>Voice amp</label>
|
||||
<input type="range" id="voiceAmp" min="0" max="1" step="0.01" value="0">
|
||||
<span class="value" id="voiceAmpVal">0.00</span>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label>Intensity</label>
|
||||
<input type="range" id="intensity" min="0" max="1" step="0.01" value="0">
|
||||
<span class="value" id="intensityVal">0.00</span>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label>Tool burst</label>
|
||||
<input type="range" id="toolBurst" min="0" max="1" step="0.01" value="0">
|
||||
<span class="value" id="toolBurstVal">0.00</span>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label><input type="checkbox" id="paused"> Pause</label>
|
||||
<button class="mini" id="resetTime">reset t</button>
|
||||
</div>
|
||||
|
||||
<div class="row" style="opacity: 0.6;">
|
||||
<label>Cols × Rows</label>
|
||||
<span class="value" style="min-width: auto;">58 × 34</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="fps">— fps</div>
|
||||
<div id="hint"><code>space</code> pause · <code>1-6</code> state</div>
|
||||
|
||||
<script type="module">
|
||||
import { SphereState, paramsFor, colorsFor, forEachSphereCell } from "./sphere.js";
|
||||
|
||||
// ── DOM wiring ─────────────────────────────────────────────────────
|
||||
const canvas = document.getElementById("sphere");
|
||||
const ctx = canvas.getContext("2d");
|
||||
const stateSel = document.getElementById("state");
|
||||
const voiceModeEl = document.getElementById("voiceMode");
|
||||
const voiceAmpEl = document.getElementById("voiceAmp");
|
||||
const voiceAmpVal = document.getElementById("voiceAmpVal");
|
||||
const intensityEl = document.getElementById("intensity");
|
||||
const intensityVal = document.getElementById("intensityVal");
|
||||
const toolBurstEl = document.getElementById("toolBurst");
|
||||
const toolBurstVal = document.getElementById("toolBurstVal");
|
||||
const pausedEl = document.getElementById("paused");
|
||||
const resetTimeBtn = document.getElementById("resetTime");
|
||||
const fpsEl = document.getElementById("fps");
|
||||
|
||||
// Sync displayed values for sliders.
|
||||
function bindSlider(el, display) {
|
||||
const update = () => display.textContent = parseFloat(el.value).toFixed(2);
|
||||
el.addEventListener("input", update);
|
||||
update();
|
||||
}
|
||||
bindSlider(voiceAmpEl, voiceAmpVal);
|
||||
bindSlider(intensityEl, intensityVal);
|
||||
bindSlider(toolBurstEl, toolBurstVal);
|
||||
|
||||
// Keyboard shortcuts — space pauses, 1-6 pick state.
|
||||
const stateKeys = ["Idle", "Thinking", "Streaming", "Listening", "Speaking", "Error"];
|
||||
window.addEventListener("keydown", (e) => {
|
||||
if (e.code === "Space") { pausedEl.checked = !pausedEl.checked; e.preventDefault(); }
|
||||
const n = parseInt(e.key, 10);
|
||||
if (n >= 1 && n <= 6) {
|
||||
stateSel.value = stateKeys[n - 1];
|
||||
stateSel.dispatchEvent(new Event("change"));
|
||||
}
|
||||
});
|
||||
|
||||
// ── Canvas sizing ──────────────────────────────────────────────────
|
||||
const COLS = 58;
|
||||
const ROWS = 34;
|
||||
function resize() {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
// Fit canvas into viewport while keeping 58:34 cell grid legible.
|
||||
// Aspect chosen so cells are close to square for legible glyphs.
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
// Target grid aspect ~= COLS / (ROWS * 1.8) — chars are ~0.55 wide per em.
|
||||
const targetAspect = COLS / (ROWS * 1.7);
|
||||
let w = vw * 0.92;
|
||||
let h = w / targetAspect;
|
||||
if (h > vh * 0.92) {
|
||||
h = vh * 0.92;
|
||||
w = h * targetAspect;
|
||||
}
|
||||
canvas.style.width = `${w}px`;
|
||||
canvas.style.height = `${h}px`;
|
||||
canvas.width = Math.floor(w * dpr);
|
||||
canvas.height = Math.floor(h * dpr);
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
}
|
||||
resize();
|
||||
window.addEventListener("resize", resize);
|
||||
|
||||
// ── Animated state params (mirrors Compose animateFloatAsState) ────
|
||||
// FastOutSlowInEasing ≈ cubic-bezier(0.4, 0, 0.2, 1).
|
||||
const EASE_DURATION = 0.8; // seconds
|
||||
const VOICE_SCALE_DURATION = 0.6;
|
||||
|
||||
function fastOutSlowIn(t) {
|
||||
// Good enough approximation of Android's FastOutSlowInEasing.
|
||||
// Cubic-bezier(0.4, 0.0, 0.2, 1.0) — closed-form approximation:
|
||||
if (t <= 0) return 0;
|
||||
if (t >= 1) return 1;
|
||||
// Solve x=t for bezier and evaluate y — cheap Newton's iteration.
|
||||
const cx = 3 * 0.4, bx = 3 * (0.2 - 0.4) - cx, ax = 1 - cx - bx;
|
||||
const cy = 3 * 0.0, by = 3 * (1.0 - 0.0) - cy, ay = 1 - cy - by;
|
||||
// Newton solve for u given t = axu³ + bxu² + cxu
|
||||
let u = t;
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const x = ((ax * u + bx) * u + cx) * u;
|
||||
const dx = (3 * ax * u + 2 * bx) * u + cx;
|
||||
if (Math.abs(dx) < 1e-6) break;
|
||||
u -= (x - t) / dx;
|
||||
}
|
||||
return ((ay * u + by) * u + cy) * u;
|
||||
}
|
||||
|
||||
// Tween helper: returns interpolated value given start/target/startTime/duration.
|
||||
class Tween {
|
||||
constructor(value) {
|
||||
this.current = value;
|
||||
this.target = value;
|
||||
this.start = value;
|
||||
this.startTime = 0;
|
||||
this.duration = EASE_DURATION;
|
||||
}
|
||||
setTarget(v, nowSec, duration = EASE_DURATION) {
|
||||
if (v === this.target) return;
|
||||
this.start = this.current;
|
||||
this.target = v;
|
||||
this.startTime = nowSec;
|
||||
this.duration = duration;
|
||||
}
|
||||
update(nowSec) {
|
||||
if (this.duration <= 0) { this.current = this.target; return; }
|
||||
const t = Math.min(1, (nowSec - this.startTime) / this.duration);
|
||||
this.current = this.start + (this.target - this.start) * fastOutSlowIn(t);
|
||||
}
|
||||
}
|
||||
|
||||
const initial = paramsFor(SphereState.Idle);
|
||||
const initialC = colorsFor(SphereState.Idle);
|
||||
const tw = {
|
||||
breatheSpeed: new Tween(initial.breatheSpeed),
|
||||
breatheAmp: new Tween(initial.breatheAmp),
|
||||
lightSpeedX: new Tween(initial.lightSpeedX),
|
||||
lightSpeedY: new Tween(initial.lightSpeedY),
|
||||
lightInfluence: new Tween(initial.lightInfluence),
|
||||
coreTightness: new Tween(initial.coreTightness),
|
||||
turbulenceAmp: new Tween(initial.turbulenceAmp),
|
||||
rippleScale: new Tween(initial.rippleScale),
|
||||
heartbeatSpeed: new Tween(initial.heartbeatSpeed),
|
||||
radialFlowSpeed: new Tween(initial.radialFlowSpeed),
|
||||
cr1: new Tween(initialC.r1), cg1: new Tween(initialC.g1), cb1: new Tween(initialC.b1),
|
||||
cr2: new Tween(initialC.r2), cg2: new Tween(initialC.g2), cb2: new Tween(initialC.b2),
|
||||
voiceRadiusScale: new Tween(1.0),
|
||||
};
|
||||
|
||||
function retargetFor(state, nowSec) {
|
||||
const p = paramsFor(state);
|
||||
const c = colorsFor(state);
|
||||
tw.breatheSpeed.setTarget(p.breatheSpeed, nowSec);
|
||||
tw.breatheAmp.setTarget(p.breatheAmp, nowSec);
|
||||
tw.lightSpeedX.setTarget(p.lightSpeedX, nowSec);
|
||||
tw.lightSpeedY.setTarget(p.lightSpeedY, nowSec);
|
||||
tw.lightInfluence.setTarget(p.lightInfluence, nowSec);
|
||||
tw.coreTightness.setTarget(p.coreTightness, nowSec);
|
||||
tw.turbulenceAmp.setTarget(p.turbulenceAmp, nowSec);
|
||||
tw.rippleScale.setTarget(p.rippleScale, nowSec);
|
||||
tw.heartbeatSpeed.setTarget(p.heartbeatSpeed, nowSec);
|
||||
tw.radialFlowSpeed.setTarget(p.radialFlowSpeed, nowSec);
|
||||
tw.cr1.setTarget(c.r1, nowSec); tw.cg1.setTarget(c.g1, nowSec); tw.cb1.setTarget(c.b1, nowSec);
|
||||
tw.cr2.setTarget(c.r2, nowSec); tw.cg2.setTarget(c.g2, nowSec); tw.cb2.setTarget(c.b2, nowSec);
|
||||
}
|
||||
|
||||
stateSel.addEventListener("change", () => retargetFor(stateSel.value, perfNowSec()));
|
||||
voiceModeEl.addEventListener("change", () => {
|
||||
tw.voiceRadiusScale.setTarget(voiceModeEl.checked ? 1.08 : 1.0, perfNowSec(), VOICE_SCALE_DURATION);
|
||||
});
|
||||
resetTimeBtn.addEventListener("click", () => { timeOrigin = performance.now(); });
|
||||
|
||||
function perfNowSec() { return performance.now() / 1000; }
|
||||
|
||||
// ── Animation loop ─────────────────────────────────────────────────
|
||||
let timeOrigin = performance.now();
|
||||
let pausedAccum = 0; // total paused ms
|
||||
let pauseStart = null;
|
||||
let lastFrame = performance.now();
|
||||
let fpsAccum = 0, fpsCount = 0;
|
||||
|
||||
pausedEl.addEventListener("change", () => {
|
||||
if (pausedEl.checked) pauseStart = performance.now();
|
||||
else if (pauseStart !== null) {
|
||||
pausedAccum += performance.now() - pauseStart;
|
||||
pauseStart = null;
|
||||
}
|
||||
});
|
||||
|
||||
function animationTime() {
|
||||
const now = pausedEl.checked ? (pauseStart ?? performance.now()) : performance.now();
|
||||
return (now - timeOrigin - pausedAccum) / 1000;
|
||||
}
|
||||
function colorPhaseAt(elapsedSec) {
|
||||
const cycle = (elapsedSec * 1000) % 8000;
|
||||
return (cycle / 8000) * 6.2832;
|
||||
}
|
||||
|
||||
function render() {
|
||||
const nowMs = performance.now();
|
||||
const dt = nowMs - lastFrame;
|
||||
lastFrame = nowMs;
|
||||
fpsAccum += dt;
|
||||
fpsCount++;
|
||||
if (fpsAccum >= 500) {
|
||||
const fps = Math.round(1000 * fpsCount / fpsAccum);
|
||||
fpsEl.textContent = `${fps} fps`;
|
||||
fpsAccum = 0; fpsCount = 0;
|
||||
}
|
||||
|
||||
const nowSec = perfNowSec();
|
||||
for (const k in tw) tw[k].update(nowSec);
|
||||
|
||||
// ── Canvas layout (mirrors Compose Canvas block) ──
|
||||
const canvasW = parseFloat(canvas.style.width);
|
||||
const canvasH = parseFloat(canvas.style.height);
|
||||
const cellW = canvasW / COLS;
|
||||
const cellH = canvasH / ROWS;
|
||||
const charSize = Math.min(cellW * 1.3, cellH * 1.1);
|
||||
const charAspect = cellW / cellH;
|
||||
|
||||
ctx.clearRect(0, 0, canvasW, canvasH);
|
||||
ctx.font = `${charSize}px ui-monospace, "JetBrains Mono", "Menlo", "Consolas", monospace`;
|
||||
ctx.textBaseline = "alphabetic";
|
||||
|
||||
const state = stateSel.value;
|
||||
const t = animationTime();
|
||||
const frame = {
|
||||
cols: COLS,
|
||||
rows: ROWS,
|
||||
charAspect,
|
||||
state,
|
||||
time: t,
|
||||
colorPhase: colorPhaseAt(t),
|
||||
breatheSpeed: tw.breatheSpeed.current,
|
||||
breatheAmp: tw.breatheAmp.current,
|
||||
lightSpeedX: tw.lightSpeedX.current,
|
||||
lightSpeedY: tw.lightSpeedY.current,
|
||||
lightInfluence: tw.lightInfluence.current,
|
||||
coreTightness: tw.coreTightness.current,
|
||||
turbulenceAmp: tw.turbulenceAmp.current,
|
||||
rippleScale: tw.rippleScale.current,
|
||||
heartbeatSpeed: tw.heartbeatSpeed.current,
|
||||
radialFlowSpeed: tw.radialFlowSpeed.current,
|
||||
cr1: tw.cr1.current, cg1: tw.cg1.current, cb1: tw.cb1.current,
|
||||
cr2: tw.cr2.current, cg2: tw.cg2.current, cb2: tw.cb2.current,
|
||||
intensity: parseFloat(intensityEl.value),
|
||||
toolCallBurst: parseFloat(toolBurstEl.value),
|
||||
voiceAmplitude: parseFloat(voiceAmpEl.value),
|
||||
voiceMode: voiceModeEl.checked,
|
||||
voiceRadiusScale: tw.voiceRadiusScale.current,
|
||||
};
|
||||
|
||||
forEachSphereCell(frame, (col, row, ch, r, g, b, a) => {
|
||||
// Same placement as Android: baseline at 80% of cell height; left edge at col*cellW.
|
||||
const px = col * cellW;
|
||||
const py = row * cellH + cellH * 0.8;
|
||||
ctx.fillStyle = `rgba(${Math.round(r * 255)},${Math.round(g * 255)},${Math.round(b * 255)},${a.toFixed(3)})`;
|
||||
ctx.fillText(ch, px, py);
|
||||
});
|
||||
|
||||
requestAnimationFrame(render);
|
||||
}
|
||||
|
||||
render();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,268 @@
|
||||
// MorphingSphere — JavaScript port.
|
||||
//
|
||||
// This is a line-for-line mirror of MorphingSphereCore.kt. Keep them in sync.
|
||||
// No build step, no dependencies — loaded directly by index.html.
|
||||
|
||||
// ── State + params ───────────────────────────────────────────────────
|
||||
|
||||
export const SphereState = Object.freeze({
|
||||
Idle: "Idle",
|
||||
Thinking: "Thinking",
|
||||
Streaming: "Streaming",
|
||||
Listening: "Listening",
|
||||
Speaking: "Speaking",
|
||||
Error: "Error",
|
||||
});
|
||||
|
||||
export function paramsFor(state) {
|
||||
switch (state) {
|
||||
case SphereState.Idle:
|
||||
return { breatheSpeed: 0.5, breatheAmp: 0.04, lightSpeedX: 0.25, lightSpeedY: 0.18, lightInfluence: 0.35, coreTightness: 0.75, turbulenceAmp: 0.06, rippleScale: 1.0, heartbeatSpeed: 1.0, radialFlowSpeed: 0.2 };
|
||||
case SphereState.Thinking:
|
||||
return { breatheSpeed: 0.8, breatheAmp: 0.02, lightSpeedX: 0.5, lightSpeedY: 0.35, lightInfluence: 0.30, coreTightness: 0.90, turbulenceAmp: 0.12, rippleScale: 1.5, heartbeatSpeed: 4.0, radialFlowSpeed: 0.1 };
|
||||
case SphereState.Streaming:
|
||||
return { breatheSpeed: 0.3, breatheAmp: 0.06, lightSpeedX: 0.15, lightSpeedY: 0.10, lightInfluence: 0.25, coreTightness: 0.60, turbulenceAmp: 0.08, rippleScale: 2.0, heartbeatSpeed: 1.5, radialFlowSpeed: 0.5 };
|
||||
case SphereState.Listening:
|
||||
return { breatheSpeed: 0.55, breatheAmp: 0.035, lightSpeedX: 0.22, lightSpeedY: 0.16, lightInfluence: 0.38, coreTightness: 0.78, turbulenceAmp: 0.05, rippleScale: 0.9, heartbeatSpeed: 1.2, radialFlowSpeed: 0.18 };
|
||||
case SphereState.Speaking:
|
||||
return { breatheSpeed: 0.45, breatheAmp: 0.05, lightSpeedX: 0.20, lightSpeedY: 0.14, lightInfluence: 0.30, coreTightness: 0.55, turbulenceAmp: 0.07, rippleScale: 1.8, heartbeatSpeed: 1.8, radialFlowSpeed: 0.45 };
|
||||
case SphereState.Error:
|
||||
return { breatheSpeed: 1.2, breatheAmp: 0.03, lightSpeedX: 0.7, lightSpeedY: 0.6, lightInfluence: 0.40, coreTightness: 0.80, turbulenceAmp: 0.15, rippleScale: 0.5, heartbeatSpeed: 6.0, radialFlowSpeed: 0.3 };
|
||||
}
|
||||
throw new Error(`Unknown state: ${state}`);
|
||||
}
|
||||
|
||||
export function colorsFor(state) {
|
||||
switch (state) {
|
||||
case SphereState.Idle: return { r1: 0.25, g1: 0.85, b1: 0.40, r2: 0.61, g2: 0.42, b2: 0.94 };
|
||||
case SphereState.Thinking: return { r1: 0.30, g1: 0.55, b1: 0.95, r2: 0.55, g2: 0.35, b2: 0.90 };
|
||||
case SphereState.Streaming: return { r1: 0.20, g1: 0.90, b1: 0.50, r2: 0.25, g2: 0.80, b2: 0.85 };
|
||||
case SphereState.Listening: return { r1: 0.35, g1: 0.55, b1: 0.95, r2: 0.65, g2: 0.45, b2: 0.95 };
|
||||
case SphereState.Speaking: return { r1: 0.25, g1: 0.92, b1: 0.55, r2: 0.30, g2: 0.85, b2: 0.88 };
|
||||
case SphereState.Error: return { r1: 0.90, g1: 0.30, b1: 0.25, r2: 0.85, g2: 0.50, b2: 0.20 };
|
||||
}
|
||||
throw new Error(`Unknown state: ${state}`);
|
||||
}
|
||||
|
||||
// ── Math helpers ─────────────────────────────────────────────────────
|
||||
|
||||
export function lerp(a, b, t) { return a + (b - a) * t; }
|
||||
|
||||
function clamp(x, lo, hi) { return Math.max(lo, Math.min(hi, x)); }
|
||||
function atLeast(x, lo) { return Math.max(lo, x); }
|
||||
|
||||
// 32-bit int hash mirroring the Kotlin implementation exactly.
|
||||
// Math.imul + |0 force 32-bit signed overflow semantics.
|
||||
function hash(x, y) {
|
||||
let h = (Math.imul(x, 374761393) + Math.imul(y, 668265263)) | 0;
|
||||
h = Math.imul(h ^ (h >>> 13), 1274126177) | 0;
|
||||
h = h ^ (h >>> 16);
|
||||
return (h & 0x7fffffff) / 2147483647;
|
||||
}
|
||||
|
||||
function smoothNoise(x, y) {
|
||||
const xi = Math.floor(x);
|
||||
const yi = Math.floor(y);
|
||||
const xf = x - xi;
|
||||
const yf = y - yi;
|
||||
const u = xf * xf * (3 - 2 * xf);
|
||||
const v = yf * yf * (3 - 2 * yf);
|
||||
const n00 = hash(xi, yi);
|
||||
const n10 = hash(xi + 1, yi);
|
||||
const n01 = hash(xi, yi + 1);
|
||||
const n11 = hash(xi + 1, yi + 1);
|
||||
return lerp(lerp(n00, n10, u), lerp(n01, n11, u), v);
|
||||
}
|
||||
|
||||
export function fbm(x, y, octaves = 3) {
|
||||
let value = 0, amplitude = 0.5, frequency = 1;
|
||||
for (let i = 0; i < octaves; i++) {
|
||||
value += amplitude * smoothNoise(x * frequency, y * frequency);
|
||||
amplitude *= 0.5;
|
||||
frequency *= 2;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// ── Sphere iteration ─────────────────────────────────────────────────
|
||||
|
||||
const CHAR_SETS = [
|
||||
" ·:;=+*#%@",
|
||||
" .:;=+*#%@",
|
||||
" ·;:+=*%#@",
|
||||
" .:;+=*#@%",
|
||||
];
|
||||
const DATA_CHARS = "01<>[]{}|/\\~^";
|
||||
|
||||
/**
|
||||
* Mirrors `forEachSphereCell` in MorphingSphereCore.kt.
|
||||
*
|
||||
* @param frame - See SphereFrame shape in the Kotlin core.
|
||||
* @param onCell - Invoked as (col, row, char, r, g, b, alpha). RGB and alpha are 0..1.
|
||||
*/
|
||||
export function forEachSphereCell(frame, onCell) {
|
||||
const amp = clamp(frame.voiceAmplitude, 0, 1);
|
||||
|
||||
// ── Voice modulation ────────────────────────────────────────
|
||||
let breatheSpeed = frame.breatheSpeed;
|
||||
let turbulenceAmp = frame.turbulenceAmp;
|
||||
let coreWarmth = 0.30;
|
||||
let wobbleAmplitude = 0.06;
|
||||
let dataRingSpeed = 0.4;
|
||||
|
||||
if (frame.state === SphereState.Listening) {
|
||||
breatheSpeed = lerp(frame.breatheSpeed, frame.breatheSpeed * 1.3, amp * 0.5);
|
||||
turbulenceAmp = frame.turbulenceAmp + amp * 0.15;
|
||||
wobbleAmplitude = 0.06 * (1 + amp * 0.3);
|
||||
} else if (frame.state === SphereState.Speaking) {
|
||||
breatheSpeed = lerp(frame.breatheSpeed, frame.breatheSpeed * 2.0, amp);
|
||||
turbulenceAmp = frame.turbulenceAmp + amp * 0.5;
|
||||
coreWarmth = lerp(0.30, 1.0, amp);
|
||||
wobbleAmplitude = 0.06 * (1 + amp * 0.8);
|
||||
dataRingSpeed = 0.4 * (1 + amp * 3);
|
||||
}
|
||||
|
||||
const cx = frame.cols / 2;
|
||||
const cy = frame.rows / 2;
|
||||
const charAspect = frame.charAspect;
|
||||
|
||||
const maxRadiusFromRows = (frame.rows / 2) * 0.60;
|
||||
const maxRadiusFromCols = (frame.cols / 2) * charAspect * 0.60;
|
||||
const baseRadius = Math.min(maxRadiusFromRows, maxRadiusFromCols) * frame.voiceRadiusScale;
|
||||
const t = frame.time;
|
||||
|
||||
const breathe = Math.sin(t * breatheSpeed) * frame.breatheAmp;
|
||||
const breathingRadius = baseRadius * (1 + breathe);
|
||||
|
||||
const noiseJitter1 = fbm(t * 0.05 + 7.3, 1.7) * 0.5;
|
||||
const noiseJitter2 = fbm(3.1, t * 0.04 + 13.7) * 0.5;
|
||||
const lightAngle1 = t * frame.lightSpeedX + noiseJitter1;
|
||||
const lightAngle2 = t * frame.lightSpeedY + noiseJitter2;
|
||||
const lx = Math.sin(lightAngle1) * 0.65;
|
||||
const ly = Math.cos(lightAngle2) * 0.65;
|
||||
const lz = Math.sqrt(atLeast(1 - lx * lx - ly * ly, 0.01));
|
||||
|
||||
const heartbeat = Math.sin(t * frame.heartbeatSpeed) * 0.5 + 0.5;
|
||||
|
||||
const pulse = Math.sin(frame.colorPhase) * 0.5 + 0.5;
|
||||
const colR = lerp(frame.cr1, frame.cr2, pulse);
|
||||
const colG = lerp(frame.cg1, frame.cg2, pulse);
|
||||
const colB = lerp(frame.cb1, frame.cb2, pulse);
|
||||
|
||||
const distWeight = 1 - frame.lightInfluence;
|
||||
|
||||
const effTurbulence = turbulenceAmp + frame.intensity * 0.04 + frame.toolCallBurst * 0.15;
|
||||
const effRadialFlow = frame.radialFlowSpeed + frame.intensity * 0.3;
|
||||
const effRipple = frame.rippleScale + frame.intensity * 0.5 + frame.toolCallBurst * 1.0;
|
||||
|
||||
for (let row = 0; row < frame.rows; row++) {
|
||||
for (let col = 0; col < frame.cols; col++) {
|
||||
const dx = (col - cx) * charAspect;
|
||||
const dy = (row - cy);
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
const angle = Math.atan2(dy, dx);
|
||||
|
||||
const perimeterNoise = fbm(
|
||||
angle * 1.8 + t * 0.08,
|
||||
angle * 0.7 + t * 0.12
|
||||
) * 2 - 1;
|
||||
const distortedRadius = breathingRadius * (1 + perimeterNoise * wobbleAmplitude);
|
||||
const glowRadius = distortedRadius * 1.35;
|
||||
const dataRingInner = distortedRadius * 1.40;
|
||||
const dataRingOuter = distortedRadius * 1.55;
|
||||
const normDist = dist / distortedRadius;
|
||||
|
||||
if (dist > dataRingOuter) continue;
|
||||
|
||||
if (normDist <= 1) {
|
||||
// ── INSIDE SPHERE ────────────────────────────
|
||||
const nx = dx / distortedRadius;
|
||||
const ny2 = dy / distortedRadius;
|
||||
const nzSq = atLeast(1 - nx * nx - ny2 * ny2, 0);
|
||||
const nz = Math.sqrt(nzSq);
|
||||
|
||||
const distBrightness = atLeast(1 - normDist * normDist * frame.coreTightness, 0.15);
|
||||
|
||||
const directionalLight = clamp(nx * lx + ny2 * ly + nz * lz, 0, 1);
|
||||
|
||||
const structural = fbm(col * 0.25 + t * 0.18, row * 0.25 + t * 0.13, 2) * 0.15 - 0.075;
|
||||
|
||||
const turbulence = fbm(col * 0.8 + t * 0.6, row * 0.8 + t * 0.45, 2) * effTurbulence - effTurbulence * 0.5;
|
||||
|
||||
const radialFlow = fbm(angle * 2 + t * 0.15, dist * 0.3 - t * effRadialFlow, 2) * 0.06 - 0.03;
|
||||
|
||||
const ripple = (
|
||||
Math.sin(normDist * 8 - t * 1.2) * 0.04 * (1 - normDist) +
|
||||
Math.sin(normDist * 5 - t * 0.7 + 2) * 0.03 * (1 - normDist)
|
||||
) * effRipple;
|
||||
|
||||
const heartbeatFx = heartbeat * 0.05 * (1 - normDist * normDist);
|
||||
|
||||
const brightness = distWeight * distBrightness + frame.lightInfluence * directionalLight + heartbeatFx;
|
||||
const charNoise = structural + turbulence + radialFlow + ripple;
|
||||
|
||||
// Kotlin: `(t * 0.3f + col * 0.17f + row * 0.13f).toInt()` — truncation toward zero.
|
||||
const rotationPhase = Math.trunc(t * 0.3 + col * 0.17 + row * 0.13);
|
||||
const chars = CHAR_SETS[rotationPhase & 3];
|
||||
|
||||
const charIdx = clamp(Math.trunc((brightness + charNoise) * (chars.length - 1)), 1, chars.length - 1);
|
||||
const ch = chars[charIdx];
|
||||
|
||||
let edgeFade = 1;
|
||||
if (normDist > 0.80) {
|
||||
const ef = (normDist - 0.80) / 0.20;
|
||||
edgeFade = 1 - ef * ef;
|
||||
}
|
||||
|
||||
const scanline = (row % 2 === 1) ? 0.82 : 1;
|
||||
const alpha = clamp((brightness * 0.4 + 0.6) * edgeFade * scanline, 0.1, 1);
|
||||
|
||||
const warmth = (1 - normDist * normDist) * (coreWarmth * 0.40);
|
||||
const lightBoost = directionalLight * 0.08;
|
||||
|
||||
onCell(col, row, ch,
|
||||
clamp(colR + lightBoost + warmth, 0, 1),
|
||||
clamp(colG + lightBoost * 0.5 + warmth, 0, 1),
|
||||
clamp(colB + lightBoost + warmth, 0, 1),
|
||||
alpha);
|
||||
|
||||
} else if (dist <= glowRadius) {
|
||||
// ── GLOW / DEBRIS ZONE ───────────────────────
|
||||
const glowT = (dist - distortedRadius) / (glowRadius - distortedRadius);
|
||||
const glowFalloff = clamp(1 - glowT, 0, 1);
|
||||
|
||||
const sparsityNoise = fbm(angle * 3.5 + t * 0.25, dist * 0.4 + t * 0.08, 2);
|
||||
const sparsityThreshold = 0.35 + glowT * 0.25;
|
||||
if (sparsityNoise < sparsityThreshold) continue;
|
||||
|
||||
const debrisChars = "·:;- ";
|
||||
const debrisIdx = clamp(Math.trunc((1 - glowFalloff) * (debrisChars.length - 1)), 0, debrisChars.length - 1);
|
||||
const ch = debrisChars[debrisIdx];
|
||||
if (ch === ' ') continue;
|
||||
|
||||
const alpha = glowFalloff * 0.85;
|
||||
onCell(col, row, ch, clamp(colR, 0, 1), clamp(colG, 0, 1), clamp(colB, 0, 1), alpha);
|
||||
|
||||
} else if (dist >= dataRingInner) {
|
||||
// ── DATA RING ────────────────────────────────
|
||||
const ringT = (dist - dataRingInner) / (dataRingOuter - dataRingInner);
|
||||
const orbitAngle = angle - t * dataRingSpeed + ringT * 1.5;
|
||||
const ringNoise = fbm(orbitAngle * 4 + t * 0.3, ringT * 3 + t * 0.15, 2);
|
||||
if (ringNoise < 0.55) continue;
|
||||
|
||||
// Kotlin `.mod(n)` is floored-positive modulo.
|
||||
const rawIdx = Math.trunc((orbitAngle * 2 + t * 0.5) * DATA_CHARS.length);
|
||||
const dataIdx = ((rawIdx % DATA_CHARS.length) + DATA_CHARS.length) % DATA_CHARS.length;
|
||||
const ch = DATA_CHARS[dataIdx];
|
||||
|
||||
const ringFade = clamp(1 - ringT, 0, 1);
|
||||
const alpha = ringFade * 0.65;
|
||||
onCell(col, row, ch,
|
||||
clamp(colR * 0.85, 0, 1),
|
||||
clamp(colG * 0.85, 0, 1),
|
||||
clamp(colB * 0.85, 0, 1),
|
||||
alpha);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user