feat(tahuantinsuyu): fase 21 — background compute + UranianModule

Cierre del brief original — última pieza visual (Uraniano) + perf.

## #1 — Compute en background thread

render_current() pasa de bloqueante a async. La pipeline corre en
cx.background_executor().spawn (no UI thread), y al terminar el
update vuelve al UI vía cx.spawn. Sin esto, un drag del slider con
muchos overlays bloquea el frame por hasta 200ms.

Cancelación: Shell gana `render_seq: u64`. Cada render_current()
incrementa el counter y captura su número; el closure async compara
antes de aplicar. Si llegó un compute más nuevo en el medio (drag
rápido), el viejo se descarta — evita el race donde un cómputo
lento sobrescribe uno reciente y rápido.

Inputs al background: Chart clonado + offset + Vec<PipelineRequest>
+ NatalOptions. La sesión VSOP2013 sigue siendo `static OnceLock`
read-only, accesible desde cualquier thread.

## #11 — UranianModule (versión textual)

Cierra la última pieza del brief original. Toggle "Uraniano (90°)"
en el panel; engine detecta cuerpos natales cuya longitud módulo 90
cae dentro de ε=2° y los agrupa como "ejes". Footer renderea cada
grupo como pill con los unicodes (☉ ♃ · 14.3°) bajo el header
"Ejes uranianos (90°)".

El algoritmo:
1. mod90 = longitude.rem_euclid(90.0) para cada placement
2. Sort por mod90 ascendente
3. Walk lineal agrupando entradas con diff(mod90) ≤ ε
4. Wrap-around check: el primer y último grupo se mergean si
   abarcan el cierre del dial (88→2 = solo 4° de diff modular)
5. Solo emite grupos con 2+ miembros (singletons no son fórmulas)

- engine: PipelineRequest::Uranian + UranianGroup struct +
  build_uranian_groups helper. RenderModel gana uranian_groups field.
  push_overlay_meta tipo "Uraniano · N ejes" o "sin ejes".
- modules: uranian::UranianModule (toggle "Activar"). Registry pasa
  a 9 módulos para ChartKind::Natal. Test actualizado.
- shell: build_requests detecta uranian.enabled, pushea
  PipelineRequest::Uranian (sin parámetros).
- canvas: footer agrega sección "Ejes uranianos (90°)" con pills
  arriba de la lista de aspectos — border angle_highlight para
  invitar a la lectura.

