use super::common;
use super::pie::{Geom, make_slice};
use crate::ir::{ChartKind, ChartSpec, Color, LegendPos, OutlabelConfig};
use crate::num::fmt_num;
use crate::scene::{Anchor, Prim, Scene};
use crate::text::TextMeasurer;
use std::f64::consts::PI;
const RADIUS_FACTOR: f64 = 0.55;
const SHELF_LEN: f64 = 20.0;
const LABEL_PAD: f64 = 3.0;
const LINE_GAP: f64 = 2.0;
pub fn build(spec: &ChartSpec, m: &TextMeasurer) -> Scene {
let mut items: Vec<Prim> = Vec::new();
let ink = spec.theme.text_color;
let label_font = spec.theme.font_size;
let (donut_ratio, outlabel) = match &spec.kind {
ChartKind::OutlabeledPie {
donut_ratio,
outlabel,
} => (*donut_ratio, outlabel.clone()),
_ => {
return Scene {
width: spec.width,
height: spec.height,
items,
};
}
};
let series = spec.series.first();
let empty: Vec<f64> = Vec::new();
let values = series.map(|s| &s.values).unwrap_or(&empty);
let title_band = if spec.title.is_some() {
common::TITLE_BAND
} else {
0.0
};
if let Some(title) = &spec.title {
items.push(Prim::Text {
x: spec.width / 2.0,
y: common::OUTER_PAD + common::TITLE_FONT,
size: common::TITLE_FONT,
anchor: Anchor::Middle,
fill: ink,
content: title.clone(),
});
}
let has_legend = matches!(spec.legend, LegendPos::Top | LegendPos::Bottom)
&& spec.categories.iter().any(|c| !c.is_empty());
let legend_top = if has_legend && spec.legend == LegendPos::Top {
common::LEGEND_BAND
} else {
0.0
};
let legend_bottom = if has_legend && spec.legend == LegendPos::Bottom {
common::LEGEND_BAND
} else {
0.0
};
if has_legend {
let mut total = 0.0_f64;
let n = spec.categories.len();
for (k, cat) in spec.categories.iter().enumerate() {
total += common::legend_entry_width(m, cat, label_font);
if k == n - 1 {
total -= 16.0;
}
}
let start_x = (spec.width - total) / 2.0;
let legend_cy = if spec.legend == LegendPos::Top {
common::OUTER_PAD + title_band + common::LEGEND_BAND / 2.0
} else {
spec.height - common::OUTER_PAD - common::LEGEND_BAND / 2.0
};
let mut cursor = start_x;
for (i, cat) in spec.categories.iter().enumerate() {
let swatch = series.map(|s| s.fill_at(i)).unwrap_or(ink);
items.push(Prim::Rect {
x: cursor,
y: legend_cy - 6.0,
w: 12.0,
h: 12.0,
fill: swatch,
});
items.push(Prim::Text {
x: cursor + 16.0,
y: legend_cy + label_font * common::TEXT_BASELINE_RATIO,
size: label_font,
anchor: Anchor::Start,
fill: ink,
content: cat.clone(),
});
cursor += common::legend_entry_width(m, cat, label_font);
}
}
let area_top = common::OUTER_PAD + title_band + legend_top;
let area_bottom = spec.height - common::OUTER_PAD - legend_bottom;
let cx = spec.width / 2.0;
let cy = (area_top + area_bottom) / 2.0;
let available = (spec.width).min(area_bottom - area_top) / 2.0;
let radius = (available * RADIUS_FACTOR).max(0.0);
let inner = radius * donut_ratio;
let total: f64 = values.iter().filter(|v| v.is_finite() && **v > 0.0).sum();
let mut label_prims: Vec<Prim> = Vec::new();
if total > 0.0 && radius > 0.0 {
let mut a0 = -PI / 2.0;
for (i, &v) in values.iter().enumerate() {
if !(v.is_finite() && v > 0.0) {
continue;
}
let frac = v / total;
let a1 = a0 + frac * 2.0 * PI;
let fill = series.map(|s| s.fill_at(i)).unwrap_or(ink);
let g = Geom {
cx,
cy,
r_outer: radius,
r_inner: inner,
};
if a1 - a0 >= 2.0 * PI - 1e-9 {
let amid = a0 + (a1 - a0) / 2.0;
items.push(make_slice(&g, a0, amid, fill));
items.push(make_slice(&g, amid, a1, fill));
} else {
items.push(make_slice(&g, a0, a1, fill));
}
let amid = (a0 + a1) / 2.0;
draw_outlabel(
&mut label_prims,
cx,
cy,
radius,
amid,
fill,
i,
v,
frac,
&spec.categories,
&outlabel,
label_font,
m,
);
a0 = a1;
}
}
items.extend(label_prims);
Scene {
width: spec.width,
height: spec.height,
items,
}
}
#[allow(clippy::too_many_arguments)]
fn draw_outlabel(
out: &mut Vec<Prim>,
cx: f64,
cy: f64,
radius: f64,
amid: f64,
slice_fill: Color,
idx: usize,
value: f64,
frac: f64,
categories: &[String],
cfg: &OutlabelConfig,
font_size: f64,
m: &TextMeasurer,
) {
let p0 = (cx + radius * amid.cos(), cy + radius * amid.sin());
let stretch_r = radius + cfg.stretch;
let p1 = (cx + stretch_r * amid.cos(), cy + stretch_r * amid.sin());
let on_right = amid.cos() >= 0.0;
let p2 = if on_right {
(p1.0 + SHELF_LEN, p1.1)
} else {
(p1.0 - SHELF_LEN, p1.1)
};
out.push(Prim::Polyline {
points: vec![p0, p1, p2],
stroke: slice_fill,
stroke_width: 1.5,
});
let label_str = categories.get(idx).map(|s| s.as_str()).unwrap_or("");
let pct = (frac * 100.0).round() as i64;
const MAX_EXPANDED_BYTES: usize = crate::guard::DEFAULT_MAX_LABEL_BYTES;
let lines: Vec<String> = cfg
.text
.split('\n')
.map(|tmpl| {
let s = expand_template(tmpl, label_str, value, pct);
if s.len() <= MAX_EXPANDED_BYTES {
s
} else {
let mut end = MAX_EXPANDED_BYTES;
while !s.is_char_boundary(end) {
end -= 1;
}
s[..end].to_string()
}
})
.collect();
let (anchor, text_x) = if on_right {
(Anchor::Start, p2.0 + LABEL_PAD)
} else {
(Anchor::End, p2.0 - LABEL_PAD)
};
let line_h = font_size + LINE_GAP;
let n_lines = lines.len() as f64;
let text_y_top = p2.1 - (n_lines - 1.0) * line_h / 2.0;
let bg_color = cfg.background.unwrap_or(slice_fill);
let max_w = lines
.iter()
.map(|l| m.width(l, font_size as f32) as f64)
.fold(0.0_f64, f64::max);
let box_w = max_w + LABEL_PAD * 2.0;
let box_h = line_h * n_lines + LABEL_PAD * 2.0;
let box_x = if on_right { p2.0 } else { p2.0 - box_w };
let box_y = text_y_top - LABEL_PAD;
out.push(Prim::Rect {
x: box_x,
y: box_y,
w: box_w,
h: box_h,
fill: bg_color,
});
for (i, line) in lines.into_iter().enumerate() {
out.push(Prim::Text {
x: text_x,
y: text_y_top + (i as f64) * line_h + font_size * common::TEXT_BASELINE_RATIO,
size: font_size,
anchor,
fill: cfg.color,
content: line,
});
}
}
fn expand_template(tmpl: &str, label: &str, value: f64, pct: i64) -> String {
let value_str = fmt_num(value);
let pct_str = pct.to_string();
let mut result = String::with_capacity(tmpl.len() + label.len() + 8);
let mut chars = tmpl.chars().peekable();
while let Some(c) = chars.next() {
if c == '%' {
match chars.peek() {
Some(&'l') => {
chars.next();
result.push_str(label);
}
Some(&'v') => {
chars.next();
result.push_str(&value_str);
}
Some(&'p') => {
chars.next();
result.push_str(&pct_str);
}
_ => result.push('%'),
}
} else {
result.push(c);
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use crate::font::DEFAULT_FONT;
use crate::frontend::chartjs;
use crate::render::render_chart_with_font;
fn make_spec(type_str: &str) -> crate::ir::ChartSpec {
let json = format!(
r#"{{"type":"{}","data":{{"labels":["A","B","C"],"datasets":[{{"data":[10,20,30]}}]}}}}"#,
type_str
);
chartjs::parse(&json, false).expect("parse error")
}
#[test]
fn outlabeled_pie_renders_to_svg() {
let spec = make_spec("outlabeledPie");
let svg = render_chart_with_font(&spec, DEFAULT_FONT).unwrap();
assert!(svg.starts_with("<svg"), "should produce valid SVG");
}
#[test]
fn outlabeled_doughnut_renders_to_svg() {
let spec = make_spec("outlabeledDoughnut");
let svg = render_chart_with_font(&spec, DEFAULT_FONT).unwrap();
assert!(svg.starts_with("<svg"), "should produce valid SVG");
}
#[test]
fn outlabeled_pie_has_text_primitives() {
let spec = make_spec("outlabeledPie");
let scene = build(
&spec,
&crate::text::TextMeasurer::new(DEFAULT_FONT).unwrap(),
);
let has_text = scene
.items
.iter()
.any(|p| matches!(p, crate::scene::Prim::Text { .. }));
assert!(has_text, "scene must contain Text primitives for labels");
}
#[test]
fn outlabeled_pie_single_slice_renders() {
let json =
r#"{"type":"outlabeledPie","data":{"labels":["Only"],"datasets":[{"data":[100]}]}}"#;
let spec = chartjs::parse(json, false).expect("parse error");
let svg = render_chart_with_font(&spec, DEFAULT_FONT).unwrap();
assert!(svg.starts_with("<svg"));
}
#[test]
fn outlabeled_doughnut_inner_radius_nonzero() {
let spec = make_spec("outlabeledDoughnut");
let scene = build(
&spec,
&crate::text::TextMeasurer::new(DEFAULT_FONT).unwrap(),
);
let has_doughnut_path = scene.items.iter().any(|p| {
if let crate::scene::Prim::Path { d, .. } = p {
d.contains("0 0 ")
} else {
false
}
});
assert!(has_doughnut_path, "doughnut must have inner arc paths");
}
}