Skip to main content

hypersig_ui/
donut.rs

1//! Donut charts: each row's share of a total, drawn as an arc of a ring.
2
3use std::fmt;
4
5use hypersig::html::{El, Node, div, each, el, li, span, table, tbody, td, th, thead, tr, ul};
6use hypersig::style::value::{
7    AlignItems, Color, Display, FlexDirection, FontVariantNumeric, FontWeight, JustifyContent,
8    Position, px, raw, rem,
9};
10use hypersig::style::{Style, Token};
11use hypersig::{Expr, Item, Number, NumberFormat, Sig, Textual, if_else};
12
13use crate::chart::visually_hidden;
14use crate::look;
15use crate::token;
16
17/// How a row's value reads, as a number for drawing.
18type RowValue<S, T> = Box<dyn Fn(Item<S, T>) -> Expr<S, f64>>;
19
20/// How a row's value reads as text, in the format where there is one.
21type RowText<S, T> = Box<dyn Fn(Item<S, T>, Option<&NumberFormat>) -> Expr<S, String>>;
22
23/// The colors of the slices, which repeat from the first after the fifth row.
24fn palette() -> [Token<Color>; 5] {
25    [
26        token::chart_1(),
27        token::chart_2(),
28        token::chart_3(),
29        token::chart_4(),
30        token::chart_5(),
31    ]
32}
33
34/// A donut chart of the rows of a list signal, each an arc as long as its share of the total.
35///
36/// Each row is a slice labeled by the text field `category`, and its
37/// length is the number field `value` over the sum of `value` across the
38/// rows. The slices run clockwise from the top in the order of the rows,
39/// in the chart colors from [`token::chart_1`] to [`token::chart_5`],
40/// which repeat after the fifth row. A negative value counts as 0, since a
41/// share cannot be negative. The total is written in the middle of the
42/// ring, and the legend names each row with its value.
43///
44/// Each slice sums the rows before it, so a change to the rows costs time
45/// that grows with the square of their number.
46///
47/// The ring and the legend are hidden from assistive technology, and a
48/// screen reader reads a visually hidden table of the same rows instead,
49/// captioned with `label`.
50///
51/// ```
52/// use hypersig::{Page, Signals};
53/// use hypersig_ui::DonutChart;
54/// use serde::{Deserialize, Serialize};
55///
56/// #[derive(Signals, Serialize, Deserialize)]
57/// struct Browser {
58///     name: String,
59///     visitors: i32,
60/// }
61///
62/// #[derive(Signals, Serialize, Deserialize)]
63/// struct Traffic {
64///     browsers: Vec<Browser>,
65/// }
66///
67/// let chart = DonutChart::new("Browsers", Traffic::browsers(), Browser::name(), Browser::visitors());
68/// let browsers = vec![
69///     Browser { name: "Chrome".into(), visitors: 275 },
70///     Browser { name: "Safari".into(), visitors: 125 },
71/// ];
72/// let html = Page::new("Traffic", Traffic { browsers }).child(chart).render();
73/// assert!(html.contains(">400</span>"));
74/// assert!(html.contains(r#"stroke-dasharray="68.750 31.250""#));
75/// ```
76pub struct DonutChart<S, T> {
77    label: String,
78    rows: Sig<S, Vec<T>>,
79    category: Sig<T, String>,
80    value: RowValue<S, T>,
81    text: RowText<S, T>,
82    format: Option<NumberFormat>,
83}
84
85impl<S: 'static, T: 'static> DonutChart<S, T> {
86    /// A donut chart named `label` of `rows`, with slices of `value` labeled by `category`.
87    #[must_use]
88    pub fn new<U: Number + Textual + 'static>(
89        label: impl Into<String>,
90        rows: Sig<S, Vec<T>>,
91        category: Sig<T, String>,
92        value: Sig<T, U>,
93    ) -> Self {
94        Self {
95            label: label.into(),
96            rows,
97            category,
98            value: Box::new(move |row| row.at(value).to_f64()),
99            text: Box::new(move |row, format| match format {
100                Some(format) => row.at(value).format(format),
101                None => row.at(value).to_text(),
102            }),
103            format: None,
104        }
105    }
106
107    /// Writes the values and the total in `format`.
108    ///
109    /// Until set, they are written as JavaScript's `String` writes a number.
110    #[must_use]
111    pub fn format(mut self, format: NumberFormat) -> Self {
112        self.format = Some(format);
113        self
114    }
115
116    /// The ring, its total, its legend, and the table a screen reader reads, in a `<figure>`.
117    #[must_use]
118    pub fn render(self) -> El<S> {
119        let ring = el("svg")
120            .attr("viewBox", "0 0 100 100")
121            .attr("width", "100%")
122            .attr("height", "100%")
123            .css(Style::new().display(Display::Block).rotate(raw("-90deg")))
124            .child(
125                el("circle")
126                    .attr("cx", "50")
127                    .attr("cy", "50")
128                    .attr("r", "40")
129                    .attr("fill", "none")
130                    .attr("stroke-width", "16")
131                    .css(Style::new().stroke(token::muted().var())),
132            )
133            .child(each(self.rows, |row| self.slice(row)));
134        let total = self.total();
135        let total = match &self.format {
136            Some(format) => total.format(format),
137            None => total.to_text(),
138        };
139        let middle = div()
140            .css(
141                Style::new()
142                    .position(Position::Absolute)
143                    .inset(px(0))
144                    .display(Display::Flex)
145                    .align_items(AlignItems::Center)
146                    .justify_content(JustifyContent::Center)
147                    .font_size(rem(1.5))
148                    .font_weight(FontWeight::Number(600.0))
149                    .font_variant_numeric(FontVariantNumeric::TabularNums),
150            )
151            .child(span().bind_text(total));
152        let visual = div()
153            .attr("aria-hidden", "true")
154            .css(
155                Style::new()
156                    .position(Position::Relative)
157                    .width(rem(12))
158                    .height(rem(12))
159                    .margin_inline(raw("auto")),
160            )
161            .child(ring)
162            .child(middle);
163        el("figure")
164            .attr("aria-label", self.label.clone())
165            .css(
166                Style::new()
167                    .display(Display::Flex)
168                    .flex_direction(FlexDirection::Column)
169                    .row_gap(rem(1))
170                    .margin(px(0)),
171            )
172            .child(visual)
173            .child(self.legend())
174            .child(self.data_table())
175    }
176
177    /// The sum of the values, each held at 0 or above.
178    fn total(&self) -> Expr<S, f64> {
179        let value = &self.value;
180        self.rows.sum_of(|row| value(row).max(0.0))
181    }
182
183    /// The arc of `row`: as long as its share of the total, and starting where
184    /// the rows before it end.
185    fn slice(&self, row: Item<S, T>) -> El<S> {
186        let value = &self.value;
187        let length = self
188            .total()
189            .let_in(|total| value(row).max(0.0) / total * 100.0)
190            .let_in(|length| {
191                length
192                    .clone()
193                    .to_fixed(3)
194                    .concat(" ")
195                    .concat((Expr::lit(&100.0) - length).to_fixed(3))
196            });
197        let before = self.rows.sum_of(|other| {
198            if_else(
199                other.index().lt(row.index()),
200                value(other).max(0.0),
201                Expr::lit(&0.0),
202            )
203        });
204        // The ring is turned a quarter back, so the path starts at the top.
205        let offset = self
206            .total()
207            .let_in(|total| Expr::lit(&0.0) - before / total * 100.0);
208        let mut arc = el("circle")
209            .attr("cx", "50")
210            .attr("cy", "50")
211            .attr("r", "40")
212            .attr("fill", "none")
213            .attr("stroke-width", "16")
214            .attr("pathLength", "100")
215            .show(self.total().gt(0.0))
216            .bind_attr("stroke-dasharray", length)
217            .bind_attr("stroke-dashoffset", offset.to_fixed(3));
218        for (index, color) in (0..).zip(palette()) {
219            arc = arc.css_when(
220                Style::new().stroke(color.var()),
221                (row.index() % 5).equals(index),
222            );
223        }
224        arc
225    }
226
227    /// A swatch, the category, and the value of each row.
228    fn legend(&self) -> El<S> {
229        let category = self.category;
230        ul().attr("aria-hidden", "true")
231            .css(
232                Style::new()
233                    .display(Display::Grid)
234                    .grid_template_columns(raw("auto 1fr auto"))
235                    .column_gap(rem(0.5))
236                    .row_gap(rem(0.25))
237                    .align_items(AlignItems::Center)
238                    .margin(px(0))
239                    .padding(px(0))
240                    .list_style_type(raw("none"))
241                    .merge(look::small_text()),
242            )
243            .child(each(self.rows, |row| {
244                li().css(Style::new().display(Display::Contents))
245                    .child(swatch_for(row))
246                    .child(span().bind_text(row.at(category)))
247                    .child(
248                        span()
249                            .css(
250                                Style::new()
251                                    .font_variant_numeric(FontVariantNumeric::TabularNums)
252                                    .color(token::muted_foreground().var()),
253                            )
254                            .bind_text((self.text)(row, self.format.as_ref())),
255                    )
256            }))
257    }
258
259    /// The rows as a table that only a screen reader reads.
260    fn data_table(&self) -> El<S> {
261        let category = self.category;
262        table()
263            .css(visually_hidden())
264            .child(el("caption").text(self.label.clone()))
265            .child(
266                thead().child(
267                    tr().child(th().attr("scope", "col"))
268                        .child(th().attr("scope", "col").text(self.label.clone())),
269                ),
270            )
271            .child(tbody().child(each(self.rows, |row| {
272                tr().child(th().attr("scope", "row").bind_text(row.at(category)))
273                    .child(td().bind_text((self.text)(row, self.format.as_ref())))
274            })))
275    }
276}
277
278/// A small square in the color of the slice of `row`.
279fn swatch_for<S, T>(row: Item<S, T>) -> El<S> {
280    let mut mark = span().css(
281        Style::new()
282            .display(Display::InlineBlock)
283            .width(rem(0.625))
284            .height(rem(0.625))
285            .border_radius(px(2)),
286    );
287    for (index, color) in (0..).zip(palette()) {
288        mark = mark.css_when(
289            Style::new().background_color(color.var()),
290            (row.index() % 5).equals(index),
291        );
292    }
293    mark
294}
295
296impl<S, T> fmt::Debug for DonutChart<S, T> {
297    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
298        f.debug_struct("DonutChart")
299            .field("label", &self.label)
300            .field("rows", &self.rows)
301            .field("category", &self.category)
302            .field("format", &self.format)
303            .finish_non_exhaustive()
304    }
305}
306
307impl<S: 'static, T: 'static> From<DonutChart<S, T>> for El<S> {
308    fn from(chart: DonutChart<S, T>) -> Self {
309        chart.render()
310    }
311}
312
313impl<S: 'static, T: 'static> From<DonutChart<S, T>> for Node<S> {
314    fn from(chart: DonutChart<S, T>) -> Self {
315        chart.render().into()
316    }
317}