1use crate::Result;
2use crate::generated::pie_text_overrides_11_12_2 as pie_text_overrides;
3use crate::model::{Bounds, PieDiagramLayout, PieLegendItemLayout, PieSliceLayout};
4use crate::text::{TextMeasurer, TextStyle, WrapMode};
5use ryu_js::Buffer;
6use serde::Deserialize;
7use std::cmp::Ordering;
8
9#[derive(Debug, Clone, Deserialize)]
10struct PieSection {
11 label: String,
12 value: f64,
13}
14
15#[derive(Debug, Clone, Deserialize)]
16struct PieModel {
17 #[serde(rename = "showData")]
18 show_data: bool,
19 title: Option<String>,
20 #[serde(rename = "accTitle")]
21 acc_title: Option<String>,
22 #[serde(rename = "accDescr")]
23 acc_descr: Option<String>,
24 sections: Vec<PieSection>,
25}
26
27#[derive(Debug, Clone)]
28struct ColorScale {
29 palette: Vec<String>,
30 mapping: std::collections::HashMap<String, usize>,
31 next: usize,
32}
33
34#[derive(Debug, Clone, Copy)]
35struct Rgb01 {
36 r: f64,
37 g: f64,
38 b: f64,
39}
40
41#[derive(Debug, Clone, Copy)]
42struct Hsl {
43 h_deg: f64,
44 s_pct: f64,
45 l_pct: f64,
46}
47
48fn round_1e10(v: f64) -> f64 {
49 let v = (v * 1e10).round() / 1e10;
50 if v == -0.0 { 0.0 } else { v }
51}
52
53fn fmt_js_1e10(v: f64) -> String {
54 let v = round_1e10(v);
55 let mut b = Buffer::new();
56 b.format_finite(v).to_string()
57}
58
59fn round_hsl_1e10(mut hsl: Hsl) -> Hsl {
60 hsl.h_deg = round_1e10(hsl.h_deg) % 360.0;
63 hsl.s_pct = round_1e10(hsl.s_pct).clamp(0.0, 100.0);
64 hsl.l_pct = round_1e10(hsl.l_pct).clamp(0.0, 100.0);
65 hsl
66}
67
68fn parse_hex_rgb01(s: &str) -> Option<Rgb01> {
69 let s = s.trim();
70 let s = s.strip_prefix('#')?;
71 if s.len() != 6 {
72 return None;
73 }
74 let r = u8::from_str_radix(&s[0..2], 16).ok()? as f64 / 255.0;
75 let g = u8::from_str_radix(&s[2..4], 16).ok()? as f64 / 255.0;
76 let b = u8::from_str_radix(&s[4..6], 16).ok()? as f64 / 255.0;
77 Some(Rgb01 { r, g, b })
78}
79
80fn rgb01_to_hsl(rgb: Rgb01) -> Hsl {
81 let r = rgb.r;
82 let g = rgb.g;
83 let b = rgb.b;
84
85 let max = r.max(g.max(b));
86 let min = r.min(g.min(b));
87 let mut h = 0.0;
88 let mut s = 0.0;
89 let l = (max + min) / 2.0;
90
91 if max != min {
92 let d = max - min;
93 s = if l > 0.5 {
94 d / (2.0 - max - min)
95 } else {
96 d / (max + min)
97 };
98
99 h = if max == r {
100 (g - b) / d + if g < b { 6.0 } else { 0.0 }
101 } else if max == g {
102 (b - r) / d + 2.0
103 } else {
104 (r - g) / d + 4.0
105 };
106 h /= 6.0;
107 }
108
109 round_hsl_1e10(Hsl {
110 h_deg: h * 360.0,
111 s_pct: s * 100.0,
112 l_pct: l * 100.0,
113 })
114}
115
116fn parse_hsl(s: &str) -> Option<Hsl> {
117 let s = s.trim();
118 let inner = s.strip_prefix("hsl(")?.strip_suffix(')')?;
119 let parts: Vec<&str> = inner.split(',').map(|p| p.trim()).collect();
120 if parts.len() != 3 {
121 return None;
122 }
123 let h = parts[0].parse::<f64>().ok()?;
124 let s_pct = parts[1].trim_end_matches('%').parse::<f64>().ok()?;
125 let l_pct = parts[2].trim_end_matches('%').parse::<f64>().ok()?;
126 Some(round_hsl_1e10(Hsl {
127 h_deg: h,
128 s_pct,
129 l_pct,
130 }))
131}
132
133fn adjust_hsl(mut hsl: Hsl, h_delta: f64, s_delta: f64, l_delta: f64) -> Hsl {
134 hsl.h_deg = (hsl.h_deg + h_delta) % 360.0;
135 hsl.s_pct = (hsl.s_pct + s_delta).clamp(0.0, 100.0);
136 hsl.l_pct = (hsl.l_pct + l_delta).clamp(0.0, 100.0);
137 round_hsl_1e10(hsl)
138}
139
140fn fmt_hsl(hsl: Hsl) -> String {
141 format!(
142 "hsl({}, {}%, {}%)",
143 fmt_js_1e10(hsl.h_deg),
144 fmt_js_1e10(hsl.s_pct),
145 fmt_js_1e10(hsl.l_pct)
146 )
147}
148
149fn adjust_color_to_hsl_string(
150 color: &str,
151 h_delta: f64,
152 s_delta: f64,
153 l_delta: f64,
154) -> Option<String> {
155 let base = if let Some(rgb) = parse_hex_rgb01(color) {
156 rgb01_to_hsl(rgb)
157 } else if let Some(hsl) = parse_hsl(color) {
158 hsl
159 } else {
160 return None;
161 };
162 Some(fmt_hsl(adjust_hsl(base, h_delta, s_delta, l_delta)))
163}
164
165impl ColorScale {
166 fn new_default() -> Self {
167 const PRIMARY: &str = "#ECECFF";
177 const SECONDARY: &str = "#ffffde";
178 const TERTIARY: &str = "hsl(80, 100%, 96.2745098039%)";
179
180 let pie3 = adjust_color_to_hsl_string(TERTIARY, 0.0, 0.0, -40.0)
181 .unwrap_or_else(|| "hsl(80, 100%, 56.2745098039%)".to_string());
182 let pie4 = adjust_color_to_hsl_string(PRIMARY, 0.0, 0.0, -10.0)
183 .unwrap_or_else(|| "hsl(240, 100%, 86.2745098039%)".to_string());
184 let pie5 = adjust_color_to_hsl_string(SECONDARY, 0.0, 0.0, -30.0)
185 .unwrap_or_else(|| "hsl(60, 100%, 57.0588235294%)".to_string());
186 let pie6 = adjust_color_to_hsl_string(TERTIARY, 0.0, 0.0, -20.0)
187 .unwrap_or_else(|| "hsl(80, 100%, 76.2745098039%)".to_string());
188 let pie7 = adjust_color_to_hsl_string(PRIMARY, 60.0, 0.0, -20.0)
189 .unwrap_or_else(|| "hsl(300, 100%, 76.2745098039%)".to_string());
190 let pie8 = adjust_color_to_hsl_string(PRIMARY, -60.0, 0.0, -40.0)
191 .unwrap_or_else(|| "hsl(180, 100%, 56.2745098039%)".to_string());
192 let pie9 = adjust_color_to_hsl_string(PRIMARY, 120.0, 0.0, -40.0)
193 .unwrap_or_else(|| "hsl(0, 100%, 56.2745098039%)".to_string());
194 let pie10 = adjust_color_to_hsl_string(PRIMARY, 60.0, 0.0, -40.0)
195 .unwrap_or_else(|| "hsl(300, 100%, 56.2745098039%)".to_string());
196 let pie11 = adjust_color_to_hsl_string(PRIMARY, -90.0, 0.0, -40.0)
197 .unwrap_or_else(|| "hsl(150, 100%, 56.2745098039%)".to_string());
198 let pie12 = adjust_color_to_hsl_string(PRIMARY, 120.0, 0.0, -30.0)
199 .unwrap_or_else(|| "hsl(0, 100%, 66.2745098039%)".to_string());
200
201 Self {
202 palette: vec![
203 PRIMARY.to_string(),
204 SECONDARY.to_string(),
205 pie3,
206 pie4,
207 pie5,
208 pie6,
209 pie7,
210 pie8,
211 pie9,
212 pie10,
213 pie11,
214 pie12,
215 ],
216 mapping: std::collections::HashMap::new(),
217 next: 0,
218 }
219 }
220
221 fn color_for(&mut self, label: &str) -> String {
222 if let Some(idx) = self.mapping.get(label).copied() {
223 return self.palette[idx % self.palette.len()].clone();
224 }
225 let idx = self.next;
226 self.next += 1;
227 self.mapping.insert(label.to_string(), idx);
228 self.palette[idx % self.palette.len()].clone()
229 }
230}
231
232fn polar_xy(radius: f64, angle: f64) -> (f64, f64) {
233 let x = radius * angle.sin();
235 let y = -radius * angle.cos();
236 (x, y)
237}
238
239fn fmt_number(v: f64) -> String {
240 if !v.is_finite() {
241 return "0".to_string();
242 }
243 if v.abs() < 0.0005 {
244 return "0".to_string();
245 }
246 let mut r = (v * 1000.0).round() / 1000.0;
247 if r.abs() < 0.0005 {
248 r = 0.0;
249 }
250 let mut s = format!("{r:.3}");
251 if s.contains('.') {
252 while s.ends_with('0') {
253 s.pop();
254 }
255 if s.ends_with('.') {
256 s.pop();
257 }
258 }
259 if s == "-0" { "0".to_string() } else { s }
260}
261
262fn pie_legend_bbox_overhang_left_em(ch: char) -> f64 {
263 match ch {
272 '_' => 0.06125057352941176,
274 _ => 0.0,
275 }
276}
277
278fn pie_legend_bbox_overhang_right_em(ch: char) -> f64 {
279 match ch {
280 '_' => 0.06125057352941176,
282 't' => 0.01496444117647059,
284 'r' => 0.08091001764705883,
286 'e' => 0.04291130514705883,
288 's' => 0.007008272058823529,
290 'h' => 0.0009191176470588235,
292 ']' => 0.00045955882352941176,
294 _ => 0.0,
295 }
296}
297
298pub fn layout_pie_diagram(
299 semantic: &serde_json::Value,
300 _effective_config: &serde_json::Value,
301 measurer: &dyn TextMeasurer,
302) -> Result<PieDiagramLayout> {
303 let model: PieModel = crate::json::from_value_ref(semantic)?;
304 let _ = (
305 model.title.as_deref(),
306 model.acc_title.as_deref(),
307 model.acc_descr.as_deref(),
308 );
309
310 let margin = pie_text_overrides::pie_margin_px();
312 let legend_rect_size = pie_text_overrides::pie_legend_rect_size_px();
313 let legend_spacing = pie_text_overrides::pie_legend_spacing_px();
314
315 let center_x = pie_text_overrides::pie_center_x_px();
316 let center_y = pie_text_overrides::pie_center_y_px();
317 let radius = pie_text_overrides::pie_radius_px();
318 let outer_radius = pie_text_overrides::pie_outer_radius_px();
319 let label_radius = pie_text_overrides::pie_label_radius_px(radius);
320 let legend_x = pie_text_overrides::pie_legend_x_px();
321 let legend_step_y: f64 = legend_rect_size + legend_spacing;
322 let legend_start_y: f64 = -(legend_step_y * (model.sections.len().max(1) as f64)) / 2.0;
323
324 let total: f64 = model
325 .sections
326 .iter()
327 .filter(|s| s.value.is_finite() && s.value >= 0.0)
328 .map(|s| s.value)
329 .sum();
330
331 let mut color_scale = ColorScale::new_default();
332
333 let mut slices: Vec<PieSliceLayout> = Vec::new();
334 if total.is_finite() && total > 0.0 {
335 let mut pie_sections: Vec<&PieSection> = model
342 .sections
343 .iter()
344 .filter(|s| s.value.is_finite() && s.value > 0.0)
345 .filter(|s| (s.value / total) * 100.0 >= 1.0)
346 .collect();
347 pie_sections.sort_by(|a, b| b.value.partial_cmp(&a.value).unwrap_or(Ordering::Equal));
348
349 let pie_total: f64 = pie_sections.iter().map(|s| s.value).sum();
350 if !pie_sections.is_empty() && pie_total.is_finite() && pie_total > 0.0 {
351 if pie_sections.len() == 1 {
352 let s = pie_sections[0];
353 let fill = color_scale.color_for(&s.label);
354 let (tx, ty) = polar_xy(label_radius, std::f64::consts::PI);
355 let percent = ((100.0 * (s.value / total)).max(0.0)).round() as i64;
356 slices.push(PieSliceLayout {
357 label: s.label.clone(),
358 value: s.value,
359 start_angle: 0.0,
360 end_angle: std::f64::consts::TAU,
361 is_full_circle: true,
362 percent,
363 text_x: tx,
364 text_y: ty,
365 fill,
366 });
367 } else {
368 let mut start = 0.0;
369 for s in pie_sections {
370 let frac = (s.value / pie_total).max(0.0);
371 let delta = (frac * std::f64::consts::TAU).max(0.0);
372 let end = start + delta;
373 let mid = (start + end) / 2.0;
374 let (tx, ty) = polar_xy(label_radius, mid);
375 let fill = color_scale.color_for(&s.label);
376 let percent = ((100.0 * (s.value / total)).max(0.0)).round() as i64;
377 if percent != 0 {
378 slices.push(PieSliceLayout {
379 label: s.label.clone(),
380 value: s.value,
381 start_angle: start,
382 end_angle: end,
383 is_full_circle: false,
384 percent,
385 text_x: tx,
386 text_y: ty,
387 fill,
388 });
389 }
390 start = end;
391 }
392 }
393 }
394 }
395
396 let mut legend_items: Vec<PieLegendItemLayout> = Vec::new();
399 for (i, sec) in model.sections.iter().enumerate() {
400 let y = legend_start_y + (i as f64) * legend_step_y;
401 let fill = color_scale.color_for(&sec.label);
402 legend_items.push(PieLegendItemLayout {
403 label: sec.label.clone(),
404 value: sec.value,
405 fill,
406 y,
407 });
408 }
409
410 let legend_style = TextStyle {
411 font_family: None,
412 font_size: pie_text_overrides::pie_legend_label_font_size_px(),
413 font_weight: None,
414 };
415 let mut max_legend_width: f64 = 0.0;
416 for sec in &model.sections {
417 let label = if model.show_data {
418 format!("{} [{}]", sec.label, fmt_number(sec.value))
419 } else {
420 sec.label.clone()
421 };
422 let metrics =
426 measurer.measure_wrapped(&label, &legend_style, None, WrapMode::SvgLikeSingleRun);
427 let mut w = metrics.width.max(0.0);
428 let trimmed = label.trim_end();
429 if !trimmed.is_empty() {
430 let font_size = legend_style.font_size.max(1.0);
431 let first = trimmed.chars().next().unwrap_or(' ');
432 let last = trimmed.chars().last().unwrap_or(' ');
433 w += pie_legend_bbox_overhang_left_em(first) * font_size;
434 w += pie_legend_bbox_overhang_right_em(last) * font_size;
435 }
436 max_legend_width = max_legend_width.max(w);
437 }
438
439 let base_w: f64 = center_x * 2.0;
440 let width: f64 =
444 (base_w + margin + legend_rect_size + legend_spacing + max_legend_width).max(1.0);
445 let height: f64 = f64::max(center_y * 2.0, 1.0);
446
447 Ok(PieDiagramLayout {
448 bounds: Some(Bounds {
449 min_x: 0.0,
450 min_y: 0.0,
451 max_x: width,
452 max_y: height,
453 }),
454 center_x,
455 center_y,
456 radius,
457 outer_radius,
458 legend_x,
459 legend_start_y,
460 legend_step_y,
461 slices,
462 legend_items,
463 })
464}
465
466#[cfg(test)]
467mod tests {
468 #[test]
469 fn pie_text_constants_are_generated() {
470 assert_eq!(
471 crate::generated::pie_text_overrides_11_12_2::pie_margin_px(),
472 40.0
473 );
474 assert_eq!(
475 crate::generated::pie_text_overrides_11_12_2::pie_legend_rect_size_px(),
476 18.0
477 );
478 assert_eq!(
479 crate::generated::pie_text_overrides_11_12_2::pie_center_x_px(),
480 225.0
481 );
482 assert_eq!(
483 crate::generated::pie_text_overrides_11_12_2::pie_label_radius_px(185.0),
484 138.75
485 );
486 assert_eq!(
487 crate::generated::pie_text_overrides_11_12_2::pie_title_y_px(),
488 -200.0
489 );
490 }
491}