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>
6191ce7dee1e6336b0dfd8becaf47ca8e32a96a6 · by sergio · 2026-05-18T00:05:50Z (5 months ago) · parent a539fab15 · browse the tree at this commit
Semantic delta · 26
crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs
crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs
crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs
signature changed struct LineSeg pub
crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs
index ca9861a..bf174c9 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs
@@ -120,9 +120,8 @@ pub struct CanvasState {
}
/// Info del elemento bajo el cursor — usado por el render para mostrar
-/// un tooltip flotante con detalles. Cubre body glyphs (módulo +
-/// símbolo + grado + casa + retro + dignidad) y cusps de casa (número
-/// de la casa + grado del cusp + signo).
+/// un tooltip flotante con detalles. Cubre body glyphs, cusps de casa,
+/// y líneas de aspectos.
#[derive(Clone, Debug)]
pub enum HoverInfo {
Body {
@@ -142,6 +141,18 @@ pub enum HoverInfo {
local_x: 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 {
@@ -149,6 +160,7 @@ impl HoverInfo {
match self {
HoverInfo::Body { 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, ..
} => format!("body:{}:{}", module_id, symbol),
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
// ningún body ganó. Las cusps son líneas radiales — la
// distancia angular al cusp más cercano determina el hit.
@@ -464,6 +528,10 @@ impl AstrologyCanvas {
self.reset_time_offset(cx);
return;
}
+ "s" | "S" => {
+ cx.emit(CanvasEvent::ExportSvgRequested);
+ return;
+ }
_ => return,
};
self.toggle_layer(kind, cx);
@@ -857,6 +925,26 @@ fn render_wheel(
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 tip_x = (lx + 14.0).min(WHEEL_SIZE - 220.0).max(8.0);
@@ -981,7 +1069,7 @@ fn render_wheel(
div()
.text_size(px(10.0))
.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
@@ -1626,6 +1714,26 @@ fn paint_cross_aspect_line(
// 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(
longitude_deg: f32,
ascendant_deg: f32,
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs
index 2b8c44a..68ebf8b 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs
@@ -416,6 +416,9 @@ fn build_transit_overlay(
to_deg: transit_p.longitude.longitude_deg() as f32,
kind: aspect_kind_id(a.kind).into(),
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();
@@ -485,6 +488,9 @@ fn build_progression_overlay(
to_deg: prog_p.longitude.longitude_deg() as f32,
kind: aspect_kind_id(a.kind).into(),
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();
@@ -709,6 +715,9 @@ fn build_solar_arc_overlay(
to_deg: dir_p.longitude.longitude_deg() as f32,
kind: aspect_kind_id(a.kind).into(),
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();
@@ -773,6 +782,9 @@ fn build_synastry_overlay(
to_deg: partner_p.longitude.longitude_deg() as f32,
kind: aspect_kind_id(a.kind).into(),
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();
@@ -883,6 +895,9 @@ fn build_planetary_return_overlay(
to_deg: r_p.longitude.longitude_deg() as f32,
kind: aspect_kind_id(a.kind).into(),
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();
@@ -1014,6 +1029,9 @@ fn build_render_model(
to_deg: pb.longitude.longitude_deg() as f32,
kind: aspect_kind_id(a.kind).into(),
opacity,
+ from_body: body_symbol(a.a).into(),
+ to_body: body_symbol(a.b).into(),
+ orb_deg: a.orb_abs_deg() as f32,
});
}
}
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs
index 7a51477..e4f17ed 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs
@@ -166,7 +166,7 @@ pub enum Geometry {
Points(Vec<PointMark>),
}
-#[derive(Debug, Clone, Serialize, Deserialize)]
+#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct LineSeg {
/// Grados zodiacales del extremo "a".
pub from_deg: f32,
@@ -176,6 +176,17 @@ pub struct LineSeg {
/// resuelve a color.
pub kind: String,
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)]
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs
index 79fa638..a19b1ff 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-tree/src/lib.rs
@@ -156,6 +156,14 @@ pub struct TahuantinsuyuTree {
/// llamar [`Self::set_city_atlas`] para reemplazar por uno custom
/// cargado desde disco (TSV).
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
@@ -331,6 +339,23 @@ impl TahuantinsuyuTree {
})
.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 {
store,
inner,
@@ -339,6 +364,8 @@ impl TahuantinsuyuTree {
modal: None,
city_picker_open: false,
city_atlas: default_city_presets(),
+ search_filter: String::new(),
+ search_input,
};
me.refresh(cx);
me
@@ -360,12 +387,135 @@ impl TahuantinsuyuTree {
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>) {
let groups = match self.store.list_groups(parent) {
Ok(v) => v,
Err(_) => return,
};
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 expanded = self.expanded.contains(&id_str);
out.push(TreeRow {
@@ -389,6 +539,12 @@ impl TahuantinsuyuTree {
Err(_) => return,
};
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 expanded = self.expanded.contains(&id_str);
out.push(TreeRow {
@@ -411,6 +567,11 @@ impl TahuantinsuyuTree {
Err(_) => return,
};
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);
out.push(TreeRow {
id: RowId::new(id_str),
@@ -1049,6 +1210,13 @@ const MENU_WIDTH: f32 = 220.0;
impl Render for TahuantinsuyuTree {
fn render(&mut self, _w: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
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()
.id("tahuantinsuyu-tree-root")
.size_full()
@@ -1056,7 +1224,8 @@ impl Render for TahuantinsuyuTree {
.bg(theme.bg_panel.clone())
.flex()
.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() {
root = root.child(self.render_menu(&theme, menu, cx));