feat(tahuantinsuyu): fase 18 — aspect list + hover tooltips + dignidades + export SVG

Fase grande con 4 features que aprovechan toda la infraestructura ya
construida. Engine ganó 2 módulos nuevos (dignity table data-only +
svg_export), el RenderModel se enriqueció con AspectSummary y los
glyphs con dignity_marker, y el canvas trae hit-test pasivo + lista
textual + botón de export.

## C — Lista textual de aspectos

- engine: nuevo `AspectSummary { module_id, from_body, to_body, kind,
  orb_deg, applying }` + campo `aspect_summary: Vec<AspectSummary>`
  en RenderModel. populate_natal_aspect_summary y
  populate_cross_aspect_summary se llaman desde compose por cada
  pasada (natal + 4 overlays). Ordenado por orb_deg asc (los más
  exactos primero).
- canvas: nuevo aspect_unicode helper (☌ ☍ △ □ ⚹ ⚻ ⚺ ∠ ⚼ Q bQ).
  Footer agrega un grid flex-wrap con las top 12 entries del summary,
  cada una formateada como "[module_id] ☉ △ ☾  ·  2.3° A" coloreado
  por palette.aspect(kind).

## A — Tooltips al hover

- canvas: nuevo HoverInfo { module_id, symbol, deg, house, retrograde,
  dignity_marker, annotation, local_x, local_y } + state.hover.
  on_hover_check ejecuta hit-test sobre todos los glyphs Bodies +
  Outer (threshold 14px); se llama desde el handler MouseMoveEvent
  cuando NO está dragging (handler reescrito para soportar drag y
  hover en mismo callback). Cuando mouse sale del wheel, hover=None.
- Tooltip absoluto: "☉ Tauro · 23.4° · Casa 5 · ℞" con border
  angle_highlight. Posición offset arriba-derecha del planeta,
  clampada al wheel para no salirse.

## B — Dignidades esenciales clásicas

- engine: nuevo mod `dignity` con `Dignity { Rulership/Exaltation/
  Detriment/Fall }` + tabla rules_classical (7 planetas tradicionales)
  + exalts_at table. `essential_dignity(body, sign_index) -> Option`.
  4 tests cubren rulership/detriment/exaltation/fall + edge case
  modernos (Urano/Nept/Plutón sin dignidad clásica). 4 markers:
  + (domicilio), · (exaltación), − (exilio), * (caída).
- engine: Glyph gana campo `dignity_marker: Option<String>`. Default
  derive en Glyph para no romper N construction sites. bridge::
  annotate_dignities mutua RenderModel post-build agregando markers
  a glyphs natales según el signo de cada placement.
- NatalOptions agrega show_dignities. NatalModule.controls() agrega
  Toggle "Dignidades esenciales (+ · − *)" default false.
- canvas: glyph render append dignity_marker al texto después del ᴿ.

## D — Export SVG

- engine: nuevo `pub mod svg_export` con `render_to_svg(&RenderModel)
  -> String`. Reproduce la geometría del canvas en un SVG standalone
  800×800 escalable: anillos zodiacales, cusps, planetas con
  retrograde+dignity markers, aspectos coloreados por kind (cross
  conocen rings de origen/destino vía aspect_radii), labels ASC/MC/
  DESC/IC. Sin dependencias nuevas — write! sobre String. Test
  asserts well-formed XML.
- canvas: CanvasEvent::ExportSvgRequested + botón pequeño "⬇ SVG"
  en el header del wheel (al lado del title).
- shell: on_canvas_event ExportSvgRequested → export_current_to_svg
  recompose actual + svg_export::render_to_svg + write a
  $XDG_DATA_HOME/tahuantinsuyu/exports/<label>_<short_id>.svg.
  Ruta logueada a stderr para que el usuario encuentre el archivo.

