diff --git a/CLAUDE.md b/CLAUDE.md
index 07ea0b0c..e01a901a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -330,7 +330,7 @@ pub struct Layer {
pub enum Geom {
// Basic geoms
- Point, Line, Path, Bar, Col, Area, Tile, Polygon, Ribbon,
+ Point, Line, Path, Bar, Col, Area, Rect, Polygon, Ribbon,
// Statistical geoms
Histogram, Density, Smooth, Boxplot, Violin,
// Annotation geoms
@@ -1200,7 +1200,7 @@ All clauses (MAPPING, SETTING, PARTITION BY, FILTER) are optional.
**Geom Types**:
-- **Basic**: `point`, `line`, `path`, `bar`, `col`, `area`, `tile`, `polygon`, `ribbon`
+- **Basic**: `point`, `line`, `path`, `bar`, `col`, `area`, `rect`, `polygon`, `ribbon`
- **Statistical**: `histogram`, `density`, `smooth`, `boxplot`, `violin`
- **Annotation**: `text`, `label`, `segment`, `arrow`, `hline`, `vline`, `abline`, `errorbar`
diff --git a/doc/ggsql.xml b/doc/ggsql.xml
index d244edd8..d6b14316 100644
--- a/doc/ggsql.xml
+++ b/doc/ggsql.xml
@@ -129,7 +129,7 @@
- bar
- col
- area
- - tile
+ - rect
- polygon
- ribbon
- histogram
diff --git a/doc/syntax/layer/rect.qmd b/doc/syntax/layer/rect.qmd
new file mode 100644
index 00000000..a6dc3e5f
--- /dev/null
+++ b/doc/syntax/layer/rect.qmd
@@ -0,0 +1,103 @@
+---
+title: "Rectangle"
+---
+
+> Layers are declared with the [`DRAW` clause](../clause/draw.qmd). Read the documentation for this clause for a thorough description of how to use it.
+
+Rectangles can be used to draw heatmaps or indicate ranges.
+
+## Aesthetics
+The following aesthetics are recognised by the rectangle layer.
+
+### Required
+
+* Pick two of the following:
+ * `x`: The center along the x-axis.
+ * `width`: The size of the rectangle in the horizontal dimension.
+ * `xmin`: The left position along the x-axis. Unavailable when `x` is discrete.
+ * `xmax`: The right position laong the x-axis. Unavailable when `x` is discrete.
+
+Alternatively, use only `x`, which will set `width` to 1 by default.
+
+* Pick two of the following:
+ * `y`: The center along the y-axis.
+ * `height`: The size of the rectangle in the vertical dimension.
+ * `ymin`: The bottom position along the y-axis. Unavailable when `y` is discrete.
+ * `ymax`: The top position along the y-axis. Unailable when `y` is discrete.
+
+Alternatively, use only `y`, which will set `height` to 1 by default.
+
+### Optional
+* `stroke`: The colour of the contour lines.
+* `fill`: The colour of the inner area.
+* `colour`: Shorthand for setting `stroke` and `fill` simultaneously.
+* `opacity`: The opacity of colours.
+* `linewidth`: The width of the contour lines.
+* `linetype`: The dash pattern of the contour line.
+
+## Settings
+The rectangle layer has no additional settings.
+
+## Data transformation.
+When the x-aesthetics are continuous, x-data is reparameterised to `xmin` and `xmax`.
+When the y-aesthetics are continuous, y-data is reparameterised to `ymin` and `ymax`.
+
+## Examples
+
+Just using `x` and `y`. Note that `width` and `height` are set to 1.
+
+```{ggsql}
+VISUALISE Day AS x, Month AS y, Temp AS colour FROM ggsql:airquality
+ DRAW rect
+```
+
+Customising `width` and `height` with either the `MAPPING` or `SETTING` clauses.
+
+```{ggsql}
+VISUALISE Day AS x, Month AS y, Temp AS colour FROM ggsql:airquality
+ DRAW rect MAPPING 0.5 AS width SETTING height => 0.8
+```
+
+If `x` is continuous, then `width` can be variable. Likewise for `y` and `height`.
+
+```{ggsql}
+SELECT *, Temp / (SELECT MAX(Temp) FROM ggsql:airquality) AS norm_temp
+FROM ggsql:airquality
+VISUALISE
+ Day AS x,
+ Month AS y,
+ Temp AS colour
+ DRAW rect
+ MAPPING
+ norm_temp AS width,
+ norm_temp AS height
+```
+
+Using top, right, bottom, left parameterisation instead.
+
+```{ggsql}
+SELECT
+ MIN(Date) AS start,
+ MAX(Date) AS end,
+ MIN(Temp) AS min,
+ MAX(Temp) AS max
+FROM ggsql:airquality
+GROUP BY
+ WEEKOFYEAR(Date)
+
+VISUALISE start AS xmin, end AS xmax, min AS ymin, max AS ymax
+ DRAW rect
+```
+
+Using a rectangle as an annotation.
+
+```{ggsql}
+VISUALISE FROM ggsql:airquality
+ DRAW rect MAPPING
+ '1973-06-01' AS xmin,
+ '1973-06-30' AS xmax,
+ 50 AS ymin,
+ 100 AS ymax,
+ 'June' AS colour
+ DRAW line MAPPING Date AS x, Temp AS y
+```
\ No newline at end of file
diff --git a/ggsql-vscode/syntaxes/ggsql.tmLanguage.json b/ggsql-vscode/syntaxes/ggsql.tmLanguage.json
index b8966255..81b92106 100644
--- a/ggsql-vscode/syntaxes/ggsql.tmLanguage.json
+++ b/ggsql-vscode/syntaxes/ggsql.tmLanguage.json
@@ -294,7 +294,7 @@
"patterns": [
{
"name": "support.type.geom.ggsql",
- "match": "\\b(point|line|path|bar|col|area|tile|polygon|ribbon|histogram|density|smooth|boxplot|violin|text|label|segment|arrow|hline|vline|abline|errorbar)\\b"
+ "match": "\\b(point|line|path|bar|col|area|rect|polygon|ribbon|histogram|density|smooth|boxplot|violin|text|label|segment|arrow|hline|vline|abline|errorbar)\\b"
},
{ "include": "#common-clause-patterns" }
]
diff --git a/src/parser/builder.rs b/src/parser/builder.rs
index f7bc9cb1..c01912bc 100644
--- a/src/parser/builder.rs
+++ b/src/parser/builder.rs
@@ -583,7 +583,7 @@ fn parse_geom_type(text: &str) -> Result {
"path" => Ok(Geom::path()),
"bar" => Ok(Geom::bar()),
"area" => Ok(Geom::area()),
- "tile" => Ok(Geom::tile()),
+ "rect" => Ok(Geom::rect()),
"polygon" => Ok(Geom::polygon()),
"ribbon" => Ok(Geom::ribbon()),
"histogram" => Ok(Geom::histogram()),
diff --git a/src/parser/mod.rs b/src/parser/mod.rs
index 6c80d63a..a3d8676c 100644
--- a/src/parser/mod.rs
+++ b/src/parser/mod.rs
@@ -146,12 +146,12 @@ mod tests {
let query = r#"
SELECT x, y FROM data
VISUALIZE x, y
- DRAW tile
+ DRAW point
"#;
let specs = parse_query(query).unwrap();
assert_eq!(specs.len(), 1);
- assert_eq!(specs[0].layers[0].geom, Geom::tile());
+ assert_eq!(specs[0].layers[0].geom, Geom::point());
}
#[test]
@@ -163,7 +163,7 @@ mod tests {
VISUALIZE
DRAW bar MAPPING x AS x, y AS y
VISUALISE z AS x, y AS y
- DRAW tile
+ DRAW point
"#;
let specs = parse_query(query).unwrap();
@@ -219,7 +219,7 @@ mod tests {
VISUALISE x, y
DRAW line
VISUALIZE
- DRAW tile MAPPING x AS x, y AS y
+ DRAW point MAPPING x AS x, y AS y
VISUALISE
DRAW bar MAPPING x AS x, y AS y
"#;
@@ -227,7 +227,7 @@ mod tests {
let specs = parse_query(query).unwrap();
assert_eq!(specs.len(), 3);
assert_eq!(specs[0].layers[0].geom, Geom::line());
- assert_eq!(specs[1].layers[0].geom, Geom::tile());
+ assert_eq!(specs[1].layers[0].geom, Geom::point());
assert_eq!(specs[2].layers[0].geom, Geom::bar());
}
@@ -245,7 +245,7 @@ mod tests {
VISUALIZE
DRAW bar MAPPING date AS x, revenue AS y
VISUALISE
- DRAW tile MAPPING date AS x, revenue AS y
+ DRAW point MAPPING date AS x, revenue AS y
"#;
let specs = parse_query(query).unwrap();
diff --git a/src/plot/layer/geom/mod.rs b/src/plot/layer/geom/mod.rs
index c953c9f7..30ba7c8a 100644
--- a/src/plot/layer/geom/mod.rs
+++ b/src/plot/layer/geom/mod.rs
@@ -42,11 +42,11 @@ mod line;
mod path;
mod point;
mod polygon;
+mod rect;
mod ribbon;
mod segment;
mod smooth;
mod text;
-mod tile;
mod violin;
mod vline;
@@ -68,11 +68,11 @@ pub use line::Line;
pub use path::Path;
pub use point::Point;
pub use polygon::Polygon;
+pub use rect::Rect;
pub use ribbon::Ribbon;
pub use segment::Segment;
pub use smooth::Smooth;
pub use text::Text;
-pub use tile::Tile;
pub use violin::Violin;
pub use vline::VLine;
@@ -87,7 +87,7 @@ pub enum GeomType {
Path,
Bar,
Area,
- Tile,
+ Rect,
Polygon,
Ribbon,
Histogram,
@@ -113,7 +113,7 @@ impl std::fmt::Display for GeomType {
GeomType::Path => "path",
GeomType::Bar => "bar",
GeomType::Area => "area",
- GeomType::Tile => "tile",
+ GeomType::Rect => "rect",
GeomType::Polygon => "polygon",
GeomType::Ribbon => "ribbon",
GeomType::Histogram => "histogram",
@@ -251,9 +251,9 @@ impl Geom {
Self(Arc::new(Area))
}
- /// Create a Tile geom
- pub fn tile() -> Self {
- Self(Arc::new(Tile))
+ /// Create a Rect geom
+ pub fn rect() -> Self {
+ Self(Arc::new(Rect))
}
/// Create a Polygon geom
@@ -339,7 +339,7 @@ impl Geom {
GeomType::Path => Self::path(),
GeomType::Bar => Self::bar(),
GeomType::Area => Self::area(),
- GeomType::Tile => Self::tile(),
+ GeomType::Rect => Self::rect(),
GeomType::Polygon => Self::polygon(),
GeomType::Ribbon => Self::ribbon(),
GeomType::Histogram => Self::histogram(),
diff --git a/src/plot/layer/geom/rect.rs b/src/plot/layer/geom/rect.rs
new file mode 100644
index 00000000..5f7a8822
--- /dev/null
+++ b/src/plot/layer/geom/rect.rs
@@ -0,0 +1,902 @@
+//! Rect geom implementation with flexible parameter specification
+
+use std::collections::HashMap;
+
+use super::types::get_column_name;
+use super::{DefaultAesthetics, GeomTrait, GeomType, StatResult};
+use crate::naming;
+use crate::plot::types::{DefaultAestheticValue, ParameterValue};
+use crate::{DataFrame, GgsqlError, Mappings, Result};
+
+use super::types::Schema;
+
+/// Rect geom - rectangles with flexible parameter specification
+///
+/// Supports multiple ways to specify rectangles:
+/// - X-direction: any 2 of {x (center), width, xmin, xmax}
+/// - Y-direction: any 2 of {y (center), height, ymin, ymax}
+///
+/// For continuous scales, computes xmin/xmax and ymin/ymax
+/// For discrete scales, uses x/y with width/height as band fractions
+#[derive(Debug, Clone, Copy)]
+pub struct Rect;
+
+impl GeomTrait for Rect {
+ fn geom_type(&self) -> GeomType {
+ GeomType::Rect
+ }
+
+ fn aesthetics(&self) -> DefaultAesthetics {
+ DefaultAesthetics {
+ defaults: &[
+ // All positional aesthetics are optional inputs (Null)
+ // They become Delayed after stat transform
+ ("pos1", DefaultAestheticValue::Null), // x (center)
+ ("pos1min", DefaultAestheticValue::Null), // xmin
+ ("pos1max", DefaultAestheticValue::Null), // xmax
+ ("width", DefaultAestheticValue::Null), // width (aesthetic, can map to column)
+ ("pos2", DefaultAestheticValue::Null), // y (center)
+ ("pos2min", DefaultAestheticValue::Null), // ymin
+ ("pos2max", DefaultAestheticValue::Null), // ymax
+ ("height", DefaultAestheticValue::Null), // height (aesthetic, can map to column)
+ // Visual aesthetics
+ ("fill", DefaultAestheticValue::String("black")),
+ ("stroke", DefaultAestheticValue::String("black")),
+ ("opacity", DefaultAestheticValue::Number(0.8)),
+ ("linewidth", DefaultAestheticValue::Number(1.0)),
+ ("linetype", DefaultAestheticValue::String("solid")),
+ ],
+ }
+ }
+
+ fn default_remappings(&self) -> &'static [(&'static str, DefaultAestheticValue)] {
+ &[
+ // For continuous scales: remap to min/max
+ ("pos1min", DefaultAestheticValue::Column("pos1min")),
+ ("pos1max", DefaultAestheticValue::Column("pos1max")),
+ ("pos2min", DefaultAestheticValue::Column("pos2min")),
+ ("pos2max", DefaultAestheticValue::Column("pos2max")),
+ // For discrete scales: remap to center
+ ("pos1", DefaultAestheticValue::Column("pos1")),
+ ("pos2", DefaultAestheticValue::Column("pos2")),
+ // Width/height passed through for discrete (writer validation)
+ ("width", DefaultAestheticValue::Column("width")),
+ ("height", DefaultAestheticValue::Column("height")),
+ ]
+ }
+
+ fn valid_stat_columns(&self) -> &'static [&'static str] {
+ &[
+ "pos1", "pos2", "pos1min", "pos1max", "pos2min", "pos2max", "width", "height",
+ ]
+ }
+
+ fn stat_consumed_aesthetics(&self) -> &'static [&'static str] {
+ &[
+ "pos1", "pos1min", "pos1max", "width", "pos2", "pos2min", "pos2max", "height",
+ ]
+ }
+
+ fn needs_stat_transform(&self, _aesthetics: &Mappings) -> bool {
+ // Always apply stat transform to validate and consolidate parameters
+ true
+ }
+
+ fn apply_stat_transform(
+ &self,
+ query: &str,
+ schema: &Schema,
+ aesthetics: &Mappings,
+ group_by: &[String],
+ parameters: &HashMap,
+ _execute_query: &dyn Fn(&str) -> Result,
+ ) -> Result {
+ stat_rect(query, schema, aesthetics, group_by, parameters)
+ }
+}
+
+impl std::fmt::Display for Rect {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "rect")
+ }
+}
+
+/// Process a single direction (x or y) for rect stat transform
+/// Returns (select_parts, stat_column_names)
+fn process_direction(
+ axis: &str,
+ aesthetics: &Mappings,
+ parameters: &HashMap,
+ schema: &Schema,
+) -> Result<(Vec, Vec)> {
+ // Derive aesthetic names from axis
+ let (center_aes, min_aes, max_aes, size_aes) = match axis {
+ "x" => ("pos1", "pos1min", "pos1max", "width"),
+ "y" => ("pos2", "pos2min", "pos2max", "height"),
+ _ => unreachable!("axis must be 'x' or 'y'"),
+ };
+
+ // Get column names from MAPPING, with SETTING fallback for size
+ let center = get_column_name(aesthetics, center_aes);
+ let min = get_column_name(aesthetics, min_aes);
+ let max = get_column_name(aesthetics, max_aes);
+ let size = get_column_name(aesthetics, size_aes)
+ .or_else(|| parameters.get(size_aes).map(|v| v.to_string()));
+
+ // Detect if discrete by checking schema
+ let is_discrete = center
+ .as_ref()
+ .and_then(|col| schema.iter().find(|c| &c.name == col))
+ .map(|c| c.is_discrete)
+ .unwrap_or(false);
+
+ // Generate position expressions
+ let (expr_1, expr_2) = if is_discrete {
+ generate_discrete_position_expressions(
+ center.as_deref(),
+ min.as_deref(),
+ max.as_deref(),
+ size.as_deref(),
+ axis,
+ )?
+ } else {
+ generate_continuous_position_expressions(
+ center.as_deref(),
+ min.as_deref(),
+ max.as_deref(),
+ size.as_deref(),
+ axis,
+ )?
+ };
+
+ // Determine stat column names based on discrete vs continuous
+ let stat_cols = if is_discrete {
+ vec![center_aes.to_string(), size_aes.to_string()]
+ } else {
+ vec![min_aes.to_string(), max_aes.to_string()]
+ };
+
+ // Build SELECT parts using the stat columns
+ let select_parts = vec![
+ format!("{} AS {}", expr_1, naming::stat_column(&stat_cols[0])),
+ format!("{} AS {}", expr_2, naming::stat_column(&stat_cols[1])),
+ ];
+
+ Ok((select_parts, stat_cols))
+}
+
+/// Statistical transformation for rect: consolidate parameters and compute min/max
+fn stat_rect(
+ query: &str,
+ schema: &Schema,
+ aesthetics: &Mappings,
+ _group_by: &[String],
+ parameters: &HashMap,
+) -> Result {
+ // Process X direction
+ let (x_select, x_stat_cols) = process_direction("x", aesthetics, parameters, schema)?;
+
+ // Process Y direction
+ let (y_select, y_stat_cols) = process_direction("y", aesthetics, parameters, schema)?;
+
+ // Define consumed aesthetics (these will be transformed, not passed through)
+ let consumed_aesthetic_names = [
+ "pos1", "pos1min", "pos1max", "width", "pos2", "pos2min", "pos2max", "height",
+ ];
+
+ // Convert aesthetic names to column names for filtering
+ let consumed_columns: Vec = consumed_aesthetic_names
+ .iter()
+ .filter_map(|aes| get_column_name(aesthetics, aes))
+ .collect();
+
+ // Build SELECT list starting with non-consumed columns
+ let mut select_parts: Vec = schema
+ .iter()
+ .filter(|col| !consumed_columns.contains(&col.name))
+ .map(|col| col.name.clone())
+ .collect();
+
+ // Add X direction SELECT parts and collect stat columns
+ select_parts.extend(x_select);
+ let mut stat_columns = x_stat_cols;
+
+ // Add Y direction SELECT parts and collect stat columns
+ select_parts.extend(y_select);
+ stat_columns.extend(y_stat_cols);
+
+ let select_list = select_parts.join(", ");
+
+ // Build transformed query
+ let transformed_query = format!(
+ "SELECT {} FROM ({}) AS __ggsql_rect_stat__",
+ select_list, query
+ );
+
+ // Use the same consumed aesthetic names for StatResult
+ Ok(StatResult::Transformed {
+ query: transformed_query,
+ stat_columns,
+ dummy_columns: vec![],
+ consumed_aesthetics: consumed_aesthetic_names
+ .iter()
+ .map(|s| s.to_string())
+ .collect(),
+ })
+}
+
+/// Generate SQL expressions for discrete position (returns center, size)
+///
+/// Validates:
+/// - Discrete scales cannot use min/max aesthetics
+/// - Center is required
+/// - Size defaults to "1.0" if not provided
+fn generate_discrete_position_expressions(
+ center: Option<&str>,
+ min: Option<&str>,
+ max: Option<&str>,
+ size: Option<&str>,
+ axis: &str,
+) -> Result<(String, String)> {
+ // Validate: discrete scales cannot use min/max
+ if min.is_some() || max.is_some() {
+ return Err(GgsqlError::ValidationError(format!(
+ "Cannot use {}min/{}max with discrete {} aesthetic. Use {} + {} instead.",
+ axis,
+ axis,
+ axis,
+ axis,
+ if axis == "x" { "width" } else { "height" }
+ )));
+ }
+
+ match center {
+ Some(c) => Ok((c.to_string(), size.unwrap_or("1.0").to_string())),
+ None => Err(GgsqlError::ValidationError(format!(
+ "Discrete {} requires {}.",
+ axis, axis
+ ))),
+ }
+}
+
+/// Generate SQL expressions for continuous position (returns min_expr, max_expr)
+///
+/// Handles all 7 valid parameter combinations:
+/// - min + max
+/// - center + size
+/// - center only (defaults size to 1.0)
+/// - center + min
+/// - center + max
+/// - min + size
+/// - max + size
+fn generate_continuous_position_expressions(
+ center: Option<&str>,
+ min: Option<&str>,
+ max: Option<&str>,
+ size: Option<&str>,
+ axis: &str,
+) -> Result<(String, String)> {
+ match (center, min, max, size) {
+ // Case 1: min + max
+ (None, Some(min_col), Some(max_col), None) => {
+ Ok((min_col.to_string(), max_col.to_string()))
+ }
+ // Case 2: center + size
+ (Some(c), None, None, Some(s)) => Ok((
+ format!("({} - {} / 2.0)", c, s),
+ format!("({} + {} / 2.0)", c, s),
+ )),
+ // Case 2b: center only (default size to 1.0)
+ (Some(c), None, None, None) => Ok((format!("({} - 0.5)", c), format!("({} + 0.5)", c))),
+ // Case 3: center + min
+ (Some(c), Some(min_col), None, None) => {
+ Ok((min_col.to_string(), format!("(2 * {} - {})", c, min_col)))
+ }
+ // Case 4: center + max
+ (Some(c), None, Some(max_col), None) => {
+ Ok((format!("(2 * {} - {})", c, max_col), max_col.to_string()))
+ }
+ // Case 5: min + size
+ (None, Some(min_col), None, Some(s)) => {
+ Ok((min_col.to_string(), format!("({} + {})", min_col, s)))
+ }
+ // Case 6: max + size
+ (None, None, Some(max_col), Some(s)) => {
+ Ok((format!("({} - {})", max_col, s), max_col.to_string()))
+ }
+ // Invalid: wrong number of parameters or invalid combination
+ _ => Err(GgsqlError::ValidationError(format!(
+ "Rect requires exactly 2 {}-direction parameters from {{{}, {}min, {}max, {}}}.",
+ axis,
+ axis,
+ axis,
+ axis,
+ if axis == "x" { "width" } else { "height" }
+ ))),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::plot::types::{AestheticValue, ColumnInfo};
+ use polars::prelude::DataType;
+
+ // ==================== Helper Functions ====================
+
+ fn create_schema(discrete_cols: &[&str]) -> Schema {
+ create_schema_with_extra(discrete_cols, &[])
+ }
+
+ fn create_schema_with_extra(discrete_cols: &[&str], extra_cols: &[&str]) -> Schema {
+ let mut schema = vec![
+ ColumnInfo {
+ name: "__ggsql_aes_pos1__".to_string(),
+ dtype: if discrete_cols.contains(&"pos1") {
+ DataType::String
+ } else {
+ DataType::Float64
+ },
+ is_discrete: discrete_cols.contains(&"pos1"),
+ min: None,
+ max: None,
+ },
+ ColumnInfo {
+ name: "__ggsql_aes_pos1min__".to_string(),
+ dtype: DataType::Float64,
+ is_discrete: false,
+ min: None,
+ max: None,
+ },
+ ColumnInfo {
+ name: "__ggsql_aes_pos1max__".to_string(),
+ dtype: DataType::Float64,
+ is_discrete: false,
+ min: None,
+ max: None,
+ },
+ ColumnInfo {
+ name: "__ggsql_aes_width__".to_string(),
+ dtype: DataType::Float64,
+ is_discrete: false,
+ min: None,
+ max: None,
+ },
+ ColumnInfo {
+ name: "__ggsql_aes_pos2__".to_string(),
+ dtype: if discrete_cols.contains(&"pos2") {
+ DataType::String
+ } else {
+ DataType::Float64
+ },
+ is_discrete: discrete_cols.contains(&"pos2"),
+ min: None,
+ max: None,
+ },
+ ColumnInfo {
+ name: "__ggsql_aes_pos2min__".to_string(),
+ dtype: DataType::Float64,
+ is_discrete: false,
+ min: None,
+ max: None,
+ },
+ ColumnInfo {
+ name: "__ggsql_aes_pos2max__".to_string(),
+ dtype: DataType::Float64,
+ is_discrete: false,
+ min: None,
+ max: None,
+ },
+ ColumnInfo {
+ name: "__ggsql_aes_height__".to_string(),
+ dtype: DataType::Float64,
+ is_discrete: false,
+ min: None,
+ max: None,
+ },
+ ];
+
+ // Add extra columns (e.g., fill, color, etc.)
+ for col_name in extra_cols {
+ schema.push(ColumnInfo {
+ name: col_name.to_string(),
+ dtype: DataType::String,
+ is_discrete: true,
+ min: None,
+ max: None,
+ });
+ }
+
+ schema
+ }
+
+ fn create_aesthetics(mappings: &[&str]) -> Mappings {
+ let mut aesthetics = Mappings::new();
+ for aesthetic in mappings {
+ // Use aesthetic column naming convention
+ let col_name = naming::aesthetic_column(aesthetic);
+ aesthetics.insert(
+ aesthetic.to_string(),
+ AestheticValue::standard_column(col_name),
+ );
+ }
+ aesthetics
+ }
+
+ // ==================== X-Direction Parameter Combinations (Continuous) ====================
+
+ #[test]
+ fn test_continuous_x_all_combinations() {
+ let test_cases = vec![
+ // (name, x_aesthetics, expected_min_expr, expected_max_expr)
+ (
+ "xmin + xmax",
+ vec!["pos1min", "pos1max"],
+ "__ggsql_aes_pos1min__",
+ "__ggsql_aes_pos1max__",
+ ),
+ (
+ "x + width",
+ vec!["pos1", "width"],
+ "(__ggsql_aes_pos1__ - __ggsql_aes_width__ / 2.0)",
+ "(__ggsql_aes_pos1__ + __ggsql_aes_width__ / 2.0)",
+ ),
+ (
+ "x only (default width 1.0)",
+ vec!["pos1"],
+ "(__ggsql_aes_pos1__ - 0.5)",
+ "(__ggsql_aes_pos1__ + 0.5)",
+ ),
+ (
+ "x + xmin",
+ vec!["pos1", "pos1min"],
+ "__ggsql_aes_pos1min__",
+ "(2 * __ggsql_aes_pos1__ - __ggsql_aes_pos1min__)",
+ ),
+ (
+ "x + xmax",
+ vec!["pos1", "pos1max"],
+ "(2 * __ggsql_aes_pos1__ - __ggsql_aes_pos1max__)",
+ "__ggsql_aes_pos1max__",
+ ),
+ (
+ "xmin + width",
+ vec!["pos1min", "width"],
+ "__ggsql_aes_pos1min__",
+ "(__ggsql_aes_pos1min__ + __ggsql_aes_width__)",
+ ),
+ (
+ "xmax + width",
+ vec!["pos1max", "width"],
+ "(__ggsql_aes_pos1max__ - __ggsql_aes_width__)",
+ "__ggsql_aes_pos1max__",
+ ),
+ ];
+
+ for (name, x_aesthetics, expected_min, expected_max) in test_cases {
+ // Combine x aesthetics with fixed y mappings (ymin + ymax)
+ let mut all_mappings = x_aesthetics.clone();
+ all_mappings.extend_from_slice(&["pos2min", "pos2max"]);
+
+ let aesthetics = create_aesthetics(&all_mappings);
+ let schema = create_schema(&[]);
+ let group_by = vec![];
+ let parameters = HashMap::new();
+
+ let result = stat_rect(
+ "SELECT * FROM data",
+ &schema,
+ &aesthetics,
+ &group_by,
+ ¶meters,
+ );
+
+ assert!(
+ result.is_ok(),
+ "{}: stat_rect failed: {:?}",
+ name,
+ result.err()
+ );
+ let stat_result = result.unwrap();
+
+ if let StatResult::Transformed {
+ query,
+ stat_columns,
+ ..
+ } = stat_result
+ {
+ let stat_pos1min = naming::stat_column("pos1min");
+ let stat_pos1max = naming::stat_column("pos1max");
+ assert!(
+ query.contains(&format!("{} AS {}", expected_min, stat_pos1min)),
+ "{}: Expected '{} AS {}' in query, got: {}",
+ name,
+ expected_min,
+ stat_pos1min,
+ query
+ );
+ assert!(
+ query.contains(&format!("{} AS {}", expected_max, stat_pos1max)),
+ "{}: Expected '{} AS {}' in query, got: {}",
+ name,
+ expected_max,
+ stat_pos1max,
+ query
+ );
+ assert!(
+ stat_columns.contains(&"pos1min".to_string()),
+ "{}: Missing pos1min in stat_columns",
+ name
+ );
+ assert!(
+ stat_columns.contains(&"pos1max".to_string()),
+ "{}: Missing pos1max in stat_columns",
+ name
+ );
+ } else {
+ panic!("{}: Expected Transformed result", name);
+ }
+ }
+ }
+
+ // ==================== Y-Direction Parameter Combinations (Continuous) ====================
+
+ #[test]
+ fn test_continuous_y_all_combinations() {
+ let test_cases = vec![
+ // (name, y_aesthetics, expected_min_expr, expected_max_expr)
+ (
+ "ymin + ymax",
+ vec!["pos2min", "pos2max"],
+ "__ggsql_aes_pos2min__",
+ "__ggsql_aes_pos2max__",
+ ),
+ (
+ "y + height",
+ vec!["pos2", "height"],
+ "(__ggsql_aes_pos2__ - __ggsql_aes_height__ / 2.0)",
+ "(__ggsql_aes_pos2__ + __ggsql_aes_height__ / 2.0)",
+ ),
+ (
+ "y + ymin",
+ vec!["pos2", "pos2min"],
+ "__ggsql_aes_pos2min__",
+ "(2 * __ggsql_aes_pos2__ - __ggsql_aes_pos2min__)",
+ ),
+ (
+ "y + ymax",
+ vec!["pos2", "pos2max"],
+ "(2 * __ggsql_aes_pos2__ - __ggsql_aes_pos2max__)",
+ "__ggsql_aes_pos2max__",
+ ),
+ (
+ "ymin + height",
+ vec!["pos2min", "height"],
+ "__ggsql_aes_pos2min__",
+ "(__ggsql_aes_pos2min__ + __ggsql_aes_height__)",
+ ),
+ (
+ "ymax + height",
+ vec!["pos2max", "height"],
+ "(__ggsql_aes_pos2max__ - __ggsql_aes_height__)",
+ "__ggsql_aes_pos2max__",
+ ),
+ ];
+
+ for (name, y_aesthetics, expected_min, expected_max) in test_cases {
+ // Combine y aesthetics with fixed x mappings (xmin + xmax)
+ let mut all_mappings = vec!["pos1min", "pos1max"];
+ all_mappings.extend_from_slice(&y_aesthetics);
+
+ let aesthetics = create_aesthetics(&all_mappings);
+ let schema = create_schema(&[]);
+ let group_by = vec![];
+ let parameters = HashMap::new();
+
+ let result = stat_rect(
+ "SELECT * FROM data",
+ &schema,
+ &aesthetics,
+ &group_by,
+ ¶meters,
+ );
+
+ assert!(
+ result.is_ok(),
+ "{}: stat_rect failed: {:?}",
+ name,
+ result.err()
+ );
+ let stat_result = result.unwrap();
+
+ if let StatResult::Transformed {
+ query,
+ stat_columns,
+ ..
+ } = stat_result
+ {
+ let stat_pos2min = naming::stat_column("pos2min");
+ let stat_pos2max = naming::stat_column("pos2max");
+ assert!(
+ query.contains(&format!("{} AS {}", expected_min, stat_pos2min)),
+ "{}: Expected '{} AS {}' in query, got: {}",
+ name,
+ expected_min,
+ stat_pos2min,
+ query
+ );
+ assert!(
+ query.contains(&format!("{} AS {}", expected_max, stat_pos2max)),
+ "{}: Expected '{} AS {}' in query, got: {}",
+ name,
+ expected_max,
+ stat_pos2max,
+ query
+ );
+ assert!(
+ stat_columns.contains(&"pos2min".to_string()),
+ "{}: Missing pos2min in stat_columns",
+ name
+ );
+ assert!(
+ stat_columns.contains(&"pos2max".to_string()),
+ "{}: Missing pos2max in stat_columns",
+ name
+ );
+ } else {
+ panic!("{}: Expected Transformed result", name);
+ }
+ }
+ }
+
+ // ==================== Discrete Scale Tests ====================
+
+ #[test]
+ fn test_discrete_x_with_width() {
+ let aesthetics = create_aesthetics(&["pos1", "width", "pos2min", "pos2max"]);
+ let schema = create_schema(&["pos1"]);
+ let group_by = vec![];
+ let parameters = HashMap::new();
+
+ let result = stat_rect(
+ "SELECT * FROM data",
+ &schema,
+ &aesthetics,
+ &group_by,
+ ¶meters,
+ );
+ assert!(result.is_ok());
+
+ if let Ok(StatResult::Transformed {
+ query,
+ stat_columns,
+ ..
+ }) = result
+ {
+ assert!(query.contains("__ggsql_aes_pos1__ AS __ggsql_stat_pos1"));
+ assert!(query.contains("__ggsql_aes_width__ AS __ggsql_stat_width"));
+ assert!(stat_columns.contains(&"pos1".to_string()));
+ assert!(stat_columns.contains(&"width".to_string()));
+ assert!(stat_columns.contains(&"pos2min".to_string()));
+ assert!(stat_columns.contains(&"pos2max".to_string()));
+ }
+ }
+
+ #[test]
+ fn test_discrete_y_with_height() {
+ let aesthetics = create_aesthetics(&["pos1min", "pos1max", "pos2", "height"]);
+ let schema = create_schema(&["pos2"]);
+ let group_by = vec![];
+ let parameters = HashMap::new();
+
+ let result = stat_rect(
+ "SELECT * FROM data",
+ &schema,
+ &aesthetics,
+ &group_by,
+ ¶meters,
+ );
+ assert!(result.is_ok());
+
+ if let Ok(StatResult::Transformed {
+ query,
+ stat_columns,
+ ..
+ }) = result
+ {
+ assert!(query.contains("__ggsql_aes_pos2__ AS __ggsql_stat_pos2"));
+ assert!(query.contains("__ggsql_aes_height__ AS __ggsql_stat_height"));
+ assert!(stat_columns.contains(&"pos1min".to_string()));
+ assert!(stat_columns.contains(&"pos1max".to_string()));
+ assert!(stat_columns.contains(&"pos2".to_string()));
+ assert!(stat_columns.contains(&"height".to_string()));
+ }
+ }
+
+ #[test]
+ fn test_discrete_both_directions() {
+ let aesthetics = create_aesthetics(&["pos1", "width", "pos2", "height"]);
+ let schema = create_schema(&["pos1", "pos2"]);
+ let group_by = vec![];
+ let parameters = HashMap::new();
+
+ let result = stat_rect(
+ "SELECT * FROM data",
+ &schema,
+ &aesthetics,
+ &group_by,
+ ¶meters,
+ );
+ assert!(result.is_ok());
+
+ if let Ok(StatResult::Transformed {
+ query,
+ stat_columns,
+ ..
+ }) = result
+ {
+ assert!(query.contains("__ggsql_aes_pos1__ AS __ggsql_stat_pos1"));
+ assert!(query.contains("__ggsql_aes_width__ AS __ggsql_stat_width"));
+ assert!(query.contains("__ggsql_aes_pos2__ AS __ggsql_stat_pos2"));
+ assert!(query.contains("__ggsql_aes_height__ AS __ggsql_stat_height"));
+ assert_eq!(stat_columns.len(), 4);
+ }
+ }
+
+ // ==================== Validation Error Tests ====================
+
+ #[test]
+ fn test_continuous_x_defaults_width() {
+ // Test that continuous x without explicit width defaults to 1.0
+ let aesthetics = create_aesthetics(&["pos1", "pos2min", "pos2max"]);
+ let schema = create_schema(&[]);
+ let group_by = vec![];
+ let parameters = HashMap::new();
+
+ let result = stat_rect(
+ "SELECT * FROM data",
+ &schema,
+ &aesthetics,
+ &group_by,
+ ¶meters,
+ );
+ assert!(result.is_ok());
+ let stat_result = result.unwrap();
+ match stat_result {
+ StatResult::Transformed {
+ query,
+ stat_columns,
+ ..
+ } => {
+ assert!(query.contains("(__ggsql_aes_pos1__ - 0.5)"));
+ assert!(query.contains("(__ggsql_aes_pos1__ + 0.5)"));
+ assert!(stat_columns.contains(&"pos1min".to_string()));
+ assert!(stat_columns.contains(&"pos1max".to_string()));
+ }
+ _ => panic!("Expected Transformed"),
+ }
+ }
+
+ #[test]
+ fn test_error_too_many_x_params() {
+ let aesthetics = create_aesthetics(&["pos1", "pos1min", "pos1max", "pos2min", "pos2max"]);
+ let schema = create_schema(&[]);
+ let group_by = vec![];
+ let parameters = HashMap::new();
+
+ let result = stat_rect(
+ "SELECT * FROM data",
+ &schema,
+ &aesthetics,
+ &group_by,
+ ¶meters,
+ );
+ assert!(result.is_err());
+ let err_msg = result.unwrap_err().to_string();
+ assert!(err_msg.contains("exactly 2 x-direction parameters"));
+ }
+
+ #[test]
+ fn test_error_discrete_with_min_max() {
+ let aesthetics = create_aesthetics(&["pos1", "pos1min", "pos2min", "pos2max"]);
+ let schema = create_schema(&["pos1"]);
+ let group_by = vec![];
+ let parameters = HashMap::new();
+
+ let result = stat_rect(
+ "SELECT * FROM data",
+ &schema,
+ &aesthetics,
+ &group_by,
+ ¶meters,
+ );
+ assert!(result.is_err());
+ let err_msg = result.unwrap_err().to_string();
+ assert!(err_msg.contains("Cannot use xmin/xmax with discrete x"));
+ }
+
+ #[test]
+ fn test_discrete_x_defaults_width() {
+ // Test that discrete x without explicit width defaults to 1.0
+ let aesthetics = create_aesthetics(&["pos1", "pos2min", "pos2max"]);
+ let schema = create_schema(&["pos1"]);
+ let group_by = vec![];
+ let parameters = HashMap::new();
+
+ let result = stat_rect(
+ "SELECT * FROM data",
+ &schema,
+ &aesthetics,
+ &group_by,
+ ¶meters,
+ );
+ assert!(result.is_ok());
+ let stat_result = result.unwrap();
+ match stat_result {
+ StatResult::Transformed {
+ query,
+ stat_columns,
+ ..
+ } => {
+ assert!(query.contains("1.0 AS __ggsql_stat_width"));
+ assert!(stat_columns.contains(&"width".to_string()));
+ }
+ _ => panic!("Expected Transformed"),
+ }
+ }
+
+ // ==================== Non-Consumed Aesthetic Tests ====================
+
+ #[test]
+ fn test_non_consumed_aesthetics_passed_through() {
+ let aesthetics = create_aesthetics(&["pos1", "width", "pos2", "height"]);
+ // Include fill in schema (it's a non-consumed aesthetic)
+ let schema = create_schema_with_extra(&["pos1", "pos2"], &["__ggsql_aes_fill__"]);
+ let group_by = vec![];
+ let parameters = HashMap::new();
+
+ let result = stat_rect(
+ "SELECT * FROM data",
+ &schema,
+ &aesthetics,
+ &group_by,
+ ¶meters,
+ );
+ assert!(result.is_ok());
+
+ if let Ok(StatResult::Transformed { query, .. }) = result {
+ // Should include fill column (non-consumed aesthetic from schema)
+ assert!(query.contains("__ggsql_aes_fill__"));
+ // Should NOT include width/height as pass-through (they're consumed)
+ // They should only appear as stat columns
+ assert!(query.contains("__ggsql_aes_width__ AS __ggsql_stat_width"));
+ assert!(query.contains("__ggsql_aes_height__ AS __ggsql_stat_height"));
+ }
+ }
+
+ #[test]
+ fn test_setting_width_as_fallback() {
+ // Test that SETTING width/height are used when no MAPPING is provided
+ let aesthetics = create_aesthetics(&["pos1", "pos2"]);
+ let schema = create_schema(&["pos1", "pos2"]);
+ let group_by = vec![];
+ let mut parameters = HashMap::new();
+ parameters.insert("width".to_string(), ParameterValue::Number(0.7));
+ parameters.insert("height".to_string(), ParameterValue::Number(0.9));
+
+ let result = stat_rect(
+ "SELECT * FROM data",
+ &schema,
+ &aesthetics,
+ &group_by,
+ ¶meters,
+ );
+ assert!(result.is_ok());
+
+ if let Ok(StatResult::Transformed { query, .. }) = result {
+ // Should use SETTING values as SQL literals
+ assert!(query.contains("0.7 AS __ggsql_stat_width"));
+ assert!(query.contains("0.9 AS __ggsql_stat_height"));
+ }
+ }
+}
diff --git a/src/plot/layer/geom/tile.rs b/src/plot/layer/geom/tile.rs
deleted file mode 100644
index 870721d3..00000000
--- a/src/plot/layer/geom/tile.rs
+++ /dev/null
@@ -1,34 +0,0 @@
-//! Tile geom implementation
-
-use super::{DefaultAesthetics, GeomTrait, GeomType};
-use crate::plot::types::DefaultAestheticValue;
-
-/// Tile geom - heatmaps and tile-based visualizations
-#[derive(Debug, Clone, Copy)]
-pub struct Tile;
-
-impl GeomTrait for Tile {
- fn geom_type(&self) -> GeomType {
- GeomType::Tile
- }
-
- fn aesthetics(&self) -> DefaultAesthetics {
- DefaultAesthetics {
- defaults: &[
- ("pos1", DefaultAestheticValue::Required),
- ("pos2", DefaultAestheticValue::Required),
- ("fill", DefaultAestheticValue::String("black")),
- ("stroke", DefaultAestheticValue::String("black")),
- ("width", DefaultAestheticValue::Null),
- ("height", DefaultAestheticValue::Null),
- ("opacity", DefaultAestheticValue::Number(1.0)),
- ],
- }
- }
-}
-
-impl std::fmt::Display for Tile {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- write!(f, "tile")
- }
-}
diff --git a/src/plot/scale/mod.rs b/src/plot/scale/mod.rs
index fb7203c7..31ea9b5e 100644
--- a/src/plot/scale/mod.rs
+++ b/src/plot/scale/mod.rs
@@ -61,6 +61,8 @@ pub fn gets_default_scale(aesthetic: &str) -> bool {
"fill" | "stroke"
// Size aesthetics
| "size" | "linewidth"
+ // Dimension aesthetics
+ | "width" | "height"
// Other visual aesthetics
| "opacity" | "shape" | "linetype"
)
diff --git a/src/reader/mod.rs b/src/reader/mod.rs
index 25771b79..c9d50e7c 100644
--- a/src/reader/mod.rs
+++ b/src/reader/mod.rs
@@ -733,7 +733,7 @@ mod tests {
(4, 40, 85.0)
) AS t(x, y, value)
VISUALISE
- DRAW tile MAPPING x AS x, y AS y, value AS fill
+ DRAW point MAPPING x AS x, y AS y, value AS fill
SCALE BINNED fill FROM [0, 100] TO viridis SETTING breaks => [0, 25, 50, 75, 100]
"#;
diff --git a/src/writer/vegalite/layer.rs b/src/writer/vegalite/layer.rs
index 311b3381..249bf373 100644
--- a/src/writer/vegalite/layer.rs
+++ b/src/writer/vegalite/layer.rs
@@ -30,7 +30,7 @@ pub fn geom_to_mark(geom: &Geom) -> Value {
GeomType::Path => "line",
GeomType::Bar => "bar",
GeomType::Area => "area",
- GeomType::Tile => "rect",
+ GeomType::Rect => "rect",
GeomType::Ribbon => "area",
GeomType::Polygon => "line",
GeomType::Histogram => "bar",
@@ -241,6 +241,144 @@ impl GeomRenderer for RibbonRenderer {
}
}
+// =============================================================================
+// Rect Renderer
+// =============================================================================
+
+/// Renderer for rect geom - handles continuous and discrete rectangles
+///
+/// For continuous scales: remaps xmin/xmax → x/x2, ymin/ymax → y/y2
+/// For discrete scales: keeps x/y as-is and applies width/height as band fractions
+pub struct RectRenderer;
+
+impl RectRenderer {
+ /// Extract and remove band size (width/height) from encoding for discrete scales.
+ /// Should only be called for discrete scales.
+ fn extract_band_size(
+ encoding: &mut Map,
+ aesthetic: &str,
+ axis: &str,
+ ) -> Result {
+ const DEFAULT_BAND_SIZE: f64 = 1.0;
+
+ // Extract and remove the aesthetic
+ let size_enc = encoding.remove(aesthetic);
+
+ // If no aesthetic specified, use default
+ let Some(size_enc) = size_enc else {
+ return Ok(DEFAULT_BAND_SIZE);
+ };
+
+ // Case 1: value encoding (from SETTING parameter) - extract directly
+ if let Some(value) = size_enc.get("value").and_then(|v| v.as_f64()) {
+ return Ok(value);
+ }
+
+ // Case 2: field encoding (from MAPPING) - check scale domain for constant
+ if size_enc.get("field").is_none() {
+ // Neither value nor field - shouldn't happen
+ return Err(GgsqlError::WriterError(format!(
+ "Invalid {} encoding (expected value or field).",
+ aesthetic
+ )));
+ }
+
+ // Helper closure for repeated error message
+ let domain_error = || {
+ GgsqlError::WriterError(format!(
+ "Could not determine {} value for discrete {} scale.",
+ aesthetic, axis
+ ))
+ };
+
+ // Extract domain from scale
+ let domain = size_enc
+ .get("scale")
+ .and_then(|s| s.get("domain"))
+ .and_then(|d| d.as_array())
+ .ok_or_else(domain_error)?;
+
+ if domain.len() != 2 {
+ return Err(domain_error());
+ }
+
+ let (Some(min), Some(max)) = (domain[0].as_f64(), domain[1].as_f64()) else {
+ return Err(domain_error());
+ };
+
+ if (min - max).abs() < 1e-10 {
+ // Constant value - use it
+ Ok(min)
+ } else {
+ // Variable - error
+ Err(GgsqlError::WriterError(format!(
+ "Discrete {} scale does not support variable {} columns.",
+ axis, aesthetic
+ )))
+ }
+ }
+}
+
+impl GeomRenderer for RectRenderer {
+ fn modify_encoding(&self, encoding: &mut Map, _layer: &Layer) -> Result<()> {
+ // Handle x-direction: continuous if has xmin/xmax, discrete otherwise
+ if let Some(xmin) = encoding.remove("xmin") {
+ encoding.insert("x".to_string(), xmin);
+ }
+ if let Some(xmax) = encoding.remove("xmax") {
+ encoding.insert("x2".to_string(), xmax);
+ }
+
+ // Handle y-direction: continuous if has ymin/ymax, discrete otherwise
+ if let Some(ymin) = encoding.remove("ymin") {
+ encoding.insert("y".to_string(), ymin);
+ }
+ if let Some(ymax) = encoding.remove("ymax") {
+ encoding.insert("y2".to_string(), ymax);
+ }
+
+ Ok(())
+ }
+
+ fn modify_spec(&self, layer_spec: &mut Value, _layer: &Layer) -> Result<()> {
+ let encoding = layer_spec
+ .get_mut("encoding")
+ .and_then(|e| e.as_object_mut());
+
+ let Some(encoding) = encoding else {
+ return Ok(());
+ };
+
+ // Check which directions are discrete
+ let x_is_discrete = !encoding.contains_key("x2");
+ let y_is_discrete = !encoding.contains_key("y2");
+
+ // Early return if both continuous
+ if !x_is_discrete && !y_is_discrete {
+ return Ok(());
+ }
+
+ // Build mark spec with band sizing for discrete directions
+ let mut mark = json!({
+ "type": "rect",
+ "clip": true
+ });
+
+ if x_is_discrete {
+ let width = Self::extract_band_size(encoding, "width", "x")?;
+ mark["width"] = json!({"band": width});
+ }
+
+ if y_is_discrete {
+ let height = Self::extract_band_size(encoding, "height", "y")?;
+ mark["height"] = json!({"band": height});
+ }
+
+ layer_spec["mark"] = mark;
+ Ok(())
+ }
+}
+
// =============================================================================
// Area Renderer
// =============================================================================
@@ -786,12 +924,13 @@ pub fn get_renderer(geom: &Geom) -> Box {
GeomType::Path => Box::new(PathRenderer),
GeomType::Bar => Box::new(BarRenderer),
GeomType::Area => Box::new(AreaRenderer),
+ GeomType::Rect => Box::new(RectRenderer),
GeomType::Ribbon => Box::new(RibbonRenderer),
GeomType::Polygon => Box::new(PolygonRenderer),
GeomType::Boxplot => Box::new(BoxplotRenderer),
GeomType::Density => Box::new(AreaRenderer),
GeomType::Violin => Box::new(ViolinRenderer),
- // All other geoms (Point, Line, Tile, etc.) use the default renderer
+ // All other geoms (Point, Line, etc.) use the default renderer
_ => Box::new(DefaultRenderer),
}
}
@@ -893,4 +1032,232 @@ mod tests {
]))
);
}
+
+ // =============================================================================
+ // RectRenderer Test Helpers
+ // =============================================================================
+
+ /// Helper to create a quantitative encoding entry
+ fn quant(field: &str) -> Value {
+ json!({"field": field, "type": "quantitative"})
+ }
+
+ /// Helper to create a nominal encoding entry
+ fn nominal(field: &str) -> Value {
+ json!({"field": field, "type": "nominal"})
+ }
+
+ /// Helper to create a literal value encoding
+ fn literal(val: f64) -> Value {
+ json!({"value": val})
+ }
+
+ /// Helper to create a scale encoding with explicit domain
+ /// Use same min/max for constant scales, different values for variable scales
+ fn scale(field: &str, min: f64, max: f64) -> Value {
+ json!({
+ "field": field,
+ "type": "quantitative",
+ "scale": {
+ "domain": [min, max]
+ }
+ })
+ }
+
+ /// Helper to run rect rendering pipeline (modify_encoding + modify_spec)
+ fn render_rect(encoding: &mut Map) -> Result {
+ let renderer = RectRenderer;
+ let layer = Layer::new(crate::plot::Geom::rect());
+
+ renderer.modify_encoding(encoding, &layer)?;
+
+ let mut layer_spec = json!({
+ "mark": {"type": "rect", "clip": true},
+ "encoding": encoding
+ });
+
+ renderer.modify_spec(&mut layer_spec, &layer)?;
+ Ok(layer_spec)
+ }
+
+ // =============================================================================
+ // RectRenderer Tests
+ // =============================================================================
+
+ #[test]
+ fn test_rect_continuous_both_axes() {
+ // Test rect with continuous scales on both axes (xmin/xmax, ymin/ymax)
+ // Should remap xmin->x, xmax->x2, ymin->y, ymax->y2
+ let mut encoding = serde_json::Map::new();
+ encoding.insert("xmin".to_string(), quant("xmin_col"));
+ encoding.insert("xmax".to_string(), quant("xmax_col"));
+ encoding.insert("ymin".to_string(), quant("ymin_col"));
+ encoding.insert("ymax".to_string(), quant("ymax_col"));
+
+ let spec = render_rect(&mut encoding).unwrap();
+
+ // Should remap to x/x2/y/y2
+ let enc = spec["encoding"].as_object().unwrap();
+ assert_eq!(enc.get("x"), Some(&quant("xmin_col")));
+ assert_eq!(enc.get("x2"), Some(&quant("xmax_col")));
+ assert_eq!(enc.get("y"), Some(&quant("ymin_col")));
+ assert_eq!(enc.get("y2"), Some(&quant("ymax_col")));
+
+ // Original min/max should be removed
+ assert!(enc.get("xmin").is_none());
+ assert!(enc.get("xmax").is_none());
+ assert!(enc.get("ymin").is_none());
+ assert!(enc.get("ymax").is_none());
+
+ // Should not have band sizing (both continuous)
+ assert!(spec["mark"].get("width").is_none());
+ assert!(spec["mark"].get("height").is_none());
+ }
+
+ #[test]
+ fn test_rect_discrete_x_continuous_y() {
+ // Test rect with discrete x scale and continuous y scale
+ // x/width (discrete) and ymin/ymax (continuous)
+ let mut encoding = serde_json::Map::new();
+ encoding.insert("x".to_string(), nominal("day"));
+ encoding.insert("width".to_string(), literal(0.8));
+ encoding.insert("ymin".to_string(), quant("ymin_col"));
+ encoding.insert("ymax".to_string(), quant("ymax_col"));
+
+ let spec = render_rect(&mut encoding).unwrap();
+ let enc = spec["encoding"].as_object().unwrap();
+
+ // x should remain as x (discrete)
+ assert_eq!(enc.get("x"), Some(&nominal("day")));
+
+ // y should be remapped from ymin/ymax
+ assert_eq!(enc.get("y"), Some(&quant("ymin_col")));
+ assert_eq!(enc.get("y2"), Some(&quant("ymax_col")));
+
+ // width should be removed
+ assert!(enc.get("width").is_none());
+
+ // Should have width band sizing for discrete x
+ assert_eq!(spec["mark"]["width"], json!({"band": 0.8}));
+ assert!(spec["mark"].get("height").is_none()); // y is continuous, no band height
+ }
+
+ #[test]
+ fn test_rect_discrete_both_axes_literal_width() {
+ // Test rect with discrete scales on both axes with literal width/height
+ let mut encoding = serde_json::Map::new();
+ encoding.insert("x".to_string(), nominal("day"));
+ encoding.insert("width".to_string(), literal(0.7));
+ encoding.insert("y".to_string(), nominal("hour"));
+ encoding.insert("height".to_string(), literal(0.9));
+
+ let spec = render_rect(&mut encoding).unwrap();
+ let enc = spec["encoding"].as_object().unwrap();
+
+ // x and y should remain
+ assert_eq!(enc.get("x"), Some(&nominal("day")));
+ assert_eq!(enc.get("y"), Some(&nominal("hour")));
+
+ // width/height should be removed
+ assert!(enc.get("width").is_none());
+ assert!(enc.get("height").is_none());
+
+ // Should have both width and height band sizing
+ assert_eq!(spec["mark"]["width"], json!({"band": 0.7}));
+ assert_eq!(spec["mark"]["height"], json!({"band": 0.9}));
+ }
+
+ #[test]
+ fn test_rect_discrete_both_axes_default_width() {
+ // Test rect with discrete scales on both axes without explicit width/height
+ // Should use default band size (1.0)
+ let mut encoding = serde_json::Map::new();
+ encoding.insert("x".to_string(), nominal("day"));
+ encoding.insert("y".to_string(), nominal("hour"));
+
+ let spec = render_rect(&mut encoding).unwrap();
+
+ // Should have default band sizing (1.0) for both
+ assert_eq!(spec["mark"]["width"], json!({"band": 1.0}));
+ assert_eq!(spec["mark"]["height"], json!({"band": 1.0}));
+ }
+
+ #[test]
+ fn test_rect_discrete_with_constant_width_column() {
+ // Test rect with discrete x scale where width is a constant-valued column
+ // This should work by detecting the constant in the scale domain
+ let mut encoding = serde_json::Map::new();
+ encoding.insert("x".to_string(), nominal("day"));
+ encoding.insert("width".to_string(), scale("width_col", 0.85, 0.85)); // constant
+ encoding.insert("ymin".to_string(), quant("ymin_col"));
+ encoding.insert("ymax".to_string(), quant("ymax_col"));
+
+ let spec = render_rect(&mut encoding).unwrap();
+
+ // Should extract the constant value 0.85
+ assert_eq!(spec["mark"]["width"], json!({"band": 0.85}));
+ }
+
+ #[test]
+ fn test_rect_discrete_with_variable_width_column_error() {
+ // Test that variable width columns on discrete scales produce an error
+ let mut encoding = serde_json::Map::new();
+ encoding.insert("x".to_string(), nominal("day"));
+ encoding.insert("width".to_string(), scale("width_col", 0.5, 0.9)); // variable
+
+ let result = render_rect(&mut encoding);
+ assert!(result.is_err());
+ let err = result.unwrap_err();
+ assert!(err.to_string().contains("Discrete x scale"));
+ assert!(err
+ .to_string()
+ .contains("does not support variable width columns"));
+ }
+
+ #[test]
+ fn test_rect_extract_band_size_missing_domain_error() {
+ // Test that missing domain in scale produces an error
+ let mut encoding = serde_json::Map::new();
+ encoding.insert("x".to_string(), nominal("day"));
+ encoding.insert(
+ "width".to_string(),
+ json!({
+ "field": "width_col",
+ "type": "quantitative",
+ "scale": {} // missing domain
+ }),
+ );
+
+ let result = render_rect(&mut encoding);
+ assert!(result.is_err());
+ let err = result.unwrap_err();
+ assert!(err.to_string().contains("Could not determine width value"));
+ }
+
+ #[test]
+ fn test_rect_continuous_x_discrete_y() {
+ // Test rect with continuous x (xmin/xmax) and discrete y (y/height)
+ let mut encoding = serde_json::Map::new();
+ encoding.insert("xmin".to_string(), quant("xmin_col"));
+ encoding.insert("xmax".to_string(), quant("xmax_col"));
+ encoding.insert("y".to_string(), nominal("category"));
+ encoding.insert("height".to_string(), literal(0.6));
+
+ let spec = render_rect(&mut encoding).unwrap();
+ let enc = spec["encoding"].as_object().unwrap();
+
+ // x should be remapped from xmin/xmax
+ assert_eq!(enc.get("x"), Some(&quant("xmin_col")));
+ assert_eq!(enc.get("x2"), Some(&quant("xmax_col")));
+
+ // y should remain as y (discrete)
+ assert_eq!(enc.get("y"), Some(&nominal("category")));
+
+ // height should be removed
+ assert!(enc.get("height").is_none());
+
+ // Should have height band sizing for discrete y
+ assert!(spec["mark"].get("width").is_none()); // x is continuous, no band width
+ assert_eq!(spec["mark"]["height"], json!({"band": 0.6}));
+ }
}
diff --git a/src/writer/vegalite/mod.rs b/src/writer/vegalite/mod.rs
index 7e5c7ef6..86e9b683 100644
--- a/src/writer/vegalite/mod.rs
+++ b/src/writer/vegalite/mod.rs
@@ -1155,10 +1155,6 @@ mod tests {
geom_to_mark(&Geom::area()),
json!({"type": "area", "clip": true})
);
- assert_eq!(
- geom_to_mark(&Geom::tile()),
- json!({"type": "rect", "clip": true})
- );
}
#[test]
diff --git a/test.txt b/test.txt
new file mode 100644
index 00000000..e69de29b
diff --git a/tree-sitter-ggsql/grammar.js b/tree-sitter-ggsql/grammar.js
index 9302f195..8d188206 100644
--- a/tree-sitter-ggsql/grammar.js
+++ b/tree-sitter-ggsql/grammar.js
@@ -470,7 +470,7 @@ module.exports = grammar({
),
geom_type: $ => choice(
- 'point', 'line', 'path', 'bar', 'area', 'tile', 'polygon', 'ribbon',
+ 'point', 'line', 'path', 'bar', 'area', 'rect', 'polygon', 'ribbon',
'histogram', 'density', 'smooth', 'boxplot', 'violin',
'text', 'label', 'segment', 'arrow', 'hline', 'vline', 'abline', 'errorbar'
),
diff --git a/tree-sitter-ggsql/queries/highlights.scm b/tree-sitter-ggsql/queries/highlights.scm
index 7066685d..110c2250 100644
--- a/tree-sitter-ggsql/queries/highlights.scm
+++ b/tree-sitter-ggsql/queries/highlights.scm
@@ -12,7 +12,7 @@
"path"
"bar"
"area"
- "tile"
+ "rect"
"polygon"
"ribbon"
"histogram"