1mod north_indian;
33mod south_indian;
34mod western_wheel;
35
36use serde::{Deserialize, Serialize};
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct PlanetPosition {
41 pub abbreviation: String,
43 pub house: usize,
45 pub longitude_deg: f64,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct ChartData {
52 pub planet_positions: Vec<PlanetPosition>,
54 pub house_cusps_deg: [f64; 12],
56 pub ascendant_sign_index: usize,
58 pub ayanamsa_deg: f64,
60}
61
62const SIGN_ABBREV: [&str; 12] = [
64 "Ar", "Ta", "Ge", "Cn", "Le", "Vi", "Li", "Sc", "Sg", "Cp", "Aq", "Pi",
65];
66
67#[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
84pub fn render_north_indian(data: &ChartData) -> String {
90 north_indian::render(data)
91}
92
93pub fn render_south_indian(data: &ChartData) -> String {
98 south_indian::render(data)
99}
100
101pub fn render_western_wheel(data: &ChartData) -> String {
106 western_wheel::render(data)
107}
108
109fn 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("<"),
117 '>' => out.push_str(">"),
118 '&' => out.push_str("&"),
119 '"' => out.push_str("""),
120 '\'' => out.push_str("'"),
121 _ => out.push(ch),
122 }
123 }
124 out
125}
126
127fn safe_longitude(deg: f64) -> f64 {
129 if deg.is_nan() || deg.is_infinite() {
130 0.0
131 } else {
132 deg
133 }
134}
135
136fn 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
145fn 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"); assert_eq!(sign_for_house(0, 12), "Pi"); assert_eq!(sign_for_house(3, 1), "Cn"); assert_eq!(sign_for_house(3, 10), "Ar"); }
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, 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}