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>
This commit is contained in:
sergio
2026-05-22 16:42:59 +00:00
parent a7e9662fad
commit 208dc15569
2 changed files with 137 additions and 11 deletions
+25 -10
View File
@@ -1411,16 +1411,31 @@ impl Shell {
.to_string(); .to_string();
// Ventana ±15 min, paso 1 min — el barrido GR estándar. // Ventana ±15 min, paso 1 min — el barrido GR estándar.
let resultado = match cosmobiologia_engine::rectificar(&chart, &eventos, 15, 1, &key_gr) { match cosmobiologia_engine::rectificar(&chart, &eventos, 15, 1, &key_gr) {
Ok(r) => format!( Ok(r) => {
"{:+} min · puntaje {:.2}", let resumen = format!(
r.mejor_offset_minutos, r.mejor_puntaje "{:+} 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| {
self.panel.update(cx, |p, cx| { p.set_string("primary_directions", "resultado", Some(resumen), cx)
p.set_string("primary_directions", "resultado", Some(resultado), 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 /// Snapshot del cielo en este instante anclado al lugar del
@@ -41,7 +41,8 @@ use gpui::{
}; };
use cosmobiologia_engine::{ 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_model::{ChartId, ContactId, GroupId};
use cosmobiologia_theme::{AspectKind as TAspectKind, AstroPalette, Element, Planet}; use cosmobiologia_theme::{AspectKind as TAspectKind, AstroPalette, Element, Planet};
@@ -151,6 +152,10 @@ pub struct CanvasState {
/// Planeta hovered actualmente (para tooltip). `None` cuando el /// Planeta hovered actualmente (para tooltip). `None` cuando el
/// mouse no está sobre ningún cuerpo. /// mouse no está sobre ningún cuerpo.
pub hover: Option<HoverInfo>, 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_jog: Option<JogDragState>,
drag_pan: Option<PanDragState>, drag_pan: Option<PanDragState>,
} }
@@ -234,6 +239,7 @@ impl Default for CanvasState {
layer_visibility: HashMap::new(), layer_visibility: HashMap::new(),
show_coords: true, show_coords: true,
hover: None, hover: None,
rectificacion: None,
drag_jog: None, drag_jog: None,
drag_pan: 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 /// 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. /// ni time offset — esos son ortogonales y tienen su propio reset.
pub fn reset_view(&mut self, cx: &mut Context<'_, Self>) { pub fn reset_view(&mut self, cx: &mut Context<'_, Self>) {
@@ -846,6 +863,7 @@ impl Render for AstrologyCanvas {
&self.state.layer_visibility, &self.state.layer_visibility,
self.state.show_coords, self.state.show_coords,
self.state.hover.as_ref(), self.state.hover.as_ref(),
self.state.rectificacion.as_ref(),
entity, entity,
), ),
CanvasMode::Thumbnails { items, .. } => render_thumbnails(&theme, items), CanvasMode::Thumbnails { items, .. } => render_thumbnails(&theme, items),
@@ -968,6 +986,7 @@ fn render_wheel(
layer_visibility: &HashMap<LayerKind, bool>, layer_visibility: &HashMap<LayerKind, bool>,
show_coords: bool, show_coords: bool,
hover: Option<&HoverInfo>, hover: Option<&HoverInfo>,
rectificacion: Option<&Rectificacion>,
entity: gpui::Entity<AstrologyCanvas>, entity: gpui::Entity<AstrologyCanvas>,
) -> gpui::Div { ) -> gpui::Div {
let asc = render.ascendant_deg; 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 // Lista textual de aspectos (top 12 por orb). Compacta, en grid
// de 3 columnas, fonts pequeños. Solo aparece cuando hay aspectos // de 3 columnas, fonts pequeños. Solo aparece cuando hay aspectos
// computados. // computados.
@@ -2014,6 +2042,89 @@ fn render_harmonic_spectrum(
.child(bars) .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 /// Dial uraniano de 90°: proyección geométrica de los cuerpos natales
/// sobre un eje horizontal 0-90° (longitud mod 90). Los cuerpos que /// sobre un eje horizontal 0-90° (longitud mod 90). Los cuerpos que
/// forman una fórmula uraniana (mismo grado dial) caen agrupados y se /// forman una fórmula uraniana (mismo grado dial) caen agrupados y se