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
+49
View File
@@ -33,6 +33,7 @@ use tahuantinsuyu_canvas::{
};
use tahuantinsuyu_engine::{
LayerKind, NatalOptions, OUTER_RING_MODULES, PipelineRequest, compose_with_options,
svg_export,
};
use tahuantinsuyu_model::{Chart, ChartId, ModuleState, TreeSelection};
use tahuantinsuyu_panel::{ChartOption, ControlPanel, PanelEvent};
@@ -308,6 +309,7 @@ impl Shell {
show_majors: read_bool("aspect_majors", true),
show_minors: read_bool("aspect_minors", false),
orb_multiplier: read_f64("orb_multiplier", 1.0),
show_dignities: read_bool("show_dignities", false),
}
}
@@ -495,6 +497,53 @@ impl Shell {
CanvasEvent::ChartRequested(_) => {
// Fase 7: doble click sobre un thumbnail abre la carta.
}
CanvasEvent::ExportSvgRequested => {
self.export_current_to_svg();
}
}
}
/// Recompone la carta actual + escribe el SVG a un archivo en
/// `$XDG_DATA_HOME/tahuantinsuyu/exports/<label>_<short_id>.svg`.
/// Logea la ruta a stderr — futuro: file save dialog GPUI.
fn export_current_to_svg(&self) {
let Some(chart) = self.current_chart.as_ref() else {
eprintln!("[shell] export svg: sin carta activa");
return;
};
let requests = self.build_requests();
let natal_options = self.build_natal_options();
let render = match compose_with_options(
chart,
self.current_offset_minutes,
&requests,
&natal_options,
) {
Ok(r) => r,
Err(e) => {
eprintln!("[shell] export svg compose: {}", e);
return;
}
};
let svg = svg_export::render_to_svg(&render);
let dir = directories::ProjectDirs::from("net", "gioser", "tahuantinsuyu")
.map(|d| d.data_dir().join("exports"))
.unwrap_or_else(|| std::path::PathBuf::from("."));
if let Err(e) = std::fs::create_dir_all(&dir) {
eprintln!("[shell] mkdir {:?}: {}", dir, e);
return;
}
let safe_label: String = chart
.label
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect();
let short = format!("{}", chart.id).chars().take(8).collect::<String>();
let path = dir.join(format!("{}_{}.svg", safe_label, short));
if let Err(e) = std::fs::write(&path, svg) {
eprintln!("[shell] write {:?}: {}", path, e);
} else {
eprintln!("[shell] SVG exportado → {}", path.display());
}
}