Skip to main content

hypersig_ui/
chart.rs

1//! Charts: bars, stacked or horizontal bars, lines, or areas drawn from the
2//! rows of a list signal, on a scale the browser fits to the values.
3
4use std::fmt;
5
6use hypersig::html::{El, Node, div, each, el, li, span, table, tbody, td, th, thead, tr, ul};
7use hypersig::style::value::{
8    AlignItems, BorderStyle, Color, Display, FlexDirection, FlexWrap, FontVariantNumeric,
9    FontWeight, JustifyContent, Opacity, Overflow, PointerEvents, Position, TextAlign, WhiteSpace,
10    ZIndex, pct, px, raw, rem,
11};
12use hypersig::style::{Condition, Property, PseudoClass, Style, Token};
13use hypersig::{Expr, IntoExpr, Item, Number, NumberFormat, Sig, Textual, if_else};
14
15use crate::look;
16use crate::token;
17
18/// How a [`Chart`] draws its series.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20enum Kind {
21    Bar,
22    StackedBar,
23    HorizontalBar,
24    Line,
25    Area,
26}
27
28/// One series of a chart: its name, its color, and how a row's value reads
29/// in the browser.
30struct Series<S, T> {
31    name: String,
32    color: Token<Color>,
33    value: RowValue<S, T>,
34    text: RowText<S, T>,
35}
36
37/// A row's value of one series, as a number for drawing.
38type RowValue<S, T> = Box<dyn Fn(Expr<S, T>) -> Expr<S, f64>>;
39
40/// How a row's value of one series reads as text, in the format where there is one.
41type RowText<S, T> = Box<dyn Fn(Item<S, T>, Option<&NumberFormat>) -> Expr<S, String>>;
42
43/// The most grid lines a scale has. The step is at least a quarter of the
44/// range of the values, and rounding both ends out to a step adds fewer
45/// than two more, so a scale spans at most five steps.
46const TICKS: i32 = 6;
47
48/// A chart of the rows of a list signal, drawn as bars, lines, or areas.
49///
50/// Each row is one point along the axis of the categories, labeled by the
51/// text field `category`, and each [`Chart::series`] reads a number field
52/// of the row. The categories run along the bottom, or down the side of a
53/// [`Chart::horizontal_bar`]. The browser fits the scale of the values to
54/// them: it runs from a
55/// round number at or below the smallest value, or 0, to a round number at
56/// or above the largest, or 0, with grid lines on a step of 1, 2, 2.5, or 5
57/// times a power of ten. A patch from the server or a statement in the
58/// browser that changes a value moves its bar or its line and refits the
59/// scale. A negative value draws its bar down from the line at 0.
60///
61/// Each bar, point, and grid line computes the scale from every row, so a
62/// change to the rows costs time that grows with the square of their
63/// number. This crate has not measured where that becomes slow. A long
64/// series can be summed into fewer rows on the server first.
65///
66/// The drawing, the axis labels, the legend, and the box that shows a row's
67/// values under the pointer are hidden from assistive technology. A screen
68/// reader reads a visually hidden table of the same rows instead, captioned
69/// with `label`.
70///
71/// ```
72/// use hypersig::{Page, Signals};
73/// use hypersig_ui::{Chart, token};
74/// use serde::{Deserialize, Serialize};
75///
76/// #[derive(Signals, Serialize, Deserialize)]
77/// struct Month {
78///     name: String,
79///     desktop: i32,
80///     mobile: i32,
81/// }
82///
83/// #[derive(Signals, Serialize, Deserialize)]
84/// struct Traffic {
85///     months: Vec<Month>,
86/// }
87///
88/// let chart = Chart::bar("Visitors", Traffic::months(), Month::name())
89///     .series("Desktop", Month::desktop(), token::chart_1())
90///     .series("Mobile", Month::mobile(), token::chart_2());
91/// let months = vec![
92///     Month { name: "January".into(), desktop: 186, mobile: 80 },
93///     Month { name: "February".into(), desktop: 305, mobile: 200 },
94/// ];
95/// let html = Page::new("Traffic", Traffic { months }).child(chart).render();
96/// assert!(html.contains("<caption>Visitors</caption>"));
97/// assert!(html.contains(">400</span>"));
98/// ```
99pub struct Chart<S, T> {
100    kind: Kind,
101    label: String,
102    rows: Sig<S, Vec<T>>,
103    category: Sig<T, String>,
104    series: Vec<Series<S, T>>,
105    format: Option<NumberFormat>,
106    max_labels: u32,
107}
108
109impl<S: 'static, T: 'static> Chart<S, T> {
110    /// A bar chart named `label` of `rows`, labeled by `category`.
111    ///
112    /// Each row has a group of bars, one per series.
113    #[must_use]
114    pub fn bar(label: impl Into<String>, rows: Sig<S, Vec<T>>, category: Sig<T, String>) -> Self {
115        Self::new(Kind::Bar, label.into(), rows, category)
116    }
117
118    /// A stacked bar chart named `label` of `rows`, labeled by `category`.
119    ///
120    /// Each row has one bar, in which each series stacks on the ones added
121    /// before it. Positive values stack up from 0 and negative ones down
122    /// from it, so the scale reaches the largest sum of a row's positive
123    /// values and the smallest sum of its negative ones.
124    #[must_use]
125    pub fn stacked_bar(
126        label: impl Into<String>,
127        rows: Sig<S, Vec<T>>,
128        category: Sig<T, String>,
129    ) -> Self {
130        Self::new(Kind::StackedBar, label.into(), rows, category)
131    }
132
133    /// A horizontal bar chart named `label` of `rows`, labeled by `category`.
134    ///
135    /// Each row is a band down the side, labeled on the left, with one bar
136    /// per series running from 0 along the scale at the bottom. It suits
137    /// categories whose names are too long to sit under a vertical bar.
138    #[must_use]
139    pub fn horizontal_bar(
140        label: impl Into<String>,
141        rows: Sig<S, Vec<T>>,
142        category: Sig<T, String>,
143    ) -> Self {
144        Self::new(Kind::HorizontalBar, label.into(), rows, category)
145    }
146
147    /// A line chart named `label` of `rows`, labeled by `category`.
148    ///
149    /// Each row is a point on the line of each series.
150    #[must_use]
151    pub fn line(label: impl Into<String>, rows: Sig<S, Vec<T>>, category: Sig<T, String>) -> Self {
152        Self::new(Kind::Line, label.into(), rows, category)
153    }
154
155    /// An area chart named `label` of `rows`, labeled by `category`.
156    ///
157    /// It is a line chart with the area between each line and 0 filled.
158    #[must_use]
159    pub fn area(label: impl Into<String>, rows: Sig<S, Vec<T>>, category: Sig<T, String>) -> Self {
160        Self::new(Kind::Area, label.into(), rows, category)
161    }
162
163    fn new(kind: Kind, label: String, rows: Sig<S, Vec<T>>, category: Sig<T, String>) -> Self {
164        Self {
165            kind,
166            label,
167            rows,
168            category,
169            series: Vec::new(),
170            format: None,
171            max_labels: 12,
172        }
173    }
174
175    /// Adds a series named `name` that draws the number field `field` in `color`.
176    #[must_use]
177    pub fn series<U: Number + Textual + 'static>(
178        mut self,
179        name: impl Into<String>,
180        field: Sig<T, U>,
181        color: Token<Color>,
182    ) -> Self {
183        self.series.push(Series {
184            name: name.into(),
185            color,
186            value: Box::new(move |row| row.at(field).to_f64()),
187            text: Box::new(move |row, format| match format {
188                Some(format) => row.at(field).format(format),
189                None => row.at(field).to_text(),
190            }),
191        });
192        self
193    }
194
195    /// Writes the values and the axis labels in `format`.
196    ///
197    /// Until set, they are written as JavaScript's `String` writes a number.
198    #[must_use]
199    pub fn format(mut self, format: NumberFormat) -> Self {
200        self.format = Some(format);
201        self
202    }
203
204    /// Labels at most `count` rows along the axis of the categories, 12 until set.
205    ///
206    /// With more rows than that, the axis labels every `n`th row from the
207    /// first, for the smallest `n` that keeps to `count`, so the labels do
208    /// not run into each other. The box under the pointer and the table a
209    /// screen reader reads still name every row.
210    ///
211    /// # Panics
212    ///
213    /// Panics if `count` is 0.
214    #[must_use]
215    pub fn max_labels(mut self, count: u32) -> Self {
216        assert!(count >= 1, "a chart labels at least one row");
217        self.max_labels = count;
218        self
219    }
220
221    /// The chart, its legend, and the table a screen reader reads, in a `<figure>`.
222    #[must_use]
223    pub fn render(self) -> El<S> {
224        let count = self.rows.len().to_f64();
225        let band = Expr::lit(&100.0) / count;
226        let plot = el("svg")
227            .attr("viewBox", "0 0 100 100")
228            .attr("preserveAspectRatio", "none")
229            .attr("width", "100%")
230            .attr("height", "100%")
231            .css(
232                Style::new()
233                    .display(Display::Block)
234                    .overflow(Overflow::Visible),
235            )
236            .children((0..TICKS).map(|tick| {
237                let at = self.tick(tick, |value, lo, hi| self.place(value, lo, hi).to_fixed(3));
238                let (along, across) = if self.horizontal() {
239                    (["y1", "y2"], ["x1", "x2"])
240                } else {
241                    (["x1", "x2"], ["y1", "y2"])
242                };
243                el("line")
244                    .attr(along[0], "0")
245                    .attr(along[1], "100")
246                    .attr("vector-effect", "non-scaling-stroke")
247                    .css(
248                        Style::new()
249                            .stroke(token::border().var())
250                            .stroke_width(px(1)),
251                    )
252                    .show(self.tick_shown(tick))
253                    .bind_attr(across[0], at.clone())
254                    .bind_attr(across[1], at)
255            }))
256            .children(self.marks(&band));
257        let visual = div()
258            .attr("aria-hidden", "true")
259            .css(
260                Style::new()
261                    .display(Display::Grid)
262                    .grid_template_columns(raw("auto 1fr"))
263                    .grid_template_rows(raw("15rem auto"))
264                    .column_gap(rem(0.5))
265                    .row_gap(rem(0.5))
266                    .font_variant_numeric(FontVariantNumeric::TabularNums)
267                    .color(token::muted_foreground().var())
268                    .font_size(rem(0.75)),
269            )
270            .child(if self.horizontal() {
271                self.category_labels(band.clone())
272            } else {
273                self.value_labels()
274            })
275            .child(
276                div()
277                    .css(Style::new().position(Position::Relative))
278                    .child(plot)
279                    .child(self.hover(&band)),
280            )
281            .child(div())
282            .child(if self.horizontal() {
283                self.value_labels()
284            } else {
285                self.category_labels(band)
286            });
287        el("figure")
288            .attr("aria-label", self.label.clone())
289            .css(
290                Style::new()
291                    .display(Display::Flex)
292                    .flex_direction(FlexDirection::Column)
293                    .row_gap(rem(1))
294                    .margin(px(0)),
295            )
296            .child(visual)
297            .child(self.legend())
298            .child(self.data_table())
299    }
300
301    /// The expression `body` builds from the low end, the high end, and the
302    /// step of the scale fitted to every value of every series.
303    fn scaled<U>(
304        &self,
305        body: impl FnOnce(Expr<S, f64>, Expr<S, f64>, Expr<S, f64>) -> Expr<S, U>,
306    ) -> Expr<S, U> {
307        let (top, bottom) = self.extremes();
308        bottom.let_in(|bottom| {
309            // With every value 0, or no rows, the scale runs from 0 to 1.
310            let top = if_else(top.clone().gt(bottom.clone()), top, bottom.clone() + 1.0);
311            top.let_in(|top| {
312                ((top.clone() - bottom.clone()) / 4.0).let_in(|rough| {
313                    let guess = Expr::lit(&10.0).pow(rough.clone().log10().floor());
314                    guess.let_in(|guess| {
315                        // A last-bit error in `log10` at a power of ten puts the
316                        // guess one power off, and the ratio brings it back.
317                        let ratio = rough.clone() / guess.clone();
318                        let power = if_else(
319                            ratio.clone().ge(10.0),
320                            guess.clone() * 10.0,
321                            if_else(ratio.lt(1.0), guess.clone() / 10.0, guess),
322                        );
323                        power.let_in(|power| {
324                            let n = rough / power.clone();
325                            let factor = if_else(
326                                n.clone().le(1.0),
327                                Expr::lit(&1.0),
328                                if_else(
329                                    n.clone().le(2.0),
330                                    Expr::lit(&2.0),
331                                    if_else(
332                                        n.clone().le(2.5),
333                                        Expr::lit(&2.5),
334                                        if_else(n.le(5.0), Expr::lit(&5.0), Expr::lit(&10.0)),
335                                    ),
336                                ),
337                            );
338                            (power * factor).let_in(|step| {
339                                let lo = clean((bottom / step.clone()).floor() * step.clone());
340                                let hi = clean((top / step.clone()).ceil() * step.clone());
341                                lo.let_in(|lo| hi.let_in(|hi| body(lo, hi, step)))
342                            })
343                        })
344                    })
345                })
346            })
347        })
348    }
349
350    /// Whether the categories run down the side and the scale along the bottom.
351    fn horizontal(&self) -> bool {
352        self.kind == Kind::HorizontalBar
353    }
354
355    /// Where `value` lies along the scale from `lo` to `hi`, in hundredths
356    /// of the drawing: down from the top, or for a horizontal chart, across
357    /// from the left.
358    fn place(&self, value: Expr<S, f64>, lo: Expr<S, f64>, hi: Expr<S, f64>) -> Expr<S, f64> {
359        if self.horizontal() {
360            (value - lo.clone()) / (hi - lo) * 100.0
361        } else {
362            y_of(value, lo, hi)
363        }
364    }
365
366    /// The largest and the smallest value the scale reaches, 0 included.
367    fn extremes(&self) -> (Expr<S, f64>, Expr<S, f64>) {
368        let rows = self.rows;
369        if self.kind == Kind::StackedBar {
370            let sum = |row: Item<S, T>, positive: bool| stack(&self.series, row, positive);
371            let top = rows.max_of(|row| sum(row, true)).unwrap_or(0.0);
372            let bottom = rows.min_of(|row| sum(row, false)).unwrap_or(0.0);
373            return (top.max(0.0), bottom.min(0.0));
374        }
375        let mut top = Expr::lit(&0.0);
376        let mut bottom = Expr::lit(&0.0);
377        for series in &self.series {
378            let value = &series.value;
379            top = top.max(rows.max_of(|row| value(row.into_expr())).unwrap_or(0.0));
380            bottom = bottom.min(rows.min_of(|row| value(row.into_expr())).unwrap_or(0.0));
381        }
382        (top, bottom)
383    }
384
385    /// The expression `body` builds from the value of the grid line numbered
386    /// `tick` from the bottom and the ends of the scale.
387    fn tick<U>(
388        &self,
389        tick: i32,
390        body: impl FnOnce(Expr<S, f64>, Expr<S, f64>, Expr<S, f64>) -> Expr<S, U>,
391    ) -> Expr<S, U> {
392        self.scaled(|lo, hi, step| {
393            let value = clean(lo.clone() + step * f64::from(tick));
394            body(value, lo, hi)
395        })
396    }
397
398    /// Whether the scale reaches the grid line numbered `tick`.
399    fn tick_shown(&self, tick: i32) -> Expr<S, bool> {
400        self.scaled(|lo, hi, step| (lo + step.clone() * f64::from(tick)).le(hi + step / 2.0))
401    }
402
403    /// The bars, or the lines and areas, of every series.
404    fn marks(&self, band: &Expr<S, f64>) -> Vec<Node<S>> {
405        let count: f64 = self.series.iter().map(|_| 1.0).sum();
406        let mut marks = Vec::with_capacity(self.series.len());
407        for (index, series) in self.series.iter().enumerate() {
408            let earlier = &self.series[..index];
409            let color = &series.color;
410            let mark = each(self.rows, |row| {
411                let value = (series.value)(row.into_expr());
412                let x = row.index().to_f64() * band.clone();
413                match self.kind {
414                    Kind::Bar | Kind::HorizontalBar => {
415                        let width = band.clone() * (0.8 / count);
416                        #[expect(
417                            clippy::cast_precision_loss,
418                            reason = "a chart has far fewer than 2^52 series"
419                        )]
420                        let left = x + band.clone() * 0.1 + width.clone() * (index as f64);
421                        self.bar_mark(Expr::lit(&0.0), value, left, width, color)
422                    }
423                    Kind::StackedBar => {
424                        let start = if_else(
425                            value.clone().ge(0.0),
426                            stack(earlier, row, true),
427                            stack(earlier, row, false),
428                        );
429                        let end = start.clone() + value;
430                        let left = x + band.clone() * 0.1;
431                        self.bar_mark(start, end, left, band.clone() * 0.8, color)
432                    }
433                    Kind::Line | Kind::Area => {
434                        let next =
435                            (series.value)(self.rows.nth(row.index() + 1).unwrap_or(row.value()));
436                        let x1 = x + band.clone() * 0.5;
437                        let x2 = x1.clone() + band.clone();
438                        self.segment(value, next, x1, x2, color)
439                            .show(self.rows.len().gt(row.index() + 1))
440                    }
441                }
442            });
443            marks.push(mark);
444        }
445        marks
446    }
447
448    /// A bar in `color` from the value `start` to the value `end`, across
449    /// the band from `offset` that is `thickness` wide.
450    fn bar_mark(
451        &self,
452        start: Expr<S, f64>,
453        end: Expr<S, f64>,
454        offset: Expr<S, f64>,
455        thickness: Expr<S, f64>,
456        color: &Token<Color>,
457    ) -> El<S> {
458        let near = self.scaled(|lo, hi, _| {
459            self.place(start.clone(), lo.clone(), hi.clone())
460                .min(self.place(end.clone(), lo, hi))
461        });
462        let length = self.scaled(|lo, hi, _| {
463            let a = self.place(start, lo.clone(), hi.clone());
464            let b = self.place(end, lo, hi);
465            a.clone().max(b.clone()) - a.min(b)
466        });
467        let ([value_at, value_size], [band_at, band_size]) = if self.horizontal() {
468            (["x", "width"], ["y", "height"])
469        } else {
470            (["y", "height"], ["x", "width"])
471        };
472        el("rect")
473            .css(Style::new().fill(color.var()))
474            .bind_attr(band_at, offset.to_fixed(3))
475            .bind_attr(band_size, thickness.to_fixed(3))
476            .bind_attr(value_at, near.to_fixed(3))
477            .bind_attr(value_size, length.to_fixed(3))
478    }
479
480    /// The line in `color` from `value` at `x1` to `next` at `x2`, and for an
481    /// area chart the region between that line and 0.
482    fn segment(
483        &self,
484        value: Expr<S, f64>,
485        next: Expr<S, f64>,
486        x1: Expr<S, f64>,
487        x2: Expr<S, f64>,
488        color: &Token<Color>,
489    ) -> El<S> {
490        let y1 = self.scaled(|lo, hi, _| y_of(value, lo, hi).to_fixed(3));
491        let y2 = self.scaled(|lo, hi, _| y_of(next, lo, hi).to_fixed(3));
492        let line = el("line")
493            .attr("vector-effect", "non-scaling-stroke")
494            .attr("stroke-linecap", "round")
495            .css(Style::new().stroke(color.var()).stroke_width(px(2)))
496            .bind_attr("x1", x1.clone().to_fixed(3))
497            .bind_attr("x2", x2.clone().to_fixed(3))
498            .bind_attr("y1", y1.clone())
499            .bind_attr("y2", y2.clone());
500        if self.kind != Kind::Area {
501            return el("g").child(line);
502        }
503        let base = self.scaled(|lo, hi, _| y_of(Expr::lit(&0.0), lo, hi).to_fixed(3));
504        let point = |x: Expr<S, f64>, y: Expr<S, String>| x.to_fixed(3).concat(",").concat(y);
505        let points = point(x1.clone(), base.clone())
506            .concat(" ")
507            .concat(point(x1, y1))
508            .concat(" ")
509            .concat(point(x2.clone(), y2))
510            .concat(" ")
511            .concat(point(x2, base));
512        el("g")
513            .child(
514                el("polygon")
515                    .css(Style::new().fill(color.var()).fill_opacity(0.25))
516                    .bind_attr("points", points),
517            )
518            .child(line)
519    }
520
521    /// The labels of the grid lines, each beside its line: to the left of
522    /// the drawing, or for a horizontal chart, under it.
523    fn value_labels(&self) -> El<S> {
524        let format = self.format;
525        let horizontal = self.horizontal();
526        let (frame, label, side) = if horizontal {
527            (
528                Style::new().position(Position::Relative).height(rem(1)),
529                Style::new().translate(raw("-50% 0")),
530                Property::Left,
531            )
532        } else {
533            (
534                Style::new()
535                    .position(Position::Relative)
536                    .min_width(rem(2.5))
537                    .text_align(TextAlign::End),
538                Style::new().right(px(0)).translate(raw("0 -50%")),
539                Property::Top,
540            )
541        };
542        div().css(frame).children((0..TICKS).map(|tick| {
543            let at = self.tick(tick, |value, lo, hi| {
544                self.place(value, lo, hi).to_fixed(3).concat("%")
545            });
546            let text = self.tick(tick, |value, _, _| match &format {
547                Some(format) => value.format(format),
548                None => value.to_text(),
549            });
550            span()
551                .css(
552                    Style::new()
553                        .position(Position::Absolute)
554                        .white_space(WhiteSpace::Nowrap)
555                        .merge(label.clone()),
556                )
557                .show(self.tick_shown(tick))
558                .bind_style(side, at)
559                .bind_text(text)
560        }))
561    }
562
563    /// One column per row over the drawing, which shows the row's values
564    /// while the pointer is on it.
565    fn hover(&self, band: &Expr<S, f64>) -> El<S> {
566        let category = self.category;
567        let series = &self.series;
568        div()
569            .css(Style::new().position(Position::Absolute).inset(px(0)))
570            .child(each(self.rows, |row| {
571                let start = (row.index().to_f64() * band.clone())
572                    .to_fixed(3)
573                    .concat("%");
574                let (place, span_style, across) = if self.horizontal() {
575                    (
576                        Property::Top,
577                        Property::Height,
578                        Style::new().left(px(0)).right(px(0)),
579                    )
580                } else {
581                    (
582                        Property::Left,
583                        Property::Width,
584                        Style::new().top(px(0)).bottom(px(0)),
585                    )
586                };
587                let mut values = div().css(
588                    Style::new()
589                        .display(Display::Grid)
590                        .grid_template_columns(raw("auto 1fr auto"))
591                        .column_gap(rem(0.5))
592                        .align_items(AlignItems::Center),
593                );
594                for series in series {
595                    values = values
596                        .child(swatch(&series.color))
597                        .child(span().text(series.name.clone()))
598                        .child(
599                            span()
600                                .css(
601                                    Style::new()
602                                        .font_weight(FontWeight::Number(500.0))
603                                        .color(token::popover_foreground().var()),
604                                )
605                                .bind_text((series.text)(row, self.format.as_ref())),
606                        );
607                }
608                div()
609                    .css(
610                        Style::new()
611                            .position(Position::Absolute)
612                            .merge(across)
613                            .group()
614                            .on(
615                                PseudoClass::Hover,
616                                // A translucent shadow over the column, since a fill on this
617                                // layer, which lies above the drawing, would hide the bars.
618                                Style::new().box_shadow(raw(
619                                    "inset 0 0 0 100vmax color-mix(in srgb, currentColor 8%, transparent)",
620                                )),
621                            ),
622                    )
623                    .bind_style(place, start)
624                    .bind_style(span_style, band.clone().to_fixed(3).concat("%"))
625                    .child(
626                        div()
627                            .css(tooltip_style())
628                            .child(
629                                div()
630                                    .css(
631                                        Style::new()
632                                            .font_weight(FontWeight::Number(500.0))
633                                            .color(token::popover_foreground().var()),
634                                    )
635                                    .bind_text(row.at(category)),
636                            )
637                            .child(values),
638                    )
639            }))
640    }
641
642    /// The category of each row beside the middle of its band, under the
643    /// drawing or for a horizontal chart to its left, for every `n`th row
644    /// that keeps the labels to `max_labels`.
645    fn category_labels(&self, band: Expr<S, f64>) -> El<S> {
646        let category = self.category;
647        let every = (self.rows.len().to_f64() / f64::from(self.max_labels)).ceil();
648        let (frame, label, side) = if self.horizontal() {
649            (
650                Style::new()
651                    .position(Position::Relative)
652                    .min_width(rem(4))
653                    .text_align(TextAlign::End),
654                Style::new().right(px(0)).translate(raw("0 -50%")),
655                Property::Top,
656            )
657        } else {
658            (
659                Style::new().position(Position::Relative).height(rem(1)),
660                Style::new().translate(raw("-50% 0")),
661                Property::Left,
662            )
663        };
664        div().css(frame).child(each(self.rows, |row| {
665            let middle = ((row.index().to_f64() + 0.5) * band)
666                .to_fixed(3)
667                .concat("%");
668            span()
669                .css(
670                    Style::new()
671                        .position(Position::Absolute)
672                        .white_space(WhiteSpace::Nowrap)
673                        .merge(label),
674                )
675                .show((row.index().to_f64() % every.clone()).equals(0.0))
676                .bind_style(side, middle)
677                .bind_text(row.at(category))
678        }))
679    }
680
681    /// A swatch and the name of each series.
682    fn legend(&self) -> El<S> {
683        ul().attr("aria-hidden", "true")
684            .css(
685                Style::new()
686                    .display(Display::Flex)
687                    .flex_wrap(FlexWrap::Wrap)
688                    .justify_content(JustifyContent::Center)
689                    .column_gap(rem(1))
690                    .margin(px(0))
691                    .padding(px(0))
692                    .list_style_type(raw("none"))
693                    .merge(look::small_text()),
694            )
695            .children(self.series.iter().map(|series| {
696                li().css(
697                    Style::new()
698                        .display(Display::Flex)
699                        .align_items(AlignItems::Center)
700                        .column_gap(rem(0.5)),
701                )
702                .child(swatch(&series.color))
703                .child(span().text(series.name.clone()))
704            }))
705    }
706
707    /// The rows as a table that only a screen reader reads.
708    fn data_table(&self) -> El<S> {
709        let mut head = tr().child(th().attr("scope", "col"));
710        for series in &self.series {
711            head = head.child(th().attr("scope", "col").text(series.name.clone()));
712        }
713        let category = self.category;
714        let series = &self.series;
715        table()
716            .css(visually_hidden())
717            .child(el("caption").text(self.label.clone()))
718            .child(thead().child(head))
719            .child(tbody().child(each(self.rows, |row| {
720                let mut line = tr().child(th().attr("scope", "row").bind_text(row.at(category)));
721                for series in series {
722                    line = line.child(td().bind_text((series.text)(row, self.format.as_ref())));
723                }
724                line
725            })))
726    }
727}
728
729impl<S, T> fmt::Debug for Chart<S, T> {
730    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
731        f.debug_struct("Chart")
732            .field("kind", &self.kind)
733            .field("label", &self.label)
734            .field("rows", &self.rows)
735            .field("category", &self.category)
736            .field("format", &self.format)
737            .field("max_labels", &self.max_labels)
738            .field(
739                "series",
740                &self
741                    .series
742                    .iter()
743                    .map(|series| &series.name)
744                    .collect::<Vec<_>>(),
745            )
746            .finish()
747    }
748}
749
750impl<S: 'static, T: 'static> From<Chart<S, T>> for El<S> {
751    fn from(chart: Chart<S, T>) -> Self {
752        chart.render()
753    }
754}
755
756impl<S: 'static, T: 'static> From<Chart<S, T>> for Node<S> {
757    fn from(chart: Chart<S, T>) -> Self {
758        chart.render().into()
759    }
760}
761
762/// The sum of the positive values of `series` in `row`, or of the negative
763/// ones where `positive` is false, which is where the next series stacks.
764fn stack<S, T>(series: &[Series<S, T>], row: Item<S, T>, positive: bool) -> Expr<S, f64> {
765    series.iter().fold(Expr::lit(&0.0), |sum, series| {
766        let value = (series.value)(row.into_expr());
767        sum + if positive {
768            value.max(0.0)
769        } else {
770            value.min(0.0)
771        }
772    })
773}
774
775/// The distance from the top of the drawing to `value`, in hundredths of
776/// its height, on the scale from `lo` to `hi`.
777fn y_of<S>(value: Expr<S, f64>, lo: Expr<S, f64>, hi: Expr<S, f64>) -> Expr<S, f64> {
778    (hi.clone() - value) / (hi - lo) * 100.0
779}
780
781/// `value` rounded to nine decimal places, which removes the error of a
782/// multiplication such as `3 * 0.1`.
783fn clean<S>(value: Expr<S, f64>) -> Expr<S, f64> {
784    (value * 1e9).round() / 1e9
785}
786
787/// A small square in `color`.
788fn swatch<S>(color: &Token<Color>) -> El<S> {
789    span().css(
790        Style::new()
791            .display(Display::InlineBlock)
792            .width(rem(0.625))
793            .height(rem(0.625))
794            .border_radius(px(2))
795            .background_color(color.var()),
796    )
797}
798
799/// The box of a row's values, shown while the pointer is on its column.
800fn tooltip_style() -> Style {
801    Style::new()
802        .display(Display::None)
803        .position(Position::Absolute)
804        .top(rem(0.5))
805        .left(pct(50))
806        .translate(raw("-50% 0"))
807        .z_index(ZIndex::Integer(10))
808        .min_width(rem(8))
809        .padding_block(rem(0.375))
810        .padding_inline(rem(0.625))
811        .border_width(px(1))
812        .border_style(BorderStyle::Solid)
813        .border_color(token::border().var())
814        .border_radius(rem(0.5))
815        .background_color(token::popover().var())
816        .box_shadow(raw("0 4px 12px rgb(0 0 0 / 0.1)"))
817        .white_space(WhiteSpace::Nowrap)
818        .pointer_events(PointerEvents::None)
819        .on(
820            Condition::Group(PseudoClass::Hover),
821            Style::new().display(Display::Flex),
822        )
823        .flex_direction(FlexDirection::Column)
824        .row_gap(rem(0.25))
825        .opacity(Opacity::Number(1.0))
826        .color(Color::CURRENT_COLOR)
827}
828
829/// Hides an element from sight and keeps it in the accessibility tree.
830pub(crate) fn visually_hidden() -> Style {
831    Style::new()
832        .position(Position::Absolute)
833        .width(px(1))
834        .height(px(1))
835        .overflow(Overflow::Hidden)
836        .clip_path(raw("inset(50%)"))
837        .white_space(WhiteSpace::Nowrap)
838}
839
840#[cfg(test)]
841mod tests {
842    use hypersig::html::span;
843    use hypersig::{Page, Signals};
844    use serde::{Deserialize, Serialize};
845
846    use super::Chart;
847    use crate::token;
848
849    #[derive(Signals, Serialize, Deserialize)]
850    struct Point {
851        name: String,
852        value: f64,
853    }
854
855    #[derive(Signals, Serialize, Deserialize)]
856    struct Plot {
857        points: Vec<Point>,
858    }
859
860    /// The low end, the high end, and the step of the scale the first HTML
861    /// shows for `values`.
862    fn fit(values: &[f64]) -> String {
863        let chart = Chart::bar("Points", Plot::points(), Point::name()).series(
864            "Value",
865            Point::value(),
866            token::chart_1(),
867        );
868        let text = chart.scaled(|lo, hi, step| {
869            lo.to_text()
870                .concat(" ")
871                .concat(hi.to_text())
872                .concat(" ")
873                .concat(step.to_text())
874        });
875        let points = values
876            .iter()
877            .map(|&value| Point {
878                name: String::new(),
879                value,
880            })
881            .collect();
882        let html = Page::new("Points", Plot { points })
883            .child(span().bind_text(text))
884            .render();
885        let end = html.find("</span>").expect("the span is rendered");
886        let start = html[..end].rfind('>').expect("the span has a start tag") + 1;
887        html[start..end].to_owned()
888    }
889
890    #[test]
891    fn rounds_out_to_a_step_of_one_two_two_and_a_half_or_five() {
892        assert_eq!(fit(&[186.0, 305.0, 237.0]), "0 400 100");
893        assert_eq!(fit(&[820.0]), "0 1000 250");
894        assert_eq!(fit(&[7.0]), "0 8 2");
895        assert_eq!(fit(&[400.0]), "0 400 100");
896        assert_eq!(fit(&[1000.0]), "0 1000 250");
897    }
898
899    #[test]
900    fn keeps_small_steps_free_of_float_error() {
901        assert_eq!(fit(&[0.3]), "0 0.3 0.1");
902    }
903
904    #[test]
905    fn reaches_below_zero_for_a_negative_value() {
906        assert_eq!(fit(&[120.0, -45.0]), "-50 150 50");
907        assert_eq!(fit(&[-7.0, -2.0]), "-8 0 2");
908    }
909
910    #[test]
911    fn runs_from_zero_to_one_without_a_value_other_than_zero() {
912        assert_eq!(fit(&[]), "0 1 0.25");
913        assert_eq!(fit(&[0.0, 0.0]), "0 1 0.25");
914    }
915}