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