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
@@ -36,6 +36,10 @@ pub use tahuantinsuyu_model::{Chart, ChartId, ChartKind};
#[cfg(feature = "eternal-bridge")]
mod bridge;
#[cfg(feature = "eternal-bridge")]
mod dignity;
#[cfg(feature = "eternal-bridge")]
pub mod svg_export;
// =====================================================================
// RenderModel — lo que el canvas necesita pintar
@@ -66,6 +70,11 @@ pub struct RenderModel {
/// la pinta como badges en el footer.
#[serde(default)]
pub overlays: Vec<OverlayMeta>,
/// Lista paralela a las LineSeg de aspectos — uno por aspecto
/// natal o cross. Ordenado por `orb_deg` ascendente (los más
/// cerrados primero). La UI lo usa para la lista textual.
#[serde(default)]
pub aspect_summary: Vec<AspectSummary>,
}
/// Etiqueta legible de un overlay para el footer del canvas. La engine
@@ -78,6 +87,26 @@ pub struct OverlayMeta {
pub label: String,
}
/// Resumen textual de un aspecto para listas legibles. La engine lo
/// emite en paralelo con las `LineSeg` de la capa de aspectos, así
/// el canvas no tiene que re-derivar nombres de cuerpos desde grados.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AspectSummary {
/// Module al que pertenece — "natal", "transit", "synastry",
/// "progression", "solar_arc", "planetary_return".
pub module_id: String,
/// Identificador agnóstico del cuerpo "a" — "sun", "moon", etc.
pub from_body: String,
pub to_body: String,
/// Identificador del aspecto — "conjunction", "trine", etc.
pub kind: String,
pub orb_deg: f64,
/// `Some(true)` = applying, `Some(false)` = separating. `None` para
/// cross-aspects (sinastría/return) donde no se computa.
#[serde(default)]
pub applying: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Layer {
pub module_id: String,
@@ -135,7 +164,7 @@ pub struct PointMark {
pub tag: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Glyph {
/// Grado eclíptico [0, 360).
pub deg: f32,
@@ -148,6 +177,11 @@ pub struct Glyph {
pub retrograde: bool,
#[serde(default)]
pub house: Option<u8>,
/// Marker de dignidad esencial, set solo cuando
/// `NatalOptions::show_dignities` está activo: `"+"` (domicilio),
/// `"·"` (exaltación), `""` (exilio), `"*"` (caída).
#[serde(default)]
pub dignity_marker: Option<String>,
}
// =====================================================================
@@ -237,6 +271,10 @@ pub struct NatalOptions {
/// Multiplicador uniforme sobre los orbes default. `1.0` = orbes
/// modern_western; `0.5` = tight; `2.0` = wide.
pub orb_multiplier: f64,
/// Si `true`, anota cada cuerpo natal con su dignidad esencial
/// (domicilio +, exaltación ·, exilio , caída *). El canvas lo
/// renderea como sufijo del glifo.
pub show_dignities: bool,
}
impl Default for NatalOptions {
@@ -245,6 +283,7 @@ impl Default for NatalOptions {
show_majors: true,
show_minors: false,
orb_multiplier: 1.0,
show_dignities: false,
}
}
}
@@ -316,6 +355,7 @@ pub fn compute_mock(chart: &Chart) -> RenderModel {
annotation: None,
retrograde: false,
house: None,
dignity_marker: None,
})
.collect(),
};
@@ -332,6 +372,7 @@ pub fn compute_mock(chart: &Chart) -> RenderModel {
imum_coeli_deg: 90.0,
layers: vec![sign_dial],
overlays: Vec::new(),
aspect_summary: Vec::new(),
}
}