Órbita: el núcleo es una figura viva, no un punto
El centro del átomo era un punto blanco con un halo que latía. Ahora es una curva polar de tres armónicos múltiplos del mismo orden —simetría exacta de n puntas—, que pasa de estrella afilada a mandala de pétalos redondos según qué armónico pese más, con dos ecos hacia dentro y el primero girado media punta. Nace al azar en cada arranque y deriva: cada dos segundos, cada parámetro (amplitudes, giros, tamaño, filo, brillo, ritmo del color) se pone una meta a una micra de donde está y se acerca suavemente. Cambiar de orden sí sería un salto, así que suenan dos voces y una mezcla las reparte: la callada es la que estrena orden —emparentado con el que manda, para no perder la simetría— y el relevo dura un cuarto de hora. El halo y los anillos toman de la figura su color y su brillo. Todo se lee en la fase de dibujo, a 30 fps, así que el núcleo muta sin recomponer nada.
Delta semántico
Ninguna definición cambió: el cambio es de texto, documentación o datos.
3 archivos · +445 −98
diff --git a/launcher/README.md b/launcher/README.md
index b82621b..984ee00 100644
--- a/launcher/README.md
+++ b/launcher/README.md
@@ -19,11 +19,23 @@ de ahí la sensación de continuidad. «Atrás» siempre vuelve al centro.
## El núcleo
-- **Brilla**: un punto blanco envuelto en un halo muy amplio y difuminado,
- que alcanza más allá del primer anillo y respira despacio.
-- **Deriva de color**: recorre la paleta entera (verde → cian → violeta →
- rosa → ámbar) en 15 minutos. Nunca se ve cambiar; solo se nota que el
- color de hace un rato ya no es el de ahora. Los anillos se tiñen con él.
+Una figura que nunca es dos veces la misma. No hay dibujo guardado: la silueta
+es una curva polar de tres armónicos —n, 2n y 3n puntas— y por eso sale siempre
+con simetría exacta; va de estrella afilada a mandala de pétalos redondos según
+qué armónico pese más. Hacia dentro se repite en dos ecos, el primero girado
+media punta, para que se entrelacen.
+
+- **Nace al azar** en cada arranque y de ahí **deriva**: cada dos segundos,
+ cada parámetro —forma, tamaño, filo, brillo, color— se pone una meta a una
+ micra de donde está y se acerca despacio. En un minuto no se ve cambiar nada;
+ en media hora es otra figura, y no vuelve atrás.
+- **Cambia de simetría sin saltar**: suenan dos voces a la vez y una mezcla las
+ reparte. La que está callada es la que estrena orden, y el relevo dura un
+ cuarto de hora. El orden nuevo comparte divisor con el que manda, así que ni
+ en mitad del relevo se pierde la simetría.
+- **Deriva de color**: recorre la paleta entera (verde → cian → violeta → rosa
+ → ámbar) en un cuarto de hora, a paso algo vivo o algo lento según el momento.
+ El halo y los anillos se tiñen con él y se avivan con su brillo.
- **Se toca para buscar.**
## La ronda no salta
diff --git a/launcher/src/main/java/com/rizoma/orbita/ui/Nucleo.kt b/launcher/src/main/java/com/rizoma/orbita/ui/Nucleo.kt
new file mode 100644
index 0000000..a815988
--- /dev/null
+++ b/launcher/src/main/java/com/rizoma/orbita/ui/Nucleo.kt
@@ -0,0 +1,411 @@
+package com.rizoma.orbita.ui
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.State
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.withFrameNanos
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.BlendMode
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.graphics.lerp
+import androidx.compose.ui.unit.dp
+import com.rizoma.orbita.ui.theme.NucleoPaleta
+import kotlin.math.PI
+import kotlin.math.abs
+import kotlin.math.cos
+import kotlin.math.floor
+import kotlin.math.max
+import kotlin.math.pow
+import kotlin.math.sin
+import kotlin.random.Random
+
+/**
+ * El núcleo del átomo: una figura que nunca es dos veces la misma.
+ *
+ * No hay dibujo guardado. La silueta es una curva polar: el radio en cada ángulo
+ * es uno más la suma de tres armónicos —n, 2n y 3n puntas—. Como los tres son
+ * múltiplos del mismo orden n, la figura sale siempre con simetría exacta de n
+ * puntas: estrella cuando manda el primer armónico y va afilado, mandala cuando
+ * los otros dos tienen peso y el pétalo es redondo. Moviendo solo las amplitudes
+ * se pasa de una a otra sin un salto.
+ *
+ * Cambiar n sí sería un salto, así que hay **dos voces** sonando a la vez y una
+ * mezcla que las reparte: mientras una manda, la otra está callada y es entonces
+ * —invisible— cuando estrena orden. El relevo tarda unos minutos.
+ *
+ * Todo lo demás (amplitudes, fases, tamaño, filo, brillo y color) arranca en un
+ * punto al azar y de ahí deriva: cada dos segundos cada parámetro se pone una meta
+ * a una micra de donde está y se acerca a ella suavemente. En un minuto no se ve
+ * cambiar nada; en media hora es otra figura, y no vuelve atrás.
+ */
+
+/** Órdenes de simetría que puede tomar el núcleo. */
+private val ORDENES = intArrayOf(4, 5, 6, 8, 10, 12)
+
+private const val MUTACION_MS = 2_000f // cada cuánto se replantea la meta
+private const val SUAVIZADO_MS = 2_600f // lo que tarda en llegar a ella
+private const val REFRESCO_MS = 32f // 30 fps bastan para algo tan lento
+private const val RELEVO_MS = 900_000f // un cuarto de hora en pasar de una voz a la otra
+private const val TAU = (2 * PI).toFloat()
+
+/** Una voz: un orden de simetría con sus tres armónicos. */
+class VozNucleo internal constructor(
+ internal val orden: Int,
+ internal val amp: FloatArray,
+ internal val fase: FloatArray,
+) {
+ internal val suma: Float = amp.sum()
+}
+
+/** Una foto del núcleo en un instante: lo único que necesita el dibujo. */
+class FormaNucleo internal constructor(
+ internal val voz1: VozNucleo,
+ internal val voz2: VozNucleo,
+ /** Quién manda: 0 la primera voz, 1 la segunda. */
+ internal val mezcla: Float,
+ /** Tamaño, como fracción del hueco disponible. */
+ internal val radio: Float,
+ /** 0 = pétalo redondo (mandala) · 1 = punta afilada (estrella). */
+ internal val punta: Float,
+ /** Cuánta luz da ahora mismo. */
+ val brillo: Float,
+ /** Dónde cae en la paleta del rizoma. */
+ val tinte: Float,
+) {
+ /** El orden que se está viendo: manda el de la voz con más peso. */
+ internal val orden: Int get() = if (mezcla < 0.5f) voz1.orden else voz2.orden
+}
+
+/** Un parámetro a la deriva: nunca salta, siempre va hacia una meta cercana. */
+private class Deriva(
+ var valor: Float,
+ private val min: Float,
+ private val max: Float,
+ /** La micra: lo más que puede alejarse la meta en cada mutación. */
+ private val micra: Float,
+) {
+ private var meta = valor
+
+ fun mutar(rnd: Random) {
+ meta = (valor + (rnd.nextFloat() * 2f - 1f) * micra).coerceIn(min, max)
+ }
+
+ fun avanzar(dt: Float) {
+ valor += (meta - valor) * (dt / SUAVIZADO_MS).coerceIn(0f, 1f)
+ }
+
+ /** Solo para lo que está callado: ahí sí se puede saltar sin que se vea. */
+ fun saltar(v: Float) {
+ valor = v
+ meta = v
+ }
+}
+
+/** Una voz a la deriva: su orden, el peso de cada armónico y lo que giran. */
+private class Voz(rnd: Random, pariente: Int) {
+
+ var orden = 6
+ private set
+
+ private val peso = Array(3) { Deriva(0f, 0f, 1f, 0.05f) }
+ private val fase = FloatArray(3)
+ private val giro = Array(3) { Deriva(0f, -VUELTA, VUELTA, VUELTA * 0.15f) }
+
+ init {
+ renacer(rnd, pariente)
+ }
+
+ /**
+ * Estrena orden y figura. Solo se llama con la voz callada, y el orden nuevo se
+ * escoge emparentado con el que manda —que compartan divisor— para que mientras
+ * las dos suenan a la vez la figura conserve simetría en vez de volverse un borrón.
+ */
+ fun renacer(rnd: Random, pariente: Int) {
+ val posibles = ORDENES.filter { it != pariente && mcd(it, pariente) > 1 }
+ orden = if (posibles.isEmpty()) ORDENES[rnd.nextInt(ORDENES.size)]
+ else posibles[rnd.nextInt(posibles.size)]
+ peso[0].saltar(0.35f + rnd.nextFloat() * 0.65f)
+ peso[1].saltar(rnd.nextFloat())
+ peso[2].saltar(rnd.nextFloat())
+ for (i in fase.indices) {
+ fase[i] = rnd.nextFloat() * TAU
+ giro[i].saltar((rnd.nextFloat() * 2f - 1f) * VUELTA)
+ }
+ }
+
+ fun mutar(rnd: Random) {
+ peso.forEach { it.mutar(rnd) }
+ giro.forEach { it.mutar(rnd) }
+ }
+
+ fun avanzar(dt: Float) {
+ peso.forEach { it.avanzar(dt) }
+ giro.forEach { it.avanzar(dt) }
+ for (i in fase.indices) fase[i] = (fase[i] + giro[i].valor * dt).mod(TAU)
+ }
+
+ fun foto(): VozNucleo {
+ // Cuantas más puntas, menos hondas: si no, el núcleo se vuelve una pelusa.
+ val techo = (0.34f * (6f / orden).pow(0.45f)).coerceIn(0.15f, 0.34f)
+ return VozNucleo(
+ orden = orden,
+ amp = floatArrayOf(
+ peso[0].valor * techo,
+ peso[1].valor * techo * 0.50f,
+ peso[2].valor * techo * 0.30f,
+ ),
+ fase = fase.copyOf(),
+ )
+ }
+
+ private companion object {
+ /** Giro máximo de un armónico: una vuelta cada media hora. */
+ const val VUELTA = TAU / 1_800_000f
+ }
+}
+
+/**
+ * La deriva entera del núcleo. Arranca al azar —cada vez que Órbita nace, nace
+ * también una figura distinta— y a partir de ahí solo se deja llevar.
+ */
+private class DerivaNucleo(private val rnd: Random) {
+
+ private val voz1 = Voz(rnd, 0)
+ private val voz2 = Voz(rnd, voz1.orden)
+ private val radio = Deriva(0.60f + rnd.nextFloat() * 0.32f, 0.55f, 0.95f, 0.012f)
+ private val punta = Deriva(rnd.nextFloat(), 0f, 1f, 0.03f)
+ private val brillo = Deriva(0.55f + rnd.nextFloat() * 0.45f, 0.45f, 1f, 0.035f)
+ // La deriva de color mantiene el rumbo del rizoma: una vuelta a la paleta en
+ // un cuarto de hora, con el paso un poco vivo o un poco lento según el momento.
+ private var tinte = rnd.nextFloat() * NucleoPaleta.size
+ private val ritmo = Deriva(1f, 0.5f, 1.6f, 0.05f)
+
+ private var mezcla = if (rnd.nextBoolean()) 0f else 1f
+ private var hacia = 1f - mezcla
+ private var descanso = reposo()
+
+ private var desdeMutacion = 0f
+
+ fun avanzar(dt: Float) {
+ desdeMutacion += dt
+ if (desdeMutacion >= MUTACION_MS) {
+ desdeMutacion = 0f
+ voz1.mutar(rnd)
+ voz2.mutar(rnd)
+ radio.mutar(rnd)
+ punta.mutar(rnd)
+ brillo.mutar(rnd)
+ ritmo.mutar(rnd)
+ }
+ voz1.avanzar(dt)
+ voz2.avanzar(dt)
+ radio.avanzar(dt)
+ punta.avanzar(dt)
+ brillo.avanzar(dt)
+ ritmo.avanzar(dt)
+ tinte = (tinte + ritmo.valor * dt / TINTE_MS * NucleoPaleta.size).mod(NucleoPaleta.size.toFloat())
+ relevo(dt)
+ }
+
+ /** El paso de una voz a la otra, y el estreno de orden de la que se calla. */
+ private fun relevo(dt: Float) {
+ if (descanso > 0f) {
+ descanso -= dt
+ return
+ }
+ val paso = dt / RELEVO_MS
+ mezcla = if (hacia > mezcla) (mezcla + paso).coerceAtMost(1f) else (mezcla - paso).coerceAtLeast(0f)
+ if (mezcla == hacia) {
+ // Ya manda una sola voz: la otra, muda, puede estrenar orden sin que se note.
+ if (mezcla == 1f) voz1.renacer(rnd, voz2.orden) else voz2.renacer(rnd, voz1.orden)
+ hacia = 1f - mezcla
+ descanso = reposo()
+ }
+ }
+
+ private fun reposo() = 120_000f + rnd.nextFloat() * 360_000f
+
+ fun foto() = FormaNucleo(
+ voz1 = voz1.foto(),
+ voz2 = voz2.foto(),
+ mezcla = mezcla,
+ radio = radio.valor,
+ punta = punta.valor,
+ brillo = brillo.valor,
+ tinte = tinte,
+ )
+
+ private companion object {
+ /** Una vuelta entera a la paleta, a ritmo normal. */
+ const val TINTE_MS = 900_000f
+ }
+}
+
+/**
+ * Mantiene la deriva viva mientras Órbita esté a la vista. El estado se lee solo
+ * en la fase de dibujo, así que mutar el núcleo repinta pero no recompone nada.
+ */
+@Composable
+fun rememberNucleo(): State<FormaNucleo> {
+ val deriva = remember { DerivaNucleo(Random(Random.nextInt())) }
+ val estado = remember { mutableStateOf(deriva.foto()) }
+ LaunchedEffect(Unit) {
+ var previo = 0L
+ var desdeRefresco = 0f
+ while (true) {
+ withFrameNanos { ahora ->
+ val dt = if (previo == 0L) 0f else ((ahora - previo) / 1_000_000f).coerceIn(0f, 64f)
+ previo = ahora
+ deriva.avanzar(dt)
+ desdeRefresco += dt
+ if (desdeRefresco >= REFRESCO_MS) {
+ desdeRefresco = 0f
+ estado.value = deriva.foto()
+ }
+ }
+ }
+ }
+ return estado
+}
+
+/** Color continuo a lo largo de la paleta del núcleo (t recorre 0..paleta.size). */
+internal fun colorDelCiclo(t: Float): Color {
+ val n = NucleoPaleta.size
+ val base = floor(t).toInt()
+ val f = t - base
+ return lerp(NucleoPaleta[((base % n) + n) % n], NucleoPaleta[((base + 1) % n + n) % n], f)
+}
+
+/**
+ * El corazón del átomo, y el botón de buscar: la figura a la deriva, rellena de
+ * un degradado que va del blanco del centro al color del ciclo, ribeteada por un
+ * barrido de la paleta, y repetida hacia dentro en dos siluetas más pequeñas —la
+ * primera girada media punta, para que se entrelacen como un mandala.
+ */
+@Composable
+fun Nucleo(
+ modifier: Modifier,
+ forma: () -> FormaNucleo,
+ onTap: () -> Unit,
+) {
+ val silueta = remember { Path() }
+ val dentro = remember { Path() }
+ Box(
+ modifier
+ .size(LADO)
+ .clip(CircleShape)
+ .clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null,
+ onClick = onTap,
+ )
+ .drawBehind {
+ val f = forma()
+ val c = colorDelCiclo(f.tinte)
+ val b = f.brillo
+ val r = size.minDimension / 2f * f.radio
+ trazar(silueta, f, center, r, 0f)
+
+ drawPath(
+ silueta,
+ brush = Brush.radialGradient(
+ 0.00f to Color.White.copy(alpha = 0.34f * b),
+ 0.16f to c.copy(alpha = 0.50f * b),
+ 0.52f to c.copy(alpha = 0.21f * b),
+ 1.00f to c.copy(alpha = 0.05f * b),
+ center = center,
+ radius = r,
+ ),
+ blendMode = BlendMode.Plus,
+ )
+ drawPath(
+ silueta,
+ brush = Brush.sweepGradient(barrido(f.tinte, 0.55f * b), center),
+ style = Stroke(width = 1.3f.dp.toPx()),
+ blendMode = BlendMode.Plus,
+ )
+ // Los ecos hacia dentro: el de en medio, girado media punta.
+ trazar(dentro, f, center, r * 0.62f, PI.toFloat() / f.orden)
+ drawPath(
+ dentro,
+ brush = Brush.sweepGradient(barrido(f.tinte + 1.5f, 0.34f * b), center),
+ style = Stroke(width = 1f.dp.toPx()),
+ blendMode = BlendMode.Plus,
+ )
+ trazar(dentro, f, center, r * 0.34f, 0f)
+ drawPath(
+ dentro,
+ brush = Brush.sweepGradient(barrido(f.tinte + 3f, 0.26f * b), center),
+ style = Stroke(width = 1f.dp.toPx()),
+ blendMode = BlendMode.Plus,
+ )
+ // El punto vivo: siempre blanco, siempre en el centro.
+ val corazon = r * 0.17f
+ drawCircle(
+ brush = Brush.radialGradient(
+ 0.0f to Color.White.copy(alpha = 0.55f + 0.35f * b),
+ 1.0f to Color.Transparent,
+ center = center,
+ radius = corazon,
+ ),
+ radius = corazon,
+ center = center,
+ blendMode = BlendMode.Plus,
+ )
+ }
+ )
+}
+
+/** Divisor común: dos órdenes emparentados componen una figura que sigue siendo simétrica. */
+private tailrec fun mcd(a: Int, b: Int): Int = if (b == 0) a else mcd(b, a % b)
+
+/** Los colores del ribete: un tramo de la paleta que cierra sobre sí mismo. */
+private fun barrido(tinte: Float, alpha: Float): List<Color> =
+ List(5) { colorDelCiclo(tinte + it * 0.5f).copy(alpha = alpha) } +
+ colorDelCiclo(tinte).copy(alpha = alpha)
+
+/**
+ * Levanta la silueta: radio = 1 + Σ amplitud·armónico, con las dos voces mezcladas
+ * y normalizado para que la punta más larga mida justo [radio]. [rot] gira la figura
+ * (media punta, para los ecos de dentro).
+ */
+private fun trazar(path: Path, f: FormaNucleo, centro: Offset, radio: Float, rot: Float) {
+ val escala = radio / (1f + max(f.voz1.suma, f.voz2.suma))
+ val filo = 1f + 3f * f.punta
+ path.reset()
+ for (i in 0..PASOS) {
+ val th = TAU * i / PASOS
+ val a = th + rot
+ val rr = 1f + (1f - f.mezcla) * vozEn(f.voz1, a, filo) + f.mezcla * vozEn(f.voz2, a, filo)
+ val x = centro.x + escala * rr * cos(th)
+ val y = centro.y + escala * rr * sin(th)
+ if (i == 0) path.moveTo(x, y) else path.lineTo(x, y)
+ }
+ path.close()
+}
+
+/** Lo que aporta una voz en un ángulo: sus tres armónicos, afilados por [filo]. */
+private fun vozEn(v: VozNucleo, th: Float, filo: Float): Float {
+ var r = 0f
+ for (j in 0..2) {
+ val c = cos((j + 1) * v.orden * th + v.fase[j])
+ r += v.amp[j] * if (c < 0f) -abs(c).pow(filo) else c.pow(filo)
+ }
+ return r
+}
+
+private const val PASOS = 300
+private val LADO = 168.dp
diff --git a/launcher/src/main/java/com/rizoma/orbita/ui/OrbitLayout.kt b/launcher/src/main/java/com/rizoma/orbita/ui/OrbitLayout.kt
index 45e4ce6..2415bcc 100644
--- a/launcher/src/main/java/com/rizoma/orbita/ui/OrbitLayout.kt
+++ b/launcher/src/main/java/com/rizoma/orbita/ui/OrbitLayout.kt
@@ -13,10 +13,7 @@ import androidx.compose.foundation.Canvas
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.border
-import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
-import androidx.compose.foundation.interaction.MutableInteractionSource
-import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
@@ -25,13 +22,11 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.State
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
-import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
@@ -40,7 +35,6 @@ import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.platform.LocalDensity
-import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -49,7 +43,6 @@ import com.rizoma.orbita.model.AppNode
import com.rizoma.orbita.ui.theme.NeonCian
import com.rizoma.orbita.ui.theme.NeonRosa
import com.rizoma.orbita.ui.theme.NeonVerde
-import com.rizoma.orbita.ui.theme.NucleoPaleta
import com.rizoma.orbita.ui.theme.Tenue
import kotlin.math.PI
import kotlin.math.cos
@@ -71,9 +64,6 @@ private data class Orbitas(val slots: Map<String, Slot>, val rings: Int)
private val ringColors = listOf(NeonVerde, NeonRosa, NeonCian)
private const val TAU_F = (2 * PI).toFloat()
-/** Una vuelta completa del tinte del núcleo: tan larga que el cambio no se percibe. */
-private const val TINTE_MS = 900_000
-
/**
* Distribución anti-rejilla: los nodos caen en órbitas concéntricas según su calor.
* Las órbitas internas (más cerca del centro) son las apps más usadas y las fijadas.
@@ -102,20 +92,10 @@ fun OrbitLayout(
animationSpec = infiniteRepeatable(tween(180_000, easing = LinearEasing), RepeatMode.Restart),
label = "spin",
)
- val pulse = transition.animateFloat(
- initialValue = 0.94f,
- targetValue = 1.06f,
- animationSpec = infiniteRepeatable(tween(5_200, easing = LinearEasing), RepeatMode.Reverse),
- label = "pulso",
- )
- // Deriva de color del núcleo: recorre la paleta entera en 15 minutos.
- val tinte = transition.animateFloat(
- initialValue = 0f,
- targetValue = NucleoPaleta.size.toFloat(),
- animationSpec = infiniteRepeatable(tween(TINTE_MS, easing = LinearEasing), RepeatMode.Restart),
- label = "tinte",
- )
- val glow: () -> Color = { colorDelCiclo(tinte.value) }
+ // El núcleo va por su cuenta: una figura a la deriva (ver Nucleo.kt). El halo
+ // y los anillos toman de ella su color y su brillo, para que todo respire igual.
+ val nucleo = rememberNucleo()
+ val forma: () -> FormaNucleo = { nucleo.value }
BoxWithConstraints(modifier.fillMaxSize()) {
val wPx = with(density) { maxWidth.toPx() }
@@ -134,15 +114,17 @@ fun OrbitLayout(
// Halo del núcleo (mucho alcance, muy difuminado) + anillos orbitales.
Canvas(Modifier.fillMaxSize()) {
- val c = glow()
- val p = pulse.value
- val halo = min(wPx, hPx) * 0.58f * p
+ val f = forma()
+ val c = colorDelCiclo(f.tinte)
+ val b = f.brillo
+ // El halo crece y mengua con la figura: cuanto más abierta y más viva, más lejos llega.
+ val halo = min(wPx, hPx) * (0.46f + 0.16f * f.radio + 0.10f * b)
drawCircle(
brush = Brush.radialGradient(
- 0.00f to c.copy(alpha = 0.22f),
- 0.16f to c.copy(alpha = 0.115f),
- 0.38f to c.copy(alpha = 0.055f),
- 0.66f to c.copy(alpha = 0.018f),
+ 0.00f to c.copy(alpha = 0.24f * b),
+ 0.16f to c.copy(alpha = 0.125f * b),
+ 0.38f to c.copy(alpha = 0.060f * b),
+ 0.66f to c.copy(alpha = 0.020f * b),
1.00f to Color.Transparent,
center = Offset(cx, cy),
radius = halo,
@@ -154,7 +136,8 @@ fun OrbitLayout(
for (r in 0 until rings) {
// Los anillos se tiñen a medias con el color vivo del núcleo.
drawCircle(
- color = lerp(ringColors[r % ringColors.size], c, 0.5f).copy(alpha = 0.14f),
+ color = lerp(ringColors[r % ringColors.size], c, 0.5f)
+ .copy(alpha = 0.09f + 0.07f * b),
radius = firstR + r * ringGap,
center = Offset(cx, cy),
style = Stroke(width = 1.2f),
@@ -163,10 +146,9 @@ fun OrbitLayout(
}
// Núcleo: toque para buscar en el rizoma.
- CenterHub(
+ Nucleo(
modifier = Modifier.align(Alignment.Center),
- glow = glow,
- pulse = pulse,
+ forma = forma,
onTap = onCenterTap,
)
@@ -244,14 +226,6 @@ private fun desenrollar(previo: Float, destino: Float): Float {
return a
}
-/** Color continuo a lo largo de la paleta del núcleo (t recorre 0..paleta.size). */
-private fun colorDelCiclo(t: Float): Color {
- val n = NucleoPaleta.size
- val base = floor(t).toInt()
- val f = t - base
- return lerp(NucleoPaleta[((base % n) + n) % n], NucleoPaleta[((base + 1) % n + n) % n], f)
-}
-
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun NodeItem(
@@ -290,56 +264,6 @@ private fun NodeItem(
}
}
-/**
- * El corazón del átomo: un núcleo blanco muy brillante envuelto en el color del ciclo.
- * El halo amplio lo pinta el lienzo de fondo; aquí solo va el punto vivo y su toque.
- */
-@Composable
-private fun CenterHub(
- modifier: Modifier,
- glow: () -> Color,
- pulse: State<Float>,
- onTap: () -> Unit,
-) {
- Column(
- modifier = modifier
- .size(96.dp)
- .clip(CircleShape)
- .drawBehind {
- val c = glow()
- val r = size.minDimension / 2f * pulse.value
- drawCircle(
- brush = Brush.radialGradient(
- 0.00f to Color.White.copy(alpha = 0.42f),
- 0.10f to c.copy(alpha = 0.80f),
- 0.35f to c.copy(alpha = 0.34f),
- 0.68f to c.copy(alpha = 0.10f),
- 1.00f to Color.Transparent,
- center = center,
- radius = r,
- ),
- radius = r,
- center = center,
- blendMode = BlendMode.Plus,
- )
- }
- .clickable(
- interactionSource = remember { MutableInteractionSource() },
- indication = null,
- onClick = onTap,
- ),
- horizontalAlignment = Alignment.CenterHorizontally,
- verticalArrangement = Arrangement.Center,
- ) {
- Text(
- text = "\u2726",
- color = Color.White.copy(alpha = 0.92f),
- fontSize = 24.sp,
- fontWeight = FontWeight.Light,
- )
- }
-}
-
/**
* Reparte las claves (ya ordenadas) en anillos. Cada anillo crece hacia afuera y su
* capacidad depende de su circunferencia, así nada se amontona.