Skip to main content

merman_render/
pie.rs

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    // Match Mermaid's base theme output: wrap using remainder without forcing positive hue.
61    // (JS `%` keeps the sign, so negative hues remain negative.)
62    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        // Default theme colors as emitted by Mermaid 11.12.2 in SVG.
168        //
169        // Mermaid derives this palette from `theme-default.js` `pie1..pie12` (using `adjust()`),
170        // where the base colors are:
171        // - primaryColor = "#ECECFF"
172        // - secondaryColor = "#ffffde"
173        // - tertiaryColor = "hsl(80, 100%, 96.2745098039%)"
174        //
175        // Note: `adjust(...)` serializes as `hsl(...)` (not hex), so the palette contains a mix.
176        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    // Mermaid pie charts use a "12 o'clock is zero" convention with y increasing downwards.
234    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    // Mermaid pie charts compute `longestTextWidth` via `legend.selectAll('text').nodes()
264    // .map(node => node.getBoundingClientRect().width)` (Mermaid@11.12.2). For some ASCII glyphs,
265    // Chromium's SVG text bbox extends beyond the advance width (e.g. trailing `t`/`r`, leading
266    // and trailing `_`).
267    //
268    // Our vendored measurer primarily models advance widths (close to `getComputedTextLength()`).
269    // Model the bbox delta with a small per-glyph overhang in `em` units so viewport sizing
270    // (`viewBox` / `max-width`) matches upstream baselines.
271    match ch {
272        // Leading underscore (observed in `__proto__`).
273        '_' => 0.06125057352941176,
274        _ => 0.0,
275    }
276}
277
278fn pie_legend_bbox_overhang_right_em(ch: char) -> f64 {
279    match ch {
280        // Trailing underscore (observed in `__proto__`).
281        '_' => 0.06125057352941176,
282        // Trailing `t` expands bbox in Chromium (`bat`).
283        't' => 0.01496444117647059,
284        // Trailing `r` expands bbox in Chromium (`constructor`).
285        'r' => 0.08091001764705883,
286        // Trailing `e` expands bbox in Chromium (`prototype`).
287        'e' => 0.04291130514705883,
288        // Trailing `s` expands bbox in Chromium (`dogs`/`rats`).
289        's' => 0.007008272058823529,
290        // Trailing `h` small bbox delta (`ash`).
291        'h' => 0.0009191176470588235,
292        // Trailing `]` small bbox delta (`bat [40]`).
293        ']' => 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    // Mermaid@11.12.2 `packages/mermaid/src/diagrams/pie/pieRenderer.ts` constants.
311    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        // Mermaid@11.12.2 `packages/mermaid/src/diagrams/pie/pieRenderer.ts`:
336        //
337        // - filter out values < 1% (based on the original total)
338        // - sort remaining values by descending value before D3 pie() computes angles
339        // - angles are normalized over the filtered set (so drawn slices fill the whole circle)
340        // - percentage labels are still computed using the original total
341        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    // Lock the color scale domain based on the drawn slices first, then compute legend colors in
397    // the original section order (this matches Mermaid's zero-slice behavior).
398    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        // Mermaid pie legend labels render as a single `<text>` run (no `<tspan>` tokenization).
423        // Mermaid measures the width via `getBoundingClientRect().width` (not `getBBox()`), but
424        // we approximate it with a single-run SVG measurement plus a small overhang correction.
425        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    // Mermaid computes:
441    //   totalWidth = pieWidth + MARGIN + LEGEND_RECT_SIZE + LEGEND_SPACING + longestTextWidth
442    // where `pieWidth == height == 450`.
443    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}