1
//! Backend-agnostic chart drawing. Both `render_svg` and
2
//! `render_canvas` call `draw_on` with their own drawing area, so a
3
//! chart drawn to SVG looks identical to one drawn to a canvas.
4

            
5
use plotters::coord::Shift;
6
use plotters::prelude::*;
7

            
8
use crate::spec::{ChartKind, ChartSpec, SeriesPoint};
9

            
10
/// Palette cycled through series. Deliberately short — in practice
11
/// reports have 2-5 series.
12
const PALETTE: &[RGBColor] = &[
13
    RGBColor(31, 119, 180),
14
    RGBColor(255, 127, 14),
15
    RGBColor(44, 160, 44),
16
    RGBColor(214, 39, 40),
17
    RGBColor(148, 103, 189),
18
    RGBColor(140, 86, 75),
19
    RGBColor(227, 119, 194),
20
];
21

            
22
6
fn color_for(index: usize) -> RGBColor {
23
6
    PALETTE[index % PALETTE.len()]
24
6
}
25

            
26
type DrawResult<DB> = Result<(), DrawingAreaErrorKind<<DB as DrawingBackend>::ErrorType>>;
27

            
28
/// Draw `spec` onto `root`. Common layout for every chart kind: title
29
/// across the top, chart in the middle, notes (if any) across the
30
/// bottom.
31
///
32
/// # Errors
33
///
34
/// Returns an error if the backend rejects a draw call (e.g. the
35
/// drawing area is zero-sized). Callers fall back to a plain-text
36
/// representation on failure.
37
7
pub fn draw_on<DB>(root: &DrawingArea<DB, Shift>, spec: &ChartSpec) -> DrawResult<DB>
38
7
where
39
7
    DB: DrawingBackend,
