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
/// 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,