1use rich::measure::Measurement;
5use rich::{Console, ConsoleOptions, Renderable, Segment};
6
7use super::{
8 BarChart, Charset, Chart, Line, LineChart, Scale, Sparkline, ValueFormat, has_colour,
9 lines_to_segments, theme_style, truncate, wrap_entries,
10};
11
12const DEFAULT_MIN_WIDTH: usize = 24;
14const DEFAULT_GAP: usize = 2;
16const MAX_GAP: usize = u16::MAX as usize;
18
19#[derive(Clone, Debug, PartialEq)]
29#[non_exhaustive]
30#[must_use = "builder methods return a new value; it does nothing until it is rendered or used"]
31pub enum Facet {
32 Sparkline(Sparkline),
33 Bars(BarChart),
34 Line(LineChart),
35 Chart(Chart),
36}
37
38impl From<Sparkline> for Facet {
39 fn from(chart: Sparkline) -> Self {
40 Facet::Sparkline(chart)
41 }
42}
43
44impl From<BarChart> for Facet {
45 fn from(chart: BarChart) -> Self {
46 Facet::Bars(chart)
47 }
48}
49
50impl From<LineChart> for Facet {
51 fn from(chart: LineChart) -> Self {
52 Facet::Line(chart)
53 }
54}
55
56impl From<Chart> for Facet {
57 fn from(chart: Chart) -> Self {
58 Facet::Chart(chart)
59 }
60}
61
62impl Facet {
63 pub fn value_range(&self) -> Option<(f64, f64)> {
66 match self {
67 Facet::Sparkline(c) => c.value_extent(),
68 Facet::Bars(c) => c.value_extent(),
69 Facet::Line(c) => c.y_extent(),
70 Facet::Chart(c) => c.y_extent(),
71 }
72 }
73
74 pub fn with_range(self, min: f64, max: f64) -> Facet {
76 match self {
77 Facet::Sparkline(c) => Facet::Sparkline(c.range(min, max)),
78 Facet::Bars(c) => Facet::Bars(c.range(min, max)),
79 Facet::Line(c) => Facet::Line(c.y_range(min, max)),
80 Facet::Chart(c) => Facet::Chart(c.with_y_bounds(min, max)),
81 }
82 }
83
84 fn has_axis(&self) -> bool {
87 matches!(self, Facet::Line(_) | Facet::Chart(_))
88 }
89
90 fn renderable(&self) -> &dyn Renderable {
91 match self {
92 Facet::Sparkline(c) => c,
93 Facet::Bars(c) => c,
94 Facet::Line(c) => c,
95 Facet::Chart(c) => c,
96 }
97 }
98}
99
100impl Renderable for Facet {
101 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
102 self.renderable().rich_render(console, options)
103 }
104
105 fn measure(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
106 self.renderable().measure(console, options)
107 }
108}
109
110impl crate::a11y::AccessibleText for Facet {
111 fn accessible_text(&self, width: usize) -> String {
112 match self {
113 Facet::Sparkline(c) => c.accessible_text(width),
114 Facet::Bars(c) => c.accessible_text(width),
115 Facet::Line(c) => c.accessible_text(width),
116 Facet::Chart(c) => c.accessible_text(width),
117 }
118 }
119}
120
121#[derive(Clone, Debug, PartialEq)]
173#[must_use = "builder methods return a new value; it does nothing until it is rendered or used"]
174pub struct SmallMultiples {
175 items: Vec<(String, Facet)>,
176 columns: Option<usize>,
177 min_width: usize,
178 gap: usize,
179 shared: bool,
180 range: Option<(f64, f64)>,
181 height: Option<usize>,
182 note: bool,
183 charset: Charset,
184}
185
186impl Default for SmallMultiples {
187 fn default() -> Self {
188 Self::new(Vec::<(String, Facet)>::new())
189 }
190}
191
192impl SmallMultiples {
193 pub fn new<T: Into<String>, F: Into<Facet>>(items: impl IntoIterator<Item = (T, F)>) -> Self {
195 SmallMultiples {
196 items: items
197 .into_iter()
198 .map(|(t, f)| (t.into(), f.into()))
199 .collect(),
200 columns: None,
201 min_width: DEFAULT_MIN_WIDTH,
202 gap: DEFAULT_GAP,
203 shared: true,
204 range: None,
205 height: None,
206 note: true,
207 charset: Charset::Auto,
208 }
209 }
210
211 pub fn push(mut self, title: impl Into<String>, chart: impl Into<Facet>) -> Self {
213 self.items.push((title.into(), chart.into()));
214 self
215 }
216
217 pub fn columns(mut self, n: usize) -> Self {
219 self.columns = Some(n.max(1));
220 self
221 }
222
223 pub fn min_cell_width(mut self, cells: usize) -> Self {
226 self.min_width = cells.max(1);
227 self
228 }
229
230 pub fn gap(mut self, cells: usize) -> Self {
232 self.gap = cells.min(MAX_GAP);
233 self
234 }
235
236 pub fn shared_scale(mut self, shared: bool) -> Self {
238 self.shared = shared;
239 self
240 }
241
242 pub fn range(mut self, min: f64, max: f64) -> Self {
244 self.range = Some((min, max));
245 self
246 }
247
248 pub fn height(mut self, rows: usize) -> Self {
250 self.height = Some(rows.max(1));
251 self
252 }
253
254 pub fn note(mut self, show: bool) -> Self {
257 self.note = show;
258 self
259 }
260
261 pub fn charset(mut self, charset: Charset) -> Self {
265 self.charset = charset;
266 self
267 }
268
269 pub fn items(&self) -> &[(String, Facet)] {
271 &self.items
272 }
273
274 pub fn columns_at(&self, width: usize) -> usize {
276 let n = self.items.len().max(1);
277 let gap = self.gap;
278 let wanted = self.columns.unwrap_or_else(|| {
279 (width.saturating_add(gap) / self.min_width.saturating_add(gap)).max(1)
280 });
281 let fits = (width.saturating_add(gap) / gap.saturating_add(1)).max(1);
283 wanted.min(n).min(fits)
284 }
285
286 pub fn scale(&self) -> Option<(f64, f64)> {
290 if !self.shared {
291 return None;
292 }
293 if let Some((a, b)) = self.range {
294 let s = Scale::new(a, b);
295 return Some((s.min(), s.max()));
296 }
297 let ends = self
298 .items
299 .iter()
300 .filter_map(|(_, f)| f.value_range())
301 .flat_map(|(lo, hi)| [lo, hi]);
302 let (lo, hi) = super::describe::extent(ends)?;
303 let scale = Scale::new(lo, hi);
304 let scale = if self.items.iter().any(|(_, f)| f.has_axis()) {
305 scale.nice(5)
306 } else {
307 scale
308 };
309 Some((scale.min(), scale.max()))
310 }
311
312 fn facets(&self) -> Vec<Facet> {
315 let scale = self.scale();
316 self.items
317 .iter()
318 .map(|(_, f)| {
319 let f = match scale {
320 Some((lo, hi)) => f.clone().with_range(lo, hi),
321 None => f.clone(),
322 };
323 match f {
324 Facet::Line(c) if self.note => Facet::Line(c.legend(false)),
325 f => f,
326 }
327 })
328 .collect()
329 }
330
331 fn legend(
334 &self,
335 console: &Console,
336 options: &ConsoleOptions,
337 ascii: bool,
338 ) -> Vec<Vec<(String, Option<rich::Style>)>> {
339 let label_style = has_colour(console).then(|| theme_style(console, "chart.label"));
340 let mut names: Vec<String> = Vec::new();
341 let mut entries = Vec::new();
342 for (_, facet) in &self.items {
343 let Facet::Line(chart) = facet else {
344 continue;
345 };
346 if !chart.has_legend() {
347 continue;
348 }
349 for (index, series) in chart.all_series().iter().enumerate() {
350 if index < names.len() || series.name().is_empty() {
351 continue;
352 }
353 while names.len() < index {
354 names.push(String::new());
355 }
356 names.push(series.name().to_string());
357 let (marker, style) = chart.legend_entry(console, options, index);
358 entries.push(vec![
359 (marker.to_string(), style),
360 (" ".to_string(), None),
361 (truncate(series.name(), 40, ascii), label_style.clone()),
362 ]);
363 }
364 }
365 entries
366 }
367
368 fn note_lines(&self, console: &Console, options: &ConsoleOptions, ascii: bool) -> Vec<Line> {
370 if !self.note {
371 return Vec::new();
372 }
373 let mut entries = Vec::new();
374 if let Some((lo, hi)) = self.scale() {
375 let format = ValueFormat::Compact;
376 let style = has_colour(console).then(|| theme_style(console, "chart.axis"));
377 entries.push(vec![(
378 format!(
379 "scale {} to {} in every chart",
380 format.format(lo),
381 format.format(hi)
382 ),
383 style,
384 )]);
385 }
386 entries.extend(self.legend(console, options, ascii));
387 wrap_entries(entries, options.max_width)
388 }
389
390 fn chart_height(&self, given: Option<usize>, rows: usize, note: usize) -> Option<usize> {
392 if self.height.is_some() {
393 return self.height;
394 }
395 let given = given?;
396 let chrome = rows + rows.saturating_sub(1) + note;
398 Some((given.saturating_sub(chrome) / rows.max(1)).max(1))
399 }
400}
401
402fn to_lines(lines: Vec<Vec<Segment>>) -> Vec<Line> {
404 lines
405 .into_iter()
406 .map(|segments| {
407 let mut line = Line::new();
408 for s in segments.into_iter().filter(|s| !s.control) {
409 line.push(&s.text, s.style);
410 }
411 line
412 })
413 .collect()
414}
415
416impl Renderable for SmallMultiples {
417 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
418 let width = options.max_width;
419 let ascii = self.charset.resolve(console, options, Charset::Blocks) == Charset::Ascii;
420 if self.items.is_empty() {
421 let mut line = Line::new();
422 line.push(&truncate("no data", width, ascii), None);
423 return lines_to_segments(vec![line], width);
424 }
425 let columns = self.columns_at(width);
426 let gaps = self.gap.saturating_mul(columns - 1);
427 let cell = (width.saturating_sub(gaps) / columns).max(1);
428 let rows = self.items.len().div_ceil(columns);
429 let note = self.note_lines(console, options, ascii);
430 let height = self.chart_height(options.height, rows, note.len());
431 let title_style = has_colour(console).then(|| theme_style(console, "chart.title"));
432 let facets = self.facets();
433
434 let mut out: Vec<Line> = Vec::new();
435 for (row, chunk) in facets.chunks(columns).enumerate() {
436 if row > 0 {
437 out.push(Line::new());
438 }
439 let first = row * columns;
440 let mut blocks: Vec<Vec<Line>> = Vec::new();
441 for (k, facet) in chunk.iter().enumerate() {
442 let mut opts = options.update_width(cell);
443 opts.height = height;
444 let facet = match (facet, height) {
447 (Facet::Line(c), Some(h)) => Facet::Line(c.clone().height(h.saturating_sub(2))),
448 (f, _) => f.clone(),
449 };
450 let mut block = vec![{
451 let mut title = Line::new();
452 let text = truncate(&self.items[first + k].0, cell, ascii);
453 title.push(&text, title_style.clone());
454 title
455 }];
456 block.extend(to_lines(console.render_lines(&facet, &opts, false)));
457 blocks.push(block);
458 }
459 let tall = blocks.iter().map(Vec::len).max().unwrap_or(0);
460 for i in 0..tall {
461 let mut line = Line::new();
462 for (k, block) in blocks.iter_mut().enumerate() {
463 if k > 0 {
464 line.pad(self.gap);
465 }
466 let mut part = block.get_mut(i).map(std::mem::take).unwrap_or_default();
467 part.crop(cell);
468 let fill = cell - part.width();
469 line.append(part);
470 line.pad(fill);
471 }
472 out.push(line);
473 }
474 }
475 out.extend(note);
476 lines_to_segments(out, width)
477 }
478
479 fn measure(&self, _console: &Console, options: &ConsoleOptions) -> Measurement {
480 let min = self.min_width.min(options.max_width);
481 Measurement::new(min, options.max_width)
482 .with_maximum(options.max_width)
483 .normalize()
484 }
485}
486
487impl crate::a11y::AccessibleText for SmallMultiples {
490 fn accessible_text(&self, width: usize) -> String {
491 if self.items.is_empty() {
492 return "Small multiples, no charts".to_string();
493 }
494 let mut head = format!(
495 "Small multiples, {}",
496 super::describe::counted(self.items.len(), "chart", "charts")
497 );
498 if let Some((lo, hi)) = self.scale() {
499 head.push_str(&format!(
500 " on a shared scale {}",
501 super::describe::range(lo, hi, ValueFormat::Compact)
502 ));
503 }
504 let mut out = vec![head];
505 for ((title, _), facet) in self.items.iter().zip(self.facets()) {
506 let text = facet.accessible_text(width).replace('\n', "; ");
507 if title.is_empty() {
508 out.push(text);
509 } else {
510 out.push(format!("{title}: {text}"));
511 }
512 }
513 out.join("\n")
514 }
515}
516
517#[cfg(test)]
518mod tests {
519 use super::*;
520 use crate::a11y::AccessibleText;
521 use crate::chart::{Series, SeriesKind};
522
523 fn rows(width: usize, r: &dyn Renderable) -> Vec<String> {
524 Console::builder()
525 .width(width)
526 .color_system(None)
527 .build()
528 .render_export(r)
529 .lines()
530 .map(str::to_string)
531 .collect()
532 }
533
534 #[test]
535 fn a_huge_gap_or_width_does_not_overflow() {
536 let grid = sparks().gap(usize::MAX);
537 assert_eq!(grid.columns_at(usize::MAX), grid.items().len());
538 assert_eq!(grid.columns_at(80), 1);
539 assert_eq!(
540 sparks().min_cell_width(usize::MAX).columns_at(usize::MAX),
541 1
542 );
543 assert!(!rows(40, &sparks().gap(usize::MAX)).is_empty());
544 }
545
546 fn sparks() -> SmallMultiples {
547 SmallMultiples::new([
548 ("api", Sparkline::new([10.0, 20.0, 30.0, 40.0])),
549 ("web", Sparkline::new([5.0, 5.0, 10.0, 15.0])),
550 ("worker", Sparkline::new([40.0, 30.0, 20.0, 50.0])),
551 ("cron", Sparkline::new([0.0, 1.0, 0.0, 2.0])),
552 ])
553 }
554
555 #[test]
556 fn columns_follow_the_width() {
557 let grid = sparks();
558 assert_eq!(grid.columns_at(40), 1);
559 assert_eq!(grid.columns_at(80), 3);
560 assert_eq!(grid.columns_at(120), 4);
561 assert_eq!(grid.clone().columns(2).columns_at(120), 2);
562 assert_eq!(grid.clone().columns(9).columns_at(120), 4);
563 assert_eq!(grid.clone().min_cell_width(10).columns_at(40), 3);
564 }
565
566 #[test]
567 fn a_grid_at_40_80_and_120() {
568 let grid = sparks();
569 assert_eq!(
570 rows(40, &grid),
571 vec![
572 "api ",
573 "▂▄▅▇ ",
574 " ",
575 "web ",
576 "▂▂▂▃ ",
577 " ",
578 "worker ",
579 "▇▅▄█ ",
580 " ",
581 "cron ",
582 "▁▁▁▁ ",
583 "scale 0 to 50 in every chart ",
584 ]
585 );
586 assert_eq!(rows(80, &grid).len(), 6);
587 assert_eq!(
588 rows(120, &grid),
589 vec![
590 format!("{:<30}{:<30}{:<30}{:<28}", "api", "web", "worker", "cron"),
592 format!("{:<30}{:<30}{:<30}{:<28}", "▂▄▅▇", "▂▂▂▃", "▇▅▄█", "▁▁▁▁"),
593 format!("{:<118}", "scale 0 to 50 in every chart"),
594 ]
595 );
596 }
597
598 #[test]
599 fn the_scale_is_shared_unless_asked_not_to() {
600 let grid = sparks();
601 assert_eq!(grid.scale(), Some((0.0, 50.0)));
602 let own = grid.clone().shared_scale(false);
603 assert_eq!(own.scale(), None);
604 assert_eq!(rows(120, &own)[1].split_whitespace().last(), Some("▁▅▁█"));
606 assert_eq!(grid.clone().range(0.0, 100.0).scale(), Some((0.0, 100.0)));
607 let mixed = SmallMultiples::new([
609 ("bars", Facet::from(BarChart::new().bar("a", 12.0))),
610 (
611 "line",
612 Facet::from(LineChart::new().series(Series::from_values("p50", [13.0, 47.0]))),
613 ),
614 ]);
615 assert_eq!(mixed.scale(), Some((0.0, 50.0)));
616 }
617
618 #[test]
619 fn line_charts_share_one_legend() {
620 let chart = |ys: [f64; 3]| {
621 LineChart::new()
622 .series(Series::from_values("p50", ys))
623 .series(Series::from_values("p99", ys.map(|y| y * 2.0)).kind(SeriesKind::Scatter))
624 .charset(Charset::Ascii)
625 .height(3)
626 };
627 let grid =
628 SmallMultiples::new([("a", chart([1.0, 2.0, 3.0])), ("b", chart([2.0, 2.0, 2.0]))])
629 .min_cell_width(16);
630 let out = rows(48, &grid);
631 assert_eq!(
632 out.last().map(|l| l.trim_end()),
633 Some("scale 1 to 6 in every chart * p50 + p99")
634 );
635 assert!(
637 !out[..out.len() - 1].iter().any(|l| l.contains("p50")),
638 "{out:#?}"
639 );
640 }
641
642 #[test]
643 fn a_height_is_shared_among_the_rows() {
644 let grid = sparks().columns(2).note(false);
645 let console = Console::builder().width(40).color_system(None).build();
646 let lines = console.render_lines(&grid, &console.options().update_height(9), false);
647 assert_eq!(lines.len(), 9);
649 let fixed = grid.height(2);
650 assert_eq!(rows(40, &fixed).len(), 2 * 3 + 1);
651 }
652
653 #[test]
654 fn accessible_text_reads_each_chart() {
655 let grid = SmallMultiples::new([
656 ("api", Sparkline::new([10.0, 20.0, 30.0, 40.0])),
657 ("cron", Sparkline::new([0.0, 1.0, 0.0, 2.0])),
658 ]);
659 assert_eq!(
660 grid.accessible_text(80),
661 "Small multiples, 2 charts on a shared scale from 0 to 40\n\
662 api: Sparkline, 4 values: min 10, max 40, last 40, trend up\n\
663 cron: Sparkline, 4 values: min 0, max 2, last 2, trend up"
664 );
665 assert_eq!(
666 SmallMultiples::default().accessible_text(80),
667 "Small multiples, no charts"
668 );
669 }
670}