Skip to main content

xalen_chart/
lib.rs

1//! # xalen-chart — SVG Chart Rendering
2//!
3//! Renders astrological charts as SVG strings. Zero external dependencies beyond
4//! the XALEN core crates. No browser, no canvas, no image library needed.
5//!
6//! ## Supported chart styles
7//!
8//! - **North Indian** diamond chart (Vedic standard)
9//! - **South Indian** box chart
10//! - **Western wheel** chart
11//!
12//! ## Quick start
13//!
14//! ```rust
15//! use xalen_chart::{ChartData, PlanetPosition, render_north_indian};
16//!
17//! let chart = ChartData {
18//!     planet_positions: vec![
19//!         PlanetPosition { abbreviation: "Su".into(), house: 1, longitude_deg: 15.0 },
20//!         PlanetPosition { abbreviation: "Mo".into(), house: 4, longitude_deg: 105.0 },
21//!     ],
22//!     house_cusps_deg: [0.0, 30.0, 60.0, 90.0, 120.0, 150.0,
23//!                       180.0, 210.0, 240.0, 270.0, 300.0, 330.0],
24//!     ascendant_sign_index: 0,
25//!     ayanamsa_deg: 23.87,
26//! };
27//!
28//! let svg = render_north_indian(&chart);
29//! assert!(svg.starts_with("<svg"));
30//! ```
31
32mod north_indian;
33mod south_indian;
34mod western_wheel;
35
36use serde::{Deserialize, Serialize};
37
38/// A planet's position for chart rendering.
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct PlanetPosition {
41    /// Short label shown in the chart cell (e.g. "Su", "Mo", "Ma", "Ra").
42    pub abbreviation: String,
43    /// House number 1-12 (the house this planet occupies).
44    pub house: usize,
45    /// Ecliptic longitude in degrees [0, 360).
46    pub longitude_deg: f64,
47}
48
49/// All data needed to render any chart style.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct ChartData {
52    /// Planet positions to place in the chart.
53    pub planet_positions: Vec<PlanetPosition>,
54    /// Ecliptic longitude of each house cusp in degrees, index 0 = house 1.
55    pub house_cusps_deg: [f64; 12],
56    /// 0-based sign index of the ascendant (0 = Aries/Mesha, 11 = Pisces/Meena).
57    pub ascendant_sign_index: usize,
58    /// Ayanamsa applied (displayed as annotation).
59    pub ayanamsa_deg: f64,
60}
61
62/// Sign abbreviations used in chart labels.
63const SIGN_ABBREV: [&str; 12] = [
64    "Ar", "Ta", "Ge", "Cn", "Le", "Vi", "Li", "Sc", "Sg", "Cp", "Aq", "Pi",
65];
66
67/// Full sign names (available for chart annotations and tooltips).
68#[allow(dead_code)]
69const SIGN_NAMES: [&str; 12] = [
70    "Aries",
71    "Taurus",
72    "Gemini",
73    "Cancer",
74    "Leo",
75    "Virgo",
76    "Libra",
77    "Scorpio",
78    "Sagittarius",
79    "Capricorn",
80    "Aquarius",
81    "Pisces",
82];
83
84/// Render a **North Indian diamond chart** (Vedic standard).
85///
86/// Returns a complete `<svg>` string. The chart is 400x400 with 12 triangular
87/// houses arranged in the classic diamond pattern, planet abbreviations placed
88/// inside the correct houses, and sign labels on each house.
89pub fn render_north_indian(data: &ChartData) -> String {
90    north_indian::render(data)
91}
92
93/// Render a **South Indian box chart**.
94///
95/// Returns a complete `<svg>` string. 4x4 grid of boxes, fixed sign positions
96/// (Pisces top-left), planets placed in the box matching their sign.
97pub fn render_south_indian(data: &ChartData) -> String {
98    south_indian::render(data)
99}
100
101/// Render a **Western wheel chart**.
102///
103/// Returns a complete `<svg>` string. Circular wheel with 12 house segments,
104/// zodiac ring, and planet glyphs placed at their ecliptic longitude.
105pub fn render_western_wheel(data: &ChartData) -> String {
106    western_wheel::render(data)
107}
108
109/// Escape a string for safe embedding inside SVG text elements.
110///
111/// Prevents SVG/XML injection by replacing `<`, `>`, `&`, `"`, and `'`.
112fn svg_escape(s: &str) -> String {
113    let mut out = String::with_capacity(s.len());
114    for ch in s.chars() {
115        match ch {
116            '<' => out.push_str("&lt;"),
117            '>' => out.push_str("&gt;"),
118            '&' => out.push_str("&amp;"),
119            '"' => out.push_str("&quot;"),
120            '\'' => out.push_str("&#39;"),
121            _ => out.push(ch),
122        }
123    }
124    out
125}
126
127/// Sanitize a longitude value: replace NaN/Inf with 0.0.
128fn safe_longitude(deg: f64) -> f64 {
129    if deg.is_nan() || deg.is_infinite() {
130        0.0
131    } else {
132        deg
133    }
134}
135
136/// Collect planet abbreviations per house (1-indexed).
137fn planets_in_house(data: &ChartData, house: usize) -> Vec<&str> {
138    data.planet_positions
139        .iter()
140        .filter(|p| p.house == house)
141        .map(|p| p.abbreviation.as_str())
142        .collect()
143}
144
145/// Return the sign abbreviation for a given house, based on the ascendant sign.
146fn sign_for_house(asc_sign: usize, house: usize) -> &'static str {
147    let idx = (asc_sign + house - 1) % 12;
148    SIGN_ABBREV[idx]
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    fn sample_chart() -> ChartData {
156        ChartData {
157            planet_positions: vec![
158                PlanetPosition {
159                    abbreviation: "Su".into(),
160                    house: 1,
161                    longitude_deg: 15.0,
162                },
163                PlanetPosition {
164                    abbreviation: "Mo".into(),
165                    house: 4,
166                    longitude_deg: 100.0,
167                },
168                PlanetPosition {
169                    abbreviation: "Ma".into(),
170                    house: 7,
171                    longitude_deg: 195.0,
172                },
173                PlanetPosition {
174                    abbreviation: "Me".into(),
175                    house: 1,
176                    longitude_deg: 20.0,
177                },
178                PlanetPosition {
179                    abbreviation: "Ju".into(),
180                    house: 10,
181                    longitude_deg: 280.0,
182                },
183                PlanetPosition {
184                    abbreviation: "Ve".into(),
185                    house: 2,
186                    longitude_deg: 45.0,
187                },
188                PlanetPosition {
189                    abbreviation: "Sa".into(),
190                    house: 5,
191                    longitude_deg: 135.0,
192                },
193                PlanetPosition {
194                    abbreviation: "Ra".into(),
195                    house: 8,
196                    longitude_deg: 220.0,
197                },
198                PlanetPosition {
199                    abbreviation: "Ke".into(),
200                    house: 2,
201                    longitude_deg: 40.0,
202                },
203            ],
204            house_cusps_deg: [
205                0.0, 30.0, 60.0, 90.0, 120.0, 150.0, 180.0, 210.0, 240.0, 270.0, 300.0, 330.0,
206            ],
207            ascendant_sign_index: 0,
208            ayanamsa_deg: 24.17,
209        }
210    }
211
212    #[test]
213    fn north_indian_produces_valid_svg() {
214        let svg = render_north_indian(&sample_chart());
215        assert!(svg.starts_with("<svg"), "should start with <svg");
216        assert!(svg.ends_with("</svg>"), "should end with </svg>");
217        assert!(svg.contains("Su"), "should contain Sun");
218        assert!(svg.contains("Mo"), "should contain Moon");
219        assert!(svg.contains("Ar"), "should contain Aries sign label");
220    }
221
222    #[test]
223    fn south_indian_produces_valid_svg() {
224        let svg = render_south_indian(&sample_chart());
225        assert!(svg.starts_with("<svg"), "should start with <svg");
226        assert!(svg.ends_with("</svg>"), "should end with </svg>");
227        assert!(svg.contains("Su"), "should contain Sun");
228        assert!(svg.contains("Pi"), "Pisces sign should appear");
229    }
230
231    #[test]
232    fn western_wheel_produces_valid_svg() {
233        let svg = render_western_wheel(&sample_chart());
234        assert!(svg.starts_with("<svg"), "should start with <svg");
235        assert!(svg.ends_with("</svg>"), "should end with </svg>");
236        assert!(svg.contains("circle"), "should contain circle elements");
237        assert!(svg.contains("Su"), "should contain Sun");
238    }
239
240    #[test]
241    fn planets_in_house_grouping() {
242        let chart = sample_chart();
243        let h1 = planets_in_house(&chart, 1);
244        assert_eq!(h1.len(), 2);
245        assert!(h1.contains(&"Su"));
246        assert!(h1.contains(&"Me"));
247    }
248
249    #[test]
250    fn sign_for_house_wraps() {
251        assert_eq!(sign_for_house(0, 1), "Ar"); // Aries asc, house 1 = Aries
252        assert_eq!(sign_for_house(0, 12), "Pi"); // house 12 = Pisces
253        assert_eq!(sign_for_house(3, 1), "Cn"); // Cancer asc, house 1 = Cancer
254        assert_eq!(sign_for_house(3, 10), "Ar"); // house 10 from Cancer = Aries
255    }
256
257    #[test]
258    fn empty_chart_no_panic() {
259        let empty = ChartData {
260            planet_positions: vec![],
261            house_cusps_deg: [0.0; 12],
262            ascendant_sign_index: 0,
263            ayanamsa_deg: 0.0,
264        };
265        let svg = render_north_indian(&empty);
266        assert!(svg.starts_with("<svg"));
267        let svg2 = render_south_indian(&empty);
268        assert!(svg2.starts_with("<svg"));
269        let svg3 = render_western_wheel(&empty);
270        assert!(svg3.starts_with("<svg"));
271    }
272
273    #[test]
274    fn all_houses_populated() {
275        let mut positions = Vec::new();
276        for h in 1..=12 {
277            positions.push(PlanetPosition {
278                abbreviation: format!("P{h}"),
279                house: h,
280                longitude_deg: (h as f64 - 1.0) * 30.0 + 15.0,
281            });
282        }
283        let chart = ChartData {
284            planet_positions: positions,
285            house_cusps_deg: [
286                0.0, 30.0, 60.0, 90.0, 120.0, 150.0, 180.0, 210.0, 240.0, 270.0, 300.0, 330.0,
287            ],
288            ascendant_sign_index: 6, // Libra ascendant
289            ayanamsa_deg: 24.17,
290        };
291        let svg = render_north_indian(&chart);
292        for h in 1..=12 {
293            assert!(
294                svg.contains(&format!("P{h}")),
295                "house {h} planet missing from SVG"
296            );
297        }
298    }
299}