La visualización geométrica completa del dial de 90° con árbol de
simetría al hover queda para una fase posterior — esta versión
textual cubre el caso analítico (ver qué cuerpos están "en
relación uraniana") sin requerir un canvas secundario.

cargo check verde, 8 tests engine + 1 test modules (9 módulos
aplicables a ChartKind::Natal) verdes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-17 23:34:35 +00:00
parent d3649bfd1a
commit d890bd4b3a
5 changed files with 269 additions and 29 deletions
@@ -1005,6 +1005,51 @@ fn render_wheel(
footer = footer.child(b);
}
// Ejes uranianos detectados (cuerpos en la misma posición mod 90).
// Aparece sólo cuando el módulo Uranian está activo y hay
// grupos. Cada grupo se muestra como pill con los unicode de los
// cuerpos + el grado dial-90.
if !render.uranian_groups.is_empty() {
let mut row = div().flex().flex_row().flex_wrap().gap(px(6.0));
for group in &render.uranian_groups {
let bodies_text: String = group
.bodies
.iter()
.map(|b| planet_unicode(b))
.collect::<Vec<_>>()
.join(" ");
row = row.child(
div()
.px(px(8.0))
.py(px(2.0))
.rounded(px(10.0))
.bg(theme.bg_panel_alt.clone())
.border_1()
.border_color(with_alpha(palette.angle_highlight, 0.6))
.text_size(px(11.0))
.text_color(theme.fg_text)
.child(SharedString::from(format!(
"{} · {:.1}°",
bodies_text, group.mod90_deg
))),
);
}
footer = footer.child(
div()
.flex()
.flex_col()
.items_center()
.gap(px(3.0))
.child(
div()
.text_size(px(10.0))
.text_color(theme.fg_muted)
.child("Ejes uranianos (90°)"),
)
.child(row),
);
}
// Lista textual de aspectos (top 12 por orb). Compacta, en grid
// de 3 columnas, fonts pequeños. Solo aparece cuando hay aspectos
// computados.
@@ -20,7 +20,7 @@ use tahuantinsuyu_model::{Chart, HouseSystem, StoredChartConfig, Zodiac};
use crate::dignity::essential_dignity;
use crate::{
AspectSummary, EngineError, Geometry, Glyph, Layer, LayerKind, LineSeg, OverlayMeta,
RenderModel,
RenderModel, UranianGroup,
};
// =====================================================================
@@ -343,6 +343,19 @@ pub fn compose(
format!("Composite · {}", partner_label),
);
}
crate::PipelineRequest::Uranian => {
build_uranian_groups(&natal, &mut render);
let n = render.uranian_groups.len();
push_overlay_meta(
&mut render,
"uranian",
if n == 0 {
"Uraniano · sin ejes".into()
} else {
format!("Uraniano · {} ejes", n)
},
);
}
}
}
@@ -487,6 +500,75 @@ fn build_progression_overlay(
Ok(())
}
/// Helper: detecta "ejes" del dial uraniano de 90° — cuerpos natales
/// cuya longitud módulo 90 cae dentro de una tolerancia ε (2° por
/// default). Llena `render.uranian_groups` con los grupos detectados.
fn build_uranian_groups(natal: &NatalChart, render: &mut RenderModel) {
const EPSILON: f64 = 2.0;
let mut entries: Vec<(String, f64)> = natal
.placements
.iter()
.map(|p| {
let lon = p.longitude.longitude_deg();
let mod90 = lon.rem_euclid(90.0);
(body_symbol(p.body).to_string(), mod90)
})
.collect();
entries.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
let mut groups: Vec<UranianGroup> = Vec::new();
let mut current: Vec<(String, f64)> = Vec::new();
let mut anchor_mod90 = 0.0_f64;
for entry in entries {
if current.is_empty() {
anchor_mod90 = entry.1;
current.push(entry);
continue;
}
// Distancia circular módulo 90 entre el entry y el anchor.
let mut diff = (entry.1 - anchor_mod90).abs();
if diff > 45.0 {
diff = 90.0 - diff;
}
if diff <= EPSILON {
current.push(entry);
} else {
if current.len() >= 2 {
let center = current.iter().map(|(_, m)| *m).sum::<f64>() / current.len() as f64;
groups.push(UranianGroup {
bodies: current.iter().map(|(s, _)| s.clone()).collect(),
mod90_deg: center,
});
}
anchor_mod90 = entry.1;
current = vec![entry];
}
}
if current.len() >= 2 {
let center = current.iter().map(|(_, m)| *m).sum::<f64>() / current.len() as f64;
groups.push(UranianGroup {
bodies: current.iter().map(|(s, _)| s.clone()).collect(),
mod90_deg: center,
});
}
// Wrap-around check: el primer y último grupo podrían ser el mismo
// (si span >88° abarcando el wrap en 90/0). Si los anchors están a
// ≤EPSILON modulo 90, mergeamos.
if groups.len() >= 2 {
let first_mod = groups[0].mod90_deg;
let last_mod = groups[groups.len() - 1].mod90_deg;
let mut diff = (first_mod - last_mod).abs();
if diff > 45.0 {
diff = 90.0 - diff;
}
if diff <= EPSILON {
let last = groups.pop().unwrap();
groups[0].bodies.extend(last.bodies);
}
}
render.uranian_groups = groups;
}
/// Helper: agrega al `RenderModel` la carta compuesta (midpoint
/// composite, Davison 1958) entre la natal del sujeto y la carta del
/// partner. Cada planeta compuesto es el angular midpoint entre los
@@ -972,6 +1054,7 @@ fn build_render_model(
layers: vec![sign_dial, houses, bodies, aspects_layer],
overlays: Vec::new(),
aspect_summary: Vec::new(),
uranian_groups: Vec::new(),
}
}
@@ -75,6 +75,10 @@ pub struct RenderModel {
/// cerrados primero). La UI lo usa para la lista textual.
#[serde(default)]
pub aspect_summary: Vec<AspectSummary>,
/// Grupos uranianos detectados (cuerpos en la misma posición mod 90).
/// Vacío sino se activó el módulo Uranian.
#[serde(default)]
pub uranian_groups: Vec<UranianGroup>,
}
/// Etiqueta legible de un overlay para el footer del canvas. La engine
@@ -87,6 +91,23 @@ pub struct OverlayMeta {
pub label: String,
}
/// Grupo de cuerpos natales que caen en la misma posición del
/// dial uraniano de 90° (su longitud zodiacal módulo 90 es igual o
/// muy cercana). En la astrología uraniana esto es una "fórmula" o
/// "axis" — los cuerpos están en correspondencia simbólica directa
/// porque comparten un cuadrante simétrico.
///
/// Solo se emiten grupos con 2+ miembros (los singletons no son
/// fórmulas). La engine los ordena por proximidad al ε de tolerancia.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UranianGroup {
/// Identificadores agnósticos de los cuerpos en el grupo
/// (ej. `["sun", "jupiter", "saturn"]`).
pub bodies: Vec<String>,
/// Posición en el dial de 90° (la longitud módulo 90).
pub mod90_deg: f64,
}
/// Resumen textual de un aspecto para listas legibles. La engine lo
/// emite en paralelo con las `LineSeg` de la capa de aspectos, así
/// el canvas no tiene que re-derivar nombres de cuerpos desde grados.
@@ -274,6 +295,13 @@ pub enum PipelineRequest {
Composite {
partner_chart: Box<Chart>,
},
/// `module_id = "uranian"` — calcula los "ejes" del dial uraniano
/// de 90°: agrupa los cuerpos natales cuya longitud módulo 90 cae
/// dentro de una tolerancia (~2°). El resultado se publica en
/// `RenderModel.uranian_groups` para que la UI lo liste como
/// fórmulas analíticas. La visualización geométrica completa del
/// dial de 90° queda pendiente para una fase posterior.
Uranian,
}
/// Opciones que afectan la pasada natal (qué aspectos pintar, qué
@@ -400,6 +428,7 @@ pub fn compute_mock(chart: &Chart) -> RenderModel {
layers: vec![sign_dial],
overlays: Vec::new(),
aspect_summary: Vec::new(),
uranian_groups: Vec::new(),
}
}
@@ -139,6 +139,7 @@ impl Registry {
r.register(Box::new(planetary_return::PlanetaryReturnModule));
r.register(Box::new(midpoints::MidpointsModule));
r.register(Box::new(composite::CompositeModule));
r.register(Box::new(uranian::UranianModule));
r
}
@@ -663,8 +664,52 @@ mod tests {
assert!(r.find("planetary_return").is_some());
assert!(r.find("midpoints").is_some());
assert!(r.find("composite").is_some());
// Natal kind tiene 8 módulos aplicables.
assert_eq!(r.for_kind(ChartKind::Natal).len(), 8);
assert!(r.find("uranian").is_some());
// Natal kind tiene 9 módulos aplicables.
assert_eq!(r.for_kind(ChartKind::Natal).len(), 9);
assert!(r.for_kind(ChartKind::Synastry).is_empty());
}
}
// =====================================================================
// UranianModule — ejes del dial uraniano de 90° (versión textual)
// =====================================================================
pub mod uranian {
use super::*;
/// Detecta "ejes" del dial uraniano: grupos de cuerpos natales cuya
/// longitud módulo 90 cae dentro de una tolerancia. Los grupos
/// resultantes se listan en el footer del canvas. La visualización
/// geométrica del dial completo de 90° queda para una fase futura.
pub struct UranianModule;
impl Module for UranianModule {
fn id(&self) -> &'static str {
"uranian"
}
fn label(&self) -> &'static str {
"Uraniano (90°)"
}
fn description(&self) -> &'static str {
"Ejes del dial uraniano — cuerpos en la misma posición mod 90."
}
fn applies_to(&self, kind: ChartKind) -> bool {
matches!(kind, ChartKind::Natal)
}
fn enabled_by_default(&self) -> bool {
false
}
fn controls(&self) -> Vec<Control> {
vec![Control::Toggle {
key: "enabled".into(),
label: "Activar".into(),
default: false,
hotkey: None,
}]
}
fn compute_layers(&self, _chart: &Chart, _cfg: &serde_json::Value) -> Vec<Layer> {
Vec::new()
}
}
}