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