diff --git a/doc/syntax/layer/density.qmd b/doc/syntax/layer/density.qmd index 8b8500f0..97319e31 100644 --- a/doc/syntax/layer/density.qmd +++ b/doc/syntax/layer/density.qmd @@ -73,14 +73,14 @@ A typical KDE computation with different groups: ```{ggsql} VISUALISE bill_dep AS x, species AS colour FROM ggsql:penguins - DRAW density SETTING opacity => 0.8 + DRAW density ``` Changing the relative bandwidth through the `adjust` setting. ```{ggsql} VISUALISE bill_dep AS x, species AS colour FROM ggsql:penguins - DRAW density SETTING opacity => 0.8, adjust => 0.1 + DRAW density SETTING adjust => 0.1 ``` Stacking the different groups instead of overlaying them. @@ -94,9 +94,7 @@ Using weighted estimates by mapping a column to the optional weight aesthetic. N ```{ggsql} VISUALISE bill_dep AS x, species AS colour FROM ggsql:penguins - DRAW density - MAPPING body_mass AS weight - SETTING opacity => 0.8 + DRAW density MAPPING body_mass AS weight ``` If you want to compare a histogram and a density layer, you can use the `intensity` computed variable to match the histogram scale. @@ -114,8 +112,6 @@ Note the relative height of the groups. ```{ggsql} VISUALISE bill_dep AS x, species AS colour FROM ggsql:penguins - DRAW density - REMAPPING intensity AS y - SETTING opacity => 0.8 + DRAW density REMAPPING intensity AS y ``` diff --git a/doc/syntax/layer/point.qmd b/doc/syntax/layer/point.qmd index f8e7b887..a1f181cb 100644 --- a/doc/syntax/layer/point.qmd +++ b/doc/syntax/layer/point.qmd @@ -35,7 +35,6 @@ Create a classic scatterplot VISUALISE FROM ggsql:penguins DRAW point MAPPING bill_len AS x, bill_dep AS y, species AS fill - SETTING size => 30 ``` Map to size to create a bubble chart @@ -52,6 +51,5 @@ Use filter to only plot a subset of the data VISUALISE FROM ggsql:penguins DRAW point MAPPING bill_len AS x, bill_dep AS y, species AS fill - SETTING size => 30 FILTER sex = 'female' ``` diff --git a/src/execute/mod.rs b/src/execute/mod.rs index 3d29d087..5b793fd2 100644 --- a/src/execute/mod.rs +++ b/src/execute/mod.rs @@ -50,7 +50,7 @@ use crate::reader::DuckDBReader; fn validate(layers: &[Layer], layer_schemas: &[Schema]) -> Result<()> { for (idx, (layer, schema)) in layers.iter().zip(layer_schemas.iter()).enumerate() { let schema_columns: HashSet<&str> = schema.iter().map(|c| c.name.as_str()).collect(); - let supported = layer.geom.aesthetics().supported; + let supported = layer.geom.aesthetics().supported(); // Validate required aesthetics for this geom layer @@ -97,14 +97,10 @@ fn validate(layers: &[Layer], layer_schemas: &[Schema]) -> Result<()> { } // Validate remapping target aesthetics are supported by geom - // Target can be in supported OR hidden (hidden = valid REMAPPING targets but not MAPPING targets) + // REMAPPING can target any aesthetic (including Delayed ones from stat transforms) let aesthetics_info = layer.geom.aesthetics(); for target_aesthetic in layer.remappings.aesthetics.keys() { - let is_supported = aesthetics_info - .supported - .contains(&target_aesthetic.as_str()); - let is_hidden = aesthetics_info.hidden.contains(&target_aesthetic.as_str()); - if !is_supported && !is_hidden { + if !aesthetics_info.contains(target_aesthetic) { return Err(GgsqlError::ValidationError(format!( "Layer {}: REMAPPING targets unsupported aesthetic '{}' for geom '{}'", idx + 1, @@ -157,7 +153,7 @@ fn validate(layers: &[Layer], layer_schemas: &[Schema]) -> Result<()> { fn merge_global_mappings_into_layers(specs: &mut [Plot], layer_schemas: &[Schema]) { for spec in specs { for (layer, schema) in spec.layers.iter_mut().zip(layer_schemas.iter()) { - let supported = layer.geom.aesthetics().supported; + let supported = layer.geom.aesthetics().supported(); let schema_columns: HashSet<&str> = schema.iter().map(|c| c.name.as_str()).collect(); // 1. First merge explicit global aesthetics (layer overrides global) @@ -180,14 +176,14 @@ fn merge_global_mappings_into_layers(specs: &mut [Plot], layer_schemas: &[Schema // 2. Smart wildcard expansion: only expand to columns that exist in schema let has_wildcard = layer.mappings.wildcard || spec.global_mappings.wildcard; if has_wildcard { - for &aes in supported { + for aes in &supported { // Only create mapping if column exists in the schema - if schema_columns.contains(aes) { + if schema_columns.contains(*aes) { layer .mappings .aesthetics .entry(crate::parser::builder::normalise_aes_name(aes)) - .or_insert(AestheticValue::standard_column(aes)); + .or_insert(AestheticValue::standard_column(*aes)); } } } @@ -228,10 +224,10 @@ fn split_color_aesthetic(spec: &mut Plot) { // 2. Split color mapping to fill/stroke in layers, then remove color for layer in &mut spec.layers { if let Some(color_value) = layer.mappings.aesthetics.get("color").cloned() { - let supported = layer.geom.aesthetics().supported; + let aesthetics = layer.geom.aesthetics(); for &aes in &["stroke", "fill"] { - if supported.contains(&aes) { + if aesthetics.is_supported(aes) { layer .mappings .aesthetics @@ -248,10 +244,10 @@ fn split_color_aesthetic(spec: &mut Plot) { // 3. Split color parameter (SETTING) to fill/stroke in layers for layer in &mut spec.layers { if let Some(color_value) = layer.parameters.get("color").cloned() { - let supported = layer.geom.aesthetics().supported; + let aesthetics = layer.geom.aesthetics(); for &aes in &["stroke", "fill"] { - if supported.contains(&aes) { + if aesthetics.is_supported(aes) { layer .parameters .entry(aes.to_string()) @@ -1127,6 +1123,11 @@ pub fn prepare_data_with_reader(query: &str, reader: &R) -> Result GeomAesthetics { - GeomAesthetics { - supported: &[ - "slope", - "intercept", - "stroke", - "linetype", - "linewidth", - "opacity", + fn aesthetics(&self) -> DefaultAesthetics { + DefaultAesthetics { + defaults: &[ + ("slope", DefaultAestheticValue::Required), + ("intercept", DefaultAestheticValue::Required), + ("stroke", DefaultAestheticValue::String("black")), + ("linewidth", DefaultAestheticValue::Number(1.0)), + ("opacity", DefaultAestheticValue::Number(1.0)), + ("linetype", DefaultAestheticValue::String("solid")), ], - required: &["slope", "intercept"], - hidden: &[], } } } diff --git a/src/plot/layer/geom/area.rs b/src/plot/layer/geom/area.rs index f191bc16..5df42c4c 100644 --- a/src/plot/layer/geom/area.rs +++ b/src/plot/layer/geom/area.rs @@ -1,8 +1,8 @@ //! Area geom implementation -use crate::plot::{DefaultParam, DefaultParamValue}; +use crate::plot::{types::DefaultAestheticValue, DefaultParam, DefaultParamValue}; -use super::{GeomAesthetics, GeomTrait, GeomType}; +use super::{DefaultAesthetics, GeomTrait, GeomType}; /// Area geom - filled area charts #[derive(Debug, Clone, Copy)] @@ -13,19 +13,17 @@ impl GeomTrait for Area { GeomType::Area } - fn aesthetics(&self) -> GeomAesthetics { - GeomAesthetics { - supported: &[ - "x", - "y", - "fill", - "stroke", - "opacity", - "linewidth", - // "linetype", // vegalite doesn't support strokeDash + fn aesthetics(&self) -> DefaultAesthetics { + DefaultAesthetics { + defaults: &[ + ("x", DefaultAestheticValue::Required), + ("y", DefaultAestheticValue::Required), + ("fill", DefaultAestheticValue::String("black")), + ("stroke", DefaultAestheticValue::String("black")), + ("opacity", DefaultAestheticValue::Number(0.8)), + ("linewidth", DefaultAestheticValue::Number(1.0)), + ("linetype", DefaultAestheticValue::String("solid")), ], - required: &["x", "y"], - hidden: &[], } } diff --git a/src/plot/layer/geom/arrow.rs b/src/plot/layer/geom/arrow.rs index 2baa5396..673a51dd 100644 --- a/src/plot/layer/geom/arrow.rs +++ b/src/plot/layer/geom/arrow.rs @@ -1,6 +1,7 @@ //! Arrow geom implementation -use super::{GeomAesthetics, GeomTrait, GeomType}; +use super::{DefaultAesthetics, GeomTrait, GeomType}; +use crate::plot::types::DefaultAestheticValue; /// Arrow geom - line segments with arrowheads #[derive(Debug, Clone, Copy)] @@ -11,20 +12,19 @@ impl GeomTrait for Arrow { GeomType::Arrow } - fn aesthetics(&self) -> GeomAesthetics { - GeomAesthetics { - supported: &[ - "x", - "y", - "xend", - "yend", - "stroke", - "linetype", - "linewidth", - "opacity", + fn aesthetics(&self) -> DefaultAesthetics { + DefaultAesthetics { + defaults: &[ + ("x", DefaultAestheticValue::Required), + ("y", DefaultAestheticValue::Required), + ("xend", DefaultAestheticValue::Required), + ("yend", DefaultAestheticValue::Required), + ("stroke", DefaultAestheticValue::String("black")), + ("linewidth", DefaultAestheticValue::Number(1.0)), + ("opacity", DefaultAestheticValue::Number(1.0)), + ("linetype", DefaultAestheticValue::String("solid")), + ("fill", DefaultAestheticValue::Null), ], - required: &["x", "y", "xend", "yend"], - hidden: &[], } } } diff --git a/src/plot/layer/geom/bar.rs b/src/plot/layer/geom/bar.rs index 7ab7e2bf..763a6774 100644 --- a/src/plot/layer/geom/bar.rs +++ b/src/plot/layer/geom/bar.rs @@ -4,7 +4,7 @@ use std::collections::HashMap; use std::collections::HashSet; use super::types::get_column_name; -use super::{DefaultParam, DefaultParamValue, GeomAesthetics, GeomTrait, GeomType, StatResult}; +use super::{DefaultAesthetics, DefaultParam, DefaultParamValue, GeomTrait, GeomType, StatResult}; use crate::naming; use crate::plot::types::{DefaultAestheticValue, ParameterValue}; use crate::{DataFrame, GgsqlError, Mappings, Result}; @@ -20,15 +20,23 @@ impl GeomTrait for Bar { GeomType::Bar } - fn aesthetics(&self) -> GeomAesthetics { - GeomAesthetics { + fn aesthetics(&self) -> DefaultAesthetics { + DefaultAesthetics { // Bar supports optional x and y - stat decides aggregation // If x is missing: single bar showing total // If y is missing: stat computes COUNT or SUM(weight) // weight: optional, if mapped uses SUM(weight) instead of COUNT(*) - supported: &["x", "y", "weight", "fill", "stroke", "width", "opacity"], - required: &[], - hidden: &[], + // width is a parameter, not an aesthetic. + // if we ever want to make 'width' an aesthetic, we'd probably need to + // translate it to 'size'. + defaults: &[ + ("x", DefaultAestheticValue::Null), // Optional - stat may provide + ("y", DefaultAestheticValue::Null), // Optional - stat may compute + ("weight", DefaultAestheticValue::Null), + ("fill", DefaultAestheticValue::String("black")), + ("stroke", DefaultAestheticValue::String("black")), + ("opacity", DefaultAestheticValue::Number(0.8)), + ], } } diff --git a/src/plot/layer/geom/boxplot.rs b/src/plot/layer/geom/boxplot.rs index 8a95ee85..e3c513ad 100644 --- a/src/plot/layer/geom/boxplot.rs +++ b/src/plot/layer/geom/boxplot.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; -use super::{GeomAesthetics, GeomTrait, GeomType}; +use super::{DefaultAesthetics, GeomTrait, GeomType}; use crate::{ naming, plot::{ @@ -21,22 +21,22 @@ impl GeomTrait for Boxplot { GeomType::Boxplot } - fn aesthetics(&self) -> GeomAesthetics { - GeomAesthetics { - supported: &[ - "x", - "y", - "fill", - "stroke", - "opacity", - "linetype", - "linewidth", - "size", - "shape", + fn aesthetics(&self) -> DefaultAesthetics { + DefaultAesthetics { + defaults: &[ + ("x", DefaultAestheticValue::Required), + ("y", DefaultAestheticValue::Required), + ("stroke", DefaultAestheticValue::String("black")), + ("fill", DefaultAestheticValue::String("white")), + ("linewidth", DefaultAestheticValue::Number(1.0)), + ("opacity", DefaultAestheticValue::Number(0.8)), + ("linetype", DefaultAestheticValue::String("solid")), + ("size", DefaultAestheticValue::Number(3.0)), + ("shape", DefaultAestheticValue::String("circle")), + // Internal aesthetics produced by stat transform + ("type", DefaultAestheticValue::Delayed), + ("yend", DefaultAestheticValue::Delayed), ], - required: &["x", "y"], - // Internal aesthetics produced by stat transform - hidden: &["type", "y", "yend"], } } @@ -547,9 +547,9 @@ mod tests { let boxplot = Boxplot; let aes = boxplot.aesthetics(); - assert!(aes.required.contains(&"x")); - assert!(aes.required.contains(&"y")); - assert_eq!(aes.required.len(), 2); + assert!(aes.is_required("x")); + assert!(aes.is_required("y")); + assert_eq!(aes.required().len(), 2); } #[test] @@ -557,11 +557,11 @@ mod tests { let boxplot = Boxplot; let aes = boxplot.aesthetics(); - assert!(aes.supported.contains(&"x")); - assert!(aes.supported.contains(&"y")); - assert!(aes.supported.contains(&"fill")); - assert!(aes.supported.contains(&"stroke")); - assert!(aes.supported.contains(&"opacity")); + assert!(aes.is_supported("x")); + assert!(aes.is_supported("y")); + assert!(aes.is_supported("fill")); + assert!(aes.is_supported("stroke")); + assert!(aes.is_supported("opacity")); } #[test] diff --git a/src/plot/layer/geom/density.rs b/src/plot/layer/geom/density.rs index ff3c9e19..2651b9eb 100644 --- a/src/plot/layer/geom/density.rs +++ b/src/plot/layer/geom/density.rs @@ -1,6 +1,6 @@ //! Density geom implementation -use super::{GeomAesthetics, GeomTrait, GeomType}; +use super::{DefaultAesthetics, GeomTrait, GeomType}; use crate::{ naming, plot::{ @@ -24,19 +24,18 @@ impl GeomTrait for Density { GeomType::Density } - fn aesthetics(&self) -> GeomAesthetics { - GeomAesthetics { - supported: &[ - "x", - "weight", - "fill", - "stroke", - "opacity", - "linewidth", - "linetype", + fn aesthetics(&self) -> DefaultAesthetics { + DefaultAesthetics { + defaults: &[ + ("x", DefaultAestheticValue::Required), + ("weight", DefaultAestheticValue::Null), + ("fill", DefaultAestheticValue::String("black")), + ("stroke", DefaultAestheticValue::String("black")), + ("opacity", DefaultAestheticValue::Number(0.8)), + ("linewidth", DefaultAestheticValue::Number(1.0)), + ("linetype", DefaultAestheticValue::String("solid")), + ("y", DefaultAestheticValue::Delayed), // Computed by stat ], - required: &["x"], - hidden: &["y"], } } diff --git a/src/plot/layer/geom/errorbar.rs b/src/plot/layer/geom/errorbar.rs index eb007d60..3e23876c 100644 --- a/src/plot/layer/geom/errorbar.rs +++ b/src/plot/layer/geom/errorbar.rs @@ -1,6 +1,7 @@ //! ErrorBar geom implementation -use super::{GeomAesthetics, GeomTrait, GeomType}; +use super::{DefaultAesthetics, GeomTrait, GeomType}; +use crate::plot::types::DefaultAestheticValue; /// ErrorBar geom - error bars (confidence intervals) #[derive(Debug, Clone, Copy)] @@ -11,21 +12,19 @@ impl GeomTrait for ErrorBar { GeomType::ErrorBar } - fn aesthetics(&self) -> GeomAesthetics { - GeomAesthetics { - supported: &[ - "x", - "y", - "ymin", - "ymax", - "xmin", - "xmax", - "stroke", - "linewidth", - "opacity", + fn aesthetics(&self) -> DefaultAesthetics { + DefaultAesthetics { + defaults: &[ + ("x", DefaultAestheticValue::Null), + ("y", DefaultAestheticValue::Null), + ("ymin", DefaultAestheticValue::Null), + ("ymax", DefaultAestheticValue::Null), + ("xmin", DefaultAestheticValue::Null), + ("xmax", DefaultAestheticValue::Null), + ("stroke", DefaultAestheticValue::String("black")), + ("linewidth", DefaultAestheticValue::Number(1.0)), + ("opacity", DefaultAestheticValue::Number(1.0)), ], - required: &[], - hidden: &[], } } } diff --git a/src/plot/layer/geom/histogram.rs b/src/plot/layer/geom/histogram.rs index 2a4358ae..8c3651d2 100644 --- a/src/plot/layer/geom/histogram.rs +++ b/src/plot/layer/geom/histogram.rs @@ -3,7 +3,7 @@ use std::collections::HashMap; use super::types::get_column_name; -use super::{DefaultParam, DefaultParamValue, GeomAesthetics, GeomTrait, GeomType, StatResult}; +use super::{DefaultAesthetics, DefaultParam, DefaultParamValue, GeomTrait, GeomType, StatResult}; use crate::naming; use crate::plot::types::{DefaultAestheticValue, ParameterValue}; use crate::{DataFrame, GgsqlError, Mappings, Result}; @@ -19,12 +19,18 @@ impl GeomTrait for Histogram { GeomType::Histogram } - fn aesthetics(&self) -> GeomAesthetics { - GeomAesthetics { - supported: &["x", "weight", "fill", "stroke", "opacity"], - required: &["x"], - // y and xend are produced by stat_histogram but not valid for manual MAPPING - hidden: &["y", "xend"], + fn aesthetics(&self) -> DefaultAesthetics { + DefaultAesthetics { + defaults: &[ + ("x", DefaultAestheticValue::Required), + ("weight", DefaultAestheticValue::Null), + ("fill", DefaultAestheticValue::String("black")), + ("stroke", DefaultAestheticValue::String("black")), + ("opacity", DefaultAestheticValue::Number(0.8)), + // y and xend are produced by stat_histogram but not valid for manual MAPPING + ("y", DefaultAestheticValue::Delayed), + ("xend", DefaultAestheticValue::Delayed), + ], } } diff --git a/src/plot/layer/geom/hline.rs b/src/plot/layer/geom/hline.rs index 1843cc19..d6ad6951 100644 --- a/src/plot/layer/geom/hline.rs +++ b/src/plot/layer/geom/hline.rs @@ -1,6 +1,7 @@ //! HLine geom implementation -use super::{GeomAesthetics, GeomTrait, GeomType}; +use super::{DefaultAesthetics, GeomTrait, GeomType}; +use crate::plot::types::DefaultAestheticValue; /// HLine geom - horizontal reference lines #[derive(Debug, Clone, Copy)] @@ -11,11 +12,15 @@ impl GeomTrait for HLine { GeomType::HLine } - fn aesthetics(&self) -> GeomAesthetics { - GeomAesthetics { - supported: &["yintercept", "stroke", "linetype", "linewidth", "opacity"], - required: &["yintercept"], - hidden: &[], + fn aesthetics(&self) -> DefaultAesthetics { + DefaultAesthetics { + defaults: &[ + ("yintercept", DefaultAestheticValue::Required), + ("stroke", DefaultAestheticValue::String("black")), + ("linewidth", DefaultAestheticValue::Number(1.0)), + ("opacity", DefaultAestheticValue::Number(1.0)), + ("linetype", DefaultAestheticValue::String("solid")), + ], } } } diff --git a/src/plot/layer/geom/label.rs b/src/plot/layer/geom/label.rs index 8481898a..e5c92111 100644 --- a/src/plot/layer/geom/label.rs +++ b/src/plot/layer/geom/label.rs @@ -1,6 +1,7 @@ //! Label geom implementation -use super::{GeomAesthetics, GeomTrait, GeomType}; +use super::{DefaultAesthetics, GeomTrait, GeomType}; +use crate::plot::types::DefaultAestheticValue; /// Label geom - text labels with background #[derive(Debug, Clone, Copy)] @@ -11,14 +12,21 @@ impl GeomTrait for Label { GeomType::Label } - fn aesthetics(&self) -> GeomAesthetics { - GeomAesthetics { - supported: &[ - "x", "y", "label", "fill", "stroke", "size", "opacity", "family", "fontface", - "hjust", "vjust", + fn aesthetics(&self) -> DefaultAesthetics { + DefaultAesthetics { + defaults: &[ + ("x", DefaultAestheticValue::Required), + ("y", DefaultAestheticValue::Required), + ("label", DefaultAestheticValue::Null), + ("fill", DefaultAestheticValue::Null), + ("stroke", DefaultAestheticValue::Null), + ("size", DefaultAestheticValue::Number(11.0)), + ("opacity", DefaultAestheticValue::Number(1.0)), + ("family", DefaultAestheticValue::Null), + ("fontface", DefaultAestheticValue::Null), + ("hjust", DefaultAestheticValue::Null), + ("vjust", DefaultAestheticValue::Null), ], - required: &["x", "y"], - hidden: &[], } } } diff --git a/src/plot/layer/geom/line.rs b/src/plot/layer/geom/line.rs index f26d7494..c8c58494 100644 --- a/src/plot/layer/geom/line.rs +++ b/src/plot/layer/geom/line.rs @@ -1,6 +1,7 @@ //! Line geom implementation -use super::{GeomAesthetics, GeomTrait, GeomType}; +use super::{DefaultAesthetics, GeomTrait, GeomType}; +use crate::plot::types::DefaultAestheticValue; /// Line geom - line charts with connected points #[derive(Debug, Clone, Copy)] @@ -11,11 +12,16 @@ impl GeomTrait for Line { GeomType::Line } - fn aesthetics(&self) -> GeomAesthetics { - GeomAesthetics { - supported: &["x", "y", "stroke", "linetype", "linewidth", "opacity"], - required: &["x", "y"], - hidden: &[], + fn aesthetics(&self) -> DefaultAesthetics { + DefaultAesthetics { + defaults: &[ + ("x", DefaultAestheticValue::Required), + ("y", DefaultAestheticValue::Required), + ("stroke", DefaultAestheticValue::String("black")), + ("linewidth", DefaultAestheticValue::Number(1.5)), + ("opacity", DefaultAestheticValue::Number(1.0)), + ("linetype", DefaultAestheticValue::String("solid")), + ], } } } diff --git a/src/plot/layer/geom/mod.rs b/src/plot/layer/geom/mod.rs index 0b34b245..38aa90ca 100644 --- a/src/plot/layer/geom/mod.rs +++ b/src/plot/layer/geom/mod.rs @@ -17,7 +17,7 @@ //! //! let point = Geom::point(); //! assert_eq!(point.geom_type(), GeomType::Point); -//! assert!(point.aesthetics().required.contains(&"x")); +//! assert!(point.aesthetics().is_required("x")); //! ``` use crate::{DataFrame, Mappings, Result}; @@ -51,7 +51,7 @@ mod violin; mod vline; // Re-export types -pub use types::{DefaultParam, DefaultParamValue, GeomAesthetics, StatResult}; +pub use types::{DefaultAesthetics, DefaultParam, DefaultParamValue, StatResult}; // Re-export aesthetic family utilities from the central module pub use crate::plot::aesthetic::{get_aesthetic_family, AESTHETIC_FAMILIES}; @@ -146,7 +146,7 @@ pub trait GeomTrait: std::fmt::Debug + std::fmt::Display + Send + Sync { fn geom_type(&self) -> GeomType; /// Returns aesthetic information (REQUIRED - each geom is different) - fn aesthetics(&self) -> GeomAesthetics; + fn aesthetics(&self) -> DefaultAesthetics; /// Returns default remappings for stat-computed columns and literals to aesthetics. /// @@ -213,7 +213,7 @@ pub trait GeomTrait: std::fmt::Debug + std::fmt::Display + Send + Sync { /// /// Combines supported aesthetics with non-aesthetic parameters. fn valid_settings(&self) -> Vec<&'static str> { - let mut valid: Vec<&'static str> = self.aesthetics().supported.to_vec(); + let mut valid: Vec<&'static str> = self.aesthetics().supported(); for param in self.default_params() { valid.push(param.name); } @@ -367,7 +367,7 @@ impl Geom { } /// Get aesthetics information - pub fn aesthetics(&self) -> GeomAesthetics { + pub fn aesthetics(&self) -> DefaultAesthetics { self.0.aesthetics() } @@ -506,8 +506,8 @@ mod tests { fn test_geom_aesthetics() { let point = Geom::point(); let aes = point.aesthetics(); - assert!(aes.required.contains(&"x")); - assert!(aes.required.contains(&"y")); + assert!(aes.is_required("x")); + assert!(aes.is_required("y")); } #[test] diff --git a/src/plot/layer/geom/path.rs b/src/plot/layer/geom/path.rs index f289032c..543ae821 100644 --- a/src/plot/layer/geom/path.rs +++ b/src/plot/layer/geom/path.rs @@ -1,6 +1,7 @@ //! Path geom implementation -use super::{GeomAesthetics, GeomTrait, GeomType}; +use super::{DefaultAesthetics, GeomTrait, GeomType}; +use crate::plot::types::DefaultAestheticValue; /// Path geom - connected line segments in order #[derive(Debug, Clone, Copy)] @@ -11,11 +12,16 @@ impl GeomTrait for Path { GeomType::Path } - fn aesthetics(&self) -> GeomAesthetics { - GeomAesthetics { - supported: &["x", "y", "stroke", "linetype", "linewidth", "opacity"], - required: &["x", "y"], - hidden: &[], + fn aesthetics(&self) -> DefaultAesthetics { + DefaultAesthetics { + defaults: &[ + ("x", DefaultAestheticValue::Required), + ("y", DefaultAestheticValue::Required), + ("stroke", DefaultAestheticValue::String("black")), + ("linewidth", DefaultAestheticValue::Number(1.5)), + ("opacity", DefaultAestheticValue::Number(1.0)), + ("linetype", DefaultAestheticValue::String("solid")), + ], } } } diff --git a/src/plot/layer/geom/point.rs b/src/plot/layer/geom/point.rs index 0e925795..8ede18a1 100644 --- a/src/plot/layer/geom/point.rs +++ b/src/plot/layer/geom/point.rs @@ -1,6 +1,7 @@ //! Point geom implementation -use super::{GeomAesthetics, GeomTrait, GeomType}; +use super::{DefaultAesthetics, GeomTrait, GeomType}; +use crate::plot::types::DefaultAestheticValue; /// Point geom - scatter plots and similar #[derive(Debug, Clone, Copy)] @@ -11,20 +12,18 @@ impl GeomTrait for Point { GeomType::Point } - fn aesthetics(&self) -> GeomAesthetics { - GeomAesthetics { - supported: &[ - "x", - "y", - "fill", - "stroke", - "size", - "shape", - "opacity", - "linewidth", + fn aesthetics(&self) -> DefaultAesthetics { + DefaultAesthetics { + defaults: &[ + ("x", DefaultAestheticValue::Required), + ("y", DefaultAestheticValue::Required), + ("size", DefaultAestheticValue::Number(3.0)), + ("stroke", DefaultAestheticValue::String("black")), + ("fill", DefaultAestheticValue::String("black")), + ("opacity", DefaultAestheticValue::Number(0.8)), + ("shape", DefaultAestheticValue::String("circle")), + ("linewidth", DefaultAestheticValue::Number(1.0)), ], - required: &["x", "y"], - hidden: &[], } } } diff --git a/src/plot/layer/geom/polygon.rs b/src/plot/layer/geom/polygon.rs index e232db34..ad5c1098 100644 --- a/src/plot/layer/geom/polygon.rs +++ b/src/plot/layer/geom/polygon.rs @@ -1,6 +1,7 @@ //! Polygon geom implementation -use super::{GeomAesthetics, GeomTrait, GeomType}; +use super::{DefaultAesthetics, GeomTrait, GeomType}; +use crate::plot::types::DefaultAestheticValue; /// Polygon geom - arbitrary polygons #[derive(Debug, Clone, Copy)] @@ -11,19 +12,17 @@ impl GeomTrait for Polygon { GeomType::Polygon } - fn aesthetics(&self) -> GeomAesthetics { - GeomAesthetics { - supported: &[ - "x", - "y", - "fill", - "stroke", - "opacity", - "linewidth", - "linetype", + fn aesthetics(&self) -> DefaultAesthetics { + DefaultAesthetics { + defaults: &[ + ("x", DefaultAestheticValue::Required), + ("y", DefaultAestheticValue::Required), + ("fill", DefaultAestheticValue::String("black")), + ("stroke", DefaultAestheticValue::String("black")), + ("opacity", DefaultAestheticValue::Number(0.8)), + ("linewidth", DefaultAestheticValue::Number(1.0)), + ("linetype", DefaultAestheticValue::String("solid")), ], - required: &["x", "y"], - hidden: &[], } } } diff --git a/src/plot/layer/geom/ribbon.rs b/src/plot/layer/geom/ribbon.rs index c6d62073..6cf81638 100644 --- a/src/plot/layer/geom/ribbon.rs +++ b/src/plot/layer/geom/ribbon.rs @@ -1,6 +1,7 @@ //! Ribbon geom implementation -use super::{GeomAesthetics, GeomTrait, GeomType}; +use super::{DefaultAesthetics, GeomTrait, GeomType}; +use crate::plot::types::DefaultAestheticValue; /// Ribbon geom - confidence bands and ranges #[derive(Debug, Clone, Copy)] @@ -11,20 +12,18 @@ impl GeomTrait for Ribbon { GeomType::Ribbon } - fn aesthetics(&self) -> GeomAesthetics { - GeomAesthetics { - supported: &[ - "x", - "ymin", - "ymax", - "fill", - "stroke", - "opacity", - "linewidth", - // "linetype" // vegalite doesn't support strokeDash + fn aesthetics(&self) -> DefaultAesthetics { + DefaultAesthetics { + defaults: &[ + ("x", DefaultAestheticValue::Required), + ("ymin", DefaultAestheticValue::Required), + ("ymax", DefaultAestheticValue::Required), + ("fill", DefaultAestheticValue::String("black")), + ("stroke", DefaultAestheticValue::String("black")), + ("opacity", DefaultAestheticValue::Number(0.8)), + ("linewidth", DefaultAestheticValue::Number(1.0)), + ("linetype", DefaultAestheticValue::String("solid")), ], - required: &["x", "ymin", "ymax"], - hidden: &[], } } } diff --git a/src/plot/layer/geom/segment.rs b/src/plot/layer/geom/segment.rs index 0c26cc02..163ca795 100644 --- a/src/plot/layer/geom/segment.rs +++ b/src/plot/layer/geom/segment.rs @@ -1,6 +1,7 @@ //! Segment geom implementation -use super::{GeomAesthetics, GeomTrait, GeomType}; +use super::{DefaultAesthetics, GeomTrait, GeomType}; +use crate::plot::types::DefaultAestheticValue; /// Segment geom - line segments between two points #[derive(Debug, Clone, Copy)] @@ -11,20 +12,18 @@ impl GeomTrait for Segment { GeomType::Segment } - fn aesthetics(&self) -> GeomAesthetics { - GeomAesthetics { - supported: &[ - "x", - "y", - "xend", - "yend", - "stroke", - "linetype", - "linewidth", - "opacity", + fn aesthetics(&self) -> DefaultAesthetics { + DefaultAesthetics { + defaults: &[ + ("x", DefaultAestheticValue::Required), + ("y", DefaultAestheticValue::Required), + ("xend", DefaultAestheticValue::Required), + ("yend", DefaultAestheticValue::Required), + ("stroke", DefaultAestheticValue::String("black")), + ("linewidth", DefaultAestheticValue::Number(1.0)), + ("opacity", DefaultAestheticValue::Number(1.0)), + ("linetype", DefaultAestheticValue::String("solid")), ], - required: &["x", "y", "xend", "yend"], - hidden: &[], } } } diff --git a/src/plot/layer/geom/smooth.rs b/src/plot/layer/geom/smooth.rs index 06243523..ce0104ea 100644 --- a/src/plot/layer/geom/smooth.rs +++ b/src/plot/layer/geom/smooth.rs @@ -1,6 +1,7 @@ //! Smooth geom implementation -use super::{GeomAesthetics, GeomTrait, GeomType}; +use super::{DefaultAesthetics, GeomTrait, GeomType}; +use crate::plot::types::DefaultAestheticValue; use crate::Mappings; /// Smooth geom - smoothed conditional means (regression, LOESS, etc.) @@ -12,11 +13,16 @@ impl GeomTrait for Smooth { GeomType::Smooth } - fn aesthetics(&self) -> GeomAesthetics { - GeomAesthetics { - supported: &["x", "y", "stroke", "linetype", "opacity"], - required: &["x", "y"], - hidden: &[], + fn aesthetics(&self) -> DefaultAesthetics { + DefaultAesthetics { + defaults: &[ + ("x", DefaultAestheticValue::Required), + ("y", DefaultAestheticValue::Required), + ("stroke", DefaultAestheticValue::String("#3366FF")), + ("linewidth", DefaultAestheticValue::Number(1.0)), + ("opacity", DefaultAestheticValue::Number(1.0)), + ("linetype", DefaultAestheticValue::String("solid")), + ], } } diff --git a/src/plot/layer/geom/text.rs b/src/plot/layer/geom/text.rs index 7107f5c5..b63cb780 100644 --- a/src/plot/layer/geom/text.rs +++ b/src/plot/layer/geom/text.rs @@ -1,6 +1,7 @@ //! Text geom implementation -use super::{GeomAesthetics, GeomTrait, GeomType}; +use super::{DefaultAesthetics, GeomTrait, GeomType}; +use crate::plot::types::DefaultAestheticValue; /// Text geom - text labels at positions #[derive(Debug, Clone, Copy)] @@ -11,14 +12,20 @@ impl GeomTrait for Text { GeomType::Text } - fn aesthetics(&self) -> GeomAesthetics { - GeomAesthetics { - supported: &[ - "x", "y", "label", "stroke", "size", "opacity", "family", "fontface", "hjust", - "vjust", + fn aesthetics(&self) -> DefaultAesthetics { + DefaultAesthetics { + defaults: &[ + ("x", DefaultAestheticValue::Required), + ("y", DefaultAestheticValue::Required), + ("label", DefaultAestheticValue::Null), + ("stroke", DefaultAestheticValue::Null), + ("size", DefaultAestheticValue::Number(11.0)), + ("opacity", DefaultAestheticValue::Number(1.0)), + ("family", DefaultAestheticValue::Null), + ("fontface", DefaultAestheticValue::Null), + ("hjust", DefaultAestheticValue::Null), + ("vjust", DefaultAestheticValue::Null), ], - required: &["x", "y"], - hidden: &[], } } } diff --git a/src/plot/layer/geom/tile.rs b/src/plot/layer/geom/tile.rs index effe2a89..133eaf3d 100644 --- a/src/plot/layer/geom/tile.rs +++ b/src/plot/layer/geom/tile.rs @@ -1,6 +1,7 @@ //! Tile geom implementation -use super::{GeomAesthetics, GeomTrait, GeomType}; +use super::{DefaultAesthetics, GeomTrait, GeomType}; +use crate::plot::types::DefaultAestheticValue; /// Tile geom - heatmaps and tile-based visualizations #[derive(Debug, Clone, Copy)] @@ -11,11 +12,17 @@ impl GeomTrait for Tile { GeomType::Tile } - fn aesthetics(&self) -> GeomAesthetics { - GeomAesthetics { - supported: &["x", "y", "fill", "stroke", "width", "height", "opacity"], - required: &["x", "y"], - hidden: &[], + fn aesthetics(&self) -> DefaultAesthetics { + DefaultAesthetics { + defaults: &[ + ("x", DefaultAestheticValue::Required), + ("y", DefaultAestheticValue::Required), + ("fill", DefaultAestheticValue::String("black")), + ("stroke", DefaultAestheticValue::String("black")), + ("width", DefaultAestheticValue::Null), + ("height", DefaultAestheticValue::Null), + ("opacity", DefaultAestheticValue::Number(1.0)), + ], } } } diff --git a/src/plot/layer/geom/types.rs b/src/plot/layer/geom/types.rs index 2d4ecc97..178209cf 100644 --- a/src/plot/layer/geom/types.rs +++ b/src/plot/layer/geom/types.rs @@ -2,20 +2,81 @@ //! //! These types are used by all geom implementations and are shared across the module. -use crate::Mappings; +use crate::{plot::types::DefaultAestheticValue, Mappings}; -/// Aesthetic information for a geom type +/// Default aesthetic values for a geom type /// -/// This struct describes which aesthetics a geom supports, requires, and hides. +/// This struct describes which aesthetics a geom supports, requires, and their default values. #[derive(Debug, Clone, Copy)] -pub struct GeomAesthetics { - /// All aesthetics this geom type supports for user MAPPING - pub supported: &'static [&'static str], - /// Aesthetics required for this geom type to be valid - pub required: &'static [&'static str], - /// Hidden aesthetics (valid REMAPPING targets, not valid MAPPING targets) - /// These are produced by stat transforms but shouldn't be manually mapped - pub hidden: &'static [&'static str], +pub struct DefaultAesthetics { + /// Aesthetic defaults: maps aesthetic name to default value + /// - Required: Must be provided via MAPPING + /// - Delayed: Produced by stat transform (REMAPPING only) + /// - Null: Supported but no default + /// - Other variants: Actual default values + pub defaults: &'static [(&'static str, DefaultAestheticValue)], +} + +impl DefaultAesthetics { + /// Get all aesthetic names (including Delayed) + pub fn names(&self) -> Vec<&'static str> { + self.defaults.iter().map(|(name, _)| *name).collect() + } + + /// Get supported aesthetic names (excludes Delayed, for MAPPING validation) + pub fn supported(&self) -> Vec<&'static str> { + self.defaults + .iter() + .filter_map(|(name, value)| { + if !matches!(value, DefaultAestheticValue::Delayed) { + Some(*name) + } else { + None + } + }) + .collect() + } + + /// Get required aesthetic names (those marked as Required) + pub fn required(&self) -> Vec<&'static str> { + self.defaults + .iter() + .filter_map(|(name, value)| { + if matches!(value, DefaultAestheticValue::Required) { + Some(*name) + } else { + None + } + }) + .collect() + } + + /// Check if an aesthetic is supported (not Delayed) + pub fn is_supported(&self, name: &str) -> bool { + self.defaults + .iter() + .any(|(n, value)| *n == name && !matches!(value, DefaultAestheticValue::Delayed)) + } + + /// Check if an aesthetic exists (including Delayed) + pub fn contains(&self, name: &str) -> bool { + self.defaults.iter().any(|(n, _)| *n == name) + } + + /// Check if an aesthetic is required + pub fn is_required(&self, name: &str) -> bool { + self.defaults + .iter() + .any(|(n, value)| *n == name && matches!(value, DefaultAestheticValue::Required)) + } + + /// Get the default value for an aesthetic by name + pub fn get(&self, name: &str) -> Option<&'static DefaultAestheticValue> { + self.defaults + .iter() + .find(|(n, _)| *n == name) + .map(|(_, value)| value) + } } /// Default value for a layer parameter @@ -73,3 +134,72 @@ pub fn get_column_name(aesthetics: &Mappings, aesthetic: &str) -> Option _ => None, }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_aesthetics_methods() { + // Create a DefaultAesthetics with various value types + let aes = DefaultAesthetics { + defaults: &[ + ("x", DefaultAestheticValue::Required), + ("y", DefaultAestheticValue::Required), + ("size", DefaultAestheticValue::Number(3.0)), + ("stroke", DefaultAestheticValue::String("black")), + ("fill", DefaultAestheticValue::Null), + ("yend", DefaultAestheticValue::Delayed), + ], + }; + + // Test get() method + assert_eq!(aes.get("x"), Some(&DefaultAestheticValue::Required)); + assert_eq!(aes.get("size"), Some(&DefaultAestheticValue::Number(3.0))); + assert_eq!( + aes.get("stroke"), + Some(&DefaultAestheticValue::String("black")) + ); + assert_eq!(aes.get("fill"), Some(&DefaultAestheticValue::Null)); + assert_eq!(aes.get("yend"), Some(&DefaultAestheticValue::Delayed)); + assert_eq!(aes.get("nonexistent"), None); + + // Test names() - includes all aesthetics + let names = aes.names(); + assert_eq!(names.len(), 6); + assert!(names.contains(&"x")); + assert!(names.contains(&"yend")); + + // Test supported() - excludes Delayed + let supported = aes.supported(); + assert_eq!(supported.len(), 5); + assert!(supported.contains(&"x")); + assert!(supported.contains(&"size")); + assert!(supported.contains(&"fill")); + assert!(!supported.contains(&"yend")); // Delayed excluded + + // Test required() - only Required variants + let required = aes.required(); + assert_eq!(required.len(), 2); + assert!(required.contains(&"x")); + assert!(required.contains(&"y")); + assert!(!required.contains(&"size")); + + // Test is_supported() - efficient membership check + assert!(aes.is_supported("x")); + assert!(aes.is_supported("size")); + assert!(!aes.is_supported("yend")); // Delayed not supported + assert!(!aes.is_supported("nonexistent")); + + // Test contains() - includes Delayed + assert!(aes.contains("x")); + assert!(aes.contains("yend")); // Delayed included + assert!(!aes.contains("nonexistent")); + + // Test is_required() + assert!(aes.is_required("x")); + assert!(aes.is_required("y")); + assert!(!aes.is_required("size")); + assert!(!aes.is_required("yend")); + } +} diff --git a/src/plot/layer/geom/violin.rs b/src/plot/layer/geom/violin.rs index 0335009e..b7872624 100644 --- a/src/plot/layer/geom/violin.rs +++ b/src/plot/layer/geom/violin.rs @@ -1,6 +1,6 @@ //! Violin geom implementation -use super::{GeomAesthetics, GeomTrait, GeomType, StatResult}; +use super::{DefaultAesthetics, GeomTrait, GeomType, StatResult}; use crate::{ plot::{ geom::types::get_column_name, DefaultAestheticValue, DefaultParam, DefaultParamValue, @@ -19,20 +19,19 @@ impl GeomTrait for Violin { GeomType::Violin } - fn aesthetics(&self) -> GeomAesthetics { - GeomAesthetics { - supported: &[ - "x", - "y", - "weight", - "fill", - "stroke", - "opacity", - "linewidth", - "linetype", + fn aesthetics(&self) -> DefaultAesthetics { + DefaultAesthetics { + defaults: &[ + ("x", DefaultAestheticValue::Required), + ("y", DefaultAestheticValue::Required), + ("weight", DefaultAestheticValue::Null), + ("fill", DefaultAestheticValue::String("black")), + ("stroke", DefaultAestheticValue::String("black")), + ("opacity", DefaultAestheticValue::Number(0.8)), + ("linewidth", DefaultAestheticValue::Number(1.0)), + ("linetype", DefaultAestheticValue::String("solid")), + ("offset", DefaultAestheticValue::Delayed), // Computed by stat ], - required: &["x", "y"], - hidden: &["offset"], } } diff --git a/src/plot/layer/geom/vline.rs b/src/plot/layer/geom/vline.rs index 84ff2bba..2b12cf1d 100644 --- a/src/plot/layer/geom/vline.rs +++ b/src/plot/layer/geom/vline.rs @@ -1,6 +1,7 @@ //! VLine geom implementation -use super::{GeomAesthetics, GeomTrait, GeomType}; +use super::{DefaultAesthetics, GeomTrait, GeomType}; +use crate::plot::types::DefaultAestheticValue; /// VLine geom - vertical reference lines #[derive(Debug, Clone, Copy)] @@ -11,11 +12,15 @@ impl GeomTrait for VLine { GeomType::VLine } - fn aesthetics(&self) -> GeomAesthetics { - GeomAesthetics { - supported: &["xintercept", "stroke", "linetype", "linewidth", "opacity"], - required: &["xintercept"], - hidden: &[], + fn aesthetics(&self) -> DefaultAesthetics { + DefaultAesthetics { + defaults: &[ + ("xintercept", DefaultAestheticValue::Required), + ("stroke", DefaultAestheticValue::String("black")), + ("linewidth", DefaultAestheticValue::Number(1.0)), + ("opacity", DefaultAestheticValue::Number(1.0)), + ("linetype", DefaultAestheticValue::String("solid")), + ], } } } diff --git a/src/plot/layer/mod.rs b/src/plot/layer/mod.rs index 5c9909f8..84076071 100644 --- a/src/plot/layer/mod.rs +++ b/src/plot/layer/mod.rs @@ -11,7 +11,7 @@ pub mod geom; // Re-export geom types for convenience pub use geom::{ - DefaultParam, DefaultParamValue, Geom, GeomAesthetics, GeomTrait, GeomType, StatResult, + DefaultAesthetics, DefaultParam, DefaultParamValue, Geom, GeomTrait, GeomType, StatResult, }; use crate::plot::types::{AestheticValue, DataSource, Mappings, ParameterValue, SqlExpression}; @@ -21,7 +21,26 @@ use crate::plot::types::{AestheticValue, DataSource, Mappings, ParameterValue, S pub struct Layer { /// Geometric object type pub geom: Geom, - /// Aesthetic mappings (from MAPPING clause) + /// All aesthetic mappings combined from multiple sources: + /// + /// 1. **MAPPING clause** (from query, highest precedence): + /// - Column references: `date AS x` → `AestheticValue::Column` + /// - Literals: `'foo' AS color` → `AestheticValue::Literal` (converted to Column during execution) + /// + /// 2. **SETTING clause** (from query, second precedence): + /// - Added during execution via `resolve_aesthetics()` + /// - Stored as `AestheticValue::Literal` + /// + /// 3. **Geom defaults** (lowest precedence): + /// - Added during execution via `resolve_aesthetics()` + /// - Stored as `AestheticValue::Literal` + /// + /// **Important distinction for scale application**: + /// - Query literals (`MAPPING 'foo' AS color`) are converted to columns during query execution + /// via `build_layer_select_list()`, becoming `AestheticValue::Column` before reaching writers. + /// These columns can have scales applied. + /// - SETTING/defaults remain as `AestheticValue::Literal` and render as constant values + /// without scale transformations. pub mappings: Mappings, /// Stat remappings (from REMAPPING clause): stat_name → aesthetic /// Maps stat-computed columns (e.g., "count") to aesthetic channels (e.g., "y") @@ -119,7 +138,7 @@ impl Layer { /// Check if this layer has the required aesthetics for its geom pub fn validate_required_aesthetics(&self) -> std::result::Result<(), String> { - for aesthetic in self.geom.aesthetics().required { + for aesthetic in self.geom.aesthetics().required() { if !self.mappings.contains_key(aesthetic) { return Err(format!( "Geom '{}' requires aesthetic '{}' but it was not provided", @@ -148,6 +167,49 @@ impl Layer { } } + /// Resolve aesthetics for all supported aesthetics not in MAPPING. + /// + /// For each supported aesthetic that's not already mapped in MAPPING: + /// - Check SETTING parameters first (user-specified, highest priority) and consume from parameters + /// - Fall back to geom defaults (lower priority) + /// - Insert into mappings as `AestheticValue::Literal` + /// + /// Precedence: MAPPING > SETTING > geom defaults + /// + /// **Important**: Query literals from MAPPING (`'foo' AS color`) have already been converted + /// to columns during query execution, so this only adds SETTING/default literals which + /// remain as `AestheticValue::Literal` and render without scale transformations. + /// + /// Call this during execution to provide a single source of truth for writers. + pub fn resolve_aesthetics(&mut self) { + let supported_aesthetics = self.geom.aesthetics().supported(); + + for aesthetic_name in supported_aesthetics { + // Skip if already in MAPPING (highest precedence) + if self.mappings.contains_key(aesthetic_name) { + continue; + } + + // Check SETTING first (user-specified) and consume from parameters + if let Some(value) = self.parameters.remove(aesthetic_name) { + self.mappings + .insert(aesthetic_name, AestheticValue::Literal(value)); + continue; + } + + // Fall back to geom default (filter out Null = non-literal defaults) + if let Some(default_value) = self.geom.aesthetics().get(aesthetic_name) { + match default_value.to_parameter_value() { + ParameterValue::Null => continue, + value => { + self.mappings + .insert(aesthetic_name, AestheticValue::Literal(value)); + } + } + } + } + } + /// Validate that all SETTING parameters are valid for this layer's geom pub fn validate_settings(&self) -> std::result::Result<(), String> { let valid = self.geom.valid_settings(); @@ -250,3 +312,93 @@ impl Layer { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_resolve_aesthetics_from_settings() { + // Test that resolve_aesthetics() moves aesthetic values from parameters to mappings + let mut layer = Layer::new(Geom::point()); + layer + .parameters + .insert("size".to_string(), ParameterValue::Number(5.0)); + layer + .parameters + .insert("opacity".to_string(), ParameterValue::Number(0.8)); + + layer.resolve_aesthetics(); + + // Values should be moved from parameters to mappings as Literal + assert!(!layer.parameters.contains_key("size")); + assert!(!layer.parameters.contains_key("opacity")); + assert_eq!( + layer.mappings.get("size"), + Some(&AestheticValue::Literal(ParameterValue::Number(5.0))) + ); + assert_eq!( + layer.mappings.get("opacity"), + Some(&AestheticValue::Literal(ParameterValue::Number(0.8))) + ); + } + + #[test] + fn test_resolve_aesthetics_from_defaults() { + // Test that resolve_aesthetics() includes geom default values + let mut layer = Layer::new(Geom::point()); + + layer.resolve_aesthetics(); + + // Point geom has default shape = 'circle' + assert_eq!( + layer.mappings.get("shape"), + Some(&AestheticValue::Literal(ParameterValue::String( + "circle".to_string() + ))) + ); + } + + #[test] + fn test_resolve_aesthetics_skips_mapped() { + // Test that resolve_aesthetics() skips aesthetics that are already in MAPPING + let mut layer = Layer::new(Geom::point()); + layer.mappings.insert( + "size", + AestheticValue::standard_column("my_size".to_string()), + ); + layer + .parameters + .insert("size".to_string(), ParameterValue::Number(5.0)); + + layer.resolve_aesthetics(); + + // size should stay in parameters (not moved to mappings) because it's already in MAPPING + assert!(layer.parameters.contains_key("size")); + // The mapping should still be the Column, not replaced with Literal + assert!(matches!( + layer.mappings.get("size"), + Some(AestheticValue::Column { .. }) + )); + } + + #[test] + fn test_resolve_aesthetics_precedence() { + // Test that SETTING takes precedence over geom defaults + let mut layer = Layer::new(Geom::point()); + layer.parameters.insert( + "shape".to_string(), + ParameterValue::String("square".to_string()), + ); + + layer.resolve_aesthetics(); + + // Should use SETTING value, not default + assert_eq!( + layer.mappings.get("shape"), + Some(&AestheticValue::Literal(ParameterValue::String( + "square".to_string() + ))) + ); + } +} diff --git a/src/plot/main.rs b/src/plot/main.rs index d56e66bb..6772ff08 100644 --- a/src/plot/main.rs +++ b/src/plot/main.rs @@ -30,7 +30,7 @@ pub use super::types::{ // Re-export Geom and related types from the layer::geom module pub use super::layer::geom::{ - DefaultParam, DefaultParamValue, Geom, GeomAesthetics, GeomTrait, GeomType, StatResult, + DefaultAesthetics, DefaultParam, DefaultParamValue, Geom, GeomTrait, GeomType, StatResult, }; use super::aesthetic::primary_aesthetic; @@ -381,56 +381,58 @@ mod tests { fn test_geom_aesthetics() { // Point geom let point = Geom::point().aesthetics(); - assert!(point.supported.contains(&"x")); - assert!(point.supported.contains(&"size")); - assert!(point.supported.contains(&"shape")); - assert!(!point.supported.contains(&"linetype")); - assert_eq!(point.required, &["x", "y"]); + assert!(point.is_supported("x")); + assert!(point.is_supported("size")); + assert!(point.is_supported("shape")); + assert!(!point.is_supported("linetype")); + assert_eq!(point.required(), &["x", "y"]); // Line geom let line = Geom::line().aesthetics(); - assert!(line.supported.contains(&"linetype")); - assert!(line.supported.contains(&"linewidth")); - assert!(!line.supported.contains(&"size")); - assert_eq!(line.required, &["x", "y"]); + assert!(line.is_supported("linetype")); + assert!(line.is_supported("linewidth")); + assert!(!line.is_supported("size")); + assert_eq!(line.required(), &["x", "y"]); // Bar geom - optional x and y (stat decides aggregation) let bar = Geom::bar().aesthetics(); - assert!(bar.supported.contains(&"fill")); - assert!(bar.supported.contains(&"width")); - assert!(bar.supported.contains(&"y")); // Bar accepts optional y - assert!(bar.supported.contains(&"x")); // Bar accepts optional x - assert_eq!(bar.required, &[] as &[&str]); // No required aesthetics + assert!(bar.is_supported("fill")); + assert!(bar.is_supported("y")); // Bar accepts optional y + assert!(bar.is_supported("x")); // Bar accepts optional x + assert_eq!(bar.required(), &[] as &[&str]); // No required aesthetics // Text geom let text = Geom::text().aesthetics(); - assert!(text.supported.contains(&"label")); - assert!(text.supported.contains(&"family")); - assert_eq!(text.required, &["x", "y"]); + assert!(text.is_supported("label")); + assert!(text.is_supported("family")); + assert_eq!(text.required(), &["x", "y"]); // Statistical geoms only require x - assert_eq!(Geom::histogram().aesthetics().required, &["x"]); - assert_eq!(Geom::density().aesthetics().required, &["x"]); + assert_eq!(Geom::histogram().aesthetics().required(), &["x"]); + assert_eq!(Geom::density().aesthetics().required(), &["x"]); // Ribbon requires ymin/ymax - assert_eq!(Geom::ribbon().aesthetics().required, &["x", "ymin", "ymax"]); + assert_eq!( + Geom::ribbon().aesthetics().required(), + &["x", "ymin", "ymax"] + ); // Segment/arrow require endpoints assert_eq!( - Geom::segment().aesthetics().required, + Geom::segment().aesthetics().required(), &["x", "y", "xend", "yend"] ); // Reference lines - assert_eq!(Geom::hline().aesthetics().required, &["yintercept"]); - assert_eq!(Geom::vline().aesthetics().required, &["xintercept"]); + assert_eq!(Geom::hline().aesthetics().required(), &["yintercept"]); + assert_eq!(Geom::vline().aesthetics().required(), &["xintercept"]); assert_eq!( - Geom::abline().aesthetics().required, + Geom::abline().aesthetics().required(), &["slope", "intercept"] ); // ErrorBar has no strict requirements - assert_eq!(Geom::errorbar().aesthetics().required, &[] as &[&str]); + assert_eq!(Geom::errorbar().aesthetics().required(), &[] as &[&str]); } #[test] diff --git a/src/plot/types.rs b/src/plot/types.rs index ec1ce054..58a85abd 100644 --- a/src/plot/types.rs +++ b/src/plot/types.rs @@ -255,16 +255,35 @@ pub enum DefaultAestheticValue { Number(f64), /// Literal boolean value Boolean(bool), + /// Supported but no default value (optional aesthetic) + Null, + /// Required aesthetic (must be provided via MAPPING) + Required, + /// Delayed aesthetic (produced by stat transform, valid for REMAPPING only, not MAPPING) + Delayed, } impl DefaultAestheticValue { + /// Convert to ParameterValue + /// + /// Returns String/Number/Boolean for literal defaults. + /// Returns Null for Column/Null/Required/Delayed (non-literal variants). + /// Use this to extract SETTING-compatible values from defaults. + pub fn to_parameter_value(&self) -> ParameterValue { + match self { + Self::String(s) => ParameterValue::String(s.to_string()), + Self::Number(n) => ParameterValue::Number(*n), + Self::Boolean(b) => ParameterValue::Boolean(*b), + Self::Column(_) | Self::Null | Self::Required | Self::Delayed => ParameterValue::Null, + } + } + /// Convert to owned AestheticValue pub fn to_aesthetic_value(&self) -> AestheticValue { match self { Self::Column(name) => AestheticValue::standard_column(name.to_string()), - Self::String(s) => AestheticValue::Literal(ParameterValue::String(s.to_string())), - Self::Number(n) => AestheticValue::Literal(ParameterValue::Number(*n)), - Self::Boolean(b) => AestheticValue::Literal(ParameterValue::Boolean(*b)), + // All literal variants (String/Number/Boolean) and non-literals (Null/Required/Delayed) + _ => AestheticValue::Literal(self.to_parameter_value()), } } } diff --git a/src/reader/mod.rs b/src/reader/mod.rs index 5565668c..b99c6ceb 100644 --- a/src/reader/mod.rs +++ b/src/reader/mod.rs @@ -340,7 +340,8 @@ mod tests { let metadata = spec.metadata(); assert_eq!(metadata.rows, 3); - assert_eq!(metadata.columns.len(), 2); + // Columns now includes both user mappings (x, y) and resolved defaults (size, stroke, fill, opacity, shape, linewidth) + assert_eq!(metadata.columns.len(), 8); assert!(metadata.columns.contains(&"x".to_string())); assert!(metadata.columns.contains(&"y".to_string())); assert_eq!(metadata.layer_count, 1); diff --git a/src/writer/vegalite/encoding.rs b/src/writer/vegalite/encoding.rs index d9c074db..38aa2a64 100644 --- a/src/writer/vegalite/encoding.rs +++ b/src/writer/vegalite/encoding.rs @@ -874,7 +874,12 @@ fn build_column_encoding( /// Build encoding for a literal aesthetic value fn build_literal_encoding(aesthetic: &str, lit: &ParameterValue) -> Result { let val = match lit { - ParameterValue::String(s) => json!(s), + ParameterValue::String(s) => match aesthetic { + "linetype" => linetype_to_stroke_dash(s) + .map(|arr| json!(arr)) + .unwrap_or_else(|| json!(s)), + _ => json!(s), + }, ParameterValue::Number(n) => { match aesthetic { // Size: radius (points) → area (pixels²) @@ -884,10 +889,7 @@ fn build_literal_encoding(aesthetic: &str, lit: &ParameterValue) -> Result json!(n), } } - ParameterValue::Boolean(b) => json!(b), - ParameterValue::Array(_) | ParameterValue::Null => { - unreachable!("Grammar prevents arrays and null in literal aesthetic mappings") - } + _ => lit.to_json(), }; Ok(json!({"value": val})) } diff --git a/src/writer/vegalite/layer.rs b/src/writer/vegalite/layer.rs index 00fe0ae4..ee2a225a 100644 --- a/src/writer/vegalite/layer.rs +++ b/src/writer/vegalite/layer.rs @@ -297,9 +297,7 @@ impl GeomRenderer for PolygonRenderer { fn modify_spec(&self, layer_spec: &mut Value, _layer: &Layer) -> Result<()> { layer_spec["mark"] = json!({ "type": "line", - "interpolate": "linear-closed", - "fill": "#888888", - "stroke": "#888888" + "interpolate": "linear-closed" }); Ok(()) } @@ -583,11 +581,6 @@ impl BoxplotRenderer { width = *num; } - // Default styling - let default_stroke = "black"; - let default_fill = "#FFFFFF00"; - let default_linewidth = 1.0; - // Helper to create filter transform for source selection let make_source_filter = |type_suffix: &str| -> Value { let source_key = format!("{}{}", base_key, type_suffix); @@ -620,9 +613,7 @@ impl BoxplotRenderer { &prototype, "outlier", json!({ - "type": "point", - "stroke": default_stroke, - "strokeWidth": default_linewidth + "type": "point" }), ); if points["encoding"].get("color").is_some() { @@ -656,9 +647,7 @@ impl BoxplotRenderer { &summary_prototype, "lower_whisker", json!({ - "type": "rule", - "stroke": default_stroke, - "size": default_linewidth + "type": "rule" }), ); @@ -678,9 +667,7 @@ impl BoxplotRenderer { &summary_prototype, "upper_whisker", json!({ - "type": "rule", - "stroke": default_stroke, - "size": default_linewidth + "type": "rule" }), ); @@ -702,10 +689,7 @@ impl BoxplotRenderer { json!({ "type": "bar", "width": {"band": width}, - "align": "center", - "stroke": default_stroke, - "color": default_fill, - "strokeWidth": default_linewidth + "align": "center" }), ); box_part["encoding"][value_var1] = y_encoding.clone(); @@ -717,10 +701,8 @@ impl BoxplotRenderer { "median", json!({ "type": "tick", - "stroke": default_stroke, "width": {"band": width}, - "align": "center", - "strokeWidth": default_linewidth + "align": "center" }), ); median_line["encoding"][value_var1] = y_encoding; diff --git a/src/writer/vegalite/mod.rs b/src/writer/vegalite/mod.rs index 08f87951..09801aa0 100644 --- a/src/writer/vegalite/mod.rs +++ b/src/writer/vegalite/mod.rs @@ -242,6 +242,10 @@ fn build_layer_encoding( }; // Build encoding channels for each aesthetic mapping + // Mappings contains: + // 1. Column references from MAPPING clause (apply scales) + // 2. Query literals from MAPPING (converted to columns, apply scales) + // 3. Literals from SETTING/defaults (remain as literals, no scales) for (aesthetic, value) in &layer.mappings.aesthetics { // Skip facet aesthetics - they are handled via top-level facet structure, // not as encoding channels. Adding them to encoding would create row-based @@ -250,7 +254,12 @@ fn build_layer_encoding( continue; } - let channel_name = map_aesthetic_name(aesthetic); + let mut channel_name = map_aesthetic_name(aesthetic); + // Opacity is retargeted to the fill when fill is supported + if channel_name == "opacity" && layer.mappings.contains_key("fill") { + channel_name = "fillOpacity".to_string(); + } + let channel_encoding = build_encoding_channel(aesthetic, value, &mut enc_ctx)?; encoding.insert(channel_name, channel_encoding); @@ -266,29 +275,6 @@ fn build_layer_encoding( } } - // Add aesthetic parameters from SETTING as literal encodings - // (e.g., SETTING color => 'red' becomes {"color": {"value": "red"}}) - // Only parameters that are supported aesthetics for this geom type are included - let supported_aesthetics = layer.geom.aesthetics().supported; - for (param_name, param_value) in &layer.parameters { - if supported_aesthetics.contains(¶m_name.as_str()) { - let channel_name = map_aesthetic_name(param_name); - // Only add if not already set by MAPPING (MAPPING takes precedence) - if !encoding.contains_key(&channel_name) { - // Convert size and linewidth from points to Vega-Lite units - let converted_value = match (param_name.as_str(), param_value) { - // Size: interpret as radius in points, convert to area in pixels^2 - ("size", ParameterValue::Number(n)) => json!(n * n * POINTS_TO_AREA), - // Linewidth: interpret as width in points, convert to pixels - ("linewidth", ParameterValue::Number(n)) => json!(n * POINTS_TO_PIXELS), - // Other aesthetics: pass through unchanged - _ => param_value.to_json(), - }; - encoding.insert(channel_name, json!({"value": converted_value})); - } - } - } - // Add detail encoding for partition_by columns (grouping) if let Some(detail) = build_detail_encoding(&layer.partition_by) { encoding.insert("detail".to_string(), detail); @@ -1084,6 +1070,55 @@ mod tests { data_map } + /// Helper to build a layer with x and y aesthetics already set up + /// + /// By default, maps "x" column to x aesthetic and "y" column to y aesthetic. + /// Additional aesthetics and parameters can be added via builder methods. + /// + /// # Example + /// ``` + /// let layer = build_layer(Geom::point()) + /// .with_aesthetic("color".to_string(), AestheticValue::standard_column("category".to_string())); + /// ``` + fn build_layer(geom: Geom) -> Layer { + Layer::new(geom) + .with_aesthetic( + "x".to_string(), + AestheticValue::standard_column("x".to_string()), + ) + .with_aesthetic( + "y".to_string(), + AestheticValue::standard_column("y".to_string()), + ) + } + + /// Helper to build a complete spec with a single layer + /// + /// Creates a Plot with one layer that has x and y aesthetics mapped to "x" and "y" columns. + /// Additional aesthetics and parameters can be added to the layer before calling this. + /// + /// # Example + /// ``` + /// let spec = build_spec(Geom::line()); + /// ``` + fn build_spec(geom: Geom) -> Plot { + let mut spec = Plot::new(); + let mut layer = build_layer(geom); + // Resolve aesthetics (normally done in execution pipeline) + layer.resolve_aesthetics(); + spec.layers.push(layer); + spec + } + + /// Helper to create a simple DataFrame with x and y columns for testing + fn simple_df() -> DataFrame { + df! { + "x" => &[1, 2, 3], + "y" => &[4, 5, 6], + } + .unwrap() + } + #[test] fn test_geom_to_mark_mapping() { // All marks should be objects with type and clip: true @@ -1561,6 +1596,178 @@ mod tests { ); } + #[test] + fn test_default_aesthetics_applied() { + let writer = VegaLiteWriter::new(); + + // Point geom without explicit size/stroke - should use defaults + let spec = build_spec(Geom::point()); + + let result = writer.write(&spec, &wrap_data(simple_df())); + assert!(result.is_ok()); + let json_str = result.unwrap(); + let json: Value = serde_json::from_str(&json_str).unwrap(); + + // Single-layer spec uses layer array structure + let encoding = &json["layer"][0]["encoding"]; + + // Point default stroke = "black" + assert_eq!(encoding["stroke"]["value"], "black"); + + // Point default opacity = 0.8 (retargeted to fillOpacity when fill is present) + assert_eq!(encoding["fillOpacity"]["value"], 0.8); + } + + #[test] + fn test_setting_overrides_default() { + let writer = VegaLiteWriter::new(); + + // Point with SETTING opacity => 0.5 should override default (1.0) + let mut spec = Plot::new(); + let mut layer = build_layer(Geom::point()) + .with_parameter("opacity".to_string(), ParameterValue::Number(0.5)); + + // Resolve aesthetics (normally done in execution pipeline) + layer.resolve_aesthetics(); + spec.layers.push(layer); + + let result = writer.write(&spec, &wrap_data(simple_df())); + assert!(result.is_ok()); + let json_str = result.unwrap(); + let json: Value = serde_json::from_str(&json_str).unwrap(); + + let encoding = &json["layer"][0]["encoding"]; + + // Should use SETTING value (0.5), not default (0.8) + // Opacity is retargeted to fillOpacity when fill is present + assert_eq!(encoding["fillOpacity"]["value"], 0.5); + } + + #[test] + fn test_mapping_overrides_default() { + let writer = VegaLiteWriter::new(); + + // Point with MAPPING stroke AS stroke should override default + let mut spec = Plot::new(); + let mut layer = Layer::new(Geom::point()) + .with_aesthetic( + "x".to_string(), + AestheticValue::standard_column("x".to_string()), + ) + .with_aesthetic( + "y".to_string(), + AestheticValue::standard_column("y".to_string()), + ) + .with_aesthetic( + "stroke".to_string(), + AestheticValue::standard_column("stroke".to_string()), + ); + + // Resolve aesthetics (normally done in execution pipeline) + layer.resolve_aesthetics(); + spec.layers.push(layer); + + let df = df! { + "x" => &[1, 2, 3], + "y" => &[4, 5, 6], + "stroke" => &["red", "blue", "green"], + } + .unwrap(); + + let result = writer.write(&spec, &wrap_data(df)); + assert!(result.is_ok()); + let json_str = result.unwrap(); + let json: Value = serde_json::from_str(&json_str).unwrap(); + + let encoding = &json["layer"][0]["encoding"]; + + // Should have field encoding, not value encoding + assert!(encoding["stroke"]["field"].is_string()); + assert_eq!(encoding["stroke"]["field"], "stroke"); + assert!(encoding["stroke"]["value"].is_null()); + } + + #[test] + fn test_null_defaults_not_applied() { + let writer = VegaLiteWriter::new(); + + // Point has linetype as Null - should not appear in encoding + let mut spec = Plot::new(); + let mut layer = Layer::new(Geom::point()) + .with_aesthetic( + "x".to_string(), + AestheticValue::standard_column("x".to_string()), + ) + .with_aesthetic( + "y".to_string(), + AestheticValue::standard_column("y".to_string()), + ); + + // Resolve aesthetics (normally done in execution pipeline) + layer.resolve_aesthetics(); + spec.layers.push(layer); + + let df = df! { + "x" => &[1, 2, 3], + "y" => &[4, 5, 6], + } + .unwrap(); + + let result = writer.write(&spec, &wrap_data(df)); + assert!(result.is_ok()); + let json_str = result.unwrap(); + let json: Value = serde_json::from_str(&json_str).unwrap(); + + // Point has linetype => Null, should not appear in encoding + assert!(json["encoding"]["strokeDash"].is_null()); + } + + #[test] + fn test_linetype_translated_to_stroke_dash() { + let writer = VegaLiteWriter::new(); + + // Line with linetype as SETTING (literal) + let mut spec = Plot::new(); + let mut layer = build_layer(Geom::line()).with_aesthetic( + "linetype".to_string(), + AestheticValue::Literal(ParameterValue::String("dashed".to_string())), + ); + + // Resolve aesthetics (normally done in execution pipeline) + layer.resolve_aesthetics(); + spec.layers.push(layer); + + let result = writer.write(&spec, &wrap_data(simple_df())); + assert!(result.is_ok()); + let json_str = result.unwrap(); + let json: Value = serde_json::from_str(&json_str).unwrap(); + + let encoding = &json["layer"][0]["encoding"]; + + // "dashed" should translate to [6, 4] + assert!(encoding["strokeDash"]["value"].is_array()); + assert_eq!(encoding["strokeDash"]["value"], json!([6, 4])); + } + + #[test] + fn test_linetype_default_translated_to_stroke_dash() { + let writer = VegaLiteWriter::new(); + + // Line geom has linetype default of "solid" + let spec = build_spec(Geom::line()); + + let result = writer.write(&spec, &wrap_data(simple_df())); + assert!(result.is_ok()); + let json_str = result.unwrap(); + let json: Value = serde_json::from_str(&json_str).unwrap(); + + let encoding = &json["layer"][0]["encoding"]; + + // "solid" should translate to empty array [] + assert!(encoding["strokeDash"]["value"].is_array()); + assert_eq!(encoding["strokeDash"]["value"], json!([])); + } + #[test] fn test_facet_ordering_uses_input_range() { // Test that apply_facet_ordering uses input_range (FROM clause) for discrete scales