use super::constants::*;
use super::parser::PieDiagram;
use super::templates::{self, esc, fmt, fmt_value};
use crate::text_browser_metrics::measure_browser;
use crate::theme::Theme;
use std::f64::consts::PI;
struct ArcDatum {
label: String,
value: f64,
start_angle: f64, end_angle: f64,
}
fn create_pie_arcs(sections: &indexmap::IndexMap<String, f64>) -> Vec<ArcDatum> {
let sum: f64 = sections.values().sum();
if sum == 0.0 {
return Vec::new();
}
let filtered: Vec<(&String, &f64)> = sections
.iter()
.filter(|(_, v)| ((*v) / sum) * 100.0 >= 1.0)
.collect();
let filtered_sum: f64 = filtered.iter().map(|(_, v)| **v).sum();
let mut arcs = Vec::new();
let mut current_angle = 0.0_f64;
for (label, value) in &filtered {
let fraction = **value / filtered_sum;
let sweep = fraction * 2.0 * PI;
let start = current_angle;
let end = current_angle + sweep;
arcs.push(ArcDatum {
label: (*label).clone(),
value: **value,
start_angle: start,
end_angle: end,
});
current_angle = end;
}
arcs
}
fn arc_path(start_angle: f64, end_angle: f64, radius: f64) -> String {
let x0 = start_angle.sin() * radius;
let y0 = -start_angle.cos() * radius; let x1 = end_angle.sin() * radius;
let y1 = -end_angle.cos() * radius;
let sweep = end_angle - start_angle;
let large_arc = if sweep > PI { 1 } else { 0 };
format!(
"M{},{:.3}A{},{},0,{},1,{},{:.3}L0,0Z",
fmt(x0),
y0,
fmt(radius),
fmt(radius),
large_arc,
fmt(x1),
y1,
)
}
fn arc_centroid(start_angle: f64, end_angle: f64, radius: f64) -> (f64, f64) {
let mid = (start_angle + end_angle) / 2.0;
let x = mid.sin() * radius;
let y = -mid.cos() * radius;
(x, y)
}
const DARK_PIE_COLORS: &[&str] = &[
"#0b0000", "#4d1037", "#3f5258", "#4f2f1b", "#6e0a0a", "#3b0048", "#995a01", "#154706",
"#161722", "#00296f", "#01629c", "#1f2020",
];
const FOREST_PIE_COLORS_STATIC: &[&str] = &[
"hsl(78.1578947368, 58.4615384615%, 84.5098039216%)", "hsl(78.1578947368, 58.4615384615%, 44.5098039216%)", "hsl(98.961038961, 100%, 54.9019607843%)", "hsl(118.1578947368, 58.4615384615%, 44.5098039216%)", "hsl(138.1578947368, 58.4615384615%, 64.5098039216%)", "hsl(158.1578947368, 58.4615384615%, 44.5098039216%)", "hsl(178.1578947368, 58.4615384615%, 64.5098039216%)", "hsl(198.1578947368, 58.4615384615%, 44.5098039216%)", "hsl(218.1578947368, 58.4615384615%, 64.5098039216%)", "hsl(238.1578947368, 58.4615384615%, 54.5098039216%)", ];
const NEUTRAL_PIE_COLORS: &[&str] = &[
"#F4F4F4", "#555", "#BBB", "#777", "#999", "#DDD", "#FFF", "#DDD", "#BBB", "#999", "#777",
"#555",
];
fn pie_slice_color<'a>(
index: usize,
primary: &'a str,
secondary: &'a str,
theme: Theme,
) -> &'a str {
match theme {
Theme::Dark => DARK_PIE_COLORS[index % DARK_PIE_COLORS.len()],
Theme::Forest => match index % 12 {
0 => primary,
1 => secondary,
i => FOREST_PIE_COLORS_STATIC[(i - 2) % FOREST_PIE_COLORS_STATIC.len()],
},
Theme::Neutral => NEUTRAL_PIE_COLORS[index % NEUTRAL_PIE_COLORS.len()],
_ => match index % 12 {
0 => primary,
1 => secondary,
i => PIE_COLORS_STATIC[(i - 2) % PIE_COLORS_STATIC.len()],
},
}
}
#[allow(clippy::vec_init_then_push)]
pub fn render(diag: &PieDiagram, theme: Theme, _use_foreign_object: bool) -> String {
let vars = theme.resolve();
let primary_text = vars.primary_text;
let primary_color = vars.primary_color;
let secondary_color = vars.secondary_color;
let radius = (PIE_WIDTH.min(HEIGHT) / 2.0) - MARGIN; let label_radius = radius * TEXT_POSITION;
let arcs = create_pie_arcs(&diag.sections);
let total_sum: f64 = diag.sections.values().sum();
let filtered_arcs: Vec<&ArcDatum> = arcs
.iter()
.filter(|a| (a.value / total_sum * 100.0).round() as u64 != 0)
.collect();
let all_sections: Vec<(&String, &f64)> = diag.sections.iter().collect();
let legend_text_width = all_sections
.iter()
.map(|(label, value)| {
let text = if diag.show_data {
format!("{} [{}]", label, fmt_value(**value))
} else {
(*label).clone()
};
measure_browser(&text, LEGEND_FONT_SIZE).0
})
.fold(0.0_f64, f64::max);
let chart_and_legend_width =
PIE_WIDTH + MARGIN + LEGEND_RECT_SIZE + LEGEND_SPACING + legend_text_width;
let title_text_str = diag.title.as_deref().unwrap_or("");
let title_width = if title_text_str.is_empty() {
0.0
} else {
measure_browser(title_text_str, TITLE_FONT_SIZE).0
};
let title_left = PIE_WIDTH / 2.0 - title_width / 2.0;
let title_right = PIE_WIDTH / 2.0 + title_width / 2.0;
let view_box_x = 0.0_f64.min(title_left);
let view_box_right = chart_and_legend_width.max(title_right);
let total_width = view_box_right - view_box_x;
let id = "mermaid-pie";
let group_tx = PIE_WIDTH / 2.0; let group_ty = HEIGHT / 2.0;
let mut svg_parts: Vec<String> = Vec::new();
svg_parts.push(templates::svg_root(
id,
&fmt(view_box_x),
&fmt(total_width),
&fmt(HEIGHT),
&fmt(total_width),
));
svg_parts.push("<g></g>".to_string());
svg_parts.push(templates::main_group(&fmt(group_tx), &fmt(group_ty)));
svg_parts.push(templates::outer_circle(
&fmt(radius + OUTER_STROKE_WIDTH / 2.0),
"black",
));
for (i, arc) in filtered_arcs.iter().enumerate() {
let color_idx = diag.sections.get_index_of(&arc.label).unwrap_or(i);
let color = pie_slice_color(color_idx, primary_color, secondary_color, theme);
let d = arc_path(arc.start_angle, arc.end_angle, radius);
svg_parts.push(templates::pie_slice(&d, color, "black"));
}
for arc in &filtered_arcs {
let pct = (arc.value / total_sum * 100.0).round() as u64;
let (cx, cy) = arc_centroid(arc.start_angle, arc.end_angle, label_radius);
svg_parts.push(templates::slice_label(
&fmt(cx),
&fmt(cy),
pct,
primary_text,
));
}
let title_y = -((HEIGHT - 50.0) / 2.0); svg_parts.push(templates::title_text(
&fmt(title_y),
&esc(title_text_str),
primary_text,
));
let legend_height = LEGEND_RECT_SIZE + LEGEND_SPACING;
let legend_offset = (legend_height * all_sections.len() as f64) / 2.0;
for (i, (label, value)) in all_sections.iter().enumerate() {
let color = pie_slice_color(i, primary_color, secondary_color, theme);
let vertical = (i as f64) * legend_height - legend_offset;
let legend_text = if diag.show_data {
format!("{} [{}]", label, fmt_value(**value))
} else {
(*label).to_string()
};
svg_parts.push(templates::legend_item(
&fmt(LEGEND_HORIZONTAL_OFFSET),
&fmt(vertical),
color,
&esc(&legend_text),
primary_text,
));
}
svg_parts.push("</g>".to_string());
svg_parts.push("</svg>".to_string());
svg_parts.join("")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::diagrams::pie::parser;
#[test]
fn basic_render_produces_svg() {
let input = "pie\n \"Dogs\" : 386\n \"Cats\" : 85\n \"Rats\" : 15";
let diag = parser::parse(input).diagram;
let svg = render(&diag, Theme::Default, false);
assert!(svg.contains("<svg"));
assert!(svg.contains("pieCircle"));
assert!(svg.contains("pieOuterCircle"));
assert!(svg.contains("79%"));
assert!(svg.contains("Dogs"));
}
#[test]
fn arc_path_full_circle_large_arc() {
let path = arc_path(-PI / 2.0, PI / 2.0 * 3.0, 185.0);
assert!(
path.contains(",0,1,1,"),
"Expected large-arc-flag=1 in: {}",
path
);
}
#[test]
fn fmt_value_integer() {
assert_eq!(fmt_value(386.0), "386");
assert_eq!(fmt_value(42.96), "42.96");
}
#[test]
fn pie_color_static_0_is_valid() {
assert!(
PIE_COLORS_STATIC[0].starts_with('#')
|| PIE_COLORS_STATIC[0].starts_with("hsl(")
|| PIE_COLORS_STATIC[0].starts_with("rgb("),
"Expected a valid color string, got: {}",
PIE_COLORS_STATIC[0]
);
}
#[test]
fn pie_slice_color_uses_theme_vars() {
assert_eq!(
pie_slice_color(0, "#ECECFF", "#ffffde", Theme::Default),
"#ECECFF"
);
assert_eq!(
pie_slice_color(1, "#ECECFF", "#ffffde", Theme::Default),
"#ffffde"
);
assert_eq!(
pie_slice_color(2, "#ECECFF", "#ffffde", Theme::Default),
PIE_COLORS_STATIC[0]
);
}
#[test]
fn snapshot_default_theme() {
let input = "pie title Pets\n \"Dogs\" : 386\n \"Cats\" : 85\n \"Rats\" : 15";
let diag = parser::parse(input).diagram;
let svg = render(&diag, crate::theme::Theme::Default, false);
insta::assert_snapshot!(crate::svg::normalize_floats(&svg));
}
}