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, + /// Ú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, drag_jog: Option, drag_pan: Option, } @@ -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, + 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, show_coords: bool, hover: Option<&HoverInfo>, + rectificacion: Option<&Rectificacion>, entity: gpui::Entity, ) -> 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