1use ratatui::{
6 layout::{Constraint, Direction, Layout, Rect},
7 style::{Modifier, Style},
8 text::{Line, Span},
9 widgets::{Chart, Clear, Dataset, GraphType, Paragraph, Widget, Wrap},
10};
11
12use crate::chart_data::{
13 AxisNumbers, BarData, BoxPlotData, HeatmapData, HistogramData, KdeData, XAxisTemporalKind,
14 segments,
15};
16use crate::chart_modal::{
17 Aggregate, ChartFocus, ChartModal, Cumulative, Mark, PickerFor, ShelfUse, TimeUnit,
18};
19use crate::config::Theme;
20use crate::glyphs::Glyphs;
21use crate::pointer::Hit;
22use crate::render::context::RenderContext;
23use crate::widgets::axes::{
24 AxisSpec, Legend, PlotAxes, Track, cut, fit_x_labels, fit_y_labels, resolution,
25};
26use crate::widgets::crosshair::{self, PlotPlace};
27use crate::widgets::ui::{Picker, SectionRule, Surface, Working};
28use polars::prelude::Schema;
29use unicode_width::UnicodeWidthStr;
30
31const SIDEBAR_WIDTH: u16 = 40;
33const LABEL_WIDTH: u16 = 13;
36const HEATMAP_TITLE_HEIGHT: u16 = 1;
37const HEATMAP_X_LABEL_HEIGHT: u16 = 2;
38
39pub struct ChartView<'a> {
42 pub data: ChartRenderData<'a>,
43 pub notes: Vec<String>,
45 pub error: Option<&'a str>,
47 pub working: Option<Working<'a>>,
50 pub schema: Option<&'a Schema>,
52}
53
54pub enum ChartRenderData<'a> {
55 XY {
56 series: Option<&'a Vec<Vec<(f64, f64)>>>,
57 breaks: Option<&'a Vec<Vec<usize>>>,
59 values: Option<&'a Vec<Vec<(f64, f64)>>>,
62 names: &'a [String],
64 x_axis_kind: XAxisTemporalKind,
65 x_bounds: Option<(f64, f64)>,
66 numbers: PlotNumbers,
67 other: bool,
69 },
70 Histogram {
71 data: Option<&'a HistogramData>,
72 x: AxisNumbers,
74 },
75 BoxPlot {
76 data: Option<&'a BoxPlotData>,
77 y: AxisNumbers,
78 },
79 Kde {
80 data: Option<&'a KdeData>,
81 x: AxisNumbers,
82 },
83 Heatmap {
84 data: Option<&'a HeatmapData>,
85 numbers: PlotNumbers,
86 },
87 Bar {
88 data: Option<&'a BarData>,
89 },
90}
91
92impl ChartRenderData<'_> {
93 fn draws_plot(&self) -> bool {
96 match self {
97 Self::XY { .. } => true,
98 Self::Histogram { data, .. } => data.is_some(),
99 Self::BoxPlot { data, .. } => data.is_some(),
100 Self::Kde { data, .. } => data.is_some(),
101 Self::Heatmap { data, .. } => data.is_some(),
102 Self::Bar { data } => data.is_some(),
103 }
104 }
105}
106
107#[derive(Default)]
110pub struct PlotNumbers {
111 pub x: AxisNumbers,
112 pub y: AxisNumbers,
113}
114
115enum PanelLine {
117 Blank,
118 Rule(&'static str),
119 Row {
121 label: &'static str,
122 value: Vec<Span<'static>>,
123 field: Option<ChartFocus>,
124 dimmed: bool,
125 },
126}
127
128fn column_span(name: &str, schema: Option<&Schema>, ctx: &RenderContext) -> Span<'static> {
130 let color = schema
131 .and_then(|s| s.get(name))
132 .map(|dtype| ctx.type_color(dtype))
133 .unwrap_or(ctx.text_primary);
134 Span::styled(name.to_string(), Style::default().fg(color))
135}
136
137fn plain(text: impl Into<String>, ctx: &RenderContext) -> Span<'static> {
138 Span::styled(text.into(), Style::default().fg(ctx.text_primary))
139}
140
141fn quiet(text: impl Into<String>, ctx: &RenderContext) -> Span<'static> {
142 Span::styled(text.into(), Style::default().fg(ctx.dimmed))
143}
144
145fn columns_spans(
147 names: &[String],
148 schema: Option<&Schema>,
149 ctx: &RenderContext,
150) -> Vec<Span<'static>> {
151 let mut spans = Vec::new();
152 for (i, name) in names.iter().enumerate() {
153 if i > 0 {
154 spans.push(quiet(", ", ctx));
155 }
156 spans.push(column_span(name, schema, ctx));
157 }
158 spans
159}
160
161fn panel_lines(modal: &ChartModal, schema: Option<&Schema>, ctx: &RenderContext) -> Vec<PanelLine> {
164 let g = crate::glyphs::get();
165 let spec = &modal.spec;
166 let mark = spec.mark;
167 let encoding = &spec.encoding;
168 let row = |label, value, field: Option<ChartFocus>, dimmed| PanelLine::Row {
169 label,
170 value,
171 field,
172 dimmed,
173 };
174 let sub = |value, field: Option<ChartFocus>, dimmed| PanelLine::Row {
175 label: "",
176 value,
177 field,
178 dimmed,
179 };
180 let pick = |what: &str| vec![quiet(format!("pick {what}"), ctx)];
181 let mut lines = vec![PanelLine::Rule("Chart"), PanelLine::Blank];
182
183 lines.push(row(
185 "Type",
186 vec![plain(mark.label(), ctx)],
187 Some(ChartFocus::Type),
188 false,
189 ));
190 if let Some(dtype) = &modal.suggested {
191 lines.push(sub(
192 vec![quiet(format!("suggested for {dtype}"), ctx)],
193 None,
194 false,
195 ));
196 }
197 lines.push(PanelLine::Blank);
198
199 let x_value = match encoding.x.field.as_deref() {
201 Some(x) => vec![column_span(x, schema, ctx)],
202 None if mark == Mark::Box => vec![quiet(crate::chart_modal::NONE_ITEM, ctx)],
203 None if mark == Mark::Bar => pick("a category"),
204 None => pick("a column"),
205 };
206 lines.push(row("X", x_value, Some(ChartFocus::X), false));
207 let rows = modal.row_order();
208 if rows.contains(&ChartFocus::TimeUnit) {
209 let unit = encoding.x.time_unit;
210 let value = if unit == TimeUnit::None {
211 vec![quiet("by row", ctx)]
212 } else {
213 vec![quiet("by ", ctx), plain(unit.label(), ctx)]
214 };
215 lines.push(sub(value, Some(ChartFocus::TimeUnit), false));
216 }
217 if mark == Mark::Bar {
218 let order = match modal.bar_order {
219 crate::chart_data::BarOrder::Value => format!("by value {}", g.sort_desc),
220 crate::chart_data::BarOrder::Label => format!("by label {}", g.sort_asc),
221 };
222 lines.push(sub(vec![plain(order, ctx)], Some(ChartFocus::Order), false));
223 }
224 if mark == Mark::Histogram {
225 let mut value = vec![plain(format!("{} bins", modal.hist_bins), ctx)];
226 if modal.value_range != crate::chart_data::ValueRange::All {
227 value.push(quiet(
228 format!(" {} {}", g.middot, modal.value_range.label()),
229 ctx,
230 ));
231 }
232 lines.push(sub(value, Some(ChartFocus::Bins), false));
233 }
234 lines.push(PanelLine::Blank);
235
236 match (mark, modal.y_use()) {
238 (_, ShelfUse::Dimmed(why)) => lines.push(row("Y", vec![quiet(why, ctx)], None, true)),
239 (Mark::Histogram, _) => {
240 let value = if modal.share {
241 if modal.colored() {
242 "share of group"
243 } else {
244 "share"
245 }
246 } else {
247 "count"
248 };
249 lines.push(row(
250 "Y",
251 vec![plain(value, ctx)],
252 Some(ChartFocus::Y),
253 false,
254 ));
255 }
256 _ => {
257 let value = if encoding.y.aggregate == Aggregate::Count {
258 vec![plain("rows", ctx)]
259 } else if encoding.y.field.is_empty() {
260 pick("a column")
261 } else {
262 columns_spans(&encoding.y.field, schema, ctx)
263 };
264 lines.push(row("Y", value, Some(ChartFocus::Y), false));
265 }
266 }
267 if modal.takes_aggregate() {
268 let value = match encoding.y.cumulative {
270 Cumulative::Off if encoding.y.aggregate.follows_row_order() => {
272 let order = modal.row_order.as_deref().unwrap_or("row order");
273 vec![
274 plain(encoding.y.aggregate.label(), ctx),
275 quiet(format!(" {} by {order}", g.middot), ctx),
276 ]
277 }
278 Cumulative::Off => vec![plain(encoding.y.aggregate.label(), ctx)],
279 _ if encoding.y.aggregate == Aggregate::Count => vec![plain("running count", ctx)],
280 how => vec![
281 quiet(encoding.y.aggregate.label(), ctx),
282 quiet(format!(" {} ", g.middot), ctx),
283 plain(how.label(), ctx),
284 quiet(" of rows", ctx),
285 ],
286 };
287 lines.push(row("Aggregate", value, Some(ChartFocus::Aggregate), false));
289 if rows.contains(&ChartFocus::Quantile) {
290 let p = format!("p{}", encoding.y.quantile());
291 lines.push(sub(vec![plain(p, ctx)], Some(ChartFocus::Quantile), false));
292 }
293 }
294 lines.push(PanelLine::Blank);
295
296 match modal.color_use() {
298 ShelfUse::Dimmed(why) => {
299 let value = match encoding.color.field.as_deref() {
300 Some(c) => c.to_string(),
301 None => crate::chart_modal::NONE_ITEM.to_string(),
302 };
303 lines.push(row("Color", vec![quiet(value, ctx)], None, true));
304 lines.push(sub(vec![quiet(why, ctx)], None, true));
305 }
306 ShelfUse::Used => {
307 let value = match encoding.color.field.as_deref() {
308 Some(c) => vec![column_span(c, schema, ctx)],
309 None => vec![quiet(crate::chart_modal::NONE_ITEM, ctx)],
310 };
311 lines.push(row("Color", value, Some(ChartFocus::Color), false));
312 if encoding.color.field.is_some() {
313 lines.push(sub(
314 color_values_line(modal, ctx),
315 Some(ChartFocus::ColorValues),
316 false,
317 ));
318 }
319 }
320 }
321 lines.push(PanelLine::Blank);
322
323 lines.push(PanelLine::Rule("Options"));
325 lines.push(PanelLine::Blank);
326 let on_off = |on: bool| plain(if on { "on" } else { "off" }, ctx);
327 for field in rows.iter().copied() {
328 let (label, value) = match field {
329 ChartFocus::Cumulative => ("Cumulative", plain(encoding.y.cumulative.label(), ctx)),
330 ChartFocus::Bins if mark == Mark::Heatmap => {
331 ("Bins", plain(modal.heatmap_bins.to_string(), ctx))
332 }
333 ChartFocus::Bandwidth => (
334 "Bandwidth",
335 plain(format!("{:.1}x", modal.kde_bandwidth_factor), ctx),
336 ),
337 ChartFocus::Range => ("Range", plain(modal.value_range.label(), ctx)),
338 ChartFocus::YStartsAtZero => ("Y from zero", on_off(modal.y_starts_at_zero)),
339 ChartFocus::LogScale => ("Log scale", on_off(modal.log_scale)),
340 ChartFocus::ShowLegend => (
341 "Legend",
342 plain(if modal.show_legend { "auto" } else { "off" }, ctx),
343 ),
344 ChartFocus::Grid => ("Grid", on_off(modal.grid)),
345 ChartFocus::LimitRows => {
346 lines.push(row("Rows", rows_value(modal, ctx), Some(field), false));
347 if modal.focus == field && !modal.plot_focus {
349 lines.push(sub(rows_hint(modal, ctx), None, false));
350 }
351 continue;
352 }
353 _ => continue,
354 };
355 lines.push(row(label, vec![value], Some(field), false));
356 }
357 if modal.aggregates() {
358 lines.push(row("Rows", vec![quiet("all, exact", ctx)], None, false));
359 }
360 lines
361}
362
363fn rows_value(modal: &ChartModal, ctx: &RenderContext) -> Vec<Span<'static>> {
366 let shown = modal.rows_shown();
367 if let Some(typed) = shown.typed {
368 return vec![
369 plain(format!("Sample {typed}"), ctx),
370 Span::styled(crate::glyphs::get().cursor, Style::default().fg(ctx.accent)),
371 ];
372 }
373 if shown.every {
374 let mut spans = vec![plain("Every row", ctx)];
375 if let Some(rows) = modal.view_rows {
376 spans.push(quiet(
377 format!(" ({})", crate::discover::format_rows(rows)),
378 ctx,
379 ));
380 }
381 return spans;
382 }
383 vec![plain(
384 format!("Sample {}", crate::numfmt::group_chrome(modal.sample_rows)),
385 ctx,
386 )]
387}
388
389fn rows_hint(modal: &ChartModal, ctx: &RenderContext) -> Vec<Span<'static>> {
392 let g = crate::glyphs::get();
393 if let Some(error) = modal.rows_shown().error {
394 return vec![Span::styled(error, Style::default().fg(ctx.warning))];
395 }
396 if modal.rows_pending() {
397 return vec![quiet("Enter to read", ctx)];
398 }
399 vec![quiet(
400 format!(
401 "{}/{} switch {} type a size",
402 g.arrow_left, g.arrow_right, g.middot
403 ),
404 ctx,
405 )]
406}
407
408fn color_values_line(modal: &ChartModal, ctx: &RenderContext) -> Vec<Span<'static>> {
411 let picked = &modal.spec.encoding.color.values;
412 let Some(counts) = modal
413 .color_counts
414 .as_ref()
415 .filter(|_| modal.has_color_counts())
416 else {
417 return vec![quiet("counting values", ctx)];
418 };
419 let total = counts.values.len();
420 let of = crate::numfmt::group_chrome(total);
421 let (drawn, rest) = if picked.is_empty() {
422 let drawn = total.min(modal.series_max());
423 (format!("top {drawn}"), total - drawn)
424 } else {
425 let rest = counts
426 .values
427 .iter()
428 .filter(|(v, _)| !picked.contains(v))
429 .count();
430 (format!("{} picked", picked.len()), rest)
431 };
432 if picked.is_empty() && rest == 0 {
433 return vec![plain(format!("all {total}"), ctx)];
434 }
435 if modal.shows_other() && rest > 0 {
437 let rest = crate::numfmt::group_chrome(rest);
438 return vec![plain(drawn, ctx), plain(format!(" + {rest} other"), ctx)];
439 }
440 let by_rows = if picked.is_empty() { " by rows" } else { "" };
441 vec![plain(drawn, ctx), quiet(format!(" of {of}{by_rows}"), ctx)]
442}
443
444fn render_sidebar(
448 area: Rect,
449 buf: &mut ratatui::buffer::Buffer,
450 modal: &ChartModal,
451 schema: Option<&Schema>,
452 ctx: &RenderContext,
453) -> Option<Rect> {
454 if area.height == 0 || area.width < 4 {
455 return None;
456 }
457 let mut lines = panel_lines(modal, schema, ctx);
458 if lines.len() > area.height as usize {
459 lines.retain(|l| !matches!(l, PanelLine::Blank));
460 }
461 let focus = (!modal.plot_focus).then_some(modal.focus);
462 let at = lines
463 .iter()
464 .position(
465 |l| matches!(l, PanelLine::Row { field, .. } if *field == focus && focus.is_some()),
466 )
467 .unwrap_or(0);
468 let height = area.height as usize;
469 let first = at.saturating_sub(height.saturating_sub(1));
470 let g = crate::glyphs::get();
471 let mut focused_at = None;
472 for (i, line) in lines.iter().enumerate().skip(first).take(height) {
473 let y = area.y + (i - first) as u16;
474 let row = Rect {
475 y,
476 height: 1,
477 ..area
478 };
479 match line {
480 PanelLine::Blank => {}
481 PanelLine::Rule(title) => SectionRule { title, chip: None }.render(
482 Rect {
483 x: area.x + 1,
484 width: area.width - 1,
485 ..row
486 },
487 buf,
488 ctx,
489 ),
490 PanelLine::Row {
491 label,
492 value,
493 field,
494 dimmed,
495 } => {
496 let focused = field.is_some() && *field == focus;
497 if let Some(field) = field {
498 crate::pointer::record_field::<ChartModal>(row, *field);
499 }
500 if focused {
501 focused_at = Some(row);
502 if modal.picker.is_none() {
505 buf.set_string(area.x, y, g.rail, Style::default().fg(ctx.accent));
506 }
507 }
508 let label_style = if *dimmed {
509 Style::default().fg(ctx.dimmed)
510 } else if focused {
511 Style::default().fg(ctx.accent).add_modifier(Modifier::BOLD)
512 } else {
513 Style::default().fg(ctx.label)
514 };
515 let label_area = Rect {
516 x: area.x + 2,
517 width: LABEL_WIDTH.min(area.width.saturating_sub(2)),
518 ..row
519 };
520 Paragraph::new(*label)
521 .style(label_style)
522 .render(label_area, buf);
523 let value_x = area.x + 2 + LABEL_WIDTH;
524 if value_x < area.right() {
525 let spans: Vec<Span> = if *dimmed {
526 value
527 .iter()
528 .map(|s| {
529 Span::styled(s.content.clone(), Style::default().fg(ctx.dimmed))
530 })
531 .collect()
532 } else {
533 value.clone()
534 };
535 Paragraph::new(Line::from(spans)).render(
536 Rect {
537 x: value_x,
538 width: area.right() - value_x,
539 ..row
540 },
541 buf,
542 );
543 }
544 }
545 }
546 }
547 focused_at
548}
549
550fn render_picker(
553 area: Rect,
554 anchor: Rect,
555 buf: &mut ratatui::buffer::Buffer,
556 modal: &ChartModal,
557 ctx: &RenderContext,
558) {
559 let Some(state) = &modal.picker else {
560 return;
561 };
562 crate::pointer::record(area, Hit::Picker);
564 let title = match modal.picker_for {
565 Some(PickerFor::X) => "X",
566 Some(PickerFor::Y) => "Y",
567 Some(PickerFor::Color) => "Color",
568 Some(PickerFor::ColorValues) => modal.color().map(String::as_str).unwrap_or("Values"),
569 None => "",
570 };
571 let filtered = state.filtered();
572 let widest = filtered
573 .iter()
574 .map(|(_, item)| UnicodeWidthStr::width(*item))
575 .max()
576 .unwrap_or(0)
577 .max(14) as u16;
578 let detail_w = modal
579 .picker_details
580 .iter()
581 .map(|d| d.len())
582 .max()
583 .unwrap_or(0) as u16;
584 let width = (widest + detail_w + 9).clamp(28, 44).min(area.width);
585 let x = (anchor.x + 2 + LABEL_WIDTH.saturating_sub(4)).min(area.right().saturating_sub(width));
586 let y = anchor.y + 1;
587 let height = u16::try_from(filtered.len())
588 .unwrap_or(u16::MAX)
589 .saturating_add(5)
590 .min(area.bottom().saturating_sub(y))
591 .max(5);
592 let y = y.min(area.bottom().saturating_sub(height));
593 let frame = Rect {
594 x,
595 y,
596 width,
597 height: height.min(area.height),
598 };
599 Clear.render(frame, buf);
600 let content = Surface::new(title).render(frame, buf, ctx);
601 if content.height == 0 {
602 return;
603 }
604 let filter = if state.filter.is_empty() {
605 quiet("type to narrow", ctx)
606 } else {
607 plain(state.filter.clone(), ctx)
608 };
609 let g = crate::glyphs::get();
610 Paragraph::new(Line::from(vec![
611 quiet(format!("{} ", g.prompt), ctx),
612 filter,
613 ]))
614 .render(
615 Rect {
616 height: 1,
617 ..content
618 },
619 buf,
620 );
621 let mut list = Rect {
622 y: content.y + 1,
623 height: content.height.saturating_sub(1),
624 ..content
625 };
626 if modal.picker_for == Some(PickerFor::ColorValues)
627 && modal.spec.encoding.color.values.is_empty()
628 && list.height > 1
629 {
630 Paragraph::new(quiet(
631 format!("default: top {} by rows", modal.series_max()),
632 ctx,
633 ))
634 .render(Rect { height: 1, ..list }, buf);
635 list.y += 1;
636 list.height -= 1;
637 }
638 let mut picker = Picker::from_state(state, true);
639 if modal.picker_multi() {
640 let marks = filtered.iter().map(|(i, _)| modal.is_marked(*i)).collect();
641 picker = picker.marks(marks);
642 }
643 if !modal.picker_details.is_empty() {
644 let details = filtered
645 .iter()
646 .map(|(i, _)| modal.picker_details.get(*i).cloned().unwrap_or_default())
647 .collect();
648 picker = picker.details(details);
649 }
650 picker.render(list, buf, ctx);
651}
652
653const NOTE_MIN: usize = 8;
655
656fn render_title(
662 area: Rect,
663 buf: &mut ratatui::buffer::Buffer,
664 modal: &ChartModal,
665 notes: &[String],
666 ctx: &RenderContext,
667) {
668 let g = crate::glyphs::get();
669 let width = area.width as usize;
670 let how = cut(&modal.how(), width, g);
671 buf.set_string(
672 area.x,
673 area.y,
674 &how,
675 Style::default().fg(ctx.text_secondary),
676 );
677 let used = how.width();
678 let room = width.saturating_sub(if used > 0 { used + 2 } else { 0 });
680 let notes = notes.join(&format!(" {} ", g.middot));
681 if notes.is_empty() || room < NOTE_MIN.min(notes.width()) {
682 return;
683 }
684 let notes = cut(¬es, room, g);
685 let x = area.right() - notes.width() as u16;
686 buf.set_string(x, area.y, ¬es, Style::default().fg(ctx.dimmed));
687}
688
689pub fn render_chart_view(
691 area: Rect,
692 buf: &mut ratatui::buffer::Buffer,
693 modal: &mut ChartModal,
694 theme: &Theme,
695 ctx: &RenderContext,
696 view: ChartView<'_>,
697) {
698 let sidebar_width = SIDEBAR_WIDTH.min(area.width / 2);
701 let [sidebar, rule, plot_area] = Layout::horizontal([
702 Constraint::Length(sidebar_width),
703 Constraint::Length(1),
704 Constraint::Fill(1),
705 ])
706 .areas(area);
707 let anchor = render_sidebar(sidebar, buf, modal, view.schema, ctx);
708 let g = crate::glyphs::get();
709 for y in rule.top()..rule.bottom() {
710 buf.set_string(rule.x, y, g.rule, Style::default().fg(ctx.column_separator));
711 }
712 let plot_area = Rect {
714 x: plot_area.x + 1,
715 width: plot_area.width.saturating_sub(1),
716 ..plot_area
717 };
718 let [title, chart_inner] =
719 Layout::vertical([Constraint::Length(1), Constraint::Fill(1)]).areas(plot_area);
720 let notes: &[String] = if view.error.is_some() {
721 &[]
722 } else {
723 &view.notes
724 };
725 render_title(title, buf, modal, notes, ctx);
726
727 modal.plot = None;
728 if let Some(message) = view.error {
729 Paragraph::new(message)
730 .style(Style::default().fg(ctx.error))
731 .wrap(Wrap { trim: true })
732 .centered()
733 .render(chart_inner, buf);
734 } else {
735 match view.working {
736 Some(working) if !view.data.draws_plot() => {
737 working.render_centered(chart_inner, buf, ctx)
738 }
739 working => {
740 modal.plot = render_plot(
741 chart_inner,
742 buf,
743 modal,
744 theme,
745 ctx,
746 view.data,
747 crate::glyphs::get(),
748 );
749 if let Some(working) = working {
750 working.render_corner(chart_inner, buf, ctx);
751 }
752 }
753 }
754 }
755 if let Some(anchor) = anchor
756 && modal.picker.is_some()
757 {
758 render_picker(area, anchor, buf, modal, ctx);
759 }
760}
761
762fn missing(modal: &ChartModal) -> &'static str {
764 let encoding = &modal.spec.encoding;
765 match modal.mark() {
766 Mark::Line | Mark::Scatter if encoding.x.field.is_none() => "Pick X and Y in the panel",
767 Mark::Bar if encoding.x.field.is_none() => "Pick a category for X",
768 Mark::Histogram | Mark::Kde => "Pick a column for X",
769 Mark::Heatmap => "Pick X and Y in the panel",
770 _ => "Pick a column for Y",
771 }
772}
773
774fn render_plot(
777 area: Rect,
778 buf: &mut ratatui::buffer::Buffer,
779 modal: &ChartModal,
780 theme: &Theme,
781 ctx: &RenderContext,
782 data: ChartRenderData<'_>,
783 g: &Glyphs,
784) -> Option<PlotPlace> {
785 let text_secondary = theme.get("text_secondary");
786 let hint = |buf: &mut ratatui::buffer::Buffer| {
787 Paragraph::new(missing(modal))
788 .style(Style::default().fg(text_secondary))
789 .centered()
790 .render(area, buf);
791 };
792 match data {
793 ChartRenderData::XY {
794 series,
795 breaks,
796 values,
797 names,
798 x_axis_kind,
799 x_bounds,
800 numbers,
801 other,
802 } => {
803 let xy = XYData {
804 series,
805 breaks,
806 values,
807 names,
808 x_axis_kind,
809 x_bounds,
810 numbers,
811 other,
812 };
813 return render_xy_chart(area, buf, modal, theme, xy, text_secondary, g);
814 }
815 ChartRenderData::Histogram { data: None, .. }
816 | ChartRenderData::BoxPlot { data: None, .. }
817 | ChartRenderData::Kde { data: None, .. }
818 | ChartRenderData::Heatmap { data: None, .. } => hint(buf),
819 ChartRenderData::Histogram {
820 data: Some(data),
821 x,
822 } => {
823 let numbers = PlotNumbers {
824 x,
825 y: if data.share {
826 AxisNumbers::measure(&ctx.number_format, "Share")
827 } else {
828 AxisNumbers::count(&ctx.number_format)
829 },
830 };
831 let x_title = modal.axis_title(&data.column);
832 let look = HistogramLook {
833 grid: modal.grid,
834 legend: modal.show_legend,
835 x_title: &x_title,
836 };
837 render_histogram_chart(area, buf, &look, theme, data, numbers, g)
838 }
839 ChartRenderData::BoxPlot {
840 data: Some(data),
841 y,
842 } => render_box_plot_chart(area, buf, modal, theme, data, y, g),
843 ChartRenderData::Kde {
844 data: Some(data),
845 x,
846 } => {
847 let numbers = PlotNumbers {
848 x: x.fractional(),
849 y: AxisNumbers::measure(&ctx.number_format, "Density"),
850 };
851 render_kde_chart(area, buf, modal, theme, data, numbers, g)
852 }
853 ChartRenderData::Heatmap {
854 data: Some(data),
855 numbers,
856 } => render_heatmap_chart(area, buf, theme, data, numbers, text_secondary, g),
857 ChartRenderData::Bar { data } => {
858 let picked = ChartModal::is_complete(&modal.effective_spec());
859 render_bar_chart(
860 area,
861 buf,
862 (ctx, theme),
863 data,
864 (picked, modal.show_legend),
865 g,
866 )
867 }
868 }
869 None
870}
871
872fn render_bar_chart(
878 area: Rect,
879 buf: &mut ratatui::buffer::Buffer,
880 (ctx, theme): (&RenderContext, &Theme),
881 data: Option<&BarData>,
882 (picked, legend): (bool, bool),
883 g: &Glyphs,
884) {
885 let hint = |text: &str, buf: &mut ratatui::buffer::Buffer| {
886 Paragraph::new(text.to_string())
887 .style(Style::default().fg(ctx.text_secondary))
888 .centered()
889 .render(area, buf);
890 };
891 let Some(data) = data else {
893 if !picked {
894 hint("Pick a category for X", buf);
895 }
896 return;
897 };
898 if data.bars.is_empty() {
899 hint("No data for bar chart", buf);
900 return;
901 }
902 if area.height < 2 || area.width < 5 {
903 return;
904 }
905 let area = Rect {
907 x: area.x + 1,
908 width: area.width - 1,
909 ..area
910 };
911 let width = area.width as usize;
912
913 if !data.groups.is_empty() {
914 render_grouped_bars(area, buf, (ctx, theme), data, legend, g);
915 return;
916 }
917 let rows = area.height as usize - 1;
920 let total = data.bars.len() + data.more;
921 let shown = if total <= rows {
922 data.bars.len()
923 } else {
924 rows.saturating_sub(1).min(data.bars.len())
925 };
926 let bars = &data.bars[..shown];
927 let hidden = total - shown;
928
929 let mut values = data.value_labels(&ctx.number_format);
930 values.truncate(shown);
931 let value_w = values
932 .iter()
933 .map(|v| crate::glyphs::display_width(v))
934 .max()
935 .unwrap_or(0);
936 let label_cap = (width * 2 / 5).max(4);
937 let label_w = bars
939 .iter()
940 .map(|b| crate::glyphs::display_width(b.label.as_deref().unwrap_or(g.null)))
941 .chain([crate::glyphs::display_width(&data.category)])
942 .max()
943 .unwrap_or(0)
944 .clamp(1, label_cap);
945 let value_x = label_w + 1;
946 let bar_x = value_x + value_w + 1;
947 let bar_w = width.saturating_sub(bar_x);
948
949 let header = Style::default().fg(ctx.text_secondary);
950 let text = Style::default().fg(ctx.text_primary);
951 let bar_style = Style::default().fg(ctx.primary_chart_series_color);
952 let put = |buf: &mut ratatui::buffer::Buffer, x: usize, y: u16, s: &str, style: Style| {
953 if x < width {
954 buf.set_stringn(area.x + x as u16, y, s, width - x, style);
955 }
956 };
957 let fit = |s: &str, w: usize| -> String {
958 if crate::glyphs::display_width(s) <= w {
959 return s.to_string();
960 }
961 let room = w.saturating_sub(crate::glyphs::display_width(g.ellipsis));
962 format!("{}{}", crate::glyphs::take_columns(s, room), g.ellipsis)
963 };
964
965 put(buf, 0, area.y, &fit(&data.category, label_w), header);
968 let name_w = crate::glyphs::display_width(&data.value_column);
969 let name_x = if name_w <= value_w {
970 value_x + value_w - name_w
971 } else {
972 value_x
973 };
974 put(buf, name_x, area.y, &data.value_column, header);
975
976 let lo = bars.iter().map(|b| b.value).fold(0.0_f64, f64::min);
977 let hi = bars.iter().map(|b| b.value).fold(0.0_f64, f64::max);
978 let span = if hi > lo { hi - lo } else { 1.0 };
979 let zero = (((-lo / span) * bar_w as f64).round() as usize)
981 .max(usize::from(lo < 0.0))
982 .min(bar_w);
983 for (i, (bar, value)) in bars.iter().zip(&values).enumerate() {
984 let y = area.y + 1 + i as u16;
985 match &bar.label {
986 Some(label) => put(buf, 0, y, &fit(label, label_w), text),
987 None => put(buf, 0, y, g.null, Style::default().fg(ctx.dimmed)),
988 }
989 let pad = value_w - crate::glyphs::display_width(value);
990 put(buf, value_x + pad, y, value, text);
991 if bar_w == 0 {
992 continue;
993 }
994 let nonzero = usize::from(bar.value != 0.0);
996 if bar.value >= 0.0 {
997 let eighths = ((bar.value / span) * bar_w as f64 * 8.0).round() as usize;
999 let eighths = eighths.max(nonzero).min((bar_w - zero.min(bar_w)) * 8);
1000 let mut body = g.bar_eighths[7].repeat(eighths / 8);
1001 if let Some(part) = (eighths % 8).checked_sub(1) {
1002 body.push_str(g.bar_eighths[part]);
1003 }
1004 put(buf, bar_x + zero, y, &body, bar_style);
1005 } else {
1006 let cells = ((-bar.value / span) * bar_w as f64).round() as usize;
1008 let cells = cells.max(nonzero).min(zero);
1009 put(
1010 buf,
1011 bar_x + zero - cells,
1012 y,
1013 &g.bar_eighths[7].repeat(cells),
1014 bar_style,
1015 );
1016 }
1017 }
1018 if hidden > 0 {
1019 let y = area.y + 1 + shown as u16;
1020 let chip = format!(" + {} more ", crate::numfmt::group_chrome(hidden));
1021 put(
1022 buf,
1023 0,
1024 y,
1025 &chip,
1026 Style::default().bg(ctx.controls_bg).fg(ctx.text_primary),
1027 );
1028 }
1029}
1030
1031fn render_grouped_bars(
1035 area: Rect,
1036 buf: &mut ratatui::buffer::Buffer,
1037 (ctx, theme): (&RenderContext, &Theme),
1038 data: &BarData,
1039 legend: bool,
1040 g: &Glyphs,
1041) {
1042 let width = area.width as usize;
1043 let groups = data.groups.len();
1044 let other = other_at(data.other, groups);
1045 let color = |i: usize| series_style(theme, i, other);
1046 let mut names = vec![Span::styled(
1048 format!("{} ", data.value_column),
1049 Style::default().fg(ctx.text_secondary),
1050 )];
1051 for (i, name) in data.groups.iter().enumerate().filter(|_| legend) {
1052 names.push(Span::styled(g.bar_eighths[7].repeat(2), color(i)));
1053 names.push(Span::styled(
1054 format!(" {name} "),
1055 Style::default().fg(ctx.text_primary),
1056 ));
1057 }
1058 Paragraph::new(Line::from(names)).render(Rect { height: 1, ..area }, buf);
1059 let rows = (area.height as usize).saturating_sub(1);
1060 let total = data.bars.len() + data.more;
1061 let per = groups.max(1);
1062 let fit = rows / per;
1063 let shown = if total * per <= rows {
1064 data.bars.len()
1065 } else {
1066 fit.saturating_sub(1).min(data.bars.len())
1067 };
1068 let label_w = data.bars[..shown]
1069 .iter()
1070 .map(|b| crate::glyphs::display_width(b.label.as_deref().unwrap_or(g.null)))
1071 .max()
1072 .unwrap_or(0)
1073 .clamp(1, (width * 2 / 5).max(4));
1074 let bar_x = label_w + 1;
1075 let bar_w = width.saturating_sub(bar_x);
1076 let values = || {
1077 data.bars[..shown]
1078 .iter()
1079 .flat_map(|b| b.by_group.iter().flatten().copied())
1080 };
1081 let lo = values().fold(0.0_f64, f64::min);
1082 let hi = values().fold(0.0_f64, f64::max);
1083 let span = if hi > lo { hi - lo } else { 1.0 };
1084 let zero = (((-lo / span) * bar_w as f64).round() as usize)
1085 .max(usize::from(lo < 0.0))
1086 .min(bar_w);
1087 let put = |buf: &mut ratatui::buffer::Buffer, x: usize, y: u16, s: &str, style: Style| {
1088 if x < width {
1089 buf.set_stringn(area.x + x as u16, y, s, width - x, style);
1090 }
1091 };
1092 for (i, bar) in data.bars[..shown].iter().enumerate() {
1093 let top = area.y + 1 + (i * per) as u16;
1094 let label = bar.label.as_deref().unwrap_or(g.null);
1095 let label = if crate::glyphs::display_width(label) > label_w {
1096 let room = label_w.saturating_sub(crate::glyphs::display_width(g.ellipsis));
1097 format!("{}{}", crate::glyphs::take_columns(label, room), g.ellipsis)
1098 } else {
1099 label.to_string()
1100 };
1101 put(buf, 0, top, &label, Style::default().fg(ctx.text_primary));
1102 if bar_w == 0 {
1103 continue;
1104 }
1105 for (k, value) in bar.by_group.iter().enumerate() {
1106 let Some(v) = value else { continue };
1107 let y = top + k as u16;
1108 let cells = ((v.abs() / span) * bar_w as f64).round() as usize;
1109 let cells = cells.max(usize::from(*v != 0.0));
1110 let (x, cells) = if *v >= 0.0 {
1111 (bar_x + zero, cells.min(bar_w - zero.min(bar_w)))
1112 } else {
1113 let cells = cells.min(zero);
1114 (bar_x + zero - cells, cells)
1115 };
1116 put(buf, x, y, &g.bar_eighths[7].repeat(cells), color(k));
1117 }
1118 }
1119 let hidden = total - shown;
1120 if hidden > 0 {
1121 let y = area.y + 1 + (shown * per) as u16;
1122 if y < area.bottom() {
1123 let chip = format!(" + {} more ", crate::numfmt::group_chrome(hidden));
1124 put(
1125 buf,
1126 0,
1127 y,
1128 &chip,
1129 Style::default().bg(ctx.controls_bg).fg(ctx.text_primary),
1130 );
1131 }
1132 }
1133}
1134
1135struct XYData<'a> {
1138 series: Option<&'a Vec<Vec<(f64, f64)>>>,
1139 breaks: Option<&'a Vec<Vec<usize>>>,
1140 values: Option<&'a Vec<Vec<(f64, f64)>>>,
1141 names: &'a [String],
1142 x_axis_kind: XAxisTemporalKind,
1143 x_bounds: Option<(f64, f64)>,
1144 numbers: PlotNumbers,
1145 other: bool,
1146}
1147
1148struct SeriesRuns<'a> {
1150 index: usize,
1153 name: &'a str,
1154 points: &'a [(f64, f64)],
1155 breaks: &'a [usize],
1156}
1157
1158fn render_xy_chart(
1159 area: Rect,
1160 buf: &mut ratatui::buffer::Buffer,
1161 modal: &ChartModal,
1162 theme: &Theme,
1163 xy: XYData<'_>,
1164 text_secondary: ratatui::style::Color,
1165 g: &Glyphs,
1166) -> Option<PlotPlace> {
1167 let XYData {
1168 series: chart_data,
1169 breaks,
1170 values,
1171 names,
1172 x_axis_kind,
1173 x_bounds,
1174 numbers,
1175 other,
1176 } = xy;
1177 let other_at = other.then(|| names.len().saturating_sub(1));
1178 let scatter = modal.mark() == Mark::Scatter;
1179 let graph_type = if scatter {
1180 GraphType::Scatter
1181 } else {
1182 GraphType::Line
1183 };
1184 let y_starts_at_zero = modal.y_starts_at_zero;
1185 let log_scale = modal.log_scale;
1186 let show_legend = modal.show_legend;
1187 let spec = modal.effective_spec();
1188 let y_columns: Vec<String> = if spec.encoding.y.aggregate == Aggregate::Count {
1189 vec!["count".to_string()]
1190 } else {
1191 spec.encoding.y.field.clone()
1192 };
1193
1194 let has_x_selected = spec.encoding.x.field.is_some();
1195 let has_data = chart_data
1196 .map(|d| d.iter().any(|s| !s.is_empty()))
1197 .unwrap_or(false);
1198
1199 if has_x_selected && !has_data {
1200 let x_name = spec.encoding.x.field.as_deref().unwrap_or("X");
1201 let y_names: String = y_columns.join(", ");
1202 const PLACEHOLDER_MIN: f64 = 0.0;
1203 const PLACEHOLDER_MAX: f64 = 1.0;
1204 let (x_min, x_max) = x_bounds.unwrap_or((PLACEHOLDER_MIN, PLACEHOLDER_MAX));
1205 let axes = plot_axes(
1206 theme,
1207 x_axis([x_min, x_max], x_axis_kind, &numbers.x, x_name),
1208 AxisSpec::y_numbers([PLACEHOLDER_MIN, PLACEHOLDER_MAX], &numbers.y, &y_names),
1209 g.plot.line,
1210 modal.grid,
1211 );
1212 let empty_dataset = Dataset::default().name("").data(&[]).graph_type(graph_type);
1213 axes.render(Chart::new(vec![empty_dataset]), area, buf, g);
1214 return None;
1215 }
1216
1217 if has_data {
1218 if let Some(data) = chart_data {
1219 let mut all_x_min = f64::INFINITY;
1220 let mut all_x_max = f64::NEG_INFINITY;
1221 let mut all_y_min = f64::INFINITY;
1222 let mut all_y_max = f64::NEG_INFINITY;
1223
1224 let no_breaks = Vec::new();
1226 let names_and_points: Vec<SeriesRuns> = data
1227 .iter()
1228 .zip(names.iter())
1229 .enumerate()
1230 .filter_map(|(i, (points, name))| {
1231 if points.is_empty() {
1232 return None;
1233 }
1234 let series_breaks = breaks.and_then(|b| b.get(i)).unwrap_or(&no_breaks);
1235 Some(SeriesRuns {
1236 index: i,
1237 name: name.as_str(),
1238 points: points.as_slice(),
1239 breaks: series_breaks.as_slice(),
1240 })
1241 })
1242 .collect();
1243
1244 for SeriesRuns { points, .. } in &names_and_points {
1245 let (x_min, x_max) = points
1246 .iter()
1247 .map(|&(x, _)| x)
1248 .fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), x| {
1249 (a.min(x), b.max(x))
1250 });
1251 let (y_min, y_max) = points
1252 .iter()
1253 .map(|&(_, y)| y)
1254 .fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), y| {
1255 (a.min(y), b.max(y))
1256 });
1257 all_x_min = all_x_min.min(x_min);
1258 all_x_max = all_x_max.max(x_max);
1259 all_y_min = all_y_min.min(y_min);
1260 all_y_max = all_y_max.max(y_max);
1261 }
1262
1263 let points: usize = names_and_points.iter().map(|s| s.points.len()).sum();
1266 let cells = usize::from(area.width) * usize::from(area.height);
1267 let finer = resolution(g.plot.line) > resolution(g.plot.point);
1268 let marker = match scatter {
1269 false => g.plot.line,
1270 true if finer && points * 4 > cells => g.plot.line,
1271 true => g.plot.point,
1272 };
1273
1274 let datasets: Vec<Dataset> = names_and_points
1278 .iter()
1279 .filter(|s| Some(s.index) == other_at)
1280 .chain(
1281 names_and_points
1282 .iter()
1283 .filter(|s| Some(s.index) != other_at),
1284 )
1285 .flat_map(|series| {
1286 let style = series_style(theme, series.index, other_at);
1287 segments(series.points, series.breaks)
1288 .into_iter()
1289 .map(move |run| {
1290 Dataset::default()
1291 .marker(marker)
1292 .graph_type(graph_type)
1293 .style(style)
1294 .data(run)
1295 })
1296 })
1297 .collect();
1298
1299 if datasets.is_empty() {
1300 Paragraph::new("No valid data points")
1301 .style(Style::default().fg(text_secondary))
1302 .centered()
1303 .render(area, buf);
1304 return None;
1305 }
1306
1307 let y_min_bounds = if y_starts_at_zero {
1308 0.0_f64.min(all_y_min)
1309 } else {
1310 all_y_min
1311 };
1312 let y_max_bounds = if all_y_max > y_min_bounds {
1313 all_y_max
1314 } else {
1315 y_min_bounds + 1.0
1316 };
1317 let x_min_bounds = if all_x_max > all_x_min {
1318 all_x_min
1319 } else {
1320 all_x_min - 0.5
1321 };
1322 let x_max_bounds = if all_x_max > all_x_min {
1323 all_x_max
1324 } else {
1325 all_x_min + 0.5
1326 };
1327
1328 let x_axis_title = spec
1329 .encoding
1330 .x
1331 .field
1332 .as_deref()
1333 .map(|s| modal.axis_title(s))
1334 .unwrap_or_default();
1335 let y_axis_title = y_columns
1336 .iter()
1337 .map(|c| modal.axis_title(c))
1338 .collect::<Vec<_>>()
1339 .join(", ");
1340 let y_bounds = [y_min_bounds, y_max_bounds];
1341 let y = if log_scale {
1343 AxisSpec::y_log(y_bounds, &numbers.y.clone().fractional(), &y_axis_title)
1344 } else {
1345 AxisSpec::y_numbers(y_bounds, &numbers.y, &y_axis_title)
1346 };
1347 let mut axes = plot_axes(
1348 theme,
1349 x_axis(
1350 [x_min_bounds, x_max_bounds],
1351 x_axis_kind,
1352 &numbers.x,
1353 &x_axis_title,
1354 ),
1355 y,
1356 marker,
1357 modal.grid,
1358 );
1359 axes.legend = legend(
1360 show_legend,
1361 names_and_points
1362 .iter()
1363 .map(|s| (s.name, series_style(theme, s.index, other_at))),
1364 );
1365 let x_bounds = [x_min_bounds, x_max_bounds];
1366 let sub = resolution(marker).0;
1367 let values = values.unwrap_or(data);
1370 let cursor = modal
1371 .cursor_x
1372 .filter(|_| modal.plot_focus)
1373 .and_then(|x| crosshair::nearest(&crosshair::xs(values), x));
1374 let readout = cursor
1375 .map(|x| {
1376 let written = crosshair::format_x(x, x_axis_kind, &numbers.x);
1377 let entries = readout_entries(
1378 theme,
1379 g,
1380 (x, &x_axis_title, written),
1381 &numbers.y,
1382 values,
1383 names,
1384 other_at,
1385 );
1386 crosshair::readout_lines(&entries, area.width as usize, g)
1387 })
1388 .unwrap_or_default();
1389 let rows = (readout.len() as u16).min(area.height / 3);
1390 let [plot_area, readout_area] =
1391 Layout::vertical([Constraint::Fill(1), Constraint::Length(rows)]).areas(area);
1392 let frame = axes.render(Chart::new(datasets), plot_area, buf, g);
1393 let place = PlotPlace {
1394 graph: frame.graph,
1395 x_bounds,
1396 sub,
1397 };
1398 if let Some(x) = cursor {
1399 let style = Style::default().fg(theme.get("accent"));
1400 crosshair::draw(buf, &place, x, style, g);
1401 Paragraph::new(readout).render(readout_area, buf);
1402 }
1403 return Some(place);
1404 }
1405 } else {
1406 Paragraph::new(missing(modal))
1407 .style(Style::default().fg(text_secondary))
1408 .centered()
1409 .render(area, buf);
1410 }
1411 None
1412}
1413
1414fn readout_entries(
1417 theme: &Theme,
1418 g: &Glyphs,
1419 (x, x_title, written): (f64, &str, String),
1420 y: &AxisNumbers,
1421 series: &[Vec<(f64, f64)>],
1422 names: &[String],
1423 other_at: Option<usize>,
1424) -> Vec<crosshair::Entry> {
1425 let value_style = Style::default().fg(theme.get("text_primary"));
1426 let x_title = if x_title.is_empty() { "x" } else { x_title };
1427 let mut entries = vec![crosshair::Entry {
1428 name: x_title.to_string(),
1429 name_style: Style::default().fg(theme.get("text_secondary")),
1430 value: written,
1431 value_style,
1432 }];
1433 for (i, (value, name)) in crosshair::values_at(series, x)
1434 .into_iter()
1435 .zip(names)
1436 .enumerate()
1437 {
1438 let (value, value_style) = match value {
1439 Some(v) => (crosshair::format_number(v, y), value_style),
1440 None => (g.null.to_string(), Style::default().fg(theme.get("dimmed"))),
1441 };
1442 entries.push(crosshair::Entry {
1443 name: name.clone(),
1444 name_style: series_style(theme, i, other_at),
1445 value,
1446 value_style,
1447 });
1448 }
1449 entries
1450}
1451
1452fn plot_axes<'a>(
1455 theme: &Theme,
1456 x: AxisSpec<'a>,
1457 y: AxisSpec<'a>,
1458 marker: ratatui::symbols::Marker,
1459 grid: bool,
1460) -> PlotAxes<'a> {
1461 let style = Style::default().fg(theme.get("text_primary"));
1462 PlotAxes {
1463 grid: grid.then(|| Style::default().fg(theme.get("chart_grid"))),
1464 ..PlotAxes::new(x, y, style, marker)
1465 }
1466}
1467
1468fn x_axis<'a>(
1471 bounds: [f64; 2],
1472 kind: XAxisTemporalKind,
1473 numbers: &AxisNumbers,
1474 title: &'a str,
1475) -> AxisSpec<'a> {
1476 AxisSpec::calendar(bounds, kind, numbers, title)
1477}
1478
1479fn legend<'a>(show: bool, entries: impl Iterator<Item = (&'a str, Style)>) -> Option<Legend> {
1482 let entries: Vec<(String, Style)> = entries.map(|(n, s)| (n.to_string(), s)).collect();
1483 (show && entries.len() > 1).then_some(Legend { entries })
1484}
1485
1486fn series_style(theme: &Theme, i: usize, other_at: Option<usize>) -> Style {
1489 if other_at == Some(i) {
1490 return Style::default().fg(theme.get("dimmed"));
1491 }
1492 let colors = theme.series_colors();
1493 Style::default().fg(colors[i % colors.len()])
1494}
1495
1496fn other_at(other: bool, n: usize) -> Option<usize> {
1498 (other && n > 0).then(|| n - 1)
1499}
1500
1501pub struct HistogramLook<'a> {
1505 pub grid: bool,
1506 pub legend: bool,
1507 pub x_title: &'a str,
1508}
1509
1510pub fn render_histogram(
1513 area: Rect,
1514 buf: &mut ratatui::buffer::Buffer,
1515 theme: &Theme,
1516 ctx: &RenderContext,
1517 data: &HistogramData,
1518 x: AxisNumbers,
1519) {
1520 let numbers = PlotNumbers {
1521 x,
1522 y: AxisNumbers::count(&ctx.number_format),
1523 };
1524 let look = HistogramLook {
1525 grid: false,
1526 legend: false,
1527 x_title: &data.column,
1528 };
1529 render_histogram_chart(area, buf, &look, theme, data, numbers, crate::glyphs::get());
1530}
1531
1532fn render_histogram_chart(
1533 area: Rect,
1534 buf: &mut ratatui::buffer::Buffer,
1535 look: &HistogramLook<'_>,
1536 theme: &Theme,
1537 data: &HistogramData,
1538 numbers: PlotNumbers,
1539 g: &Glyphs,
1540) {
1541 let text_secondary = theme.get("text_secondary");
1542 if data.bins.is_empty() {
1543 Paragraph::new("No data for histogram")
1544 .style(Style::default().fg(text_secondary))
1545 .centered()
1546 .render(area, buf);
1547 return;
1548 }
1549
1550 let x_min_bounds = data.x_min;
1551 let x_max_bounds = if data.x_max > data.x_min {
1552 data.x_max
1553 } else {
1554 data.x_min + 1.0
1555 };
1556 let y_min_bounds = 0.0;
1557 let y_max_bounds = if data.max_count > 0.0 {
1558 data.max_count
1559 } else {
1560 1.0
1561 };
1562
1563 let y_title = if data.share { "Share" } else { "Count" };
1564 let x_title = look.x_title;
1565 let marker = if data.groups.is_empty() {
1566 g.plot.bar
1567 } else {
1568 g.plot.line
1569 };
1570 let mut axes = plot_axes(
1571 theme,
1572 AxisSpec::numbers([x_min_bounds, x_max_bounds], &numbers.x, x_title),
1573 AxisSpec::y_numbers([y_min_bounds, y_max_bounds], &numbers.y, y_title),
1574 marker,
1575 look.grid,
1576 );
1577 if !data.groups.is_empty() {
1578 let steps = step_outlines(data);
1579 let other = other_at(data.other, steps.len());
1580 let mut datasets: Vec<(usize, Dataset)> = steps
1582 .iter()
1583 .enumerate()
1584 .map(|(i, points)| {
1585 let dataset = Dataset::default()
1586 .graph_type(GraphType::Line)
1587 .marker(marker)
1588 .style(series_style(theme, i, other))
1589 .data(points);
1590 (i, dataset)
1591 })
1592 .collect();
1593 datasets.sort_by_key(|(i, _)| Some(*i) != other);
1594 let datasets: Vec<Dataset> = datasets.into_iter().map(|(_, d)| d).collect();
1595 axes.legend = legend(
1596 look.legend,
1597 data.groups
1598 .iter()
1599 .enumerate()
1600 .map(|(i, group)| (group.name.as_str(), series_style(theme, i, other))),
1601 );
1602 axes.render(Chart::new(datasets), area, buf, g);
1603 return;
1604 }
1605
1606 let columns = axes.frame(area).graph.width;
1607 let points = bin_columns(data, [x_min_bounds, x_max_bounds], columns);
1608 let style = Style::default().fg(theme.get("chart_1"));
1609 let dataset = Dataset::default()
1610 .name("")
1611 .marker(g.plot.bar)
1612 .graph_type(GraphType::Bar)
1613 .style(style)
1614 .data(&points);
1615
1616 axes.render(Chart::new(vec![dataset]), area, buf, g);
1617}
1618
1619fn step_outlines(data: &HistogramData) -> Vec<Vec<(f64, f64)>> {
1622 let n = data.bins.len().max(1);
1623 let width = (data.x_max - data.x_min) / n as f64;
1624 data.groups
1625 .iter()
1626 .map(|group| {
1627 let mut points = vec![(data.x_min, 0.0)];
1628 for (i, &count) in group.counts.iter().enumerate() {
1629 let x0 = data.x_min + i as f64 * width;
1630 points.push((x0, count));
1631 points.push((x0 + width, count));
1632 }
1633 points.push((data.x_max, 0.0));
1634 points
1635 })
1636 .collect()
1637}
1638
1639fn bin_columns(data: &HistogramData, [lo, hi]: [f64; 2], columns: u16) -> Vec<(f64, f64)> {
1643 let n = data.bins.len();
1644 if n == 0 || columns < 2 || hi <= lo {
1645 return data.bins.iter().map(|b| (b.center, b.count)).collect();
1646 }
1647 let last = f64::from(columns - 1);
1648 let at = |c: u16| lo + f64::from(c) / last * (hi - lo);
1650 let bin_of = |v: f64| (((v - lo) / (hi - lo) * n as f64).floor() as usize).min(n - 1);
1651 let bin_cols: Vec<u16> = (0..columns).map(|c| bin_of(at(c)) as u16).collect();
1652 (0..columns)
1653 .filter_map(|c| {
1654 let bin = bin_cols[c as usize];
1655 let wide = bin_cols.iter().filter(|b| **b == bin).count();
1656 let last_of_bin = bin_cols.get(c as usize + 1).is_some_and(|b| *b != bin);
1657 (!(last_of_bin && wide >= 3)).then(|| (at(c), data.bins[bin as usize].count))
1658 })
1659 .collect()
1660}
1661
1662fn render_kde_chart(
1663 area: Rect,
1664 buf: &mut ratatui::buffer::Buffer,
1665 modal: &ChartModal,
1666 theme: &Theme,
1667 data: &KdeData,
1668 numbers: PlotNumbers,
1669 g: &Glyphs,
1670) {
1671 let text_secondary = theme.get("text_secondary");
1672 if data.series.is_empty() {
1673 Paragraph::new("No data for KDE")
1674 .style(Style::default().fg(text_secondary))
1675 .centered()
1676 .render(area, buf);
1677 return;
1678 }
1679
1680 let other = other_at(data.other, data.series.len());
1681 let mut datasets: Vec<(usize, Dataset)> = data
1682 .series
1683 .iter()
1684 .enumerate()
1685 .map(|(i, s)| {
1686 let dataset = Dataset::default()
1687 .graph_type(GraphType::Line)
1688 .marker(g.plot.line)
1689 .style(series_style(theme, i, other))
1690 .data(&s.points);
1691 (i, dataset)
1692 })
1693 .collect();
1694 datasets.sort_by_key(|(i, _)| Some(*i) != other);
1696 let datasets: Vec<Dataset> = datasets.into_iter().map(|(_, d)| d).collect();
1697
1698 let x_title = modal.x().map(|x| modal.axis_title(x)).unwrap_or_default();
1699 let mut axes = plot_axes(
1700 theme,
1701 AxisSpec::numbers([data.x_min, data.x_max], &numbers.x, &x_title),
1702 AxisSpec::y_numbers([0.0, data.y_max], &numbers.y, "Density"),
1703 g.plot.line,
1704 modal.grid,
1705 );
1706 axes.legend = legend(
1707 modal.show_legend,
1708 data.series
1709 .iter()
1710 .enumerate()
1711 .map(|(i, s)| (s.name.as_str(), series_style(theme, i, other))),
1712 );
1713 axes.render(Chart::new(datasets), area, buf, g);
1714}
1715
1716fn render_box_plot_chart(
1717 area: Rect,
1718 buf: &mut ratatui::buffer::Buffer,
1719 modal: &ChartModal,
1720 theme: &Theme,
1721 data: &BoxPlotData,
1722 y_numbers: AxisNumbers,
1723 g: &Glyphs,
1724) {
1725 let text_secondary = theme.get("text_secondary");
1726 if data.stats.is_empty() {
1727 Paragraph::new("No data for box plot")
1728 .style(Style::default().fg(text_secondary))
1729 .centered()
1730 .render(area, buf);
1731 return;
1732 }
1733
1734 let mut segments: Vec<Vec<(f64, f64)>> = Vec::new();
1735 let mut segment_styles: Vec<Style> = Vec::new();
1736 let box_half = 0.3;
1737 let cap_half = 0.2;
1738 for (i, stat) in data.stats.iter().enumerate() {
1739 let x = i as f64;
1740 let style = series_style(theme, i, None);
1741 segments.push(vec![
1742 (x - box_half, stat.q1),
1743 (x + box_half, stat.q1),
1744 (x + box_half, stat.q3),
1745 (x - box_half, stat.q3),
1746 (x - box_half, stat.q1),
1747 ]);
1748 segment_styles.push(style);
1749 segments.push(vec![
1750 (x - box_half, stat.median),
1751 (x + box_half, stat.median),
1752 ]);
1753 segment_styles.push(style);
1754 segments.push(vec![(x, stat.min), (x, stat.q1)]);
1755 segment_styles.push(style);
1756 segments.push(vec![(x, stat.q3), (x, stat.max)]);
1757 segment_styles.push(style);
1758 segments.push(vec![(x - cap_half, stat.min), (x + cap_half, stat.min)]);
1759 segment_styles.push(style);
1760 segments.push(vec![(x - cap_half, stat.max), (x + cap_half, stat.max)]);
1761 segment_styles.push(style);
1762 }
1763
1764 let datasets: Vec<Dataset> = segments
1765 .iter()
1766 .zip(segment_styles.iter())
1767 .map(|(points, style)| {
1768 Dataset::default()
1769 .name("")
1770 .graph_type(GraphType::Line)
1771 .marker(g.plot.point)
1772 .style(*style)
1773 .data(points)
1774 })
1775 .collect();
1776
1777 let x_min_bounds = -0.5;
1778 let x_max_bounds = (data.stats.len() as f64 - 1.0).max(0.0) + 0.5;
1779 let name = |i: f64, level| {
1781 let stat = data.stats.get(i as usize)?;
1782 (level == 0).then(|| stat.name.clone())
1783 };
1784 let spec = modal.effective_spec();
1785 let x_title = spec.encoding.x.field.as_deref().unwrap_or("");
1786 let y_title = spec
1787 .encoding
1788 .y
1789 .field
1790 .first()
1791 .map(|y| modal.axis_title(y))
1792 .unwrap_or_default();
1793 let x = AxisSpec::fixed(
1794 [x_min_bounds, x_max_bounds],
1795 (0..data.stats.len()).map(|i| i as f64).collect(),
1796 Box::new(name),
1797 x_title,
1798 );
1799 let y = AxisSpec::y_numbers([data.y_min, data.y_max], &y_numbers, &y_title);
1800 plot_axes(theme, x, y, g.plot.point, modal.grid).render(Chart::new(datasets), area, buf, g);
1801}
1802
1803fn render_heatmap_chart(
1804 area: Rect,
1805 buf: &mut ratatui::buffer::Buffer,
1806 theme: &Theme,
1807 data: &HeatmapData,
1808 numbers: PlotNumbers,
1809 text_secondary: ratatui::style::Color,
1810 g: &Glyphs,
1811) {
1812 if data.counts.is_empty() || data.max_count <= 0.0 {
1813 Paragraph::new("No data for heatmap")
1814 .style(Style::default().fg(text_secondary))
1815 .centered()
1816 .render(area, buf);
1817 return;
1818 }
1819
1820 let layout = Layout::default()
1821 .direction(Direction::Vertical)
1822 .constraints([
1823 Constraint::Length(HEATMAP_TITLE_HEIGHT),
1824 Constraint::Min(1),
1825 Constraint::Length(HEATMAP_X_LABEL_HEIGHT),
1826 ])
1827 .split(area);
1828 let title_style = Style::default().fg(theme.get("text_primary"));
1831 let y_title = cut(&data.y_column, layout[0].width as usize, g);
1832 buf.set_string(layout[0].x, layout[0].y, &y_title, title_style);
1833
1834 const Y_LABEL_MAX: u16 = 12;
1835 let y_axis = AxisSpec::numbers([data.y_min, data.y_max], &numbers.y, "");
1837 let rows = layout[1];
1838 let y_track = Track {
1839 start: rows.y,
1840 cells: rows.height,
1841 sub: 1,
1842 };
1843 let y_labels = fit_y_labels(&y_axis, y_track, Y_LABEL_MAX).labels;
1844 let y_label_width = y_labels.iter().map(|(_, l)| l.width()).max().unwrap_or(1);
1845 let y_label_width = (y_label_width as u16).clamp(4, Y_LABEL_MAX);
1846 let body = Layout::default()
1847 .direction(Direction::Horizontal)
1848 .constraints([Constraint::Length(y_label_width + 1), Constraint::Min(1)])
1849 .split(layout[1]);
1850 let label_area = body[0];
1851 let plot_area = body[1];
1852 if plot_area.width == 0 || plot_area.height == 0 {
1853 return;
1854 }
1855
1856 let label_style = Style::default().fg(theme.get("text_primary"));
1857 for (row, label) in &y_labels {
1858 let pad = usize::from(y_label_width).saturating_sub(label.width()) as u16;
1859 buf.set_stringn(
1860 label_area.x + pad,
1861 *row,
1862 label,
1863 usize::from(y_label_width),
1864 label_style,
1865 );
1866 }
1867
1868 let intensity_chars: Vec<char> = " .:-=+*#%@".chars().collect();
1869 for row in 0..plot_area.height {
1870 for col in 0..plot_area.width {
1871 let max_x_bin = data.x_bins.saturating_sub(1) as f64;
1872 let max_y_bin = data.y_bins.saturating_sub(1) as f64;
1873 let x_bin = ((col as f64 / plot_area.width as f64) * data.x_bins as f64)
1874 .floor()
1875 .clamp(0.0, max_x_bin) as usize;
1876 let y_bin_raw = ((row as f64 / plot_area.height as f64) * data.y_bins as f64).floor();
1877 let y_bin = data
1878 .y_bins
1879 .saturating_sub(1)
1880 .saturating_sub(y_bin_raw.clamp(0.0, max_y_bin) as usize);
1881 let count = data.counts[y_bin][x_bin];
1882 let level = ((count / data.max_count) * (intensity_chars.len() as f64 - 1.0))
1883 .round()
1884 .clamp(0.0, intensity_chars.len() as f64 - 1.0) as usize;
1885 let ch = intensity_chars[level];
1886 let cell = &mut buf[(plot_area.x + col, plot_area.y + row)];
1887 let symbol = ch.to_string();
1888 cell.set_symbol(&symbol);
1889 cell.set_style(Style::default().fg(theme.get("chart_1")));
1890 }
1891 }
1892
1893 let x_label_area = layout[2];
1894 let x_axis = AxisSpec::numbers([data.x_min, data.x_max], &numbers.x, "");
1895 let span = (x_label_area.left(), x_label_area.right());
1896 let x_track = Track {
1897 start: plot_area.x,
1898 cells: plot_area.width,
1899 sub: 1,
1900 };
1901 for (x, label) in fit_x_labels(&x_axis, span, x_track).labels {
1902 buf.set_string(x, x_label_area.y, label, label_style);
1903 }
1904 if x_label_area.height > 1 {
1905 let x_title = cut(&data.x_column, x_label_area.width as usize, g);
1906 let x = x_label_area.right() - x_title.width() as u16;
1907 buf.set_string(x, x_label_area.y + 1, &x_title, title_style);
1908 }
1909}
1910
1911#[cfg(test)]
1912mod tests {
1913 use super::*;
1914 use ratatui::buffer::Buffer;
1915
1916 fn open_modal() -> ChartModal {
1917 let mut modal = ChartModal::new();
1918 modal.open(
1919 crate::chart_modal::ChartColumns {
1920 numeric: &["price".to_string(), "volume".to_string()],
1921 datetime: &["date".to_string()],
1922 bucketable: &["date".to_string()],
1923 category: &["carrier".to_string()],
1924 },
1925 None,
1926 Some(10_000),
1927 false,
1928 1,
1929 );
1930 modal
1931 }
1932
1933 fn names() -> &'static [String] {
1935 static NAMES: std::sync::OnceLock<Vec<String>> = std::sync::OnceLock::new();
1936 NAMES.get_or_init(|| {
1937 ["price", "volume", "c", "d", "e", "f", "g"]
1938 .iter()
1939 .map(|s| s.to_string())
1940 .collect()
1941 })
1942 }
1943
1944 fn render_rows(modal: &mut ChartModal, width: u16, height: u16) -> Vec<String> {
1945 render_view(
1946 modal,
1947 ChartView {
1948 data: ChartRenderData::XY {
1949 series: None,
1950 breaks: None,
1951 values: None,
1952 names: names(),
1953 x_axis_kind: XAxisTemporalKind::Numeric,
1954 x_bounds: None,
1955 other: false,
1956 numbers: PlotNumbers::default(),
1957 },
1958 notes: Vec::new(),
1959 error: None,
1960 working: None,
1961 schema: None,
1962 },
1963 width,
1964 height,
1965 )
1966 }
1967
1968 #[test]
1971 fn the_panel_shows_every_shelf_and_echoes_every_option() {
1972 let mut modal = open_modal();
1973 modal.spec.encoding.x.field = Some("date".to_string());
1974 modal.spec.encoding.y.field = vec!["price".to_string()];
1975 let rows = render_rows(&mut modal, 100, 30);
1976 let text = rows.join("\n");
1977 for row in &rows {
1978 assert!(
1979 !row.contains('╭') && !row.contains('╰'),
1980 "a border inside the panel: {row:?}"
1981 );
1982 }
1983 let line = |label: &str| {
1984 rows.iter()
1985 .find(|r| {
1986 let head: String = r.chars().take(15).collect();
1987 head.trim_start().trim_start_matches('▎').trim() == label
1988 })
1989 .cloned()
1990 .unwrap_or_else(|| panic!("{label} in {text}"))
1991 };
1992 assert!(rows[0].contains("Chart"));
1993 assert!(line("Type").contains("Line"));
1994 assert!(line("X").contains("date"));
1995 assert!(line("Y").contains("price"));
1996 assert!(line("Color").contains("none"));
1997 assert!(text.contains("by row"), "the bucket under a date X: {text}");
1998 assert!(text.contains("Options"));
1999 assert!(line("Y from zero").contains("off"));
2000 assert!(line("Rows").contains("Sample 10,000"));
2001 assert!(line("Aggregate").contains("none"));
2002 assert!(
2003 !text.contains("type a size"),
2004 "the row's keys only under focus"
2005 );
2006
2007 modal.focus = ChartFocus::LimitRows;
2009 modal.view_rows = Some(36_800_000);
2010 let under = |rows: &[String]| {
2011 let at = rows
2012 .iter()
2013 .position(|r| r.contains("Sample") || r.contains("Every row"));
2014 rows[at.unwrap() + 1].clone()
2015 };
2016 let rows = render_rows(&mut modal, 100, 30);
2017 assert!(under(&rows).contains("switch · type a size"), "{rows:?}");
2018 modal.step(ChartFocus::LimitRows, 1);
2019 let rows = render_rows(&mut modal, 100, 30);
2020 assert!(
2021 rows.iter().any(|r| r.contains("Every row (36.8M)")),
2022 "{rows:?}"
2023 );
2024 assert!(under(&rows).contains("Enter to read"), "{rows:?}");
2025 modal.type_rows('5');
2026 modal.type_rows('x');
2027 modal.commit_rows();
2028 let rows = render_rows(&mut modal, 100, 30);
2029 assert!(rows.iter().any(|r| r.contains("Sample 5x")), "{rows:?}");
2030 assert!(under(&rows).contains("not a size"), "{rows:?}");
2031 }
2032
2033 #[test]
2036 fn an_open_picker_has_the_only_rail() {
2037 let g = crate::glyphs::get();
2038 let rails = |rows: &[String]| {
2039 rows.iter()
2040 .map(|row| row.matches(g.rail).count())
2041 .sum::<usize>()
2042 };
2043 let mut modal = open_modal();
2044 modal.set_mark(Mark::Line);
2045 modal.focus = ChartFocus::X;
2046 assert_eq!(rails(&render_rows(&mut modal, 100, 30)), 1);
2047 modal.open_picker();
2048 assert!(modal.picker.is_some());
2049 let rows = render_rows(&mut modal, 100, 30);
2050 assert_eq!(rails(&rows), 1, "{rows:#?}");
2051 let x = rows
2052 .iter()
2053 .find(|row| row.chars().skip(2).collect::<String>().starts_with("X "))
2054 .expect("the X row");
2055 assert!(!x.starts_with(g.rail), "the row gave the rail up: {x:?}");
2056 }
2057
2058 #[test]
2062 fn the_aggregate_row_is_labeled() {
2063 let ctx = RenderContext::for_test();
2064 let theme =
2065 crate::config::Theme::from_config(&crate::config::ThemeConfig::default()).unwrap();
2066 let mut modal = open_modal();
2067 modal.set_mark(Mark::Scatter);
2068 modal.spec.encoding.x.field = Some("volume".to_string());
2069 modal.spec.encoding.y.field = vec!["price".to_string()];
2070 modal.focus = ChartFocus::Aggregate;
2071 let area = Rect::new(0, 0, 100, 30);
2072 let mut buf = Buffer::empty(area);
2073 render_chart_view(
2074 area,
2075 &mut buf,
2076 &mut modal,
2077 &theme,
2078 &ctx,
2079 ChartView {
2080 data: ChartRenderData::XY {
2081 series: None,
2082 breaks: None,
2083 values: None,
2084 names: names(),
2085 x_axis_kind: XAxisTemporalKind::Numeric,
2086 x_bounds: None,
2087 other: false,
2088 numbers: PlotNumbers::default(),
2089 },
2090 notes: Vec::new(),
2091 error: None,
2092 working: None,
2093 schema: None,
2094 },
2095 );
2096 let cells = |y: u16, xs: std::ops::Range<u16>| -> String {
2098 xs.map(|x| buf[(x, y)].symbol()).collect::<String>()
2099 };
2100 let label_end = 2 + LABEL_WIDTH;
2101 let at = |label: &str| {
2102 (0..area.height)
2103 .find(|&y| cells(y, 2..label_end).trim_end() == label)
2104 .unwrap_or_else(|| panic!("{label} in the panel"))
2105 };
2106 let (y, row) = (at("Y"), at("Aggregate"));
2107 assert_eq!(row, y + 1, "right under Y");
2108 assert_eq!(cells(row, label_end..SIDEBAR_WIDTH).trim_end(), "none");
2109 assert!(cells(y, label_end..SIDEBAR_WIDTH).contains("price"));
2110 assert_eq!(buf[(0, row)].symbol(), crate::glyphs::get().rail);
2111 assert_eq!(buf[(2, row)].fg, ctx.accent, "the focused label");
2112 }
2113
2114 #[test]
2117 fn the_title_says_how_and_y_is_named_at_its_axis() {
2118 let mut modal = open_modal();
2119 modal.set_mark(Mark::Scatter);
2120 modal.spec.encoding.x.field = Some("volume".to_string());
2121 modal.spec.encoding.y.field = vec!["price".to_string()];
2122 let plot = |r: &String| -> String { r.chars().skip(SIDEBAR_WIDTH as usize + 1).collect() };
2123 let check = |modal: &mut ChartModal, how: &str| {
2124 let rows = render_rows(modal, 100, 30);
2125 assert_eq!(plot(&rows[0]).trim(), how, "{:?}", rows[0]);
2126 let named: Vec<String> = rows
2127 .iter()
2128 .map(plot)
2129 .filter(|r| r.contains("price"))
2130 .collect();
2131 assert_eq!(named.len(), 1, "price once, at its axis: {named:#?}");
2132 assert_eq!(named[0].trim(), "price", "{named:#?}");
2133 };
2134 check(&mut modal, "");
2135 modal.spec.encoding.color.field = Some("carrier".to_string());
2136 check(&mut modal, "colored by carrier");
2137 modal.spec.encoding.color.field = None;
2138 modal.spec.encoding.y.aggregate = Aggregate::Mean;
2139 check(&mut modal, "mean by volume");
2140 modal.set_mark(Mark::Line);
2141 modal.spec.encoding.y.cumulative = Cumulative::Sum;
2142 check(&mut modal, "by volume, running sum");
2143 }
2144
2145 #[test]
2147 fn a_shelf_that_does_not_apply_is_dimmed_not_hidden() {
2148 let ctx = RenderContext::for_test();
2149 let mut modal = open_modal();
2150 modal.set_mark(Mark::Kde);
2151 modal.spec.encoding.x.field = Some("price".to_string());
2152 let rows = render_rows(&mut modal, 100, 30);
2153 let text = rows.join("\n");
2154 assert!(text.contains("density"), "{text}");
2155 assert!(text.contains("Bandwidth") && !text.contains("Log scale"));
2156
2157 modal.set_mark(Mark::Box);
2158 let theme =
2159 crate::config::Theme::from_config(&crate::config::ThemeConfig::default()).unwrap();
2160 let area = Rect::new(0, 0, 100, 30);
2161 let mut buf = Buffer::empty(area);
2162 render_chart_view(
2163 area,
2164 &mut buf,
2165 &mut modal,
2166 &theme,
2167 &ctx,
2168 ChartView {
2169 data: ChartRenderData::BoxPlot {
2170 data: None,
2171 y: AxisNumbers::default(),
2172 },
2173 notes: Vec::new(),
2174 error: None,
2175 working: None,
2176 schema: None,
2177 },
2178 );
2179 let row = (0..area.height)
2180 .find(|&y| {
2181 (0..14)
2182 .map(|x| buf[(x, y)].symbol())
2183 .collect::<String>()
2184 .trim()
2185 == "Color"
2186 })
2187 .expect("the Color shelf stays");
2188 assert_eq!(buf[(2, row)].fg, ctx.dimmed, "dimmed");
2189 let text: String = (0..area.height)
2190 .map(|y| (0..40).map(|x| buf[(x, y)].symbol()).collect::<String>())
2191 .collect::<Vec<_>>()
2192 .join("\n");
2193 assert!(text.contains("same as X"), "{text}");
2194 }
2195
2196 #[test]
2199 fn the_values_line_counts_other() {
2200 let mut modal = open_modal();
2201 modal.set_mark(Mark::Scatter);
2202 modal.spec.encoding.x.field = Some("price".to_string());
2203 modal.spec.encoding.y.field = vec!["volume".to_string()];
2204 modal.spec.encoding.color.field = Some("carrier".to_string());
2205 modal.color_counts = Some(crate::chart_modal::ColorCounts {
2206 column: "carrier".to_string(),
2207 values: (0..16)
2208 .map(|i| (Some(format!("C{i}")), 100 - i as u64))
2209 .collect(),
2210 });
2211 let line = |modal: &mut ChartModal| -> String {
2212 let rows = render_rows(modal, 100, 30);
2213 let at = rows.iter().position(|r| r.contains("Color")).unwrap();
2214 rows[at + 1]
2215 .chars()
2216 .take(40)
2217 .collect::<String>()
2218 .trim()
2219 .to_string()
2220 };
2221 assert_eq!(line(&mut modal), "top 10 + 6 other");
2222 modal.step(ChartFocus::ColorValues, 1);
2223 assert_eq!(line(&mut modal), "top 10 of 16 by rows");
2224 modal.spec.encoding.color.other = None;
2225 modal.set_mark(Mark::Line);
2226 assert_eq!(line(&mut modal), "top 10 of 16 by rows");
2227 modal.step(ChartFocus::ColorValues, -1);
2228 assert_eq!(line(&mut modal), "top 10 + 6 other");
2229 modal.spec.encoding.color.values = vec![Some("C3".to_string()), Some("C9".to_string())];
2230 assert_eq!(line(&mut modal), "2 picked + 14 other");
2231 }
2232
2233 #[test]
2237 fn the_aggregate_row_says_percentile_and_order() {
2238 let mut modal = open_modal();
2239 modal.set_mark(Mark::Line);
2240 modal.spec.encoding.x.field = Some("volume".to_string());
2241 modal.spec.encoding.y.field = vec!["price".to_string()];
2242 let panel = |modal: &mut ChartModal| -> Vec<String> {
2243 render_rows(modal, 100, 30)
2244 .iter()
2245 .map(|r| r.chars().take(40).collect::<String>().trim().to_string())
2246 .collect()
2247 };
2248 let g = crate::glyphs::get();
2249 modal.spec.encoding.y.aggregate = Aggregate::Quantile;
2250 let rows = panel(&mut modal);
2251 let at = rows
2252 .iter()
2253 .position(|r| r.starts_with("Aggregate"))
2254 .unwrap();
2255 assert_eq!(rows[at], "Aggregate quantile");
2256 assert_eq!(rows[at + 1], "p90");
2257 modal.step(ChartFocus::Quantile, -1);
2258 assert_eq!(panel(&mut modal)[at + 1], "p75");
2259
2260 modal.spec.encoding.y.aggregate = Aggregate::Last;
2261 let rows = panel(&mut modal);
2262 assert_eq!(
2263 rows[at],
2264 format!("Aggregate last {} by row order", g.middot)
2265 );
2266 let request = crate::chart_jobs::ChartRequest::from_modal(&modal).unwrap();
2267 assert!(!request.sorted);
2268 modal.row_order = Some(format!("time {}", g.sort_asc));
2269 let rows = panel(&mut modal);
2270 assert_eq!(
2271 rows[at],
2272 format!("Aggregate last {} by time {}", g.middot, g.sort_asc)
2273 );
2274 let request = crate::chart_jobs::ChartRequest::from_modal(&modal).unwrap();
2275 assert!(request.sorted, "first and last read the view sorted");
2276 modal.spec.encoding.y.aggregate = Aggregate::Mean;
2277 let request = crate::chart_jobs::ChartRequest::from_modal(&modal).unwrap();
2278 assert!(!request.sorted, "a mean needs no order");
2279 }
2280
2281 #[test]
2283 fn ten_series_take_ten_colors() {
2284 let mut modal = open_modal();
2285 modal.set_mark(Mark::Line);
2286 modal.spec.encoding.x.field = Some("price".to_string());
2287 modal.spec.encoding.y.field = vec!["volume".to_string()];
2288 modal.show_legend = true;
2289 let series: Vec<Vec<(f64, f64)>> = (0..10)
2290 .map(|s| (0..5).map(|i| (i as f64, (i * 10 + s) as f64)).collect())
2291 .collect();
2292 let names: Vec<String> = (0..10).map(|i| format!("s{i}")).collect();
2293 let ctx = RenderContext::for_test();
2294 let theme =
2295 crate::config::Theme::from_config(&crate::config::ThemeConfig::default()).unwrap();
2296 let mut theme = theme;
2298 let config = crate::config::ColorConfig::default();
2299 let hex = [
2300 &config.chart_1,
2301 &config.chart_2,
2302 &config.chart_3,
2303 &config.chart_4,
2304 &config.chart_5,
2305 &config.chart_6,
2306 &config.chart_7,
2307 &config.chart_8,
2308 &config.chart_9,
2309 &config.chart_10,
2310 ];
2311 for (i, value) in hex.iter().enumerate() {
2312 let channel = |at: usize| u8::from_str_radix(&value[at..at + 2], 16).unwrap();
2313 let color = ratatui::style::Color::Rgb(channel(1), channel(3), channel(5));
2314 theme.colors.insert(format!("chart_{}", i + 1), color);
2315 }
2316 assert_eq!(theme.series_colors().len(), 10);
2317 let area = Rect::new(0, 0, 80, 30);
2318 let mut buf = Buffer::empty(area);
2319 render_plot(
2320 area,
2321 &mut buf,
2322 &modal,
2323 &theme,
2324 &ctx,
2325 ChartRenderData::XY {
2326 series: Some(&series),
2327 breaks: None,
2328 values: None,
2329 names: &names,
2330 x_axis_kind: XAxisTemporalKind::Numeric,
2331 x_bounds: None,
2332 numbers: PlotNumbers::default(),
2333 other: false,
2334 },
2335 crate::glyphs::unicode(),
2336 );
2337 let mut swatches = Vec::new();
2338 for y in 0..area.height {
2339 let row: String = (0..area.width).map(|x| buf[(x, y)].symbol()).collect();
2340 if let Some(name) = names.iter().find(|n| row.contains(&format!("█ {n} "))) {
2341 let x = (0..area.width)
2342 .find(|&x| buf[(x, y)].symbol() == "█")
2343 .unwrap();
2344 swatches.push((name.clone(), buf[(x, y)].fg));
2345 }
2346 }
2347 assert_eq!(swatches.len(), 10, "{swatches:?}");
2348 let mut colors: Vec<_> = swatches.iter().map(|(_, c)| format!("{c:?}")).collect();
2349 colors.dedup();
2350 assert_eq!(colors.len(), 10, "{swatches:?}");
2351 }
2352
2353 #[test]
2356 fn sixteen_colors_cap_the_series() {
2357 let sixteen = |hex: &str| {
2358 let channel = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16).unwrap();
2359 crate::config::rgb_to_basic_ansi(channel(1), channel(3), channel(5))
2360 };
2361 let config = crate::config::ColorConfig::default();
2362 let mut theme =
2363 crate::config::Theme::from_config(&crate::config::ThemeConfig::default()).unwrap();
2364 for (i, value) in [
2365 &config.chart_1,
2366 &config.chart_2,
2367 &config.chart_3,
2368 &config.chart_4,
2369 &config.chart_5,
2370 &config.chart_6,
2371 &config.chart_7,
2372 &config.chart_8,
2373 &config.chart_9,
2374 &config.chart_10,
2375 ]
2376 .iter()
2377 .enumerate()
2378 {
2379 theme
2380 .colors
2381 .insert(format!("chart_{}", i + 1), sixteen(value));
2382 }
2383 let colors = theme.series_colors();
2384 assert!((2..10).contains(&colors.len()), "{colors:?}");
2385 let mut modal = open_modal();
2386 modal.series_cap = Some(colors.len());
2387 modal.set_mark(Mark::Line);
2388 modal.spec.encoding.x.field = Some("price".to_string());
2389 modal.spec.encoding.y.field = vec!["volume".to_string()];
2390 modal.spec.encoding.color.field = Some("carrier".to_string());
2391 modal.color_counts = Some(crate::chart_modal::ColorCounts {
2392 column: "carrier".to_string(),
2393 values: (0..16)
2394 .map(|i| (Some(format!("C{i}")), 100 - i as u64))
2395 .collect(),
2396 });
2397 let rows = render_rows(&mut modal, 100, 30);
2398 let at = rows.iter().position(|r| r.contains("Color")).unwrap();
2399 assert!(
2400 rows[at + 1].contains(&format!("top {} of 16 by rows", colors.len())),
2401 "{}",
2402 rows[at + 1]
2403 );
2404 let request = crate::chart_jobs::ChartRequest::from_modal(&modal).unwrap();
2405 assert_eq!(request.series_cap, colors.len());
2406 }
2407
2408 #[test]
2410 fn other_is_the_legends_last_entry() {
2411 let mut modal = open_modal();
2412 modal.set_mark(Mark::Scatter);
2413 modal.spec.encoding.x.field = Some("price".to_string());
2414 modal.spec.encoding.y.field = vec!["volume".to_string()];
2415 modal.show_legend = true;
2416 let series: Vec<Vec<(f64, f64)>> = (0..3)
2417 .map(|s| (0..5).map(|i| (i as f64, (i * 10 + s) as f64)).collect())
2418 .collect();
2419 let names: Vec<String> = ["UA", "B6", crate::chart_data::OTHER]
2420 .iter()
2421 .map(|s| s.to_string())
2422 .collect();
2423 let ctx = RenderContext::for_test();
2424 let theme =
2425 crate::config::Theme::from_config(&crate::config::ThemeConfig::default()).unwrap();
2426 let area = Rect::new(0, 0, 60, 24);
2427 let mut buf = Buffer::empty(area);
2428 render_plot(
2429 area,
2430 &mut buf,
2431 &modal,
2432 &theme,
2433 &ctx,
2434 ChartRenderData::XY {
2435 series: Some(&series),
2436 breaks: None,
2437 values: None,
2438 names: &names,
2439 x_axis_kind: XAxisTemporalKind::Numeric,
2440 x_bounds: None,
2441 numbers: PlotNumbers::default(),
2442 other: true,
2443 },
2444 crate::glyphs::unicode(),
2445 );
2446 let rows: Vec<String> = (0..area.height)
2447 .map(|y| (0..area.width).map(|x| buf[(x, y)].symbol()).collect())
2448 .collect();
2449 let at = |name: &str| {
2450 rows.iter()
2451 .position(|r| r.contains(&format!("█ {name}")))
2452 .unwrap_or_else(|| panic!("{name}: {rows:#?}"))
2453 };
2454 assert_eq!(at("UA") + 1, at("B6"));
2455 assert_eq!(at("B6") + 1, at("Other"));
2456 let row = at("Other") as u16;
2457 let swatch = (0..area.width)
2458 .find(|&x| buf[(x, row)].symbol() == "█")
2459 .unwrap();
2460 assert_eq!(buf[(swatch, row)].fg, theme.get("dimmed"));
2461 }
2462
2463 #[test]
2466 fn the_value_picker_lists_values_by_rows() {
2467 let g = crate::glyphs::get();
2468 let mut modal = open_modal();
2469 modal.spec.encoding.color.field = Some("carrier".to_string());
2470 modal.color_counts = Some(crate::chart_modal::ColorCounts {
2471 column: "carrier".to_string(),
2472 values: vec![(Some("UA".to_string()), 2514), (Some("B6".to_string()), 12)],
2473 });
2474 modal.focus = ChartFocus::ColorValues;
2475 modal.open_picker();
2476 modal.picker_toggle(); let rows = render_rows(&mut modal, 100, 30);
2478 let body = rows.join("\n");
2479 assert!(
2480 body.contains(&format!("{} UA", g.checkbox_on)) && body.contains("2,514"),
2481 "{body}"
2482 );
2483 assert!(body.contains(&format!("{} B6", g.checkbox_off)), "{body}");
2484 assert!(body.contains("1 picked"), "{body}");
2485 }
2486
2487 #[test]
2489 fn an_open_picker_with_no_room_still_takes_the_clicks() {
2490 let mut modal = open_modal();
2491 modal.focus = ChartFocus::Y;
2492 modal.open_picker();
2493 let hits = crate::pointer::recording(|| {
2494 render_rows(&mut modal, 100, 7);
2495 });
2496 assert!(hits.iter().any(|(_, h)| *h == Hit::Picker), "{hits:?}");
2497 }
2498
2499 fn render_view(modal: &mut ChartModal, view: ChartView<'_>, w: u16, h: u16) -> Vec<String> {
2500 let ctx = RenderContext::for_test();
2501 let theme = crate::config::Theme::from_config(&crate::config::ThemeConfig::default())
2502 .expect("default theme colors must resolve");
2503 let area = Rect::new(0, 0, w, h);
2504 let mut buf = Buffer::empty(area);
2505 render_chart_view(area, &mut buf, modal, &theme, &ctx, view);
2506 (0..h)
2507 .map(|y| (0..w).map(|x| buf[(x, y)].symbol().to_string()).collect())
2508 .collect()
2509 }
2510
2511 #[test]
2514 fn notes_sit_at_the_title_rows_right() {
2515 let mut modal = open_modal();
2516 modal.spec.encoding.x.field = Some("price".to_string());
2517 modal.spec.encoding.y.field = vec!["volume".to_string()];
2518 let series = vec![vec![(0.0, 1.0), (1.0, 2.0)]];
2519 let note = "sample of 10,000 of 3.5M rows";
2520 let rows = render_view(
2521 &mut modal,
2522 ChartView {
2523 data: ChartRenderData::XY {
2524 series: Some(&series),
2525 breaks: None,
2526 values: None,
2527 names: names(),
2528 x_axis_kind: XAxisTemporalKind::Numeric,
2529 x_bounds: None,
2530 other: false,
2531 numbers: PlotNumbers::default(),
2532 },
2533 notes: vec![note.to_string()],
2534 error: None,
2535 working: None,
2536 schema: None,
2537 },
2538 120,
2539 24,
2540 );
2541 assert!(rows[0].trim_end().ends_with(note), "{:?}", rows[0]);
2542 assert!(rows[23].trim_end().ends_with("price"), "{:?}", rows[23]);
2543 let canvas = |r: &String| r.chars().skip(42).collect::<String>();
2544 assert!(
2545 rows[1..].iter().all(|r| !canvas(r).contains("sample")),
2546 "{rows:#?}"
2547 );
2548 }
2549
2550 #[test]
2553 fn notes_give_way_to_the_how() {
2554 let mut modal = open_modal();
2555 modal.set_mark(Mark::Histogram);
2556 let notes = [
2557 "sample of 1,000,000 of 36.8M rows",
2558 "1,207 values outside p1-p99",
2559 ];
2560 let rows = render_view(
2561 &mut modal,
2562 ChartView {
2563 data: ChartRenderData::Histogram {
2564 data: None,
2565 x: AxisNumbers::default(),
2566 },
2567 notes: notes.iter().map(|n| n.to_string()).collect(),
2568 error: None,
2569 working: None,
2570 schema: None,
2571 },
2572 100,
2573 20,
2574 );
2575 let g = crate::glyphs::get();
2576 let title = rows[0].chars().skip(42).collect::<String>();
2577 assert!(title.starts_with("count per bin "), "{title:?}");
2578 assert!(title.contains("sample of 1,000,000"), "{title:?}");
2579 assert!(title.trim_end().ends_with(g.ellipsis), "{title:?}");
2580 let canvas = |r: &String| r.chars().skip(42).collect::<String>();
2581 assert!(
2582 rows[1..].iter().all(|r| !canvas(r).contains("sample")),
2583 "{rows:#?}"
2584 );
2585 }
2586
2587 #[test]
2589 fn an_error_replaces_the_plot() {
2590 let mut modal = open_modal();
2591 let rows = render_view(
2592 &mut modal,
2593 ChartView {
2594 data: ChartRenderData::XY {
2595 series: None,
2596 breaks: None,
2597 values: None,
2598 names: names(),
2599 x_axis_kind: XAxisTemporalKind::Numeric,
2600 x_bounds: None,
2601 other: false,
2602 numbers: PlotNumbers::default(),
2603 },
2604 notes: Vec::new(),
2605 error: Some("column not found: gone"),
2606 working: None,
2607 schema: None,
2608 },
2609 100,
2610 24,
2611 );
2612 assert!(rows.join("\n").contains("column not found: gone"));
2613 }
2614
2615 #[test]
2617 fn a_line_does_not_bridge_a_gap() {
2618 let mut modal = open_modal();
2619 modal.spec.encoding.x.field = Some("price".to_string());
2620 modal.spec.encoding.y.field = vec!["volume".to_string()];
2621 modal.show_legend = false;
2622 let series = vec![vec![(0.0, 0.0), (1.0, 0.0), (9.0, 0.0), (10.0, 0.0)]];
2623 let draw = |modal: &mut ChartModal, breaks: &Vec<Vec<usize>>| {
2624 render_view(
2625 modal,
2626 ChartView {
2627 data: ChartRenderData::XY {
2628 series: Some(&series),
2629 breaks: Some(breaks),
2630 values: None,
2631 names: names(),
2632 x_axis_kind: XAxisTemporalKind::Numeric,
2633 x_bounds: None,
2634 other: false,
2635 numbers: PlotNumbers::default(),
2636 },
2637 notes: Vec::new(),
2638 error: None,
2639 working: None,
2640 schema: None,
2641 },
2642 100,
2643 24,
2644 )
2645 };
2646 let blank = |c: char| c == ' ' || c == '\u{2800}';
2647 let marks = |rows: &[String]| -> usize {
2648 rows.iter()
2649 .map(|r| r.chars().skip(44).filter(|c| !blank(*c)).count())
2650 .sum()
2651 };
2652 let joined = draw(&mut modal, &vec![Vec::new()]);
2653 let broken = draw(&mut modal, &vec![vec![2]]);
2654 assert!(
2655 marks(&broken) < marks(&joined),
2656 "the run from 1 to 9 is not drawn"
2657 );
2658 }
2659
2660 fn bar_data(n: usize) -> BarData {
2661 use crate::chart_data::Bar;
2662 BarData {
2663 category: "carrier".to_string(),
2664 value_column: "delay".to_string(),
2665 bars: (0..n)
2666 .map(|i| Bar {
2667 label: Some(format!("C{i}")),
2668 value: (n - i) as f64,
2669 by_group: Vec::new(),
2670 })
2671 .collect(),
2672 more: 0,
2673 no_value: 0,
2674 rows: Default::default(),
2675 value_dtype: polars::prelude::DataType::Float64,
2676 counted: None,
2677 groups: Vec::new(),
2678 other: false,
2679 rows_note: None,
2680 }
2681 }
2682
2683 fn render_bars(data: &BarData, w: u16, h: u16) -> Vec<String> {
2684 let mut modal = open_modal();
2685 modal.set_mark(Mark::Bar);
2686 render_view(
2687 &mut modal,
2688 ChartView {
2689 data: ChartRenderData::Bar { data: Some(data) },
2690 notes: Vec::new(),
2691 error: None,
2692 working: None,
2693 schema: None,
2694 },
2695 w,
2696 h,
2697 )
2698 }
2699
2700 #[test]
2703 fn bars_draw_label_value_and_length() {
2704 let g = crate::glyphs::get();
2705 let full = g.bar_eighths[7];
2706 let data = bar_data(3);
2707 let rows = render_bars(&data, 100, 24);
2708 let canvas: Vec<String> = rows.iter().map(|r| r.chars().skip(42).collect()).collect();
2710 assert!(canvas[1].trim_start().starts_with("carrier") && canvas[1].contains("delay"));
2711 let bar_len = |row: &str| row.matches(full).count();
2712 let words = |row: &str| row.split_whitespace().take(2).collect::<Vec<_>>().join(" ");
2713 assert_eq!(words(&canvas[2]), "C0 3.00", "{:?}", canvas[2]);
2714 assert_eq!(words(&canvas[4]), "C2 1.00", "{:?}", canvas[4]);
2715 let (a, c) = (bar_len(&canvas[2]), bar_len(&canvas[4]));
2716 assert!(
2717 a > 40 && (a as f64 / c as f64 - 3.0).abs() < 0.2,
2718 "{a} vs {c}"
2719 );
2720 }
2721
2722 #[test]
2725 fn bars_past_the_height_are_counted_on_a_chip() {
2726 let mut data = bar_data(50);
2727 data.more = 200;
2728 let rows = render_bars(&data, 80, 24);
2729 let body = rows.join("\n");
2730 assert!(body.contains("C20 "), "{body}");
2732 assert!(!body.contains("C21 "), "{body}");
2733 assert!(rows[23].contains(" + 229 more "), "{:?}", rows[23]);
2734 }
2735
2736 #[test]
2738 fn negative_bars_grow_left_and_null_categories_show() {
2739 use crate::chart_data::Bar;
2740 let g = crate::glyphs::get();
2741 let full = g.bar_eighths[7];
2742 let mut data = bar_data(0);
2743 data.bars = vec![
2744 Bar {
2745 label: Some("UA".to_string()),
2746 value: 10.0,
2747 by_group: Vec::new(),
2748 },
2749 Bar {
2750 label: None,
2751 value: -10.0,
2752 by_group: Vec::new(),
2753 },
2754 ];
2755 let rows = render_bars(&data, 100, 24);
2756 let canvas: Vec<String> = rows.iter().map(|r| r.chars().skip(42).collect()).collect();
2757 let first = |row: &str| row.find(full).map(|i| row[..i].chars().count());
2758 let pos = first(&canvas[2]).unwrap();
2759 let neg = first(&canvas[3]).unwrap();
2760 assert!(neg < pos, "the negative bar starts left of zero");
2761 assert!(
2762 canvas[3].trim_start().starts_with(g.null),
2763 "{:?}",
2764 canvas[3]
2765 );
2766 }
2767
2768 #[test]
2771 fn a_tiny_value_still_draws_a_mark() {
2772 use crate::chart_data::Bar;
2773 let g = crate::glyphs::get();
2774 let mut data = bar_data(0);
2775 data.bars = [
2776 ("big", 10_000.0),
2777 ("tiny", 0.1),
2778 ("zero", 0.0),
2779 ("neg", -0.1),
2780 ]
2781 .into_iter()
2782 .map(|(label, value)| Bar {
2783 label: Some(label.to_string()),
2784 value,
2785 by_group: Vec::new(),
2786 })
2787 .collect();
2788 let rows = render_bars(&data, 100, 24);
2789 let canvas: Vec<String> = rows.iter().map(|r| r.chars().skip(42).collect()).collect();
2790 let marks = |row: &str| {
2792 let mut words = row.split_whitespace();
2793 let (label, value) = (words.next().unwrap(), words.next().unwrap());
2794 let zone = row.replacen(label, "", 1).replacen(value, "", 1);
2795 g.bar_eighths
2796 .iter()
2797 .filter(|m| !m.trim().is_empty() && zone.contains(**m))
2798 .count()
2799 };
2800 assert!(marks(&canvas[3]) > 0, "tiny: {:?}", canvas[3]);
2801 assert_eq!(marks(&canvas[4]), 0, "zero: {:?}", canvas[4]);
2802 assert!(marks(&canvas[5]) > 0, "neg: {:?}", canvas[5]);
2803 }
2804
2805 fn plot_text(modal: &ChartModal, data: ChartRenderData<'_>, g: &Glyphs) -> String {
2806 plot_text_in(modal, data, g, Rect::new(0, 0, 60, 20))
2807 }
2808
2809 fn plot_text_in(
2810 modal: &ChartModal,
2811 data: ChartRenderData<'_>,
2812 g: &Glyphs,
2813 area: Rect,
2814 ) -> String {
2815 plot_text_with(&RenderContext::for_test(), modal, data, g, area)
2816 }
2817
2818 fn plot_text_with(
2819 ctx: &RenderContext,
2820 modal: &ChartModal,
2821 data: ChartRenderData<'_>,
2822 g: &Glyphs,
2823 area: Rect,
2824 ) -> String {
2825 let theme = crate::config::Theme::from_config(&crate::config::ThemeConfig::default())
2826 .expect("default theme colors must resolve");
2827 let mut buf = Buffer::empty(area);
2828 render_plot(area, &mut buf, modal, &theme, ctx, data, g);
2829 (0..area.height)
2830 .map(|y| {
2831 (0..area.width)
2832 .map(|x| buf[(x, y)].symbol())
2833 .collect::<String>()
2834 })
2835 .collect::<Vec<_>>()
2836 .join("\n")
2837 }
2838
2839 #[test]
2843 fn axis_labels_stand_apart_and_titles_keep_their_rows() {
2844 type Case<'a> = (
2846 &'a str,
2847 ChartRenderData<'a>,
2848 &'a str,
2849 &'a str,
2850 &'a dyn Fn(&str) -> bool,
2851 );
2852 use crate::chart_data::{HistogramBin, KdeSeries};
2853 let dates: Vec<Vec<(f64, f64)>> = vec![
2855 (0..=100)
2856 .map(|i| (18262.0 + f64::from(i) * 18.26, f64::from(i % 7) * 1000.0))
2857 .collect(),
2858 ];
2859 let numbers: Vec<Vec<(f64, f64)>> = vec![
2860 (0..20)
2861 .map(|i| (i as f64 * 1234.5, (i * i) as f64))
2862 .collect(),
2863 ];
2864 let histogram = HistogramData {
2865 column: "price".to_string(),
2866 groups: Vec::new(),
2867 other: false,
2868 share: false,
2869 bins: (0..10)
2870 .map(|i| HistogramBin {
2871 center: i as f64 * 250.0 + 125.0,
2872 count: (1 + i % 4) as f64 * 1000.0,
2873 })
2874 .collect(),
2875 x_min: 0.0,
2876 x_max: 2500.0,
2877 max_count: 4000.0,
2878 rows: Default::default(),
2879 clipped: None,
2880 };
2881 let kde = KdeData {
2882 other: false,
2883 series: vec![KdeSeries {
2884 name: "price".to_string(),
2885 points: (0..=100)
2886 .map(|i| {
2887 let x = i as f64 * 100.0 - 5000.0;
2888 (x, (-x * x / 2e6).exp())
2889 })
2890 .collect(),
2891 }],
2892 x_min: -5000.0,
2893 x_max: 5000.0,
2894 y_max: 1.0,
2895 rows: Default::default(),
2896 clipped: None,
2897 };
2898 let is_date = |t: &str| {
2899 [4, 5, 7, 10].contains(&t.len()) && t.chars().all(|c| c.is_ascii_digit() || c == '-')
2900 };
2901 let is_number = |t: &str| {
2902 t.trim_end_matches(['k', 'M', 'G', 'T'])
2903 .parse::<f64>()
2904 .is_ok()
2905 };
2906
2907 let mut modal = open_modal();
2908 modal.spec.encoding.y.field = vec!["price".to_string()];
2909 modal.show_legend = false;
2910 for columns in [40u16, 60, 80] {
2911 let area = Rect::new(0, 0, columns - SIDEBAR_WIDTH.min(columns / 2), 18);
2912 for g in [crate::glyphs::ascii(), crate::glyphs::unicode()] {
2913 let cases: [Case; 4] = [
2914 (
2915 "date",
2916 ChartRenderData::XY {
2917 series: Some(&dates),
2918 breaks: None,
2919 values: None,
2920 names: names(),
2921 x_axis_kind: XAxisTemporalKind::Date,
2922 x_bounds: None,
2923 other: false,
2924 numbers: PlotNumbers::default(),
2925 },
2926 "date",
2927 "price",
2928 &is_date,
2929 ),
2930 (
2931 "number",
2932 ChartRenderData::XY {
2933 series: Some(&numbers),
2934 breaks: None,
2935 values: None,
2936 names: names(),
2937 x_axis_kind: XAxisTemporalKind::Numeric,
2938 x_bounds: None,
2939 other: false,
2940 numbers: PlotNumbers::default(),
2941 },
2942 "volume",
2943 "price",
2944 &is_number,
2945 ),
2946 (
2947 "histogram",
2948 ChartRenderData::Histogram {
2949 data: Some(&histogram),
2950 x: AxisNumbers::default(),
2951 },
2952 "price",
2953 "Count",
2954 &is_number,
2955 ),
2956 (
2957 "KDE",
2958 ChartRenderData::Kde {
2959 data: Some(&kde),
2960 x: AxisNumbers::default(),
2961 },
2962 "Value",
2963 "Density",
2964 &is_number,
2965 ),
2966 ];
2967 for (what, data, x_title, y_title, is_label) in cases {
2968 modal.spec.encoding.x.field = Some(x_title.to_string());
2969 let text = plot_text_in(&modal, data, g, area);
2970 let rows: Vec<&str> = text.lines().collect();
2971 let what = format!("{what} at {columns} columns:\n{text}");
2972 let axis = rows
2973 .iter()
2974 .rposition(|r| r.contains(g.plot.axis.bottom_left))
2975 .expect(&what);
2976 let labels: Vec<&str> = rows[axis + 1].split_whitespace().collect();
2977 assert!(labels.len() >= 2, "both ends: {what}");
2978 assert!(labels.iter().all(|l| is_label(l)), "apart: {what}");
2979 assert_eq!(rows[0].trim_end(), y_title, "y title row: {what}");
2980 assert_eq!(rows[axis + 2].trim_start(), x_title, "x title row: {what}");
2981 }
2982 }
2983 }
2984 }
2985
2986 #[test]
2990 fn an_axis_writes_every_label_in_one_format() {
2991 use crate::chart_data::KdeSeries;
2992 use crate::numfmt::NumberFormat;
2993 let g = crate::glyphs::unicode();
2994 let y_labels = |text: &str| -> Vec<String> {
2995 text.lines()
2996 .filter_map(|r| r.split_once(['│', '┤'])?.0.split_whitespace().next())
2997 .map(String::from)
2998 .collect()
2999 };
3000 let mut modal = open_modal();
3001 modal.show_legend = false;
3002
3003 let kde = KdeData {
3004 other: false,
3005 series: vec![KdeSeries {
3006 name: "price".to_string(),
3007 points: (0..=100)
3008 .map(|i| {
3009 let x = f64::from(i);
3010 (x, 0.0126 * (-(x - 50.0).powi(2) / 400.0).exp())
3011 })
3012 .collect(),
3013 }],
3014 x_min: 0.0,
3015 x_max: 100.0,
3016 y_max: 0.0126,
3017 rows: Default::default(),
3018 clipped: None,
3019 };
3020 let data = ChartRenderData::Kde {
3021 data: Some(&kde),
3022 x: AxisNumbers::default(),
3023 };
3024 let text = plot_text_in(&modal, data, g, Rect::new(0, 0, 40, 12));
3025 assert_eq!(y_labels(&text), ["0.02", "0.01", "0.00"], "{text}");
3026
3027 let european = NumberFormat::preset("european").unwrap();
3028 let mut ctx = RenderContext::for_test();
3029 ctx.number_format.format = european.clone();
3030 modal.spec.encoding.x.field = Some("volume".to_string());
3031 modal.spec.encoding.y.field = vec!["price".to_string()];
3032 modal.y_starts_at_zero = false;
3033 let series = vec![vec![(0.0, 12_000.0), (5.0, 12_600.0)]];
3034 let xy = || ChartRenderData::XY {
3035 series: Some(&series),
3036 breaks: None,
3037 values: None,
3038 names: names(),
3039 x_axis_kind: XAxisTemporalKind::Numeric,
3040 x_bounds: None,
3041 other: false,
3042 numbers: PlotNumbers {
3043 x: AxisNumbers::default(),
3044 y: AxisNumbers {
3045 format: european.clone(),
3046 whole: false,
3047 },
3048 },
3049 };
3050 let text = plot_text_with(&ctx, &modal, xy(), g, Rect::new(0, 0, 40, 12));
3052 assert_eq!(
3053 y_labels(&text),
3054 ["12.600", "12.400", "12.200", "12.000"],
3055 "{text}"
3056 );
3057 let text = plot_text_with(&ctx, &modal, xy(), g, Rect::new(0, 0, 16, 12));
3059 assert_eq!(
3060 y_labels(&text),
3061 ["12,6k", "12,4k", "12,2k", "12,0k"],
3062 "{text}"
3063 );
3064 }
3065
3066 #[test]
3069 fn heatmap_y_labels_are_whole_where_the_column_is() {
3070 let heatmap = |(y_min, y_max)| HeatmapData {
3071 x_column: "price".to_string(),
3072 y_column: "flag".to_string(),
3073 x_min: 0.0,
3074 x_max: 10.0,
3075 y_min,
3076 y_max,
3077 x_bins: 4,
3078 y_bins: 4,
3079 counts: vec![vec![1.0; 4]; 4],
3080 max_count: 1.0,
3081 rows: Default::default(),
3082 };
3083 let y_labels = |text: &str| -> Vec<String> {
3084 text.lines()
3086 .filter(|r| r.contains('@'))
3087 .filter_map(|r| r.split_whitespace().next())
3088 .filter(|l| !l.starts_with('@'))
3089 .map(String::from)
3090 .collect()
3091 };
3092 let whole = PlotNumbers {
3093 x: AxisNumbers::default(),
3094 y: AxisNumbers {
3095 whole: true,
3096 ..AxisNumbers::default()
3097 },
3098 };
3099 let g = crate::glyphs::unicode();
3100 let modal = open_modal();
3101 let area = Rect::new(0, 0, 40, 12);
3102 let data = heatmap((0.0, 1.0));
3103 let text = plot_text_in(
3104 &modal,
3105 ChartRenderData::Heatmap {
3106 data: Some(&data),
3107 numbers: whole,
3108 },
3109 g,
3110 area,
3111 );
3112 assert_eq!(y_labels(&text), ["1", "0"], "{text}");
3113 let data = heatmap((0.0, 0.0126));
3114 let text = plot_text_in(
3115 &modal,
3116 ChartRenderData::Heatmap {
3117 data: Some(&data),
3118 numbers: PlotNumbers::default(),
3119 },
3120 g,
3121 area,
3122 );
3123 assert_eq!(y_labels(&text), ["0.010", "0.005", "0.000"], "{text}");
3124 }
3125
3126 #[test]
3129 fn whole_number_axes_tick_whole_in_the_table_format() {
3130 use crate::chart_data::HistogramBin;
3131 use crate::numfmt::NumberFormat;
3132 let thousands = NumberFormat::preset("thousands").unwrap();
3133 let whole = AxisNumbers {
3134 format: thousands.clone(),
3135 whole: true,
3136 };
3137 let mut ctx = RenderContext::for_test();
3138 ctx.number_format.format = thousands.clone();
3139 let g = crate::glyphs::unicode();
3140 let area = Rect::new(0, 0, 40, 12);
3141 let y_labels = |text: &str| -> Vec<String> {
3143 text.lines()
3144 .filter_map(|r| r.split_once(['│', '┤'])?.0.split_whitespace().next())
3145 .map(String::from)
3146 .collect()
3147 };
3148 let axis_row = |text: &str| {
3149 let rows: Vec<&str> = text.lines().collect();
3150 let axis = rows.iter().rposition(|r| r.contains('└')).expect(text);
3151 rows[axis + 1]
3152 .split_whitespace()
3153 .collect::<Vec<_>>()
3154 .join(" ")
3155 };
3156
3157 let histogram = HistogramData {
3159 column: "passengers".to_string(),
3160 groups: Vec::new(),
3161 other: false,
3162 share: false,
3163 bins: (0..7)
3164 .map(|i| HistogramBin {
3165 center: f64::from(i) + 0.5,
3166 count: [4321.0, 12.0, 900.0][i as usize % 3],
3167 })
3168 .collect(),
3169 x_min: 0.0,
3170 x_max: 7.0,
3171 max_count: 4321.0,
3172 rows: Default::default(),
3173 clipped: None,
3174 };
3175 let data = ChartRenderData::Histogram {
3176 data: Some(&histogram),
3177 x: whole.clone(),
3178 };
3179 let text = plot_text_with(&ctx, &open_modal(), data, g, area);
3180 assert_eq!(y_labels(&text), ["5,000", "2,500", "0"], "{text}");
3182 assert_eq!(axis_row(&text), "0 2 4 6", "{text}");
3184
3185 let series = vec![vec![(0.0, 3.0), (5.0, 10.0)]];
3187 let mut modal = open_modal();
3188 modal.spec.encoding.x.field = Some("volume".to_string());
3189 modal.spec.encoding.y.field = vec!["price".to_string()];
3190 modal.show_legend = false;
3191 let xy = |numbers| ChartRenderData::XY {
3192 series: Some(&series),
3193 breaks: None,
3194 values: None,
3195 names: names(),
3196 x_axis_kind: XAxisTemporalKind::Numeric,
3197 x_bounds: None,
3198 other: false,
3199 numbers,
3200 };
3201 let numbers = PlotNumbers {
3202 x: whole.clone(),
3203 y: whole,
3204 };
3205 let text = plot_text_with(&ctx, &modal, xy(numbers), g, area);
3206 assert_eq!(y_labels(&text), ["10", "5", "0"], "{text}");
3208 assert_eq!(axis_row(&text), "0 2 4", "{text}");
3209 let text = plot_text_with(&ctx, &modal, xy(PlotNumbers::default()), g, area);
3211 assert_eq!(axis_row(&text), "0 2 4", "{text}");
3212 }
3213
3214 #[test]
3217 fn every_plot_is_ascii_under_the_ascii_set() {
3218 use crate::chart_data::{BoxPlotStats, HistogramBin, KdeSeries};
3219 let (ascii, unicode) = (crate::glyphs::ascii(), crate::glyphs::unicode());
3220 let check = |what: &str, text: &str, marks: &[char]| {
3221 assert!(text.is_ascii(), "{what}:\n{text}");
3222 assert!(text.contains("+-"), "{what} has its axis corner:\n{text}");
3223 for mark in marks {
3224 assert!(text.contains(*mark), "{what} draws {mark:?}:\n{text}");
3225 }
3226 };
3227
3228 let mut modal = open_modal();
3229 modal.spec.encoding.x.field = Some("price".to_string());
3230 modal.spec.encoding.y.field = vec!["price".to_string(), "volume".to_string()];
3231 modal.show_legend = true;
3232 let series = vec![
3233 (0..20).map(|i| (i as f64, (i * i) as f64)).collect(),
3234 (0..20)
3235 .map(|i| (i as f64, 400.0 - (i * i) as f64))
3236 .collect(),
3237 ];
3238 let xy = |series| ChartRenderData::XY {
3239 series,
3240 breaks: None,
3241 values: None,
3242 names: names(),
3243 x_axis_kind: XAxisTemporalKind::Numeric,
3244 x_bounds: None,
3245 other: false,
3246 numbers: PlotNumbers::default(),
3247 };
3248 for (chart_type, mark) in [(Mark::Line, '*'), (Mark::Scatter, 'o')] {
3249 modal.spec.mark = chart_type;
3250 let text = plot_text(&modal, xy(Some(&series)), ascii);
3251 check(chart_type.label(), &text, &[mark]);
3252 assert!(text.contains("# price"), "{text}");
3254 let text = plot_text(&modal, xy(Some(&series)), unicode);
3255 assert!(text.contains("█ price"), "{text}");
3256 }
3257 check("placeholder", &plot_text(&modal, xy(None), ascii), &[]);
3259
3260 let histogram = HistogramData {
3261 column: "price".to_string(),
3262 groups: Vec::new(),
3263 other: false,
3264 share: false,
3265 bins: (0..10)
3266 .map(|i| HistogramBin {
3267 center: i as f64 + 0.5,
3268 count: (1 + i % 4) as f64,
3269 })
3270 .collect(),
3271 x_min: 0.0,
3272 x_max: 10.0,
3273 max_count: 4.0,
3274 rows: Default::default(),
3275 clipped: None,
3276 };
3277 let data = ChartRenderData::Histogram {
3278 data: Some(&histogram),
3279 x: AxisNumbers::default(),
3280 };
3281 check("histogram", &plot_text(&modal, data, ascii), &['#']);
3282
3283 let box_plot = BoxPlotData {
3284 stats: ["price", "volume"]
3285 .iter()
3286 .map(|name| BoxPlotStats {
3287 name: name.to_string(),
3288 min: 0.0,
3289 q1: 2.0,
3290 median: 5.0,
3291 q3: 7.0,
3292 max: 10.0,
3293 })
3294 .collect(),
3295 of: 0,
3296 y_min: 0.0,
3297 y_max: 10.0,
3298 rows: Default::default(),
3299 clipped: None,
3300 };
3301 let data = ChartRenderData::BoxPlot {
3302 data: Some(&box_plot),
3303 y: AxisNumbers::default(),
3304 };
3305 check("box plot", &plot_text(&modal, data, ascii), &['o']);
3306
3307 let kde = KdeData {
3308 other: false,
3309 series: vec![KdeSeries {
3310 name: "price".to_string(),
3311 points: (0..=100)
3312 .map(|i| {
3313 let x = i as f64 / 10.0 - 5.0;
3314 (x, (-x * x / 2.0).exp())
3315 })
3316 .collect(),
3317 }],
3318 x_min: -5.0,
3319 x_max: 5.0,
3320 y_max: 1.0,
3321 rows: Default::default(),
3322 clipped: None,
3323 };
3324 let data = ChartRenderData::Kde {
3325 data: Some(&kde),
3326 x: AxisNumbers::default(),
3327 };
3328 check("KDE", &plot_text(&modal, data, ascii), &['*']);
3329
3330 let bars = bar_data(5);
3332 let text = plot_text(&modal, ChartRenderData::Bar { data: Some(&bars) }, ascii);
3333 assert!(text.is_ascii() && text.contains('#'), "bars:\n{text}");
3334 }
3335
3336 #[test]
3339 fn the_legend_hides_the_plot_behind_it() {
3340 let mut modal = open_modal();
3341 modal.spec.encoding.x.field = Some("price".to_string());
3342 modal.spec.encoding.y.field = vec!["price".to_string(), "volume".to_string()];
3343 modal.show_legend = true;
3344 let series: Vec<Vec<(f64, f64)>> = vec![
3346 (0..240)
3347 .map(|i| (i as f64 / 2.0, if i % 2 == 0 { 0.0 } else { 100.0 }))
3348 .collect();
3349 2
3350 ];
3351 for g in [crate::glyphs::ascii(), crate::glyphs::unicode()] {
3352 let text = plot_text(
3353 &modal,
3354 ChartRenderData::XY {
3355 series: Some(&series),
3356 breaks: None,
3357 values: None,
3358 names: names(),
3359 x_axis_kind: XAxisTemporalKind::Numeric,
3360 x_bounds: None,
3361 other: false,
3362 numbers: PlotNumbers::default(),
3363 },
3364 g,
3365 );
3366 let swatch = g.bar_eighths[7];
3367 let price = format!(" {swatch} price ");
3368 let volume = format!(" {swatch} volume ");
3369 let lines: Vec<&str> = text.lines().collect();
3370 let at = lines
3371 .iter()
3372 .position(|l| l.contains(&price))
3373 .unwrap_or_else(|| panic!("{text}"));
3374 assert!(lines[at + 1].contains(&volume), "{text}");
3375 let corner = g.plot.axis.top_left;
3376 assert!(!lines[at - 1].contains(corner), "no frame: {text}");
3377 }
3378 }
3379
3380 fn decade() -> Vec<Vec<(f64, f64)>> {
3382 vec![
3384 (0..3653)
3385 .map(|i| (16436.0 + f64::from(i), 1000.0 + f64::from(i % 400) * 6.0))
3386 .collect(),
3387 ]
3388 }
3389
3390 fn xy_dates(series: &Vec<Vec<(f64, f64)>>) -> ChartRenderData<'_> {
3391 ChartRenderData::XY {
3392 series: Some(series),
3393 breaks: None,
3394 values: None,
3395 names: names(),
3396 x_axis_kind: XAxisTemporalKind::Date,
3397 x_bounds: None,
3398 other: false,
3399 numbers: PlotNumbers::default(),
3400 }
3401 }
3402
3403 #[test]
3406 fn a_wide_chart_carries_ticks_scaled_to_the_space() {
3407 let mut modal = open_modal();
3408 modal.spec.encoding.x.field = Some("date".to_string());
3409 modal.spec.encoding.y.field = vec!["price".to_string()];
3410 let series = decade();
3411 let rows = render_view(
3412 &mut modal,
3413 ChartView {
3414 data: xy_dates(&series),
3415 notes: Vec::new(),
3416 error: None,
3417 working: None,
3418 schema: None,
3419 },
3420 300,
3421 60,
3422 );
3423 let rows: Vec<String> = rows
3425 .iter()
3426 .map(|r| r.chars().skip(SIDEBAR_WIDTH as usize + 2).collect())
3427 .collect();
3428 let axis = rows.iter().rposition(|r| r.contains('└')).unwrap();
3429 let x_labels: Vec<&str> = rows[axis + 1].split_whitespace().collect();
3430 assert!(
3431 (15..=20).contains(&x_labels.len()),
3432 "{} x labels: {x_labels:?}",
3433 x_labels.len()
3434 );
3435 assert!(
3436 x_labels
3437 .iter()
3438 .all(|l| l.len() == 4 || ["Apr", "Jul", "Oct"].contains(l)),
3439 "{x_labels:?}"
3440 );
3441 let y_labels: Vec<f64> = rows[..axis]
3442 .iter()
3443 .filter_map(|r| r.split_once('┤')?.0.split_whitespace().last()?.parse().ok())
3444 .collect();
3445 let plot_rows = axis - 2; let per_label = plot_rows as f64 / y_labels.len() as f64;
3447 assert!(
3448 (3.0..=5.0).contains(&per_label),
3449 "{per_label}: {y_labels:?}"
3450 );
3451 let step = y_labels[0] - y_labels[1];
3452 let mantissa = step / 10f64.powf(step.log10().floor());
3453 assert!([1.0, 2.0, 2.5, 5.0].contains(&mantissa), "{y_labels:?}");
3454 assert!(
3455 y_labels.windows(2).all(|w| w[0] - w[1] == step)
3456 && y_labels.iter().all(|v| v % step == 0.0),
3457 "{y_labels:?}"
3458 );
3459 }
3460
3461 #[test]
3463 fn the_grid_follows_the_toggle() {
3464 let g = crate::glyphs::unicode();
3465 let mut modal = open_modal();
3466 modal.spec.encoding.x.field = Some("date".to_string());
3467 modal.spec.encoding.y.field = vec!["price".to_string()];
3468 let series = decade();
3469 let theme = crate::config::Theme::from_config(&crate::config::ThemeConfig::default())
3470 .expect("default theme colors must resolve");
3471 let draw = |modal: &ChartModal| {
3472 let area = Rect::new(0, 0, 100, 30);
3473 let mut buf = Buffer::empty(area);
3474 let ctx = RenderContext::for_test();
3475 render_plot(area, &mut buf, modal, &theme, &ctx, xy_dates(&series), g);
3476 buf
3477 };
3478 let grid_cells = |buf: &Buffer| {
3479 buf.content()
3480 .iter()
3481 .filter(|c| c.symbol() == g.plot.grid_down || c.symbol() == g.plot.grid_across)
3482 .collect::<Vec<_>>()
3483 .len()
3484 };
3485 assert_eq!(grid_cells(&draw(&modal)), 0);
3486 modal.toggle_grid();
3487 let on = draw(&modal);
3488 assert!(grid_cells(&on) > 100);
3489 let grid = theme.get("chart_grid");
3490 assert!(
3491 on.content()
3492 .iter()
3493 .filter(|c| c.symbol() == g.plot.grid_across)
3494 .all(|c| c.fg == grid)
3495 );
3496 }
3497
3498 #[test]
3501 fn the_light_grid_survives_sixteen_colors() {
3502 use ratatui::style::Color;
3503 let g = crate::glyphs::unicode();
3504 let sixteen = |hex: &str| {
3505 let channel = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16).unwrap();
3506 crate::config::rgb_to_basic_ansi(channel(1), channel(3), channel(5))
3507 };
3508 let light = crate::config::ColorConfig::light();
3509 let grid = sixteen(&light.chart_grid);
3510 assert!(
3511 !matches!(grid, Color::White | Color::Black | Color::Reset),
3512 "{grid:?}"
3513 );
3514 let dark = sixteen(&crate::config::ColorConfig::default().chart_grid);
3515 assert!(!matches!(dark, Color::White | Color::Black), "{dark:?}");
3516
3517 let mut theme =
3518 crate::config::Theme::from_config(&crate::config::ThemeConfig::default()).unwrap();
3519 theme.colors.insert("chart_grid".to_string(), grid);
3520 let mut modal = open_modal();
3521 modal.spec.encoding.x.field = Some("date".to_string());
3522 modal.spec.encoding.y.field = vec!["price".to_string()];
3523 modal.toggle_grid();
3524 let series = decade();
3525 let area = Rect::new(0, 0, 80, 24);
3526 let mut buf = Buffer::empty(area);
3527 let ctx = RenderContext::for_test();
3528 render_plot(area, &mut buf, &modal, &theme, &ctx, xy_dates(&series), g);
3529 let cells: Vec<_> = buf
3530 .content()
3531 .iter()
3532 .filter(|c| c.symbol() == g.plot.grid_across || c.symbol() == g.plot.grid_down)
3533 .collect();
3534 assert!(cells.len() > 50);
3535 assert!(cells.iter().all(|c| c.fg == grid));
3536 }
3537
3538 fn rows_of(buf: &Buffer) -> Vec<String> {
3539 let area = buf.area;
3540 (0..area.height)
3541 .map(|y| (0..area.width).map(|x| buf[(x, y)].symbol()).collect())
3542 .collect()
3543 }
3544
3545 #[test]
3548 fn log_scale_ticks_fall_on_the_decades() {
3549 let g = crate::glyphs::unicode();
3550 let mut modal = open_modal();
3551 modal.spec.encoding.x.field = Some("x".to_string());
3552 modal.spec.encoding.y.field = vec!["count".to_string()];
3553 modal.log_scale = true;
3554 let linear: Vec<Vec<(f64, f64)>> = vec![
3556 (0..=300)
3557 .map(|i| (f64::from(i), f64::from(i).powi(2) * 3.333))
3558 .collect(),
3559 ];
3560 let logged = crate::chart_jobs::log_series(&linear);
3561 let theme =
3562 crate::config::Theme::from_config(&crate::config::ThemeConfig::default()).unwrap();
3563 let ctx = RenderContext::for_test();
3564 let y_labels = |height: u16| {
3565 let area = Rect::new(0, 0, 60, height);
3566 let mut buf = Buffer::empty(area);
3567 let data = ChartRenderData::XY {
3568 series: Some(&logged),
3569 breaks: None,
3570 values: Some(&linear),
3571 names: names(),
3572 x_axis_kind: XAxisTemporalKind::Numeric,
3573 x_bounds: None,
3574 other: false,
3575 numbers: PlotNumbers::default(),
3576 };
3577 render_plot(area, &mut buf, &modal, &theme, &ctx, data, g);
3578 let rows = rows_of(&buf);
3579 let labels: Vec<String> = rows
3580 .iter()
3581 .filter_map(|row| {
3582 let (label, _) = row.split_once(g.plot.tick_y)?;
3583 let label = label.trim();
3584 (!label.is_empty()).then(|| label.to_string())
3585 })
3586 .collect();
3587 (labels, rows)
3588 };
3589 let nice = |labels: &[String]| {
3591 let values: Vec<f64> = labels.iter().map(|l| l.parse().unwrap()).collect();
3592 values.windows(2).all(|w| w[0] > w[1])
3593 && values.iter().all(|&v| {
3594 let decade = 10f64.powf(v.log10().floor());
3595 v == 0.0 || [1.0, 2.0, 5.0].contains(&(v / decade))
3596 })
3597 };
3598 let (tall, rows) = y_labels(40);
3599 assert!(nice(&tall), "{rows:#?}");
3600 for decade in ["0", "10", "100", "1000", "10000", "100000", "1000000"] {
3602 assert!(tall.contains(&decade.to_string()), "{decade}: {rows:#?}");
3603 }
3604 assert_eq!(tall[0], "1000000", "{rows:#?}");
3605 assert_eq!(tall.last().unwrap(), "0", "{rows:#?}");
3606 let (short, rows) = y_labels(12);
3607 assert!(nice(&short), "{rows:#?}");
3608 assert!(short.len() >= 3 && short.len() < tall.len(), "{rows:#?}");
3609 }
3610
3611 #[test]
3614 fn the_crosshair_reads_out_every_series() {
3615 let mut modal = open_modal();
3616 modal.spec.encoding.x.field = Some("date".to_string());
3617 modal.spec.encoding.y.field = vec!["price".to_string(), "volume".to_string()];
3618 let series: Vec<Vec<(f64, f64)>> = vec![
3619 (0..10)
3620 .map(|i| (19_783.0 + f64::from(i), 1.5 * f64::from(i)))
3621 .collect(),
3622 (0..10)
3623 .filter(|i| *i != 4)
3624 .map(|i| (19_783.0 + f64::from(i), f64::from(i * 100)))
3625 .collect(),
3626 ];
3627 let theme =
3628 crate::config::Theme::from_config(&crate::config::ThemeConfig::default()).unwrap();
3629 let ctx = RenderContext::for_test();
3630 let draw = |modal: &ChartModal, g: &Glyphs| {
3631 let area = Rect::new(0, 0, 60, 20);
3632 let mut buf = Buffer::empty(area);
3633 let data = ChartRenderData::XY {
3634 series: Some(&series),
3635 breaks: None,
3636 values: None,
3637 names: names(),
3638 x_axis_kind: XAxisTemporalKind::Date,
3639 x_bounds: None,
3640 other: false,
3641 numbers: PlotNumbers::default(),
3642 };
3643 let place = render_plot(area, &mut buf, modal, &theme, &ctx, data, g);
3644 (buf, place)
3645 };
3646 for g in [crate::glyphs::unicode(), crate::glyphs::ascii()] {
3647 modal.plot_focus = false;
3649 modal.cursor_x = Some(19_786.0);
3650 let (off, place) = draw(&modal, g);
3651 let place = place.expect("an XY plot with points says where it is");
3652 assert!(!rows_of(&off).concat().contains("price:"));
3653
3654 modal.plot_focus = true;
3655 let (on, _) = draw(&modal, g);
3656 let rows = rows_of(&on);
3657 let last = rows.last().unwrap().trim_end();
3658 assert_eq!(
3659 last, "date: 2024-03-04 price: 4.5 volume: 300",
3660 "{rows:#?}"
3661 );
3662 let column = place.column(19_786.0);
3663 let accent = theme.get("accent");
3664 let marks = (place.graph.top()..place.graph.bottom())
3665 .filter(|&y| on[(column, y)].symbol() == g.plot.axis.vertical)
3666 .inspect(|&y| assert_eq!(on[(column, y)].fg, accent))
3667 .count();
3668 assert!(marks > 5, "{rows:#?}");
3669
3670 modal.cursor_x = Some(19_787.0);
3672 let (gap, _) = draw(&modal, g);
3673 let rows = rows_of(&gap);
3674 assert!(
3675 rows.last()
3676 .unwrap()
3677 .contains(&format!("volume: {}", g.null)),
3678 "{rows:#?}"
3679 );
3680 }
3681 }
3682
3683 #[test]
3686 fn a_scatter_picks_its_marker_by_density() {
3687 let g = crate::glyphs::unicode();
3688 let mut modal = open_modal();
3689 modal.spec.encoding.x.field = Some("price".to_string());
3690 modal.spec.encoding.y.field = vec!["volume".to_string()];
3691 modal.spec.mark = Mark::Scatter;
3692 let draw = |n: usize| {
3693 let series = vec![
3694 (0..n)
3695 .map(|i| (i as f64, ((i * 7919) % 1000) as f64))
3696 .collect::<Vec<_>>(),
3697 ];
3698 let data = ChartRenderData::XY {
3699 series: Some(&series),
3700 breaks: None,
3701 values: None,
3702 names: names(),
3703 x_axis_kind: XAxisTemporalKind::Numeric,
3704 x_bounds: None,
3705 other: false,
3706 numbers: PlotNumbers::default(),
3707 };
3708 plot_text_in(&modal, data, g, Rect::new(0, 0, 60, 20))
3709 };
3710 let braille = |text: &str| text.chars().any(|c| ('\u{2801}'..='\u{28ff}').contains(&c));
3711 let sparse = draw(20);
3712 assert!(
3713 sparse.contains(ratatui::symbols::DOT) && !braille(&sparse),
3714 "{sparse}"
3715 );
3716 let dense = draw(2000);
3717 assert!(
3718 braille(&dense) && !dense.contains(ratatui::symbols::DOT),
3719 "{dense}"
3720 );
3721 }
3722
3723 #[test]
3726 fn histogram_bins_fill_their_columns() {
3727 use crate::chart_data::HistogramBin;
3728 let histogram = HistogramData {
3729 column: "price".to_string(),
3730 groups: Vec::new(),
3731 other: false,
3732 share: false,
3733 bins: (0..4)
3734 .map(|i| HistogramBin {
3735 center: f64::from(i) * 25.0 + 12.5,
3736 count: 10.0,
3737 })
3738 .collect(),
3739 x_min: 0.0,
3740 x_max: 100.0,
3741 max_count: 10.0,
3742 rows: Default::default(),
3743 clipped: None,
3744 };
3745 let points = bin_columns(&histogram, [0.0, 100.0], 41);
3746 assert_eq!(points.len(), 41 - 3, "{points:?}");
3748 let text = plot_text_in(
3749 &open_modal(),
3750 ChartRenderData::Histogram {
3751 data: Some(&histogram),
3752 x: AxisNumbers::default(),
3753 },
3754 crate::glyphs::unicode(),
3755 Rect::new(0, 0, 60, 20),
3756 );
3757 let row = text.lines().find(|r| r.contains('█')).expect(&text);
3758 let bars: Vec<&str> = row.split(' ').filter(|r| r.contains('█')).collect();
3759 assert_eq!(bars.len(), 4, "{text}");
3760 }
3761
3762 #[test]
3763 fn a_tiny_area_never_panics() {
3764 for (w, h) in [(0, 0), (3, 2), (10, 4), (20, 6), (60, 20), (80, 24)] {
3765 let mut modal = open_modal();
3766 modal.focus = ChartFocus::X;
3767 modal.open_picker();
3768 let _ = render_rows(&mut modal, w, h);
3769 let mut data = bar_data(30);
3770 data.bars[3].value = -4.0;
3771 data.bars[4].label = Some("a label longer than the whole canvas is".to_string());
3772 let _ = render_bars(&data, w, h);
3773 }
3774 }
3775}