Skip to main content

rustlavel_chart/
series.rs

1//! One line, or one set of bars.
2
3use crate::palette::Palette;
4use rustlavel_core::Json;
5
6/// A named run of numbers.
7///
8/// A gap in the data is `None`, not zero: Chart.js breaks the line there,
9/// which is what "we have no reading for March" looks like. Zero is a reading.
10#[derive(Debug, Clone, PartialEq)]
11pub struct Series {
12    pub label: String,
13    pub values: Vec<Option<f64>>,
14    /// Overrides the palette. `None` lets the chart assign one by position,
15    /// which is what keeps two charts on a page agreeing about which colour
16    /// means which series.
17    pub colour: Option<String>,
18    /// Bars and lines only: stack with every other series naming the same
19    /// group.
20    pub stack: Option<String>,
21    /// Lines only: fill the area beneath.
22    pub filled: bool,
23    /// Lines only. 0 is straight segments; Chart.js's own default is 0.4,
24    /// which invents a curve between points nobody measured.
25    pub tension: f64,
26    /// Plot against the right-hand axis. For a second unit — rupiah on the
27    /// left, a count on the right.
28    pub right_axis: bool,
29}
30
31impl Series {
32    pub fn new(label: impl Into<String>, values: impl IntoIterator<Item = f64>) -> Series {
33        Series {
34            label: label.into(),
35            values: values.into_iter().map(Some).collect(),
36            colour: None,
37            stack: None,
38            filled: false,
39            tension: 0.0,
40            right_axis: false,
41        }
42    }
43
44    /// With gaps. `None` breaks the line rather than drawing it through zero.
45    pub fn sparse(label: impl Into<String>, values: impl IntoIterator<Item = Option<f64>>) -> Series {
46        Series { values: values.into_iter().collect(), ..Series::new(label, []) }
47    }
48
49    pub fn colour(mut self, colour: impl Into<String>) -> Series {
50        self.colour = Some(colour.into());
51        self
52    }
53
54    pub fn stack(mut self, group: impl Into<String>) -> Series {
55        self.stack = Some(group.into());
56        self
57    }
58
59    pub fn filled(mut self) -> Series {
60        self.filled = true;
61        self
62    }
63
64    /// Round the corners between points. Only honest when the thing measured
65    /// really is continuous.
66    pub fn smooth(mut self) -> Series {
67        self.tension = 0.35;
68        self
69    }
70
71    pub fn right_axis(mut self) -> Series {
72        self.right_axis = true;
73        self
74    }
75
76    pub fn len(&self) -> usize {
77        self.values.len()
78    }
79
80    pub fn is_empty(&self) -> bool {
81        self.values.is_empty()
82    }
83
84    /// The Chart.js dataset object. `index` picks the palette entry when the
85    /// series named no colour.
86    pub(crate) fn to_json(&self, index: usize, palette: &Palette, per_point: bool) -> Json {
87        let colour = self.colour.clone().unwrap_or_else(|| palette.at(index).to_string());
88        let data = Json::Array(
89            self.values.iter().map(|value| value.map_or(Json::Null, Json::from)).collect(),
90        );
91
92        // A pie or doughnut has one dataset and a colour per slice; everything
93        // else has one colour per dataset.
94        let background = if per_point {
95            Json::Array((0..self.values.len()).map(|n| Json::from(palette.at(n))).collect())
96        } else if self.filled {
97            Json::from(palette.translucent(&colour).as_str())
98        } else {
99            Json::from(colour.as_str())
100        };
101
102        let mut fields = vec![
103            ("label", Json::from(self.label.as_str())),
104            ("data", data),
105            ("borderColor", Json::from(colour.as_str())),
106            ("backgroundColor", background),
107            ("borderWidth", Json::from(2)),
108            ("fill", Json::from(self.filled)),
109            ("tension", Json::from(self.tension)),
110            // Draw the line across a `None` rather than leaving a gap? No.
111            ("spanGaps", Json::from(false)),
112        ];
113        if let Some(stack) = &self.stack {
114            fields.push(("stack", Json::from(stack.as_str())));
115        }
116        if self.right_axis {
117            fields.push(("yAxisID", Json::from("right")));
118        }
119        Json::object(fields)
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn a_gap_is_null_and_is_not_bridged() {
129        let series = Series::sparse("visits", [Some(1.0), None, Some(3.0)]);
130        let json = series.to_json(0, &Palette::default(), false);
131
132        let data = json.get("data").and_then(Json::as_array).unwrap();
133        assert_eq!(data.len(), 3);
134        assert!(data[1].is_null(), "a missing reading became a number");
135        assert_eq!(
136            json.get("spanGaps").and_then(Json::as_bool),
137            Some(false),
138            "the line would be drawn straight through a month nobody measured"
139        );
140    }
141
142    /// Chart.js defaults `tension` to 0.4 — a curve through points that were
143    /// never measured. A reading is a reading; the default here is straight.
144    #[test]
145    fn lines_are_straight_unless_asked_to_be_smooth() {
146        assert_eq!(Series::new("a", [1.0]).tension, 0.0);
147        assert_eq!(Series::new("a", [1.0]).smooth().tension, 0.35);
148    }
149
150    #[test]
151    fn a_named_colour_beats_the_palette() {
152        let palette = Palette::default();
153        let by_position = Series::new("a", [1.0]).to_json(1, &palette, false);
154        assert_eq!(by_position.get("borderColor").and_then(Json::as_str), Some(palette.at(1)));
155
156        let named = Series::new("a", [1.0]).colour("#123456").to_json(1, &palette, false);
157        assert_eq!(named.get("borderColor").and_then(Json::as_str), Some("#123456"));
158    }
159
160    #[test]
161    fn a_slice_chart_colours_each_point_rather_than_the_set() {
162        let json = Series::new("share", [1.0, 2.0, 3.0]).to_json(0, &Palette::default(), true);
163        let background = json.get("backgroundColor").and_then(Json::as_array).unwrap();
164        assert_eq!(background.len(), 3, "every slice would have been the same colour");
165    }
166}