40
{
41
7
    root.fill(&WHITE)?;
42

            
43
    // Reserve space at the bottom for notes (one line per note, ~14px).
44
7
    let (_, total_h) = root.dim_in_pixel();
45
7
    let note_height = if spec.notes.is_empty() {
46
7
        0
47
    } else {
48
        16 * i32::try_from(spec.notes.len()).unwrap_or(0)
49
    };
50
7
    let chart_h = i32::try_from(total_h)
51
7
        .unwrap_or(0)
52
7
        .saturating_sub(note_height)
53
7
        .max(0);
54
7
    let (chart_area, notes_area) = root.split_vertically(chart_h);
55

            
56
    // Collect the union of x-labels in the order they first appear.
57
7
    let mut x_labels: Vec<String> = Vec::new();
58
7
    for series in &spec.series {
59
12
        for point in &series.points {
60
12
            if !x_labels.iter().any(|l| l == &point.x) {
61
12
                x_labels.push(point.x.clone());
62
12
            }
63
        }
64
    }
65

            
66
7
    if x_labels.is_empty() || spec.series.is_empty() {
67
1
        chart_area.draw_text(
68
1
            &spec.title,
69
1
            &("sans-serif", 16).into_text_style(&chart_area),
70
1
            (10, 20),
71
        )?;
72
1
        draw_notes(&notes_area, spec)?;
73
1
        return Ok(());
74
6
    }
75

            
76
6
    let (y_min, y_max) = y_range(spec);
77

            
78
    // For grouped bars we subdivide each slot into one sub-slot per
79
    // series, so bars don't overlap. Stacked bars and lines use one
80
    // sub-slot per period.
81
6
    let sub_count: i32 = match spec.kind {
82
2
        ChartKind::Bar => i32::try_from(spec.series.len().max(1)).unwrap_or(1),
83
4
        ChartKind::StackedBar | ChartKind::Line => 1,
84
    };
85
6
    let x_count = i32::try_from(x_labels.len()).unwrap_or(1) * sub_count;
86

            
87
6
    let mut chart = ChartBuilder::on(&chart_area)
88
6
        .caption(spec.title.as_str(), ("sans-serif", 18))
89
6
        .x_label_area_size(36u32)
90
6
        .y_label_area_size(60u32)
91
6
        .margin(8u32)
92
6
        .build_cartesian_2d((0i32..x_count).into_segmented(), y_min..y_max)?;
93

            
94
6
    let labels_for_closure = x_labels.clone();
95
6
    chart
96
6
        .configure_mesh()
97
6
        // Cap y-ticks so the default "one line per unit" doesn't draw
98
6
        // hundreds of horizontal rules on a chart whose range spans
99
6
        // into the thousands.
100
6
        .y_labels(6)
101
6
        .x_labels(x_labels.len())
102
18
        .x_label_formatter(&move |seg: &SegmentValue<i32>| {
103
18
            format_x_label(seg, &labels_for_closure, sub_count)
104
18
        })
105
        // Drop the minor gridlines; keep only the major ticks so the
106
        // chart stays readable without visual noise.
107
6
        .disable_x_mesh()
108
6
        .light_line_style(TRANSPARENT)
109
6
        .x_desc(&spec.x_label)
110
6
        .y_desc(&spec.y_label)
111
6
        .label_style(("sans-serif", 11))
112
6
        .draw()?;
113

            
114
6
    match spec.kind {
115
3
        ChartKind::Line => draw_line(&mut chart, spec, &x_labels, sub_count)?,
116
2
        ChartKind::Bar => draw_bars(&mut chart, spec, &x_labels, false, sub_count)?,
117
1
        ChartKind::StackedBar => draw_bars(&mut chart, spec, &x_labels, true, 1)?,
118
    }
119

            
120
6
    chart
121
6
        .configure_series_labels()
122
6
        .border_style(BLACK.mix(0.2))
123
6
        .background_style(WHITE.mix(0.8))
124
6
        .label_font(("sans-serif", 11))
125
6
        .draw()?;
126

            
127
6
    draw_notes(&notes_area, spec)?;
128
6
    Ok(())
129
7
}
130

            
131
18
fn format_x_label(seg: &SegmentValue<i32>, labels: &[String], sub_count: i32) -> String {
132
18
    let raw = match seg {
133
18
        SegmentValue::CenterOf(i) | SegmentValue::Exact(i) => *i,
134
        SegmentValue::Last => return String::new(),
135
    };
136
    // With `sub_count` sub-slots per period, only the middle sub-slot
137
    // of each group prints a label — otherwise every bar gets its own
138
    // tick.
139
18
    if sub_count <= 1 {
140
18
        return usize::try_from(raw)
141
18
            .ok()
142
18
            .and_then(|idx| labels.get(idx).cloned())
143
18
            .unwrap_or_default();
144
    }
145
    let mid = sub_count / 2;
146
    if raw.rem_euclid(sub_count) != mid {
147
        return String::new();
148
    }
149
    let group = raw.div_euclid(sub_count);
150
    usize::try_from(group)
151
        .ok()
152
        .and_then(|idx| labels.get(idx).cloned())
153
        .unwrap_or_default()
154
18
}
155

            
156
6
fn y_range(spec: &ChartSpec) -> (f64, f64) {
157
6
    let (mut min, mut max) = (0.0_f64, 0.0_f64);
158
6
    if matches!(spec.kind, ChartKind::StackedBar) {
159
        // Per-x-slot: sum positive and negative stacks separately.
160
1
        let mut slots: std::collections::BTreeMap<&str, (f64, f64)> =
161
1
            std::collections::BTreeMap::new();
162
1
        for series in &spec.series {
163
2
            for point in &series.points {
164
2
                let y = point.y_f64();
165
2
                let (neg, pos) = slots.entry(point.x.as_str()).or_insert((0.0, 0.0));
166
2
                if y >= 0.0 {
167
2
                    *pos += y;
168
2
                } else {
169
                    *neg += y;
170
                }
171
            }
172
        }
173
2
        for (_, (neg, pos)) in slots {
174
2
            if neg < min {
175
                min = neg;
176
2
            }
177
2
            if pos > max {
178
1
                max = pos;
179
1
            }
180
        }
181
    } else {
182
5
        for series in &spec.series {
183
10
            for point in &series.points {
184
10
                let y = point.y_f64();
185
10
                if y < min {
186
                    min = y;
187
10
                }
188
10
                if y > max {
189
10
                    max = y;
190
10
                }
191
            }
192
        }
193
    }
194

            
195
6
    let span = (max - min).abs().max(1.0);
196
6
    let pad = span * 0.05;
197
6
    (min - pad, max + pad)
198
6
}
199

            
200
3
fn draw_line<DB, CT>(
201
3
    chart: &mut ChartContext<'_, DB, CT>,
202
3
    spec: &ChartSpec,
203
3
    x_labels: &[String],
204
3
    sub_count: i32,
205
3
) -> DrawResult<DB>
206
3
where
207
3
    DB: DrawingBackend,
208
3
    CT: plotters::coord::CoordTranslate<From = (SegmentValue<i32>, f64)>,
