gioser

tawasuyu/brahman

Rust 534TOML 203Markdown 58JSON 197690 Sutichaykuna

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 · (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

2 qillqakuna · +137 −11

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