1use crate::chart_data::{BarOrder, ValueRange};
14use crate::widgets::ui::PickerState;
15use polars::prelude::DataType;
16use serde::{Deserialize, Serialize};
17
18#[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 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 pub fn is_xy(self) -> bool {
68 matches!(self, Self::Line | Self::Scatter)
69 }
70}
71
72#[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 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 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#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
134#[serde(rename_all = "lowercase")]
135pub enum Aggregate {
136 #[default]
137 None,
138 Count,
139 Distinct,
141 Sum,
142 Mean,
143 Median,
144 Stdev,
147 Quantile,
149 Min,
150 Max,
151 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 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 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 pub fn follows_row_order(self) -> bool {
216 matches!(self, Self::First | Self::Last)
217 }
218
219 pub fn is_fractional(self) -> bool {
221 matches!(
222 self,
223 Self::Mean | Self::Median | Self::Stdev | Self::Quantile
224 )
225 }
226
227 pub fn takes_any_y(self) -> bool {
230 self == Self::Distinct
231 }
232
233 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 pub fn is_count(self) -> bool {
246 matches!(self, Self::Count | Self::Distinct)
247 }
248}
249
250#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
252#[serde(rename_all = "lowercase")]
253pub enum Cumulative {
254 #[default]
255 Off,
256 Sum,
258 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 pub field: Vec<String>,
287 pub aggregate: Aggregate,
288 pub cumulative: Cumulative,
289 #[serde(default, skip_serializing_if = "Option::is_none")]
291 pub percentile: Option<u8>,
292}
293
294pub const QUANTILES: [u8; 8] = [1, 5, 10, 25, 75, 90, 95, 99];
296
297pub const QUANTILE_DEFAULT: u8 = 90;
299
300impl YEncoding {
301 pub fn quantile(&self) -> u8 {
303 self.percentile.unwrap_or(QUANTILE_DEFAULT)
304 }
305
306 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 pub values: Vec<Option<String>>,
319 #[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#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
334pub struct ChartSpec {
335 pub mark: Mark,
336 pub encoding: Encoding,
337}
338
339impl ChartSpec {
340 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
371pub const COLOR_MAX: usize = 10;
375
376pub const Y_SERIES_MAX: usize = COLOR_MAX;
378
379pub const HISTOGRAM_DEFAULT_BINS: usize = 40;
381pub const HISTOGRAM_MIN_BINS: usize = 5;
382pub const HISTOGRAM_MAX_BINS: usize = 100;
383
384pub const HEATMAP_DEFAULT_BINS: usize = 20;
386pub const HEATMAP_MIN_BINS: usize = 5;
387pub const HEATMAP_MAX_BINS: usize = 60;
388
389pub const KDE_BANDWIDTH_MIN: f64 = 0.2;
391pub const KDE_BANDWIDTH_MAX: f64 = 5.0;
392pub const KDE_BANDWIDTH_STEP: f64 = 0.1;
393
394pub const CHART_ROW_LIMIT_MAX: usize = u32::MAX as usize;
396
397#[derive(Debug, Clone, Default, PartialEq, Eq)]
400pub struct RowsDraft {
401 pub every: bool,
403 pub typed: Option<String>,
405 pub error: Option<&'static str>,
407}
408
409#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
411pub enum ChartFocus {
412 #[default]
414 Type,
415 X,
417 TimeUnit,
419 Order,
421 Bins,
423 Y,
425 Aggregate,
427 Quantile,
429 Color,
431 ColorValues,
433 Cumulative,
435 Bandwidth,
436 Range,
437 YStartsAtZero,
438 LogScale,
439 ShowLegend,
440 Grid,
441 LimitRows,
443}
444
445#[derive(Debug, Clone, Copy, PartialEq, Eq)]
447pub enum ShelfUse {
448 Used,
450 Dimmed(&'static str),
452}
453
454#[derive(Clone, Copy, Default)]
456pub struct ChartColumns<'a> {
457 pub numeric: &'a [String],
458 pub datetime: &'a [String],
459 pub bucketable: &'a [String],
461 pub category: &'a [String],
464}
465
466#[derive(Debug, Clone, Default, PartialEq)]
469pub struct ColorCounts {
470 pub column: String,
471 pub values: Vec<(Option<String>, u64)>,
472}
473
474#[derive(Debug, Clone, Copy, PartialEq, Eq)]
476pub enum PickerFor {
477 X,
478 Y,
479 Color,
480 ColorValues,
481}
482
483pub const NONE_ITEM: &str = "none";
485
486#[derive(Default)]
488pub struct ChartModal {
489 pub active: bool,
490 pub spec: ChartSpec,
491 pub series_cap: Option<usize>,
494 pub row_order: Option<String>,
497 pub suggested: Option<String>,
500 pub y_starts_at_zero: bool,
501 pub log_scale: bool,
502 pub show_legend: bool,
503 pub grid: bool,
505 pub share: bool,
507 pub hist_bins: usize,
508 pub heatmap_bins: usize,
509 pub kde_bandwidth_factor: f64,
510 pub value_range: ValueRange,
512 pub bar_order: BarOrder,
513 pub row_limit: Option<usize>,
516 pub sample_rows: usize,
518 pub rows_draft: Option<RowsDraft>,
520 pub view_rows: Option<usize>,
522 pub view_sampled: bool,
525 pub restored: bool,
528 pub focus: ChartFocus,
529 pub picker: Option<PickerState>,
531 pub picker_for: Option<PickerFor>,
532 pub picker_details: Vec<String>,
534 pub numeric_candidates: Vec<String>,
535 pub temporal_candidates: Vec<String>,
536 pub bucketable_candidates: Vec<String>,
538 pub category_candidates: Vec<String>,
539 pub color_counts: Option<ColorCounts>,
541 pub dataset: Option<u64>,
545 pub opened_on: Option<String>,
546 pub units: Vec<(String, String)>,
549 pub plot_focus: bool,
551 pub cursor_x: Option<f64>,
554 pub plot: Option<crate::widgets::crosshair::PlotPlace>,
557}
558
559impl ChartModal {
560 pub fn new() -> Self {
561 Self::default()
562 }
563
564 pub fn series_max(&self) -> usize {
567 self.series_cap.unwrap_or(COLOR_MAX).clamp(1, COLOR_MAX)
568 }
569
570 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 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 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 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 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 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 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 pub fn has_crosshair(&self) -> bool {
757 self.spec.mark.is_xy() && self.plot.is_some()
758 }
759
760 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 fn is_temporal(&self, column: &str) -> bool {
780 self.temporal_candidates.iter().any(|c| c == column)
781 }
782
783 pub fn x_is_temporal(&self) -> bool {
785 self.x().is_some_and(|x| self.is_temporal(x))
786 }
787
788 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 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 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 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 pub fn y_is_multi(&self) -> bool {
850 self.spec.mark.is_xy()
851 }
852
853 pub fn y_use(&self) -> ShelfUse {
855 match self.spec.mark {
856 Mark::Kde => ShelfUse::Dimmed("density"),
857 _ => ShelfUse::Used,
858 }
859 }
860
861 pub fn color_use(&self) -> ShelfUse {
863 Self::color_use_in(&self.spec)
864 }
865
866 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 pub fn colored_in(spec: &ChartSpec) -> bool {
880 spec.encoding.color.field.is_some() && Self::color_use_in(spec) == ShelfUse::Used
881 }
882
883 pub fn takes_aggregate(&self) -> bool {
885 matches!(self.spec.mark, Mark::Line | Mark::Scatter | Mark::Bar)
886 }
887
888 pub fn aggregates(&self) -> bool {
891 self.takes_aggregate() && self.aggregate() != Aggregate::None
892 }
893
894 pub fn colored(&self) -> bool {
896 Self::colored_in(&self.spec)
897 }
898
899 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 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 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 pub fn has_grid(&self) -> bool {
964 self.row_order().contains(&ChartFocus::Grid)
965 }
966
967 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 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 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 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 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 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 fn offers_none(&self, which: PickerFor) -> bool {
1103 which == PickerFor::Color || (which == PickerFor::X && self.spec.mark == Mark::Box)
1104 }
1105
1106 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 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 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 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 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 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 fn picker_cursor(&self) -> Option<usize> {
1206 self.picker.as_ref()?.selected_original()
1207 }
1208
1209 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 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 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 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 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 pub fn picker_multi(&self) -> bool {
1324 self.picker_for.is_some_and(|w| self.picker_is_multi(w))
1325 }
1326
1327 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 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 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 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 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 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 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 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 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 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 pub fn typing_rows(&self) -> bool {
1510 self.rows_draft.as_ref().is_some_and(|d| d.typed.is_some())
1511 }
1512
1513 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 pub fn discard_rows(&mut self) {
1528 self.rows_draft = None;
1529 }
1530
1531 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 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 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 pub fn leave_rows(&mut self) {
1575 if !self.commit_rows() {
1576 self.rows_draft = None;
1577 }
1578 }
1579
1580 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 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 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 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 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 #[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 #[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 #[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 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 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 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 #[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 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 #[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 #[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 #[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 #[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 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 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 crate::form::Form::focus(&mut modal, ChartFocus::Type);
2146 assert_eq!(modal.row_limit, Some(2_000_000));
2147
2148 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 #[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 #[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 #[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 #[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}