Skip to main content

datui_lib/widgets/
chart.rs

1//! Chart view widget: the panel of shelves on the left (Type, X, Y, Color, then the
2//! options), the plot on the right under its title, and the shared Picker over
3//! them while a shelf is edited.
4
5use 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
31/// The panel's width, at most: the label column, the value column, and air.
32const SIDEBAR_WIDTH: u16 = 40;
33/// Where the value column starts, past the rail gutter: the longest label,
34/// "Y from zero", plus air.
35const LABEL_WIDTH: u16 = 13;
36const HEATMAP_TITLE_HEIGHT: u16 = 1;
37const HEATMAP_X_LABEL_HEIGHT: u16 = 2;
38
39/// What the chart area shows: the plot, the notes over it about its input, or the
40/// reason it could not be prepared.
41pub struct ChartView<'a> {
42    pub data: ChartRenderData<'a>,
43    /// Dimmed at the right of the title row: a sample, values a range left out.
44    pub notes: Vec<String>,
45    /// Preparing the selection failed; shown in place of an empty plot.
46    pub error: Option<&'a str>,
47    /// The selection is being prepared: said over the chart standing in for it, or in
48    /// place of a plot when there is none.
49    pub working: Option<Working<'a>>,
50    /// The view's schema, so column names take their type's color.
51    pub schema: Option<&'a Schema>,
52}
53
54pub enum ChartRenderData<'a> {
55    XY {
56        series: Option<&'a Vec<Vec<(f64, f64)>>>,
57        /// Per series, where its line starts again after a gap.
58        breaks: Option<&'a Vec<Vec<usize>>>,
59        /// The series before any log, for the crosshair's readout; `None` reads
60        /// `series`.
61        values: Option<&'a Vec<Vec<(f64, f64)>>>,
62        /// Each series' name: its Y column or its color group.
63        names: &'a [String],
64        x_axis_kind: XAxisTemporalKind,
65        x_bounds: Option<(f64, f64)>,
66        numbers: PlotNumbers,
67        /// The last series is Other, drawn in `dimmed` under the rest.
68        other: bool,
69    },
70    Histogram {
71        data: Option<&'a HistogramData>,
72        /// The column's numbers; the counts are the table's.
73        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    /// Whether there is a plot to draw: data, or a line chart's axes, which stand
94    /// empty until their series arrive.
95    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/// What each axis holds, so its ticks print as the table prints its columns: whole
108/// for an integer column, grouped and separated in the table's number format.
109#[derive(Default)]
110pub struct PlotNumbers {
111    pub x: AxisNumbers,
112    pub y: AxisNumbers,
113}
114
115/// One line of the panel.
116enum PanelLine {
117    Blank,
118    Rule(&'static str),
119    /// A shelf or an option: its label, its value, and the field it edits.
120    Row {
121        label: &'static str,
122        value: Vec<Span<'static>>,
123        field: Option<ChartFocus>,
124        dimmed: bool,
125    },
126}
127
128/// The style a column name takes: its type's color.
129fn 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
145/// The columns of a shelf, joined, each in its type's color.
146fn 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
161/// The panel's lines for the chart on screen: every shelf, dimmed where the type
162/// does not use it, each with the line under it, then the options.
163fn 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    // Type.
184    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    // X.
200    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    // Y.
237    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        // With cumulative on the rows run as a total and the aggregate waits.
269        let value = match encoding.y.cumulative {
270            // First and last say which order the rows are read in.
271            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        // A row of its own: unlabeled under Y, `none` read as a second Y column.
288        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    // Color.
297    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    // Options.
324    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                // The row's keys, or what is waiting on them, while it has focus.
348                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
363/// The Rows option: how many rows a chart that samples reads, `Sample 10,000` or
364/// `Every row (36.8M)`; a size being typed shows as typed, with the cursor.
365fn 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
389/// Under the focused Rows row: why a typed size cannot be read, that a change
390/// waits for Enter, or the row's keys.
391fn 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
408/// The line under Color: which of the column's values have a series, and with
409/// Other on, how many values it gathers: `top 10 + 6 other`.
410fn 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    // With Other every value is drawn: the rest is counted, not the whole.
436    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
444/// The panel: its lines, with the blank ones given up first when the height runs
445/// out, and scrolled to keep the focused row on screen. Returns where each field
446/// was drawn, for the Picker to drop from.
447fn 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                    // An open picker's line has the one rail; the row keeps its
503                    // accent label.
504                    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
550/// The open Picker, over the panel and the plot under the row it edits: one
551/// Surface, the narrowing filter on its first line, then the list.
552fn 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    // It owns the keys, drawn or not: the panel's rows take no clicks.
563    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
653/// The fewest cells the notes are cut to; with less room they are left out.
654const NOTE_MIN: usize = 8;
655
656/// The plot's title row: how the chart was made of the rows at the left, what it
657/// says of the rows it read (a sample, values left out) at the right. The columns
658/// are named at their axes. The how keeps its room; the notes are cut to what is
659/// left, or left out when hardly any is. A row with nothing to say stays blank, so
660/// the plot does not move when it gets something.
661fn 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    // Two cells of air after the how.
679    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(&notes, room, g);
685    let x = area.right() - notes.width() as u16;
686    buf.set_string(x, area.y, &notes, Style::default().fg(ctx.dimmed));
687}
688
689/// Renders the chart view: the panel, a rule, and the plot under its title.
690pub 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    // The panel caps its share of the width, so a narrow terminal still keeps a
699    // plot.
700    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    // A cell of air beside the rule.
713    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
762/// What the plot asks for while a shelf it needs is empty.
763fn 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
774/// The plot itself, drawn with the marks of the glyph set `g`; where a line or
775/// scatter plot with points was drawn.
776fn 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
872/// One horizontal bar per category: the label, the value, then the bar, from a zero
873/// line that sits at the left edge unless some value is negative. The bars that fit
874/// are drawn and the rest are counted on a `+ 212 more` chip, never squeezed in.
875/// Split by a color, each category is a row per group, in the group's color, under
876/// a legend of the groups.
877fn 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    // Picked but not here yet: the chart view says it is being computed.
892    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    // A cell of air between the sidebar and the labels.
906    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    // A header row, then a row per bar; when they do not all fit, the last row is
918    // the count of the rest.
919    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    // Wide enough for the column's name above the labels, too.
938    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    // The value column's name ends where the values end, or starts where they start
966    // when it is the wider of the two.
967    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    // A negative value keeps a cell left of zero, however small it is beside the rest.
980    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        // A value that is not zero always draws: the thinnest mark, at least.
995        let nonzero = usize::from(bar.value != 0.0);
996        if bar.value >= 0.0 {
997            // Eighths of a cell, so short bars still differ.
998            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            // Leftward from zero in whole cells: the eighths fill from the left.
1007            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
1031/// Bars split by a color: a legend line of the groups, then per category its label
1032/// and a bar per group, each its group's color, scaled together. The categories
1033/// that fit are drawn; the rest are counted.
1034fn 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    // The legend: a swatch and a name per group.
1047    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
1135/// The line or scatter chart's prepared series, as `ChartRenderData::XY` carries
1136/// them.
1137struct 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
1148/// One XY series with where its line breaks.
1149struct SeriesRuns<'a> {
1150    /// Its place among every series, which picks its color: an empty series
1151    /// before it keeps its color too.
1152    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            // Data is already in display form (log-scaled when log_scale) from cache; use as-is.
1225            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            // A scatter of few points marks each with a dot a cell wide; past one
1264            // point per four cells, the line's finer marks keep them apart.
1265            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            // A series is drawn as its runs between gaps, so a line never bridges a
1275            // missing value.
1276            // Other first, so the series drawn over it keep their colors.
1277            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            // On a log scale a tick stands for its value before the log.
1342            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            // The crosshair's readout takes the rows under the plot while the plot
1368            // has the keys.
1369            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
1414/// The readout at the crosshair's `x`: x under its title as `written`, then each
1415/// series' value there under its name in its color, `∅` where it has a gap.
1416fn 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
1452/// Axes drawn in the theme's primary text color, ticked for series drawn with
1453/// `marker`, with the grid in `chart_grid` when `grid` is on.
1454fn 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
1468/// An XY chart's x axis: numbers on nice steps, or dates and times on calendar
1469/// boundaries.
1470fn 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
1479/// The legend, when it is on and there is more than one series to tell apart: the
1480/// y title names a lone one.
1481fn 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
1486/// The style series `i` draws in: its palette color, or `dimmed` for Other, the
1487/// rows of every value without a series of its own.
1488fn 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
1496/// Where Other is among `n` series, when the last one is.
1497fn other_at(other: bool, n: usize) -> Option<usize> {
1498    (other && n > 0).then(|| n - 1)
1499}
1500
1501/// A histogram: filled bars, or split by a color, each group's bins as a step
1502/// outline over the others, since filled bars would hide one another.
1503/// How a histogram is drawn: its grid, its legend, and the X axis's title.
1504pub struct HistogramLook<'a> {
1505    pub grid: bool,
1506    pub legend: bool,
1507    pub x_title: &'a str,
1508}
1509
1510/// A histogram of `data` in `area`, in the chart view's marks: for the Value Counts
1511/// screen's histogram view.
1512pub 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        // Other first, under the rest.
1581        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
1619/// Each group's bins as the outline of its bars: up the left edge of each bin,
1620/// across its top, and down at the end.
1621fn 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
1639/// A histogram's bars as a column of the plot each, `columns` wide over `bounds`: a
1640/// bin fills the columns its values land on, less one between it and the next when
1641/// it is three or more wide, so the bars read as bins and not as needles.
1642fn 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    // The value at a column's center, as the canvas maps values to columns.
1649    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    // Other first, under the rest.
1695    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    // A box's name sits under the box, at its index.
1780    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    // The axes' titles sit as every plot's do: Y over its labels, X at the right
1829    // under its own.
1830    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    // Nice values up the side, one per few rows, each on the row its value falls in.
1836    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    /// Series names for test plots: as many as any test draws.
1934    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    /// The panel: every shelf in the same place for every type, then the options;
1969    /// no border inside it.
1970    #[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        // Focused, the line under Rows names its keys; a change waits for Enter.
2008        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    /// With a row's picker open, the picker's line carries the one rail on
2034    /// screen; closed, the row has it back.
2035    #[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    /// The aggregate is a row of its own under Y, labeled, so `none` does not read
2059    /// as a second Y column; focused, it carries the rail and the accent like any
2060    /// row.
2061    #[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        // Each panel row past the rail gutter: its label column and its value.
2097        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    /// The title row says only how the rows were made; the Y column is named once,
2115    /// at its axis. With nothing to say the row is blank.
2116    #[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    /// A shelf the type does not use stays, dimmed, with why.
2146    #[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    /// The line under Color says how many values Other gathers while it is on: on by
2197    /// default for a scatter, off for a line; ←/→ on the line turn it over.
2198    #[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    /// A quantile's percentile sits on the line under Aggregate; first and last say
2234    /// the order they read the rows in, the sort's own words when there is one, and
2235    /// only then read the view sorted.
2236    #[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    /// Ten series take ten colors, each its own, and the legend names all ten.
2282    #[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        // Hex all the way, as a true-color terminal shows them.
2297        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    /// A 16-color terminal shows the ten slots as fewer colors: the chart draws one
2354    /// series per color, and the line under Color says how many.
2355    #[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    /// Other is the legend's last entry and is drawn in `dimmed`, under the series.
2409    #[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    /// The open Picker drops over the panel under its row, with a checkbox per item
2464    /// where it takes several, and each value's rows beside it.
2465    #[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(); // UA in
2477        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    /// An open Picker owns the clicks, drawn or not: the panel's rows take none.
2488    #[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    /// A sampled or clipped chart says so at the right of the title row, at 80x24
2512    /// too; the bottom right holds only the X axis's title.
2513    #[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    /// On a narrow canvas the how keeps its room and the notes are cut to the rest,
2551    /// with the ellipsis.
2552    #[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    /// A failed preparation shows its message where the plot would be.
2588    #[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    /// A line breaks at a gap: nothing is drawn between the runs on either side.
2616    #[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    /// A bar per category: its label, its value beside it, and a bar as long as the
2701    /// value, the largest filling the width.
2702    #[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        // The canvas starts past the 42-column sidebar.
2709        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    /// More bars than rows: the ones that fit, then a chip counting the rest, which
2723    /// includes those the preparation already left out.
2724    #[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        // 23 rows under the tab line: a header, 21 bars, the chip.
2731        assert!(body.contains("C20 "), "{body}");
2732        assert!(!body.contains("C21 "), "{body}");
2733        assert!(rows[23].contains(" + 229 more "), "{:?}", rows[23]);
2734    }
2735
2736    /// A negative value draws left of the zero line; a null category reads as null.
2737    #[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    /// A value far smaller than the rest still draws a mark, either side of zero, so
2769    /// no bar that is not zero reads as zero.
2770    #[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        // The bar zone: the row with its label and value taken out.
2791        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    /// At 40, 60 and 80 columns, in both glyph sets, every plot's x labels stand a
2840    /// space apart with both ends kept, and its axis titles sit on rows of their own
2841    /// that hold nothing of the plot.
2842    #[test]
2843    fn axis_labels_stand_apart_and_titles_keep_their_rows() {
2844        /// A plot, its x and y titles, and what its x labels look like.
2845        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        // 2020-01-01 to 2024-12-31, in days since the epoch.
2854        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    /// Every label on an axis in one format, in the table's number style: KDE
2987    /// densities around 0.01 keep one precision all the way up, and under the
2988    /// european format a narrow axis's short form reads `12,5k`.
2989    #[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        // 12,000 to 12,600 fits in steps of 200, not out to 13,000 in steps of 500.
3051        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        // Too narrow for those: the short form, each in the same unit and places.
3058        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    /// A heatmap's y labels in one format; an integer column's middle label is left
3067    /// out when it falls between two whole numbers, where it read `0` twice.
3068    #[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            // The plot's rows, each starting with its label if it has one.
3085            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    /// A count, or an integer column, ticks in whole numbers as the table prints
3127    /// them: never `4321.00`, and never a tick between two whole numbers.
3128    #[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        // What sits left of the y axis.
3142        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        // Counts up the side; an integer column's values, 0 to 7, along the bottom.
3158        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        // Up to the nice value past the tallest bar, in thousands-grouped whole steps.
3181        assert_eq!(y_labels(&text), ["5,000", "2,500", "0"], "{text}");
3182        // Four labels fit, so not the two of the step nearest the spacing.
3183        assert_eq!(axis_row(&text), "0 2 4 6", "{text}");
3184
3185        // The same for an XY chart of integer columns.
3186        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        // 3 to 10 widens out to whole steps either side.
3207        assert_eq!(y_labels(&text), ["10", "5", "0"], "{text}");
3208        assert_eq!(axis_row(&text), "0 2 4", "{text}");
3209        // A float column ticks at the same nice values, written as exactly as they are.
3210        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    /// Under the ASCII set every plot draws ASCII only, and still draws: its marks,
3215    /// its bars, its axes and its legend. The Unicode set keeps its own.
3216    #[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            // The legend's swatches, ASCII too.
3253            assert!(text.contains("# price"), "{text}");
3254            let text = plot_text(&modal, xy(Some(&series)), unicode);
3255            assert!(text.contains("█ price"), "{text}");
3256        }
3257        // Axes before the data is in.
3258        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        // Bars draw no axes of their own.
3331        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    /// The legend reads clean over a full plot, with no frame: a short name's row is
3337    /// blank past the name, not the marks behind it.
3338    #[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        // A line up and down in every column fills the plot, legend corner included.
3345        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    /// Ten years of daily dates against a value, as the XY view gets them.
3381    fn decade() -> Vec<Vec<(f64, f64)>> {
3382        // 2015-01-01 on, in days since the epoch.
3383        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    /// On a 300-column terminal a line chart labels its x axis 15 to 20 times, on
3404    /// calendar boundaries, and its y axis about once per four rows, at round values.
3405    #[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        // The canvas, right of the sidebar.
3424        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; // the tab line and the y title
3446        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    /// The grid shows in the view only while it is on, in the `chart_grid` color.
3462    #[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    /// The light theme's grid stays a color under 16 colors, not the white of the
3499    /// background, and the grid draws in it.
3500    #[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    /// A log scale ticks at the powers of ten, and 2 and 5 between when there is
3546    /// room, every label in one format; the short form names each tick's own k or M.
3547    #[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        // 0 to 300,000, as the view has it: ln(1 + y).
3555        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        // Top down: falling, each a 1, 2 or 5 a power of ten up, or zero.
3590        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        // Every power of ten up to the top, which the axis widens to.
3601        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    /// With the plot focused the crosshair stands on a point: a line down its column
3612    /// in the accent, and under the plot each series' value there.
3613    #[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            // The options have the keys: no crosshair, no readout.
3648            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            // A gap in a series reads as one.
3671            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    /// A scatter of a few points marks each with a whole-cell dot; a dense one
3684    /// switches to braille, which keeps neighbors apart.
3685    #[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    /// A histogram's bins fill their columns, with a column of air between bins wide
3724    /// enough to spare one.
3725    #[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        // Four bins of about ten columns, less a gap after each but the last.
3747        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}