Skip to main content

merman_render/
pie.rs

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    // Match Mermaid's base theme output: wrap using remainder without forcing positive hue.
45    // (JS `%` keeps the sign, so negative hues remain negative.)
46    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        // Default theme colors as emitted by Mermaid 11.12.2 in SVG.
152        //
153        // Mermaid derives this palette from `theme-default.js` `pie1..pie12` (using `adjust()`),
154        // where the base colors are:
155        // - primaryColor = "#ECECFF"
156        // - secondaryColor = "#ffffde"
157        // - tertiaryColor = "hsl(80, 100%, 96.2745098039%)"
158        //
159        // Note: `adjust(...)` serializes as `hsl(...)` (not hex), so the palette contains a mix.
160        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    // Mermaid pie charts use a "12 o'clock is zero" convention with y increasing downwards.
218    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    // Mermaid@11.12.2 `packages/mermaid/src/diagrams/pie/pieRenderer.ts` constants.
267    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        // Mermaid@11.12.2 `packages/mermaid/src/diagrams/pie/pieRenderer.ts`:
291        //
292        // - filter out values < 1% (based on the original total)
293        // - sort remaining values by descending value before D3 pie() computes angles
294        // - angles are normalized over the filtered set (so drawn slices fill the whole circle)
295        // - percentage labels are still computed using the original total
296        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    // Lock the color scale domain based on the drawn slices first, then compute legend colors in
352    // the original section order (this matches Mermaid's zero-slice behavior).
353    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        // Mermaid pie legend labels render as a single SVG `<text>` run and compute
379        // `longestTextWidth` from each node's bounding client rect. The shared SVG bbox extents are
380        // closer to that browser width than the wrapped-text width path and remove the need for
381        // fixture-specific root viewport pins.
382        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    // Mermaid computes:
393    //   totalWidth = pieWidth + MARGIN + LEGEND_RECT_SIZE + LEGEND_SPACING + longestTextWidth
394    // where `pieWidth == height == 450`.
395    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}