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
@@ -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 {
@@ -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);
}
}
@@ -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(),
}
}
@@ -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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
#[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 "));
}
}