`cargo check` y `cargo test` verdes con 8 tests en engine
(2 existentes + 4 dignity + 1 svg + 1 mock).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-17 12:07:45 +00:00
parent 0ae622550d
commit 2cd34c82da
7 changed files with 906 additions and 10 deletions
@@ -59,6 +59,9 @@ pub enum CanvasEvent {
/// El usuario togggleó una capa via hotkey — el panel debería
/// reflejarlo si quisiera mantenerse en sync.
LayerVisibilityChanged { kind: LayerKind, visible: bool },
/// El usuario pidió exportar el render actual como SVG. El shell
/// se encarga de escribir el archivo (la engine genera el string).
ExportSvgRequested,
}
// =====================================================================
@@ -110,9 +113,28 @@ pub struct CanvasState {
pub time_offset_minutes: i64,
/// Por-LayerKind: `true` = visible. Default = todo visible.
pub layer_visibility: HashMap<LayerKind, bool>,
/// Planeta hovered actualmente (para tooltip). `None` cuando el
/// mouse no está sobre ningún cuerpo.
pub hover: Option<HoverInfo>,
drag_jog: Option<JogDragState>,
}
/// Info del cuerpo bajo el cursor — usado por el render para mostrar
/// un tooltip flotante con detalles.
#[derive(Clone, Debug)]
pub struct HoverInfo {
pub module_id: String,
pub symbol: String,
pub deg: f32,
pub house: Option<u8>,
pub retrograde: bool,
pub dignity_marker: Option<String>,
pub annotation: Option<String>,
/// Posición relativa al wheel (en píxeles desde su top-left).
pub local_x: f32,
pub local_y: f32,
}
impl Default for CanvasState {
fn default() -> Self {
Self {
@@ -120,6 +142,7 @@ impl Default for CanvasState {
view_rotation_deg: 0.0,
time_offset_minutes: 0,
layer_visibility: HashMap::new(),
hover: None,
drag_jog: None,
}
}
@@ -250,6 +273,81 @@ impl AstrologyCanvas {
cx.notify();
}
/// Hit-test sobre los body glyphs activos. Recibe la posición del
/// mouse en window coords y los bounds del canvas (wheel). Para
/// cada Glyph con LayerKind == Bodies u Outer en el RenderModel
/// actual, calcula su posición pintada y mide distancia. Si está
/// dentro de `threshold_px`, actualiza `state.hover`.
fn on_hover_check(
&mut self,
position: Point<Pixels>,
bounds: Bounds<Pixels>,
cx: &mut Context<Self>,
) {
let CanvasMode::Wheel { render } = &self.state.mode else {
if self.state.hover.take().is_some() {
cx.notify();
}
return;
};
let (cx_px, cy_px) = bounds_center(bounds);
let mx: f32 = position.x.into();
let my: f32 = position.y.into();
let r_outer = (WHEEL_SIZE - WHEEL_MARGIN * 2.0) / 2.0;
let radii = Radii::from_outer(r_outer);
let asc = render.ascendant_deg;
let rot = self.state.view_rotation_deg;
let threshold = 14.0_f32;
let mut best: Option<(f32, HoverInfo)> = None;
for layer in &render.layers {
let ring = match layer.kind {
LayerKind::Bodies => radii.body_ring(&layer.module_id),
LayerKind::Outer if OUTER_RING_MODULES.contains(&layer.module_id.as_str()) => {
radii.transits
}
_ => continue,
};
for g in &layer.glyphs {
let (gx, gy) = polar_to_screen(g.deg, asc, rot, ring);
let dx = mx - (cx_px + gx);
let dy = my - (cy_px + gy);
let dist = (dx * dx + dy * dy).sqrt();
if dist > threshold {
continue;
}
if best.as_ref().map(|(d, _)| dist < *d).unwrap_or(true) {
let ox: f32 = bounds.origin.x.into();
let oy: f32 = bounds.origin.y.into();
best = Some((
dist,
HoverInfo {
module_id: layer.module_id.clone(),
symbol: g.symbol.clone(),
deg: g.deg,
house: g.house,
retrograde: g.retrograde,
dignity_marker: g.dignity_marker.clone(),
annotation: g.annotation.clone(),
local_x: cx_px + gx - ox,
local_y: cy_px + gy - oy,
},
));
}
}
}
let new_hover = best.map(|(_, h)| h);
let changed = match (&self.state.hover, &new_hover) {
(Some(a), Some(b)) => a.symbol != b.symbol || a.module_id != b.module_id,
(None, None) => false,
_ => true,
};
if changed {
self.state.hover = new_hover;
cx.notify();
}
}
fn on_jog_up(&mut self, cx: &mut Context<Self>) {
let Some(jog) = self.state.drag_jog.take() else {
return;
@@ -325,6 +423,7 @@ impl Render for AstrologyCanvas {
self.state.view_rotation_deg,
self.state.time_offset_minutes,
&self.state.layer_visibility,
self.state.hover.as_ref(),
entity,
),
CanvasMode::Thumbnails { items, .. } => render_thumbnails(&theme, items),
@@ -421,6 +520,7 @@ fn render_wheel(
view_rotation_deg: f32,
time_offset_minutes: i64,
layer_visibility: &HashMap<LayerKind, bool>,
hover: Option<&HoverInfo>,
entity: gpui::Entity<AstrologyCanvas>,
) -> gpui::Div {
let asc = render.ascendant_deg;
@@ -471,10 +571,19 @@ fn render_wheel(
});
let entity_m = entity_for_canvas.clone();
window.on_mouse_event(move |ev: &MouseMoveEvent, _, _w, cx| {
if !ev.dragging() {
return;
if ev.dragging() {
entity_m.update(cx, |this, cx| this.on_jog_move(ev.position, bounds, cx));
} else if bounds.contains(&ev.position) {
// Mouse hover sin drag: hit-test sobre los body
// glyphs para el tooltip.
entity_m.update(cx, |this, cx| this.on_hover_check(ev.position, bounds, cx));
} else {
entity_m.update(cx, |this, cx| {
if this.state.hover.take().is_some() {
cx.notify();
}
});
}
entity_m.update(cx, |this, cx| this.on_jog_move(ev.position, bounds, cx));
});
let entity_u = entity_for_canvas.clone();
window.on_mouse_event(move |_: &MouseUpEvent, _, _w, cx| {
@@ -561,11 +670,13 @@ fn render_wheel(
for g in &layer.glyphs {
let (x, y) = polar_to_screen(g.deg, asc, rot_offset, ring);
let color = with_alpha(planet_color(palette, &g.symbol), alpha);
let glyph_text = if g.retrograde {
format!("{}ᴿ", planet_unicode(&g.symbol))
} else {
planet_unicode(&g.symbol).into()
};
let mut glyph_text = planet_unicode(&g.symbol).to_string();
if g.retrograde {
glyph_text.push('ᴿ');
}
if let Some(marker) = &g.dignity_marker {
glyph_text.push_str(marker);
}
wheel = wheel.child(centered_glyph(
cx_center + x,
cy_center + y,
@@ -607,6 +718,50 @@ fn render_wheel(
}
}
// Tooltip absoluto sobre el cuerpo hovered. Aparece arriba-derecha
// del planeta, con offset pequeño para no taparlo. Texto:
// "<unicode> <signo grado>° · Casa N · módulo · retrógrado?"
if let Some(hov) = hover {
let sign_idx = ((hov.deg / 30.0).floor() as usize) % 12;
let sign_name = SIGN_NAMES_ES[sign_idx];
let deg_in_sign = hov.deg - (sign_idx as f32) * 30.0;
let mut text = format!(
"{} {} · {:.1}°",
planet_unicode(&hov.symbol),
sign_name,
deg_in_sign,
);
if let Some(h) = hov.house {
text.push_str(&format!(" · Casa {}", h));
}
if hov.retrograde {
text.push_str(" · ℞");
}
if let Some(m) = &hov.dignity_marker {
text.push_str(&format!(" · {}", m));
}
if hov.module_id != "natal" {
text.push_str(&format!(" · {}", hov.module_id));
}
let tip_x = (hov.local_x + 14.0).min(WHEEL_SIZE - 220.0).max(8.0);
let tip_y = (hov.local_y - 28.0).max(8.0);
wheel = wheel.child(
div()
.absolute()
.left(px(tip_x))
.top(px(tip_y))
.px(px(8.0))
.py(px(4.0))
.rounded(px(6.0))
.bg(theme.bg_panel_alt.clone())
.border_1()
.border_color(palette.angle_highlight)
.text_size(px(11.0))
.text_color(theme.fg_text)
.child(SharedString::from(text)),
);
}
// Labels ASC/MC/DESC/IC en el perímetro. Texto pequeño en el
// margen exterior (radius * 1.05) para que no se monte con los
// glifos de los signos. Color angle_highlight para que el ojo los
@@ -652,6 +807,34 @@ fn render_wheel(
} else {
header
};
// Botón export SVG — pequeño, alineado a la derecha del title.
let export_btn = div()
.id("tts-canvas-export-svg")
.px(px(10.0))
.py(px(3.0))
.rounded(px(4.0))
.bg(theme.bg_button())
.hover(|s| s.bg(theme.bg_button_hover()))
.border_1()
.border_color(theme.border)
.text_size(px(10.0))
.text_color(theme.fg_text)
.child("⬇ SVG")
.on_click({
let entity_e = entity.clone();
move |_: &gpui::ClickEvent, _w, cx: &mut gpui::App| {
entity_e.update(cx, |_this, cx| {
cx.emit(CanvasEvent::ExportSvgRequested);
});
}
});
let header = div()
.flex()
.flex_row()
.items_center()
.gap(px(12.0))
.child(header)
.child(export_btn);
let offset_label = format_offset(time_offset_minutes);
let offset_color = if time_offset_minutes == 0 {
@@ -706,6 +889,48 @@ fn render_wheel(
footer = footer.child(b);
}
// Lista textual de aspectos (top 12 por orb). Compacta, en grid
// de 3 columnas, fonts pequeños. Solo aparece cuando hay aspectos
// computados.
if !render.aspect_summary.is_empty() {
let mut grid = div()
.flex()
.flex_row()
.flex_wrap()
.gap(px(10.0))
.max_w(px(WHEEL_SIZE + 80.0))
.justify_center();
for ap in render.aspect_summary.iter().take(12) {
let kind_sym = aspect_unicode(&ap.kind);
let line = format!(
"{} {} {} · {:.1}°{}",
planet_unicode(&ap.from_body),
kind_sym,
planet_unicode(&ap.to_body),
ap.orb_deg,
match ap.applying {
Some(true) => " A",
Some(false) => " S",
None => "",
}
);
let prefix = if ap.module_id == "natal" {
String::new()
} else {
format!("[{}] ", ap.module_id)
};
grid = grid.child(
div()
.px(px(6.0))
.py(px(2.0))
.text_size(px(11.0))
.text_color(aspect_color(palette, &ap.kind))
.child(SharedString::from(format!("{}{}", prefix, line))),
);
}
footer = footer.child(grid);
}
div()
.flex()
.flex_col()
@@ -1289,6 +1514,38 @@ fn sign_unicode(name: &str) -> &'static str {
}
}
const SIGN_NAMES_ES: [&str; 12] = [
"Aries",
"Tauro",
"Géminis",
"Cáncer",
"Leo",
"Virgo",
"Libra",
"Escorpio",
"Sagitario",
"Capricornio",
"Acuario",
"Piscis",
];
fn aspect_unicode(kind: &str) -> &'static str {
match kind {
"conjunction" => "",
"opposition" => "",
"trine" => "",
"square" => "",
"sextile" => "",
"quincunx" => "",
"semi_sextile" => "",
"semi_square" => "",
"sesquiquadrate" => "",
"quintile" => "Q",
"biquintile" => "bQ",
_ => "·",
}
}
fn planet_unicode(name: &str) -> &'static str {
match name {
"sun" => "",