Skip to main content

rustlavel_chart/
chart.rs

1//! The configuration Chart.js reads.
2
3use crate::palette::Palette;
4use crate::series::Series;
5use rustlavel_core::Json;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum Kind {
9    Line,
10    Bar,
11    /// Bars lying down. For category names too long to stand under an axis —
12    /// which is most category names.
13    HorizontalBar,
14    Doughnut,
15    Pie,
16    Radar,
17    /// Points only. `Chart::scatter` takes pairs rather than labels.
18    Scatter,
19}
20
21impl Kind {
22    /// What Chart.js calls it. `HorizontalBar` is a `bar` turned on its side by
23    /// an option, not a type of its own — it was one in Chart.js 2 and the name
24    /// still gets written by people who remember that.
25    pub fn as_str(self) -> &'static str {
26        match self {
27            Kind::Line => "line",
28            Kind::Bar | Kind::HorizontalBar => "bar",
29            Kind::Doughnut => "doughnut",
30            Kind::Pie => "pie",
31            Kind::Radar => "radar",
32            Kind::Scatter => "scatter",
33        }
34    }
35
36    /// Whether one dataset is coloured per point rather than as a whole.
37    pub fn colours_each_point(self) -> bool {
38        matches!(self, Kind::Doughnut | Kind::Pie)
39    }
40
41    /// Whether the chart has x and y axes to configure at all.
42    pub fn has_axes(self) -> bool {
43        matches!(self, Kind::Line | Kind::Bar | Kind::HorizontalBar | Kind::Scatter)
44    }
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum Legend {
49    Top,
50    Right,
51    Bottom,
52    Left,
53    Hidden,
54}
55
56impl Legend {
57    fn as_str(self) -> &'static str {
58        match self {
59            Legend::Top => "top",
60            Legend::Right => "right",
61            Legend::Bottom => "bottom",
62            Legend::Left => "left",
63            Legend::Hidden => "top",
64        }
65    }
66}
67
68/// One axis.
69#[derive(Debug, Clone, Default, PartialEq)]
70pub struct Axis {
71    pub title: Option<String>,
72    /// Start the scale at zero.
73    ///
74    /// **On by default for the value axis, and that is a judgement.** A bar
75    /// chart whose axis starts at 95 makes a 1% difference look like a
76    /// doubling. Charts that lie usually lie here. Call [`Axis::zoomed`] when
77    /// the reading genuinely lives in a narrow band far from zero — a
78    /// temperature, a latency percentile — and the shape is the point.
79    pub begin_at_zero: bool,
80    pub min: Option<f64>,
81    pub max: Option<f64>,
82    /// `"Rp "`, `"$"`.
83    pub prefix: Option<String>,
84    /// `"%"`, `" ms"`.
85    pub suffix: Option<String>,
86    pub stacked: bool,
87    pub hidden: bool,
88}
89
90impl Axis {
91    pub fn new() -> Axis {
92        Axis { begin_at_zero: true, ..Axis::default() }
93    }
94
95    pub fn title(mut self, title: impl Into<String>) -> Axis {
96        self.title = Some(title.into());
97        self
98    }
99
100    /// Let the scale start where the data does. Say why in the surrounding
101    /// copy; a reader cannot see an axis they did not look at.
102    pub fn zoomed(mut self) -> Axis {
103        self.begin_at_zero = false;
104        self
105    }
106
107    pub fn range(mut self, min: f64, max: f64) -> Axis {
108        self.min = Some(min);
109        self.max = Some(max);
110        self.begin_at_zero = false;
111        self
112    }
113
114    pub fn prefix(mut self, prefix: impl Into<String>) -> Axis {
115        self.prefix = Some(prefix.into());
116        self
117    }
118
119    pub fn suffix(mut self, suffix: impl Into<String>) -> Axis {
120        self.suffix = Some(suffix.into());
121        self
122    }
123
124    pub fn stacked(mut self) -> Axis {
125        self.stacked = true;
126        self
127    }
128
129    pub fn hidden(mut self) -> Axis {
130        self.hidden = true;
131        self
132    }
133
134    fn to_json(&self, horizontal_categories: bool) -> Json {
135        let mut fields = vec![
136            ("display", Json::from(!self.hidden)),
137            ("stacked", Json::from(self.stacked)),
138        ];
139        if !horizontal_categories {
140            fields.push(("beginAtZero", Json::from(self.begin_at_zero)));
141        }
142        if let Some(min) = self.min {
143            fields.push(("min", Json::from(min)));
144        }
145        if let Some(max) = self.max {
146            fields.push(("max", Json::from(max)));
147        }
148        if let Some(title) = &self.title {
149            fields.push((
150                "title",
151                Json::object([("display", Json::from(true)), ("text", Json::from(title.as_str()))]),
152            ));
153        }
154        // Read by the init script, which installs a tick callback. Chart.js
155        // wants a function here and a function cannot survive JSON.
156        if self.prefix.is_some() || self.suffix.is_some() {
157            fields.push((
158                "rustlavelTicks",
159                Json::object([
160                    ("prefix", self.prefix.as_deref().map_or(Json::Null, Json::from)),
161                    ("suffix", self.suffix.as_deref().map_or(Json::Null, Json::from)),
162                ]),
163            ));
164        }
165        Json::object(fields)
166    }
167}
168
169/// A chart, as a builder.
170#[derive(Debug, Clone, PartialEq)]
171pub struct Chart {
172    pub kind: Kind,
173    pub title: Option<String>,
174    pub labels: Vec<String>,
175    pub series: Vec<Series>,
176    pub palette: Palette,
177    pub legend: Legend,
178    pub x: Axis,
179    pub y: Axis,
180    /// A second value axis on the right, for a series in another unit.
181    pub right: Option<Axis>,
182    /// Let the canvas take the height its container gives it.
183    pub aspect_ratio: Option<f64>,
184    /// Points that are pairs rather than readings against a label.
185    points: Vec<(String, Vec<(f64, f64)>)>,
186}
187
188impl Chart {
189    pub fn new(kind: Kind) -> Chart {
190        Chart {
191            kind,
192            title: None,
193            labels: Vec::new(),
194            series: Vec::new(),
195            palette: Palette::default(),
196            legend: Legend::Top,
197            x: Axis { begin_at_zero: false, ..Axis::default() },
198            y: Axis::new(),
199            right: None,
200            aspect_ratio: None,
201            points: Vec::new(),
202        }
203    }
204
205    pub fn line(title: impl Into<String>) -> Chart {
206        Chart::new(Kind::Line).title(title)
207    }
208
209    pub fn bar(title: impl Into<String>) -> Chart {
210        Chart::new(Kind::Bar).title(title)
211    }
212
213    pub fn horizontal_bar(title: impl Into<String>) -> Chart {
214        Chart::new(Kind::HorizontalBar).title(title)
215    }
216
217    pub fn doughnut(title: impl Into<String>) -> Chart {
218        Chart::new(Kind::Doughnut).title(title)
219    }
220
221    pub fn pie(title: impl Into<String>) -> Chart {
222        Chart::new(Kind::Pie).title(title)
223    }
224
225    /// Points in two dimensions, which have no category labels at all.
226    pub fn scatter(title: impl Into<String>, label: impl Into<String>, points: impl IntoIterator<Item = (f64, f64)>) -> Chart {
227        let mut chart = Chart::new(Kind::Scatter).title(title);
228        chart.points.push((label.into(), points.into_iter().collect()));
229        chart.x = Axis::new();
230        chart
231    }
232
233    pub fn title(mut self, title: impl Into<String>) -> Chart {
234        self.title = Some(title.into());
235        self
236    }
237
238    /// Drop the title from the chart itself — for a card whose heading already
239    /// says it, so a screen reader does not read it twice.
240    pub fn untitled(mut self) -> Chart {
241        self.title = None;
242        self
243    }
244
245    pub fn labels(mut self, labels: impl IntoIterator<Item = impl Into<String>>) -> Chart {
246        self.labels = labels.into_iter().map(Into::into).collect();
247        self
248    }
249
250    pub fn series(mut self, series: Series) -> Chart {
251        self.series.push(series);
252        self
253    }
254
255    /// A single unnamed run — the common case for a doughnut or a sparkline.
256    pub fn values(self, label: impl Into<String>, values: impl IntoIterator<Item = f64>) -> Chart {
257        self.series(Series::new(label, values))
258    }
259
260    pub fn palette(mut self, palette: Palette) -> Chart {
261        self.palette = palette;
262        self
263    }
264
265    pub fn legend(mut self, legend: Legend) -> Chart {
266        self.legend = legend;
267        self
268    }
269
270    pub fn x(mut self, axis: Axis) -> Chart {
271        self.x = axis;
272        self
273    }
274
275    pub fn y(mut self, axis: Axis) -> Chart {
276        self.y = axis;
277        self
278    }
279
280    /// A second value axis on the right, for [`Series::right_axis`].
281    pub fn right(mut self, axis: Axis) -> Chart {
282        self.right = Some(axis);
283        self
284    }
285
286    /// Stack every series, on both axes that can be stacked.
287    pub fn stacked(mut self) -> Chart {
288        self.x = Axis { stacked: true, ..self.x };
289        self.y = Axis { stacked: true, ..self.y };
290        self
291    }
292
293    pub fn aspect_ratio(mut self, ratio: f64) -> Chart {
294        self.aspect_ratio = Some(ratio);
295        self
296    }
297
298    /// Every series is as long as the labels, and there is something to draw.
299    ///
300    /// Not enforced — a chart is not worth refusing to render a page over —
301    /// but a dashboard can call it in a test, and the first line of a
302    /// mismatched chart is a silently truncated one.
303    pub fn problems(&self) -> Vec<String> {
304        let mut problems = Vec::new();
305        if self.series.is_empty() && self.points.is_empty() {
306            problems.push("the chart has no series, so it will draw an empty grid".to_string());
307        }
308        if self.kind != Kind::Scatter {
309            for series in &self.series {
310                if series.len() != self.labels.len() {
311                    problems.push(format!(
312                        "series `{}` has {} values against {} labels; Chart.js draws the shorter of the two",
313                        series.label,
314                        series.len(),
315                        self.labels.len()
316                    ));
317                }
318            }
319        }
320        if self.kind.colours_each_point() && self.series.len() > 1 {
321            problems.push(format!(
322                "a {} shows one set of slices; {} series were given and the rest will be drawn as rings nobody can read",
323                self.kind.as_str(),
324                self.series.len()
325            ));
326        }
327        problems
328    }
329
330    /// The configuration object, ready for a `data-chart` attribute.
331    pub fn to_json(&self) -> Json {
332        let datasets: Vec<Json> = if self.kind == Kind::Scatter {
333            self.points
334                .iter()
335                .enumerate()
336                .map(|(index, (label, points))| {
337                    Json::object([
338                        ("label", Json::from(label.as_str())),
339                        (
340                            "data",
341                            Json::Array(
342                                points
343                                    .iter()
344                                    .map(|(x, y)| {
345                                        Json::object([("x", Json::from(*x)), ("y", Json::from(*y))])
346                                    })
347                                    .collect(),
348                            ),
349                        ),
350                        ("borderColor", Json::from(self.palette.at(index))),
351                        ("backgroundColor", Json::from(self.palette.at(index))),
352                    ])
353                })
354                .collect()
355        } else {
356            self.series
357                .iter()
358                .enumerate()
359                .map(|(index, series)| series.to_json(index, &self.palette, self.kind.colours_each_point()))
360                .collect()
361        };
362
363        let mut plugins = vec![(
364            "legend",
365            Json::object([
366                ("display", Json::from(self.legend != Legend::Hidden)),
367                ("position", Json::from(self.legend.as_str())),
368            ]),
369        )];
370        if let Some(title) = &self.title {
371            plugins.push((
372                "title",
373                Json::object([("display", Json::from(true)), ("text", Json::from(title.as_str()))]),
374            ));
375        }
376
377        let mut options = vec![
378            ("responsive", Json::from(true)),
379            ("maintainAspectRatio", Json::from(self.aspect_ratio.is_some())),
380            ("plugins", Json::object(plugins)),
381        ];
382        if let Some(ratio) = self.aspect_ratio {
383            options.push(("aspectRatio", Json::from(ratio)));
384        }
385        if self.kind == Kind::HorizontalBar {
386            options.push(("indexAxis", Json::from("y")));
387        }
388        if self.kind.has_axes() {
389            // Turned on its side, the categories are on y and the values on x.
390            let flipped = self.kind == Kind::HorizontalBar;
391            let (category, value) = if flipped { (&self.y, &self.x) } else { (&self.x, &self.y) };
392            let mut scales = vec![
393                ("x", if flipped { value.to_json(false) } else { category.to_json(true) }),
394                ("y", if flipped { category.to_json(true) } else { value.to_json(false) }),
395            ];
396            if let Some(right) = &self.right {
397                let mut axis = right.to_json(false);
398                if let Json::Object(fields) = &mut axis {
399                    fields.insert("position".to_string(), Json::from("right"));
400                    // Two grids drawn over each other is a moiré pattern, not
401                    // a chart.
402                    fields.insert(
403                        "grid".to_string(),
404                        Json::object([("drawOnChartArea", Json::from(false))]),
405                    );
406                }
407                scales.push(("right", axis));
408            }
409            options.push(("scales", Json::object(scales)));
410        }
411
412        Json::object([
413            ("type", Json::from(self.kind.as_str())),
414            (
415                "data",
416                Json::object([
417                    ("labels", Json::Array(self.labels.iter().map(|l| Json::from(l.as_str())).collect())),
418                    ("datasets", Json::Array(datasets)),
419                ]),
420            ),
421            ("options", Json::object(options)),
422        ])
423    }
424}
425
426impl From<Chart> for Json {
427    fn from(chart: Chart) -> Json {
428        chart.to_json()
429    }
430}
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435
436    fn revenue() -> Chart {
437        Chart::line("Revenue")
438            .labels(["Jan", "Feb", "Mar"])
439            .series(Series::new("2026", [1.0, 2.0, 3.0]))
440    }
441
442    #[test]
443    fn the_shape_is_what_chart_js_reads() {
444        let json = revenue().to_json();
445        assert_eq!(json.get("type").and_then(Json::as_str), Some("line"));
446        assert_eq!(
447            json.get("data.labels").and_then(Json::as_array).map(|l| l.len()),
448            Some(3)
449        );
450        assert_eq!(
451            json.get("data.datasets").and_then(Json::as_array).map(|d| d.len()),
452            Some(1)
453        );
454        assert_eq!(json.get("options.plugins.title.text").and_then(Json::as_str), Some("Revenue"));
455    }
456
457    /// A bar chart whose axis starts at 95 turns a 1% difference into a
458    /// doubling. The default has to be the honest one.
459    #[test]
460    fn the_value_axis_starts_at_zero_unless_asked_otherwise() {
461        let json = revenue().to_json();
462        assert_eq!(json.get("options.scales.y.beginAtZero").and_then(Json::as_bool), Some(true));
463
464        let zoomed = revenue().y(Axis::new().zoomed()).to_json();
465        assert_eq!(zoomed.get("options.scales.y.beginAtZero").and_then(Json::as_bool), Some(false));
466    }
467
468    #[test]
469    fn a_horizontal_bar_is_a_bar_with_the_axes_swapped() {
470        let chart = Chart::horizontal_bar("By channel")
471            .labels(["Virtual account", "QRIS"])
472            .values("count", [10.0, 20.0])
473            .y(Axis::new().title("Channel"))
474            .x(Axis::new().prefix("Rp "));
475        let json = chart.to_json();
476
477        assert_eq!(json.get("type").and_then(Json::as_str), Some("bar"));
478        assert_eq!(json.get("options.indexAxis").and_then(Json::as_str), Some("y"));
479        // The category axis is y, and it carries the axis the caller set on y.
480        assert_eq!(json.get("options.scales.y.title.text").and_then(Json::as_str), Some("Channel"));
481        // The value axis is x, and it is the one that begins at zero.
482        assert_eq!(json.get("options.scales.x.beginAtZero").and_then(Json::as_bool), Some(true));
483        assert_eq!(
484            json.get("options.scales.x.rustlavelTicks.prefix").and_then(Json::as_str),
485            Some("Rp ")
486        );
487    }
488
489    #[test]
490    fn a_second_axis_does_not_draw_a_second_grid() {
491        let json = revenue()
492            .series(Series::new("orders", [1.0, 2.0, 3.0]).right_axis())
493            .right(Axis::new().title("Orders"))
494            .to_json();
495
496        assert_eq!(json.get("options.scales.right.position").and_then(Json::as_str), Some("right"));
497        assert_eq!(
498            json.get("options.scales.right.grid.drawOnChartArea").and_then(Json::as_bool),
499            Some(false)
500        );
501        assert_eq!(
502            json.get("data.datasets").and_then(Json::as_array).unwrap()[1]
503                .get("yAxisID")
504                .and_then(Json::as_str),
505            Some("right")
506        );
507    }
508
509    #[test]
510    fn a_scatter_carries_pairs_rather_than_labels() {
511        let json = Chart::scatter("Latency", "p95", [(1.0, 20.0), (2.0, 24.0)]).to_json();
512        let points = json.get("data.datasets").and_then(Json::as_array).unwrap()[0]
513            .get("data")
514            .and_then(Json::as_array)
515            .unwrap()
516            .to_vec();
517        assert_eq!(points.len(), 2);
518        assert_eq!(points[1].get("x").and_then(Json::as_f64), Some(2.0));
519        assert_eq!(points[1].get("y").and_then(Json::as_f64), Some(24.0));
520    }
521
522    #[test]
523    fn problems_names_the_mismatch_rather_than_drawing_a_short_line() {
524        let chart = Chart::line("x").labels(["Jan", "Feb", "Mar"]).series(Series::new("a", [1.0, 2.0]));
525        let problems = chart.problems();
526        assert_eq!(problems.len(), 1, "{problems:?}");
527        assert!(problems[0].contains("2 values against 3 labels"), "{}", problems[0]);
528
529        assert!(Chart::line("x").problems()[0].contains("no series"));
530        assert!(revenue().problems().is_empty());
531
532        let rings = Chart::doughnut("x")
533            .labels(["a"])
534            .values("one", [1.0])
535            .series(Series::new("two", [1.0]));
536        assert!(rings.problems()[0].contains("one set of slices"), "{:?}", rings.problems());
537    }
538
539    #[test]
540    fn a_hidden_legend_is_hidden_but_still_has_a_valid_position() {
541        let json = revenue().legend(Legend::Hidden).to_json();
542        assert_eq!(json.get("options.plugins.legend.display").and_then(Json::as_bool), Some(false));
543        assert_eq!(json.get("options.plugins.legend.position").and_then(Json::as_str), Some("top"));
544    }
545}