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
@@ -17,7 +17,11 @@ use eternal_sky::{Ayanamsha, Body, EphemerisSession, Instant as ESInstant, Obser
use tahuantinsuyu_model::{Chart, HouseSystem, StoredChartConfig, Zodiac};
use crate::{EngineError, Geometry, Glyph, Layer, LayerKind, LineSeg, OverlayMeta, RenderModel};
use crate::dignity::essential_dignity;
use crate::{
AspectSummary, EngineError, Geometry, Glyph, Layer, LayerKind, LineSeg, OverlayMeta,
RenderModel,
};
// =====================================================================
// Sesión global cacheada
@@ -259,6 +263,10 @@ pub fn compose(
})
.collect();
let mut render = build_render_model(chart, &natal, &aspects, t0);
if natal_options.show_dignities {
annotate_dignities(&natal, &mut render);
}
populate_natal_aspect_summary(&aspects, &mut render);
for req in requests {
match req {
@@ -346,6 +354,7 @@ fn build_transit_overlay(
annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())),
retrograde: p.longitude_rate_rad_per_day < 0.0,
house: None,
dignity_marker: None,
})
.collect();
render.layers.push(Layer {
@@ -385,6 +394,7 @@ fn build_transit_overlay(
geometry: Geometry::Lines(cross_lines),
glyphs: Vec::new(),
});
populate_cross_aspect_summary(&cross, "transit", render);
Ok(())
}
@@ -412,6 +422,7 @@ fn build_progression_overlay(
annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())),
retrograde: p.longitude_rate_rad_per_day < 0.0,
house: Some(p.house_number),
dignity_marker: None,
})
.collect();
render.layers.push(Layer {
@@ -452,6 +463,7 @@ fn build_progression_overlay(
geometry: Geometry::Lines(cross_lines),
glyphs: Vec::new(),
});
populate_cross_aspect_summary(&cross, "progression", render);
Ok(())
}
@@ -478,6 +490,7 @@ fn build_solar_arc_overlay(
annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())),
retrograde: p.longitude_rate_rad_per_day < 0.0,
house: Some(p.house_number),
dignity_marker: None,
})
.collect();
render.layers.push(Layer {
@@ -517,6 +530,7 @@ fn build_solar_arc_overlay(
geometry: Geometry::Lines(cross_lines),
glyphs: Vec::new(),
});
populate_cross_aspect_summary(&cross, "solar_arc", render);
Ok(())
}
@@ -540,6 +554,7 @@ fn build_synastry_overlay(
annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())),
retrograde: p.longitude_rate_rad_per_day < 0.0,
house: Some(p.house_number),
dignity_marker: None,
})
.collect();
render.layers.push(Layer {
@@ -579,6 +594,7 @@ fn build_synastry_overlay(
geometry: Geometry::Lines(cross_lines),
glyphs: Vec::new(),
});
populate_cross_aspect_summary(&cross, "synastry", render);
Ok(())
}
@@ -644,6 +660,7 @@ fn build_planetary_return_overlay(
annotation: Some(format!("{:.1}°", p.longitude.degree_in_sign_decimal())),
retrograde: p.longitude_rate_rad_per_day < 0.0,
house: Some(p.house_number),
dignity_marker: None,
})
.collect();
render.layers.push(Layer {
@@ -683,6 +700,7 @@ fn build_planetary_return_overlay(
geometry: Geometry::Lines(cross_lines),
glyphs: Vec::new(),
});
populate_cross_aspect_summary(&cross, "planetary_return", render);
Ok(())
}
@@ -717,6 +735,7 @@ fn build_render_model(
annotation: None,
retrograde: false,
house: None,
dignity_marker: None,
})
.collect(),
};
@@ -745,6 +764,7 @@ fn build_render_model(
annotation: None,
retrograde: false,
house: Some((i as u8) + 1),
dignity_marker: None,
})
.collect(),
};
@@ -764,6 +784,7 @@ fn build_render_model(
// depender del wrapper.
retrograde: p.longitude_rate_rad_per_day < 0.0,
house: Some(p.house_number),
dignity_marker: None,
})
.collect();
let bodies = Layer {
@@ -838,6 +859,7 @@ fn build_render_model(
imum_coeli_deg,
layers: vec![sign_dial, houses, bodies, aspects_layer],
overlays: Vec::new(),
aspect_summary: Vec::new(),
}
}
@@ -869,6 +891,67 @@ fn push_overlay_meta(render: &mut RenderModel, module_id: &str, label: String) {
});
}
/// Decora cada Glyph de Bodies (module_id="natal") con su dignity
/// marker en `glyph.dignity_marker`. Usa `essential_dignity(body, sign)`
/// — los cuerpos modernos quedan sin marker.
fn annotate_dignities(natal: &NatalChart, render: &mut RenderModel) {
use std::collections::HashMap;
let mut by_symbol: HashMap<&'static str, &'static str> = HashMap::new();
for p in &natal.placements {
let sign_idx = (p.longitude.longitude_deg() / 30.0).floor() as u8 % 12;
if let Some(d) = essential_dignity(p.body, sign_idx) {
by_symbol.insert(body_symbol(p.body), d.marker());
}
}
for layer in render.layers.iter_mut() {
if matches!(layer.kind, LayerKind::Bodies) && layer.module_id == "natal" {
for g in layer.glyphs.iter_mut() {
if let Some(marker) = by_symbol.get(g.symbol.as_str()) {
g.dignity_marker = Some((*marker).to_string());
}
}
}
}
}
fn populate_natal_aspect_summary(aspects: &[Aspect], render: &mut RenderModel) {
for a in aspects {
render.aspect_summary.push(AspectSummary {
module_id: "natal".into(),
from_body: body_symbol(a.a).into(),
to_body: body_symbol(a.b).into(),
kind: aspect_kind_id(a.kind).into(),
orb_deg: a.orb_abs_deg(),
applying: Some(a.applying),
});
}
sort_aspect_summary(render);
}
fn populate_cross_aspect_summary(
cross: &[eternal_astrology::SynastryAspect],
module_id: &str,
render: &mut RenderModel,
) {
for a in cross {
render.aspect_summary.push(AspectSummary {
module_id: module_id.to_string(),
from_body: body_symbol(a.person_a_body).into(),
to_body: body_symbol(a.person_b_body).into(),
kind: aspect_kind_id(a.kind).into(),
orb_deg: a.orb_abs_deg(),
applying: None,
});
}
sort_aspect_summary(render);
}
fn sort_aspect_summary(render: &mut RenderModel) {
render
.aspect_summary
.sort_by(|x, y| x.orb_deg.partial_cmp(&y.orb_deg).unwrap_or(std::cmp::Ordering::Equal));
}
/// Mapea el orb absoluto a una opacidad — los aspectos más exactos se
/// pintan más fuerte, los flojos casi se desvanecen.
fn orb_to_opacity(orb_deg: f64, kind: EAspectKind) -> f32 {