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
+64 -26
View File
@@ -59,6 +59,11 @@ pub struct Shell {
/// `module_id`. Las claves dentro del JSON dependen del módulo (la
/// convención es `"enabled": bool` para el toggle principal).
module_configs: HashMap<String, serde_json::Value>,
/// Sequence counter para descartar resultados de cómputos
/// background que llegan después de uno más reciente. Cada
/// `render_current` lo incrementa y la closure async compara antes
/// de aplicar el render al canvas.
render_seq: u64,
}
impl Shell {
@@ -94,6 +99,7 @@ impl Shell {
current_chart: None,
current_offset_minutes: 0,
module_configs: HashMap::new(),
render_seq: 0,
};
shell.refresh_chart_options(cx);
shell
@@ -256,6 +262,9 @@ impl Shell {
if module_enabled(&self.module_configs, "midpoints") {
requests.push(PipelineRequest::Midpoints);
}
if module_enabled(&self.module_configs, "uranian") {
requests.push(PipelineRequest::Uranian);
}
if module_enabled(&self.module_configs, "composite") {
if let Some(partner) = self.resolve_composite_partner() {
requests.push(PipelineRequest::Composite {
@@ -464,34 +473,63 @@ impl Shell {
let Some(chart) = self.current_chart.as_ref() else {
return;
};
// Snapshot de inputs para mover al background. La sesión
// VSOP2013 vive en un static `OnceLock` adentro del bridge, así
// que es compartible read-only entre threads sin que ningún
// dato cruce más allá del Chart clonado + requests/options.
let chart = chart.clone();
let offset = self.current_offset_minutes;
let requests = self.build_requests();
let natal_options = self.build_natal_options();
let render = match compose_with_options(
chart,
self.current_offset_minutes,
&requests,
&natal_options,
) {
Ok(r) => r,
Err(e) => {
eprintln!(
"[shell] compose {} (+{}min, {} reqs): {}",
chart.id,
self.current_offset_minutes,
requests.len(),
e
);
return;
}
};
self.canvas.update(cx, |c, cx| {
c.set_mode(
CanvasMode::Wheel {
render: Box::new(render),
},
cx,
);
});
self.render_seq = self.render_seq.wrapping_add(1);
let my_seq = self.render_seq;
cx.spawn(async move |this, cx| {
// El compute corre en el background_executor — no bloquea
// el UI thread. Para una rueda completa con varios overlays
// puede tomar 100-200ms; sin esto, los drags del slider se
// sentirían atorados.
let chart_for_bg = chart.clone();
let requests_for_bg = requests.clone();
let opts_for_bg = natal_options.clone();
let result = cx
.background_executor()
.spawn(async move {
compose_with_options(&chart_for_bg, offset, &requests_for_bg, &opts_for_bg)
})
.await;
let _ = this.update(cx, |this, cx| {
// Descartar si llegó un render más nuevo en el medio.
// Sin este check, durante un drag rápido un compute
// viejo podría sobrescribir el más reciente.
if this.render_seq != my_seq {
return;
}
match result {
Ok(render) => {
this.canvas.update(cx, |c, cx| {
c.set_mode(
CanvasMode::Wheel {
render: Box::new(render),
},
cx,
);
});
}
Err(e) => {
eprintln!(
"[shell] compose {} (+{}min, {} reqs): {}",
chart.id,
offset,
requests.len(),
e
);
}
}
});
})
.detach();
}
fn on_canvas_event(&mut self, ev: &CanvasEvent, cx: &mut Context<Self>) {