Skip to main content

datui_lib/
chart_modal.rs

1//! Chart view state: the chart's spec and the panel that edits it.
2//!
3//! The spec is a set of shelves, named as Vega-Lite names them so a later version
4//! can save it: `mark` (the Type shelf), `encoding.x.field` with its `timeUnit`,
5//! `encoding.y.field` with its `aggregate`, and `encoding.color.field`. Every chart
6//! type shows the same shelves; a shelf a type does not use is dimmed, never
7//! hidden ([`ChartModal::shelf`]). Options that are not part of what is charted
8//! (bins, ranges, the grid, the sample size) sit beside the spec.
9//!
10//! The panel is one form of the shared focus model (`crate::form`); column and
11//! value rows are edited through the one shared Picker.
12
13use crate::chart_data::{BarOrder, ValueRange};
14use crate::widgets::ui::PickerState;
15use polars::prelude::DataType;
16use serde::{Deserialize, Serialize};
17
18/// The Type shelf: what marks the chart draws.
19#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "lowercase")]
21pub enum Mark {
22    #[default]
23    Line,
24    Scatter,
25    Bar,
26    Histogram,
27    Box,
28    Kde,
29    Heatmap,
30}
31
32impl Mark {
33    pub const ALL: [Self; 7] = [
34        Self::Line,
35        Self::Scatter,
36        Self::Bar,
37        Self::Histogram,
38        Self::Box,
39        Self::Kde,
40        Self::Heatmap,
41    ];
42
43    pub fn label(self) -> &'static str {
44        match self {
45            Self::Line => "Line",
46            Self::Scatter => "Scatter",
47            Self::Bar => "Bar",
48            Self::Histogram => "Histogram",
49            Self::Box => "Box",
50            Self::Kde => "KDE",
51            Self::Heatmap => "Heatmap",
52        }
53    }
54
55    /// The Vega-Lite mark that draws it.
56    pub fn vega_lite(self) -> &'static str {
57        match self {
58            Self::Line | Self::Kde => "line",
59            Self::Scatter => "point",
60            Self::Bar | Self::Histogram => "bar",
61            Self::Box => "boxplot",
62            Self::Heatmap => "rect",
63        }
64    }
65
66    /// Line and scatter: X against one or more Y columns.
67    pub fn is_xy(self) -> bool {
68        matches!(self, Self::Line | Self::Scatter)
69    }
70}
71
72/// How a temporal X is bucketed before Y is aggregated.
73#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(rename_all = "lowercase")]
75pub enum TimeUnit {
76    #[default]
77    None,
78    Day,
79    Week,
80    Month,
81    Quarter,
82    Year,
83}
84
85impl TimeUnit {
86    pub const ALL: [Self; 6] = [
87        Self::None,
88        Self::Day,
89        Self::Week,
90        Self::Month,
91        Self::Quarter,
92        Self::Year,
93    ];
94
95    pub fn label(self) -> &'static str {
96        match self {
97            Self::None => "none",
98            Self::Day => "day",
99            Self::Week => "week",
100            Self::Month => "month",
101            Self::Quarter => "quarter",
102            Self::Year => "year",
103        }
104    }
105
106    /// The Polars duration a date is truncated to.
107    pub fn every(self) -> Option<&'static str> {
108        match self {
109            Self::None => None,
110            Self::Day => Some("1d"),
111            Self::Week => Some("1w"),
112            Self::Month => Some("1mo"),
113            Self::Quarter => Some("1q"),
114            Self::Year => Some("1y"),
115        }
116    }
117
118    /// The Vega-Lite `timeUnit`.
119    pub fn vega_lite(self) -> Option<&'static str> {
120        match self {
121            Self::None => None,
122            Self::Day => Some("yearmonthdate"),
123            Self::Week => Some("yearweek"),
124            Self::Month => Some("yearmonth"),
125            Self::Quarter => Some("yearquarter"),
126            Self::Year => Some("year"),
127        }
128    }
129}
130
131/// What a Y value is, per X (and color): the column as it is, or an aggregate of
132/// the rows that share it.
133#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
134#[serde(rename_all = "lowercase")]
135pub enum Aggregate {
136    #[default]
137    None,
138    Count,
139    /// The distinct values of Y: `nunique` of the query language, nulls left out.
140    Distinct,
141    Sum,
142    Mean,
143    Median,
144    /// The sample standard deviation (one degree of freedom); a group of one row
145    /// has none.
146    Stdev,
147    /// A percentile of Y, linearly interpolated: [`YEncoding::quantile`].
148    Quantile,
149    Min,
150    Max,
151    /// The first and last Y of each group in the view's row order: its sort, or the
152    /// order the rows were read in.
153    First,
154    Last,
155}
156
157impl Aggregate {
158    pub const ALL: [Self; 12] = [
159        Self::None,
160        Self::Count,
161        Self::Distinct,
162        Self::Sum,
163        Self::Mean,
164        Self::Median,
165        Self::Stdev,
166        Self::Quantile,
167        Self::Min,
168        Self::Max,
169        Self::First,
170        Self::Last,
171    ];
172
173    pub fn label(self) -> &'static str {
174        match self {
175            Self::None => "none",
176            Self::Count => "count",
177            Self::Distinct => "distinct",
178            Self::Sum => "sum",
179            Self::Mean => "mean",
180            Self::Median => "median",
181            Self::Stdev => "stdev",
182            Self::Quantile => "quantile",
183            Self::Min => "min",
184            Self::Max => "max",
185            Self::First => "first",
186            Self::Last => "last",
187        }
188    }
189
190    /// How it reads in a title or a column name: a quantile as its percentile,
191    /// `p90`.
192    pub fn named(self, quantile: u8) -> String {
193        match self {
194            Self::Quantile => format!("p{quantile}"),
195            other => other.label().to_string(),
196        }
197    }
198
199    /// The Vega-Lite aggregate. A quantile is one only at the quartiles and the
200    /// median; first and last have none, and are left out.
201    pub fn vega_lite(self, quantile: u8) -> Option<&'static str> {
202        match self {
203            Self::None | Self::First | Self::Last => None,
204            Self::Quantile => match quantile {
205                25 => Some("q1"),
206                50 => Some("median"),
207                75 => Some("q3"),
208                _ => None,
209            },
210            other => Some(other.label()),
211        }
212    }
213
214    /// Whether it reads the rows in the view's order.
215    pub fn follows_row_order(self) -> bool {
216        matches!(self, Self::First | Self::Last)
217    }
218
219    /// Whether every value it makes may have a fraction, whatever Y is.
220    pub fn is_fractional(self) -> bool {
221        matches!(
222            self,
223            Self::Mean | Self::Median | Self::Stdev | Self::Quantile
224        )
225    }
226
227    /// Whether Y may be any column, not only a number: a count of its distinct
228    /// values is a number whatever they are.
229    pub fn takes_any_y(self) -> bool {
230        self == Self::Distinct
231    }
232
233    /// Whether cumulative can run with it: the rows run as a total, which for a
234    /// count, sum, mean, median, min or max is what was asked. A running sum of
235    /// distinct counts is not the distinct count so far, nor one of deviations,
236    /// percentiles or first and last values anything: those take none.
237    pub fn runs_cumulative(self) -> bool {
238        matches!(
239            self,
240            Self::Count | Self::Sum | Self::Mean | Self::Median | Self::Min | Self::Max
241        )
242    }
243
244    /// Whether every value it makes is a whole number, whatever Y is.
245    pub fn is_count(self) -> bool {
246        matches!(self, Self::Count | Self::Distinct)
247    }
248}
249
250/// A running total of the aggregated Y, along X.
251#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
252#[serde(rename_all = "lowercase")]
253pub enum Cumulative {
254    #[default]
255    Off,
256    /// The values added up.
257    Sum,
258    /// Returns compounded: each value is a rate, and the line is what 1 grew to,
259    /// less 1 (`(1 + a)(1 + b)... - 1`).
260    Compound,
261}
262
263impl Cumulative {
264    pub const ALL: [Self; 3] = [Self::Off, Self::Sum, Self::Compound];
265
266    pub fn label(self) -> &'static str {
267        match self {
268            Self::Off => "off",
269            Self::Sum => "running sum",
270            Self::Compound => "compound",
271        }
272    }
273}
274
275#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
276#[serde(rename_all = "camelCase")]
277pub struct XEncoding {
278    pub field: Option<String>,
279    pub time_unit: TimeUnit,
280}
281
282#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
283#[serde(rename_all = "camelCase")]
284pub struct YEncoding {
285    /// One column, or several on a line or scatter chart (one series each).
286    pub field: Vec<String>,
287    pub aggregate: Aggregate,
288    pub cumulative: Cumulative,
289    /// The percentile a quantile takes; unset, [`QUANTILE_DEFAULT`].
290    #[serde(default, skip_serializing_if = "Option::is_none")]
291    pub percentile: Option<u8>,
292}
293
294/// The percentiles the quantile steps through.
295pub const QUANTILES: [u8; 8] = [1, 5, 10, 25, 75, 90, 95, 99];
296
297/// The percentile a quantile starts at.
298pub const QUANTILE_DEFAULT: u8 = 90;
299
300impl YEncoding {
301    /// The percentile a quantile takes.
302    pub fn quantile(&self) -> u8 {
303        self.percentile.unwrap_or(QUANTILE_DEFAULT)
304    }
305
306    /// The aggregate as a title or a column name says it: `mean`, `p90`.
307    pub fn aggregate_name(&self) -> String {
308        self.aggregate.named(self.quantile())
309    }
310}
311
312#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
313#[serde(rename_all = "camelCase")]
314pub struct ColorEncoding {
315    pub field: Option<String>,
316    /// The values given a series each, in color order. Empty: the largest
317    /// [`COLOR_MAX`] by rows. `None` is the rows with no value.
318    pub values: Vec<Option<String>>,
319    /// Whether every other value's rows make one more series, Other. Unset: on for a
320    /// scatter, off otherwise.
321    #[serde(default, skip_serializing_if = "Option::is_none")]
322    pub other: Option<bool>,
323}
324
325#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
326pub struct Encoding {
327    pub x: XEncoding,
328    pub y: YEncoding,
329    pub color: ColorEncoding,
330}
331
332/// What is charted.
333#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
334pub struct ChartSpec {
335    pub mark: Mark,
336    pub encoding: Encoding,
337}
338
339impl ChartSpec {
340    /// The spec as a Vega-Lite fragment: `mark` and the three encodings.
341    pub fn to_vega_lite(&self) -> serde_json::Value {
342        let mut x = serde_json::Map::new();
343        if let Some(field) = &self.encoding.x.field {
344            x.insert("field".into(), field.clone().into());
345        }
346        if let Some(unit) = self.encoding.x.time_unit.vega_lite() {
347            x.insert("timeUnit".into(), unit.into());
348        }
349        let mut y = serde_json::Map::new();
350        if let Some(field) = self.encoding.y.field.first() {
351            y.insert("field".into(), field.clone().into());
352        }
353        if let Some(aggregate) = self
354            .encoding
355            .y
356            .aggregate
357            .vega_lite(self.encoding.y.quantile())
358        {
359            y.insert("aggregate".into(), aggregate.into());
360        }
361        let mut encoding = serde_json::Map::new();
362        encoding.insert("x".into(), x.into());
363        encoding.insert("y".into(), y.into());
364        if let Some(field) = &self.encoding.color.field {
365            encoding.insert("color".into(), serde_json::json!({ "field": field }));
366        }
367        serde_json::json!({ "mark": self.mark.vega_lite(), "encoding": encoding })
368    }
369}
370
371/// Series a color splits a chart into, at most: one per palette color
372/// (`chart_1` to `chart_10`). A terminal of fewer colors draws fewer:
373/// [`ChartModal::series_max`].
374pub const COLOR_MAX: usize = 10;
375
376/// Most Y columns a line or scatter chart draws at once.
377pub const Y_SERIES_MAX: usize = COLOR_MAX;
378
379/// Default histogram bin count.
380pub const HISTOGRAM_DEFAULT_BINS: usize = 40;
381pub const HISTOGRAM_MIN_BINS: usize = 5;
382pub const HISTOGRAM_MAX_BINS: usize = 100;
383
384/// Default heatmap bin count (applies to both axes).
385pub const HEATMAP_DEFAULT_BINS: usize = 20;
386pub const HEATMAP_MIN_BINS: usize = 5;
387pub const HEATMAP_MAX_BINS: usize = 60;
388
389/// KDE bandwidth multiplier bounds and step.
390pub const KDE_BANDWIDTH_MIN: f64 = 0.2;
391pub const KDE_BANDWIDTH_MAX: f64 = 5.0;
392pub const KDE_BANDWIDTH_STEP: f64 = 0.1;
393
394/// The largest sample size (Polars slice takes u32).
395pub const CHART_ROW_LIMIT_MAX: usize = u32::MAX as usize;
396
397/// A change to the Rows row not yet read: ←/→ or a typed size waits for Enter, or
398/// for focus to leave the row, so the chart reads once rather than per key.
399#[derive(Debug, Clone, Default, PartialEq, Eq)]
400pub struct RowsDraft {
401    /// Every row rather than a sample.
402    pub every: bool,
403    /// The sample size being typed, as typed (`50k`).
404    pub typed: Option<String>,
405    /// Why the typed size cannot be read, until it is edited.
406    pub error: Option<&'static str>,
407}
408
409/// One row of the panel: a shelf, the line under it, or an option.
410#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
411pub enum ChartFocus {
412    /// The Type shelf: ←/→ step the chart type.
413    #[default]
414    Type,
415    /// The X shelf's column.
416    X,
417    /// Under X: the time bucket of a temporal X.
418    TimeUnit,
419    /// Under X on a bar chart: bars by value or by label.
420    Order,
421    /// Under X on a histogram; an option on a heatmap.
422    Bins,
423    /// The Y shelf's column (or columns); on a histogram, count or share.
424    Y,
425    /// Under Y: the aggregate.
426    Aggregate,
427    /// Under the aggregate, a quantile's percentile.
428    Quantile,
429    /// The Color shelf's column.
430    Color,
431    /// Under Color: which values get a series.
432    ColorValues,
433    /// Options.
434    Cumulative,
435    Bandwidth,
436    Range,
437    YStartsAtZero,
438    LogScale,
439    ShowLegend,
440    Grid,
441    /// Sample size, for charts that sample.
442    LimitRows,
443}
444
445/// Whether a shelf takes part in the chart on screen.
446#[derive(Debug, Clone, Copy, PartialEq, Eq)]
447pub enum ShelfUse {
448    /// Edited and charted.
449    Used,
450    /// Shown dimmed with why: `density`, `same as X`.
451    Dimmed(&'static str),
452}
453
454/// The view's columns a chart can take, by role.
455#[derive(Clone, Copy, Default)]
456pub struct ChartColumns<'a> {
457    pub numeric: &'a [String],
458    pub datetime: &'a [String],
459    /// Dates and datetimes, which a time bucket truncates (not times of day).
460    pub bucketable: &'a [String],
461    /// Categories: text, categorical, boolean and integer columns
462    /// (see `chart_data::is_category_dtype`).
463    pub category: &'a [String],
464}
465
466/// The values of the Color column, most rows first, as the chart's last count
467/// found them: what the value picker lists.
468#[derive(Debug, Clone, Default, PartialEq)]
469pub struct ColorCounts {
470    pub column: String,
471    pub values: Vec<(Option<String>, u64)>,
472}
473
474/// What the open Picker edits.
475#[derive(Debug, Clone, Copy, PartialEq, Eq)]
476pub enum PickerFor {
477    X,
478    Y,
479    Color,
480    ColorValues,
481}
482
483/// The item a Picker offers for "nothing": no Color, no category on a box plot.
484pub const NONE_ITEM: &str = "none";
485
486/// Chart view state: the spec, the options, and the panel's focus.
487#[derive(Default)]
488pub struct ChartModal {
489    pub active: bool,
490    pub spec: ChartSpec,
491    /// The distinct colors the series slots come out as on this terminal
492    /// (`Theme::series_colors`); `None` before the app says, read as [`COLOR_MAX`].
493    pub series_cap: Option<usize>,
494    /// The view's sort as the footer writes it (`time ▲`), which first and last
495    /// read the rows in; `None` for the order they were read in.
496    pub row_order: Option<String>,
497    /// The cursor column's type when `c` chose the chart (`f64`), shown under Type
498    /// until the type is changed.
499    pub suggested: Option<String>,
500    pub y_starts_at_zero: bool,
501    pub log_scale: bool,
502    pub show_legend: bool,
503    /// The grid at the major ticks, on the kinds with axes (`g`).
504    pub grid: bool,
505    /// Histogram: each bin's share of its group's rows rather than its count.
506    pub share: bool,
507    pub hist_bins: usize,
508    pub heatmap_bins: usize,
509    pub kde_bandwidth_factor: f64,
510    /// Histogram, Box and KDE: which values are drawn.
511    pub value_range: ValueRange,
512    pub bar_order: BarOrder,
513    /// Rows a chart that samples reads: up to this many, spread across the table.
514    /// None = every row. What the chart reads; the Rows row edits `rows_draft`.
515    pub row_limit: Option<usize>,
516    /// The sample size Sample returns to, kept while Every row is chosen.
517    pub sample_rows: usize,
518    /// The Rows row's pending change, if any.
519    pub rows_draft: Option<RowsDraft>,
520    /// The view's row count when the table knows it, for Every row's cost.
521    pub view_rows: Option<usize>,
522    /// The view is a sample, held in memory: the chart reads all of it, and has no
523    /// Rows row of its own.
524    pub view_sampled: bool,
525    /// A saved view put its chart here: `c` brings it back, whichever column the
526    /// cursor is on.
527    pub restored: bool,
528    pub focus: ChartFocus,
529    /// The one Picker, open for the focused row; None while the form has the keys.
530    pub picker: Option<PickerState>,
531    pub picker_for: Option<PickerFor>,
532    /// Per item of the value picker: its rows, shown beside it.
533    pub picker_details: Vec<String>,
534    pub numeric_candidates: Vec<String>,
535    pub temporal_candidates: Vec<String>,
536    /// Dates and datetimes: the X columns a time bucket truncates.
537    pub bucketable_candidates: Vec<String>,
538    pub category_candidates: Vec<String>,
539    /// The Color column's values by rows, once a chart counted them.
540    pub color_counts: Option<ColorCounts>,
541    /// The dataset the choices were made on (`App::dataset_generation`), and the
542    /// column the table's cursor was on: `c` again from the same column on the
543    /// same dataset brings the chart back as it was left.
544    pub dataset: Option<u64>,
545    pub opened_on: Option<String>,
546    /// Each column's unit, from a delimited spec's unit row: the axis titles name
547    /// them. Set as the chart is drawn.
548    pub units: Vec<(String, String)>,
549    /// The plot has the keys rather than the panel (`x`): ←→ move the crosshair.
550    pub plot_focus: bool,
551    /// The x of the point the crosshair stands on, kept while the panel has the
552    /// keys so it comes back where it was.
553    pub cursor_x: Option<f64>,
554    /// Where the line or scatter plot was last drawn, for the crosshair and a
555    /// click; `None` when it has no points on screen.
556    pub plot: Option<crate::widgets::crosshair::PlotPlace>,
557}
558
559impl ChartModal {
560    pub fn new() -> Self {
561        Self::default()
562    }
563
564    /// Most series a chart draws: one per distinct series color, up to [`COLOR_MAX`].
565    /// A 16-color terminal draws fewer rather than two in one color.
566    pub fn series_max(&self) -> usize {
567        self.series_cap.unwrap_or(COLOR_MAX).clamp(1, COLOR_MAX)
568    }
569
570    /// An axis title for `column`: its name, and its unit when it has one.
571    pub fn axis_title(&self, column: &str) -> String {
572        match self.units.iter().find(|(name, _)| name == column) {
573            Some((_, unit)) => format!("{column} ({unit})"),
574            None => column.to_string(),
575        }
576    }
577
578    pub fn mark(&self) -> Mark {
579        self.spec.mark
580    }
581
582    pub fn x(&self) -> Option<&String> {
583        self.spec.encoding.x.field.as_ref()
584    }
585
586    pub fn y(&self) -> &[String] {
587        &self.spec.encoding.y.field
588    }
589
590    pub fn aggregate(&self) -> Aggregate {
591        self.spec.encoding.y.aggregate
592    }
593
594    pub fn color(&self) -> Option<&String> {
595        self.spec.encoding.color.field.as_ref()
596    }
597
598    /// Open the chart view. From the column it was left on, on the same dataset,
599    /// the chart comes back as it was, less any column the view no longer has.
600    /// Otherwise the chart is chosen from the cursor column's type (`cursor`): a
601    /// histogram of a number, a bar of a category's counts, a line over a date.
602    pub fn open(
603        &mut self,
604        columns: ChartColumns<'_>,
605        cursor: Option<(&str, &DataType)>,
606        default_row_limit: Option<usize>,
607        grid: bool,
608        dataset: u64,
609    ) {
610        self.active = true;
611        self.close_picker();
612        self.temporal_candidates = columns.datetime.to_vec();
613        self.bucketable_candidates = columns.bucketable.to_vec();
614        self.numeric_candidates = columns.numeric.to_vec();
615        self.category_candidates = columns.category.to_vec();
616        self.plot_focus = false;
617        self.rows_draft = None;
618        let opened_on = cursor.map(|(name, _)| name.to_string());
619        let restored = std::mem::take(&mut self.restored);
620        if self.dataset == Some(dataset) && (self.opened_on == opened_on || restored) {
621            self.keep_existing_choices();
622            self.settle();
623            self.focus = ChartFocus::Type;
624            return;
625        }
626        self.dataset = Some(dataset);
627        self.opened_on = opened_on;
628        self.cursor_x = None;
629        self.color_counts = None;
630        self.spec = ChartSpec::default();
631        self.suggested = None;
632        self.y_starts_at_zero = false;
633        self.log_scale = false;
634        self.show_legend = true;
635        self.grid = grid;
636        self.share = false;
637        self.value_range = ValueRange::All;
638        self.row_limit = default_row_limit.and_then(|n| {
639            if n == 0 {
640                None
641            } else {
642                Some(n.clamp(1, CHART_ROW_LIMIT_MAX))
643            }
644        });
645        self.sample_rows = self
646            .row_limit
647            .unwrap_or(crate::config::DEFAULT_CHART_ROW_LIMIT);
648        self.hist_bins = HISTOGRAM_DEFAULT_BINS;
649        self.kde_bandwidth_factor = 1.0;
650        self.heatmap_bins = HEATMAP_DEFAULT_BINS;
651        self.bar_order = BarOrder::Value;
652        if let Some((name, dtype)) = cursor {
653            self.suggest(name, dtype);
654        }
655        self.focus = ChartFocus::Type;
656    }
657
658    /// Put a saved view's chart in place for `dataset`: the next `c` draws it.
659    pub fn restore(&mut self, saved: &crate::view::SavedChart, dataset: u64) {
660        self.spec = saved.spec.clone();
661        self.hist_bins = saved.histogram_bins.max(1);
662        self.heatmap_bins = saved.heatmap_bins.max(1);
663        self.kde_bandwidth_factor = saved.bandwidth;
664        self.value_range = saved.range;
665        self.bar_order = saved.bar_order;
666        self.share = saved.share;
667        self.y_starts_at_zero = saved.y_starts_at_zero;
668        self.log_scale = saved.log_scale;
669        self.show_legend = saved.legend;
670        self.grid = saved.grid;
671        self.row_limit = saved.rows;
672        if let Some(rows) = saved.rows {
673            self.sample_rows = rows;
674        }
675        self.suggested = None;
676        self.color_counts = None;
677        self.cursor_x = None;
678        self.dataset = Some(dataset);
679        self.restored = true;
680    }
681
682    /// The chart as a view keeps it, with `seed`, the one its own sample is drawn
683    /// with, and `export`, how it was last exported.
684    pub fn saved(
685        &self,
686        seed: u64,
687        export: Option<crate::view::SavedChartExport>,
688    ) -> crate::view::SavedChart {
689        crate::view::SavedChart {
690            spec: self.spec.clone(),
691            histogram_bins: self.hist_bins,
692            heatmap_bins: self.heatmap_bins,
693            bandwidth: self.kde_bandwidth_factor,
694            range: self.value_range,
695            bar_order: self.bar_order,
696            share: self.share,
697            y_starts_at_zero: self.y_starts_at_zero,
698            log_scale: self.log_scale,
699            legend: self.show_legend,
700            grid: self.grid,
701            rows: if self.view_sampled {
702                None
703            } else {
704                self.row_limit
705            },
706            seed: (!self.view_sampled && self.row_limit.is_some()).then_some(seed),
707            export,
708        }
709    }
710
711    /// Show Me: the chart a column's type suggests. A number: its histogram. A
712    /// category: a bar of its counts. A date or time: a line over it, of the
713    /// first numeric column, with Y left to pick when there is none.
714    fn suggest(&mut self, name: &str, dtype: &DataType) {
715        let name = name.to_string();
716        let encoding = &mut self.spec.encoding;
717        if self.temporal_candidates.contains(&name) {
718            self.spec.mark = Mark::Line;
719            encoding.x.field = Some(name);
720            if let Some(y) = self.numeric_candidates.first() {
721                encoding.y.field = vec![y.clone()];
722            }
723        } else if dtype.is_float() || (dtype.is_numeric() && !dtype.is_integer()) {
724            self.spec.mark = Mark::Histogram;
725            encoding.x.field = Some(name);
726        } else if self.category_candidates.contains(&name) && !dtype.is_integer() {
727            self.spec.mark = Mark::Bar;
728            encoding.x.field = Some(name);
729            encoding.y.aggregate = Aggregate::Count;
730        } else if self.numeric_candidates.contains(&name) {
731            // An integer: a measure more often than a code.
732            self.spec.mark = Mark::Histogram;
733            encoding.x.field = Some(name);
734        } else {
735            return;
736        }
737        self.suggested = Some(crate::widgets::datatable::dtype_label(dtype));
738    }
739
740    /// Close the chart view. The choices stay for the next open on this dataset.
741    pub fn close(&mut self) {
742        self.active = false;
743        self.close_picker();
744        self.plot_focus = false;
745        self.rows_draft = None;
746    }
747
748    pub fn close_picker(&mut self) {
749        self.picker = None;
750        self.picker_for = None;
751        self.picker_details.clear();
752    }
753
754    /// Whether the crosshair can take the keys: a line or scatter plot with points
755    /// on screen.
756    pub fn has_crosshair(&self) -> bool {
757        self.spec.mark.is_xy() && self.plot.is_some()
758    }
759
760    /// Drop the choices whose columns the view no longer offers.
761    fn keep_existing_choices(&mut self) {
762        let mut all: Vec<&String> = self.numeric_candidates.iter().collect();
763        all.extend(self.temporal_candidates.iter());
764        all.extend(self.category_candidates.iter());
765        let has = |c: &String| all.contains(&c);
766        let encoding = &mut self.spec.encoding;
767        if encoding.x.field.as_ref().is_some_and(|c| !has(c)) {
768            encoding.x.field = None;
769        }
770        encoding.y.field.retain(|c| has(c));
771        if encoding.color.field.as_ref().is_some_and(|c| !has(c)) {
772            encoding.color.field = None;
773            encoding.color.values.clear();
774        }
775    }
776
777    // ----- What applies -----
778
779    fn is_temporal(&self, column: &str) -> bool {
780        self.temporal_candidates.iter().any(|c| c == column)
781    }
782
783    /// Whether X is a date or a time, which a line can bucket.
784    pub fn x_is_temporal(&self) -> bool {
785        self.x().is_some_and(|x| self.is_temporal(x))
786    }
787
788    /// Whether X is a date or a datetime, which a time bucket truncates; a time of
789    /// day is not.
790    pub fn x_is_bucketable(&self) -> bool {
791        self.x()
792            .is_some_and(|x| self.bucketable_candidates.iter().any(|c| c == x))
793    }
794
795    /// The columns a shelf takes on the chart on screen.
796    fn pool(&self, shelf: PickerFor) -> Vec<String> {
797        let mark = self.spec.mark;
798        let x = self.x();
799        match shelf {
800            PickerFor::X => match mark {
801                Mark::Line | Mark::Scatter => {
802                    let mut out = self.temporal_candidates.clone();
803                    for c in &self.numeric_candidates {
804                        if !out.contains(c) {
805                            out.push(c.clone());
806                        }
807                    }
808                    out
809                }
810                Mark::Bar | Mark::Box => self.category_candidates.clone(),
811                Mark::Histogram | Mark::Kde | Mark::Heatmap => self.numeric_candidates.clone(),
812            },
813            // A distinct count takes any column; the rest a number.
814            PickerFor::Y
815                if self.spec.encoding.y.aggregate.takes_any_y() && self.takes_aggregate() =>
816            {
817                let mut all = self.numeric_candidates.clone();
818                for c in self
819                    .temporal_candidates
820                    .iter()
821                    .chain(&self.category_candidates)
822                {
823                    if !all.contains(c) {
824                        all.push(c.clone());
825                    }
826                }
827                all.retain(|c| Some(c) != x);
828                all
829            }
830            // The X column against itself is only a diagonal.
831            PickerFor::Y => self
832                .numeric_candidates
833                .iter()
834                .filter(|c| Some(*c) != x || mark == Mark::Box)
835                .cloned()
836                .collect(),
837            PickerFor::Color => self
838                .category_candidates
839                .iter()
840                .filter(|c| Some(*c) != x)
841                .cloned()
842                .collect(),
843            PickerFor::ColorValues => Vec::new(),
844        }
845    }
846
847    /// Whether the Y shelf takes several columns: a line or scatter chart draws one
848    /// series each.
849    pub fn y_is_multi(&self) -> bool {
850        self.spec.mark.is_xy()
851    }
852
853    /// How the Y shelf is used on the chart on screen.
854    pub fn y_use(&self) -> ShelfUse {
855        match self.spec.mark {
856            Mark::Kde => ShelfUse::Dimmed("density"),
857            _ => ShelfUse::Used,
858        }
859    }
860
861    /// How the Color shelf is used on the chart on screen.
862    pub fn color_use(&self) -> ShelfUse {
863        Self::color_use_in(&self.spec)
864    }
865
866    /// How `spec` uses its Color shelf.
867    pub fn color_use_in(spec: &ChartSpec) -> ShelfUse {
868        let y = &spec.encoding.y;
869        match spec.mark {
870            Mark::Box => ShelfUse::Dimmed("same as X"),
871            Mark::Heatmap => ShelfUse::Dimmed("density"),
872            Mark::Line | Mark::Scatter if y.field.len() > 1 => ShelfUse::Dimmed("one per Y column"),
873            Mark::Bar if y.aggregate == Aggregate::None => ShelfUse::Dimmed("needs an aggregate"),
874            _ => ShelfUse::Used,
875        }
876    }
877
878    /// Whether `spec`'s color splits its chart into series.
879    pub fn colored_in(spec: &ChartSpec) -> bool {
880        spec.encoding.color.field.is_some() && Self::color_use_in(spec) == ShelfUse::Used
881    }
882
883    /// Whether the chart takes an aggregate: a line, a scatter, a bar.
884    pub fn takes_aggregate(&self) -> bool {
885        matches!(self.spec.mark, Mark::Line | Mark::Scatter | Mark::Bar)
886    }
887
888    /// Whether the chart reads every row (an aggregate over the whole view) rather
889    /// than a sample.
890    pub fn aggregates(&self) -> bool {
891        self.takes_aggregate() && self.aggregate() != Aggregate::None
892    }
893
894    /// Whether the color splits the chart into series.
895    pub fn colored(&self) -> bool {
896        Self::colored_in(&self.spec)
897    }
898
899    /// Whether a colored `spec` draws Other: as set, or on for a scatter, whose
900    /// cloud keeps its shape with every point drawn.
901    pub fn shows_other_in(spec: &ChartSpec) -> bool {
902        Self::colored_in(spec)
903            && spec
904                .encoding
905                .color
906                .other
907                .unwrap_or(spec.mark == Mark::Scatter)
908    }
909
910    pub fn shows_other(&self) -> bool {
911        Self::shows_other_in(&self.spec)
912    }
913
914    /// The panel's rows for the chart on screen, in Tab order. A dimmed shelf is
915    /// left out: focus passes over it.
916    pub fn row_order(&self) -> Vec<ChartFocus> {
917        use ChartFocus::*;
918        let mark = self.spec.mark;
919        let mut rows = vec![Type, X];
920        match mark {
921            Mark::Line | Mark::Scatter if self.x_is_bucketable() => rows.push(TimeUnit),
922            Mark::Bar => rows.push(Order),
923            Mark::Histogram => rows.push(Bins),
924            _ => {}
925        }
926        if self.y_use() == ShelfUse::Used {
927            rows.push(Y);
928        }
929        if self.takes_aggregate() {
930            rows.push(Aggregate);
931            if self.aggregate() == self::Aggregate::Quantile {
932                rows.push(Quantile);
933            }
934        }
935        if self.color_use() == ShelfUse::Used {
936            rows.push(Color);
937            if self.color().is_some() {
938                rows.push(ColorValues);
939            }
940        }
941        // Options.
942        match mark {
943            Mark::Line | Mark::Scatter => {
944                if self.aggregates() && self.aggregate().runs_cumulative() {
945                    rows.push(Cumulative);
946                }
947                rows.extend([YStartsAtZero, LogScale, ShowLegend, Grid]);
948            }
949            Mark::Bar => rows.push(ShowLegend),
950            Mark::Histogram => rows.extend([Range, ShowLegend, Grid]),
951            Mark::Kde => rows.extend([Bandwidth, Range, ShowLegend, Grid]),
952            Mark::Box => rows.extend([Range, Grid]),
953            Mark::Heatmap => rows.push(Bins),
954        }
955        if !self.aggregates() && !self.view_sampled {
956            rows.push(LimitRows);
957        }
958        rows
959    }
960
961    /// Whether the chart on screen has axes to draw a grid on: the heatmap's cells
962    /// and the bar chart's rows have none.
963    pub fn has_grid(&self) -> bool {
964        self.row_order().contains(&ChartFocus::Grid)
965    }
966
967    // ----- Type -----
968
969    /// Switch the chart type. The shelves carry over where the new type takes
970    /// what they hold; a column it cannot take moves to where it can, or goes.
971    pub fn set_mark(&mut self, mark: Mark) {
972        if mark == self.spec.mark {
973            return;
974        }
975        let old = self.spec.mark;
976        self.spec.mark = mark;
977        self.suggested = None;
978        self.close_picker();
979        if !mark.is_xy() {
980            self.plot_focus = false;
981        }
982        let encoding = &mut self.spec.encoding;
983        // A number charted against something becomes the value of a one-column
984        // chart, and the other way round.
985        let numeric = |c: &String| self.numeric_candidates.contains(c);
986        let category = |c: &String| self.category_candidates.contains(c);
987        match mark {
988            Mark::Histogram | Mark::Kde => {
989                if !encoding.x.field.as_ref().is_some_and(numeric) {
990                    encoding.x.field = encoding.y.field.first().cloned();
991                }
992            }
993            Mark::Box => {
994                if encoding.y.field.is_empty()
995                    && let Some(x) = encoding.x.field.clone().filter(numeric)
996                {
997                    encoding.y.field = vec![x];
998                    encoding.x.field = None;
999                }
1000                if !encoding.x.field.as_ref().is_some_and(category) {
1001                    encoding.x.field = None;
1002                }
1003            }
1004            Mark::Bar => {
1005                if !encoding.x.field.as_ref().is_some_and(category) {
1006                    encoding.x.field = encoding.color.field.take();
1007                    encoding.color.values.clear();
1008                }
1009                if encoding.y.field.is_empty() && encoding.y.aggregate == Aggregate::None {
1010                    encoding.y.aggregate = Aggregate::Count;
1011                }
1012            }
1013            Mark::Heatmap => {
1014                if !encoding.x.field.as_ref().is_some_and(numeric) {
1015                    encoding.x.field = None;
1016                }
1017            }
1018            Mark::Line | Mark::Scatter => {
1019                if matches!(old, Mark::Histogram | Mark::Kde | Mark::Box)
1020                    && encoding.y.field.is_empty()
1021                    && let Some(x) = encoding.x.field.take()
1022                {
1023                    encoding.y.field = vec![x];
1024                }
1025            }
1026        }
1027        self.settle();
1028        if !self.row_order().contains(&self.focus) {
1029            self.focus = ChartFocus::Type;
1030        }
1031    }
1032
1033    /// Keep the spec within what the chart on screen takes.
1034    fn settle(&mut self) {
1035        let x_pool = self.pool(PickerFor::X);
1036        let y_pool = self.pool(PickerFor::Y);
1037        let color_pool = self.pool(PickerFor::Color);
1038        let mark = self.spec.mark;
1039        let x_temporal = self.x_is_bucketable();
1040        let encoding = &mut self.spec.encoding;
1041        if encoding
1042            .x
1043            .field
1044            .as_ref()
1045            .is_some_and(|x| !x_pool.contains(x))
1046        {
1047            encoding.x.field = None;
1048        }
1049        encoding.y.field.retain(|y| y_pool.contains(y));
1050        if !mark.is_xy() {
1051            encoding.y.field.truncate(1);
1052        }
1053        // A bucket holds many rows: without an aggregate there is nothing to bucket.
1054        if !x_temporal || !mark.is_xy() || encoding.y.aggregate == Aggregate::None {
1055            encoding.x.time_unit = TimeUnit::None;
1056        }
1057        if encoding
1058            .color
1059            .field
1060            .as_ref()
1061            .is_some_and(|c| !color_pool.contains(c))
1062        {
1063            encoding.color.field = None;
1064            encoding.color.values.clear();
1065        }
1066        if !matches!(mark, Mark::Line | Mark::Scatter | Mark::Bar) {
1067            encoding.y.aggregate = Aggregate::None;
1068        }
1069        if !encoding.y.aggregate.runs_cumulative() || !mark.is_xy() {
1070            encoding.y.cumulative = Cumulative::Off;
1071        }
1072    }
1073
1074    pub fn step_mark(&mut self, delta: i8) {
1075        let mark = crate::form::step_value(&Mark::ALL, self.spec.mark, delta);
1076        self.set_mark(mark);
1077    }
1078
1079    // ----- Picker -----
1080
1081    /// Which picker the focused row opens, if it opens one.
1082    pub fn picker_for(&self, focus: ChartFocus) -> Option<PickerFor> {
1083        match focus {
1084            ChartFocus::X => Some(PickerFor::X),
1085            ChartFocus::Y if self.spec.mark != Mark::Histogram => Some(PickerFor::Y),
1086            ChartFocus::Color => Some(PickerFor::Color),
1087            ChartFocus::ColorValues => Some(PickerFor::ColorValues),
1088            _ => None,
1089        }
1090    }
1091
1092    /// Whether the focused row's picker toggles several items.
1093    pub fn picker_is_multi(&self, which: PickerFor) -> bool {
1094        match which {
1095            PickerFor::Y => self.y_is_multi(),
1096            PickerFor::ColorValues => true,
1097            PickerFor::X | PickerFor::Color => false,
1098        }
1099    }
1100
1101    /// Whether a shelf's picker offers "none" first.
1102    fn offers_none(&self, which: PickerFor) -> bool {
1103        which == PickerFor::Color || (which == PickerFor::X && self.spec.mark == Mark::Box)
1104    }
1105
1106    /// What a picker offers, and what goes beside each item.
1107    fn picker_items(&self, which: PickerFor) -> (Vec<String>, Vec<String>) {
1108        if which == PickerFor::ColorValues {
1109            let null = crate::glyphs::get().null;
1110            let Some(counts) = &self.color_counts else {
1111                return (Vec::new(), Vec::new());
1112            };
1113            return counts
1114                .values
1115                .iter()
1116                .map(|(value, rows)| {
1117                    (
1118                        value.clone().unwrap_or_else(|| null.to_string()),
1119                        crate::numfmt::group_chrome(*rows as usize),
1120                    )
1121                })
1122                .unzip();
1123        }
1124        let mut items = Vec::new();
1125        if self.offers_none(which) {
1126            items.push(NONE_ITEM.to_string());
1127        }
1128        items.extend(self.pool(which));
1129        (items, Vec::new())
1130    }
1131
1132    /// The value at `i` of the value picker.
1133    fn color_value_at(&self, i: usize) -> Option<Option<String>> {
1134        self.color_counts
1135            .as_ref()?
1136            .values
1137            .get(i)
1138            .map(|(value, _)| value.clone())
1139    }
1140
1141    /// Where the row's current choice sits among `items`.
1142    fn current_index(&self, which: PickerFor, items: &[String]) -> Option<usize> {
1143        let encoding = &self.spec.encoding;
1144        let current = match which {
1145            PickerFor::X => encoding.x.field.as_deref(),
1146            PickerFor::Y => encoding.y.field.first().map(String::as_str),
1147            PickerFor::Color => encoding.color.field.as_deref(),
1148            PickerFor::ColorValues => return Some(0),
1149        };
1150        match current {
1151            Some(current) => items.iter().position(|i| i == current),
1152            None if self.offers_none(which) => Some(0),
1153            None => None,
1154        }
1155    }
1156
1157    /// Open the Picker for the focused row, cursor on the current choice. The value
1158    /// picker opens only once the chart has counted the values.
1159    pub fn open_picker(&mut self) {
1160        let Some(which) = self.picker_for(self.focus) else {
1161            return;
1162        };
1163        if which == PickerFor::ColorValues && !self.has_color_counts() {
1164            return;
1165        }
1166        let (items, details) = self.picker_items(which);
1167        let mut state = PickerState::new(items.clone());
1168        if let Some(i) = self.current_index(which, &items) {
1169            state.select_original(i);
1170        }
1171        self.picker = Some(state);
1172        self.picker_for = Some(which);
1173        self.picker_details = details;
1174    }
1175
1176    /// Whether the value picker has values to list: the chart has counted the
1177    /// Color column on screen.
1178    pub fn has_color_counts(&self) -> bool {
1179        self.color_counts
1180            .as_ref()
1181            .is_some_and(|c| Some(&c.column) == self.color())
1182    }
1183
1184    /// ←/→ on a pick-one column row: the next or previous column, chosen at once.
1185    pub fn step_picker_row(&mut self, delta: i8) {
1186        let Some(which) = self.picker_for(self.focus) else {
1187            return;
1188        };
1189        if self.picker_is_multi(which) {
1190            return;
1191        }
1192        let (items, _) = self.picker_items(which);
1193        if items.is_empty() {
1194            return;
1195        }
1196        let next = match self.current_index(which, &items) {
1197            Some(at) => crate::form::step_index(at, items.len(), delta),
1198            None if delta < 0 => items.len() - 1,
1199            None => 0,
1200        };
1201        self.choose(which, &items[next], next);
1202    }
1203
1204    /// The original index under the open Picker's cursor.
1205    fn picker_cursor(&self) -> Option<usize> {
1206        self.picker.as_ref()?.selected_original()
1207    }
1208
1209    /// Enter in the Picker: a pick-one row takes the cursor's item; a row of
1210    /// several keeps its toggles, or adopts the cursor's item when none are
1211    /// toggled, so Enter on a fresh list still charts something. The Picker closes.
1212    pub fn picker_choose(&mut self) {
1213        let (Some(which), Some(i)) = (self.picker_for, self.picker_cursor()) else {
1214            self.close_picker();
1215            return;
1216        };
1217        let item = self.picker.as_ref().and_then(|p| p.items().get(i).cloned());
1218        self.close_picker();
1219        let Some(item) = item else {
1220            return;
1221        };
1222        match which {
1223            PickerFor::Y if self.y_is_multi() => {
1224                if self.spec.encoding.y.field.is_empty() {
1225                    self.spec.encoding.y.field.push(item);
1226                }
1227            }
1228            PickerFor::ColorValues => {
1229                if self.spec.encoding.color.values.is_empty()
1230                    && let Some(value) = self.color_value_at(i)
1231                {
1232                    self.spec.encoding.color.values.push(value);
1233                }
1234            }
1235            _ => self.choose(which, &item, i),
1236        }
1237        self.settle();
1238    }
1239
1240    /// Take `item` (at `i` in the row's items) as the row's one choice.
1241    fn choose(&mut self, which: PickerFor, item: &str, i: usize) {
1242        let none = self.offers_none(which) && i == 0;
1243        let encoding = &mut self.spec.encoding;
1244        match which {
1245            PickerFor::X => {
1246                encoding.x.field = (!none).then(|| item.to_string());
1247                // A series cannot be the X axis too, nor a color.
1248                encoding.y.field.retain(|y| y != item);
1249                if encoding.color.field.as_deref() == Some(item) {
1250                    encoding.color.field = None;
1251                    encoding.color.values.clear();
1252                }
1253                encoding.x.time_unit = TimeUnit::None;
1254            }
1255            PickerFor::Y => encoding.y.field = vec![item.to_string()],
1256            PickerFor::Color => {
1257                let field = (!none).then(|| item.to_string());
1258                if field != encoding.color.field {
1259                    encoding.color.values.clear();
1260                }
1261                encoding.color.field = field;
1262            }
1263            PickerFor::ColorValues => {}
1264        }
1265        self.settle();
1266        if !self.row_order().contains(&self.focus) {
1267            self.focus = ChartFocus::Type;
1268        }
1269    }
1270
1271    /// Space in a picker of several: flip the cursor's item in or out, up to the
1272    /// palette's colors.
1273    pub fn picker_toggle(&mut self) {
1274        let (Some(which), Some(i)) = (self.picker_for, self.picker_cursor()) else {
1275            return;
1276        };
1277        match which {
1278            PickerFor::Y if self.y_is_multi() => {
1279                let Some(item) = self.picker.as_ref().and_then(|p| p.items().get(i).cloned())
1280                else {
1281                    return;
1282                };
1283                let most = Y_SERIES_MAX.min(self.series_max());
1284                let field = &mut self.spec.encoding.y.field;
1285                if let Some(pos) = field.iter().position(|c| *c == item) {
1286                    field.remove(pos);
1287                } else if field.len() < most {
1288                    field.push(item);
1289                }
1290            }
1291            PickerFor::ColorValues => {
1292                let Some(value) = self.color_value_at(i) else {
1293                    return;
1294                };
1295                let most = self.series_max();
1296                let values = &mut self.spec.encoding.color.values;
1297                if let Some(pos) = values.iter().position(|v| *v == value) {
1298                    values.remove(pos);
1299                } else if values.len() < most {
1300                    values.push(value);
1301                }
1302            }
1303            _ => {}
1304        }
1305    }
1306
1307    /// Whether the item at `i` of the open picker is toggled on.
1308    pub fn is_marked(&self, i: usize) -> bool {
1309        match self.picker_for {
1310            Some(PickerFor::Y) => self
1311                .picker
1312                .as_ref()
1313                .and_then(|p| p.items().get(i))
1314                .is_some_and(|item| self.y().contains(item)),
1315            Some(PickerFor::ColorValues) => self
1316                .color_value_at(i)
1317                .is_some_and(|v| self.spec.encoding.color.values.contains(&v)),
1318            _ => false,
1319        }
1320    }
1321
1322    /// Whether the open picker toggles several items.
1323    pub fn picker_multi(&self) -> bool {
1324        self.picker_for.is_some_and(|w| self.picker_is_multi(w))
1325    }
1326
1327    // ----- What is charted right now -----
1328
1329    /// The spec to chart: the one chosen, with an open Picker's cursor previewed on
1330    /// the Y shelf (and on X, except for a line or scatter, whose X re-reads
1331    /// everything).
1332    pub fn effective_spec(&self) -> ChartSpec {
1333        let mut spec = self.spec.clone();
1334        let (Some(which), Some(i)) = (self.picker_for, self.picker_cursor()) else {
1335            return spec;
1336        };
1337        let Some(item) = self.picker.as_ref().and_then(|p| p.items().get(i).cloned()) else {
1338            return spec;
1339        };
1340        match which {
1341            PickerFor::Y if self.y_is_multi() => {
1342                if !spec.encoding.y.field.contains(&item) {
1343                    spec.encoding.y.field.push(item);
1344                }
1345            }
1346            PickerFor::Y => spec.encoding.y.field = vec![item],
1347            PickerFor::X if !spec.mark.is_xy() => {
1348                spec.encoding.x.field = (!(self.offers_none(which) && i == 0)).then_some(item);
1349            }
1350            _ => {}
1351        }
1352        spec
1353    }
1354
1355    // ----- Stepping -----
1356
1357    /// ←/→ (and Space, on a choice) on the focused row.
1358    pub fn step(&mut self, focus: ChartFocus, delta: i8) {
1359        match focus {
1360            ChartFocus::Type => self.step_mark(delta),
1361            ChartFocus::TimeUnit => {
1362                let encoding = &mut self.spec.encoding;
1363                encoding.x.time_unit =
1364                    crate::form::step_value(&TimeUnit::ALL, encoding.x.time_unit, delta);
1365                // A bucket holds many rows, so it needs something to make of them.
1366                if encoding.x.time_unit != TimeUnit::None && encoding.y.aggregate == Aggregate::None
1367                {
1368                    encoding.y.aggregate = Aggregate::Mean;
1369                }
1370            }
1371            ChartFocus::Aggregate => {
1372                let was = self.aggregate();
1373                let bucketable = self.x_is_bucketable() && self.spec.mark.is_xy();
1374                let encoding = &mut self.spec.encoding;
1375                encoding.y.aggregate =
1376                    crate::form::step_value(&Aggregate::ALL, encoding.y.aggregate, delta);
1377                // A date X with a group per raw value is close to a group per row:
1378                // an aggregate starts by the day.
1379                if was == Aggregate::None
1380                    && bucketable
1381                    && encoding.y.aggregate != Aggregate::None
1382                    && encoding.x.time_unit == TimeUnit::None
1383                {
1384                    encoding.x.time_unit = TimeUnit::Day;
1385                }
1386                self.settle();
1387            }
1388            ChartFocus::Cumulative => {
1389                let y = &mut self.spec.encoding.y;
1390                y.cumulative = crate::form::step_value(&Cumulative::ALL, y.cumulative, delta);
1391            }
1392            ChartFocus::Quantile => {
1393                let y = &mut self.spec.encoding.y;
1394                y.percentile = Some(crate::form::step_value(&QUANTILES, y.quantile(), delta));
1395            }
1396            ChartFocus::Y if self.spec.mark == Mark::Histogram => self.share = !self.share,
1397            ChartFocus::Order => {
1398                self.bar_order = crate::form::step_value(&BarOrder::ALL, self.bar_order, delta);
1399            }
1400            ChartFocus::Range => {
1401                self.value_range =
1402                    crate::form::step_value(&ValueRange::ALL, self.value_range, delta);
1403            }
1404            ChartFocus::Bins => self.adjust_bins(delta.into()),
1405            ChartFocus::Bandwidth => {
1406                self.adjust_kde_bandwidth_factor(f64::from(delta) * KDE_BANDWIDTH_STEP)
1407            }
1408            ChartFocus::LimitRows => self.toggle_rows(),
1409            ChartFocus::YStartsAtZero => self.y_starts_at_zero = !self.y_starts_at_zero,
1410            ChartFocus::LogScale => self.log_scale = !self.log_scale,
1411            ChartFocus::ShowLegend => self.show_legend = !self.show_legend,
1412            ChartFocus::Grid => self.grid = !self.grid,
1413            // The values line: Space picks them, ←/→ turn Other on or off.
1414            ChartFocus::ColorValues => {
1415                self.spec.encoding.color.other = Some(!self.shows_other());
1416            }
1417            focus => {
1418                if self.picker_for(focus).is_some() {
1419                    self.step_picker_row(delta);
1420                }
1421            }
1422        }
1423        if !self.row_order().contains(&self.focus) {
1424            self.focus = ChartFocus::Type;
1425            self.leave_rows();
1426        }
1427    }
1428
1429    pub fn is_toggle_row(&self, focus: ChartFocus) -> bool {
1430        matches!(
1431            focus,
1432            ChartFocus::YStartsAtZero
1433                | ChartFocus::LogScale
1434                | ChartFocus::ShowLegend
1435                | ChartFocus::Grid
1436        )
1437    }
1438
1439    pub fn toggle_grid(&mut self) {
1440        self.grid = !self.grid;
1441    }
1442
1443    fn adjust_bins(&mut self, delta: i32) {
1444        if self.spec.mark == Mark::Heatmap {
1445            self.heatmap_bins = (self.heatmap_bins as i32 + delta)
1446                .clamp(HEATMAP_MIN_BINS as i32, HEATMAP_MAX_BINS as i32)
1447                as usize;
1448        } else {
1449            self.hist_bins = (self.hist_bins as i32 + delta)
1450                .clamp(HISTOGRAM_MIN_BINS as i32, HISTOGRAM_MAX_BINS as i32)
1451                as usize;
1452        }
1453    }
1454
1455    pub fn adjust_kde_bandwidth_factor(&mut self, delta: f64) {
1456        let next = (self.kde_bandwidth_factor + delta).clamp(KDE_BANDWIDTH_MIN, KDE_BANDWIDTH_MAX);
1457        self.kde_bandwidth_factor = (next * 10.0).round() / 10.0;
1458    }
1459
1460    /// `+`/`-`: step the focused number row.
1461    pub fn adjust_number_row(&mut self, delta: i32) {
1462        match self.focus {
1463            ChartFocus::Bins => self.adjust_bins(delta),
1464            ChartFocus::Bandwidth => {
1465                self.adjust_kde_bandwidth_factor(delta as f64 * KDE_BANDWIDTH_STEP)
1466            }
1467            _ => {}
1468        }
1469    }
1470
1471    // ----- The Rows row -----
1472
1473    /// What the Rows row shows: the pending change, or what the chart reads.
1474    pub fn rows_shown(&self) -> RowsDraft {
1475        self.rows_draft.clone().unwrap_or(RowsDraft {
1476            every: self.row_limit.is_none(),
1477            ..RowsDraft::default()
1478        })
1479    }
1480
1481    /// Whether the Rows row holds a change the chart has not read.
1482    pub fn rows_pending(&self) -> bool {
1483        self.rows_draft
1484            .as_ref()
1485            .is_some_and(|draft| draft.typed.is_some() || draft.every != self.row_limit.is_none())
1486    }
1487
1488    /// ←/→ or Space on Rows: Sample or Every row, pending until Enter. A size being
1489    /// typed is taken first; one that cannot be read stays to be fixed.
1490    pub fn toggle_rows(&mut self) {
1491        if !self.take_typed_size() {
1492            return;
1493        }
1494        let mut draft = self.rows_shown();
1495        draft.every = !draft.every;
1496        self.rows_draft = Some(draft);
1497    }
1498
1499    /// A key typed on Rows: part of a sample size (`50k`). Typing chooses Sample.
1500    pub fn type_rows(&mut self, c: char) {
1501        let mut draft = self.rows_shown();
1502        draft.every = false;
1503        draft.error = None;
1504        draft.typed.get_or_insert_with(String::new).push(c);
1505        self.rows_draft = Some(draft);
1506    }
1507
1508    /// Whether a size is being typed on Rows.
1509    pub fn typing_rows(&self) -> bool {
1510        self.rows_draft.as_ref().is_some_and(|d| d.typed.is_some())
1511    }
1512
1513    /// Backspace on Rows: one character of the typed size off.
1514    pub fn backspace_rows(&mut self) {
1515        if let Some(draft) = self.rows_draft.as_mut()
1516            && let Some(typed) = draft.typed.as_mut()
1517        {
1518            typed.pop();
1519            draft.error = None;
1520            if typed.is_empty() {
1521                draft.typed = None;
1522            }
1523        }
1524    }
1525
1526    /// Esc on Rows with a change pending: put back what the chart reads.
1527    pub fn discard_rows(&mut self) {
1528        self.rows_draft = None;
1529    }
1530
1531    /// Resolve a typed size into the draft's sample size. False, with the reason on
1532    /// the row, when it cannot be read.
1533    fn take_typed_size(&mut self) -> bool {
1534        let Some(draft) = self.rows_draft.as_mut() else {
1535            return true;
1536        };
1537        let Some(typed) = draft.typed.take() else {
1538            return true;
1539        };
1540        match crate::sampling::parse_size(&typed) {
1541            Ok(rows) => {
1542                let rows = rows.min(CHART_ROW_LIMIT_MAX);
1543                // A sample of at least every row is every row.
1544                if self.view_rows.is_some_and(|total| rows >= total) {
1545                    draft.every = true;
1546                } else {
1547                    draft.every = false;
1548                    self.sample_rows = rows;
1549                }
1550                true
1551            }
1552            Err(e) => {
1553                draft.error = Some(e.short());
1554                draft.typed = Some(typed);
1555                false
1556            }
1557        }
1558    }
1559
1560    /// Enter on Rows: read what it says. False when a typed size cannot be read,
1561    /// which stays on the row with why.
1562    pub fn commit_rows(&mut self) -> bool {
1563        if !self.take_typed_size() {
1564            return false;
1565        }
1566        if let Some(draft) = self.rows_draft.take() {
1567            self.row_limit = (!draft.every).then_some(self.sample_rows);
1568        }
1569        true
1570    }
1571
1572    /// Focus left the Rows row: what it says is read, and a size that cannot be
1573    /// is dropped.
1574    pub fn leave_rows(&mut self) {
1575        if !self.commit_rows() {
1576            self.rows_draft = None;
1577        }
1578    }
1579
1580    /// Whether the spec names everything its chart needs.
1581    pub fn is_complete(spec: &ChartSpec) -> bool {
1582        let encoding = &spec.encoding;
1583        let x = encoding.x.field.is_some();
1584        let y = !encoding.y.field.is_empty();
1585        match spec.mark {
1586            Mark::Line | Mark::Scatter => x && (y || encoding.y.aggregate == Aggregate::Count),
1587            Mark::Bar => x && (y || encoding.y.aggregate == Aggregate::Count),
1588            Mark::Histogram | Mark::Kde => x,
1589            Mark::Box => y,
1590            Mark::Heatmap => x && y,
1591        }
1592    }
1593
1594    pub fn can_export(&self) -> bool {
1595        Self::is_complete(&self.effective_spec())
1596    }
1597
1598    /// How the chart was made of the rows, as a phrase that stands alone: `mean by
1599    /// month, running sum, colored by carrier`; empty when there is nothing to say.
1600    /// The columns charted are named at their axes, not here.
1601    pub fn how(&self) -> String {
1602        let spec = self.effective_spec();
1603        let encoding = &spec.encoding;
1604        let x = encoding.x.field.clone().unwrap_or_default();
1605        let mut parts: Vec<String> = Vec::new();
1606        match spec.mark {
1607            Mark::Histogram => parts.push(if self.share {
1608                "share per bin".to_string()
1609            } else {
1610                "count per bin".to_string()
1611            }),
1612            // The y axis already says density.
1613            Mark::Kde => {}
1614            Mark::Box if !x.is_empty() => parts.push(format!("one box per {x}")),
1615            Mark::Box => {}
1616            Mark::Heatmap => parts.push("rows per cell".to_string()),
1617            Mark::Line | Mark::Scatter | Mark::Bar => {
1618                let aggregate = encoding.y.aggregate;
1619                let mut how = String::new();
1620                // Cumulative runs over the rows, not the aggregate.
1621                if aggregate != Aggregate::None && encoding.y.cumulative == Cumulative::Off {
1622                    how.push_str(&encoding.y.aggregate_name());
1623                    how.push(' ');
1624                }
1625                let unit = encoding.x.time_unit;
1626                if unit != TimeUnit::None {
1627                    how.push_str(&format!("by {}", unit.label()));
1628                } else if aggregate != Aggregate::None {
1629                    how.push_str(&format!("by {x}"));
1630                }
1631                if !how.trim().is_empty() {
1632                    parts.push(how.trim().to_string());
1633                }
1634                match encoding.y.cumulative {
1635                    Cumulative::Off => {}
1636                    Cumulative::Sum => parts.push("running sum".to_string()),
1637                    Cumulative::Compound => parts.push("compounded".to_string()),
1638                }
1639            }
1640        }
1641        if self.colored() {
1642            parts.push(format!("colored by {}", self.color().unwrap()));
1643        }
1644        parts.join(", ")
1645    }
1646}
1647
1648impl crate::form::Form for ChartModal {
1649    type Field = ChartFocus;
1650
1651    fn fields(&self) -> Vec<(ChartFocus, crate::form::FieldKind)> {
1652        use crate::form::FieldKind;
1653        self.row_order()
1654            .into_iter()
1655            .map(|row| {
1656                let kind = match self.picker_for(row) {
1657                    Some(which) => FieldKind::Picker {
1658                        multi: self.picker_is_multi(which),
1659                    },
1660                    None if self.is_toggle_row(row) => FieldKind::Checkbox,
1661                    // Type, the buckets, the aggregates and the numbers step.
1662                    None => FieldKind::Choice,
1663                };
1664                (row, kind)
1665            })
1666            .collect()
1667    }
1668
1669    fn focused(&self) -> ChartFocus {
1670        self.focus
1671    }
1672
1673    fn set_focused(&mut self, field: ChartFocus) {
1674        if field != ChartFocus::LimitRows {
1675            self.leave_rows();
1676        }
1677        self.focus = field;
1678    }
1679}
1680
1681#[cfg(test)]
1682mod tests {
1683    use super::*;
1684    use crate::form::Form;
1685
1686    fn s(v: &[&str]) -> Vec<String> {
1687        v.iter().map(|s| s.to_string()).collect()
1688    }
1689
1690    struct Cols {
1691        numeric: Vec<String>,
1692        datetime: Vec<String>,
1693        category: Vec<String>,
1694    }
1695
1696    fn cols() -> Cols {
1697        Cols {
1698            numeric: s(&["delay", "distance", "year"]),
1699            datetime: s(&["date"]),
1700            category: s(&["carrier", "origin", "year"]),
1701        }
1702    }
1703
1704    fn open_on(cursor: Option<(&str, &DataType)>) -> ChartModal {
1705        let c = cols();
1706        let mut modal = ChartModal::new();
1707        modal.open(
1708            ChartColumns {
1709                numeric: &c.numeric,
1710                datetime: &c.datetime,
1711                bucketable: &c.datetime,
1712                category: &c.category,
1713            },
1714            cursor,
1715            Some(10_000),
1716            false,
1717            1,
1718        );
1719        modal
1720    }
1721
1722    #[test]
1723    fn an_axis_title_names_the_unit() {
1724        let mut modal = ChartModal::default();
1725        assert_eq!(modal.axis_title("cht1"), "cht1");
1726        modal.units = vec![("cht1".to_string(), "deg F".to_string())];
1727        assert_eq!(modal.axis_title("cht1"), "cht1 (deg F)");
1728    }
1729
1730    /// `c` picks the chart from the cursor column's type and says so under Type.
1731    #[test]
1732    fn quick_chart_follows_the_cursor_column_type() {
1733        let modal = open_on(Some(("delay", &DataType::Float64)));
1734        assert_eq!(modal.mark(), Mark::Histogram);
1735        assert_eq!(modal.x().map(String::as_str), Some("delay"));
1736        assert_eq!(modal.suggested.as_deref(), Some("f64"));
1737
1738        let modal = open_on(Some(("carrier", &DataType::String)));
1739        assert_eq!(modal.mark(), Mark::Bar);
1740        assert_eq!(modal.aggregate(), Aggregate::Count);
1741        assert!(ChartModal::is_complete(&modal.spec), "counts need no Y");
1742
1743        let modal = open_on(Some(("date", &DataType::Date)));
1744        assert_eq!(modal.mark(), Mark::Line);
1745        assert_eq!(modal.x().map(String::as_str), Some("date"));
1746        assert_eq!(modal.y(), ["delay"], "the first numeric column");
1747
1748        let modal = open_on(Some(("year", &DataType::Int64)));
1749        assert_eq!(modal.mark(), Mark::Histogram, "an integer is a measure");
1750
1751        let modal = open_on(None);
1752        assert_eq!(modal.mark(), Mark::Line);
1753        assert!(modal.x().is_none() && modal.suggested.is_none());
1754    }
1755
1756    #[test]
1757    fn changing_the_type_clears_the_suggestion() {
1758        let mut modal = open_on(Some(("delay", &DataType::Float64)));
1759        modal.step(ChartFocus::Type, 1);
1760        assert_eq!(modal.mark(), Mark::Box);
1761        assert!(modal.suggested.is_none());
1762        assert_eq!(modal.y(), ["delay"], "the histogram's value is the box's");
1763        assert!(modal.x().is_none(), "a box's X is a category or none");
1764    }
1765
1766    /// Every type shows the same shelves; one it does not use is dimmed and
1767    /// focus passes over it.
1768    #[test]
1769    fn shelves_dim_by_type() {
1770        let mut modal = open_on(None);
1771        let rows = |m: &ChartModal| m.row_order();
1772        assert!(rows(&modal).contains(&ChartFocus::Color));
1773        modal.set_mark(Mark::Kde);
1774        assert_eq!(modal.y_use(), ShelfUse::Dimmed("density"));
1775        assert!(!rows(&modal).contains(&ChartFocus::Y));
1776        modal.set_mark(Mark::Box);
1777        assert_eq!(modal.color_use(), ShelfUse::Dimmed("same as X"));
1778        assert!(!rows(&modal).contains(&ChartFocus::Color));
1779        modal.set_mark(Mark::Heatmap);
1780        assert_eq!(modal.color_use(), ShelfUse::Dimmed("density"));
1781        modal.set_mark(Mark::Line);
1782        modal.spec.encoding.y.field = s(&["delay", "distance"]);
1783        assert_eq!(modal.color_use(), ShelfUse::Dimmed("one per Y column"));
1784        modal.set_mark(Mark::Bar);
1785        modal.spec.encoding.y.aggregate = Aggregate::None;
1786        assert_eq!(modal.color_use(), ShelfUse::Dimmed("needs an aggregate"));
1787    }
1788
1789    /// The panel's rows follow the type: a bucket under a date X, the order under
1790    /// a bar's X, the bins under a histogram's; the sample size only where the
1791    /// chart samples.
1792    #[test]
1793    fn rows_follow_the_type() {
1794        use ChartFocus::*;
1795        let mut modal = open_on(Some(("date", &DataType::Date)));
1796        assert_eq!(
1797            modal.fields().iter().map(|(f, _)| *f).collect::<Vec<_>>(),
1798            [
1799                Type,
1800                X,
1801                TimeUnit,
1802                Y,
1803                Aggregate,
1804                Color,
1805                YStartsAtZero,
1806                LogScale,
1807                ShowLegend,
1808                Grid,
1809                LimitRows
1810            ]
1811        );
1812        modal.step(TimeUnit, 1);
1813        assert_eq!(modal.spec.encoding.x.time_unit, super::TimeUnit::Day);
1814        assert_eq!(
1815            modal.aggregate(),
1816            super::Aggregate::Mean,
1817            "a bucket needs one"
1818        );
1819        let rows = modal.row_order();
1820        assert!(rows.contains(&Cumulative) && !rows.contains(&LimitRows));
1821
1822        modal.set_mark(Mark::Bar);
1823        assert_eq!(modal.spec.encoding.x.time_unit, super::TimeUnit::None);
1824        assert!(modal.row_order().contains(&Order));
1825        modal.set_mark(Mark::Histogram);
1826        assert!(modal.row_order().contains(&Bins));
1827    }
1828
1829    /// Step the aggregate row by one until it reads `to`.
1830    fn step_to(modal: &mut ChartModal, to: Aggregate, delta: i8) {
1831        for _ in 0..Aggregate::ALL.len() {
1832            if modal.aggregate() == to {
1833                return;
1834            }
1835            modal.step(ChartFocus::Aggregate, delta);
1836        }
1837        assert_eq!(modal.aggregate(), to);
1838    }
1839
1840    #[test]
1841    fn the_aggregate_steps_through_every_one() {
1842        let mut modal = open_on(Some(("date", &DataType::Date)));
1843        let labels: Vec<&str> = (0..Aggregate::ALL.len())
1844            .map(|_| {
1845                modal.step(ChartFocus::Aggregate, 1);
1846                modal.aggregate().label()
1847            })
1848            .collect();
1849        assert_eq!(
1850            labels,
1851            [
1852                "count", "distinct", "sum", "mean", "median", "stdev", "quantile", "min", "max",
1853                "first", "last", "none"
1854            ]
1855        );
1856        modal.step(ChartFocus::Aggregate, -1);
1857        assert_eq!(modal.aggregate(), Aggregate::Last);
1858    }
1859
1860    #[test]
1861    fn cumulative_goes_with_the_aggregate() {
1862        let mut modal = open_on(Some(("date", &DataType::Date)));
1863        modal.spec.encoding.y.aggregate = Aggregate::Sum;
1864        modal.step(ChartFocus::Cumulative, 1);
1865        assert_eq!(modal.spec.encoding.y.cumulative, Cumulative::Sum);
1866        modal.step(ChartFocus::Cumulative, 1);
1867        assert_eq!(modal.spec.encoding.y.cumulative, Cumulative::Compound);
1868        step_to(&mut modal, Aggregate::None, -1);
1869        assert_eq!(modal.spec.encoding.y.cumulative, Cumulative::Off);
1870    }
1871
1872    #[test]
1873    fn color_picks_a_category_then_values_by_count() {
1874        let mut modal = open_on(Some(("delay", &DataType::Float64)));
1875        modal.focus = ChartFocus::Color;
1876        modal.open_picker();
1877        let items = modal.picker.as_ref().unwrap().items().to_vec();
1878        assert_eq!(items, ["none", "carrier", "origin", "year"]);
1879        modal.picker.as_mut().unwrap().select_original(1);
1880        modal.picker_choose();
1881        assert_eq!(modal.color().map(String::as_str), Some("carrier"));
1882        assert!(modal.row_order().contains(&ChartFocus::ColorValues));
1883
1884        // Nothing to pick until a chart has counted the values.
1885        modal.focus = ChartFocus::ColorValues;
1886        modal.open_picker();
1887        assert!(modal.picker.is_none());
1888        modal.color_counts = Some(ColorCounts {
1889            column: "carrier".to_string(),
1890            values: (0..COLOR_MAX + 2)
1891                .map(|i| (Some(format!("C{i}")), 100 - i as u64))
1892                .chain([(None, 1)])
1893                .collect(),
1894        });
1895        modal.open_picker();
1896        assert_eq!(modal.picker_details[0], "100");
1897        for _ in 0..COLOR_MAX + 2 {
1898            modal.picker_toggle();
1899            modal.picker.as_mut().unwrap().move_down();
1900        }
1901        assert_eq!(modal.spec.encoding.color.values.len(), COLOR_MAX, "capped");
1902        assert!(modal.is_marked(0));
1903        modal.picker.as_mut().unwrap().select_original(0);
1904        modal.picker_toggle();
1905        assert!(!modal.is_marked(0));
1906        modal.picker_choose();
1907        assert_eq!(modal.spec.encoding.color.values.len(), COLOR_MAX - 1);
1908
1909        // Another column starts from the top values again.
1910        modal.focus = ChartFocus::Color;
1911        modal.step(ChartFocus::Color, 1);
1912        assert_eq!(modal.color().map(String::as_str), Some("origin"));
1913        assert!(modal.spec.encoding.color.values.is_empty());
1914        modal.step(ChartFocus::Color, -1);
1915        modal.step(ChartFocus::Color, -1);
1916        assert!(modal.color().is_none(), "none is the first choice");
1917    }
1918
1919    #[test]
1920    fn the_spec_reads_as_vega_lite() {
1921        let mut modal = open_on(Some(("date", &DataType::Date)));
1922        modal.step(ChartFocus::TimeUnit, 3);
1923        modal.spec.encoding.color.field = Some("carrier".to_string());
1924        assert_eq!(
1925            modal.spec.to_vega_lite(),
1926            serde_json::json!({
1927                "mark": "line",
1928                "encoding": {
1929                    "x": {"field": "date", "timeUnit": "yearmonth"},
1930                    "y": {"field": "delay", "aggregate": "mean"},
1931                    "color": {"field": "carrier"},
1932                }
1933            })
1934        );
1935        let saved = serde_json::to_value(&modal.spec).unwrap();
1936        assert_eq!(saved["encoding"]["x"]["timeUnit"], "month");
1937        assert_eq!(saved["encoding"]["y"]["aggregate"], "mean");
1938    }
1939
1940    #[test]
1941    fn the_title_says_how_the_rows_were_made() {
1942        let mut modal = open_on(Some(("date", &DataType::Date)));
1943        modal.step(ChartFocus::TimeUnit, 3);
1944        modal.spec.encoding.y.cumulative = Cumulative::Sum;
1945        modal.spec.encoding.color.field = Some("carrier".to_string());
1946        assert_eq!(modal.how(), "by month, running sum, colored by carrier");
1947    }
1948
1949    /// Distinct takes any Y, strings too, and reads `distinct by month`; leaving it
1950    /// for an aggregate of numbers lets a string Y go, as a sum never had one. It
1951    /// takes no cumulative.
1952    #[test]
1953    fn distinct_takes_any_y_and_no_cumulative() {
1954        let mut modal = open_on(Some(("date", &DataType::Date)));
1955        step_to(&mut modal, Aggregate::Distinct, 1);
1956        modal.focus = ChartFocus::Y;
1957        modal.open_picker();
1958        let items = modal.picker.as_ref().unwrap().items().to_vec();
1959        assert!(items.contains(&"carrier".to_string()), "{items:?}");
1960        modal.close_picker();
1961        modal.spec.encoding.y.field = vec!["carrier".to_string()];
1962        modal.step(ChartFocus::TimeUnit, 2);
1963        assert_eq!(modal.how(), "distinct by month");
1964        assert!(!modal.row_order().contains(&ChartFocus::Cumulative));
1965        modal.spec.encoding.y.cumulative = Cumulative::Sum;
1966        modal.step(ChartFocus::Aggregate, 0);
1967        assert_eq!(modal.spec.encoding.y.cumulative, Cumulative::Off);
1968        // Over to sum: a string is no number to add.
1969        modal.step(ChartFocus::Aggregate, 1);
1970        assert_eq!(modal.aggregate(), Aggregate::Sum);
1971        assert!(modal.spec.encoding.y.field.is_empty());
1972        assert_eq!(Aggregate::Distinct.vega_lite(90), Some("distinct"));
1973    }
1974
1975    /// A quantile's percentile is the line under Aggregate: ←/→ step it, the title
1976    /// says `p95 by month`, and Vega-Lite names the quartiles. Stdev, quantile,
1977    /// first and last take no cumulative.
1978    #[test]
1979    fn a_quantile_steps_its_percentile() {
1980        let mut modal = open_on(Some(("date", &DataType::Date)));
1981        step_to(&mut modal, Aggregate::Quantile, 1);
1982        modal.step(ChartFocus::TimeUnit, 2);
1983        assert_eq!(modal.spec.encoding.x.time_unit, TimeUnit::Month);
1984        let rows = modal.row_order();
1985        let at = rows
1986            .iter()
1987            .position(|r| *r == ChartFocus::Aggregate)
1988            .unwrap();
1989        assert_eq!(rows[at + 1], ChartFocus::Quantile);
1990        assert_eq!(modal.how(), "p90 by month");
1991        modal.step(ChartFocus::Quantile, 1);
1992        assert_eq!(modal.spec.encoding.y.quantile(), 95);
1993        assert_eq!(modal.how(), "p95 by month");
1994        modal.step(ChartFocus::Quantile, 1);
1995        modal.step(ChartFocus::Quantile, 1);
1996        assert_eq!(modal.spec.encoding.y.quantile(), 1, "wraps");
1997        assert_eq!(Aggregate::Quantile.vega_lite(25), Some("q1"));
1998        assert_eq!(Aggregate::Quantile.vega_lite(75), Some("q3"));
1999        assert_eq!(Aggregate::Quantile.vega_lite(90), None);
2000        assert_eq!(Aggregate::Last.vega_lite(90), None);
2001        for aggregate in [
2002            Aggregate::Stdev,
2003            Aggregate::Quantile,
2004            Aggregate::First,
2005            Aggregate::Last,
2006        ] {
2007            modal.spec.encoding.y.aggregate = aggregate;
2008            assert!(!modal.row_order().contains(&ChartFocus::Cumulative));
2009        }
2010        modal.spec.encoding.y.aggregate = Aggregate::Last;
2011        assert!(!modal.row_order().contains(&ChartFocus::Quantile));
2012        assert_eq!(modal.how(), "last by month");
2013    }
2014
2015    /// Each type's phrase reads alone and never names the Y column, which its axis
2016    /// names.
2017    #[test]
2018    fn the_how_names_no_y_column() {
2019        let mut modal = open_on(Some(("date", &DataType::Date)));
2020        modal.step(ChartFocus::TimeUnit, 3);
2021        assert_eq!(modal.how(), "mean by month");
2022        modal.set_mark(Mark::Scatter);
2023        modal.spec.encoding.x.time_unit = TimeUnit::None;
2024        modal.spec.encoding.y.aggregate = Aggregate::None;
2025        assert_eq!(modal.how(), "");
2026        modal.spec.encoding.color.field = Some("carrier".to_string());
2027        assert_eq!(modal.how(), "colored by carrier");
2028        modal.spec.encoding.color.field = None;
2029        modal.spec.encoding.y.aggregate = Aggregate::Mean;
2030        assert_eq!(modal.how(), "mean by date");
2031        modal.set_mark(Mark::Histogram);
2032        assert_eq!(modal.how(), "count per bin");
2033        modal.set_mark(Mark::Kde);
2034        assert_eq!(modal.how(), "");
2035        modal.set_mark(Mark::Heatmap);
2036        assert_eq!(modal.how(), "rows per cell");
2037        for mark in Mark::ALL {
2038            modal.set_mark(mark);
2039            assert!(!modal.how().contains("delay"), "{mark:?}: {}", modal.how());
2040        }
2041    }
2042
2043    /// Reopening from the same column on the same dataset keeps the chart; another
2044    /// column suggests again.
2045    #[test]
2046    fn reopening_from_the_same_column_keeps_the_chart() {
2047        let mut modal = open_on(Some(("delay", &DataType::Float64)));
2048        modal.set_mark(Mark::Kde);
2049        modal.toggle_grid();
2050        modal.close();
2051        let c = cols();
2052        let columns = ChartColumns {
2053            numeric: &c.numeric,
2054            datetime: &c.datetime,
2055            bucketable: &c.datetime,
2056            category: &c.category,
2057        };
2058        modal.open(columns, Some(("delay", &DataType::Float64)), None, false, 1);
2059        assert_eq!(modal.mark(), Mark::Kde);
2060        assert!(modal.grid);
2061        modal.open(
2062            columns,
2063            Some(("carrier", &DataType::String)),
2064            None,
2065            false,
2066            1,
2067        );
2068        assert_eq!(modal.mark(), Mark::Bar);
2069        assert!(!modal.grid);
2070    }
2071
2072    #[test]
2073    fn the_y_picker_leaves_out_x_and_caps_its_series() {
2074        let mut modal = open_on(Some(("date", &DataType::Date)));
2075        modal.spec.encoding.x.field = Some("delay".to_string());
2076        modal.spec.encoding.y.field.clear();
2077        modal.focus = ChartFocus::Y;
2078        modal.open_picker();
2079        assert_eq!(modal.picker.as_ref().unwrap().items(), ["distance", "year"]);
2080        assert!(modal.picker_multi());
2081        modal.picker_choose();
2082        assert_eq!(
2083            modal.y(),
2084            ["distance"],
2085            "Enter on a fresh list takes the cursor"
2086        );
2087    }
2088
2089    #[test]
2090    fn number_rows_route_by_the_type() {
2091        let mut modal = open_on(Some(("delay", &DataType::Float64)));
2092        modal.focus = ChartFocus::Bins;
2093        modal.adjust_number_row(1);
2094        assert_eq!(modal.hist_bins, HISTOGRAM_DEFAULT_BINS + 1);
2095        modal.set_mark(Mark::Heatmap);
2096        modal.focus = ChartFocus::Bins;
2097        modal.adjust_number_row(-1);
2098        assert_eq!(modal.heatmap_bins, HEATMAP_DEFAULT_BINS - 1);
2099    }
2100
2101    /// Rows: ←/→ switch between a sample and every row, a typed size edits the
2102    /// sample, and nothing reaches what the chart reads until Enter or focus leaves.
2103    #[test]
2104    fn rows_change_is_read_on_enter() {
2105        let mut modal = open_on(Some(("delay", &DataType::Float64)));
2106        modal.view_rows = Some(36_800_000);
2107        modal.focus = ChartFocus::LimitRows;
2108        assert_eq!(modal.row_limit, Some(10_000));
2109        modal.step(ChartFocus::LimitRows, 1);
2110        assert!(modal.rows_shown().every && modal.rows_pending());
2111        assert_eq!(modal.row_limit, Some(10_000), "pending until Enter");
2112        modal.step(ChartFocus::LimitRows, -1);
2113        assert!(!modal.rows_pending(), "back where it was");
2114        modal.step(ChartFocus::LimitRows, 1);
2115        assert!(modal.commit_rows());
2116        assert_eq!(modal.row_limit, None);
2117
2118        // Typing a size chooses Sample; Backspace edits; Esc puts it back.
2119        for c in "250kx".chars() {
2120            modal.type_rows(c);
2121        }
2122        modal.backspace_rows();
2123        assert_eq!(modal.rows_shown().typed.as_deref(), Some("250k"));
2124        assert!(!modal.rows_shown().every);
2125        modal.discard_rows();
2126        assert_eq!(modal.row_limit, None);
2127        assert!(modal.rows_shown().every);
2128
2129        for c in "250k".chars() {
2130            modal.type_rows(c);
2131        }
2132        assert!(modal.commit_rows());
2133        assert_eq!(modal.row_limit, Some(250_000));
2134        assert_eq!(modal.rows_draft, None);
2135
2136        // A size it cannot read stays on the row with why, and changes nothing.
2137        modal.type_rows('0');
2138        assert!(!modal.commit_rows());
2139        assert!(modal.rows_shown().error.is_some());
2140        assert_eq!(modal.row_limit, Some(250_000));
2141        modal.backspace_rows();
2142        modal.type_rows('2');
2143        modal.type_rows('m');
2144        // Leaving the row reads it.
2145        crate::form::Form::focus(&mut modal, ChartFocus::Type);
2146        assert_eq!(modal.row_limit, Some(2_000_000));
2147
2148        // At least every row is Every row; Sample remembers its size.
2149        modal.focus = ChartFocus::LimitRows;
2150        for c in "40m".chars() {
2151            modal.type_rows(c);
2152        }
2153        assert!(modal.commit_rows());
2154        assert_eq!(modal.row_limit, None);
2155        modal.step(ChartFocus::LimitRows, 1);
2156        assert!(modal.commit_rows());
2157        assert_eq!(modal.row_limit, Some(2_000_000));
2158    }
2159
2160    /// A bucket goes with its aggregate: none takes the bucket away. A time of day
2161    /// has no bucket to pick.
2162    #[test]
2163    fn a_bucket_needs_an_aggregate_and_a_date() {
2164        let mut modal = open_on(Some(("date", &DataType::Date)));
2165        modal.step(ChartFocus::TimeUnit, 3);
2166        assert_eq!(modal.spec.encoding.x.time_unit, TimeUnit::Month);
2167        modal.spec.encoding.y.aggregate = Aggregate::Sum;
2168        step_to(&mut modal, Aggregate::None, -1);
2169        assert_eq!(modal.spec.encoding.x.time_unit, TimeUnit::None);
2170
2171        let numeric = s(&["delay"]);
2172        let datetime = s(&["date", "clock"]);
2173        let mut modal = ChartModal::new();
2174        modal.open(
2175            ChartColumns {
2176                numeric: &numeric,
2177                datetime: &datetime,
2178                bucketable: &datetime[..1],
2179                category: &[],
2180            },
2181            Some(("clock", &DataType::Time)),
2182            None,
2183            false,
2184            1,
2185        );
2186        assert_eq!(modal.mark(), Mark::Line);
2187        assert!(!modal.row_order().contains(&ChartFocus::TimeUnit));
2188    }
2189
2190    /// An aggregate over a date X starts by the day: a group per raw value would be
2191    /// close to a group per row.
2192    #[test]
2193    fn an_aggregate_on_a_date_starts_by_the_day() {
2194        let mut modal = open_on(Some(("date", &DataType::Date)));
2195        assert_eq!(modal.spec.encoding.x.time_unit, TimeUnit::None);
2196        modal.step(ChartFocus::Aggregate, 1);
2197        assert_eq!(modal.spec.encoding.x.time_unit, TimeUnit::Day);
2198        step_to(&mut modal, Aggregate::Mean, 1);
2199        assert_eq!(modal.spec.encoding.x.time_unit, TimeUnit::Day);
2200        modal.step(ChartFocus::TimeUnit, -1);
2201        assert_eq!(
2202            modal.spec.encoding.x.time_unit,
2203            TimeUnit::None,
2204            "still the user's call"
2205        );
2206    }
2207
2208    /// An integer histogram turned into a box: its column is the box's value, and
2209    /// not also its category.
2210    #[test]
2211    fn a_box_from_an_integer_histogram_has_no_category() {
2212        let mut modal = open_on(Some(("year", &DataType::Int64)));
2213        modal.set_mark(Mark::Box);
2214        assert_eq!(modal.y(), ["year"]);
2215        assert!(modal.x().is_none());
2216    }
2217
2218    /// Whether color splits a chart reads the spec charted: a second Y previewed in
2219    /// the picker dims the color.
2220    #[test]
2221    fn color_use_reads_the_spec_charted() {
2222        let mut modal = open_on(Some(("date", &DataType::Date)));
2223        modal.spec.encoding.color.field = Some("carrier".to_string());
2224        assert!(modal.colored());
2225        let mut spec = modal.spec.clone();
2226        spec.encoding.y.field.push("distance".to_string());
2227        assert!(!ChartModal::colored_in(&spec));
2228    }
2229}