feat(cosmobiologia): rectificador automático — curva del perfil del barrido
Tercer y último incremento: la visualización. El rectificador ya
muestra POR QUÉ una hora gana, no sólo cuál.
- cosmobiologia-canvas: CanvasState gana `rectificacion` +
`set_rectificacion`. render_rectify_profile dibuja el barrido como
un histograma en el footer — cada barra es una hora candidata, su
altura crece cuanto menor el puntaje; la barra más alta (el valle
del puntaje) es la hora rectificada, resaltada. Etiqueta los hitos
(mejor, 0, extremos).
- shell: run_rectificacion publica el Rectificacion al canvas además
del resumen textual al panel.
Con esto el rectificador automático (#67) queda completo: motor de
escaneo GR + UI de entrada + visualización del perfil.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
208dc1556961f02370b6c9fcc22dd52963b56e67 · ruwaq sergio · 2026-05-22T16:42:59Z (5 killa ñawpaq) · tayta a7e9662fa · kay commitpi sach'ata qhaway
Semántico sayk'uy · 8
crates/apps/cosmobiologia/src/shell.rs
crates/modules/cosmobiologia/cosmobiologia-canvas/src/lib.rs
diff --git a/crates/apps/cosmobiologia/src/shell.rs b/crates/apps/cosmobiologia/src/shell.rs
index cc40dea..3db279c 100644
--- a/crates/apps/cosmobiologia/src/shell.rs
+++ b/crates/apps/cosmobiologia/src/shell.rs
@@ -1411,16 +1411,31 @@ impl Shell {
.to_string();
// Ventana ±15 min, paso 1 min — el barrido GR estándar.
- let resultado = match cosmobiologia_engine::rectificar(&chart, &eventos, 15, 1, &key_gr) {
- Ok(r) => format!(
- "{:+} min · puntaje {:.2}",
- r.mejor_offset_minutos, r.mejor_puntaje
- ),
- Err(_) => "define al menos un evento (edad > 0)".to_string(),
- };
- self.panel.update(cx, |p, cx| {
- p.set_string("primary_directions", "resultado", Some(resultado), cx)
- });
+ match cosmobiologia_engine::rectificar(&chart, &eventos, 15, 1, &key_gr) {
+ Ok(r) => {
+ let resumen = format!(
+ "{:+} min · puntaje {:.2}",
+ r.mejor_offset_minutos, r.mejor_puntaje
+ );
+ self.panel.update(cx, |p, cx| {
+ p.set_string("primary_directions", "resultado", Some(resumen), cx)
+ });
+ // Publicar el perfil al canvas: dibuja la curva del
+ // barrido, cuyo valle marca la hora rectificada.
+ self.canvas
+ .update(cx, |c, cx| c.set_rectificacion(Some(r), cx));
+ }
+ Err(_) => {
+ self.panel.update(cx, |p, cx| {
+ p.set_string(
+ "primary_directions",
+ "resultado",
+ Some("define al menos un evento (edad > 0)".to_string()),
+ cx,
+ )
+ });
+ }
+ }
}
/// Snapshot del cielo en este instante anclado al lugar del
diff --git a/crates/modules/cosmobiologia/cosmobiologia-canvas/src/lib.rs b/crates/modules/cosmobiologia/cosmobiologia-canvas/src/lib.rs
index 1aba2c5..200facb 100644
--- a/crates/modules/cosmobiologia/cosmobiologia-canvas/src/lib.rs
+++ b/crates/modules/cosmobiologia/cosmobiologia-canvas/src/lib.rs
@@ -41,7 +41,8 @@ use gpui::{
};
use cosmobiologia_engine::{
- Geometry, GrTrigger, Layer, LayerKind, RenderModel, UranianGroup, OUTER_RING_MODULES,
+ Geometry, GrTrigger, Layer, LayerKind, Rectificacion, RenderModel, UranianGroup,
+ OUTER_RING_MODULES,
};
use cosmobiologia_model::{ChartId, ContactId, GroupId};
use cosmobiologia_theme::{AspectKind as TAspectKind, AstroPalette, Element, Planet};
@@ -151,6 +152,10 @@ pub struct CanvasState {
/// Planeta hovered actualmente (para tooltip). `None` cuando el
/// mouse no está sobre ningún cuerpo.
pub hover: Option<HoverInfo>,
+ /// Último resultado del rectificador automático, si se corrió uno.
+ /// El canvas dibuja su perfil como una curva en el footer; el valle
+ /// marca la hora de nacimiento que mejor explica los eventos.
+ pub rectificacion: Option<Rectificacion>,
drag_jog: Option<JogDragState>,
drag_pan: Option<PanDragState>,
}
@@ -234,6 +239,7 @@ impl Default for CanvasState {
layer_visibility: HashMap::new(),
show_coords: true,
hover: None,
+ rectificacion: None,
drag_jog: None,
drag_pan: None,
}
@@ -340,6 +346,17 @@ impl AstrologyCanvas {
}
}
+ /// Publica el resultado de un barrido de rectificación: el canvas
+ /// dibuja su perfil como una curva en el footer. `None` lo borra.
+ pub fn set_rectificacion(
+ &mut self,
+ rectificacion: Option<Rectificacion>,
+ cx: &mut Context<'_, Self>,
+ ) {
+ self.state.rectificacion = rectificacion;
+ cx.notify();
+ }
+
/// Resetea zoom y pan a sus defaults (1.0 y 0,0). No toca rotation
/// ni time offset — esos son ortogonales y tienen su propio reset.
pub fn reset_view(&mut self, cx: &mut Context<'_, Self>) {
@@ -846,6 +863,7 @@ impl Render for AstrologyCanvas {
&self.state.layer_visibility,
self.state.show_coords,
self.state.hover.as_ref(),
+ self.state.rectificacion.as_ref(),
entity,
),
CanvasMode::Thumbnails { items, .. } => render_thumbnails(&theme, items),
@@ -968,6 +986,7 @@ fn render_wheel(
layer_visibility: &HashMap<LayerKind, bool>,
show_coords: bool,
hover: Option<&HoverInfo>,
+ rectificacion: Option<&Rectificacion>,
entity: gpui::Entity<AstrologyCanvas>,
) -> gpui::Div {
let asc = render.ascendant_deg;
@@ -1773,6 +1792,15 @@ fn render_wheel(
));
}
+ // Perfil del rectificador automático — la curva del barrido de horas
+ // candidatas. Aparece tras correr una rectificación; su valle marca
+ // la hora de nacimiento que mejor explica los eventos conocidos.
+ if let Some(r) = rectificacion {
+ if !r.perfil.is_empty() {
+ footer = footer.child(render_rectify_profile(theme, palette, r));
+ }
+ }
+
// Lista textual de aspectos (top 12 por orb). Compacta, en grid
// de 3 columnas, fonts pequeños. Solo aparece cuando hay aspectos
// computados.
@@ -2014,6 +2042,89 @@ fn render_harmonic_spectrum(
.child(bars)
}
+/// Curva del barrido del rectificador automático. Cada barra es una hora
+/// de nacimiento candidata; su altura crece cuanto MEJOR explica los
+/// eventos conocidos (menor puntaje de convergencia). La barra más alta
+/// —el valle del puntaje— es la hora rectificada, y va resaltada.
+fn render_rectify_profile(
+ theme: &Theme,
+ palette: &AstroPalette,
+ r: &Rectificacion,
+) -> gpui::Div {
+ const BAR_AREA_H: f32 = 46.0;
+
+ let (min_p, max_p) = r.perfil.iter().fold(
+ (f32::INFINITY, f32::NEG_INFINITY),
+ |(lo, hi), &(_, p)| (lo.min(p), hi.max(p)),
+ );
+ let rango = (max_p - min_p).max(1e-3);
+ let primero = r.perfil.first().map(|&(o, _)| o).unwrap_or(0);
+ let ultimo = r.perfil.last().map(|&(o, _)| o).unwrap_or(0);
+
+ let mut bars = div().flex().flex_row().items_end().gap(px(2.0));
+ for &(offset, puntaje) in &r.perfil {
+ // Fitness: el mejor candidato (puntaje mínimo) → barra más alta.
+ let fitness = ((max_p - puntaje) / rango).clamp(0.0, 1.0);
+ let bar_h = (fitness * BAR_AREA_H).max(2.0);
+ let es_mejor = offset == r.mejor_offset_minutos;
+ let color = if es_mejor {
+ palette.angle_highlight
+ } else {
+ with_alpha(palette.angle_highlight, 0.25 + fitness * 0.45)
+ };
+ // Etiquetar sólo los hitos: el mejor, el 0 y los dos extremos.
+ let label = if es_mejor || offset == 0 || offset == primero || offset == ultimo {
+ if offset == 0 {
+ "0".to_string()
+ } else {
+ format!("{offset:+}")
+ }
+ } else {
+ String::new()
+ };
+ let column = div()
+ .flex()
+ .flex_col()
+ .items_center()
+ .gap(px(2.0))
+ .child(
+ div()
+ .h(px(BAR_AREA_H))
+ .flex()
+ .flex_col()
+ .justify_end()
+ .child(div().w(px(9.0)).h(px(bar_h)).rounded(px(1.5)).bg(color)),
+ )
+ .child(
+ div()
+ .text_size(px(7.0))
+ .text_color(if es_mejor {
+ palette.angle_highlight
+ } else {
+ theme.fg_disabled
+ })
+ .child(SharedString::from(label)),
+ );
+ bars = bars.child(column);
+ }
+
+ div()
+ .flex()
+ .flex_col()
+ .items_center()
+ .gap(px(3.0))
+ .child(
+ div()
+ .text_size(px(10.0))
+ .text_color(theme.fg_muted)
+ .child(SharedString::from(format!(
+ "Rectificación · hora {:+} min · puntaje {:.2} · el valle es la hora",
+ r.mejor_offset_minutos, r.mejor_puntaje
+ ))),
+ )
+ .child(bars)
+}
+
/// Dial uraniano de 90°: proyección geométrica de los cuerpos natales
/// sobre un eje horizontal 0-90° (longitud mod 90). Los cuerpos que
/// forman una fórmula uraniana (mismo grado dial) caen agrupados y se