1use 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20enum ChartKind {
21 Line,
23 Bar,
25}
26
27impl ChartKind {
28 const fn name(self) -> &'static str {
30 match self {
31 Self::Line => "line",
32 Self::Bar => "bar",
33 }
34 }
35}
36
37#[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 pub fn line(labels: impl IntoIterator<Item = impl Into<Text>>) -> Self {
63 Self::new(ChartKind::Line, labels)
64 }
65
66 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 #[must_use]
83 pub fn heading(mut self, heading: impl Into<Text>) -> Self {
84 self.heading = Some(heading.into());
85 self
86 }
87
88 #[must_use]
90 pub fn description(mut self, description: impl Into<Text>) -> Self {
91 self.description = Some(description.into());
92 self
93 }
94
95 #[must_use]
97 pub fn series(mut self, series: Series) -> Self {
98 self.series.push(series);
99 self
100 }
101}
102
103#[derive(Clone, Debug)]
105pub struct Series {
106 label: Text,
107 values: Vec<f64>,
108 color: Option<Color>,
109}
110
111impl Series {
112 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 #[must_use]
125 pub const fn color(mut self, color: Color) -> Self {
126 self.color = Some(color);
127 self
128 }
129}
130
131const PALETTE: [Color; 5] = [
133 Color::Primary,
134 Color::Info,
135 Color::Success,
136 Color::Warning,
137 Color::Danger,
138];
139
140const PLOT: f64 = 100.0;
142const BAR_GROUP: f64 = 0.7;
144const MAX_AXIS_LABELS: usize = 12;
146
147pub 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 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
235struct 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
260fn 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 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
381fn 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
410fn 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
431fn 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 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
470fn 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
522fn coordinate(value: f64) -> String {
524 format!("{value:.3}")
525}
526
527fn format_value(value: f64, numbers: &NumberFormat) -> String {
530 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;