⎇ d3649bfd1a80153652c2a028315369bb4b184024
crates/modules/tahuantinsuyu/tahuantinsuyu-engine/src/svg_export.rs
Historia
Quién tocó qué
crudo
feat(tahuantinsuyu): fase 18 — aspect list + hover tooltips + dignidades + export SVG
1//! Export del `RenderModel` a SVG.
2//!
3//! Genera un documento SVG standalone con la misma geometría que pinta
4//! el canvas: anillos zodiacales, cusps, planetas, aspectos. El
5//! resultado es escalable (imprimible a cualquier tamaño) y no requiere
6//! la app GPUI para verse — cualquier visor de SVG sirve.
7//!
8//! Convención de coordenadas idéntica al canvas:
9//! `screen_angle_deg = 180 - (longitude - ascendant)` con +y para abajo.
10
11use std::f64::consts::PI;
12use std::fmt::Write;
13
14use crate::{Geometry, LayerKind, RenderModel};
15
16/// Dimensiones default del viewport. Aspect ratio cuadrada.
17const VIEWBOX: f64 = 800.0;
18const MARGIN: f64 = 40.0;
19
20/// Radios normalizados — espejan los de `tahuantinsuyu-canvas`.
21const R_SIGN_OUTER: f64 = 1.00;
22const R_SIGN_INNER: f64 = 0.88;
23const R_TRANSITS: f64 = 0.82;
24const R_HOUSES_OUTER: f64 = 0.78;
25const R_HOUSES_INNER: f64 = 0.66;
26const R_BODIES: f64 = 0.58;
27const R_PROGRESSION: f64 = 0.48;
28const R_SOLAR_ARC: f64 = 0.40;
29const R_ASPECTS: f64 = 0.32;
30
31/// Convierte el `RenderModel` a un documento SVG completo.
32pub fn render_to_svg(render: &RenderModel) -> String {
33 let mut out = String::with_capacity(8192);
34 let r_outer = (VIEWBOX - MARGIN * 2.0) / 2.0;
35 let cx = VIEWBOX / 2.0;
36 let cy = VIEWBOX / 2.0;
37 let asc = render.ascendant_deg as f64;
38
39 writeln!(
40 out,
41 r#"<?xml version="1.0" encoding="UTF-8"?>
42<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">"#,
43 VIEWBOX, VIEWBOX
44 )
45 .unwrap();
46
47 // Fondo + título.
48 writeln!(
49 out,
50 r##" <rect x="0" y="0" width="{0}" height="{0}" fill="#fdfaf3"/>
51 <text x="{cx}" y="20" font-size="14" fill="#2a2620">{title}</text>"##,
52 VIEWBOX,
53 cx = cx,
54 title = escape_xml(&render.title)
55 )
56 .unwrap();
57
58 // Anillos base.
59 for r in [R_SIGN_OUTER, R_SIGN_INNER, R_HOUSES_OUTER, R_HOUSES_INNER] {
60 writeln!(
61 out,
62 r##" <circle cx="{cx}" cy="{cy}" r="{r}" fill="none" stroke="#a89572" stroke-width="0.6"/>"##,
63 r = r * r_outer
64 )
65 .unwrap();
66 }
67
68 // Cusps del zodíaco cada 30°.
69 for i in 0..12 {
70 let lon = (i as f64) * 30.0;
71 let (x1, y1) = polar(lon, asc, R_SIGN_INNER * r_outer, cx, cy);
72 let (x2, y2) = polar(lon, asc, R_SIGN_OUTER * r_outer, cx, cy);
73 writeln!(
74 out,
75 r##" <line x1="{x1:.2}" y1="{y1:.2}" x2="{x2:.2}" y2="{y2:.2}" stroke="#a89572" stroke-width="0.5"/>"##,
76 )
77 .unwrap();
78 }
79
80 // Glifos de signos a media-altura del dial.
81 let sign_mid = (R_SIGN_OUTER + R_SIGN_INNER) / 2.0;
82 for layer in &render.layers {
83 if matches!(layer.kind, LayerKind::SignDial) {
84 for g in &layer.glyphs {
85 let (x, y) = polar(g.deg as f64, asc, sign_mid * r_outer, cx, cy);
86 writeln!(
87 out,
88 r##" <text x="{x:.2}" y="{y:.2}" font-size="16" fill="#5a4830">{}</text>"##,
89 sign_unicode(&g.symbol)
90 )
91 .unwrap();
92 }
93 }
94 }
95
96 // Cusps de casas + énfasis Asc/IC/Desc/MC.
97 for layer in &render.layers {
98 if matches!(layer.kind, LayerKind::Houses) {
99 if let Geometry::Ring { cusps_deg } = &layer.geometry {
100 for (i, c) in cusps_deg.iter().enumerate() {
101 let is_angle = i == 0 || i == 3 || i == 6 || i == 9;
102 let (color, w) = if is_angle {
103 ("#b8862e", 1.6)
104 } else {
105 ("#9b8460", 0.5)
106 };
107 let (x1, y1) =
108 polar(*c as f64, asc, R_HOUSES_INNER * r_outer, cx, cy);
109 let (x2, y2) =
110 polar(*c as f64, asc, R_HOUSES_OUTER * r_outer, cx, cy);
111 writeln!(
112 out,
113 r##" <line x1="{x1:.2}" y1="{y1:.2}" x2="{x2:.2}" y2="{y2:.2}" stroke="{color}" stroke-width="{w}"/>"##,
114 )
115 .unwrap();
116 }
117 }
118 }
119 }
120
121 // Líneas de aspectos. Para natal usamos un solo ring; para
122 // cross-aspects (transit/synastry/progression/solar_arc/...) los
123 // extremos van en rings distintos según el `module_id`.
124 for layer in &render.layers {
125 if !matches!(layer.kind, LayerKind::Aspects) {
126 continue;
127 }
128 if let Geometry::Lines(segs) = &layer.geometry {
129 let (r_from, r_to) = aspect_radii(&layer.module_id);
130 for seg in segs {
131 let color = aspect_color_hex(&seg.kind);
132 let (x1, y1) = polar(seg.from_deg as f64, asc, r_from * r_outer, cx, cy);
133 let (x2, y2) = polar(seg.to_deg as f64, asc, r_to * r_outer, cx, cy);
134 writeln!(
135 out,
136 r##" <line x1="{x1:.2}" y1="{y1:.2}" x2="{x2:.2}" y2="{y2:.2}" stroke="{color}" stroke-width="0.6" stroke-opacity="{op:.2}"/>"##,
137 op = seg.opacity
138 )
139 .unwrap();
140 }
141 }
142 }
143
144 // Glifos planetarios (natal + overlays). Cada uno en su ring.
145 for layer in &render.layers {
146 if !matches!(layer.kind, LayerKind::Bodies | LayerKind::Outer) {
147 continue;
148 }
149 let ring = body_ring_radius(&layer.module_id);
150 let size = if layer.module_id == "natal" { 18 } else { 14 };
151 for g in &layer.glyphs {
152 let (x, y) = polar(g.deg as f64, asc, ring * r_outer, cx, cy);
153 let glyph = planet_unicode(&g.symbol);
154 let suffix = match (g.retrograde, g.dignity_marker.as_deref()) {
155 (true, Some(m)) => format!("ᴿ{}", m),
156 (true, None) => "ᴿ".into(),
157 (false, Some(m)) => m.to_string(),
158 (false, None) => String::new(),
159 };
160 writeln!(
161 out,
162 r##" <text x="{x:.2}" y="{y:.2}" font-size="{size}" fill="#1f1812">{glyph}{suffix}</text>"##
163 )
164 .unwrap();
165 }
166 }
167
168 // Etiquetas ASC / MC / DESC / IC en el perímetro.
169 for (deg, label) in [
170 (asc, "ASC"),
171 (render.midheaven_deg as f64, "MC"),
172 (render.descendant_deg as f64, "DESC"),
173 (render.imum_coeli_deg as f64, "IC"),
174 ] {
175 let (x, y) = polar(deg, asc, 1.06 * r_outer, cx, cy);
176 writeln!(
177 out,
178 r##" <text x="{x:.2}" y="{y:.2}" font-size="10" fill="#b8862e">{label}</text>"##
179 )
180 .unwrap();
181 }
182
183 writeln!(out, "</svg>").unwrap();
184 out
185}
186
187fn polar(longitude_deg: f64, ascendant_deg: f64, radius: f64, cx: f64, cy: f64) -> (f64, f64) {
188 let deg = 180.0 - (longitude_deg - ascendant_deg);
189 let rad = deg * PI / 180.0;
190 (cx + radius * rad.cos(), cy + radius * rad.sin())
191}
192
193fn aspect_radii(module_id: &str) -> (f64, f64) {
194 if crate::OUTER_RING_MODULES.contains(&module_id) {
195 return (R_BODIES, R_TRANSITS);
196 }
197 match module_id {
198 "progression" => (R_BODIES, R_PROGRESSION),
199 "solar_arc" => (R_BODIES, R_SOLAR_ARC),
200 _ => (R_ASPECTS, R_ASPECTS),
201 }
202}
203
204fn body_ring_radius(module_id: &str) -> f64 {
205 if crate::OUTER_RING_MODULES.contains(&module_id) {
206 return R_TRANSITS;
207 }
208 match module_id {
209 "progression" => R_PROGRESSION,
210 "solar_arc" => R_SOLAR_ARC,
211 _ => R_BODIES,
212 }
213}
214
215fn sign_unicode(name: &str) -> &'static str {
216 match name {
217 "aries" => "♈",
218 "taurus" => "♉",
219 "gemini" => "♊",
220 "cancer" => "♋",
221 "leo" => "♌",
222 "virgo" => "♍",
223 "libra" => "♎",
224 "scorpio" => "♏",
225 "sagittarius" => "♐",
226 "capricorn" => "♑",
227 "aquarius" => "♒",
228 "pisces" => "♓",
229 _ => "?",
230 }
231}
232
233fn planet_unicode(name: &str) -> &'static str {
234 match name {
235 "sun" => "☉",
236 "moon" => "☽",
237 "mercury" => "☿",
238 "venus" => "♀",
239 "mars" => "♂",
240 "jupiter" => "♃",
241 "saturn" => "♄",
242 "uranus" => "♅",
243 "neptune" => "♆",
244 "pluto" => "♇",
245 "north_node" => "☊",
246 "south_node" => "☋",
247 "chiron" => "⚷",
248 "lilith" => "⚸",
249 "ceres" => "⚳",
250 "pallas" => "⚴",
251 "juno" => "⚵",
252 "vesta" => "⚶",
253 _ => "•",
254 }
255}
256
257fn aspect_color_hex(kind: &str) -> &'static str {
258 match kind {
259 "conjunction" => "#b8862e",
260 "opposition" => "#a64a8a",
261 "trine" => "#3f7d57",
262 "square" => "#c64b2a",
263 "sextile" => "#3a6db5",
264 _ => "#8a7660",
265 }
266}
267
268fn escape_xml(s: &str) -> String {
269 s.replace('&', "&")
270 .replace('<', "<")
271 .replace('>', ">")
272 .replace('"', """)
273 .replace('\'', "'")
274}
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279 use crate::{compute_mock, ChartKind};
280 use tahuantinsuyu_model::{Chart, ContactId, StoredBirthData, StoredChartConfig};
281
282 fn sample_chart() -> Chart {
283 Chart {
284 id: tahuantinsuyu_model::ChartId::new(),
285 contact_id: ContactId::new(),
286 kind: ChartKind::Natal,
287 label: "Test".into(),
288 birth_data: StoredBirthData {
289 year: 1987,
290 month: 3,
291 day: 14,
292 hour: 5,
293 minute: 22,
294 second: 0.0,
295 tz_offset_minutes: -240,
296 latitude_deg: 10.0,
297 longitude_deg: -66.0,
298 altitude_m: 0.0,
299 time_certainty: Default::default(),
300 subject_name: None,
301 birthplace_label: None,
302 },
303 config: StoredChartConfig::default(),
304 related_chart_id: None,
305 created_at_ms: 0,
306 }
307 }
308
309 #[test]
310 fn svg_well_formed_minimal() {
311 let render = compute_mock(&sample_chart());
312 let svg = render_to_svg(&render);
313 assert!(svg.starts_with("<?xml"));
314 assert!(svg.contains("<svg"));
315 assert!(svg.ends_with("</svg>\n"));
316 // Debe traer al menos un círculo de los rings base.
317 assert!(svg.contains("<circle "));
318 }
319}