Skip to main content

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