Skip to main content

weirdnet_render/
chart.rs

1//! Line charts: a grey frame and one colored line per series, scaled to the finite values.
2use crate::canvas::Canvas;
3use crate::{Chart, Error, Limits, Result, check_size};
4
5const COLORS: [[u8; 3]; 6] = [[31, 119, 180], [214, 39, 40], [44, 160, 44], [255, 127, 14], [148, 103, 189], [140, 86, 75]];
6const NAMES: [&str; 6] = ["blue", "red", "green", "orange", "purple", "brown"];
7
8fn check(c: &Chart, l: &Limits) -> Result<()> {
9    if c.series.len() > l.max_series {
10        return Err(Error::TooLarge(format!("{} series (at most {})", c.series.len(), l.max_series)));
11    }
12    let most = c.series.iter().map(|s| s.values.len()).chain([c.x.len()]).max().unwrap_or(0);
13    if most > l.max_points {
14        return Err(Error::TooLarge(format!("{most} points (at most {})", l.max_points)));
15    }
16    Ok(())
17}
18
19/// Draw the series over x as a `width`×`height` PNG. [`Error::Empty`] when no value (or no
20/// x) is there to draw.
21pub fn chart_png(c: &Chart, width: usize, height: usize, limits: &Limits) -> Result<Vec<u8>> {
22    check_size(width, height, limits)?;
23    check(c, limits)?;
24    let (x, lines, w, h) = (&c.x, &c.series, width, height);
25    let xs: Vec<f64> = x.iter().enumerate().map(|(i, v)| v.filter(|v| v.is_finite()).unwrap_or(i as f64)).collect();
26    let all: Vec<f64> = lines.iter().flat_map(|l| l.values.iter().flatten().copied()).filter(|v| v.is_finite()).collect();
27    if all.is_empty() || xs.is_empty() {
28        return Err(Error::Empty);
29    }
30    let (lo, hi) = all.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), v| (a.min(*v), b.max(*v)));
31    let (xl, xh) = xs.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), v| (a.min(*v), b.max(*v)));
32    let (hi, lo) = if hi > lo { (hi, lo) } else { (hi + 1.0, lo - 1.0) };
33    let xh = if xh > xl { xh } else { xl + 1.0 };
34    let mut c = Canvas::new(w, h, [255; 3]);
35    let m = 12i64;
36    let (pw, ph) = (w as i64 - 2 * m, h as i64 - 2 * m);
37    let grey = [200, 200, 200];
38    for (a, b) in [((m, m), (m + pw, m)), ((m, m + ph), (m + pw, m + ph)), ((m, m), (m, m + ph)), ((m + pw, m), (m + pw, m + ph))] {
39        c.line(a, b, grey);
40    }
41    for (k, l) in lines.iter().enumerate() {
42        let col = COLORS[k % COLORS.len()];
43        let mut prev: Option<(i64, i64)> = None;
44        for (i, v) in l.values.iter().enumerate() {
45            let Some(v) = v.filter(|v| v.is_finite()) else {
46                prev = None;
47                continue;
48            };
49            let px = m + ((xs.get(i).copied().unwrap_or(i as f64) - xl) / (xh - xl) * pw as f64).round() as i64;
50            let py = m + ph - ((v - lo) / (hi - lo) * ph as f64).round() as i64;
51            match prev {
52                Some(p) => c.line(p, (px, py), col),
53                None => c.set(px, py, col),
54            }
55            prev = Some((px, py));
56        }
57    }
58    c.png()
59}
60
61/// The legend: series names with their colors' names, in drawing order.
62pub fn legend(c: &Chart) -> String {
63    c.series.iter().enumerate().map(|(i, l)| format!("{} ({})", l.name, NAMES[i % NAMES.len()])).collect::<Vec<_>>().join(", ")
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69    use crate::Series;
70
71    fn decode(b: &[u8]) -> (u32, u32, Vec<u8>) {
72        let mut r = ::png::Decoder::new(std::io::Cursor::new(b)).read_info().unwrap();
73        let mut buf = vec![0; r.output_buffer_size().unwrap()];
74        let i = r.next_frame(&mut buf).unwrap();
75        buf.truncate(i.buffer_size());
76        (i.width, i.height, buf)
77    }
78
79    fn sample() -> Chart {
80        Chart {
81            x: vec![Some(0.0), Some(1.0), Some(2.0)],
82            series: vec![
83                Series { name: "a".into(), values: vec![Some(1.0), None, Some(3.0)] },
84                Series { name: "b".into(), values: vec![Some(2.0); 3] },
85            ],
86        }
87    }
88
89    #[test]
90    fn charts_draw_as_png() {
91        let l = Limits::default();
92        let png = chart_png(&sample(), 200, 100, &l).unwrap();
93        let (w, h, px) = decode(&png);
94        assert_eq!((w, h), (200, 100));
95        let at = |x: usize, y: usize| px[(y * 200 + x) * 3..][..3].to_vec();
96        assert_eq!(at(0, 0), vec![255, 255, 255], "background");
97        assert_eq!(at(12, 12), vec![200, 200, 200], "frame corner");
98        // series b is flat at 2, the middle of 1..3: a red line across the middle
99        assert_eq!(at(100, 50), vec![214, 39, 40]);
100        // series a starts at its low (1) bottom left, in blue
101        assert_eq!(at(12, 88), vec![31, 119, 180]);
102        assert_eq!(legend(&sample()), "a (blue), b (red)");
103    }
104
105    #[test]
106    fn nothing_finite_is_empty_and_limits_hold() {
107        let l = Limits::default();
108        let none =
109            Chart { x: vec![Some(0.0); 3], series: vec![Series { name: "n".into(), values: vec![None, Some(f64::NAN), Some(f64::INFINITY)] }] };
110        assert_eq!(chart_png(&none, 10, 10, &l), Err(Error::Empty));
111        assert_eq!(chart_png(&Chart::default(), 10, 10, &l), Err(Error::Empty));
112        assert!(matches!(chart_png(&sample(), 0, 10, &l), Err(Error::Invalid(_))));
113        assert!(matches!(chart_png(&sample(), 9_000, 10, &l), Err(Error::TooLarge(_))));
114        let small = Limits { max_pixels: 100, ..l };
115        assert!(matches!(chart_png(&sample(), 20, 20, &small), Err(Error::TooLarge(_))));
116        let few = Limits { max_points: 2, ..l };
117        assert!(matches!(chart_png(&sample(), 20, 20, &few), Err(Error::TooLarge(_))));
118        let one = Limits { max_series: 1, ..l };
119        assert!(matches!(chart_png(&sample(), 20, 20, &one), Err(Error::TooLarge(_))));
120    }
121
122    #[test]
123    fn missing_x_values_fall_back_to_the_index() {
124        let l = Limits::default();
125        let mut c = sample();
126        let a = chart_png(&c, 64, 48, &l).unwrap();
127        c.x = vec![None, Some(f64::NAN), Some(2.0)];
128        assert_eq!(chart_png(&c, 64, 48, &l).unwrap(), a);
129    }
130}