Skip to main content

tablo_core/
chart.rs

1//! A [`Chart`] of one or more [`Series`] over shared labels, and [`chart`], which draws it as SVG
2//! the server writes.
3
4use topcoat::{
5    context::Cx,
6    view::{BoxView, ViewExt, view},
7};
8
9use crate::{
10    Color, Text,
11    display::{Number, NumberFormat},
12};
13
14mod scale;
15
16use scale::Axis;
17
18/// How a [`Chart`] draws its series.
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20enum ChartKind {
21    /// A line through each series' values.
22    Line,
23    /// A bar per value, the series of one label side by side.
24    Bar,
25}
26
27impl ChartKind {
28    /// Its `data-chart` hook.
29    const fn name(self) -> &'static str {
30        match self {
31            Self::Line => "line",
32            Self::Bar => "bar",
33        }
34    }
35}
36
37/// A chart for a [`Widget`](crate::Widget)'s body: a heading, labels along the bottom, such as
38/// months, and one [`Series`] of values per thing it measures. [`chart`] draws it.
39///
40/// ```rust
41/// # use tablo_core::{Chart, Color, Series};
42/// Chart::bar(["Jan", "Feb", "Mar"])
43///     .heading("Orders")
44///     .description("Placed and refunded, per month")
45///     .series(Series::new("Placed", [120.0, 98.0, 143.0]))
46///     .series(Series::new("Refunded", [4.0, 7.0, 2.0]).color(Color::Danger));
47/// ```
48///
49/// A chart over a resource's records reads them as a widget reads any: it asks
50/// [`can_list`](crate::can_list) first and reads through [`scoped_query`](crate::scoped_query).
51#[derive(Clone, Debug)]
52pub struct Chart {
53    kind: ChartKind,
54    heading: Option<Text>,
55    description: Option<Text>,
56    labels: Vec<Text>,
57    series: Vec<Series>,
58}
59
60impl Chart {
61    /// A line chart over `labels`, oldest first when they are dates.
62    pub fn line(labels: impl IntoIterator<Item = impl Into<Text>>) -> Self {
63        Self::new(ChartKind::Line, labels)
64    }
65
66    /// A bar chart over `labels`.
67    pub fn bar(labels: impl IntoIterator<Item = impl Into<Text>>) -> Self {
68        Self::new(ChartKind::Bar, labels)
69    }
70
71    fn new(kind: ChartKind, labels: impl IntoIterator<Item = impl Into<Text>>) -> Self {
72        Self {
73            kind,
74            heading: None,
75            description: None,
76            labels: labels.into_iter().map(Into::into).collect(),
77            series: Vec::new(),
78        }
79    }
80
81    /// The title above the chart, which also captions its data table.
82    #[must_use]
83    pub fn heading(mut self, heading: impl Into<Text>) -> Self {
84        self.heading = Some(heading.into());
85        self
86    }
87
88    /// The line under the heading, such as the period the chart covers.
89    #[must_use]
90    pub fn description(mut self, description: impl Into<Text>) -> Self {
91        self.description = Some(description.into());
92        self
93    }
94
95    /// Adds a series, drawn after the ones added before it.
96    #[must_use]
97    pub fn series(mut self, series: Series) -> Self {
98        self.series.push(series);
99        self
100    }
101}
102
103/// One thing a [`Chart`] measures: a label for its legend and a value per label of the chart.
104#[derive(Clone, Debug)]
105pub struct Series {
106    label: Text,
107    values: Vec<f64>,
108    color: Option<Color>,
109}
110
111impl Series {
112    /// The series `label`, with one value per label of the chart, in the same order. A value
113    /// past the chart's last label is dropped; a missing or non-finite value leaves a gap.
114    pub fn new(label: impl Into<Text>, values: impl IntoIterator<Item = f64>) -> Self {
115        Self {
116            label: label.into(),
117            values: values.into_iter().collect(),
118            color: None,
119        }
120    }
121
122    /// The series' color; defaults to the next of primary, info, success, warning and danger, in
123    /// the order the series were added.
124    #[must_use]
125    pub const fn color(mut self, color: Color) -> Self {
126        self.color = Some(color);
127        self
128    }
129}
130
131/// The colors series take in turn when they name none.
132const PALETTE: [Color; 5] = [
133    Color::Primary,
134    Color::Info,
135    Color::Success,
136    Color::Warning,
137    Color::Danger,
138];
139
140/// The plot's drawing box: 100 units each way, stretched to the plot's size.
141const PLOT: f64 = 100.0;
142/// The share of a label's column its bars fill, the rest split either side.
143const BAR_GROUP: f64 = 0.7;
144/// The most labels the bottom axis spells; past it, it spells every `n`th.
145const MAX_AXIS_LABELS: usize = 12;
146
147/// Draws `chart` in a card: its heading and description, the plot with its axes, a legend when
148/// it has more than one series, and a table of its values for screen readers. The plot reads
149/// "No data" when no value is finite.
150///
151/// ```rust
152/// # use tablo_core::{Chart, Series};
153/// # fn body(cx: &topcoat::context::Cx) -> topcoat::view::BoxView<'_> {
154/// tablo_core::chart(
155///     cx,
156///     Chart::line(["Mon", "Tue", "Wed"])
157///         .heading("Sign-ups")
158///         .series(Series::new("Sign-ups", [3.0, 8.0, 5.0])),
159/// )
160/// # }
161/// ```
162pub fn chart(cx: &Cx, chart: Chart) -> BoxView<'_> {
163    let Chart {
164        kind,
165        heading,
166        description,
167        labels,
168        series,
169    } = chart;
170    let heading = heading.map(|heading| heading.localize(cx));
171    let description = description.map(|description| description.localize(cx));
172    let labels: Vec<String> = labels.iter().map(|label| label.localize(cx)).collect();
173    let series: Vec<Drawn> = series
174        .into_iter()
175        .enumerate()
176        .map(|(index, series)| Drawn::new(cx, series, index, labels.len()))
177        .collect();
178    let axis = Axis::over(
179        series
180            .iter()
181            .flat_map(|drawn| drawn.values.iter().flatten()),
182    );
183    let plot = axis.map(|axis| plot(cx, kind, &axis, &labels, &series));
184    let empty = crate::t!(cx, "tablo-chart-empty");
185    let legend: Vec<(String, &'static str)> = if series.len() > 1 {
186        series
187            .iter()
188            .map(|drawn| (drawn.label.clone(), drawn.color.dot_class()))
189            .collect()
190    } else {
191        Vec::new()
192    };
193    let table = data_table(cx, heading.clone(), &labels, &series);
194    // The card is the data table's containing block, and clips it: an `sr-only` table keeps its
195    // columns' minimum width, so taking the page as its containing block, it widens the page.
196    view! {
197        <section
198            data-chart=(kind.name())
199            class="relative flex flex-col gap-4 overflow-hidden rounded-xl border border-border bg-card p-5 text-card-foreground shadow-xs"
200        >
201            if heading.is_some() || description.is_some() {
202                <div class="flex flex-col gap-1">
203                    if let Some(heading) = heading {
204                        <h2 class="text-base font-semibold">(heading)</h2>
205                    }
206                    if let Some(description) = description {
207                        <p class="text-sm text-muted-foreground">(description)</p>
208                    }
209                </div>
210            }
211            if let Some(plot) = plot {
212                (plot)
213            } else {
214                <p class="py-12 text-center text-sm text-muted-foreground">(empty)</p>
215            }
216            if !legend.is_empty() {
217                <ul
218                    class="flex flex-wrap gap-x-4 gap-y-1 text-sm text-muted-foreground"
219                    aria-hidden="true"
220                >
221                    for (label, dot) in legend {
222                        <li class="flex items-center gap-1.5">
223                            <span class=(dot)></span>
224                            (label)
225                        </li>
226                    }
227                </ul>
228            }
229            (table)
230        </section>
231    }
232    .boxed()
233}
234
235/// A [`Series`] ready to draw: its label localized, one value per label of the chart, its color
236/// settled.
237struct Drawn {
238    label: String,
239    values: Vec<Option<f64>>,
240    color: Color,
241}
242
243impl Drawn {
244    fn new(cx: &Cx, series: Series, index: usize, labels: usize) -> Self {
245        let mut values: Vec<Option<f64>> = series
246            .values
247            .into_iter()
248            .take(labels)
249            .map(|value| value.is_finite().then_some(value))
250            .collect();
251        values.resize(labels, None);
252        Self {
253            label: series.label.localize(cx),
254            values,
255            color: series.color.unwrap_or(PALETTE[index % PALETTE.len()]),
256        }
257    }
258}
259
260/// The plot: tick labels up the left, the SVG, and the chart's labels along the bottom. The
261/// labels are HTML so they keep their size while the SVG stretches to the widget's width.
262fn plot<'a>(
263    cx: &'a Cx,
264    kind: ChartKind,
265    axis: &Axis,
266    labels: &[String],
267    series: &[Drawn],
268) -> BoxView<'a> {
269    let numbers = NumberFormat::of(cx);
270    let ticks: Vec<(String, String)> = axis
271        .ticks()
272        .map(|tick| {
273            (
274                axis.format(tick, &numbers),
275                coordinate(PLOT * axis.share(tick)),
276            )
277        })
278        .collect();
279    let width = PLOT / labels.len() as f64;
280    let marks: Vec<BoxView<'a>> = series
281        .iter()
282        .enumerate()
283        .map(|(index, drawn)| match kind {
284            ChartKind::Line => line(cx, axis, width, drawn),
285            ChartKind::Bar => bars(cx, axis, width, index, series.len(), drawn),
286        })
287        .collect();
288    // One hover target per label, naming every series' value there.
289    let columns: Vec<(String, String)> = labels
290        .iter()
291        .enumerate()
292        .map(|(index, label)| {
293            let values: Vec<String> = series
294                .iter()
295                .map(|drawn| {
296                    let value = drawn.values[index]
297                        .map_or_else(|| "–".to_string(), |value| format_value(value, &numbers));
298                    format!("{}: {value}", drawn.label)
299                })
300                .collect();
301            (
302                coordinate(index as f64 * width),
303                format!("{label} — {}", values.join(", ")),
304            )
305        })
306        .collect();
307    let step = labels.len().div_ceil(MAX_AXIS_LABELS);
308    let bottom: Vec<String> = labels
309        .iter()
310        .enumerate()
311        .map(|(index, label)| {
312            if index % step == 0 {
313                label.clone()
314            } else {
315                String::new()
316            }
317        })
318        .collect();
319    let bottom_columns = format!(
320        "grid-template-columns: repeat({}, minmax(0, 1fr))",
321        labels.len()
322    );
323    let column_width = coordinate(width);
324    let plot = PLOT.to_string();
325    let view_box = format!("0 0 {PLOT} {PLOT}");
326    view! {
327        <div
328            class="grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-2"
329            aria-hidden="true"
330        >
331            <div
332                class="flex h-48 flex-col justify-between text-xs text-muted-foreground tabular-nums"
333            >
334                for (tick, _) in ticks.iter().rev() {
335                    <div class="flex h-0 items-center justify-end">(tick.clone())</div>
336                }
337            </div>
338            <svg
339                class="h-48 w-full overflow-visible"
340                viewBox=(view_box)
341                preserveAspectRatio="none"
342            >
343                <g class="text-border">
344                    for (_, y) in ticks.iter() {
345                        <line
346                            x1="0"
347                            x2=(plot.clone())
348                            y1=(y.clone())
349                            y2=(y.clone())
350                            stroke="currentColor"
351                            vector-effect="non-scaling-stroke"
352                        />
353                    }
354                </g>
355                for mark in marks {
356                    (mark)
357                }
358                for (x, title) in columns {
359                    <rect
360                        x=(x)
361                        y="0"
362                        width=(column_width.clone())
363                        height=(plot.clone())
364                        fill="transparent"
365                    >
366                        <title>(title)</title>
367                    </rect>
368                }
369            </svg>
370            <div></div>
371            <div class="grid text-xs text-muted-foreground" style=(bottom_columns)>
372                for label in bottom {
373                    <span class="truncate text-center">(label)</span>
374                }
375            </div>
376        </div>
377    }
378    .boxed()
379}
380
381/// A series as a line through the middle of each label's column, broken where a value is
382/// missing. A lone value between gaps draws as a dot: a zero-length line with round caps.
383fn line<'a>(cx: &'a Cx, axis: &Axis, width: f64, drawn: &Drawn) -> BoxView<'a> {
384    let runs = runs(&drawn.values, |index, value| {
385        format!(
386            "{},{}",
387            coordinate((index as f64 + 0.5) * width),
388            coordinate(PLOT * axis.share(value))
389        )
390    });
391    let class = drawn.color.text_class();
392    view! {
393        <g class=(class)>
394            for points in runs {
395                <polyline
396                    points=(points)
397                    fill="none"
398                    stroke="currentColor"
399                    stroke-width="2"
400                    stroke-linejoin="round"
401                    stroke-linecap="round"
402                    vector-effect="non-scaling-stroke"
403                />
404            }
405        </g>
406    }
407    .boxed()
408}
409
410/// The `points` of each unbroken run of `values`, a lone value's point given twice.
411fn runs(values: &[Option<f64>], point: impl Fn(usize, f64) -> String) -> Vec<String> {
412    let mut runs: Vec<Vec<String>> = vec![Vec::new()];
413    for (index, value) in values.iter().enumerate() {
414        match value {
415            Some(value) => runs
416                .last_mut()
417                .expect("one run at least")
418                .push(point(index, *value)),
419            None => runs.push(Vec::new()),
420        }
421    }
422    runs.into_iter()
423        .filter_map(|run| match run.as_slice() {
424            [] => None,
425            [point] => Some(format!("{point} {point}")),
426            _ => Some(run.join(" ")),
427        })
428        .collect()
429}
430
431/// A series as one bar per value, from zero to the value, beside the other series' bars of the
432/// same label.
433fn bars<'a>(
434    cx: &'a Cx,
435    axis: &Axis,
436    width: f64,
437    index: usize,
438    count: usize,
439    drawn: &Drawn,
440) -> BoxView<'a> {
441    let bar = width * BAR_GROUP / count as f64;
442    let zero = PLOT * axis.share(0.0);
443    let rects: Vec<[String; 4]> = drawn
444        .values
445        .iter()
446        .enumerate()
447        .filter_map(|(label, value)| {
448            let y = PLOT * axis.share((*value)?);
449            let x = label as f64 * width + width * (1.0 - BAR_GROUP) / 2.0 + index as f64 * bar;
450            // A tenth of the bar's slot keeps it apart from its neighbour.
451            Some([
452                coordinate(x + bar * 0.05),
453                coordinate(y.min(zero)),
454                coordinate(bar * 0.9),
455                coordinate((y - zero).abs()),
456            ])
457        })
458        .collect();
459    let class = drawn.color.text_class();
460    view! {
461        <g class=(class)>
462            for [x, y, w, h] in rects {
463                <rect x=(x) y=(y) width=(w) height=(h) fill="currentColor" />
464            }
465        </g>
466    }
467    .boxed()
468}
469
470/// The chart's values as a table, for screen readers: a column per label, a row per series.
471fn data_table<'a>(
472    cx: &'a Cx,
473    heading: Option<String>,
474    labels: &[String],
475    series: &[Drawn],
476) -> BoxView<'a> {
477    let numbers = NumberFormat::of(cx);
478    let labels = labels.to_vec();
479    let rows: Vec<(String, Vec<String>)> = series
480        .iter()
481        .map(|drawn| {
482            let values = drawn
483                .values
484                .iter()
485                .map(|value| {
486                    value
487                        .map(|value| format_value(value, &numbers))
488                        .unwrap_or_default()
489                })
490                .collect();
491            (drawn.label.clone(), values)
492        })
493        .collect();
494    view! {
495        <table class="sr-only" data-chart-data="">
496            if let Some(heading) = heading {
497                <caption>(heading)</caption>
498            }
499            <thead>
500                <tr>
501                    <td></td>
502                    for label in labels {
503                        <th scope="col">(label)</th>
504                    }
505                </tr>
506            </thead>
507            <tbody>
508                for (label, values) in rows {
509                    <tr>
510                        <th scope="row">(label)</th>
511                        for value in values {
512                            <td>(value)</td>
513                        }
514                    </tr>
515                }
516            </tbody>
517        </table>
518    }
519    .boxed()
520}
521
522/// A coordinate in the drawing box, to three decimals.
523fn coordinate(value: f64) -> String {
524    format!("{value:.3}")
525}
526
527/// A value as the chart spells it in `numbers`: a whole number without decimals, any other with
528/// two.
529fn format_value(value: f64, numbers: &NumberFormat) -> String {
530    // `i128` holds every whole float below 1.7e38 exactly.
531    if value.fract() == 0.0 && value.abs() < 1e38 {
532        #[allow(clippy::cast_possible_truncation)]
533        numbers.spell(Number::Int(value as i128), 0)
534    } else {
535        numbers.spell(Number::Float(value), 2)
536    }
537}
538
539#[cfg(test)]
540mod tests;