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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user