209
{
210
    // Line mode uses `sub_count = 1`, so each period centres on
211
    // `CenterOf(idx)`. If we ever subdivide line-mode x-slots in the
212
    // future this multiplier keeps the points aligned.
213
3
    for (i, series) in spec.series.iter().enumerate() {
214
3
        let color = color_for(i);
215
3
        let points: Vec<(SegmentValue<i32>, f64)> = x_labels
216
3
            .iter()
217
3
            .enumerate()
218
6
            .map(|(idx, label)| {
219
6
                let y = series
220
6
                    .points
221
6
                    .iter()
222
9
                    .find(|p| &p.x == label)
223
6
                    .map_or(0.0, SeriesPoint::y_f64);
224
6
                let x = i32::try_from(idx).unwrap_or(0) * sub_count;
225
6
                (SegmentValue::CenterOf(x), y)
226
6
            })
227
3
            .collect();
228

            
229
3
        chart
230
3
            .draw_series(LineSeries::new(points.clone(), color.stroke_width(2)))?
231
3
            .label(series.label.as_str())
232
3
            .legend(move |(x, y)| {
233
3
                PathElement::new(vec![(x, y), (x + 20, y)], color.stroke_width(2))
234
3
            });
235
3
        chart.draw_series(
236
3
            points
237
3
                .into_iter()
238
6
                .map(|p| Circle::new(p, 3, color.filled())),
239
        )?;
240
    }
241
3
    Ok(())
242
3
}
243

            
244
3
fn draw_bars<DB, CT>(
245
3
    chart: &mut ChartContext<'_, DB, CT>,
246
3
    spec: &ChartSpec,
247
3
    x_labels: &[String],
248
3
    stacked: bool,
249
3
    sub_count: i32,
250
3
) -> DrawResult<DB>
251
3
where
252
3
    DB: DrawingBackend,
253
3
    CT: plotters::coord::CoordTranslate<From = (SegmentValue<i32>, f64)>,
254
{
255
    // Stacked-bar accumulators per x-slot and sign.
256
3
    let mut pos_acc: Vec<f64> = vec![0.0; x_labels.len()];
257
3
    let mut neg_acc: Vec<f64> = vec![0.0; x_labels.len()];
258

            
259
3
    for (s_idx, series) in spec.series.iter().enumerate() {
260
3
        let color = color_for(s_idx);
261
3
        let series_label = series.label.clone();
262
6
        for (x_idx, label) in x_labels.iter().enumerate() {
263
9
            let Some(point) = series.points.iter().find(|p| &p.x == label) else {
264
                continue;
265
            };
266
6
            let y = point.y_f64();
267
6
            if y == 0.0 {
268
                continue;
269
6
            }
270

            
271
6
            let (base, top) = if stacked {
272
2
                let acc = if y >= 0.0 {
273
2
                    &mut pos_acc[x_idx]
274
                } else {
275
                    &mut neg_acc[x_idx]
276
                };
277
2
                let start = *acc;
278
2
                *acc += y;
279
2
                (start, *acc)
280
            } else {
281
4
                (0.0, y)
282
            };
283

            
284
            // Grouped bars: each slot has `sub_count` sub-slots,
285
            // series `s_idx` occupies the `s_idx`-th one. Stacked
286
            // bars pass `sub_count = 1` so every series shares the
287
            // single slot.
288
6
            let x_left = i32::try_from(x_idx).unwrap_or(0) * sub_count
289
6
                + i32::try_from(s_idx)
290
6
                    .unwrap_or(0)
291
6
                    .min(sub_count.saturating_sub(1));
292
6
            chart.draw_series(std::iter::once(Rectangle::new(
293
6
                [
294
6
                    (SegmentValue::Exact(x_left), base),
295
6
                    (SegmentValue::Exact(x_left + 1), top),
296
6
                ],
297
6
                color.filled(),
298
            )))?;
299
        }
300

            
301
        // Synthetic empty series so the legend picks up this colour.
302
3
        chart
303
3
            .draw_series(std::iter::empty::<Rectangle<(SegmentValue<i32>, f64)>>())?
304
3
            .label(series_label)
305
3
            .legend(move |(x, y)| Rectangle::new([(x, y - 5), (x + 10, y + 5)], color.filled()));
306
    }
307

            
308
3
    Ok(())
309
3
}
310

            
311
7
fn draw_notes<DB>(area: &DrawingArea<DB, Shift>, spec: &ChartSpec) -> DrawResult<DB>
312
7
where
313
7
    DB: DrawingBackend,
314
{
315
7
    if spec.notes.is_empty() || area.dim_in_pixel().1 == 0 {
316
7
        return Ok(());
317
    }
318
    let style = ("sans-serif", 11).into_text_style(area);
319
    for (i, note) in spec.notes.iter().enumerate() {
320
        let y = 2 + i32::try_from(i).unwrap_or(0) * 14;
321
        area.draw_text(note, &style, (8, y))?;
322
    }
323
    Ok(())
324
7
}