Skip to main content

merman_render/
pie.rs

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