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>
2cd34c82da7e745a2f9cfac90715f745f4487036 · por sergio · 2026-05-17T12:07:45Z (hace 5 meses) · padre 0ae622550 · ver el árbol en este commit
Delta semántico · 65
crates/apps/tahuantinsuyu/src/shell.rs
crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs
crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs
crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/dignity.rs
crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs
crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/svg_export.rs
crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs
diff --git a/crates/apps/tahuantinsuyu/src/shell.rs b/crates/apps/tahuantinsuyu/src/shell.rs
index a85f42d..a0fc6b2 100644
--- a/crates/apps/tahuantinsuyu/src/shell.rs
+++ b/crates/apps/tahuantinsuyu/src/shell.rs
@@ -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());
}
}
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs
index 83341ff..21e0de5 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-canvas/src/lib.rs
@@ -59,6 +59,9 @@ pub enum CanvasEvent {
/// El usuario togggleó una capa via hotkey — el panel debería
/// reflejarlo si quisiera mantenerse en sync.
LayerVisibilityChanged { kind: LayerKind, visible: bool },
+ /// El usuario pidió exportar el render actual como SVG. El shell
+ /// se encarga de escribir el archivo (la engine genera el string).
+ ExportSvgRequested,
}
// =====================================================================
@@ -110,9 +113,28 @@ pub struct CanvasState {
pub time_offset_minutes: i64,
/// Por-LayerKind: `true` = visible. Default = todo visible.
pub layer_visibility: HashMap<LayerKind, bool>,
+ /// Planeta hovered actualmente (para tooltip). `None` cuando el
+ /// mouse no está sobre ningún cuerpo.
+ pub hover: Option<HoverInfo>,
drag_jog: Option<JogDragState>,
}
+/// Info del cuerpo bajo el cursor — usado por el render para mostrar
+/// un tooltip flotante con detalles.
+#[derive(Clone, Debug)]
+pub struct HoverInfo {
+ pub module_id: String,
+ pub symbol: String,
+ pub deg: f32,
+ pub house: Option<u8>,
+ pub retrograde: bool,
+ pub dignity_marker: Option<String>,
+ pub annotation: Option<String>,
+ /// Posición relativa al wheel (en píxeles desde su top-left).
+ pub local_x: f32,
+ pub local_y: f32,
+}
+
impl Default for CanvasState {
fn default() -> Self {
Self {
@@ -120,6 +142,7 @@ impl Default for CanvasState {
view_rotation_deg: 0.0,
time_offset_minutes: 0,
layer_visibility: HashMap::new(),
+ hover: None,
drag_jog: None,
}
}
@@ -250,6 +273,81 @@ impl AstrologyCanvas {
cx.notify();
}
+ /// Hit-test sobre los body glyphs activos. Recibe la posición del
+ /// mouse en window coords y los bounds del canvas (wheel). Para
+ /// cada Glyph con LayerKind == Bodies u Outer en el RenderModel
+ /// actual, calcula su posición pintada y mide distancia. Si está
+ /// dentro de `threshold_px`, actualiza `state.hover`.
+ fn on_hover_check(
+ &mut self,
+ position: Point<Pixels>,
+ bounds: Bounds<Pixels>,
+ cx: &mut Context<Self>,
+ ) {
+ let CanvasMode::Wheel { render } = &self.state.mode else {
+ if self.state.hover.take().is_some() {
+ cx.notify();
+ }
+ return;
+ };
+ let (cx_px, cy_px) = bounds_center(bounds);
+ let mx: f32 = position.x.into();
+ let my: f32 = position.y.into();
+ let r_outer = (WHEEL_SIZE - WHEEL_MARGIN * 2.0) / 2.0;
+ let radii = Radii::from_outer(r_outer);
+ let asc = render.ascendant_deg;
+ let rot = self.state.view_rotation_deg;
+ let threshold = 14.0_f32;
+
+ let mut best: Option<(f32, HoverInfo)> = None;
+ for layer in &render.layers {
+ let ring = match layer.kind {
+ LayerKind::Bodies => radii.body_ring(&layer.module_id),
+ LayerKind::Outer if OUTER_RING_MODULES.contains(&layer.module_id.as_str()) => {
+ radii.transits
+ }
+ _ => continue,
+ };
+ for g in &layer.glyphs {
+ let (gx, gy) = polar_to_screen(g.deg, asc, rot, ring);
+ let dx = mx - (cx_px + gx);
+ let dy = my - (cy_px + gy);
+ let dist = (dx * dx + dy * dy).sqrt();
+ if dist > threshold {
+ continue;
+ }
+ if best.as_ref().map(|(d, _)| dist < *d).unwrap_or(true) {
+ let ox: f32 = bounds.origin.x.into();
+ let oy: f32 = bounds.origin.y.into();
+ best = Some((
+ dist,
+ HoverInfo {
+ module_id: layer.module_id.clone(),
+ symbol: g.symbol.clone(),
+ deg: g.deg,
+ house: g.house,
+ retrograde: g.retrograde,
+ dignity_marker: g.dignity_marker.clone(),
+ annotation: g.annotation.clone(),
+ local_x: cx_px + gx - ox,
+ local_y: cy_px + gy - oy,
+ },
+ ));
+ }
+ }
+ }
+ let new_hover = best.map(|(_, h)| h);
+ let changed = match (&self.state.hover, &new_hover) {
+ (Some(a), Some(b)) => a.symbol != b.symbol || a.module_id != b.module_id,
+ (None, None) => false,
+ _ => true,
+ };
+ if changed {
+ self.state.hover = new_hover;
+ cx.notify();
+ }
+ }
+
fn on_jog_up(&mut self, cx: &mut Context<Self>) {
let Some(jog) = self.state.drag_jog.take() else {
return;
@@ -325,6 +423,7 @@ impl Render for AstrologyCanvas {
self.state.view_rotation_deg,
self.state.time_offset_minutes,
&self.state.layer_visibility,
+ self.state.hover.as_ref(),
entity,
),
CanvasMode::Thumbnails { items, .. } => render_thumbnails(&theme, items),
@@ -421,6 +520,7 @@ fn render_wheel(
view_rotation_deg: f32,
time_offset_minutes: i64,
layer_visibility: &HashMap<LayerKind, bool>,
+ hover: Option<&HoverInfo>,
entity: gpui::Entity<AstrologyCanvas>,
) -> gpui::Div {
let asc = render.ascendant_deg;
@@ -471,10 +571,19 @@ fn render_wheel(
});
let entity_m = entity_for_canvas.clone();
window.on_mouse_event(move |ev: &MouseMoveEvent, _, _w, cx| {
- if !ev.dragging() {
- return;
+ if ev.dragging() {
+ entity_m.update(cx, |this, cx| this.on_jog_move(ev.position, bounds, cx));
+ } else if bounds.contains(&ev.position) {
+ // Mouse hover sin drag: hit-test sobre los body
+ // glyphs para el tooltip.
+ entity_m.update(cx, |this, cx| this.on_hover_check(ev.position, bounds, cx));
+ } else {
+ entity_m.update(cx, |this, cx| {
+ if this.state.hover.take().is_some() {
+ cx.notify();
+ }
+ });
}
- entity_m.update(cx, |this, cx| this.on_jog_move(ev.position, bounds, cx));
});
let entity_u = entity_for_canvas.clone();
window.on_mouse_event(move |_: &MouseUpEvent, _, _w, cx| {
@@ -561,11 +670,13 @@ fn render_wheel(
for g in &layer.glyphs {
let (x, y) = polar_to_screen(g.deg, asc, rot_offset, ring);
let color = with_alpha(planet_color(palette, &g.symbol), alpha);
- let glyph_text = if g.retrograde {
- format!("{}ᴿ", planet_unicode(&g.symbol))
- } else {
- planet_unicode(&g.symbol).into()
- };
+ let mut glyph_text = planet_unicode(&g.symbol).to_string();
+ if g.retrograde {
+ glyph_text.push('ᴿ');
+ }
+ if let Some(marker) = &g.dignity_marker {
+ glyph_text.push_str(marker);
+ }
wheel = wheel.child(centered_glyph(
cx_center + x,
cy_center + y,
@@ -607,6 +718,50 @@ fn render_wheel(
}
}
+ // Tooltip absoluto sobre el cuerpo hovered. Aparece arriba-derecha
+ // del planeta, con offset pequeño para no taparlo. Texto:
+ // "<unicode> <signo grado>° · Casa N · módulo · retrógrado?"
+ if let Some(hov) = hover {
+ let sign_idx = ((hov.deg / 30.0).floor() as usize) % 12;
+ let sign_name = SIGN_NAMES_ES[sign_idx];
+ let deg_in_sign = hov.deg - (sign_idx as f32) * 30.0;
+ let mut text = format!(
+ "{} {} · {:.1}°",
+ planet_unicode(&hov.symbol),
+ sign_name,
+ deg_in_sign,
+ );
+ if let Some(h) = hov.house {
+ text.push_str(&format!(" · Casa {}", h));
+ }
+ if hov.retrograde {
+ text.push_str(" · ℞");
+ }
+ if let Some(m) = &hov.dignity_marker {
+ text.push_str(&format!(" · {}", m));
+ }
+ if hov.module_id != "natal" {
+ text.push_str(&format!(" · {}", hov.module_id));
+ }
+ let tip_x = (hov.local_x + 14.0).min(WHEEL_SIZE - 220.0).max(8.0);
+ let tip_y = (hov.local_y - 28.0).max(8.0);
+ wheel = wheel.child(
+ div()
+ .absolute()
+ .left(px(tip_x))
+ .top(px(tip_y))
+ .px(px(8.0))
+ .py(px(4.0))
+ .rounded(px(6.0))
+ .bg(theme.bg_panel_alt.clone())
+ .border_1()
+ .border_color(palette.angle_highlight)
+ .text_size(px(11.0))
+ .text_color(theme.fg_text)
+ .child(SharedString::from(text)),
+ );
+ }
+
// Labels ASC/MC/DESC/IC en el perímetro. Texto pequeño en el
// margen exterior (radius * 1.05) para que no se monte con los
// glifos de los signos. Color angle_highlight para que el ojo los
@@ -652,6 +807,34 @@ fn render_wheel(
} else {
header
};
+ // Botón export SVG — pequeño, alineado a la derecha del title.
+ let export_btn = div()
+ .id("tts-canvas-export-svg")
+ .px(px(10.0))
+ .py(px(3.0))
+ .rounded(px(4.0))
+ .bg(theme.bg_button())
+ .hover(|s| s.bg(theme.bg_button_hover()))
+ .border_1()
+ .border_color(theme.border)
+ .text_size(px(10.0))
+ .text_color(theme.fg_text)
+ .child("⬇ SVG")
+ .on_click({
+ let entity_e = entity.clone();
+ move |_: &gpui::ClickEvent, _w, cx: &mut gpui::App| {
+ entity_e.update(cx, |_this, cx| {
+ cx.emit(CanvasEvent::ExportSvgRequested);
+ });
+ }
+ });
+ let header = div()
+ .flex()
+ .flex_row()
+ .items_center()
+ .gap(px(12.0))
+ .child(header)
+ .child(export_btn);
let offset_label = format_offset(time_offset_minutes);
let offset_color = if time_offset_minutes == 0 {
@@ -706,6 +889,48 @@ fn render_wheel(
footer = footer.child(b);
}
+ // Lista textual de aspectos (top 12 por orb). Compacta, en grid
+ // de 3 columnas, fonts pequeños. Solo aparece cuando hay aspectos
+ // computados.
+ if !render.aspect_summary.is_empty() {
+ let mut grid = div()
+ .flex()
+ .flex_row()
+ .flex_wrap()
+ .gap(px(10.0))
+ .max_w(px(WHEEL_SIZE + 80.0))
+ .justify_center();
+ for ap in render.aspect_summary.iter().take(12) {
+ let kind_sym = aspect_unicode(&ap.kind);
+ let line = format!(
+ "{} {} {} · {:.1}°{}",
+ planet_unicode(&ap.from_body),
+ kind_sym,
+ planet_unicode(&ap.to_body),
+ ap.orb_deg,
+ match ap.applying {
+ Some(true) => " A",
+ Some(false) => " S",
+ None => "",
+ }
+ );
+ let prefix = if ap.module_id == "natal" {
+ String::new()
+ } else {
+ format!("[{}] ", ap.module_id)
+ };
+ grid = grid.child(
+ div()
+ .px(px(6.0))
+ .py(px(2.0))
+ .text_size(px(11.0))
+ .text_color(aspect_color(palette, &ap.kind))
+ .child(SharedString::from(format!("{}{}", prefix, line))),
+ );
+ }
+ footer = footer.child(grid);
+ }
+
div()
.flex()
.flex_col()
@@ -1289,6 +1514,38 @@ fn sign_unicode(name: &str) -> &'static str {
}
}
+const SIGN_NAMES_ES: [&str; 12] = [
+ "Aries",
+ "Tauro",
+ "Géminis",
+ "Cáncer",
+ "Leo",
+ "Virgo",
+ "Libra",
+ "Escorpio",
+ "Sagitario",
+ "Capricornio",
+ "Acuario",
+ "Piscis",
+];
+
+fn aspect_unicode(kind: &str) -> &'static str {
+ match kind {
+ "conjunction" => "☌",
+ "opposition" => "☍",
+ "trine" => "△",
+ "square" => "□",
+ "sextile" => "⚹",
+ "quincunx" => "⚻",
+ "semi_sextile" => "⚺",
+ "semi_square" => "∠",
+ "sesquiquadrate" => "⚼",
+ "quintile" => "Q",
+ "biquintile" => "bQ",
+ _ => "·",
+ }
+}
+
fn planet_unicode(name: &str) -> &'static str {
match name {
"sun" => "☉",
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs
index ca29fe2..9e5a5a9 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/bridge.rs
@@ -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 {
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/dignity.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/dignity.rs
new file mode 100644
index 0000000..066d4c3
--- /dev/null
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/dignity.rs
@@ -0,0 +1,141 @@
+//! Dignidades esenciales clásicas — tabla data-only.
+//!
+//! Cada planeta tradicional tiene cuatro estatus posibles según el
+//! signo en el que cae:
+//!
+//! - **Domicilio** (rulership) — el signo del que es regente.
+//! - **Exaltación** — un signo "huésped" que le da fuerza extra.
+//! - **Exilio** (detriment) — opuesto al domicilio, debilita.
+//! - **Caída** (fall) — opuesto a la exaltación, debilita.
+//!
+//! Esta tabla usa las regencias **clásicas** (Aries=Marte, Escorpio=
+//! Marte, Acuario=Saturno, Piscis=Júpiter) — los planetas modernos
+//! (Urano/Neptuno/Plutón) no tienen regencia clásica por convención.
+//! En una fase futura podemos exponer un toggle "regencias modernas"
+//! que mapee Escorpio→Plutón, Acuario→Urano, Piscis→Neptuno.
+
+use eternal_sky::Body;
+
+/// Status de dignidad esencial de un cuerpo en un signo dado.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Dignity {
+ /// Domicilio. Marker `"+"`.
+ Rulership,
+ /// Exaltación. Marker `"·"`.
+ Exaltation,
+ /// Exilio. Marker `"−"`.
+ Detriment,
+ /// Caída. Marker `"*"`.
+ Fall,
+}
+
+impl Dignity {
+ pub fn marker(self) -> &'static str {
+ match self {
+ Dignity::Rulership => "+",
+ Dignity::Exaltation => "·",
+ Dignity::Detriment => "−",
+ Dignity::Fall => "*",
+ }
+ }
+}
+
+/// Devuelve el status de dignidad de `body` en `sign_index` (0..12,
+/// Aries=0) o `None` si no aplica (sin dignidad / cuerpo moderno sin
+/// regencia clásica).
+pub fn essential_dignity(body: Body, sign_index: u8) -> Option<Dignity> {
+ let sign = sign_index % 12;
+ let opposite = (sign + 6) % 12;
+
+ // Rulership clásico — el "regente" del signo.
+ if rules_classical(body, sign) {
+ return Some(Dignity::Rulership);
+ }
+ // Detriment = el cuerpo gobierna el signo opuesto.
+ if rules_classical(body, opposite) {
+ return Some(Dignity::Detriment);
+ }
+ // Exaltación tabular.
+ if exalts_at(body) == Some(sign) {
+ return Some(Dignity::Exaltation);
+ }
+ // Caída = opuesto a la exaltación.
+ if exalts_at(body) == Some(opposite) {
+ return Some(Dignity::Fall);
+ }
+ None
+}
+
+/// Devuelve true si `body` gobierna `sign` (0=Aries..11=Pisces) en el
+/// esquema clásico de 7 planetas.
+fn rules_classical(body: Body, sign: u8) -> bool {
+ match (body, sign) {
+ // Sol: Leo (4)
+ (Body::Sun, 4) => true,
+ // Luna: Cancer (3)
+ (Body::Moon, 3) => true,
+ // Mercurio: Gemini (2), Virgo (5)
+ (Body::Mercury, 2) | (Body::Mercury, 5) => true,
+ // Venus: Taurus (1), Libra (6)
+ (Body::Venus, 1) | (Body::Venus, 6) => true,
+ // Marte: Aries (0), Scorpio (7)
+ (Body::Mars, 0) | (Body::Mars, 7) => true,
+ // Júpiter: Sagittarius (8), Pisces (11)
+ (Body::Jupiter, 8) | (Body::Jupiter, 11) => true,
+ // Saturno: Capricorn (9), Aquarius (10)
+ (Body::Saturn, 9) | (Body::Saturn, 10) => true,
+ _ => false,
+ }
+}
+
+/// Devuelve el signo (0..12) donde el cuerpo exalta, o `None` si no
+/// tiene exaltación clásica documentada.
+fn exalts_at(body: Body) -> Option<u8> {
+ Some(match body {
+ Body::Sun => 0, // Aries
+ Body::Moon => 1, // Taurus
+ Body::Mercury => 5, // Virgo (algunas tradiciones la ponen acá)
+ Body::Venus => 11, // Pisces
+ Body::Mars => 9, // Capricorn
+ Body::Jupiter => 3, // Cancer
+ Body::Saturn => 6, // Libra
+ _ => return None,
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn rulership_examples() {
+ assert_eq!(essential_dignity(Body::Sun, 4), Some(Dignity::Rulership)); // Sol en Leo
+ assert_eq!(essential_dignity(Body::Moon, 3), Some(Dignity::Rulership)); // Luna en Cancer
+ assert_eq!(essential_dignity(Body::Mars, 7), Some(Dignity::Rulership)); // Marte en Scorpio
+ }
+
+ #[test]
+ fn detriment_examples() {
+ assert_eq!(essential_dignity(Body::Sun, 10), Some(Dignity::Detriment)); // Sol en Acuario
+ assert_eq!(essential_dignity(Body::Moon, 9), Some(Dignity::Detriment)); // Luna en Capricornio
+ }
+
+ #[test]
+ fn exaltation_examples() {
+ assert_eq!(essential_dignity(Body::Sun, 0), Some(Dignity::Exaltation)); // Sol en Aries
+ assert_eq!(essential_dignity(Body::Saturn, 6), Some(Dignity::Exaltation)); // Saturno en Libra
+ }
+
+ #[test]
+ fn fall_examples() {
+ assert_eq!(essential_dignity(Body::Sun, 6), Some(Dignity::Fall)); // Sol en Libra
+ assert_eq!(essential_dignity(Body::Saturn, 0), Some(Dignity::Fall)); // Saturno en Aries
+ }
+
+ #[test]
+ fn modern_planets_no_classical_dignity() {
+ assert_eq!(essential_dignity(Body::Uranus, 10), None);
+ assert_eq!(essential_dignity(Body::Neptune, 11), None);
+ assert_eq!(essential_dignity(Body::Pluto, 7), None);
+ }
+}
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs
index e47fdc0..823127f 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/lib.rs
@@ -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(),
}
}
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/svg_export.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/svg_export.rs
new file mode 100644
index 0000000..1cc49fa
--- /dev/null
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/svg_export.rs
@@ -0,0 +1,319 @@
+//! Export del `RenderModel` a SVG.
+//!
+//! Genera un documento SVG standalone con la misma geometría que pinta
+//! el canvas: anillos zodiacales, cusps, planetas, aspectos. El
+//! resultado es escalable (imprimible a cualquier tamaño) y no requiere
+//! la app GPUI para verse — cualquier visor de SVG sirve.
+//!
+//! Convención de coordenadas idéntica al canvas:
+//! `screen_angle_deg = 180 - (longitude - ascendant)` con +y para abajo.
+
+use std::f64::consts::PI;
+use std::fmt::Write;
+
+use crate::{Geometry, LayerKind, RenderModel};
+
+/// Dimensiones default del viewport. Aspect ratio cuadrada.
+const VIEWBOX: f64 = 800.0;
+const MARGIN: f64 = 40.0;
+
+/// Radios normalizados — espejan los de `tahuantinsuyu-canvas`.
+const R_SIGN_OUTER: f64 = 1.00;
+const R_SIGN_INNER: f64 = 0.88;
+const R_TRANSITS: f64 = 0.82;
+const R_HOUSES_OUTER: f64 = 0.78;
+const R_HOUSES_INNER: f64 = 0.66;
+const R_BODIES: f64 = 0.58;
+const R_PROGRESSION: f64 = 0.48;
+const R_SOLAR_ARC: f64 = 0.40;
+const R_ASPECTS: f64 = 0.32;
+
+/// Convierte el `RenderModel` a un documento SVG completo.
+pub fn render_to_svg(render: &RenderModel) -> String {
+ let mut out = String::with_capacity(8192);
+ let r_outer = (VIEWBOX - MARGIN * 2.0) / 2.0;
+ let cx = VIEWBOX / 2.0;
+ let cy = VIEWBOX / 2.0;
+ let asc = render.ascendant_deg as f64;
+
+ writeln!(
+ out,
+ r#"<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {0} {1}" width="{0}" height="{1}" font-family="serif" text-anchor="middle" dominant-baseline="central">"#,
+ VIEWBOX, VIEWBOX
+ )
+ .unwrap();
+
+ // Fondo + título.
+ writeln!(
+ out,
+ r##" <rect x="0" y="0" width="{0}" height="{0}" fill="#fdfaf3"/>
+ <text x="{cx}" y="20" font-size="14" fill="#2a2620">{title}</text>"##,
+ VIEWBOX,
+ cx = cx,
+ title = escape_xml(&render.title)
+ )
+ .unwrap();
+
+ // Anillos base.
+ for r in [R_SIGN_OUTER, R_SIGN_INNER, R_HOUSES_OUTER, R_HOUSES_INNER] {
+ writeln!(
+ out,
+ r##" <circle cx="{cx}" cy="{cy}" r="{r}" fill="none" stroke="#a89572" stroke-width="0.6"/>"##,
+ r = r * r_outer
+ )
+ .unwrap();
+ }
+
+ // Cusps del zodíaco cada 30°.
+ for i in 0..12 {
+ let lon = (i as f64) * 30.0;
+ let (x1, y1) = polar(lon, asc, R_SIGN_INNER * r_outer, cx, cy);
+ let (x2, y2) = polar(lon, asc, R_SIGN_OUTER * r_outer, cx, cy);
+ writeln!(
+ out,
+ r##" <line x1="{x1:.2}" y1="{y1:.2}" x2="{x2:.2}" y2="{y2:.2}" stroke="#a89572" stroke-width="0.5"/>"##,
+ )
+ .unwrap();
+ }
+
+ // Glifos de signos a media-altura del dial.
+ let sign_mid = (R_SIGN_OUTER + R_SIGN_INNER) / 2.0;
+ for layer in &render.layers {
+ if matches!(layer.kind, LayerKind::SignDial) {
+ for g in &layer.glyphs {
+ let (x, y) = polar(g.deg as f64, asc, sign_mid * r_outer, cx, cy);
+ writeln!(
+ out,
+ r##" <text x="{x:.2}" y="{y:.2}" font-size="16" fill="#5a4830">{}</text>"##,
+ sign_unicode(&g.symbol)
+ )
+ .unwrap();
+ }
+ }
+ }
+
+ // Cusps de casas + énfasis Asc/IC/Desc/MC.
+ for layer in &render.layers {
+ if matches!(layer.kind, LayerKind::Houses) {
+ if let Geometry::Ring { cusps_deg } = &layer.geometry {
+ for (i, c) in cusps_deg.iter().enumerate() {
+ let is_angle = i == 0 || i == 3 || i == 6 || i == 9;
+ let (color, w) = if is_angle {
+ ("#b8862e", 1.6)
+ } else {
+ ("#9b8460", 0.5)
+ };
+ let (x1, y1) =
+ polar(*c as f64, asc, R_HOUSES_INNER * r_outer, cx, cy);
+ let (x2, y2) =
+ polar(*c as f64, asc, R_HOUSES_OUTER * r_outer, cx, cy);
+ writeln!(
+ out,
+ r##" <line x1="{x1:.2}" y1="{y1:.2}" x2="{x2:.2}" y2="{y2:.2}" stroke="{color}" stroke-width="{w}"/>"##,
+ )
+ .unwrap();
+ }
+ }
+ }
+ }
+
+ // Líneas de aspectos. Para natal usamos un solo ring; para
+ // cross-aspects (transit/synastry/progression/solar_arc/...) los
+ // extremos van en rings distintos según el `module_id`.
+ for layer in &render.layers {
+ if !matches!(layer.kind, LayerKind::Aspects) {
+ continue;
+ }
+ if let Geometry::Lines(segs) = &layer.geometry {
+ let (r_from, r_to) = aspect_radii(&layer.module_id);
+ for seg in segs {
+ let color = aspect_color_hex(&seg.kind);
+ let (x1, y1) = polar(seg.from_deg as f64, asc, r_from * r_outer, cx, cy);
+ let (x2, y2) = polar(seg.to_deg as f64, asc, r_to * r_outer, cx, cy);
+ writeln!(
+ out,
+ r##" <line x1="{x1:.2}" y1="{y1:.2}" x2="{x2:.2}" y2="{y2:.2}" stroke="{color}" stroke-width="0.6" stroke-opacity="{op:.2}"/>"##,
+ op = seg.opacity
+ )
+ .unwrap();
+ }
+ }
+ }
+
+ // Glifos planetarios (natal + overlays). Cada uno en su ring.
+ for layer in &render.layers {
+ if !matches!(layer.kind, LayerKind::Bodies | LayerKind::Outer) {
+ continue;
+ }
+ let ring = body_ring_radius(&layer.module_id);
+ let size = if layer.module_id == "natal" { 18 } else { 14 };
+ for g in &layer.glyphs {
+ let (x, y) = polar(g.deg as f64, asc, ring * r_outer, cx, cy);
+ let glyph = planet_unicode(&g.symbol);
+ let suffix = match (g.retrograde, g.dignity_marker.as_deref()) {
+ (true, Some(m)) => format!("ᴿ{}", m),
+ (true, None) => "ᴿ".into(),
+ (false, Some(m)) => m.to_string(),
+ (false, None) => String::new(),
+ };
+ writeln!(
+ out,
+ r##" <text x="{x:.2}" y="{y:.2}" font-size="{size}" fill="#1f1812">{glyph}{suffix}</text>"##
+ )
+ .unwrap();
+ }
+ }
+
+ // Etiquetas ASC / MC / DESC / IC en el perímetro.
+ for (deg, label) in [
+ (asc, "ASC"),
+ (render.midheaven_deg as f64, "MC"),
+ (render.descendant_deg as f64, "DESC"),
+ (render.imum_coeli_deg as f64, "IC"),
+ ] {
+ let (x, y) = polar(deg, asc, 1.06 * r_outer, cx, cy);
+ writeln!(
+ out,
+ r##" <text x="{x:.2}" y="{y:.2}" font-size="10" fill="#b8862e">{label}</text>"##
+ )
+ .unwrap();
+ }
+
+ writeln!(out, "</svg>").unwrap();
+ out
+}
+
+fn polar(longitude_deg: f64, ascendant_deg: f64, radius: f64, cx: f64, cy: f64) -> (f64, f64) {
+ let deg = 180.0 - (longitude_deg - ascendant_deg);
+ let rad = deg * PI / 180.0;
+ (cx + radius * rad.cos(), cy + radius * rad.sin())
+}
+
+fn aspect_radii(module_id: &str) -> (f64, f64) {
+ if crate::OUTER_RING_MODULES.contains(&module_id) {
+ return (R_BODIES, R_TRANSITS);
+ }
+ match module_id {
+ "progression" => (R_BODIES, R_PROGRESSION),
+ "solar_arc" => (R_BODIES, R_SOLAR_ARC),
+ _ => (R_ASPECTS, R_ASPECTS),
+ }
+}
+
+fn body_ring_radius(module_id: &str) -> f64 {
+ if crate::OUTER_RING_MODULES.contains(&module_id) {
+ return R_TRANSITS;
+ }
+ match module_id {
+ "progression" => R_PROGRESSION,
+ "solar_arc" => R_SOLAR_ARC,
+ _ => R_BODIES,
+ }
+}
+
+fn sign_unicode(name: &str) -> &'static str {
+ match name {
+ "aries" => "♈",
+ "taurus" => "♉",
+ "gemini" => "♊",
+ "cancer" => "♋",
+ "leo" => "♌",
+ "virgo" => "♍",
+ "libra" => "♎",
+ "scorpio" => "♏",
+ "sagittarius" => "♐",
+ "capricorn" => "♑",
+ "aquarius" => "♒",
+ "pisces" => "♓",
+ _ => "?",
+ }
+}
+
+fn planet_unicode(name: &str) -> &'static str {
+ match name {
+ "sun" => "☉",
+ "moon" => "☽",
+ "mercury" => "☿",
+ "venus" => "♀",
+ "mars" => "♂",
+ "jupiter" => "♃",
+ "saturn" => "♄",
+ "uranus" => "♅",
+ "neptune" => "♆",
+ "pluto" => "♇",
+ "north_node" => "☊",
+ "south_node" => "☋",
+ "chiron" => "⚷",
+ "lilith" => "⚸",
+ "ceres" => "⚳",
+ "pallas" => "⚴",
+ "juno" => "⚵",
+ "vesta" => "⚶",
+ _ => "•",
+ }
+}
+
+fn aspect_color_hex(kind: &str) -> &'static str {
+ match kind {
+ "conjunction" => "#b8862e",
+ "opposition" => "#a64a8a",
+ "trine" => "#3f7d57",
+ "square" => "#c64b2a",
+ "sextile" => "#3a6db5",
+ _ => "#8a7660",
+ }
+}
+
+fn escape_xml(s: &str) -> String {
+ s.replace('&', "&")
+ .replace('<', "<")
+ .replace('>', ">")
+ .replace('"', """)
+ .replace('\'', "'")
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::{compute_mock, ChartKind};
+ use tahuantinsuyu_model::{Chart, ContactId, StoredBirthData, StoredChartConfig};
+
+ fn sample_chart() -> Chart {
+ Chart {
+ id: tahuantinsuyu_model::ChartId::new(),
+ contact_id: ContactId::new(),
+ kind: ChartKind::Natal,
+ label: "Test".into(),
+ birth_data: StoredBirthData {
+ year: 1987,
+ month: 3,
+ day: 14,
+ hour: 5,
+ minute: 22,
+ second: 0.0,
+ tz_offset_minutes: -240,
+ latitude_deg: 10.0,
+ longitude_deg: -66.0,
+ altitude_m: 0.0,
+ time_certainty: Default::default(),
+ subject_name: None,
+ birthplace_label: None,
+ },
+ config: StoredChartConfig::default(),
+ related_chart_id: None,
+ created_at_ms: 0,
+ }
+ }
+
+ #[test]
+ fn svg_well_formed_minimal() {
+ let render = compute_mock(&sample_chart());
+ let svg = render_to_svg(&render);
+ assert!(svg.starts_with("<?xml"));
+ assert!(svg.contains("<svg"));
+ assert!(svg.ends_with("</svg>\n"));
+ // Debe traer al menos un círculo de los rings base.
+ assert!(svg.contains("<circle "));
+ }
+}
diff --git a/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs b/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs
index 9a8320f..04050b3 100644
--- a/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs
+++ b/crates/modules/tahuantinsuyu/tahuantinsuyu-modules/src/lib.rs
@@ -239,6 +239,12 @@ pub mod natal {
step: 0.25,
default: 1.0,
},
+ Control::Toggle {
+ key: "show_dignities".into(),
+ label: "Dignidades esenciales (+ · − *)".into(),
+ default: false,
+ hotkey: None,
+ },
Control::Slider {
key: "harmonic".into(),
label: "Armónico".into(),