use crate::chart_data::{BarOrder, ValueRange};
use crate::widgets::ui::PickerState;
use polars::prelude::DataType;
use serde::{Deserialize, Serialize};
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Mark {
#[default]
Line,
Scatter,
Bar,
Histogram,
Box,
Kde,
Heatmap,
}
impl Mark {
pub const ALL: [Self; 7] = [
Self::Line,
Self::Scatter,
Self::Bar,
Self::Histogram,
Self::Box,
Self::Kde,
Self::Heatmap,
];
pub fn label(self) -> &'static str {
match self {
Self::Line => "Line",
Self::Scatter => "Scatter",
Self::Bar => "Bar",
Self::Histogram => "Histogram",
Self::Box => "Box",
Self::Kde => "KDE",
Self::Heatmap => "Heatmap",
}
}
pub fn vega_lite(self) -> &'static str {
match self {
Self::Line | Self::Kde => "line",
Self::Scatter => "point",
Self::Bar | Self::Histogram => "bar",
Self::Box => "boxplot",
Self::Heatmap => "rect",
}
}
pub fn is_xy(self) -> bool {
matches!(self, Self::Line | Self::Scatter)
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TimeUnit {
#[default]
None,
Day,
Week,
Month,
Quarter,
Year,
}
impl TimeUnit {
pub const ALL: [Self; 6] = [
Self::None,
Self::Day,
Self::Week,
Self::Month,
Self::Quarter,
Self::Year,
];
pub fn label(self) -> &'static str {
match self {
Self::None => "none",
Self::Day => "day",
Self::Week => "week",
Self::Month => "month",
Self::Quarter => "quarter",
Self::Year => "year",
}
}
pub fn every(self) -> Option<&'static str> {
match self {
Self::None => None,
Self::Day => Some("1d"),
Self::Week => Some("1w"),
Self::Month => Some("1mo"),
Self::Quarter => Some("1q"),
Self::Year => Some("1y"),
}
}
pub fn vega_lite(self) -> Option<&'static str> {
match self {
Self::None => None,
Self::Day => Some("yearmonthdate"),
Self::Week => Some("yearweek"),
Self::Month => Some("yearmonth"),
Self::Quarter => Some("yearquarter"),
Self::Year => Some("year"),
}
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Aggregate {
#[default]
None,
Count,
Distinct,
Sum,
Mean,
Median,
Stdev,
Quantile,
Min,
Max,
First,
Last,
}
impl Aggregate {
pub const ALL: [Self; 12] = [
Self::None,
Self::Count,
Self::Distinct,
Self::Sum,
Self::Mean,
Self::Median,
Self::Stdev,
Self::Quantile,
Self::Min,
Self::Max,
Self::First,
Self::Last,
];
pub fn label(self) -> &'static str {
match self {
Self::None => "none",
Self::Count => "count",
Self::Distinct => "distinct",
Self::Sum => "sum",
Self::Mean => "mean",
Self::Median => "median",
Self::Stdev => "stdev",
Self::Quantile => "quantile",
Self::Min => "min",
Self::Max => "max",
Self::First => "first",
Self::Last => "last",
}
}
pub fn named(self, quantile: u8) -> String {
match self {
Self::Quantile => format!("p{quantile}"),
other => other.label().to_string(),
}
}
pub fn vega_lite(self, quantile: u8) -> Option<&'static str> {
match self {
Self::None | Self::First | Self::Last => None,
Self::Quantile => match quantile {
25 => Some("q1"),
50 => Some("median"),
75 => Some("q3"),
_ => None,
},
other => Some(other.label()),
}
}
pub fn follows_row_order(self) -> bool {
matches!(self, Self::First | Self::Last)
}
pub fn is_fractional(self) -> bool {
matches!(
self,
Self::Mean | Self::Median | Self::Stdev | Self::Quantile
)
}
pub fn takes_any_y(self) -> bool {
self == Self::Distinct
}
pub fn runs_cumulative(self) -> bool {
matches!(
self,
Self::Count | Self::Sum | Self::Mean | Self::Median | Self::Min | Self::Max
)
}
pub fn is_count(self) -> bool {
matches!(self, Self::Count | Self::Distinct)
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Cumulative {
#[default]
Off,
Sum,
Compound,
}
impl Cumulative {
pub const ALL: [Self; 3] = [Self::Off, Self::Sum, Self::Compound];
pub fn label(self) -> &'static str {
match self {
Self::Off => "off",
Self::Sum => "running sum",
Self::Compound => "compound",
}
}
}
#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct XEncoding {
pub field: Option<String>,
pub time_unit: TimeUnit,
}
#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct YEncoding {
pub field: Vec<String>,
pub aggregate: Aggregate,
pub cumulative: Cumulative,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub percentile: Option<u8>,
}
pub const QUANTILES: [u8; 8] = [1, 5, 10, 25, 75, 90, 95, 99];
pub const QUANTILE_DEFAULT: u8 = 90;
impl YEncoding {
pub fn quantile(&self) -> u8 {
self.percentile.unwrap_or(QUANTILE_DEFAULT)
}
pub fn aggregate_name(&self) -> String {
self.aggregate.named(self.quantile())
}
}
#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ColorEncoding {
pub field: Option<String>,
pub values: Vec<Option<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub other: Option<bool>,
}
#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
pub struct Encoding {
pub x: XEncoding,
pub y: YEncoding,
pub color: ColorEncoding,
}
#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
pub struct ChartSpec {
pub mark: Mark,
pub encoding: Encoding,
}
impl ChartSpec {
pub fn to_vega_lite(&self) -> serde_json::Value {
let mut x = serde_json::Map::new();
if let Some(field) = &self.encoding.x.field {
x.insert("field".into(), field.clone().into());
}
if let Some(unit) = self.encoding.x.time_unit.vega_lite() {
x.insert("timeUnit".into(), unit.into());
}
let mut y = serde_json::Map::new();
if let Some(field) = self.encoding.y.field.first() {
y.insert("field".into(), field.clone().into());
}
if let Some(aggregate) = self
.encoding
.y
.aggregate
.vega_lite(self.encoding.y.quantile())
{
y.insert("aggregate".into(), aggregate.into());
}
let mut encoding = serde_json::Map::new();
encoding.insert("x".into(), x.into());
encoding.insert("y".into(), y.into());
if let Some(field) = &self.encoding.color.field {
encoding.insert("color".into(), serde_json::json!({ "field": field }));
}
serde_json::json!({ "mark": self.mark.vega_lite(), "encoding": encoding })
}
}
pub const COLOR_MAX: usize = 10;
pub const Y_SERIES_MAX: usize = COLOR_MAX;
pub const HISTOGRAM_DEFAULT_BINS: usize = 40;
pub const HISTOGRAM_MIN_BINS: usize = 5;
pub const HISTOGRAM_MAX_BINS: usize = 100;
pub const HEATMAP_DEFAULT_BINS: usize = 20;
pub const HEATMAP_MIN_BINS: usize = 5;
pub const HEATMAP_MAX_BINS: usize = 60;
pub const KDE_BANDWIDTH_MIN: f64 = 0.2;
pub const KDE_BANDWIDTH_MAX: f64 = 5.0;
pub const KDE_BANDWIDTH_STEP: f64 = 0.1;
pub const CHART_ROW_LIMIT_MAX: usize = u32::MAX as usize;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RowsDraft {
pub every: bool,
pub typed: Option<String>,
pub error: Option<&'static str>,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum ChartFocus {
#[default]
Type,
X,
TimeUnit,
Order,
Bins,
Y,
Aggregate,
Quantile,
Color,
ColorValues,
Cumulative,
Bandwidth,
Range,
YStartsAtZero,
LogScale,
ShowLegend,
Grid,
LimitRows,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ShelfUse {
Used,
Dimmed(&'static str),
}
#[derive(Clone, Copy, Default)]
pub struct ChartColumns<'a> {
pub numeric: &'a [String],
pub datetime: &'a [String],
pub bucketable: &'a [String],
pub category: &'a [String],
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ColorCounts {
pub column: String,
pub values: Vec<(Option<String>, u64)>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PickerFor {
X,
Y,
Color,
ColorValues,
}
pub const NONE_ITEM: &str = "none";
#[derive(Default)]
pub struct ChartModal {
pub active: bool,
pub spec: ChartSpec,
pub series_cap: Option<usize>,
pub row_order: Option<String>,
pub suggested: Option<String>,
pub y_starts_at_zero: bool,
pub log_scale: bool,
pub show_legend: bool,
pub grid: bool,
pub share: bool,
pub hist_bins: usize,
pub heatmap_bins: usize,
pub kde_bandwidth_factor: f64,
pub value_range: ValueRange,
pub bar_order: BarOrder,
pub row_limit: Option<usize>,
pub sample_rows: usize,
pub rows_draft: Option<RowsDraft>,
pub view_rows: Option<usize>,
pub view_sampled: bool,
pub restored: bool,
pub focus: ChartFocus,
pub picker: Option<PickerState>,
pub picker_for: Option<PickerFor>,
pub picker_details: Vec<String>,
pub numeric_candidates: Vec<String>,
pub temporal_candidates: Vec<String>,
pub bucketable_candidates: Vec<String>,
pub category_candidates: Vec<String>,
pub color_counts: Option<ColorCounts>,
pub dataset: Option<u64>,
pub opened_on: Option<String>,
pub units: Vec<(String, String)>,
pub plot_focus: bool,
pub cursor_x: Option<f64>,
pub plot: Option<crate::widgets::crosshair::PlotPlace>,
}
impl ChartModal {
pub fn new() -> Self {
Self::default()
}
pub fn series_max(&self) -> usize {
self.series_cap.unwrap_or(COLOR_MAX).clamp(1, COLOR_MAX)
}
pub fn axis_title(&self, column: &str) -> String {
match self.units.iter().find(|(name, _)| name == column) {
Some((_, unit)) => format!("{column} ({unit})"),
None => column.to_string(),
}
}
pub fn mark(&self) -> Mark {
self.spec.mark
}
pub fn x(&self) -> Option<&String> {
self.spec.encoding.x.field.as_ref()
}
pub fn y(&self) -> &[String] {
&self.spec.encoding.y.field
}
pub fn aggregate(&self) -> Aggregate {
self.spec.encoding.y.aggregate
}
pub fn color(&self) -> Option<&String> {
self.spec.encoding.color.field.as_ref()
}
pub fn open(
&mut self,
columns: ChartColumns<'_>,
cursor: Option<(&str, &DataType)>,
default_row_limit: Option<usize>,
grid: bool,
dataset: u64,
) {
self.active = true;
self.close_picker();
self.temporal_candidates = columns.datetime.to_vec();
self.bucketable_candidates = columns.bucketable.to_vec();
self.numeric_candidates = columns.numeric.to_vec();
self.category_candidates = columns.category.to_vec();
self.plot_focus = false;
self.rows_draft = None;
let opened_on = cursor.map(|(name, _)| name.to_string());
let restored = std::mem::take(&mut self.restored);
if self.dataset == Some(dataset) && (self.opened_on == opened_on || restored) {
self.keep_existing_choices();
self.settle();
self.focus = ChartFocus::Type;
return;
}
self.dataset = Some(dataset);
self.opened_on = opened_on;
self.cursor_x = None;
self.color_counts = None;
self.spec = ChartSpec::default();
self.suggested = None;
self.y_starts_at_zero = false;
self.log_scale = false;
self.show_legend = true;
self.grid = grid;
self.share = false;
self.value_range = ValueRange::All;
self.row_limit = default_row_limit.and_then(|n| {
if n == 0 {
None
} else {
Some(n.clamp(1, CHART_ROW_LIMIT_MAX))
}
});
self.sample_rows = self
.row_limit
.unwrap_or(crate::config::DEFAULT_CHART_ROW_LIMIT);
self.hist_bins = HISTOGRAM_DEFAULT_BINS;
self.kde_bandwidth_factor = 1.0;
self.heatmap_bins = HEATMAP_DEFAULT_BINS;
self.bar_order = BarOrder::Value;
if let Some((name, dtype)) = cursor {
self.suggest(name, dtype);
}
self.focus = ChartFocus::Type;
}
pub fn restore(&mut self, saved: &crate::view::SavedChart, dataset: u64) {
self.spec = saved.spec.clone();
self.hist_bins = saved.histogram_bins.max(1);
self.heatmap_bins = saved.heatmap_bins.max(1);
self.kde_bandwidth_factor = saved.bandwidth;
self.value_range = saved.range;
self.bar_order = saved.bar_order;
self.share = saved.share;
self.y_starts_at_zero = saved.y_starts_at_zero;
self.log_scale = saved.log_scale;
self.show_legend = saved.legend;
self.grid = saved.grid;
self.row_limit = saved.rows;
if let Some(rows) = saved.rows {
self.sample_rows = rows;
}
self.suggested = None;
self.color_counts = None;
self.cursor_x = None;
self.dataset = Some(dataset);
self.restored = true;
}
pub fn saved(
&self,
seed: u64,
export: Option<crate::view::SavedChartExport>,
) -> crate::view::SavedChart {
crate::view::SavedChart {
spec: self.spec.clone(),
histogram_bins: self.hist_bins,
heatmap_bins: self.heatmap_bins,
bandwidth: self.kde_bandwidth_factor,
range: self.value_range,
bar_order: self.bar_order,
share: self.share,
y_starts_at_zero: self.y_starts_at_zero,
log_scale: self.log_scale,
legend: self.show_legend,
grid: self.grid,
rows: if self.view_sampled {
None
} else {
self.row_limit
},
seed: (!self.view_sampled && self.row_limit.is_some()).then_some(seed),
export,
}
}
fn suggest(&mut self, name: &str, dtype: &DataType) {
let name = name.to_string();
let encoding = &mut self.spec.encoding;
if self.temporal_candidates.contains(&name) {
self.spec.mark = Mark::Line;
encoding.x.field = Some(name);
if let Some(y) = self.numeric_candidates.first() {
encoding.y.field = vec![y.clone()];
}
} else if dtype.is_float() || (dtype.is_numeric() && !dtype.is_integer()) {
self.spec.mark = Mark::Histogram;
encoding.x.field = Some(name);
} else if self.category_candidates.contains(&name) && !dtype.is_integer() {
self.spec.mark = Mark::Bar;
encoding.x.field = Some(name);
encoding.y.aggregate = Aggregate::Count;
} else if self.numeric_candidates.contains(&name) {
self.spec.mark = Mark::Histogram;
encoding.x.field = Some(name);
} else {
return;
}
self.suggested = Some(crate::widgets::datatable::dtype_label(dtype));
}
pub fn close(&mut self) {
self.active = false;
self.close_picker();
self.plot_focus = false;
self.rows_draft = None;
}
pub fn close_picker(&mut self) {
self.picker = None;
self.picker_for = None;
self.picker_details.clear();
}
pub fn has_crosshair(&self) -> bool {
self.spec.mark.is_xy() && self.plot.is_some()
}
fn keep_existing_choices(&mut self) {
let mut all: Vec<&String> = self.numeric_candidates.iter().collect();
all.extend(self.temporal_candidates.iter());
all.extend(self.category_candidates.iter());
let has = |c: &String| all.contains(&c);
let encoding = &mut self.spec.encoding;
if encoding.x.field.as_ref().is_some_and(|c| !has(c)) {
encoding.x.field = None;
}
encoding.y.field.retain(|c| has(c));
if encoding.color.field.as_ref().is_some_and(|c| !has(c)) {
encoding.color.field = None;
encoding.color.values.clear();
}
}
fn is_temporal(&self, column: &str) -> bool {
self.temporal_candidates.iter().any(|c| c == column)
}
pub fn x_is_temporal(&self) -> bool {
self.x().is_some_and(|x| self.is_temporal(x))
}
pub fn x_is_bucketable(&self) -> bool {
self.x()
.is_some_and(|x| self.bucketable_candidates.iter().any(|c| c == x))
}
fn pool(&self, shelf: PickerFor) -> Vec<String> {
let mark = self.spec.mark;
let x = self.x();
match shelf {
PickerFor::X => match mark {
Mark::Line | Mark::Scatter => {
let mut out = self.temporal_candidates.clone();
for c in &self.numeric_candidates {
if !out.contains(c) {
out.push(c.clone());
}
}
out
}
Mark::Bar | Mark::Box => self.category_candidates.clone(),
Mark::Histogram | Mark::Kde | Mark::Heatmap => self.numeric_candidates.clone(),
},
PickerFor::Y
if self.spec.encoding.y.aggregate.takes_any_y() && self.takes_aggregate() =>
{
let mut all = self.numeric_candidates.clone();
for c in self
.temporal_candidates
.iter()
.chain(&self.category_candidates)
{
if !all.contains(c) {
all.push(c.clone());
}
}
all.retain(|c| Some(c) != x);
all
}
PickerFor::Y => self
.numeric_candidates
.iter()
.filter(|c| Some(*c) != x || mark == Mark::Box)
.cloned()
.collect(),
PickerFor::Color => self
.category_candidates
.iter()
.filter(|c| Some(*c) != x)
.cloned()
.collect(),
PickerFor::ColorValues => Vec::new(),
}
}
pub fn y_is_multi(&self) -> bool {
self.spec.mark.is_xy()
}
pub fn y_use(&self) -> ShelfUse {
match self.spec.mark {
Mark::Kde => ShelfUse::Dimmed("density"),
_ => ShelfUse::Used,
}
}
pub fn color_use(&self) -> ShelfUse {
Self::color_use_in(&self.spec)
}
pub fn color_use_in(spec: &ChartSpec) -> ShelfUse {
let y = &spec.encoding.y;
match spec.mark {
Mark::Box => ShelfUse::Dimmed("same as X"),
Mark::Heatmap => ShelfUse::Dimmed("density"),
Mark::Line | Mark::Scatter if y.field.len() > 1 => ShelfUse::Dimmed("one per Y column"),
Mark::Bar if y.aggregate == Aggregate::None => ShelfUse::Dimmed("needs an aggregate"),
_ => ShelfUse::Used,
}
}
pub fn colored_in(spec: &ChartSpec) -> bool {
spec.encoding.color.field.is_some() && Self::color_use_in(spec) == ShelfUse::Used
}
pub fn takes_aggregate(&self) -> bool {
matches!(self.spec.mark, Mark::Line | Mark::Scatter | Mark::Bar)
}
pub fn aggregates(&self) -> bool {
self.takes_aggregate() && self.aggregate() != Aggregate::None
}
pub fn colored(&self) -> bool {
Self::colored_in(&self.spec)
}
pub fn shows_other_in(spec: &ChartSpec) -> bool {
Self::colored_in(spec)
&& spec
.encoding
.color
.other
.unwrap_or(spec.mark == Mark::Scatter)
}
pub fn shows_other(&self) -> bool {
Self::shows_other_in(&self.spec)
}
pub fn row_order(&self) -> Vec<ChartFocus> {
use ChartFocus::*;
let mark = self.spec.mark;
let mut rows = vec![Type, X];
match mark {
Mark::Line | Mark::Scatter if self.x_is_bucketable() => rows.push(TimeUnit),
Mark::Bar => rows.push(Order),
Mark::Histogram => rows.push(Bins),
_ => {}
}
if self.y_use() == ShelfUse::Used {
rows.push(Y);
}
if self.takes_aggregate() {
rows.push(Aggregate);
if self.aggregate() == self::Aggregate::Quantile {
rows.push(Quantile);
}
}
if self.color_use() == ShelfUse::Used {
rows.push(Color);
if self.color().is_some() {
rows.push(ColorValues);
}
}
match mark {
Mark::Line | Mark::Scatter => {
if self.aggregates() && self.aggregate().runs_cumulative() {
rows.push(Cumulative);
}
rows.extend([YStartsAtZero, LogScale, ShowLegend, Grid]);
}
Mark::Bar => rows.push(ShowLegend),
Mark::Histogram => rows.extend([Range, ShowLegend, Grid]),
Mark::Kde => rows.extend([Bandwidth, Range, ShowLegend, Grid]),
Mark::Box => rows.extend([Range, Grid]),
Mark::Heatmap => rows.push(Bins),
}
if !self.aggregates() && !self.view_sampled {
rows.push(LimitRows);
}
rows
}
pub fn has_grid(&self) -> bool {
self.row_order().contains(&ChartFocus::Grid)
}
pub fn set_mark(&mut self, mark: Mark) {
if mark == self.spec.mark {
return;
}
let old = self.spec.mark;
self.spec.mark = mark;
self.suggested = None;
self.close_picker();
if !mark.is_xy() {
self.plot_focus = false;
}
let encoding = &mut self.spec.encoding;
let numeric = |c: &String| self.numeric_candidates.contains(c);
let category = |c: &String| self.category_candidates.contains(c);
match mark {
Mark::Histogram | Mark::Kde => {
if !encoding.x.field.as_ref().is_some_and(numeric) {
encoding.x.field = encoding.y.field.first().cloned();
}
}
Mark::Box => {
if encoding.y.field.is_empty()
&& let Some(x) = encoding.x.field.clone().filter(numeric)
{
encoding.y.field = vec![x];
encoding.x.field = None;
}
if !encoding.x.field.as_ref().is_some_and(category) {
encoding.x.field = None;
}
}
Mark::Bar => {
if !encoding.x.field.as_ref().is_some_and(category) {
encoding.x.field = encoding.color.field.take();
encoding.color.values.clear();
}
if encoding.y.field.is_empty() && encoding.y.aggregate == Aggregate::None {
encoding.y.aggregate = Aggregate::Count;
}
}
Mark::Heatmap => {
if !encoding.x.field.as_ref().is_some_and(numeric) {
encoding.x.field = None;
}
}
Mark::Line | Mark::Scatter => {
if matches!(old, Mark::Histogram | Mark::Kde | Mark::Box)
&& encoding.y.field.is_empty()
&& let Some(x) = encoding.x.field.take()
{
encoding.y.field = vec![x];
}
}
}
self.settle();
if !self.row_order().contains(&self.focus) {
self.focus = ChartFocus::Type;
}
}
fn settle(&mut self) {
let x_pool = self.pool(PickerFor::X);
let y_pool = self.pool(PickerFor::Y);
let color_pool = self.pool(PickerFor::Color);
let mark = self.spec.mark;
let x_temporal = self.x_is_bucketable();
let encoding = &mut self.spec.encoding;
if encoding
.x
.field
.as_ref()
.is_some_and(|x| !x_pool.contains(x))
{
encoding.x.field = None;
}
encoding.y.field.retain(|y| y_pool.contains(y));
if !mark.is_xy() {
encoding.y.field.truncate(1);
}
if !x_temporal || !mark.is_xy() || encoding.y.aggregate == Aggregate::None {
encoding.x.time_unit = TimeUnit::None;
}
if encoding
.color
.field
.as_ref()
.is_some_and(|c| !color_pool.contains(c))
{
encoding.color.field = None;
encoding.color.values.clear();
}
if !matches!(mark, Mark::Line | Mark::Scatter | Mark::Bar) {
encoding.y.aggregate = Aggregate::None;
}
if !encoding.y.aggregate.runs_cumulative() || !mark.is_xy() {
encoding.y.cumulative = Cumulative::Off;
}
}
pub fn step_mark(&mut self, delta: i8) {
let mark = crate::form::step_value(&Mark::ALL, self.spec.mark, delta);
self.set_mark(mark);
}
pub fn picker_for(&self, focus: ChartFocus) -> Option<PickerFor> {
match focus {
ChartFocus::X => Some(PickerFor::X),
ChartFocus::Y if self.spec.mark != Mark::Histogram => Some(PickerFor::Y),
ChartFocus::Color => Some(PickerFor::Color),
ChartFocus::ColorValues => Some(PickerFor::ColorValues),
_ => None,
}
}
pub fn picker_is_multi(&self, which: PickerFor) -> bool {
match which {
PickerFor::Y => self.y_is_multi(),
PickerFor::ColorValues => true,
PickerFor::X | PickerFor::Color => false,
}
}
fn offers_none(&self, which: PickerFor) -> bool {
which == PickerFor::Color || (which == PickerFor::X && self.spec.mark == Mark::Box)
}
fn picker_items(&self, which: PickerFor) -> (Vec<String>, Vec<String>) {
if which == PickerFor::ColorValues {
let null = crate::glyphs::get().null;
let Some(counts) = &self.color_counts else {
return (Vec::new(), Vec::new());
};
return counts
.values
.iter()
.map(|(value, rows)| {
(
value.clone().unwrap_or_else(|| null.to_string()),
crate::numfmt::group_chrome(*rows as usize),
)
})
.unzip();
}
let mut items = Vec::new();
if self.offers_none(which) {
items.push(NONE_ITEM.to_string());
}
items.extend(self.pool(which));
(items, Vec::new())
}
fn color_value_at(&self, i: usize) -> Option<Option<String>> {
self.color_counts
.as_ref()?
.values
.get(i)
.map(|(value, _)| value.clone())
}
fn current_index(&self, which: PickerFor, items: &[String]) -> Option<usize> {
let encoding = &self.spec.encoding;
let current = match which {
PickerFor::X => encoding.x.field.as_deref(),
PickerFor::Y => encoding.y.field.first().map(String::as_str),
PickerFor::Color => encoding.color.field.as_deref(),
PickerFor::ColorValues => return Some(0),
};
match current {
Some(current) => items.iter().position(|i| i == current),
None if self.offers_none(which) => Some(0),
None => None,
}
}
pub fn open_picker(&mut self) {
let Some(which) = self.picker_for(self.focus) else {
return;
};
if which == PickerFor::ColorValues && !self.has_color_counts() {
return;
}
let (items, details) = self.picker_items(which);
let mut state = PickerState::new(items.clone());
if let Some(i) = self.current_index(which, &items) {
state.select_original(i);
}
self.picker = Some(state);
self.picker_for = Some(which);
self.picker_details = details;
}
pub fn has_color_counts(&self) -> bool {
self.color_counts
.as_ref()
.is_some_and(|c| Some(&c.column) == self.color())
}
pub fn step_picker_row(&mut self, delta: i8) {
let Some(which) = self.picker_for(self.focus) else {
return;
};
if self.picker_is_multi(which) {
return;
}
let (items, _) = self.picker_items(which);
if items.is_empty() {
return;
}
let next = match self.current_index(which, &items) {
Some(at) => crate::form::step_index(at, items.len(), delta),
None if delta < 0 => items.len() - 1,
None => 0,
};
self.choose(which, &items[next], next);
}
fn picker_cursor(&self) -> Option<usize> {
self.picker.as_ref()?.selected_original()
}
pub fn picker_choose(&mut self) {
let (Some(which), Some(i)) = (self.picker_for, self.picker_cursor()) else {
self.close_picker();
return;
};
let item = self.picker.as_ref().and_then(|p| p.items().get(i).cloned());
self.close_picker();
let Some(item) = item else {
return;
};
match which {
PickerFor::Y if self.y_is_multi() => {
if self.spec.encoding.y.field.is_empty() {
self.spec.encoding.y.field.push(item);
}
}
PickerFor::ColorValues => {
if self.spec.encoding.color.values.is_empty()
&& let Some(value) = self.color_value_at(i)
{
self.spec.encoding.color.values.push(value);
}
}
_ => self.choose(which, &item, i),
}
self.settle();
}
fn choose(&mut self, which: PickerFor, item: &str, i: usize) {
let none = self.offers_none(which) && i == 0;
let encoding = &mut self.spec.encoding;
match which {
PickerFor::X => {
encoding.x.field = (!none).then(|| item.to_string());
encoding.y.field.retain(|y| y != item);
if encoding.color.field.as_deref() == Some(item) {
encoding.color.field = None;
encoding.color.values.clear();
}
encoding.x.time_unit = TimeUnit::None;
}
PickerFor::Y => encoding.y.field = vec![item.to_string()],
PickerFor::Color => {
let field = (!none).then(|| item.to_string());
if field != encoding.color.field {
encoding.color.values.clear();
}
encoding.color.field = field;
}
PickerFor::ColorValues => {}
}
self.settle();
if !self.row_order().contains(&self.focus) {
self.focus = ChartFocus::Type;
}
}
pub fn picker_toggle(&mut self) {
let (Some(which), Some(i)) = (self.picker_for, self.picker_cursor()) else {
return;
};
match which {
PickerFor::Y if self.y_is_multi() => {
let Some(item) = self.picker.as_ref().and_then(|p| p.items().get(i).cloned())
else {
return;
};
let most = Y_SERIES_MAX.min(self.series_max());
let field = &mut self.spec.encoding.y.field;
if let Some(pos) = field.iter().position(|c| *c == item) {
field.remove(pos);
} else if field.len() < most {
field.push(item);
}
}
PickerFor::ColorValues => {
let Some(value) = self.color_value_at(i) else {
return;
};
let most = self.series_max();
let values = &mut self.spec.encoding.color.values;
if let Some(pos) = values.iter().position(|v| *v == value) {
values.remove(pos);
} else if values.len() < most {
values.push(value);
}
}
_ => {}
}
}
pub fn is_marked(&self, i: usize) -> bool {
match self.picker_for {
Some(PickerFor::Y) => self
.picker
.as_ref()
.and_then(|p| p.items().get(i))
.is_some_and(|item| self.y().contains(item)),
Some(PickerFor::ColorValues) => self
.color_value_at(i)
.is_some_and(|v| self.spec.encoding.color.values.contains(&v)),
_ => false,
}
}
pub fn picker_multi(&self) -> bool {
self.picker_for.is_some_and(|w| self.picker_is_multi(w))
}
pub fn effective_spec(&self) -> ChartSpec {
let mut spec = self.spec.clone();
let (Some(which), Some(i)) = (self.picker_for, self.picker_cursor()) else {
return spec;
};
let Some(item) = self.picker.as_ref().and_then(|p| p.items().get(i).cloned()) else {
return spec;
};
match which {
PickerFor::Y if self.y_is_multi() => {
if !spec.encoding.y.field.contains(&item) {
spec.encoding.y.field.push(item);
}
}
PickerFor::Y => spec.encoding.y.field = vec![item],
PickerFor::X if !spec.mark.is_xy() => {
spec.encoding.x.field = (!(self.offers_none(which) && i == 0)).then_some(item);
}
_ => {}
}
spec
}
pub fn step(&mut self, focus: ChartFocus, delta: i8) {
match focus {
ChartFocus::Type => self.step_mark(delta),
ChartFocus::TimeUnit => {
let encoding = &mut self.spec.encoding;
encoding.x.time_unit =
crate::form::step_value(&TimeUnit::ALL, encoding.x.time_unit, delta);
if encoding.x.time_unit != TimeUnit::None && encoding.y.aggregate == Aggregate::None
{
encoding.y.aggregate = Aggregate::Mean;
}
}
ChartFocus::Aggregate => {
let was = self.aggregate();
let bucketable = self.x_is_bucketable() && self.spec.mark.is_xy();
let encoding = &mut self.spec.encoding;
encoding.y.aggregate =
crate::form::step_value(&Aggregate::ALL, encoding.y.aggregate, delta);
if was == Aggregate::None
&& bucketable
&& encoding.y.aggregate != Aggregate::None
&& encoding.x.time_unit == TimeUnit::None
{
encoding.x.time_unit = TimeUnit::Day;
}
self.settle();
}
ChartFocus::Cumulative => {
let y = &mut self.spec.encoding.y;
y.cumulative = crate::form::step_value(&Cumulative::ALL, y.cumulative, delta);
}
ChartFocus::Quantile => {
let y = &mut self.spec.encoding.y;
y.percentile = Some(crate::form::step_value(&QUANTILES, y.quantile(), delta));
}
ChartFocus::Y if self.spec.mark == Mark::Histogram => self.share = !self.share,
ChartFocus::Order => {
self.bar_order = crate::form::step_value(&BarOrder::ALL, self.bar_order, delta);
}
ChartFocus::Range => {
self.value_range =
crate::form::step_value(&ValueRange::ALL, self.value_range, delta);
}
ChartFocus::Bins => self.adjust_bins(delta.into()),
ChartFocus::Bandwidth => {
self.adjust_kde_bandwidth_factor(f64::from(delta) * KDE_BANDWIDTH_STEP)
}
ChartFocus::LimitRows => self.toggle_rows(),
ChartFocus::YStartsAtZero => self.y_starts_at_zero = !self.y_starts_at_zero,
ChartFocus::LogScale => self.log_scale = !self.log_scale,
ChartFocus::ShowLegend => self.show_legend = !self.show_legend,
ChartFocus::Grid => self.grid = !self.grid,
ChartFocus::ColorValues => {
self.spec.encoding.color.other = Some(!self.shows_other());
}
focus => {
if self.picker_for(focus).is_some() {
self.step_picker_row(delta);
}
}
}
if !self.row_order().contains(&self.focus) {
self.focus = ChartFocus::Type;
self.leave_rows();
}
}
pub fn is_toggle_row(&self, focus: ChartFocus) -> bool {
matches!(
focus,
ChartFocus::YStartsAtZero
| ChartFocus::LogScale
| ChartFocus::ShowLegend
| ChartFocus::Grid
)
}
pub fn toggle_grid(&mut self) {
self.grid = !self.grid;
}
fn adjust_bins(&mut self, delta: i32) {
if self.spec.mark == Mark::Heatmap {
self.heatmap_bins = (self.heatmap_bins as i32 + delta)
.clamp(HEATMAP_MIN_BINS as i32, HEATMAP_MAX_BINS as i32)
as usize;
} else {
self.hist_bins = (self.hist_bins as i32 + delta)
.clamp(HISTOGRAM_MIN_BINS as i32, HISTOGRAM_MAX_BINS as i32)
as usize;
}
}
pub fn adjust_kde_bandwidth_factor(&mut self, delta: f64) {
let next = (self.kde_bandwidth_factor + delta).clamp(KDE_BANDWIDTH_MIN, KDE_BANDWIDTH_MAX);
self.kde_bandwidth_factor = (next * 10.0).round() / 10.0;
}
pub fn adjust_number_row(&mut self, delta: i32) {
match self.focus {
ChartFocus::Bins => self.adjust_bins(delta),
ChartFocus::Bandwidth => {
self.adjust_kde_bandwidth_factor(delta as f64 * KDE_BANDWIDTH_STEP)
}
_ => {}
}
}
pub fn rows_shown(&self) -> RowsDraft {
self.rows_draft.clone().unwrap_or(RowsDraft {
every: self.row_limit.is_none(),
..RowsDraft::default()
})
}
pub fn rows_pending(&self) -> bool {
self.rows_draft
.as_ref()
.is_some_and(|draft| draft.typed.is_some() || draft.every != self.row_limit.is_none())
}
pub fn toggle_rows(&mut self) {
if !self.take_typed_size() {
return;
}
let mut draft = self.rows_shown();
draft.every = !draft.every;
self.rows_draft = Some(draft);
}
pub fn type_rows(&mut self, c: char) {
let mut draft = self.rows_shown();
draft.every = false;
draft.error = None;
draft.typed.get_or_insert_with(String::new).push(c);
self.rows_draft = Some(draft);
}
pub fn typing_rows(&self) -> bool {
self.rows_draft.as_ref().is_some_and(|d| d.typed.is_some())
}
pub fn backspace_rows(&mut self) {
if let Some(draft) = self.rows_draft.as_mut()
&& let Some(typed) = draft.typed.as_mut()
{
typed.pop();
draft.error = None;
if typed.is_empty() {
draft.typed = None;
}
}
}
pub fn discard_rows(&mut self) {
self.rows_draft = None;
}
fn take_typed_size(&mut self) -> bool {
let Some(draft) = self.rows_draft.as_mut() else {
return true;
};
let Some(typed) = draft.typed.take() else {
return true;
};
match crate::sampling::parse_size(&typed) {
Ok(rows) => {
let rows = rows.min(CHART_ROW_LIMIT_MAX);
if self.view_rows.is_some_and(|total| rows >= total) {
draft.every = true;
} else {
draft.every = false;
self.sample_rows = rows;
}
true
}
Err(e) => {
draft.error = Some(e.short());
draft.typed = Some(typed);
false
}
}
}
pub fn commit_rows(&mut self) -> bool {
if !self.take_typed_size() {
return false;
}
if let Some(draft) = self.rows_draft.take() {
self.row_limit = (!draft.every).then_some(self.sample_rows);
}
true
}
pub fn leave_rows(&mut self) {
if !self.commit_rows() {
self.rows_draft = None;
}
}
pub fn is_complete(spec: &ChartSpec) -> bool {
let encoding = &spec.encoding;
let x = encoding.x.field.is_some();
let y = !encoding.y.field.is_empty();
match spec.mark {
Mark::Line | Mark::Scatter => x && (y || encoding.y.aggregate == Aggregate::Count),
Mark::Bar => x && (y || encoding.y.aggregate == Aggregate::Count),
Mark::Histogram | Mark::Kde => x,
Mark::Box => y,
Mark::Heatmap => x && y,
}
}
pub fn can_export(&self) -> bool {
Self::is_complete(&self.effective_spec())
}
pub fn how(&self) -> String {
let spec = self.effective_spec();
let encoding = &spec.encoding;
let x = encoding.x.field.clone().unwrap_or_default();
let mut parts: Vec<String> = Vec::new();
match spec.mark {
Mark::Histogram => parts.push(if self.share {
"share per bin".to_string()
} else {
"count per bin".to_string()
}),
Mark::Kde => {}
Mark::Box if !x.is_empty() => parts.push(format!("one box per {x}")),
Mark::Box => {}
Mark::Heatmap => parts.push("rows per cell".to_string()),
Mark::Line | Mark::Scatter | Mark::Bar => {
let aggregate = encoding.y.aggregate;
let mut how = String::new();
if aggregate != Aggregate::None && encoding.y.cumulative == Cumulative::Off {
how.push_str(&encoding.y.aggregate_name());
how.push(' ');
}
let unit = encoding.x.time_unit;
if unit != TimeUnit::None {
how.push_str(&format!("by {}", unit.label()));
} else if aggregate != Aggregate::None {
how.push_str(&format!("by {x}"));
}
if !how.trim().is_empty() {
parts.push(how.trim().to_string());
}
match encoding.y.cumulative {
Cumulative::Off => {}
Cumulative::Sum => parts.push("running sum".to_string()),
Cumulative::Compound => parts.push("compounded".to_string()),
}
}
}
if self.colored() {
parts.push(format!("colored by {}", self.color().unwrap()));
}
parts.join(", ")
}
}
impl crate::form::Form for ChartModal {
type Field = ChartFocus;
fn fields(&self) -> Vec<(ChartFocus, crate::form::FieldKind)> {
use crate::form::FieldKind;
self.row_order()
.into_iter()
.map(|row| {
let kind = match self.picker_for(row) {
Some(which) => FieldKind::Picker {
multi: self.picker_is_multi(which),
},
None if self.is_toggle_row(row) => FieldKind::Checkbox,
None => FieldKind::Choice,
};
(row, kind)
})
.collect()
}
fn focused(&self) -> ChartFocus {
self.focus
}
fn set_focused(&mut self, field: ChartFocus) {
if field != ChartFocus::LimitRows {
self.leave_rows();
}
self.focus = field;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::form::Form;
fn s(v: &[&str]) -> Vec<String> {
v.iter().map(|s| s.to_string()).collect()
}
struct Cols {
numeric: Vec<String>,
datetime: Vec<String>,
category: Vec<String>,
}
fn cols() -> Cols {
Cols {
numeric: s(&["delay", "distance", "year"]),
datetime: s(&["date"]),
category: s(&["carrier", "origin", "year"]),
}
}
fn open_on(cursor: Option<(&str, &DataType)>) -> ChartModal {
let c = cols();
let mut modal = ChartModal::new();
modal.open(
ChartColumns {
numeric: &c.numeric,
datetime: &c.datetime,
bucketable: &c.datetime,
category: &c.category,
},
cursor,
Some(10_000),
false,
1,
);
modal
}
#[test]
fn an_axis_title_names_the_unit() {
let mut modal = ChartModal::default();
assert_eq!(modal.axis_title("cht1"), "cht1");
modal.units = vec![("cht1".to_string(), "deg F".to_string())];
assert_eq!(modal.axis_title("cht1"), "cht1 (deg F)");
}
#[test]
fn quick_chart_follows_the_cursor_column_type() {
let modal = open_on(Some(("delay", &DataType::Float64)));
assert_eq!(modal.mark(), Mark::Histogram);
assert_eq!(modal.x().map(String::as_str), Some("delay"));
assert_eq!(modal.suggested.as_deref(), Some("f64"));
let modal = open_on(Some(("carrier", &DataType::String)));
assert_eq!(modal.mark(), Mark::Bar);
assert_eq!(modal.aggregate(), Aggregate::Count);
assert!(ChartModal::is_complete(&modal.spec), "counts need no Y");
let modal = open_on(Some(("date", &DataType::Date)));
assert_eq!(modal.mark(), Mark::Line);
assert_eq!(modal.x().map(String::as_str), Some("date"));
assert_eq!(modal.y(), ["delay"], "the first numeric column");
let modal = open_on(Some(("year", &DataType::Int64)));
assert_eq!(modal.mark(), Mark::Histogram, "an integer is a measure");
let modal = open_on(None);
assert_eq!(modal.mark(), Mark::Line);
assert!(modal.x().is_none() && modal.suggested.is_none());
}
#[test]
fn changing_the_type_clears_the_suggestion() {
let mut modal = open_on(Some(("delay", &DataType::Float64)));
modal.step(ChartFocus::Type, 1);
assert_eq!(modal.mark(), Mark::Box);
assert!(modal.suggested.is_none());
assert_eq!(modal.y(), ["delay"], "the histogram's value is the box's");
assert!(modal.x().is_none(), "a box's X is a category or none");
}
#[test]
fn shelves_dim_by_type() {
let mut modal = open_on(None);
let rows = |m: &ChartModal| m.row_order();
assert!(rows(&modal).contains(&ChartFocus::Color));
modal.set_mark(Mark::Kde);
assert_eq!(modal.y_use(), ShelfUse::Dimmed("density"));
assert!(!rows(&modal).contains(&ChartFocus::Y));
modal.set_mark(Mark::Box);
assert_eq!(modal.color_use(), ShelfUse::Dimmed("same as X"));
assert!(!rows(&modal).contains(&ChartFocus::Color));
modal.set_mark(Mark::Heatmap);
assert_eq!(modal.color_use(), ShelfUse::Dimmed("density"));
modal.set_mark(Mark::Line);
modal.spec.encoding.y.field = s(&["delay", "distance"]);
assert_eq!(modal.color_use(), ShelfUse::Dimmed("one per Y column"));
modal.set_mark(Mark::Bar);
modal.spec.encoding.y.aggregate = Aggregate::None;
assert_eq!(modal.color_use(), ShelfUse::Dimmed("needs an aggregate"));
}
#[test]
fn rows_follow_the_type() {
use ChartFocus::*;
let mut modal = open_on(Some(("date", &DataType::Date)));
assert_eq!(
modal.fields().iter().map(|(f, _)| *f).collect::<Vec<_>>(),
[
Type,
X,
TimeUnit,
Y,
Aggregate,
Color,
YStartsAtZero,
LogScale,
ShowLegend,
Grid,
LimitRows
]
);
modal.step(TimeUnit, 1);
assert_eq!(modal.spec.encoding.x.time_unit, super::TimeUnit::Day);
assert_eq!(
modal.aggregate(),
super::Aggregate::Mean,
"a bucket needs one"
);
let rows = modal.row_order();
assert!(rows.contains(&Cumulative) && !rows.contains(&LimitRows));
modal.set_mark(Mark::Bar);
assert_eq!(modal.spec.encoding.x.time_unit, super::TimeUnit::None);
assert!(modal.row_order().contains(&Order));
modal.set_mark(Mark::Histogram);
assert!(modal.row_order().contains(&Bins));
}
fn step_to(modal: &mut ChartModal, to: Aggregate, delta: i8) {
for _ in 0..Aggregate::ALL.len() {
if modal.aggregate() == to {
return;
}
modal.step(ChartFocus::Aggregate, delta);
}
assert_eq!(modal.aggregate(), to);
}
#[test]
fn the_aggregate_steps_through_every_one() {
let mut modal = open_on(Some(("date", &DataType::Date)));
let labels: Vec<&str> = (0..Aggregate::ALL.len())
.map(|_| {
modal.step(ChartFocus::Aggregate, 1);
modal.aggregate().label()
})
.collect();
assert_eq!(
labels,
[
"count", "distinct", "sum", "mean", "median", "stdev", "quantile", "min", "max",
"first", "last", "none"
]
);
modal.step(ChartFocus::Aggregate, -1);
assert_eq!(modal.aggregate(), Aggregate::Last);
}
#[test]
fn cumulative_goes_with_the_aggregate() {
let mut modal = open_on(Some(("date", &DataType::Date)));
modal.spec.encoding.y.aggregate = Aggregate::Sum;
modal.step(ChartFocus::Cumulative, 1);
assert_eq!(modal.spec.encoding.y.cumulative, Cumulative::Sum);
modal.step(ChartFocus::Cumulative, 1);
assert_eq!(modal.spec.encoding.y.cumulative, Cumulative::Compound);
step_to(&mut modal, Aggregate::None, -1);
assert_eq!(modal.spec.encoding.y.cumulative, Cumulative::Off);
}
#[test]
fn color_picks_a_category_then_values_by_count() {
let mut modal = open_on(Some(("delay", &DataType::Float64)));
modal.focus = ChartFocus::Color;
modal.open_picker();
let items = modal.picker.as_ref().unwrap().items().to_vec();
assert_eq!(items, ["none", "carrier", "origin", "year"]);
modal.picker.as_mut().unwrap().select_original(1);
modal.picker_choose();
assert_eq!(modal.color().map(String::as_str), Some("carrier"));
assert!(modal.row_order().contains(&ChartFocus::ColorValues));
modal.focus = ChartFocus::ColorValues;
modal.open_picker();
assert!(modal.picker.is_none());
modal.color_counts = Some(ColorCounts {
column: "carrier".to_string(),
values: (0..COLOR_MAX + 2)
.map(|i| (Some(format!("C{i}")), 100 - i as u64))
.chain([(None, 1)])
.collect(),
});
modal.open_picker();
assert_eq!(modal.picker_details[0], "100");
for _ in 0..COLOR_MAX + 2 {
modal.picker_toggle();
modal.picker.as_mut().unwrap().move_down();
}
assert_eq!(modal.spec.encoding.color.values.len(), COLOR_MAX, "capped");
assert!(modal.is_marked(0));
modal.picker.as_mut().unwrap().select_original(0);
modal.picker_toggle();
assert!(!modal.is_marked(0));
modal.picker_choose();
assert_eq!(modal.spec.encoding.color.values.len(), COLOR_MAX - 1);
modal.focus = ChartFocus::Color;
modal.step(ChartFocus::Color, 1);
assert_eq!(modal.color().map(String::as_str), Some("origin"));
assert!(modal.spec.encoding.color.values.is_empty());
modal.step(ChartFocus::Color, -1);
modal.step(ChartFocus::Color, -1);
assert!(modal.color().is_none(), "none is the first choice");
}
#[test]
fn the_spec_reads_as_vega_lite() {
let mut modal = open_on(Some(("date", &DataType::Date)));
modal.step(ChartFocus::TimeUnit, 3);
modal.spec.encoding.color.field = Some("carrier".to_string());
assert_eq!(
modal.spec.to_vega_lite(),
serde_json::json!({
"mark": "line",
"encoding": {
"x": {"field": "date", "timeUnit": "yearmonth"},
"y": {"field": "delay", "aggregate": "mean"},
"color": {"field": "carrier"},
}
})
);
let saved = serde_json::to_value(&modal.spec).unwrap();
assert_eq!(saved["encoding"]["x"]["timeUnit"], "month");
assert_eq!(saved["encoding"]["y"]["aggregate"], "mean");
}
#[test]
fn the_title_says_how_the_rows_were_made() {
let mut modal = open_on(Some(("date", &DataType::Date)));
modal.step(ChartFocus::TimeUnit, 3);
modal.spec.encoding.y.cumulative = Cumulative::Sum;
modal.spec.encoding.color.field = Some("carrier".to_string());
assert_eq!(modal.how(), "by month, running sum, colored by carrier");
}
#[test]
fn distinct_takes_any_y_and_no_cumulative() {
let mut modal = open_on(Some(("date", &DataType::Date)));
step_to(&mut modal, Aggregate::Distinct, 1);
modal.focus = ChartFocus::Y;
modal.open_picker();
let items = modal.picker.as_ref().unwrap().items().to_vec();
assert!(items.contains(&"carrier".to_string()), "{items:?}");
modal.close_picker();
modal.spec.encoding.y.field = vec!["carrier".to_string()];
modal.step(ChartFocus::TimeUnit, 2);
assert_eq!(modal.how(), "distinct by month");
assert!(!modal.row_order().contains(&ChartFocus::Cumulative));
modal.spec.encoding.y.cumulative = Cumulative::Sum;
modal.step(ChartFocus::Aggregate, 0);
assert_eq!(modal.spec.encoding.y.cumulative, Cumulative::Off);
modal.step(ChartFocus::Aggregate, 1);
assert_eq!(modal.aggregate(), Aggregate::Sum);
assert!(modal.spec.encoding.y.field.is_empty());
assert_eq!(Aggregate::Distinct.vega_lite(90), Some("distinct"));
}
#[test]
fn a_quantile_steps_its_percentile() {
let mut modal = open_on(Some(("date", &DataType::Date)));
step_to(&mut modal, Aggregate::Quantile, 1);
modal.step(ChartFocus::TimeUnit, 2);
assert_eq!(modal.spec.encoding.x.time_unit, TimeUnit::Month);
let rows = modal.row_order();
let at = rows
.iter()
.position(|r| *r == ChartFocus::Aggregate)
.unwrap();
assert_eq!(rows[at + 1], ChartFocus::Quantile);
assert_eq!(modal.how(), "p90 by month");
modal.step(ChartFocus::Quantile, 1);
assert_eq!(modal.spec.encoding.y.quantile(), 95);
assert_eq!(modal.how(), "p95 by month");
modal.step(ChartFocus::Quantile, 1);
modal.step(ChartFocus::Quantile, 1);
assert_eq!(modal.spec.encoding.y.quantile(), 1, "wraps");
assert_eq!(Aggregate::Quantile.vega_lite(25), Some("q1"));
assert_eq!(Aggregate::Quantile.vega_lite(75), Some("q3"));
assert_eq!(Aggregate::Quantile.vega_lite(90), None);
assert_eq!(Aggregate::Last.vega_lite(90), None);
for aggregate in [
Aggregate::Stdev,
Aggregate::Quantile,
Aggregate::First,
Aggregate::Last,
] {
modal.spec.encoding.y.aggregate = aggregate;
assert!(!modal.row_order().contains(&ChartFocus::Cumulative));
}
modal.spec.encoding.y.aggregate = Aggregate::Last;
assert!(!modal.row_order().contains(&ChartFocus::Quantile));
assert_eq!(modal.how(), "last by month");
}
#[test]
fn the_how_names_no_y_column() {
let mut modal = open_on(Some(("date", &DataType::Date)));
modal.step(ChartFocus::TimeUnit, 3);
assert_eq!(modal.how(), "mean by month");
modal.set_mark(Mark::Scatter);
modal.spec.encoding.x.time_unit = TimeUnit::None;
modal.spec.encoding.y.aggregate = Aggregate::None;
assert_eq!(modal.how(), "");
modal.spec.encoding.color.field = Some("carrier".to_string());
assert_eq!(modal.how(), "colored by carrier");
modal.spec.encoding.color.field = None;
modal.spec.encoding.y.aggregate = Aggregate::Mean;
assert_eq!(modal.how(), "mean by date");
modal.set_mark(Mark::Histogram);
assert_eq!(modal.how(), "count per bin");
modal.set_mark(Mark::Kde);
assert_eq!(modal.how(), "");
modal.set_mark(Mark::Heatmap);
assert_eq!(modal.how(), "rows per cell");
for mark in Mark::ALL {
modal.set_mark(mark);
assert!(!modal.how().contains("delay"), "{mark:?}: {}", modal.how());
}
}
#[test]
fn reopening_from_the_same_column_keeps_the_chart() {
let mut modal = open_on(Some(("delay", &DataType::Float64)));
modal.set_mark(Mark::Kde);
modal.toggle_grid();
modal.close();
let c = cols();
let columns = ChartColumns {
numeric: &c.numeric,
datetime: &c.datetime,
bucketable: &c.datetime,
category: &c.category,
};
modal.open(columns, Some(("delay", &DataType::Float64)), None, false, 1);
assert_eq!(modal.mark(), Mark::Kde);
assert!(modal.grid);
modal.open(
columns,
Some(("carrier", &DataType::String)),
None,
false,
1,
);
assert_eq!(modal.mark(), Mark::Bar);
assert!(!modal.grid);
}
#[test]
fn the_y_picker_leaves_out_x_and_caps_its_series() {
let mut modal = open_on(Some(("date", &DataType::Date)));
modal.spec.encoding.x.field = Some("delay".to_string());
modal.spec.encoding.y.field.clear();
modal.focus = ChartFocus::Y;
modal.open_picker();
assert_eq!(modal.picker.as_ref().unwrap().items(), ["distance", "year"]);
assert!(modal.picker_multi());
modal.picker_choose();
assert_eq!(
modal.y(),
["distance"],
"Enter on a fresh list takes the cursor"
);
}
#[test]
fn number_rows_route_by_the_type() {
let mut modal = open_on(Some(("delay", &DataType::Float64)));
modal.focus = ChartFocus::Bins;
modal.adjust_number_row(1);
assert_eq!(modal.hist_bins, HISTOGRAM_DEFAULT_BINS + 1);
modal.set_mark(Mark::Heatmap);
modal.focus = ChartFocus::Bins;
modal.adjust_number_row(-1);
assert_eq!(modal.heatmap_bins, HEATMAP_DEFAULT_BINS - 1);
}
#[test]
fn rows_change_is_read_on_enter() {
let mut modal = open_on(Some(("delay", &DataType::Float64)));
modal.view_rows = Some(36_800_000);
modal.focus = ChartFocus::LimitRows;
assert_eq!(modal.row_limit, Some(10_000));
modal.step(ChartFocus::LimitRows, 1);
assert!(modal.rows_shown().every && modal.rows_pending());
assert_eq!(modal.row_limit, Some(10_000), "pending until Enter");
modal.step(ChartFocus::LimitRows, -1);
assert!(!modal.rows_pending(), "back where it was");
modal.step(ChartFocus::LimitRows, 1);
assert!(modal.commit_rows());
assert_eq!(modal.row_limit, None);
for c in "250kx".chars() {
modal.type_rows(c);
}
modal.backspace_rows();
assert_eq!(modal.rows_shown().typed.as_deref(), Some("250k"));
assert!(!modal.rows_shown().every);
modal.discard_rows();
assert_eq!(modal.row_limit, None);
assert!(modal.rows_shown().every);
for c in "250k".chars() {
modal.type_rows(c);
}
assert!(modal.commit_rows());
assert_eq!(modal.row_limit, Some(250_000));
assert_eq!(modal.rows_draft, None);
modal.type_rows('0');
assert!(!modal.commit_rows());
assert!(modal.rows_shown().error.is_some());
assert_eq!(modal.row_limit, Some(250_000));
modal.backspace_rows();
modal.type_rows('2');
modal.type_rows('m');
crate::form::Form::focus(&mut modal, ChartFocus::Type);
assert_eq!(modal.row_limit, Some(2_000_000));
modal.focus = ChartFocus::LimitRows;
for c in "40m".chars() {
modal.type_rows(c);
}
assert!(modal.commit_rows());
assert_eq!(modal.row_limit, None);
modal.step(ChartFocus::LimitRows, 1);
assert!(modal.commit_rows());
assert_eq!(modal.row_limit, Some(2_000_000));
}
#[test]
fn a_bucket_needs_an_aggregate_and_a_date() {
let mut modal = open_on(Some(("date", &DataType::Date)));
modal.step(ChartFocus::TimeUnit, 3);
assert_eq!(modal.spec.encoding.x.time_unit, TimeUnit::Month);
modal.spec.encoding.y.aggregate = Aggregate::Sum;
step_to(&mut modal, Aggregate::None, -1);
assert_eq!(modal.spec.encoding.x.time_unit, TimeUnit::None);
let numeric = s(&["delay"]);
let datetime = s(&["date", "clock"]);
let mut modal = ChartModal::new();
modal.open(
ChartColumns {
numeric: &numeric,
datetime: &datetime,
bucketable: &datetime[..1],
category: &[],
},
Some(("clock", &DataType::Time)),
None,
false,
1,
);
assert_eq!(modal.mark(), Mark::Line);
assert!(!modal.row_order().contains(&ChartFocus::TimeUnit));
}
#[test]
fn an_aggregate_on_a_date_starts_by_the_day() {
let mut modal = open_on(Some(("date", &DataType::Date)));
assert_eq!(modal.spec.encoding.x.time_unit, TimeUnit::None);
modal.step(ChartFocus::Aggregate, 1);
assert_eq!(modal.spec.encoding.x.time_unit, TimeUnit::Day);
step_to(&mut modal, Aggregate::Mean, 1);
assert_eq!(modal.spec.encoding.x.time_unit, TimeUnit::Day);
modal.step(ChartFocus::TimeUnit, -1);
assert_eq!(
modal.spec.encoding.x.time_unit,
TimeUnit::None,
"still the user's call"
);
}
#[test]
fn a_box_from_an_integer_histogram_has_no_category() {
let mut modal = open_on(Some(("year", &DataType::Int64)));
modal.set_mark(Mark::Box);
assert_eq!(modal.y(), ["year"]);
assert!(modal.x().is_none());
}
#[test]
fn color_use_reads_the_spec_charted() {
let mut modal = open_on(Some(("date", &DataType::Date)));
modal.spec.encoding.color.field = Some("carrier".to_string());
assert!(modal.colored());
let mut spec = modal.spec.clone();
spec.encoding.y.field.push("distance".to_string());
assert!(!ChartModal::colored_in(&spec));
}
}