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
10
fn color_for(index: usize) -> RGBColor {
23
10
    PALETTE[index % PALETTE.len()]
24
10
}
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
11
pub fn draw_on<DB>(root: &DrawingArea<DB, Shift>, spec: &ChartSpec) -> DrawResult<DB>
38
11
where
39
11
    DB: DrawingBackend,
40
{
41
    // Before any text is drawn: without the embedded face plotters would look for
42
    // a system font, which a static musl binary cannot even attempt (see font.rs).
43
    #[cfg(not(target_arch = "wasm32"))]
44
11
    crate::font::ensure_registered();
45

            
46
11
    root.fill(&WHITE)?;
47

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

            
61
    // Collect the union of x-labels in the order they first appear.
62
11
    let mut x_labels: Vec<String> = Vec::new();
63
11
    for series in &spec.series {
64
20
        for point in &series.points {
65
20
            if !x_labels.iter().any(|l| l == &point.x) {
66
20
                x_labels.push(point.x.clone());
67
20
            }
68
        }
69
    }
70

            
71
11
    if x_labels.is_empty() || spec.series.is_empty() {
72
1
        chart_area.draw_text(
73
1
            &spec.title,
74
1
            &("sans-serif", 16).into_text_style(&chart_area),
75
1
            (10, 20),
76
        )?;
77
1
        draw_notes(&notes_area, spec)?;
78
1
        return Ok(());
79
10
    }
80

            
81
10
    let (y_min, y_max) = y_range(spec);
82

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

            
92
10
    let mut chart = ChartBuilder::on(&chart_area)
93
10
        .caption(spec.title.as_str(), ("sans-serif", 18))
94
10
        .x_label_area_size(36u32)
95
10
        .y_label_area_size(60u32)
96
10
        .margin(8u32)
97
10
        .build_cartesian_2d((0i32..x_count).into_segmented(), y_min..y_max)?;
98

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

            
119
10
    match spec.kind {
120
4
        ChartKind::Line => draw_line(&mut chart, spec, &x_labels, sub_count)?,
121
4
        ChartKind::Bar => draw_bars(&mut chart, spec, &x_labels, false, sub_count)?,
122
2
        ChartKind::StackedBar => draw_bars(&mut chart, spec, &x_labels, true, 1)?,
123
    }
124

            
125
10
    chart
126
10
        .configure_series_labels()
127
10
        .border_style(BLACK.mix(0.2))
128
10
        .background_style(WHITE.mix(0.8))
129
10
        .label_font(("sans-serif", 11))
130
10
        .draw()?;
131

            
132
10
    draw_notes(&notes_area, spec)?;
133
10
    Ok(())
134
11
}
135

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

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

            
200
10
    let span = (max - min).abs().max(1.0);
201
10
    let pad = span * 0.05;
202
10
    (min - pad, max + pad)
203
10
}
204

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

            
234
4
        chart
235
4
            .draw_series(LineSeries::new(points.clone(), color.stroke_width(2)))?
236
4
            .label(series.label.as_str())
237
4
            .legend(move |(x, y)| {
238
4
                PathElement::new(vec![(x, y), (x + 20, y)], color.stroke_width(2))
239
4
            });
240
4
        chart.draw_series(
241
4
            points
242
4
                .into_iter()
243
8
                .map(|p| Circle::new(p, 3, color.filled())),
244
        )?;
245
    }
246
4
    Ok(())
247
4
}
248

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

            
264
6
    for (s_idx, series) in spec.series.iter().enumerate() {
265
6
        let color = color_for(s_idx);
266
6
        let series_label = series.label.clone();
267
12
        for (x_idx, label) in x_labels.iter().enumerate() {
268
18
            let Some(point) = series.points.iter().find(|p| &p.x == label) else {
269
                continue;
270
            };
271
12
            let y = point.y_f64();
272
12
            if y == 0.0 {
273
                continue;
274
12
            }
275

            
276
12
            let (base, top) = if stacked {
277
4
                let acc = if y >= 0.0 {
278
4
                    &mut pos_acc[x_idx]
279
                } else {
280
                    &mut neg_acc[x_idx]
281
                };
282
4
                let start = *acc;
283
4
                *acc += y;
284
4
                (start, *acc)
285
            } else {
286
8
                (0.0, y)
287
            };
288

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

            
306
        // Synthetic empty series so the legend picks up this colour.
307
6
        chart
308
6
            .draw_series(std::iter::empty::<Rectangle<(SegmentValue<i32>, f64)>>())?
309
6
            .label(series_label)
310
6
            .legend(move |(x, y)| Rectangle::new([(x, y - 5), (x + 10, y + 5)], color.filled()));
311
    }
312

            
313
6
    Ok(())
314
6
}
315

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