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::Style,
8    text::{Line, Span},
9    widgets::{Chart, Clear, Dataset, GraphType, Paragraph, Widget, Wrap},
10};
11
12use crate::app::pointer::Hit;
13use crate::chart::chart_data::{
14    BarData, BoxPlotData, HeatmapData, HistogramData, KdeData, XAxisTemporalKind, other_at,
15    segments,
16};
17use crate::chart::chart_modal::{
18    Aggregate, ChartFocus, ChartModal, Cumulative, Mark, PickerFor, ShelfUse, TimeUnit,
19};
20use crate::chart::chart_plot::{Axis, Curve, LinesData, Plot, PlotData};
21use crate::config::Theme;
22use crate::glyphs::Glyphs;
23use crate::render::context::RenderContext;
24use crate::widgets::axes::{
25    AxisSpec, Legend, PlotAxes, Track, cut, fit_x_labels, fit_y_labels, resolution,
26};
27use crate::widgets::axis_numbers::AxisNumbers;
28use crate::widgets::crosshair::{self, PlotPlace};
29use crate::widgets::ui::{FormRow, FormValue, Picker, SectionRule, Surface, Working};
30use polars::prelude::Schema;
31use unicode_width::UnicodeWidthStr;
32
33/// The panel's width, at most: the label column, the value column, and air.
34const SIDEBAR_WIDTH: u16 = 40;
35/// Where the value column starts, past the rail gutter: the longest label,
36/// "Y from zero", plus air.
37const LABEL_WIDTH: u16 = 13;
38const HEATMAP_TITLE_HEIGHT: u16 = 1;
39const HEATMAP_X_LABEL_HEIGHT: u16 = 2;
40
41/// What the chart area shows: the plot, the notes over it about its input, or the
42/// reason it could not be prepared.
43pub struct ChartView<'a> {
44    /// What to plot: `None` while a kind other than a line or scatter waits for its
45    /// data, or a shelf it needs is empty.
46    pub plot: Option<Plot<'a>>,
47    /// Dimmed at the right of the title row: a sample, values a range left out.
48    pub notes: Vec<String>,
49    /// Preparing the selection failed; shown in place of an empty plot.
50    pub error: Option<&'a str>,
51    /// The selection is being prepared: said over the chart standing in for it, or in
52    /// place of a plot when there is none.
53    pub working: Option<Working<'a>>,
54    /// The view's schema, so column names take their type's color.
55    pub schema: Option<&'a Schema>,
56}
57
58/// One line of the panel.
59enum PanelLine {
60    Blank,
61    Rule(&'static str),
62    /// A shelf or an option: its label, its value, and the field it edits.
63    Row {
64        label: &'static str,
65        value: Vec<Span<'static>>,
66        field: Option<ChartFocus>,
67        dimmed: bool,
68    },
69}
70
71/// The style a column name takes: its type's color.
72fn column_span(name: &str, schema: Option<&Schema>, ctx: &RenderContext) -> Span<'static> {
73    let color = schema
74        .and_then(|s| s.get(name))
75        .map(|dtype| ctx.type_color(dtype))
76        .unwrap_or(ctx.text_primary);
77    Span::styled(name.to_string(), Style::default().fg(color))
78}
79
80fn plain(text: impl Into<String>, ctx: &RenderContext) -> Span<'static> {
81    Span::styled(text.into(), Style::default().fg(ctx.text_primary))
82}
83
84fn quiet(text: impl Into<String>, ctx: &RenderContext) -> Span<'static> {
85    Span::styled(text.into(), Style::default().fg(ctx.dimmed))
86}
87
88/// The columns of a shelf, joined, each in its type's color.
89fn columns_spans(
90    names: &[String],
91    schema: Option<&Schema>,
92    ctx: &RenderContext,
93) -> Vec<Span<'static>> {
94    let mut spans = Vec::new();
95    for (i, name) in names.iter().enumerate() {
96        if i > 0 {
97            spans.push(quiet(", ", ctx));
98        }
99        spans.push(column_span(name, schema, ctx));
100    }
101    spans
102}
103
104/// The panel's lines for the chart on screen: every shelf, dimmed where the type
105/// does not use it, each with the line under it, then the options.
106fn panel_lines(modal: &ChartModal, schema: Option<&Schema>, ctx: &RenderContext) -> Vec<PanelLine> {
107    let g = crate::glyphs::get();
108    let spec = &modal.spec;
109    let mark = spec.mark;
110    let encoding = &spec.encoding;
111    let row = |label, value, field: Option<ChartFocus>, dimmed| PanelLine::Row {
112        label,
113        value,
114        field,
115        dimmed,
116    };
117    let sub = |value, field: Option<ChartFocus>, dimmed| PanelLine::Row {
118        label: "",
119        value,
120        field,
121        dimmed,
122    };
123    let pick = |what: &str| vec![quiet(format!("pick {what}"), ctx)];
124    let mut lines = vec![PanelLine::Rule("Chart"), PanelLine::Blank];
125
126    // Type.
127    lines.push(row(
128        "Type",
129        vec![plain(mark.label(), ctx)],
130        Some(ChartFocus::Type),
131        false,
132    ));
133    if let Some(dtype) = &modal.suggested {
134        lines.push(sub(
135            vec![quiet(format!("suggested for {dtype}"), ctx)],
136            None,
137            false,
138        ));
139    }
140    lines.push(PanelLine::Blank);
141
142    // X.
143    let x_value = match encoding.x.field.as_deref() {
144        Some(x) => vec![column_span(x, schema, ctx)],
145        None if mark == Mark::Box => vec![quiet(crate::chart::chart_modal::NONE_ITEM, ctx)],
146        None if mark == Mark::Bar => pick("a category"),
147        None => pick("a column"),
148    };
149    lines.push(row("X", x_value, Some(ChartFocus::X), false));
150    let rows = modal.row_order();
151    if rows.contains(&ChartFocus::TimeUnit) {
152        let unit = encoding.x.time_unit;
153        let value = if unit == TimeUnit::None {
154            vec![quiet("by row", ctx)]
155        } else {
156            vec![quiet("by ", ctx), plain(unit.label(), ctx)]
157        };
158        lines.push(sub(value, Some(ChartFocus::TimeUnit), false));
159    }
160    if mark == Mark::Bar {
161        let order = match modal.bar_order {
162            crate::chart::chart_data::BarOrder::Value => format!("by value {}", g.sort_desc),
163            crate::chart::chart_data::BarOrder::Label => format!("by label {}", g.sort_asc),
164        };
165        lines.push(sub(vec![plain(order, ctx)], Some(ChartFocus::Order), false));
166    }
167    if mark == Mark::Histogram {
168        let mut value = vec![plain(format!("{} bins", modal.hist_bins), ctx)];
169        if modal.value_range != crate::chart::chart_data::ValueRange::All {
170            value.push(quiet(
171                format!(" {} {}", g.middot, modal.value_range.label()),
172                ctx,
173            ));
174        }
175        lines.push(sub(value, Some(ChartFocus::Bins), false));
176    }
177    lines.push(PanelLine::Blank);
178
179    // Y.
180    match (mark, modal.y_use()) {
181        (_, ShelfUse::Dimmed(why)) => lines.push(row("Y", vec![quiet(why, ctx)], None, true)),
182        (Mark::Histogram, _) => {
183            let value = if modal.share {
184                if modal.colored() {
185                    "share of group"
186                } else {
187                    "share"
188                }
189            } else {
190                "count"
191            };
192            lines.push(row(
193                "Y",
194                vec![plain(value, ctx)],
195                Some(ChartFocus::Y),
196                false,
197            ));
198        }
199        _ => {
200            let value = if encoding.y.aggregate == Aggregate::Count {
201                vec![plain("rows", ctx)]
202            } else if encoding.y.field.is_empty() {
203                pick("a column")
204            } else {
205                columns_spans(&encoding.y.field, schema, ctx)
206            };
207            lines.push(row("Y", value, Some(ChartFocus::Y), false));
208        }
209    }
210    if modal.takes_aggregate() {
211        // With cumulative on the rows run as a total and the aggregate waits.
212        let value = match encoding.y.cumulative {
213            // First and last say which order the rows are read in.
214            Cumulative::Off if encoding.y.aggregate.follows_row_order() => {
215                let order = modal.row_order.as_deref().unwrap_or("row order");
216                vec![
217                    plain(encoding.y.aggregate.label(), ctx),
218                    quiet(format!(" {} by {order}", g.middot), ctx),
219                ]
220            }
221            Cumulative::Off => vec![plain(encoding.y.aggregate.label(), ctx)],
222            _ if encoding.y.aggregate == Aggregate::Count => vec![plain("running count", ctx)],
223            how => vec![
224                quiet(encoding.y.aggregate.label(), ctx),
225                quiet(format!(" {} ", g.middot), ctx),
226                plain(how.label(), ctx),
227                quiet(" of rows", ctx),
228            ],
229        };
230        // A row of its own: unlabeled under Y, `none` read as a second Y column.
231        lines.push(row("Aggregate", value, Some(ChartFocus::Aggregate), false));
232        if rows.contains(&ChartFocus::Quantile) {
233            let p = format!("p{}", encoding.y.quantile());
234            lines.push(sub(vec![plain(p, ctx)], Some(ChartFocus::Quantile), false));
235        }
236    }
237    lines.push(PanelLine::Blank);
238
239    // Color.
240    match modal.color_use() {
241        ShelfUse::Dimmed(why) => {
242            let value = match encoding.color.field.as_deref() {
243                Some(c) => c.to_string(),
244                None => crate::chart::chart_modal::NONE_ITEM.to_string(),
245            };
246            lines.push(row("Color", vec![quiet(value, ctx)], None, true));
247            lines.push(sub(vec![quiet(why, ctx)], None, true));
248        }
249        ShelfUse::Used => {
250            let value = match encoding.color.field.as_deref() {
251                Some(c) => vec![column_span(c, schema, ctx)],
252                None => vec![quiet(crate::chart::chart_modal::NONE_ITEM, ctx)],
253            };
254            lines.push(row("Color", value, Some(ChartFocus::Color), false));
255            if encoding.color.field.is_some() {
256                lines.push(sub(
257                    color_values_line(modal, ctx),
258                    Some(ChartFocus::ColorValues),
259                    false,
260                ));
261            }
262        }
263    }
264    lines.push(PanelLine::Blank);
265
266    // Options.
267    lines.push(PanelLine::Rule("Options"));
268    lines.push(PanelLine::Blank);
269    let on_off = |on: bool| plain(if on { "on" } else { "off" }, ctx);
270    for field in rows.iter().copied() {
271        let (label, value) = match field {
272            ChartFocus::Cumulative => ("Cumulative", plain(encoding.y.cumulative.label(), ctx)),
273            ChartFocus::Bins if mark == Mark::Heatmap => {
274                ("Bins", plain(modal.heatmap_bins.to_string(), ctx))
275            }
276            ChartFocus::Bandwidth => (
277                "Bandwidth",
278                plain(format!("{:.1}x", modal.kde_bandwidth_factor), ctx),
279            ),
280            ChartFocus::Range => ("Range", plain(modal.value_range.label(), ctx)),
281            ChartFocus::YStartsAtZero => ("Y from zero", on_off(modal.y_starts_at_zero)),
282            ChartFocus::LogScale => ("Log scale", on_off(modal.log_scale)),
283            ChartFocus::ShowLegend => (
284                "Legend",
285                plain(if modal.show_legend { "auto" } else { "off" }, ctx),
286            ),
287            ChartFocus::Grid => ("Grid", on_off(modal.grid)),
288            ChartFocus::LimitRows => {
289                lines.push(row("Rows", rows_value(modal, ctx), Some(field), false));
290                // The row's keys, or what is waiting on them, while it has focus.
291                if modal.focus == field && !modal.plot_focus {
292                    lines.push(sub(rows_hint(modal, ctx), None, false));
293                }
294                continue;
295            }
296            _ => continue,
297        };
298        lines.push(row(label, vec![value], Some(field), false));
299    }
300    if modal.aggregates() {
301        lines.push(row("Rows", vec![quiet("all, exact", ctx)], None, false));
302    }
303    lines
304}
305
306/// The Rows option: how many rows a chart that samples reads, `Sample 10,000` or
307/// `Every row (36.8M)`; a size being typed shows as typed, with the cursor.
308fn rows_value(modal: &ChartModal, ctx: &RenderContext) -> Vec<Span<'static>> {
309    let shown = modal.rows_shown();
310    if let Some(typed) = shown.typed {
311        return vec![
312            plain(format!("Sample {typed}"), ctx),
313            Span::styled(crate::glyphs::get().cursor, Style::default().fg(ctx.accent)),
314        ];
315    }
316    if shown.every {
317        let mut spans = vec![plain("Every row", ctx)];
318        if let Some(rows) = modal.view_rows {
319            spans.push(quiet(
320                format!(" ({})", crate::home::discover::format_rows(rows)),
321                ctx,
322            ));
323        }
324        return spans;
325    }
326    vec![plain(
327        format!("Sample {}", crate::numfmt::group_chrome(modal.sample_rows)),
328        ctx,
329    )]
330}
331
332/// Under the focused Rows row: why a typed size cannot be read, that a change
333/// waits for Enter, or the row's keys.
334fn rows_hint(modal: &ChartModal, ctx: &RenderContext) -> Vec<Span<'static>> {
335    let g = crate::glyphs::get();
336    if let Some(error) = modal.rows_shown().error {
337        return vec![Span::styled(error, Style::default().fg(ctx.warning))];
338    }
339    if modal.rows_pending() {
340        return vec![quiet("Enter to read", ctx)];
341    }
342    vec![quiet(
343        format!(
344            "{}/{} switch {} type a size",
345            g.arrow_left, g.arrow_right, g.middot
346        ),
347        ctx,
348    )]
349}
350
351/// The line under Color: which of the column's values have a series, and with
352/// Other on, how many values it gathers: `top 10 + 6 other`.
353fn color_values_line(modal: &ChartModal, ctx: &RenderContext) -> Vec<Span<'static>> {
354    let picked = &modal.spec.encoding.color.values;
355    let Some(counts) = modal
356        .color_counts
357        .as_ref()
358        .filter(|_| modal.has_color_counts())
359    else {
360        return vec![quiet("counting values", ctx)];
361    };
362    let total = counts.values.len();
363    let of = crate::numfmt::group_chrome(total);
364    let (drawn, rest) = if picked.is_empty() {
365        let drawn = total.min(modal.series_max());
366        (format!("top {drawn}"), total - drawn)
367    } else {
368        let rest = counts
369            .values
370            .iter()
371            .filter(|(v, _)| !picked.contains(v))
372            .count();
373        (format!("{} picked", picked.len()), rest)
374    };
375    if picked.is_empty() && rest == 0 {
376        return vec![plain(format!("all {total}"), ctx)];
377    }
378    // With Other every value is drawn: the rest is counted, not the whole.
379    if modal.shows_other() && rest > 0 {
380        let rest = crate::numfmt::group_chrome(rest);
381        return vec![plain(drawn, ctx), plain(format!(" + {rest} other"), ctx)];
382    }
383    let by_rows = if picked.is_empty() { " by rows" } else { "" };
384    vec![plain(drawn, ctx), quiet(format!(" of {of}{by_rows}"), ctx)]
385}
386
387/// The panel: its lines, with the blank ones given up first when the height runs
388/// out, and scrolled to keep the focused row on screen. Returns where each field
389/// was drawn, for the Picker to drop from.
390fn render_sidebar(
391    area: Rect,
392    buf: &mut ratatui::buffer::Buffer,
393    modal: &ChartModal,
394    schema: Option<&Schema>,
395    ctx: &RenderContext,
396) -> Option<Rect> {
397    if area.height == 0 || area.width < 4 {
398        return None;
399    }
400    let mut lines = panel_lines(modal, schema, ctx);
401    if lines.len() > area.height as usize {
402        lines.retain(|l| !matches!(l, PanelLine::Blank));
403    }
404    let focus = (!modal.plot_focus).then_some(modal.focus);
405    let at = lines
406        .iter()
407        .position(
408            |l| matches!(l, PanelLine::Row { field, .. } if *field == focus && focus.is_some()),
409        )
410        .unwrap_or(0);
411    let height = area.height as usize;
412    let first = at.saturating_sub(height.saturating_sub(1));
413    let g = crate::glyphs::get();
414    let mut focused_at = None;
415    for (i, line) in lines.iter().enumerate().skip(first).take(height) {
416        let y = area.y + (i - first) as u16;
417        let row = Rect {
418            y,
419            height: 1,
420            ..area
421        };
422        match line {
423            PanelLine::Blank => {}
424            PanelLine::Rule(title) => SectionRule { title, chip: None }.render(
425                Rect {
426                    x: area.x + 1,
427                    width: area.width - 1,
428                    ..row
429                },
430                buf,
431                ctx,
432            ),
433            PanelLine::Row {
434                label,
435                value,
436                field,
437                dimmed,
438            } => {
439                let focused = field.is_some() && *field == focus;
440                if let Some(field) = field {
441                    crate::app::pointer::record_field::<ChartModal>(row, *field);
442                }
443                if focused {
444                    focused_at = Some(row);
445                    // An open picker's line has the one rail; the row keeps its
446                    // accent label.
447                    if modal.picker.is_none() {
448                        buf.set_string(area.x, y, g.rail, Style::default().fg(ctx.accent));
449                    }
450                }
451                // Past the rail and a cell of air.
452                FormRow {
453                    label,
454                    value: FormValue::Spans {
455                        spans: value.clone(),
456                        dimmed: *dimmed,
457                    },
458                    focused,
459                    label_width: LABEL_WIDTH,
460                }
461                .render_body(
462                    Rect {
463                        x: area.x + 2,
464                        width: area.width.saturating_sub(2),
465                        ..row
466                    },
467                    buf,
468                    ctx,
469                );
470            }
471        }
472    }
473    focused_at
474}
475
476/// The open Picker, over the panel and the plot under the row it edits: one
477/// Surface, the narrowing filter on its first line, then the list.
478fn render_picker(
479    area: Rect,
480    anchor: Rect,
481    buf: &mut ratatui::buffer::Buffer,
482    modal: &ChartModal,
483    ctx: &RenderContext,
484) {
485    let Some(state) = &modal.picker else {
486        return;
487    };
488    // It owns the keys, drawn or not: the panel's rows take no clicks.
489    crate::app::pointer::record(area, Hit::Picker);
490    let title = match modal.picker_for {
491        Some(PickerFor::X) => "X",
492        Some(PickerFor::Y) => "Y",
493        Some(PickerFor::Color) => "Color",
494        Some(PickerFor::ColorValues) => modal.color().map(String::as_str).unwrap_or("Values"),
495        None => "",
496    };
497    let filtered = state.filtered();
498    let widest = filtered
499        .iter()
500        .map(|(_, item)| UnicodeWidthStr::width(*item))
501        .max()
502        .unwrap_or(0)
503        .max(14) as u16;
504    let detail_w = modal
505        .picker_details
506        .iter()
507        .map(|d| d.len())
508        .max()
509        .unwrap_or(0) as u16;
510    let width = (widest + detail_w + 9).clamp(28, 44).min(area.width);
511    let x = (anchor.x + 2 + LABEL_WIDTH.saturating_sub(4)).min(area.right().saturating_sub(width));
512    let y = anchor.y + 1;
513    let height = u16::try_from(filtered.len())
514        .unwrap_or(u16::MAX)
515        .saturating_add(5)
516        .min(area.bottom().saturating_sub(y))
517        .max(5);
518    let y = y.min(area.bottom().saturating_sub(height));
519    let frame = Rect {
520        x,
521        y,
522        width,
523        height: height.min(area.height),
524    };
525    Clear.render(frame, buf);
526    let content = Surface::new(title).render(frame, buf, ctx);
527    if content.height == 0 {
528        return;
529    }
530    let filter = if state.filter.is_empty() {
531        quiet("type to narrow", ctx)
532    } else {
533        plain(state.filter.clone(), ctx)
534    };
535    let g = crate::glyphs::get();
536    Paragraph::new(Line::from(vec![
537        quiet(format!("{} ", g.prompt), ctx),
538        filter,
539    ]))
540    .render(
541        Rect {
542            height: 1,
543            ..content
544        },
545        buf,
546    );
547    let mut list = Rect {
548        y: content.y + 1,
549        height: content.height.saturating_sub(1),
550        ..content
551    };
552    if modal.picker_for == Some(PickerFor::ColorValues)
553        && modal.spec.encoding.color.values.is_empty()
554        && list.height > 1
555    {
556        Paragraph::new(quiet(
557            format!("default: top {} by rows", modal.series_max()),
558            ctx,
559        ))
560        .render(Rect { height: 1, ..list }, buf);
561        list.y += 1;
562        list.height -= 1;
563    }
564    let mut picker = Picker::from_state(state, true);
565    if modal.picker_multi() {
566        let marks = filtered.iter().map(|(i, _)| modal.is_marked(*i)).collect();
567        picker = picker.marks(marks);
568    }
569    if !modal.picker_details.is_empty() {
570        let details = filtered
571            .iter()
572            .map(|(i, _)| modal.picker_details.get(*i).cloned().unwrap_or_default())
573            .collect();
574        picker = picker.details(details);
575    }
576    picker.render(list, buf, ctx);
577}
578
579/// The fewest cells the notes are cut to; with less room they are left out.
580const NOTE_MIN: usize = 8;
581
582/// The plot's title row: how the chart was made of the rows at the left, what it
583/// says of the rows it read (a sample, values left out) at the right. The columns
584/// are named at their axes. The how keeps its room; the notes are cut to what is
585/// left, or left out when hardly any is. A row with nothing to say stays blank, so
586/// the plot does not move when it gets something.
587fn render_title(
588    area: Rect,
589    buf: &mut ratatui::buffer::Buffer,
590    modal: &ChartModal,
591    notes: &[String],
592    ctx: &RenderContext,
593) {
594    let g = crate::glyphs::get();
595    let width = area.width as usize;
596    let how = cut(&modal.how(), width, g);
597    buf.set_string(
598        area.x,
599        area.y,
600        &how,
601        Style::default().fg(ctx.text_secondary),
602    );
603    let used = how.width();
604    // Two cells of air after the how.
605    let room = width.saturating_sub(if used > 0 { used + 2 } else { 0 });
606    let notes = notes.join(&format!(" {} ", g.middot));
607    if notes.is_empty() || room < NOTE_MIN.min(notes.width()) {
608        return;
609    }
610    let notes = cut(&notes, room, g);
611    let x = area.right() - notes.width() as u16;
612    buf.set_string(x, area.y, &notes, Style::default().fg(ctx.dimmed));
613}
614
615/// Renders the chart view: the panel, a rule, and the plot under its title.
616pub fn render_chart_view(
617    area: Rect,
618    buf: &mut ratatui::buffer::Buffer,
619    modal: &mut ChartModal,
620    theme: &Theme,
621    ctx: &RenderContext,
622    view: ChartView<'_>,
623) {
624    // The panel caps its share of the width, so a narrow terminal still keeps a
625    // plot.
626    let sidebar_width = SIDEBAR_WIDTH.min(area.width / 2);
627    let [sidebar, rule, plot_area] = Layout::horizontal([
628        Constraint::Length(sidebar_width),
629        Constraint::Length(1),
630        Constraint::Fill(1),
631    ])
632    .areas(area);
633    let anchor = render_sidebar(sidebar, buf, modal, view.schema, ctx);
634    let g = crate::glyphs::get();
635    for y in rule.top()..rule.bottom() {
636        buf.set_string(rule.x, y, g.rule, Style::default().fg(ctx.column_separator));
637    }
638    // A cell of air beside the rule.
639    let plot_area = Rect {
640        x: plot_area.x + 1,
641        width: plot_area.width.saturating_sub(1),
642        ..plot_area
643    };
644    let [title, chart_inner] =
645        Layout::vertical([Constraint::Length(1), Constraint::Fill(1)]).areas(plot_area);
646    let notes: &[String] = if view.error.is_some() {
647        &[]
648    } else {
649        &view.notes
650    };
651    render_title(title, buf, modal, notes, ctx);
652
653    modal.plot = None;
654    if let Some(message) = view.error {
655        Paragraph::new(message)
656            .style(Style::default().fg(ctx.error))
657            .wrap(Wrap { trim: true })
658            .centered()
659            .render(chart_inner, buf);
660    } else {
661        match view.working {
662            Some(working) if view.plot.is_none() => working.render_centered(chart_inner, buf, ctx),
663            working => {
664                modal.plot = render_plot(
665                    chart_inner,
666                    buf,
667                    modal,
668                    theme,
669                    ctx,
670                    view.plot,
671                    crate::glyphs::get(),
672                );
673                if let Some(working) = working {
674                    working.render_corner(chart_inner, buf, ctx);
675                }
676            }
677        }
678    }
679    if let Some(anchor) = anchor
680        && modal.picker.is_some()
681    {
682        render_picker(area, anchor, buf, modal, ctx);
683    }
684}
685
686/// What the plot asks for while a shelf it needs is empty.
687fn missing(modal: &ChartModal) -> &'static str {
688    let encoding = &modal.spec.encoding;
689    match modal.mark() {
690        Mark::Line | Mark::Scatter if encoding.x.field.is_none() => "Pick X and Y in the panel",
691        Mark::Bar if encoding.x.field.is_none() => "Pick a category for X",
692        Mark::Histogram | Mark::Kde => "Pick a column for X",
693        Mark::Heatmap => "Pick X and Y in the panel",
694        _ => "Pick a column for Y",
695    }
696}
697
698/// The plot itself, drawn with the marks of the glyph set `g`; where a line or
699/// scatter plot with points was drawn.
700fn render_plot(
701    area: Rect,
702    buf: &mut ratatui::buffer::Buffer,
703    modal: &ChartModal,
704    theme: &Theme,
705    ctx: &RenderContext,
706    plot: Option<Plot<'_>>,
707    g: &Glyphs,
708) -> Option<PlotPlace> {
709    let text_secondary = theme.text_secondary();
710    let hint = |buf: &mut ratatui::buffer::Buffer| {
711        Paragraph::new(missing(modal))
712            .style(Style::default().fg(text_secondary))
713            .centered()
714            .render(area, buf);
715    };
716    let picked = || ChartModal::is_complete(&modal.effective_spec());
717    let Some(plot) = plot else {
718        if modal.mark() == Mark::Bar {
719            render_bar_chart(
720                area,
721                buf,
722                (ctx, theme),
723                None,
724                (picked(), modal.show_legend),
725                g,
726            );
727        } else {
728            hint(buf);
729        }
730        return None;
731    };
732    let (x, y) = (&plot.x, &plot.y);
733    match &*plot.data {
734        PlotData::Lines(_) | PlotData::XRange(_) => {
735            return render_xy_chart(area, buf, modal, theme, &plot, text_secondary, g);
736        }
737        PlotData::Histogram(data) => {
738            let look = HistogramLook {
739                grid: modal.grid,
740                legend: modal.show_legend,
741            };
742            render_histogram_chart(area, buf, &look, theme, (data, &plot.curves()), (x, y), g)
743        }
744        PlotData::Box(data) => {
745            render_box_plot_chart(area, buf, modal, theme, data, (&x.title, y), g)
746        }
747        PlotData::Kde(data) => {
748            render_kde_chart(area, buf, modal, theme, (data, &plot.curves()), (x, y), g)
749        }
750        PlotData::Heatmap(data) => {
751            render_heatmap_chart(area, buf, theme, data, (x, y), text_secondary, g)
752        }
753        PlotData::Bars(data) => render_bar_chart(
754            area,
755            buf,
756            (ctx, theme),
757            Some(data),
758            (picked(), modal.show_legend),
759            g,
760        ),
761    }
762    None
763}
764
765/// One horizontal bar per category: the label, the value, then the bar, from a zero
766/// line that sits at the left edge unless some value is negative. The bars that fit
767/// are drawn and the rest are counted on a `+ 212 more` chip, never squeezed in.
768/// Split by a color, each category is a row per group, in the group's color, under
769/// a legend of the groups.
770fn render_bar_chart(
771    area: Rect,
772    buf: &mut ratatui::buffer::Buffer,
773    (ctx, theme): (&RenderContext, &Theme),
774    data: Option<&BarData>,
775    (picked, legend): (bool, bool),
776    g: &Glyphs,
777) {
778    let hint = |text: &str, buf: &mut ratatui::buffer::Buffer| {
779        Paragraph::new(text.to_string())
780            .style(Style::default().fg(ctx.text_secondary))
781            .centered()
782            .render(area, buf);
783    };
784    // Picked but not here yet: the chart view says it is being computed.
785    let Some(data) = data else {
786        if !picked {
787            hint("Pick a category for X", buf);
788        }
789        return;
790    };
791    if data.bars.is_empty() {
792        hint("No data for bar chart", buf);
793        return;
794    }
795    if area.height < 2 || area.width < 5 {
796        return;
797    }
798    // A cell of air between the sidebar and the labels.
799    let area = Rect {
800        x: area.x + 1,
801        width: area.width - 1,
802        ..area
803    };
804    let width = area.width as usize;
805
806    if !data.groups.is_empty() {
807        render_grouped_bars(area, buf, (ctx, theme), data, legend, g);
808        return;
809    }
810    // A header row, then a row per bar; when they do not all fit, the last row is
811    // the count of the rest.
812    let rows = area.height as usize - 1;
813    let total = data.bars.len() + data.more;
814    let shown = if total <= rows {
815        data.bars.len()
816    } else {
817        rows.saturating_sub(1).min(data.bars.len())
818    };
819    let bars = &data.bars[..shown];
820    let hidden = total - shown;
821
822    let mut values = data.value_labels(&ctx.number_format);
823    values.truncate(shown);
824    let value_w = values
825        .iter()
826        .map(|v| crate::glyphs::display_width(v))
827        .max()
828        .unwrap_or(0);
829    let label_cap = (width * 2 / 5).max(4);
830    // Wide enough for the column's name above the labels, too.
831    let label_w = bars
832        .iter()
833        .map(|b| crate::glyphs::display_width(b.label.as_deref().unwrap_or(g.null)))
834        .chain([crate::glyphs::display_width(&data.category)])
835        .max()
836        .unwrap_or(0)
837        .clamp(1, label_cap);
838    let value_x = label_w + 1;
839    let bar_x = value_x + value_w + 1;
840    let bar_w = width.saturating_sub(bar_x);
841
842    let header = Style::default().fg(ctx.text_secondary);
843    let text = Style::default().fg(ctx.text_primary);
844    let bar_style = Style::default().fg(ctx.primary_chart_series_color);
845    let put = |buf: &mut ratatui::buffer::Buffer, x: usize, y: u16, s: &str, style: Style| {
846        if x < width {
847            buf.set_stringn(area.x + x as u16, y, s, width - x, style);
848        }
849    };
850    let fit = |s: &str, w: usize| -> String {
851        if crate::glyphs::display_width(s) <= w {
852            return s.to_string();
853        }
854        let room = w.saturating_sub(crate::glyphs::display_width(g.ellipsis));
855        format!("{}{}", crate::glyphs::take_columns(s, room), g.ellipsis)
856    };
857
858    // The value column's name ends where the values end, or starts where they start
859    // when it is the wider of the two.
860    put(buf, 0, area.y, &fit(&data.category, label_w), header);
861    let name_w = crate::glyphs::display_width(&data.value_column);
862    let name_x = if name_w <= value_w {
863        value_x + value_w - name_w
864    } else {
865        value_x
866    };
867    put(buf, name_x, area.y, &data.value_column, header);
868
869    let lo = bars.iter().map(|b| b.value).fold(0.0_f64, f64::min);
870    let hi = bars.iter().map(|b| b.value).fold(0.0_f64, f64::max);
871    let span = if hi > lo { hi - lo } else { 1.0 };
872    // A negative value keeps a cell left of zero, however small it is beside the rest.
873    let zero = (((-lo / span) * bar_w as f64).round() as usize)
874        .max(usize::from(lo < 0.0))
875        .min(bar_w);
876    for (i, (bar, value)) in bars.iter().zip(&values).enumerate() {
877        let y = area.y + 1 + i as u16;
878        match &bar.label {
879            Some(label) => put(buf, 0, y, &fit(label, label_w), text),
880            None => put(buf, 0, y, g.null, Style::default().fg(ctx.dimmed)),
881        }
882        let pad = value_w - crate::glyphs::display_width(value);
883        put(buf, value_x + pad, y, value, text);
884        if bar_w == 0 {
885            continue;
886        }
887        // A value that is not zero always draws: the thinnest mark, at least.
888        let nonzero = usize::from(bar.value != 0.0);
889        if bar.value >= 0.0 {
890            // Eighths of a cell, so short bars still differ.
891            let eighths = ((bar.value / span) * bar_w as f64 * 8.0).round() as usize;
892            let eighths = eighths.max(nonzero).min((bar_w - zero.min(bar_w)) * 8);
893            let mut body = g.bar_eighths[7].repeat(eighths / 8);
894            if let Some(part) = (eighths % 8).checked_sub(1) {
895                body.push_str(g.bar_eighths[part]);
896            }
897            put(buf, bar_x + zero, y, &body, bar_style);
898        } else {
899            // Leftward from zero in whole cells: the eighths fill from the left.
900            let cells = ((-bar.value / span) * bar_w as f64).round() as usize;
901            let cells = cells.max(nonzero).min(zero);
902            put(
903                buf,
904                bar_x + zero - cells,
905                y,
906                &g.bar_eighths[7].repeat(cells),
907                bar_style,
908            );
909        }
910    }
911    if hidden > 0 {
912        let y = area.y + 1 + shown as u16;
913        let chip = format!(" + {} more ", crate::numfmt::group_chrome(hidden));
914        put(
915            buf,
916            0,
917            y,
918            &chip,
919            Style::default().bg(ctx.controls_bg).fg(ctx.text_primary),
920        );
921    }
922}
923
924/// Bars split by a color: a legend line of the groups, then per category its label
925/// and a bar per group, each its group's color, scaled together. The categories
926/// that fit are drawn; the rest are counted.
927fn render_grouped_bars(
928    area: Rect,
929    buf: &mut ratatui::buffer::Buffer,
930    (ctx, theme): (&RenderContext, &Theme),
931    data: &BarData,
932    legend: bool,
933    g: &Glyphs,
934) {
935    let width = area.width as usize;
936    let groups = data.groups.len();
937    let other = other_at(data.other, groups);
938    let color = |i: usize| series_style(theme, i, other);
939    // The legend: a swatch and a name per group.
940    let mut names = vec![Span::styled(
941        format!("{}  ", data.value_column),
942        Style::default().fg(ctx.text_secondary),
943    )];
944    for (i, name) in data.groups.iter().enumerate().filter(|_| legend) {
945        names.push(Span::styled(g.bar_eighths[7].repeat(2), color(i)));
946        names.push(Span::styled(
947            format!(" {name}  "),
948            Style::default().fg(ctx.text_primary),
949        ));
950    }
951    Paragraph::new(Line::from(names)).render(Rect { height: 1, ..area }, buf);
952    let rows = (area.height as usize).saturating_sub(1);
953    let total = data.bars.len() + data.more;
954    let per = groups.max(1);
955    let fit = rows / per;
956    let shown = if total * per <= rows {
957        data.bars.len()
958    } else {
959        fit.saturating_sub(1).min(data.bars.len())
960    };
961    let label_w = data.bars[..shown]
962        .iter()
963        .map(|b| crate::glyphs::display_width(b.label.as_deref().unwrap_or(g.null)))
964        .max()
965        .unwrap_or(0)
966        .clamp(1, (width * 2 / 5).max(4));
967    let bar_x = label_w + 1;
968    let bar_w = width.saturating_sub(bar_x);
969    let values = || {
970        data.bars[..shown]
971            .iter()
972            .flat_map(|b| b.by_group.iter().flatten().copied())
973    };
974    let lo = values().fold(0.0_f64, f64::min);
975    let hi = values().fold(0.0_f64, f64::max);
976    let span = if hi > lo { hi - lo } else { 1.0 };
977    let zero = (((-lo / span) * bar_w as f64).round() as usize)
978        .max(usize::from(lo < 0.0))
979        .min(bar_w);
980    let put = |buf: &mut ratatui::buffer::Buffer, x: usize, y: u16, s: &str, style: Style| {
981        if x < width {
982            buf.set_stringn(area.x + x as u16, y, s, width - x, style);
983        }
984    };
985    for (i, bar) in data.bars[..shown].iter().enumerate() {
986        let top = area.y + 1 + (i * per) as u16;
987        let label = bar.label.as_deref().unwrap_or(g.null);
988        let label = if crate::glyphs::display_width(label) > label_w {
989            let room = label_w.saturating_sub(crate::glyphs::display_width(g.ellipsis));
990            format!("{}{}", crate::glyphs::take_columns(label, room), g.ellipsis)
991        } else {
992            label.to_string()
993        };
994        put(buf, 0, top, &label, Style::default().fg(ctx.text_primary));
995        if bar_w == 0 {
996            continue;
997        }
998        for (k, value) in bar.by_group.iter().enumerate() {
999            let Some(v) = value else { continue };
1000            let y = top + k as u16;
1001            let cells = ((v.abs() / span) * bar_w as f64).round() as usize;
1002            let cells = cells.max(usize::from(*v != 0.0));
1003            let (x, cells) = if *v >= 0.0 {
1004                (bar_x + zero, cells.min(bar_w - zero.min(bar_w)))
1005            } else {
1006                let cells = cells.min(zero);
1007                (bar_x + zero - cells, cells)
1008            };
1009            put(buf, x, y, &g.bar_eighths[7].repeat(cells), color(k));
1010        }
1011    }
1012    let hidden = total - shown;
1013    if hidden > 0 {
1014        let y = area.y + 1 + (shown * per) as u16;
1015        if y < area.bottom() {
1016            let chip = format!(" + {} more ", crate::numfmt::group_chrome(hidden));
1017            put(
1018                buf,
1019                0,
1020                y,
1021                &chip,
1022                Style::default().bg(ctx.controls_bg).fg(ctx.text_primary),
1023            );
1024        }
1025    }
1026}
1027
1028fn render_xy_chart(
1029    area: Rect,
1030    buf: &mut ratatui::buffer::Buffer,
1031    modal: &ChartModal,
1032    theme: &Theme,
1033    plot: &Plot<'_>,
1034    text_secondary: ratatui::style::Color,
1035    g: &Glyphs,
1036) -> Option<PlotPlace> {
1037    let empty = LinesData::default();
1038    let (lines, x_bounds) = match &*plot.data {
1039        PlotData::Lines(lines) => (lines, None),
1040        PlotData::XRange(range) => (&empty, Some((range.x_min, range.x_max))),
1041        _ => return None,
1042    };
1043    let other_at = lines.other.then(|| lines.names.len().saturating_sub(1));
1044    let graph_type = if plot.scatter {
1045        GraphType::Scatter
1046    } else {
1047        GraphType::Line
1048    };
1049    let (x, y) = (&plot.x, &plot.y);
1050    let drawn: Vec<_> = plot.drawn().collect();
1051
1052    if drawn.is_empty() {
1053        if modal.x().is_none() {
1054            Paragraph::new(missing(modal))
1055                .style(Style::default().fg(text_secondary))
1056                .centered()
1057                .render(area, buf);
1058            return None;
1059        }
1060        // The axes stand empty until the series arrive.
1061        const PLACEHOLDER_MIN: f64 = 0.0;
1062        const PLACEHOLDER_MAX: f64 = 1.0;
1063        let (x_min, x_max) = x_bounds.unwrap_or((PLACEHOLDER_MIN, PLACEHOLDER_MAX));
1064        let axes = plot_axes(
1065            theme,
1066            x_axis([x_min, x_max], x.kind, &x.numbers, &x.title),
1067            AxisSpec::y_numbers([PLACEHOLDER_MIN, PLACEHOLDER_MAX], &y.numbers, &y.title),
1068            g.plot.line,
1069            modal.grid,
1070        );
1071        let empty_dataset = Dataset::default().name("").data(&[]).graph_type(graph_type);
1072        axes.render(Chart::new(vec![empty_dataset]), area, buf, g);
1073        return None;
1074    }
1075
1076    // Kept with the prepared series; worked out here only for series made here.
1077    let [all_x_min, all_x_max, all_y_min, all_y_max] =
1078        lines.shown_bounds(y.log).unwrap_or([0.0; 4]);
1079
1080    // A scatter of few points marks each with a dot a cell wide; past one point per
1081    // four cells, the line's finer marks keep them apart.
1082    let points: usize = drawn.iter().map(|s| s.points.len()).sum();
1083    let cells = usize::from(area.width) * usize::from(area.height);
1084    let finer = resolution(g.plot.line) > resolution(g.plot.point);
1085    let marker = match plot.scatter {
1086        false => g.plot.line,
1087        true if finer && points * 4 > cells => g.plot.line,
1088        true => g.plot.point,
1089    };
1090
1091    // A series is drawn as its runs between gaps, so a line never bridges a missing
1092    // value. Other first, so the series drawn over it keep their colors.
1093    let curves = plot.curves();
1094    let datasets = curve_datasets(&curves, theme, marker, graph_type);
1095
1096    let y_min_bounds = if plot.y_from_zero {
1097        0.0_f64.min(all_y_min)
1098    } else {
1099        all_y_min
1100    };
1101    let y_max_bounds = if all_y_max > y_min_bounds {
1102        all_y_max
1103    } else {
1104        y_min_bounds + 1.0
1105    };
1106    let (x_min_bounds, x_max_bounds) = if all_x_max > all_x_min {
1107        (all_x_min, all_x_max)
1108    } else {
1109        (all_x_min - 0.5, all_x_min + 0.5)
1110    };
1111
1112    let y_bounds = [y_min_bounds, y_max_bounds];
1113    // On a log scale a tick stands for its value before the log.
1114    let y_numbers = if y.log {
1115        y.numbers.clone().fractional()
1116    } else {
1117        y.numbers.clone()
1118    };
1119    let y_spec = if y.log {
1120        AxisSpec::y_log(y_bounds, &y_numbers, &y.title)
1121    } else {
1122        AxisSpec::y_numbers(y_bounds, &y_numbers, &y.title)
1123    };
1124    let mut axes = plot_axes(
1125        theme,
1126        x_axis([x_min_bounds, x_max_bounds], x.kind, &x.numbers, &x.title),
1127        y_spec,
1128        marker,
1129        modal.grid,
1130    );
1131    axes.legend = legend(
1132        modal.show_legend,
1133        drawn
1134            .iter()
1135            .map(|s| (s.name, series_style(theme, s.index, other_at))),
1136    );
1137    let x_bounds = [x_min_bounds, x_max_bounds];
1138    let sub = resolution(marker).0;
1139    // The crosshair's readout takes the rows under the plot while the plot has the keys;
1140    // it reads values before any log scaling.
1141    let values = &lines.series[..];
1142    let cursor = modal
1143        .cursor_x
1144        .filter(|_| modal.plot_focus)
1145        .and_then(|cursor| {
1146            if lines.xs.is_empty() {
1147                crosshair::nearest(&crosshair::xs(values), cursor)
1148            } else {
1149                crosshair::nearest(&lines.xs, cursor)
1150            }
1151        });
1152    let readout = cursor
1153        .map(|at| {
1154            let written = crosshair::format_x(at, x.kind, &x.numbers);
1155            let entries = readout_entries(
1156                theme,
1157                g,
1158                (at, &x.title, written),
1159                &y.numbers,
1160                values,
1161                &lines.names,
1162                other_at,
1163            );
1164            crosshair::readout_lines(&entries, area.width as usize, g)
1165        })
1166        .unwrap_or_default();
1167    let rows = (readout.len() as u16).min(area.height / 3);
1168    let [plot_area, readout_area] =
1169        Layout::vertical([Constraint::Fill(1), Constraint::Length(rows)]).areas(area);
1170    let frame = axes.render(Chart::new(datasets), plot_area, buf, g);
1171    let place = PlotPlace {
1172        graph: frame.graph,
1173        x_bounds,
1174        sub,
1175    };
1176    if let Some(at) = cursor {
1177        let style = Style::default().fg(theme.accent());
1178        crosshair::draw(buf, &place, at, style, g);
1179        Paragraph::new(readout).render(readout_area, buf);
1180    }
1181    Some(place)
1182}
1183
1184/// The readout at the crosshair's `x`: x under its title as `written`, then each
1185/// series' value there under its name in its color, `∅` where it has a gap.
1186fn readout_entries(
1187    theme: &Theme,
1188    g: &Glyphs,
1189    (x, x_title, written): (f64, &str, String),
1190    y: &AxisNumbers,
1191    series: &[Vec<(f64, f64)>],
1192    names: &[String],
1193    other_at: Option<usize>,
1194) -> Vec<crosshair::Entry> {
1195    let value_style = Style::default().fg(theme.text_primary());
1196    let x_title = if x_title.is_empty() { "x" } else { x_title };
1197    let mut entries = vec![crosshair::Entry {
1198        name: x_title.to_string(),
1199        name_style: Style::default().fg(theme.text_secondary()),
1200        value: written,
1201        value_style,
1202    }];
1203    for (i, (value, name)) in crosshair::values_at(series, x)
1204        .into_iter()
1205        .zip(names)
1206        .enumerate()
1207    {
1208        let (value, value_style) = match value {
1209            Some(v) => (y.write(v), value_style),
1210            None => (g.null.to_string(), Style::default().fg(theme.dimmed())),
1211        };
1212        entries.push(crosshair::Entry {
1213            name: name.clone(),
1214            name_style: series_style(theme, i, other_at),
1215            value,
1216            value_style,
1217        });
1218    }
1219    entries
1220}
1221
1222/// Axes drawn in the theme's primary text color, ticked for series drawn with
1223/// `marker`, with the grid in `chart_grid` when `grid` is on.
1224fn plot_axes<'a>(
1225    theme: &Theme,
1226    x: AxisSpec<'a>,
1227    y: AxisSpec<'a>,
1228    marker: ratatui::symbols::Marker,
1229    grid: bool,
1230) -> PlotAxes<'a> {
1231    let style = Style::default().fg(theme.text_primary());
1232    PlotAxes {
1233        grid: grid.then(|| Style::default().fg(theme.chart_grid())),
1234        ..PlotAxes::new(x, y, style, marker)
1235    }
1236}
1237
1238/// An XY chart's x axis: numbers on nice steps, or dates and times on calendar
1239/// boundaries.
1240fn x_axis<'a>(
1241    bounds: [f64; 2],
1242    kind: XAxisTemporalKind,
1243    numbers: &AxisNumbers,
1244    title: &'a str,
1245) -> AxisSpec<'a> {
1246    AxisSpec::calendar(bounds, kind, numbers, title)
1247}
1248
1249/// The legend, when it is on and there is more than one series to tell apart: the
1250/// y title names a lone one.
1251fn legend<'a>(show: bool, entries: impl Iterator<Item = (&'a str, Style)>) -> Option<Legend> {
1252    let entries: Vec<(String, Style)> = entries.map(|(n, s)| (n.to_string(), s)).collect();
1253    (show && entries.len() > 1).then_some(Legend { entries })
1254}
1255
1256/// A dataset per run of each curve, in the order they come: Other first, so the
1257/// series drawn over it keep their colors.
1258fn curve_datasets<'a>(
1259    curves: &'a [Curve<'_>],
1260    theme: &Theme,
1261    marker: ratatui::symbols::Marker,
1262    graph_type: GraphType,
1263) -> Vec<Dataset<'a>> {
1264    curves
1265        .iter()
1266        .flat_map(|curve| {
1267            let style = series_style(theme, curve.index, curve.other.then_some(curve.index));
1268            segments(&curve.points, curve.breaks)
1269                .into_iter()
1270                .map(move |run| {
1271                    Dataset::default()
1272                        .marker(marker)
1273                        .graph_type(graph_type)
1274                        .style(style)
1275                        .data(run)
1276                })
1277        })
1278        .collect()
1279}
1280
1281/// The style series `i` draws in: its palette color, or `dimmed` for Other, the
1282/// rows of every value without a series of its own.
1283fn series_style(theme: &Theme, i: usize, other_at: Option<usize>) -> Style {
1284    if other_at == Some(i) {
1285        return Style::default().fg(theme.dimmed());
1286    }
1287    let colors = theme.series_colors();
1288    Style::default().fg(colors[i % colors.len()])
1289}
1290
1291/// How a histogram is drawn: its grid and its legend.
1292pub struct HistogramLook {
1293    pub grid: bool,
1294    pub legend: bool,
1295}
1296
1297/// A histogram of `data` in `area`, in the chart view's marks: for the Value Counts
1298/// screen's histogram view.
1299pub fn render_histogram(
1300    area: Rect,
1301    buf: &mut ratatui::buffer::Buffer,
1302    theme: &Theme,
1303    ctx: &RenderContext,
1304    data: &HistogramData,
1305    x: AxisNumbers,
1306) {
1307    let x = Axis {
1308        title: data.column.clone(),
1309        numbers: x,
1310        ..Axis::default()
1311    };
1312    let y = Axis {
1313        title: "Count".to_string(),
1314        numbers: AxisNumbers::count(&ctx.number_format),
1315        ..Axis::default()
1316    };
1317    let look = HistogramLook {
1318        grid: false,
1319        legend: false,
1320    };
1321    render_histogram_chart(
1322        area,
1323        buf,
1324        &look,
1325        theme,
1326        (data, &[]),
1327        (&x, &y),
1328        crate::glyphs::get(),
1329    );
1330}
1331
1332/// A histogram: filled bars, or, split by a color, each group's bins as a step
1333/// outline over the others (filled bars would hide one another).
1334fn render_histogram_chart(
1335    area: Rect,
1336    buf: &mut ratatui::buffer::Buffer,
1337    look: &HistogramLook,
1338    theme: &Theme,
1339    (data, curves): (&HistogramData, &[Curve<'_>]),
1340    (x, y): (&Axis, &Axis),
1341    g: &Glyphs,
1342) {
1343    let text_secondary = theme.text_secondary();
1344    if data.bins.is_empty() {
1345        Paragraph::new("No data for histogram")
1346            .style(Style::default().fg(text_secondary))
1347            .centered()
1348            .render(area, buf);
1349        return;
1350    }
1351
1352    let x_min_bounds = data.x_min;
1353    let x_max_bounds = if data.x_max > data.x_min {
1354        data.x_max
1355    } else {
1356        data.x_min + 1.0
1357    };
1358    let y_min_bounds = 0.0;
1359    let y_max_bounds = if data.max_count > 0.0 {
1360        data.max_count
1361    } else {
1362        1.0
1363    };
1364
1365    let marker = if data.groups.is_empty() {
1366        g.plot.bar
1367    } else {
1368        g.plot.line
1369    };
1370    let mut axes = plot_axes(
1371        theme,
1372        AxisSpec::numbers([x_min_bounds, x_max_bounds], &x.numbers, &x.title),
1373        AxisSpec::y_numbers([y_min_bounds, y_max_bounds], &y.numbers, &y.title),
1374        marker,
1375        look.grid,
1376    );
1377    if !data.groups.is_empty() {
1378        let other = other_at(data.other, data.groups.len());
1379        let datasets = curve_datasets(curves, theme, marker, GraphType::Line);
1380        axes.legend = legend(
1381            look.legend,
1382            data.groups
1383                .iter()
1384                .enumerate()
1385                .map(|(i, group)| (group.name.as_str(), series_style(theme, i, other))),
1386        );
1387        axes.render(Chart::new(datasets), area, buf, g);
1388        return;
1389    }
1390
1391    let frame = axes.frame(area);
1392    let points = bin_columns(data, [x_min_bounds, x_max_bounds], frame.graph.width);
1393    let style = Style::default().fg(theme.chart_1());
1394    let dataset = Dataset::default()
1395        .name("")
1396        .marker(g.plot.bar)
1397        .graph_type(GraphType::Bar)
1398        .style(style)
1399        .data(&points);
1400
1401    axes.render_in(frame, Chart::new(vec![dataset]), buf, g);
1402}
1403
1404/// A histogram's bars as a column of the plot each, `columns` wide over `bounds`: a
1405/// bin fills the columns its values land on, less one between it and the next when
1406/// it is three or more wide, so the bars read as bins and not as needles.
1407fn bin_columns(data: &HistogramData, [lo, hi]: [f64; 2], columns: u16) -> Vec<(f64, f64)> {
1408    let n = data.bins.len();
1409    if n == 0 || columns < 2 || hi <= lo {
1410        return data.bins.iter().map(|b| (b.center, b.count)).collect();
1411    }
1412    let last = f64::from(columns - 1);
1413    // The value at a column's center, as the canvas maps values to columns.
1414    let at = |c: u16| lo + f64::from(c) / last * (hi - lo);
1415    let bin_of = |v: f64| (((v - lo) / (hi - lo) * n as f64).floor() as usize).min(n - 1);
1416    let bin_cols: Vec<u16> = (0..columns).map(|c| bin_of(at(c)) as u16).collect();
1417    (0..columns)
1418        .filter_map(|c| {
1419            let bin = bin_cols[c as usize];
1420            let wide = bin_cols.iter().filter(|b| **b == bin).count();
1421            let last_of_bin = bin_cols.get(c as usize + 1).is_some_and(|b| *b != bin);
1422            (!(last_of_bin && wide >= 3)).then(|| (at(c), data.bins[bin as usize].count))
1423        })
1424        .collect()
1425}
1426
1427fn render_kde_chart(
1428    area: Rect,
1429    buf: &mut ratatui::buffer::Buffer,
1430    modal: &ChartModal,
1431    theme: &Theme,
1432    (data, curves): (&KdeData, &[Curve<'_>]),
1433    (x, y): (&Axis, &Axis),
1434    g: &Glyphs,
1435) {
1436    let text_secondary = theme.text_secondary();
1437    if data.series.is_empty() {
1438        Paragraph::new("No data for KDE")
1439            .style(Style::default().fg(text_secondary))
1440            .centered()
1441            .render(area, buf);
1442        return;
1443    }
1444
1445    let other = other_at(data.other, data.series.len());
1446    let datasets = curve_datasets(curves, theme, g.plot.line, GraphType::Line);
1447
1448    let mut axes = plot_axes(
1449        theme,
1450        AxisSpec::numbers([data.x_min, data.x_max], &x.numbers, &x.title),
1451        AxisSpec::y_numbers([0.0, data.y_max], &y.numbers, &y.title),
1452        g.plot.line,
1453        modal.grid,
1454    );
1455    axes.legend = legend(
1456        modal.show_legend,
1457        data.series
1458            .iter()
1459            .enumerate()
1460            .map(|(i, s)| (s.name.as_str(), series_style(theme, i, other))),
1461    );
1462    axes.render(Chart::new(datasets), area, buf, g);
1463}
1464
1465fn render_box_plot_chart(
1466    area: Rect,
1467    buf: &mut ratatui::buffer::Buffer,
1468    modal: &ChartModal,
1469    theme: &Theme,
1470    data: &BoxPlotData,
1471    (x_title, y): (&str, &Axis),
1472    g: &Glyphs,
1473) {
1474    let text_secondary = theme.text_secondary();
1475    if data.stats.is_empty() {
1476        Paragraph::new("No data for box plot")
1477            .style(Style::default().fg(text_secondary))
1478            .centered()
1479            .render(area, buf);
1480        return;
1481    }
1482
1483    let mut segments: Vec<Vec<(f64, f64)>> = Vec::new();
1484    let mut segment_styles: Vec<Style> = Vec::new();
1485    for (i, stat) in data.stats.iter().enumerate() {
1486        let marks = stat.marks(i as f64, 0.3, 0.2);
1487        for segment in [
1488            &marks.outline[..],
1489            &marks.median,
1490            &marks.low,
1491            &marks.high,
1492            &marks.low_cap,
1493            &marks.high_cap,
1494        ] {
1495            segments.push(segment.to_vec());
1496            segment_styles.push(series_style(theme, i, None));
1497        }
1498    }
1499
1500    let datasets: Vec<Dataset> = segments
1501        .iter()
1502        .zip(segment_styles.iter())
1503        .map(|(points, style)| {
1504            Dataset::default()
1505                .name("")
1506                .graph_type(GraphType::Line)
1507                .marker(g.plot.point)
1508                .style(*style)
1509                .data(points)
1510        })
1511        .collect();
1512
1513    let x_min_bounds = -0.5;
1514    let x_max_bounds = (data.stats.len() as f64 - 1.0).max(0.0) + 0.5;
1515    // A box's name sits under the box, at its index.
1516    let name = |i: f64, level| {
1517        let stat = data.stats.get(i as usize)?;
1518        (level == 0).then(|| stat.name.clone())
1519    };
1520    let x = AxisSpec::fixed(
1521        [x_min_bounds, x_max_bounds],
1522        (0..data.stats.len()).map(|i| i as f64).collect(),
1523        Box::new(name),
1524        x_title,
1525    );
1526    let y = AxisSpec::y_numbers([data.y_min, data.y_max], &y.numbers, &y.title);
1527    plot_axes(theme, x, y, g.plot.point, modal.grid).render(Chart::new(datasets), area, buf, g);
1528}
1529
1530fn render_heatmap_chart(
1531    area: Rect,
1532    buf: &mut ratatui::buffer::Buffer,
1533    theme: &Theme,
1534    data: &HeatmapData,
1535    (x, y): (&Axis, &Axis),
1536    text_secondary: ratatui::style::Color,
1537    g: &Glyphs,
1538) {
1539    if data.counts.is_empty() || data.max_count <= 0.0 {
1540        Paragraph::new("No data for heatmap")
1541            .style(Style::default().fg(text_secondary))
1542            .centered()
1543            .render(area, buf);
1544        return;
1545    }
1546
1547    let layout = Layout::default()
1548        .direction(Direction::Vertical)
1549        .constraints([
1550            Constraint::Length(HEATMAP_TITLE_HEIGHT),
1551            Constraint::Min(1),
1552            Constraint::Length(HEATMAP_X_LABEL_HEIGHT),
1553        ])
1554        .split(area);
1555    // The axes' titles sit as every plot's do: Y over its labels, X at the right
1556    // under its own.
1557    let title_style = Style::default().fg(theme.text_primary());
1558    let y_title = cut(&y.title, layout[0].width as usize, g);
1559    buf.set_string(layout[0].x, layout[0].y, &y_title, title_style);
1560
1561    const Y_LABEL_MAX: u16 = 12;
1562    // Nice values up the side, one per few rows, each on the row its value falls in.
1563    let y_axis = AxisSpec::numbers([data.y_min, data.y_max], &y.numbers, "");
1564    let rows = layout[1];
1565    let y_track = Track {
1566        start: rows.y,
1567        cells: rows.height,
1568        sub: 1,
1569    };
1570    let y_labels = fit_y_labels(&y_axis, y_track, Y_LABEL_MAX).labels;
1571    let y_label_width = y_labels.iter().map(|(_, l)| l.width()).max().unwrap_or(1);
1572    let y_label_width = (y_label_width as u16).clamp(4, Y_LABEL_MAX);
1573    let body = Layout::default()
1574        .direction(Direction::Horizontal)
1575        .constraints([Constraint::Length(y_label_width + 1), Constraint::Min(1)])
1576        .split(layout[1]);
1577    let label_area = body[0];
1578    let plot_area = body[1];
1579    if plot_area.width == 0 || plot_area.height == 0 {
1580        return;
1581    }
1582
1583    let label_style = Style::default().fg(theme.text_primary());
1584    for (row, label) in &y_labels {
1585        let pad = usize::from(y_label_width).saturating_sub(label.width()) as u16;
1586        buf.set_stringn(
1587            label_area.x + pad,
1588            *row,
1589            label,
1590            usize::from(y_label_width),
1591            label_style,
1592        );
1593    }
1594
1595    // Ten steps of density, the same in either glyph set.
1596    const RAMP: [&str; 10] = [" ", ".", ":", "-", "=", "+", "*", "#", "%", "@"];
1597    let style = Style::default().fg(theme.chart_1());
1598    let max_x_bin = data.x_bins.saturating_sub(1) as f64;
1599    let max_y_bin = data.y_bins.saturating_sub(1) as f64;
1600    for row in 0..plot_area.height {
1601        let y_bin_raw = ((row as f64 / plot_area.height as f64) * data.y_bins as f64).floor();
1602        let y_bin = data
1603            .y_bins
1604            .saturating_sub(1)
1605            .saturating_sub(y_bin_raw.clamp(0.0, max_y_bin) as usize);
1606        for col in 0..plot_area.width {
1607            let x_bin = ((col as f64 / plot_area.width as f64) * data.x_bins as f64)
1608                .floor()
1609                .clamp(0.0, max_x_bin) as usize;
1610            let count = data.counts[y_bin][x_bin];
1611            let level = ((count / data.max_count) * (RAMP.len() as f64 - 1.0))
1612                .round()
1613                .clamp(0.0, RAMP.len() as f64 - 1.0) as usize;
1614            buf[(plot_area.x + col, plot_area.y + row)]
1615                .set_symbol(RAMP[level])
1616                .set_style(style);
1617        }
1618    }
1619
1620    let x_label_area = layout[2];
1621    let x_axis = AxisSpec::numbers([data.x_min, data.x_max], &x.numbers, "");
1622    let span = (x_label_area.left(), x_label_area.right());
1623    let x_track = Track {
1624        start: plot_area.x,
1625        cells: plot_area.width,
1626        sub: 1,
1627    };
1628    for (x, label) in fit_x_labels(&x_axis, span, x_track).labels {
1629        buf.set_string(x, x_label_area.y, label, label_style);
1630    }
1631    if x_label_area.height > 1 {
1632        let x_title = cut(&x.title, x_label_area.width as usize, g);
1633        let x = x_label_area.right() - x_title.width() as u16;
1634        buf.set_string(x, x_label_area.y + 1, &x_title, title_style);
1635    }
1636}
1637
1638#[cfg(test)]
1639mod tests;