1use 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
17type RowValue<S, T> = Box<dyn Fn(Item<S, T>) -> Expr<S, f64>>;
19
20type RowText<S, T> = Box<dyn Fn(Item<S, T>, Option<&NumberFormat>) -> Expr<S, String>>;
22
23fn 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
34pub 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 #[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 #[must_use]
111 pub fn format(mut self, format: NumberFormat) -> Self {
112 self.format = Some(format);
113 self
114 }
115
116 #[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 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 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 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 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 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
278fn 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}