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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user