Lines
87.39 %
Functions
25.23 %
Branches
100 %
//! Backend-agnostic chart drawing. Both `render_svg` and
//! `render_canvas` call `draw_on` with their own drawing area, so a
//! chart drawn to SVG looks identical to one drawn to a canvas.
use plotters::coord::Shift;
use plotters::prelude::*;
use crate::spec::{ChartKind, ChartSpec, SeriesPoint};
/// Palette cycled through series. Deliberately short — in practice
/// reports have 2-5 series.
const PALETTE: &[RGBColor] = &[
RGBColor(31, 119, 180),
RGBColor(255, 127, 14),
RGBColor(44, 160, 44),
RGBColor(214, 39, 40),
RGBColor(148, 103, 189),
RGBColor(140, 86, 75),
RGBColor(227, 119, 194),
];
fn color_for(index: usize) -> RGBColor {
PALETTE[index % PALETTE.len()]
}
type DrawResult<DB> = Result<(), DrawingAreaErrorKind<<DB as DrawingBackend>::ErrorType>>;
/// Draw `spec` onto `root`. Common layout for every chart kind: title
/// across the top, chart in the middle, notes (if any) across the
/// bottom.
///
/// # Errors
/// Returns an error if the backend rejects a draw call (e.g. the
/// drawing area is zero-sized). Callers fall back to a plain-text
/// representation on failure.
pub fn draw_on<DB>(root: &DrawingArea<DB, Shift>, spec: &ChartSpec) -> DrawResult<DB>
where
DB: DrawingBackend,
{
root.fill(&WHITE)?;
// Reserve space at the bottom for notes (one line per note, ~14px).
let (_, total_h) = root.dim_in_pixel();
let note_height = if spec.notes.is_empty() {
0
} else {
16 * i32::try_from(spec.notes.len()).unwrap_or(0)
};
let chart_h = i32::try_from(total_h)
.unwrap_or(0)
.saturating_sub(note_height)
.max(0);
let (chart_area, notes_area) = root.split_vertically(chart_h);
// Collect the union of x-labels in the order they first appear.
let mut x_labels: Vec<String> = Vec::new();
for series in &spec.series {
for point in &series.points {
if !x_labels.iter().any(|l| l == &point.x) {
x_labels.push(point.x.clone());
if x_labels.is_empty() || spec.series.is_empty() {
chart_area.draw_text(
&spec.title,
&("sans-serif", 16).into_text_style(&chart_area),
(10, 20),
)?;
draw_notes(¬es_area, spec)?;
return Ok(());
let (y_min, y_max) = y_range(spec);
// For grouped bars we subdivide each slot into one sub-slot per
// series, so bars don't overlap. Stacked bars and lines use one
// sub-slot per period.
let sub_count: i32 = match spec.kind {
ChartKind::Bar => i32::try_from(spec.series.len().max(1)).unwrap_or(1),
ChartKind::StackedBar | ChartKind::Line => 1,
let x_count = i32::try_from(x_labels.len()).unwrap_or(1) * sub_count;
let mut chart = ChartBuilder::on(&chart_area)
.caption(spec.title.as_str(), ("sans-serif", 18))
.x_label_area_size(36u32)
.y_label_area_size(60u32)
.margin(8u32)
.build_cartesian_2d((0i32..x_count).into_segmented(), y_min..y_max)?;
let labels_for_closure = x_labels.clone();
chart
.configure_mesh()
// Cap y-ticks so the default "one line per unit" doesn't draw
// hundreds of horizontal rules on a chart whose range spans
// into the thousands.
.y_labels(6)
.x_labels(x_labels.len())
.x_label_formatter(&move |seg: &SegmentValue<i32>| {
format_x_label(seg, &labels_for_closure, sub_count)
})
// Drop the minor gridlines; keep only the major ticks so the
// chart stays readable without visual noise.
.disable_x_mesh()
.light_line_style(TRANSPARENT)
.x_desc(&spec.x_label)
.y_desc(&spec.y_label)
.label_style(("sans-serif", 11))
.draw()?;
match spec.kind {
ChartKind::Line => draw_line(&mut chart, spec, &x_labels, sub_count)?,
ChartKind::Bar => draw_bars(&mut chart, spec, &x_labels, false, sub_count)?,
ChartKind::StackedBar => draw_bars(&mut chart, spec, &x_labels, true, 1)?,
.configure_series_labels()
.border_style(BLACK.mix(0.2))
.background_style(WHITE.mix(0.8))
.label_font(("sans-serif", 11))
Ok(())
fn format_x_label(seg: &SegmentValue<i32>, labels: &[String], sub_count: i32) -> String {
let raw = match seg {
SegmentValue::CenterOf(i) | SegmentValue::Exact(i) => *i,
SegmentValue::Last => return String::new(),
// With `sub_count` sub-slots per period, only the middle sub-slot
// of each group prints a label — otherwise every bar gets its own
// tick.
if sub_count <= 1 {
return usize::try_from(raw)
.ok()
.and_then(|idx| labels.get(idx).cloned())
.unwrap_or_default();
let mid = sub_count / 2;
if raw.rem_euclid(sub_count) != mid {
return String::new();
let group = raw.div_euclid(sub_count);
usize::try_from(group)
.unwrap_or_default()
fn y_range(spec: &ChartSpec) -> (f64, f64) {
let (mut min, mut max) = (0.0_f64, 0.0_f64);
if matches!(spec.kind, ChartKind::StackedBar) {
// Per-x-slot: sum positive and negative stacks separately.
let mut slots: std::collections::BTreeMap<&str, (f64, f64)> =
std::collections::BTreeMap::new();
let y = point.y_f64();
let (neg, pos) = slots.entry(point.x.as_str()).or_insert((0.0, 0.0));
if y >= 0.0 {
*pos += y;
*neg += y;
for (_, (neg, pos)) in slots {
if neg < min {
min = neg;
if pos > max {
max = pos;
if y < min {
min = y;
if y > max {
max = y;
let span = (max - min).abs().max(1.0);
let pad = span * 0.05;
(min - pad, max + pad)
fn draw_line<DB, CT>(
chart: &mut ChartContext<'_, DB, CT>,
spec: &ChartSpec,
x_labels: &[String],
sub_count: i32,
) -> DrawResult<DB>
CT: plotters::coord::CoordTranslate<From = (SegmentValue<i32>, f64)>,
// Line mode uses `sub_count = 1`, so each period centres on
// `CenterOf(idx)`. If we ever subdivide line-mode x-slots in the
// future this multiplier keeps the points aligned.
for (i, series) in spec.series.iter().enumerate() {
let color = color_for(i);
let points: Vec<(SegmentValue<i32>, f64)> = x_labels
.iter()
.enumerate()
.map(|(idx, label)| {
let y = series
.points
.find(|p| &p.x == label)
.map_or(0.0, SeriesPoint::y_f64);
let x = i32::try_from(idx).unwrap_or(0) * sub_count;
(SegmentValue::CenterOf(x), y)
.collect();
.draw_series(LineSeries::new(points.clone(), color.stroke_width(2)))?
.label(series.label.as_str())
.legend(move |(x, y)| {
PathElement::new(vec![(x, y), (x + 20, y)], color.stroke_width(2))
});
chart.draw_series(
points
.into_iter()
.map(|p| Circle::new(p, 3, color.filled())),
fn draw_bars<DB, CT>(
stacked: bool,
// Stacked-bar accumulators per x-slot and sign.
let mut pos_acc: Vec<f64> = vec![0.0; x_labels.len()];
let mut neg_acc: Vec<f64> = vec![0.0; x_labels.len()];
for (s_idx, series) in spec.series.iter().enumerate() {
let color = color_for(s_idx);
let series_label = series.label.clone();
for (x_idx, label) in x_labels.iter().enumerate() {
let Some(point) = series.points.iter().find(|p| &p.x == label) else {
continue;
if y == 0.0 {
let (base, top) = if stacked {
let acc = if y >= 0.0 {
&mut pos_acc[x_idx]
&mut neg_acc[x_idx]
let start = *acc;
*acc += y;
(start, *acc)
(0.0, y)
// Grouped bars: each slot has `sub_count` sub-slots,
// series `s_idx` occupies the `s_idx`-th one. Stacked
// bars pass `sub_count = 1` so every series shares the
// single slot.
let x_left = i32::try_from(x_idx).unwrap_or(0) * sub_count
+ i32::try_from(s_idx)
.min(sub_count.saturating_sub(1));
chart.draw_series(std::iter::once(Rectangle::new(
[
(SegmentValue::Exact(x_left), base),
(SegmentValue::Exact(x_left + 1), top),
],
color.filled(),
)))?;
// Synthetic empty series so the legend picks up this colour.
.draw_series(std::iter::empty::<Rectangle<(SegmentValue<i32>, f64)>>())?
.label(series_label)
.legend(move |(x, y)| Rectangle::new([(x, y - 5), (x + 10, y + 5)], color.filled()));
fn draw_notes<DB>(area: &DrawingArea<DB, Shift>, spec: &ChartSpec) -> DrawResult<DB>
if spec.notes.is_empty() || area.dim_in_pixel().1 == 0 {
let style = ("sans-serif", 11).into_text_style(area);
for (i, note) in spec.notes.iter().enumerate() {
let y = 2 + i32::try_from(i).unwrap_or(0) * 14;
area.draw_text(note, &style, (8, y))?;