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.