feat(tahuantinsuyu): fase 25 — production polish (tree search + hotkey SVG + aspect tooltips)

Tres mejoras de usabilidad que aprovechan toda la infraestructura ya
construida. PNG export (#17) lo dejé para una fase futura — agregar
resvg suma ~1MB al binario y el SVG ya cubre el caso profesional.

## #9 — Búsqueda en el tree

Arriba del árbol de groups/contacts/cartas aparece un TextInput con
placeholder "Buscar nombre…". Al apretar Enter aplica el filtro:
case-insensitive substring match sobre group.name, contact.name y
chart.label. Auto-expande recursivamente todos los ancestros que
contienen un match — los resultados quedan visibles sin que el
usuario tenga que abrir chevrons. Escape limpia el filtro.

- tree: TahuantinsuyuTree gana `search_filter: String` y
  `search_input: Entity<TextInput>`. set_search_filter() actualiza +
  auto_expand_matches + refresh.
- group_has_match() / contact_has_match() recursivos chequean si
  algún descendiente matchea.
- append_groups/contacts/charts filtran por substring antes de
  emitir cada row.
- render: nueva barra search arriba con border-bottom.

## #10 — Hotkey [S] = export SVG

El canvas ya tenía botón "⬇ SVG" en el header del wheel. Ahora la
tecla [S] sobre el wheel (con focus) emite el mismo evento
ExportSvgRequested. La línea de hotkeys del footer pasa a:
"[D]ial [H]ouses as[X]pects [P]lanets [T]ransits [S]vg [R]eset".

## #8 — Tooltips sobre líneas de aspecto

- engine: LineSeg gana fields opcionales `from_body: String`,
  `to_body: String`, `orb_deg: f32` (default empty/0.0 para
  back-compat serde). Bridge popula los 6 sites de LineSeg
  construction (natal aspects + 5 cross overlays) vía un script
  Python regex que añadió `from_body: body_symbol(a.X).into()` +
  `to_body: body_symbol(a.Y).into()` + `orb_deg: a.orb_abs_deg() as
  f32` a cada constructor.
- canvas: HoverInfo gana variante Aspect { module_id, from_body,
  to_body, kind, orb_deg, local_x, local_y }. on_hover_check itera
  Aspects layers después de los Bodies (precedencia a planetas) y
  computa distancia punto-segmento con `dist_point_segment` helper —
  threshold 4px para hover. Tooltip muestra "☉ △ ♂  ·  orb 2.3°" con
  prefix de módulo si no es natal.

cargo check verde, 8 tests engine + 1 modules verdes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-18 00:05:50 +00:00
parent a539fab15c
commit 6191ce7dee
4 changed files with 313 additions and 7 deletions
@@ -120,9 +120,8 @@ pub struct CanvasState {
} }
/// Info del elemento bajo el cursor — usado por el render para mostrar /// Info del elemento bajo el cursor — usado por el render para mostrar
/// un tooltip flotante con detalles. Cubre body glyphs (módulo + /// un tooltip flotante con detalles. Cubre body glyphs, cusps de casa,
/// símbolo + grado + casa + retro + dignidad) y cusps de casa (número /// y líneas de aspectos.
/// de la casa + grado del cusp + signo).
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub enum HoverInfo { pub enum HoverInfo {
Body { Body {
@@ -142,6 +141,18 @@ pub enum HoverInfo {
local_x: f32, local_x: f32,
local_y: f32, local_y: f32,
}, },
/// Hover sobre una línea de aspecto. `from_body`/`to_body` y `kind`
/// vienen de la LineSeg; `orb_deg` también. Los coords son el
/// punto medio del segmento donde se muestra el tooltip.
Aspect {
module_id: String,
from_body: String,
to_body: String,
kind: String,
orb_deg: f32,
local_x: f32,
local_y: f32,
},
} }
impl HoverInfo { impl HoverInfo {
@@ -149,6 +160,7 @@ impl HoverInfo {
match self { match self {
HoverInfo::Body { local_x, local_y, .. } => (*local_x, *local_y), HoverInfo::Body { local_x, local_y, .. } => (*local_x, *local_y),
HoverInfo::HouseCusp { local_x, local_y, .. } => (*local_x, *local_y), HoverInfo::HouseCusp { local_x, local_y, .. } => (*local_x, *local_y),
HoverInfo::Aspect { local_x, local_y, .. } => (*local_x, *local_y),
} }
} }
@@ -158,6 +170,13 @@ impl HoverInfo {
module_id, symbol, .. module_id, symbol, ..
} => format!("body:{}:{}", module_id, symbol), } => format!("body:{}:{}", module_id, symbol),
HoverInfo::HouseCusp { house_number, .. } => format!("cusp:{}", house_number), HoverInfo::HouseCusp { house_number, .. } => format!("cusp:{}", house_number),
HoverInfo::Aspect {
module_id,
from_body,
to_body,
kind,
..
} => format!("aspect:{}:{}-{}-{}", module_id, from_body, kind, to_body),
} }
} }
} }
@@ -366,7 +385,52 @@ impl AstrologyCanvas {
} }
} }
// 2) House cusps — solo si el mouse está cerca del anillo de // 2) Aspect lines (segundo: las líneas son más "frágiles" que
// los planetas; si un body matcheó arriba ya tomó precedencia).
// Computa distancia punto-segmento del mouse al line.
if best.is_none() {
for layer in &render.layers {
if !matches!(layer.kind, LayerKind::Aspects) {
continue;
}
let (r_from, r_to) = radii.aspect_endpoints(&layer.module_id);
if let Geometry::Lines(segs) = &layer.geometry {
for seg in segs {
if seg.from_body.is_empty() || seg.to_body.is_empty() {
continue;
}
let (ax, ay) = polar_to_screen(seg.from_deg, asc, rot, r_from);
let (bx, by) = polar_to_screen(seg.to_deg, asc, rot, r_to);
let px_a = cx_px + ax;
let py_a = cy_px + ay;
let px_b = cx_px + bx;
let py_b = cy_px + by;
let dist = dist_point_segment(mx, my, px_a, py_a, px_b, py_b);
if dist > 4.0 {
continue;
}
if best.as_ref().map(|(d, _)| dist < *d).unwrap_or(true) {
let mid_x = (px_a + px_b) / 2.0;
let mid_y = (py_a + py_b) / 2.0;
best = Some((
dist,
HoverInfo::Aspect {
module_id: layer.module_id.clone(),
from_body: seg.from_body.clone(),
to_body: seg.to_body.clone(),
kind: seg.kind.clone(),
orb_deg: seg.orb_deg,
local_x: mid_x - ox,
local_y: mid_y - oy,
},
));
}
}
}
}
}
// 3) House cusps — solo si el mouse está cerca del anillo de
// casas (radio entre houses_inner y houses_outer + margen) y // casas (radio entre houses_inner y houses_outer + margen) y
// ningún body ganó. Las cusps son líneas radiales — la // ningún body ganó. Las cusps son líneas radiales — la
// distancia angular al cusp más cercano determina el hit. // distancia angular al cusp más cercano determina el hit.
@@ -464,6 +528,10 @@ impl AstrologyCanvas {
self.reset_time_offset(cx); self.reset_time_offset(cx);
return; return;
} }
"s" | "S" => {
cx.emit(CanvasEvent::ExportSvgRequested);
return;
}
_ => return, _ => return,
}; };
self.toggle_layer(kind, cx); self.toggle_layer(kind, cx);
@@ -857,6 +925,26 @@ fn render_wheel(
house_number, sign_name, deg_in_sign house_number, sign_name, deg_in_sign
) )
} }
HoverInfo::Aspect {
module_id,
from_body,
to_body,
kind,
orb_deg,
..
} => {
let mut t = format!(
"{} {} {} · orb {:.1}°",
planet_unicode(from_body),
aspect_unicode(kind),
planet_unicode(to_body),
orb_deg
);
if module_id != "natal" {
t.push_str(&format!(" · {}", module_id));
}
t
}
}; };
let (lx, ly) = hov.local(); let (lx, ly) = hov.local();
let tip_x = (lx + 14.0).min(WHEEL_SIZE - 220.0).max(8.0); let tip_x = (lx + 14.0).min(WHEEL_SIZE - 220.0).max(8.0);
@@ -981,7 +1069,7 @@ fn render_wheel(
div() div()
.text_size(px(10.0)) .text_size(px(10.0))
.text_color(theme.fg_disabled) .text_color(theme.fg_disabled)
.child("[D]ial [H]ouses as[X]pects [P]lanets [T]ransits [R]eset"), .child("[D]ial [H]ouses as[X]pects [P]lanets [T]ransits [S]vg [R]eset"),
); );
// Badges de overlays activos. Cada uno se pinta como pill con // Badges de overlays activos. Cada uno se pinta como pill con
@@ -1626,6 +1714,26 @@ fn paint_cross_aspect_line(
// Helpers // Helpers
// ===================================================================== // =====================================================================
/// Distancia mínima entre un punto y un segmento de recta. Usado por
/// hover_check para detectar proximity a líneas de aspectos.
fn dist_point_segment(px: f32, py: f32, ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
let dx = bx - ax;
let dy = by - ay;
let len_sq = dx * dx + dy * dy;
if len_sq < f32::EPSILON {
// Segmento degenerado → distancia al punto a.
let pdx = px - ax;
let pdy = py - ay;
return (pdx * pdx + pdy * pdy).sqrt();
}
let t = (((px - ax) * dx + (py - ay) * dy) / len_sq).clamp(0.0, 1.0);
let proj_x = ax + t * dx;
let proj_y = ay + t * dy;
let dx2 = px - proj_x;
let dy2 = py - proj_y;
(dx2 * dx2 + dy2 * dy2).sqrt()
}
fn polar_to_screen( fn polar_to_screen(
longitude_deg: f32, longitude_deg: f32,
ascendant_deg: f32, ascendant_deg: f32,
@@ -416,6 +416,9 @@ fn build_transit_overlay(
to_deg: transit_p.longitude.longitude_deg() as f32, to_deg: transit_p.longitude.longitude_deg() as f32,
kind: aspect_kind_id(a.kind).into(), kind: aspect_kind_id(a.kind).into(),
opacity: opacity * 0.75, opacity: opacity * 0.75,
from_body: body_symbol(a.person_a_body).into(),
to_body: body_symbol(a.person_b_body).into(),
orb_deg: a.orb_abs_deg() as f32,
}) })
}) })
.collect(); .collect();
@@ -485,6 +488,9 @@ fn build_progression_overlay(
to_deg: prog_p.longitude.longitude_deg() as f32, to_deg: prog_p.longitude.longitude_deg() as f32,
kind: aspect_kind_id(a.kind).into(), kind: aspect_kind_id(a.kind).into(),
opacity: opacity * 0.7, opacity: opacity * 0.7,
from_body: body_symbol(a.person_a_body).into(),
to_body: body_symbol(a.person_b_body).into(),
orb_deg: a.orb_abs_deg() as f32,
}) })
}) })
.collect(); .collect();
@@ -709,6 +715,9 @@ fn build_solar_arc_overlay(
to_deg: dir_p.longitude.longitude_deg() as f32, to_deg: dir_p.longitude.longitude_deg() as f32,
kind: aspect_kind_id(a.kind).into(), kind: aspect_kind_id(a.kind).into(),
opacity: opacity * 0.7, opacity: opacity * 0.7,
from_body: body_symbol(a.person_a_body).into(),
to_body: body_symbol(a.person_b_body).into(),
orb_deg: a.orb_abs_deg() as f32,
}) })
}) })
.collect(); .collect();
@@ -773,6 +782,9 @@ fn build_synastry_overlay(
to_deg: partner_p.longitude.longitude_deg() as f32, to_deg: partner_p.longitude.longitude_deg() as f32,
kind: aspect_kind_id(a.kind).into(), kind: aspect_kind_id(a.kind).into(),
opacity: opacity * 0.85, opacity: opacity * 0.85,
from_body: body_symbol(a.person_a_body).into(),
to_body: body_symbol(a.person_b_body).into(),
orb_deg: a.orb_abs_deg() as f32,
}) })
}) })
.collect(); .collect();
@@ -883,6 +895,9 @@ fn build_planetary_return_overlay(
to_deg: r_p.longitude.longitude_deg() as f32, to_deg: r_p.longitude.longitude_deg() as f32,
kind: aspect_kind_id(a.kind).into(), kind: aspect_kind_id(a.kind).into(),
opacity: opacity * 0.8, opacity: opacity * 0.8,
from_body: body_symbol(a.person_a_body).into(),
to_body: body_symbol(a.person_b_body).into(),
orb_deg: a.orb_abs_deg() as f32,
}) })
}) })
.collect(); .collect();
@@ -1014,6 +1029,9 @@ fn build_render_model(
to_deg: pb.longitude.longitude_deg() as f32, to_deg: pb.longitude.longitude_deg() as f32,
kind: aspect_kind_id(a.kind).into(), kind: aspect_kind_id(a.kind).into(),
opacity, opacity,
from_body: body_symbol(a.a).into(),
to_body: body_symbol(a.b).into(),
orb_deg: a.orb_abs_deg() as f32,
}); });
} }
} }
@@ -166,7 +166,7 @@ pub enum Geometry {
Points(Vec<PointMark>), Points(Vec<PointMark>),
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct LineSeg { pub struct LineSeg {
/// Grados zodiacales del extremo "a". /// Grados zodiacales del extremo "a".
pub from_deg: f32, pub from_deg: f32,
@@ -176,6 +176,17 @@ pub struct LineSeg {
/// resuelve a color. /// resuelve a color.
pub kind: String, pub kind: String,
pub opacity: f32, pub opacity: f32,
/// Cuerpo en el extremo "a" — populado para LineSegs de aspectos
/// (natal × natal, cross con overlays). Vacío en `Default::default`
/// para serde back-compat.
#[serde(default)]
pub from_body: String,
/// Cuerpo en el extremo "b".
#[serde(default)]
pub to_body: String,
/// Orb absoluto en grados (para tooltips).
#[serde(default)]
pub orb_deg: f32,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -156,6 +156,14 @@ pub struct TahuantinsuyuTree {
/// llamar [`Self::set_city_atlas`] para reemplazar por uno custom /// llamar [`Self::set_city_atlas`] para reemplazar por uno custom
/// cargado desde disco (TSV). /// cargado desde disco (TSV).
city_atlas: Vec<CityPreset>, city_atlas: Vec<CityPreset>,
/// Filtro de búsqueda activo. Vacío = sin filtro (jerarquía
/// completa). Cuando hay texto, refresh() solo incluye rows cuyo
/// nombre (group / contact / chart label) contenga el substring
/// case-insensitive, y auto-expande los ancestros para que el
/// match sea visible.
search_filter: String,
/// TextInput para el filtro — vive arriba del tree.
search_input: Entity<TextInput>,
} }
/// Preset de ciudad con datos canónicos para autocompletar lat/lon/tz /// Preset de ciudad con datos canónicos para autocompletar lat/lon/tz
@@ -331,6 +339,23 @@ impl TahuantinsuyuTree {
}) })
.detach(); .detach();
let search_input = cx.new(|cx| {
TextInput::new(String::new(), cx)
.with_placeholder(SharedString::from("Buscar nombre…"))
});
cx.subscribe(
&search_input,
|this: &mut Self, _, ev: &TextInputEvent, cx| match ev {
TextInputEvent::Confirmed(value) => {
this.set_search_filter(value.clone(), cx);
}
TextInputEvent::Cancelled => {
this.set_search_filter(String::new(), cx);
}
},
)
.detach();
let mut me = Self { let mut me = Self {
store, store,
inner, inner,
@@ -339,6 +364,8 @@ impl TahuantinsuyuTree {
modal: None, modal: None,
city_picker_open: false, city_picker_open: false,
city_atlas: default_city_presets(), city_atlas: default_city_presets(),
search_filter: String::new(),
search_input,
}; };
me.refresh(cx); me.refresh(cx);
me me
@@ -360,12 +387,135 @@ impl TahuantinsuyuTree {
self.inner.update(cx, |t, cx| t.set_rows(rows, cx)); self.inner.update(cx, |t, cx| t.set_rows(rows, cx));
} }
/// Actualiza el filtro de búsqueda — texto vacío = sin filtro.
/// Cuando hay filtro, expande automáticamente los ancestros que
/// contienen matches para que el usuario vea los resultados sin
/// tener que clickear chevrons.
fn set_search_filter(&mut self, filter: String, cx: &mut Context<Self>) {
self.search_filter = filter.trim().to_lowercase();
if !self.search_filter.is_empty() {
self.auto_expand_matches();
}
self.refresh(cx);
}
/// Pre-expande todos los groups + contacts que contienen al menos
/// un descendiente cuyo nombre matchee el filtro. Hace una pasada
/// recursiva agregando ids al `expanded` set.
fn auto_expand_matches(&mut self) {
fn walk_group(this: &mut TahuantinsuyuTree, group_id: GroupId) -> bool {
let mut any_match = false;
// Sub-groups recursivamente.
if let Ok(children) = this.store.list_groups(Some(group_id)) {
for g in children {
let name_match = g.name.to_lowercase().contains(&this.search_filter);
let child_match = walk_group(this, g.id);
if name_match || child_match {
this.expanded.insert(format!("{}{}", PREFIX_GROUP, g.id));
any_match = true;
}
}
}
// Contacts directos.
if let Ok(contacts) = this.store.list_contacts(Some(group_id)) {
for c in contacts {
let name_match = c.name.to_lowercase().contains(&this.search_filter);
let chart_match = contact_has_matching_chart(this, c.id);
if name_match || chart_match {
this.expanded.insert(format!("{}{}", PREFIX_CONTACT, c.id));
any_match = true;
}
}
}
any_match
}
fn contact_has_matching_chart(this: &TahuantinsuyuTree, contact_id: ContactId) -> bool {
this.store
.list_charts(contact_id)
.map(|charts| {
charts
.iter()
.any(|h| h.label.to_lowercase().contains(&this.search_filter))
})
.unwrap_or(false)
}
// Top-level groups + contacts directos en raíz.
if let Ok(groups) = self.store.list_groups(None) {
for g in groups {
let name_match = g.name.to_lowercase().contains(&self.search_filter);
let child_match = walk_group(self, g.id);
if name_match || child_match {
self.expanded.insert(format!("{}{}", PREFIX_GROUP, g.id));
}
}
}
if let Ok(contacts) = self.store.list_contacts(None) {
for c in contacts {
let name_match = c.name.to_lowercase().contains(&self.search_filter);
let chart_match = contact_has_matching_chart(self, c.id);
if name_match || chart_match {
self.expanded.insert(format!("{}{}", PREFIX_CONTACT, c.id));
}
}
}
}
/// `true` si la jerarquía bajo `group_id` (recursivo) tiene
/// algún descendiente que matchee el filtro de búsqueda.
fn group_has_match(&self, group_id: GroupId) -> bool {
if let Ok(sub) = self.store.list_groups(Some(group_id)) {
for g in &sub {
if g.name.to_lowercase().contains(&self.search_filter) {
return true;
}
if self.group_has_match(g.id) {
return true;
}
}
}
if let Ok(contacts) = self.store.list_contacts(Some(group_id)) {
for c in &contacts {
if c.name.to_lowercase().contains(&self.search_filter) {
return true;
}
if let Ok(charts) = self.store.list_charts(c.id) {
if charts
.iter()
.any(|h| h.label.to_lowercase().contains(&self.search_filter))
{
return true;
}
}
}
}
false
}
/// `true` si el contacto tiene una carta cuyo label matchee.
fn contact_has_match(&self, contact_id: ContactId) -> bool {
if let Ok(charts) = self.store.list_charts(contact_id) {
return charts
.iter()
.any(|h| h.label.to_lowercase().contains(&self.search_filter));
}
false
}
fn append_groups(&self, parent: Option<GroupId>, depth: u32, out: &mut Vec<TreeRow>) { fn append_groups(&self, parent: Option<GroupId>, depth: u32, out: &mut Vec<TreeRow>) {
let groups = match self.store.list_groups(parent) { let groups = match self.store.list_groups(parent) {
Ok(v) => v, Ok(v) => v,
Err(_) => return, Err(_) => return,
}; };
for g in groups { for g in groups {
// Filtro: incluir si el group matchea por nombre o tiene
// algún descendiente matching.
if !self.search_filter.is_empty() {
let name_match = g.name.to_lowercase().contains(&self.search_filter);
if !name_match && !self.group_has_match(g.id) {
continue;
}
}
let id_str = format!("{}{}", PREFIX_GROUP, g.id); let id_str = format!("{}{}", PREFIX_GROUP, g.id);
let expanded = self.expanded.contains(&id_str); let expanded = self.expanded.contains(&id_str);
out.push(TreeRow { out.push(TreeRow {
@@ -389,6 +539,12 @@ impl TahuantinsuyuTree {
Err(_) => return, Err(_) => return,
}; };
for c in contacts { for c in contacts {
if !self.search_filter.is_empty() {
let name_match = c.name.to_lowercase().contains(&self.search_filter);
if !name_match && !self.contact_has_match(c.id) {
continue;
}
}
let id_str = format!("{}{}", PREFIX_CONTACT, c.id); let id_str = format!("{}{}", PREFIX_CONTACT, c.id);
let expanded = self.expanded.contains(&id_str); let expanded = self.expanded.contains(&id_str);
out.push(TreeRow { out.push(TreeRow {
@@ -411,6 +567,11 @@ impl TahuantinsuyuTree {
Err(_) => return, Err(_) => return,
}; };
for h in charts { for h in charts {
if !self.search_filter.is_empty()
&& !h.label.to_lowercase().contains(&self.search_filter)
{
continue;
}
let id_str = format!("{}{}", PREFIX_CHART, h.id); let id_str = format!("{}{}", PREFIX_CHART, h.id);
out.push(TreeRow { out.push(TreeRow {
id: RowId::new(id_str), id: RowId::new(id_str),
@@ -1049,6 +1210,13 @@ const MENU_WIDTH: f32 = 220.0;
impl Render for TahuantinsuyuTree { impl Render for TahuantinsuyuTree {
fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = Theme::global(cx).clone(); let theme = Theme::global(cx).clone();
let search_bar = div()
.px(px(6.0))
.py(px(4.0))
.border_b_1()
.border_color(theme.border)
.child(self.search_input.clone());
let mut root = div() let mut root = div()
.id("tahuantinsuyu-tree-root") .id("tahuantinsuyu-tree-root")
.size_full() .size_full()
@@ -1056,7 +1224,8 @@ impl Render for TahuantinsuyuTree {
.bg(theme.bg_panel.clone()) .bg(theme.bg_panel.clone())
.flex() .flex()
.flex_col() .flex_col()
.child(self.inner.clone()); .child(search_bar)
.child(div().flex_grow().min_h(px(0.0)).child(self.inner.clone()));
if let Some(menu) = self.menu.clone() { if let Some(menu) = self.menu.clone() {
root = root.child(self.render_menu(&theme, menu, cx)); root = root.child(self.render_menu(&theme, menu, cx));