1use crate::Result;
2use crate::model::{Bounds, PieDiagramLayout, PieLegendItemLayout, PieSliceLayout};
3use crate::text::{TextMeasurer, TextStyle};
4use merman_core::diagrams::pie::{PieDiagramRenderModel, PieRenderSection};
5use ryu_js::Buffer;
6use std::cmp::Ordering;
7
8pub(crate) const PIE_LEGEND_RECT_SIZE_PX: f64 = 18.0;
9pub(crate) const PIE_LEGEND_SPACING_PX: f64 = 4.0;
10
11#[derive(Debug, Clone)]
12struct ColorScale {
13 palette: Vec<String>,
14 mapping: std::collections::HashMap<String, usize>,
15 next: usize,
16}
17
18#[derive(Debug, Clone, Copy)]
19struct Rgb01 {
20 r: f64,
21 g: f64,
22 b: f64,
23}
24
25#[derive(Debug, Clone, Copy)]
26struct Hsl {
27 h_deg: f64,
28 s_pct: f64,
29 l_pct: f64,
30}
31
32fn round_1e10(v: f64) -> f64 {
33 let v = (v * 1e10).round() / 1e10;
34 if v == -0.0 { 0.0 } else { v }
35}
36
37fn fmt_js_1e10(v: f64) -> String {
38 let v = round_1e10(v);
39 let mut b = Buffer::new();
40 b.format_finite(v).to_string()
41}
42
43fn round_hsl_1e10(mut hsl: Hsl) -> Hsl {
44 hsl.h_deg = round_1e10(hsl.h_deg) % 360.0;
47 hsl.s_pct = round_1e10(hsl.s_pct).clamp(0.0, 100.0);
48 hsl.l_pct = round_1e10(hsl.l_pct).clamp(0.0, 100.0);
49 hsl
50}
51
52fn parse_hex_rgb01(s: &str) -> Option<Rgb01> {
53 let s = s.trim();
54 let s = s.strip_prefix('#')?;
55 if s.len() != 6 {
56 return None;
57 }
58 let r = u8::from_str_radix(&s[0..2], 16).ok()? as f64 / 255.0;
59 let g = u8::from_str_radix(&s[2..4], 16).ok()? as f64 / 255.0;
60 let b = u8::from_str_radix(&s[4..6], 16).ok()? as f64 / 255.0;
61 Some(Rgb01 { r, g, b })
62}
63
64fn rgb01_to_hsl(rgb: Rgb01) -> Hsl {
65 let r = rgb.r;
66 let g = rgb.g;
67 let b = rgb.b;
68
69 let max = r.max(g.max(b));
70 let min = r.min(g.min(b));
71 let mut h = 0.0;
72 let mut s = 0.0;
73 let l = (max + min) / 2.0;
74
75 if max != min {
76 let d = max - min;
77 s = if l > 0.5 {
78 d / (2.0 - max - min)
79 } else {
80 d / (max + min)
81 };
82
83 h = if max == r {
84 (g - b) / d + if g < b { 6.0 } else { 0.0 }
85 } else if max == g {
86 (b - r) / d + 2.0
87 } else {
88 (r - g) / d + 4.0
89 };
90 h /= 6.0;
91 }
92
93 round_hsl_1e10(Hsl {
94 h_deg: h * 360.0,
95 s_pct: s * 100.0,
96 l_pct: l * 100.0,
97 })
98}
99
100fn parse_hsl(s: &str) -> Option<Hsl> {
101 let s = s.trim();
102 let inner = s.strip_prefix("hsl(")?.strip_suffix(')')?;
103 let parts: Vec<&str> = inner.split(',').map(|p| p.trim()).collect();
104 if parts.len() != 3 {
105 return None;
106 }
107 let h = parts[0].parse::<f64>().ok()?;
108 let s_pct = parts[1].trim_end_matches('%').parse::<f64>().ok()?;
109 let l_pct = parts[2].trim_end_matches('%').parse::<f64>().ok()?;
110 Some(round_hsl_1e10(Hsl {
111 h_deg: h,
112 s_pct,
113 l_pct,
114 }))
115}
116
117fn adjust_hsl(mut hsl: Hsl, h_delta: f64, s_delta: f64, l_delta: f64) -> Hsl {
118 hsl.h_deg = (hsl.h_deg + h_delta) % 360.0;
119 hsl.s_pct = (hsl.s_pct + s_delta).clamp(0.0, 100.0);
120 hsl.l_pct = (hsl.l_pct + l_delta).clamp(0.0, 100.0);
121 round_hsl_1e10(hsl)
122}
123
124fn fmt_hsl(hsl: Hsl) -> String {
125 format!(
126 "hsl({}, {}%, {}%)",
127 fmt_js_1e10(hsl.h_deg),
128 fmt_js_1e10(hsl.s_pct),
129 fmt_js_1e10(hsl.l_pct)
130 )
131}
132
133fn adjust_color_to_hsl_string(
134 color: &str,
135 h_delta: f64,
136 s_delta: f64,
137 l_delta: f64,
138) -> Option<String> {
139 let base = if let Some(rgb) = parse_hex_rgb01(color) {
140 rgb01_to_hsl(rgb)
141 } else if let Some(hsl) = parse_hsl(color) {
142 hsl
143 } else {
144 return None;
145 };
146 Some(fmt_hsl(adjust_hsl(base, h_delta, s_delta, l_delta)))
147}
148
149impl ColorScale {
150 fn new_default() -> Self {
151 const PRIMARY: &str = "#ECECFF";
161 const SECONDARY: &str = "#ffffde";
162 const TERTIARY: &str = "hsl(80, 100%, 96.2745098039%)";
163
164 let pie3 = adjust_color_to_hsl_string(TERTIARY, 0.0, 0.0, -40.0)
165 .unwrap_or_else(|| "hsl(80, 100%, 56.2745098039%)".to_string());
166 let pie4 = adjust_color_to_hsl_string(PRIMARY, 0.0, 0.0, -10.0)
167 .unwrap_or_else(|| "hsl(240, 100%, 86.2745098039%)".to_string());
168 let pie5 = adjust_color_to_hsl_string(SECONDARY, 0.0, 0.0, -30.0)
169 .unwrap_or_else(|| "hsl(60, 100%, 57.0588235294%)".to_string());
170 let pie6 = adjust_color_to_hsl_string(TERTIARY, 0.0, 0.0, -20.0)
171 .unwrap_or_else(|| "hsl(80, 100%, 76.2745098039%)".to_string());
172 let pie7 = adjust_color_to_hsl_string(PRIMARY, 60.0, 0.0, -20.0)
173 .unwrap_or_else(|| "hsl(300, 100%, 76.2745098039%)".to_string());
174 let pie8 = adjust_color_to_hsl_string(PRIMARY, -60.0, 0.0, -40.0)
175 .unwrap_or_else(|| "hsl(180, 100%, 56.2745098039%)".to_string());
176 let pie9 = adjust_color_to_hsl_string(PRIMARY, 120.0, 0.0, -40.0)
177 .unwrap_or_else(|| "hsl(0, 100%, 56.2745098039%)".to_string());
178 let pie10 = adjust_color_to_hsl_string(PRIMARY, 60.0, 0.0, -40.0)
179 .unwrap_or_else(|| "hsl(300, 100%, 56.2745098039%)".to_string());
180 let pie11 = adjust_color_to_hsl_string(PRIMARY, -90.0, 0.0, -40.0)
181 .unwrap_or_else(|| "hsl(150, 100%, 56.2745098039%)".to_string());
182 let pie12 = adjust_color_to_hsl_string(PRIMARY, 120.0, 0.0, -30.0)
183 .unwrap_or_else(|| "hsl(0, 100%, 66.2745098039%)".to_string());
184
185 Self {
186 palette: vec![
187 PRIMARY.to_string(),
188 SECONDARY.to_string(),
189 pie3,
190 pie4,
191 pie5,
192 pie6,
193 pie7,
194 pie8,
195 pie9,
196 pie10,
197 pie11,
198 pie12,
199 ],
200 mapping: std::collections::HashMap::new(),
201 next: 0,
202 }
203 }
204
205 fn color_for(&mut self, label: &str) -> String {
206 if let Some(idx) = self.mapping.get(label).copied() {
207 return self.palette[idx % self.palette.len()].clone();
208 }
209 let idx = self.next;
210 self.next += 1;
211 self.mapping.insert(label.to_string(), idx);
212 self.palette[idx % self.palette.len()].clone()
213 }
214}
215
216fn polar_xy(radius: f64, angle: f64) -> (f64, f64) {
217 let x = radius * angle.sin();
219 let y = -radius * angle.cos();
220 (x, y)
221}
222
223fn fmt_number(v: f64) -> String {
224 if !v.is_finite() {
225 return "0".to_string();
226 }
227 if v.abs() < 0.0005 {
228 return "0".to_string();
229 }
230 let mut r = (v * 1000.0).round() / 1000.0;
231 if r.abs() < 0.0005 {
232 r = 0.0;
233 }
234 let mut s = format!("{r:.3}");
235 if s.contains('.') {
236 while s.ends_with('0') {
237 s.pop();
238 }
239 if s.ends_with('.') {
240 s.pop();
241 }
242 }
243 if s == "-0" { "0".to_string() } else { s }
244}
245
246pub fn layout_pie_diagram(
247 semantic: &serde_json::Value,
248 _effective_config: &serde_json::Value,
249 measurer: &dyn TextMeasurer,
250) -> Result<PieDiagramLayout> {
251 let model: PieDiagramRenderModel = crate::json::from_value_ref(semantic)?;
252 layout_pie_diagram_typed(&model, _effective_config, measurer)
253}
254
255pub fn layout_pie_diagram_typed(
256 model: &PieDiagramRenderModel,
257 _effective_config: &serde_json::Value,
258 measurer: &dyn TextMeasurer,
259) -> Result<PieDiagramLayout> {
260 let _ = (
261 model.title.as_deref(),
262 model.acc_title.as_deref(),
263 model.acc_descr.as_deref(),
264 );
265
266 let margin: f64 = 40.0;
268 let legend_rect_size = PIE_LEGEND_RECT_SIZE_PX;
269 let legend_spacing = PIE_LEGEND_SPACING_PX;
270
271 let center: f64 = 225.0;
272 let radius: f64 = 185.0;
273 let outer_radius = radius + 1.0;
274 let label_radius = radius.max(0.0) * 0.75;
275 let legend_x = 12.0 * legend_rect_size;
276 let legend_step_y: f64 = legend_rect_size + legend_spacing;
277 let legend_start_y: f64 = -(legend_step_y * (model.sections.len().max(1) as f64)) / 2.0;
278
279 let total: f64 = model
280 .sections
281 .iter()
282 .filter(|s| s.value.is_finite() && s.value >= 0.0)
283 .map(|s| s.value)
284 .sum();
285
286 let mut color_scale = ColorScale::new_default();
287
288 let mut slices: Vec<PieSliceLayout> = Vec::new();
289 if total.is_finite() && total > 0.0 {
290 let mut pie_sections: Vec<&PieRenderSection> = model
297 .sections
298 .iter()
299 .filter(|s| s.value.is_finite() && s.value > 0.0)
300 .filter(|s| (s.value / total) * 100.0 >= 1.0)
301 .collect();
302 pie_sections.sort_by(|a, b| b.value.partial_cmp(&a.value).unwrap_or(Ordering::Equal));
303
304 let pie_total: f64 = pie_sections.iter().map(|s| s.value).sum();
305 if !pie_sections.is_empty() && pie_total.is_finite() && pie_total > 0.0 {
306 if pie_sections.len() == 1 {
307 let s = pie_sections[0];
308 let fill = color_scale.color_for(&s.label);
309 let (tx, ty) = polar_xy(label_radius, std::f64::consts::PI);
310 let percent = ((100.0 * (s.value / total)).max(0.0)).round() as i64;
311 slices.push(PieSliceLayout {
312 label: s.label.clone(),
313 value: s.value,
314 start_angle: 0.0,
315 end_angle: std::f64::consts::TAU,
316 is_full_circle: true,
317 percent,
318 text_x: tx,
319 text_y: ty,
320 fill,
321 });
322 } else {
323 let mut start = 0.0;
324 for s in pie_sections {
325 let frac = (s.value / pie_total).max(0.0);
326 let delta = (frac * std::f64::consts::TAU).max(0.0);
327 let end = start + delta;
328 let mid = (start + end) / 2.0;
329 let (tx, ty) = polar_xy(label_radius, mid);
330 let fill = color_scale.color_for(&s.label);
331 let percent = ((100.0 * (s.value / total)).max(0.0)).round() as i64;
332 if percent != 0 {
333 slices.push(PieSliceLayout {
334 label: s.label.clone(),
335 value: s.value,
336 start_angle: start,
337 end_angle: end,
338 is_full_circle: false,
339 percent,
340 text_x: tx,
341 text_y: ty,
342 fill,
343 });
344 }
345 start = end;
346 }
347 }
348 }
349 }
350
351 let mut legend_items: Vec<PieLegendItemLayout> = Vec::new();
354 for (i, sec) in model.sections.iter().enumerate() {
355 let y = legend_start_y + (i as f64) * legend_step_y;
356 let fill = color_scale.color_for(&sec.label);
357 legend_items.push(PieLegendItemLayout {
358 label: sec.label.clone(),
359 value: sec.value,
360 fill,
361 y,
362 });
363 }
364
365 let legend_style = TextStyle {
366 font_family: None,
367 font_size: 17.0,
368 font_weight: None,
369 };
370 let mut max_legend_width: f64 = 0.0;
371 for sec in &model.sections {
372 let label = if model.show_data {
373 format!("{} [{}]", sec.label, fmt_number(sec.value))
374 } else {
375 sec.label.clone()
376 };
377 let trimmed = label.trim_end();
378 let w = if trimmed.is_empty() {
383 0.0
384 } else {
385 let (left, right) = measurer.measure_svg_text_bbox_x(trimmed, &legend_style);
386 crate::text::round_to_1_64_px((left + right).max(0.0))
387 };
388 max_legend_width = max_legend_width.max(w);
389 }
390
391 let base_w: f64 = center * 2.0;
392 let width: f64 =
396 (base_w + margin + legend_rect_size + legend_spacing + max_legend_width).max(1.0);
397 let height: f64 = f64::max(center * 2.0, 1.0);
398
399 Ok(PieDiagramLayout {
400 bounds: Some(Bounds {
401 min_x: 0.0,
402 min_y: 0.0,
403 max_x: width,
404 max_y: height,
405 }),
406 center_x: center,
407 center_y: center,
408 radius,
409 outer_radius,
410 legend_x,
411 legend_start_y,
412 legend_step_y,
413 slices,
414 legend_items,
415 })
416}
417
418#[cfg(test)]
419mod tests {
420 #[test]
421 fn pie_legend_geometry_constants_match_mermaid() {
422 assert_eq!(super::PIE_LEGEND_RECT_SIZE_PX, 18.0);
423 assert_eq!(super::PIE_LEGEND_SPACING_PX, 4.0);
424 }
425}