1use rich::cells::char_cell_width;
4use rich::measure::Measurement;
5use rich::{Console, ConsoleOptions, Renderable, Segment, Style};
6
7use super::axis::{self, AxisFit, AxisRequest};
8use super::canvas::bresenham;
9use super::{
10 Charset, DotCanvas, Line, Scale, ValueFormat, cells, has_colour, lines_to_segments, series_key,
11 theme_style, truncate, user_style,
12};
13
14const UNICODE_MARKERS: [char; 5] = ['●', '◆', '▲', '■', '○'];
16const ASCII_MARKERS: [char; 5] = ['*', '+', 'o', 'x', '.'];
17
18const MIN_PLOT: usize = 4;
20const MAX_HEIGHT: usize = u16::MAX as usize;
22
23#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
25pub enum SeriesKind {
26 #[default]
29 Line,
30 Scatter,
32}
33
34#[derive(Clone, Debug, PartialEq)]
36#[must_use = "builder methods return a new value; it does nothing until it is rendered or used"]
37pub struct Series {
38 name: String,
39 points: Vec<(f64, f64)>,
40 kind: SeriesKind,
41 style: Option<String>,
42 marker: Option<char>,
43}
44
45impl Series {
46 pub fn line(name: impl Into<String>, points: impl IntoIterator<Item = (f64, f64)>) -> Self {
48 Series {
49 name: name.into(),
50 points: points.into_iter().collect(),
51 kind: SeriesKind::Line,
52 style: None,
53 marker: None,
54 }
55 }
56
57 pub fn scatter(name: impl Into<String>, points: impl IntoIterator<Item = (f64, f64)>) -> Self {
59 Series {
60 kind: SeriesKind::Scatter,
61 ..Series::line(name, points)
62 }
63 }
64
65 pub fn from_values(name: impl Into<String>, values: impl IntoIterator<Item = f64>) -> Self {
67 Series::line(
68 name,
69 values.into_iter().enumerate().map(|(i, y)| (i as f64, y)),
70 )
71 }
72
73 pub fn kind(mut self, kind: SeriesKind) -> Self {
75 self.kind = kind;
76 self
77 }
78
79 pub fn style(mut self, style: impl Into<String>) -> Self {
82 self.style = Some(style.into());
83 self
84 }
85
86 pub fn marker(mut self, marker: char) -> Self {
91 self.marker = Some(marker);
92 self
93 }
94
95 pub fn name(&self) -> &str {
97 &self.name
98 }
99
100 pub fn points(&self) -> &[(f64, f64)] {
102 &self.points
103 }
104
105 fn finite(&self) -> impl Iterator<Item = (f64, f64)> + '_ {
106 self.points
107 .iter()
108 .copied()
109 .filter(|(x, y)| x.is_finite() && y.is_finite())
110 }
111}
112
113#[derive(Clone, Debug, PartialEq)]
163#[must_use = "builder methods return a new value; it does nothing until it is rendered or used"]
164pub struct LineChart {
165 series: Vec<Series>,
166 height: Option<usize>,
167 width: Option<usize>,
168 x_min: Option<f64>,
169 x_max: Option<f64>,
170 y_min: Option<f64>,
171 y_max: Option<f64>,
172 charset: Charset,
173 legend: bool,
174 y_format: ValueFormat,
175 x_format: ValueFormat,
176}
177
178impl Default for LineChart {
179 fn default() -> Self {
180 Self::new()
181 }
182}
183
184impl LineChart {
185 pub fn new() -> Self {
187 LineChart {
188 series: Vec::new(),
189 height: None,
190 width: None,
191 x_min: None,
192 x_max: None,
193 y_min: None,
194 y_max: None,
195 charset: Charset::Auto,
196 legend: true,
197 y_format: ValueFormat::Compact,
198 x_format: ValueFormat::Compact,
199 }
200 }
201
202 pub fn series(mut self, series: Series) -> Self {
204 self.series.push(series);
205 self
206 }
207
208 pub fn all_series(&self) -> &[Series] {
210 &self.series
211 }
212
213 pub fn height(mut self, rows: usize) -> Self {
217 self.height = Some(rows.clamp(1, MAX_HEIGHT));
218 self
219 }
220
221 pub fn width(mut self, width: usize) -> Self {
223 self.width = Some(width.max(1));
224 self
225 }
226
227 pub fn x_range(mut self, min: f64, max: f64) -> Self {
229 self.x_min = Some(min);
230 self.x_max = Some(max);
231 self
232 }
233
234 pub fn y_range(mut self, min: f64, max: f64) -> Self {
236 self.y_min = Some(min);
237 self.y_max = Some(max);
238 self
239 }
240
241 pub fn charset(mut self, charset: Charset) -> Self {
243 self.charset = charset;
244 self
245 }
246
247 pub fn legend(mut self, show: bool) -> Self {
249 self.legend = show;
250 self
251 }
252
253 pub fn y_format(mut self, format: ValueFormat) -> Self {
255 self.y_format = format;
256 self
257 }
258
259 pub fn x_format(mut self, format: ValueFormat) -> Self {
261 self.x_format = format;
262 self
263 }
264
265 fn y_axis(&self) -> AxisFit {
267 let ys = self.series.iter().flat_map(|s| s.finite().map(|p| p.1));
268 let fixed = self.y_min.is_some() || self.y_max.is_some();
269 let data = Scale::from_values(ys).bounds(self.y_min, self.y_max);
270 let (cells, wanted): (Vec<usize>, usize) = match self.height {
271 Some(rows) => (vec![rows - 1], (rows / 2 + 1).clamp(2, 6)),
272 None => (vec![7, 6, 8, 5, 9], 5),
273 };
274 axis::fit(&AxisRequest {
275 data,
276 fixed,
277 cells: &cells,
278 wanted,
279 label_room: false,
280 format: self.y_format,
281 })
282 }
283
284 fn x_axis(&self, columns: usize) -> AxisFit {
286 let xs = self.series.iter().flat_map(|s| s.finite().map(|p| p.0));
287 let fixed = self.x_min.is_some() || self.x_max.is_some();
288 let data = Scale::from_values(xs).bounds(self.x_min, self.x_max);
289 let guess =
290 cells(&self.x_format.format(data.min())).max(cells(&self.x_format.format(data.max())));
291 axis::fit(&AxisRequest {
292 data,
293 fixed,
294 cells: &[columns.saturating_sub(1)],
295 wanted: (columns / (guess + 3)).max(2),
296 label_room: true,
297 format: self.x_format,
298 })
299 }
300
301 fn marker(&self, index: usize, ascii: bool) -> char {
302 let set = if ascii {
303 &ASCII_MARKERS
304 } else {
305 &UNICODE_MARKERS
306 };
307 self.series[index]
308 .marker
309 .filter(|m| char_cell_width(*m) == 1 && (!ascii || m.is_ascii()))
310 .unwrap_or(set[index % set.len()])
311 }
312
313 fn series_style(&self, console: &Console, index: usize) -> Style {
314 match &self.series[index].style {
315 Some(spec) => user_style(console, spec),
316 None => theme_style(console, &series_key(index)),
317 }
318 }
319
320 fn lines(&self, console: &Console, options: &ConsoleOptions) -> Vec<Line> {
321 let width = self
322 .width
323 .map_or(options.max_width, |w| w.min(options.max_width));
324 let mut charset = self.charset.resolve(console, options, Charset::Braille);
325 let colour = has_colour(console);
326 if charset == Charset::Braille && !colour && self.series.len() > 1 {
327 charset = Charset::Blocks;
328 }
329 let ascii = charset == Charset::Ascii;
330 if self.series.iter().all(|s| s.finite().next().is_none()) {
331 let mut line = Line::new();
332 line.push(&truncate("no data", width, ascii), None);
333 return vec![line];
334 }
335 let y_axis = self.y_axis();
336 let rows = y_axis.cells + 1;
337 let axis_style = colour.then(|| theme_style(console, "chart.axis"));
338 let label_style = colour.then(|| theme_style(console, "chart.label"));
339
340 let label_w = y_axis
342 .labels
343 .iter()
344 .map(|(_, l)| cells(l))
345 .max()
346 .unwrap_or(0);
347 let (labels, axis) = if label_w > 0 && width >= label_w + 2 + MIN_PLOT {
348 (true, true)
349 } else if width >= 2 {
350 (false, true)
351 } else {
352 (false, false)
353 };
354 let prefix = if labels { label_w + 1 } else { 0 } + usize::from(axis);
355 let plot_w = width.saturating_sub(prefix).max(1);
356 let x_axis = self.x_axis(plot_w);
357
358 let braille = charset == Charset::Braille;
361 let (dot_w, dot_h) = if braille {
362 (plot_w * 2, rows * 4)
363 } else {
364 (plot_w, rows)
365 };
366 let to_dot = |x: f64, y: f64| -> (i64, i64) {
367 let col = x_axis.position(x);
368 let row = y_axis.cells as f64 - y_axis.position(y);
369 let (dx, dy) = if braille {
370 (col * 2.0 + 0.5, row * 4.0 + 1.5)
371 } else {
372 (col, row)
373 };
374 (
375 (dx.round() as i64).clamp(0, dot_w as i64 - 1),
376 (dy.round() as i64).clamp(0, dot_h as i64 - 1),
377 )
378 };
379 let mut canvas = DotCanvas::new(plot_w, rows);
380 let mut grid: Vec<Option<(char, usize)>> = vec![None; plot_w * rows];
381 for (index, series) in self.series.iter().enumerate() {
382 let marker = self.marker(index, ascii);
383 let mut plot = |(x, y): (i64, i64)| {
384 if braille {
385 canvas.set(x, y, index);
386 } else if x >= 0 && y >= 0 && (x as usize) < plot_w && (y as usize) < rows {
387 grid[y as usize * plot_w + x as usize] = Some((marker, index));
388 }
389 };
390 let mut previous: Option<(i64, i64)> = None;
391 for &(x, y) in &series.points {
392 if !(x.is_finite() && y.is_finite()) {
393 previous = None;
394 continue;
395 }
396 let point = to_dot(x, y);
397 match (series.kind, previous) {
398 (SeriesKind::Line, Some(from)) => {
399 for p in bresenham(from, point) {
400 plot(p);
401 }
402 }
403 _ => plot(point),
404 }
405 previous = Some(point);
406 }
407 }
408
409 let mut tick_rows: Vec<Option<&str>> = vec![None; rows];
411 for (position, label) in &y_axis.labels {
412 tick_rows[y_axis.cells - position] = Some(label.as_str());
413 }
414
415 let styles: Vec<Option<Style>> = (0..self.series.len())
416 .map(|i| colour.then(|| self.series_style(console, i)))
417 .collect();
418 let (v_axis, v_tick, corner, h_axis, h_tick) = if ascii {
419 ('|', '+', '+', '-', '+')
420 } else {
421 ('│', '┤', '└', '─', '┬')
422 };
423 let mut out = Vec::new();
424 for (row, tick) in tick_rows.iter().enumerate() {
425 let mut line = Line::new();
426 if labels {
427 let label = tick.unwrap_or("");
428 line.pad(label_w - cells(label));
429 line.push(label, label_style.clone());
430 line.pad(1);
431 }
432 if axis {
433 let glyph = if tick.is_some() { v_tick } else { v_axis };
434 line.push(&glyph.to_string(), axis_style.clone());
435 }
436 for col in 0..plot_w {
437 let (glyph, layer) = if braille {
438 if canvas.is_set(col, row) {
439 let (c, layer) = canvas.cell(col, row);
440 (c, layer)
441 } else {
442 (' ', None)
443 }
444 } else {
445 match grid[row * plot_w + col] {
446 Some((c, layer)) => (c, Some(layer)),
447 None => (' ', None),
448 }
449 };
450 let style = layer.and_then(|l| styles[l].clone());
451 line.push(&glyph.to_string(), style);
452 }
453 out.push(line);
454 }
455
456 if axis {
458 let x_ticks: Vec<(usize, String)> = x_axis
460 .labels
461 .iter()
462 .filter(|(col, _)| *col < plot_w)
463 .cloned()
464 .collect();
465 let mut rule = Line::new();
466 rule.pad(prefix - 1);
467 let mut glyphs: Vec<char> = vec![h_axis; plot_w];
468 for (col, _) in &x_ticks {
469 glyphs[*col] = h_tick;
470 }
471 rule.push(&corner.to_string(), axis_style.clone());
472 rule.push(&glyphs.into_iter().collect::<String>(), axis_style.clone());
473 out.push(rule);
474
475 let mut row = vec![' '; plot_w];
476 let mut free_from = 0usize;
477 for (col, label) in &x_ticks {
478 let len = label.chars().count();
479 if len > plot_w {
480 continue;
481 }
482 let start = col.saturating_sub(len / 2).min(plot_w - len);
483 if start < free_from {
484 continue;
485 }
486 for (i, c) in label.chars().enumerate() {
487 row[start + i] = c;
488 }
489 free_from = start + len + 1;
490 }
491 let mut labels_line = Line::new();
492 labels_line.pad(prefix);
493 labels_line.push(&row.into_iter().collect::<String>(), label_style.clone());
494 out.push(labels_line);
495 }
496
497 if self.legend && self.series.iter().any(|s| !s.name.is_empty()) {
499 let mut line = Line::new();
500 for (index, series) in self.series.iter().enumerate() {
501 let name = truncate(&series.name, width.saturating_sub(2), ascii);
502 let entry_w = 2 + cells(&name);
503 if line.width() > 0 && line.width() + 2 + entry_w > width {
504 out.push(std::mem::take(&mut line));
505 }
506 if line.width() > 0 {
507 line.pad(2);
508 }
509 line.push(
510 &self.marker(index, ascii).to_string(),
511 styles[index].clone(),
512 );
513 line.pad(1);
514 line.push(&name, label_style.clone());
515 }
516 out.push(line);
517 }
518 out
519 }
520}
521
522impl crate::a11y::AccessibleText for LineChart {
526 fn accessible_text(&self, _width: usize) -> String {
527 use super::describe::{counted, extent, range, series};
528 let lines = self
529 .series
530 .iter()
531 .filter(|s| s.kind == SeriesKind::Line)
532 .count();
533 let what = match (lines, self.series.len() - lines) {
534 (_, 0) => "Line chart",
535 (0, _) => "Scatter chart",
536 _ => "Line and scatter chart",
537 };
538 if self.series.is_empty() {
539 return format!("{what}, no series");
540 }
541 let mut head = format!("{what}, {}", counted(self.series.len(), "series", "series"));
542 let finite = || self.series.iter().flat_map(|s| s.finite());
543 let axis = |fixed: (Option<f64>, Option<f64>), data: Option<(f64, f64)>, format| {
544 let lo = fixed.0.or(data.map(|d| d.0))?;
545 let hi = fixed.1.or(data.map(|d| d.1))?;
546 Some(range(lo.min(hi), lo.max(hi), format))
547 };
548 if let Some(x) = axis(
549 (self.x_min, self.x_max),
550 extent(finite().map(|p| p.0)),
551 self.x_format,
552 ) {
553 head.push_str(&format!(", x {x}"));
554 }
555 if let Some(y) = axis(
556 (self.y_min, self.y_max),
557 extent(finite().map(|p| p.1)),
558 self.y_format,
559 ) {
560 head.push_str(&format!(", y {y}"));
561 }
562 let mut out = vec![head];
563 for (index, s) in self.series.iter().enumerate() {
564 let name = if s.name.is_empty() {
565 format!("series {}", index + 1)
566 } else {
567 s.name.clone()
568 };
569 let kind = match s.kind {
570 SeriesKind::Line => "line",
571 SeriesKind::Scatter => "points",
572 };
573 let facts = series(&s.points, self.y_format, ("point", "points"));
574 out.push(format!("{name}, {kind}: {facts}"));
575 }
576 out.join("\n")
577 }
578}
579
580impl LineChart {
581 pub(crate) fn y_extent(&self) -> Option<(f64, f64)> {
584 let data =
585 super::describe::extent(self.series.iter().flat_map(|s| s.finite().map(|p| p.1)));
586 let lo = self.y_min.or(data.map(|d| d.0))?;
587 let hi = self.y_max.or(data.map(|d| d.1))?;
588 Some((lo.min(hi), lo.max(hi)))
589 }
590
591 pub(crate) fn has_legend(&self) -> bool {
593 self.legend && self.series.iter().any(|s| !s.name.is_empty())
594 }
595
596 pub(crate) fn legend_entry(
598 &self,
599 console: &Console,
600 options: &ConsoleOptions,
601 index: usize,
602 ) -> (char, Option<Style>) {
603 let ascii = self.charset.resolve(console, options, Charset::Braille) == Charset::Ascii;
604 (
605 self.marker(index, ascii),
606 has_colour(console).then(|| self.series_style(console, index)),
607 )
608 }
609}
610
611impl crate::surface::Draw for LineChart {
612 fn draw(
613 &self,
614 console: &Console,
615 options: &ConsoleOptions,
616 screen: &mut crate::surface::Screen,
617 rect: crate::surface::Rect,
618 ) {
619 super::draw_lines(
620 self.lines(console, options),
621 options.max_width,
622 options,
623 screen,
624 rect,
625 );
626 }
627}
628
629impl Renderable for LineChart {
630 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
631 lines_to_segments(self.lines(console, options), options.max_width)
632 }
633
634 fn measure(&self, _console: &Console, options: &ConsoleOptions) -> Measurement {
635 let max = self.width.unwrap_or(options.max_width);
636 Measurement::new(max.min(12), max)
637 .with_maximum(options.max_width)
638 .normalize()
639 }
640}
641
642#[cfg(test)]
643mod tests {
644 use super::*;
645 use crate::a11y::AccessibleText;
646
647 #[test]
648 fn accessible_text_reads_each_series() {
649 let chart = LineChart::new()
650 .series(Series::from_values("api", [1.0, 3.0, 2.0, 5.0]))
651 .series(Series::scatter("p99", [(0.0, 9.0), (3.0, 7.0)]));
652 assert_eq!(
653 chart.accessible_text(80),
654 "Line and scatter chart, 2 series, x from 0 to 3, y from 1 to 9\n\
655 api, line: 4 points, min 1, max 5, last 5, trend up\n\
656 p99, points: 2 points, min 7, max 9, last 7, trend down"
657 );
658 let fixed = LineChart::new()
660 .series(Series::from_values("", [4.0, 4.0, f64::NAN]))
661 .y_range(0.0, 10.0);
662 assert_eq!(
663 fixed.accessible_text(80),
664 "Line chart, 1 series, x from 0 to 1, y from 0 to 10\n\
665 series 1, line: 3 points, 1 missing, min 4, max 4, last 4, trend flat"
666 );
667 assert_eq!(
668 LineChart::new().accessible_text(80),
669 "Line chart, no series"
670 );
671 }
672}