From 6191ce7dee1e6336b0dfd8becaf47ca8e32a96a6 Mon Sep 17 00:00:00 2001 From: sergio Date: Mon, 18 May 2026 00:05:50 +0000 Subject: [PATCH] =?UTF-8?q?feat(tahuantinsuyu):=20fase=2025=20=E2=80=94=20?= =?UTF-8?q?production=20polish=20(tree=20search=20+=20hotkey=20SVG=20+=20a?= =?UTF-8?q?spect=20tooltips)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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`. 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 --- .../tahuantinsuyu-canvas/src/lib.rs | 118 +++++++++++- .../tahuantinsuyu-engine/src/bridge.rs | 18 ++ .../tahuantinsuyu-engine/src/lib.rs | 13 +- .../tahuantinsuyu-tree/src/lib.rs | 171 +++++++++++++++++- 4 files changed, 313 insertions(+), 7 deletions(-) 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), } -#[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, + /// 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, } /// 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.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, depth: u32, out: &mut Vec) { 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) -> 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));