From 211c7aeb3ebecd7d8538e2329f817a084a4c7c72 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Fri, 20 Mar 2026 11:22:22 +0100 Subject: [PATCH 01/11] Fix annotation layer query generation for geoms with no required aesthetics When annotation layers have no required aesthetics (e.g., rule geom) and only non-positional scalar parameters, process_annotation_layer now adds a dummy column to generate valid SQL instead of malformed empty VALUES clause. Co-Authored-By: Claude Sonnet 4.5 --- src/execute/layer.rs | 77 +++++++++++++++++++++++++++++++++++++++++--- src/execute/mod.rs | 28 +++++++++++++--- 2 files changed, 97 insertions(+), 8 deletions(-) diff --git a/src/execute/layer.rs b/src/execute/layer.rs index 4993cf40..88005fec 100644 --- a/src/execute/layer.rs +++ b/src/execute/layer.rs @@ -717,7 +717,15 @@ fn process_annotation_layer(layer: &mut Layer) -> Result { } } - // Step 2: Determine max array length from all annotation parameters + // Step 2: Handle empty annotation_params by adding a dummy column + // This occurs when geoms have no required aesthetics and user provides only + // non-positional scalar parameters (e.g., PLACE rule SETTING stroke => 'red') + if annotation_params.is_empty() { + // Add a dummy column so we can generate a valid VALUES clause + annotation_params.push(("__ggsql_dummy__".to_string(), ParameterValue::Number(1.0))); + } + + // Step 3: Determine max array length from all annotation parameters let mut max_length = 1; for (aesthetic, value) in &annotation_params { @@ -743,7 +751,7 @@ fn process_annotation_layer(layer: &mut Layer) -> Result { } } - // Step 3: Build VALUES clause and create final mappings simultaneously + // Step 4: Build VALUES clause and create final mappings simultaneously let mut columns: Vec> = Vec::new(); let mut column_names = Vec::new(); @@ -766,6 +774,11 @@ fn process_annotation_layer(layer: &mut Layer) -> Result { // the same column→aesthetic renaming pipeline as regular layers column_names.push(aesthetic.clone()); + // Skip creating mappings for dummy columns (they're just for valid SQL) + if aesthetic == "__ggsql_dummy__" { + continue; + } + // Create final mapping directly (no intermediate Literal step) let is_positional = crate::plot::aesthetic::is_positional_aesthetic(aesthetic); let mapping_value = if is_positional { @@ -787,14 +800,14 @@ fn process_annotation_layer(layer: &mut Layer) -> Result { layer.parameters.remove(aesthetic); } - // Step 4: Build VALUES rows + // Step 5: Build VALUES rows let mut rows = Vec::new(); for i in 0..max_length { let values: Vec = columns.iter().map(|col| col[i].to_sql()).collect(); rows.push(format!("({})", values.join(", "))); } - // Step 5: Build complete SQL query + // Step 6: Build complete SQL query let values_clause = rows.join(", "); let column_list = column_names .iter() @@ -1106,4 +1119,60 @@ mod tests { ); assert_eq!(series.len(), 2); } + + #[test] + fn test_annotation_no_required_aesthetics() { + // Rule geom has no required aesthetics, only optional ones + let mut layer = Layer::new(Geom::rule()); + layer.source = Some(DataSource::Annotation); + // Only non-positional, non-required scalar parameters + layer.parameters.insert( + "stroke".to_string(), + ParameterValue::String("red".to_string()), + ); + layer + .parameters + .insert("linewidth".to_string(), ParameterValue::Number(2.0)); + + let result = process_annotation_layer(&mut layer); + + // Should generate valid SQL with a dummy column + match result { + Ok(sql) => { + // Check that SQL is valid (has VALUES with at least one column) + assert!( + !sql.contains("(VALUES ) AS t()"), + "Should not generate empty VALUES clause" + ); + assert!( + sql.contains("VALUES") && sql.contains("AS t("), + "Should have VALUES with at least one column" + ); + // Should contain the dummy column + assert!( + sql.contains("__ggsql_dummy__"), + "Should have dummy column when no data columns exist" + ); + } + Err(e) => { + panic!("Unexpected error: {}", e); + } + } + + // Verify that stroke and linewidth remain in parameters (not moved to mappings) + assert!( + layer.parameters.contains_key("stroke"), + "Non-positional, non-required parameters should stay in parameters" + ); + assert!( + layer.parameters.contains_key("linewidth"), + "Non-positional, non-required parameters should stay in parameters" + ); + + // Verify dummy column is not in mappings + assert!( + !layer.mappings.contains_key("__ggsql_dummy__"), + "Dummy column should not be added to mappings" + ); + } } diff --git a/src/execute/mod.rs b/src/execute/mod.rs index 604dabef..f4426a3a 100644 --- a/src/execute/mod.rs +++ b/src/execute/mod.rs @@ -839,6 +839,8 @@ fn collect_layer_required_columns(layer: &Layer, spec: &Plot) -> HashSet /// Prune columns from a DataFrame to only include required columns. /// /// Columns that don't exist in the DataFrame are silently ignored. +/// If no required columns exist in the DataFrame (e.g., annotation layers with only +/// literal aesthetics), returns a 0-column DataFrame with the same row count. fn prune_dataframe(df: &DataFrame, required: &HashSet) -> Result { let columns_to_keep: Vec = df .get_column_names() @@ -848,10 +850,28 @@ fn prune_dataframe(df: &DataFrame, required: &HashSet) -> Result 0.4) + // The row count determines how many marks to draw; aesthetics come from Literal values in mappings + let row_count = df.height(); + + if row_count > 0 { + // Create a 0-column DataFrame with the correct row count + // We do this by creating a dummy column and then dropping it + use polars::prelude::df; + let with_rows = df! { + "__dummy__" => vec![0i32; row_count] + } + .map_err(|e| GgsqlError::InternalError(format!("Failed to create DataFrame: {}", e)))?; + + let result = with_rows.drop("__dummy__").map_err(|e| { + GgsqlError::InternalError(format!("Failed to drop dummy column: {}", e)) + })?; + return Ok(result); + } else { + // 0 rows - just return empty DataFrame + return Ok(DataFrame::default()); + } } df.select(&columns_to_keep) From 41966380a885faf17ba0b94fbeffa2ddd5c6394b Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Fri, 20 Mar 2026 13:44:41 +0100 Subject: [PATCH 02/11] adopt linear layer functionality into rule layer --- src/plot/layer/geom/rule.rs | 63 ++++++++++++++++++- src/writer/vegalite/layer.rs | 117 ++++++++++++++++++++++++++++++++++- 2 files changed, 176 insertions(+), 4 deletions(-) diff --git a/src/plot/layer/geom/rule.rs b/src/plot/layer/geom/rule.rs index 0e1ce286..ef0f780c 100644 --- a/src/plot/layer/geom/rule.rs +++ b/src/plot/layer/geom/rule.rs @@ -1,7 +1,11 @@ //! Rule geom implementation use super::{DefaultAesthetics, GeomTrait, GeomType}; -use crate::plot::types::DefaultAestheticValue; +use crate::plot::{ + orientation::{ALIGNED, ORIENTATION_VALUES}, + types::DefaultAestheticValue, + DefaultParamValue, ParamConstraint, ParamDefinition, +}; /// Rule geom - horizontal and vertical reference lines #[derive(Debug, Clone, Copy)] @@ -16,6 +20,8 @@ impl GeomTrait for Rule { DefaultAesthetics { defaults: &[ ("pos1", DefaultAestheticValue::Null), + ("slope", DefaultAestheticValue::Null), + ("intercept", DefaultAestheticValue::Null), ("stroke", DefaultAestheticValue::String("black")), ("linewidth", DefaultAestheticValue::Number(1.0)), ("opacity", DefaultAestheticValue::Number(1.0)), @@ -23,6 +29,61 @@ impl GeomTrait for Rule { ], } } + + fn default_params(&self) -> &'static [ParamDefinition] { + const PARAMS: &[ParamDefinition] = &[ParamDefinition { + name: "orientation", + default: DefaultParamValue::String(ALIGNED), + constraint: ParamConstraint::string_option(ORIENTATION_VALUES), + }]; + PARAMS + } + + fn post_process( + &self, + df: crate::DataFrame, + parameters: &std::collections::HashMap, + ) -> crate::Result { + use crate::{naming, GgsqlError}; + use polars::prelude::{IntoColumn, NamedFrom, Series}; + + let mut result = df; + let row_count = result.height(); + + // For diagonal rules (slope + intercept), add these as DataFrame columns + // The Vega-Lite writer needs them as columns to create transform calculations + for aesthetic in &["slope", "intercept"] { + if let Some(value) = parameters.get(*aesthetic) { + // Only accept numeric values for slope and intercept + let n = match value { + crate::plot::ParameterValue::Number(n) => *n, + _ => { + return Err(GgsqlError::ValidationError(format!( + "Rule {} must be a number, not {:?}", + aesthetic, value + ))) + } + }; + + // Create a column with the aesthetic's prefixed name + let col_name = naming::aesthetic_column(aesthetic); + let series = Series::new(col_name.clone().into(), vec![n; row_count]); + + // Add the column to the DataFrame + result = result + .with_column(series.into_column()) + .map_err(|e| { + GgsqlError::InternalError(format!( + "Failed to add {} column: {}", + aesthetic, e + )) + })? + .clone(); + } + } + + Ok(result) + } } impl std::fmt::Display for Rule { diff --git a/src/writer/vegalite/layer.rs b/src/writer/vegalite/layer.rs index dcdc5092..ebb5126f 100644 --- a/src/writer/vegalite/layer.rs +++ b/src/writer/vegalite/layer.rs @@ -400,22 +400,133 @@ impl GeomRenderer for RuleRenderer { fn modify_encoding( &self, encoding: &mut Map, - _layer: &Layer, + layer: &Layer, _context: &RenderContext, ) -> Result<()> { let has_x = encoding.contains_key("x"); let has_y = encoding.contains_key("y"); - if !has_x && !has_y { + let diagonal = encoding.contains_key("slope") && encoding.contains_key("intercept"); + if !has_x && !has_y && !diagonal { return Err(GgsqlError::ValidationError( "The `rule` layer requires the `x` or `y` aesthetic. It currently has neither." .to_string(), )); - } else if has_x && has_y { + } else if has_x && has_y && !diagonal { return Err(GgsqlError::ValidationError( "The `rule` layer requires exactly one of the `x` or `y` aesthetic, not both." .to_string(), )); } + if !diagonal { + return Ok(()); + } + + // Remove slope and intercept from encoding - they're only used in transforms + encoding.remove("slope"); + encoding.remove("intercept"); + + // Check orientation + let is_horizontal = is_transposed(layer); + + // For aligned (default): x is primary axis, y is computed (secondary) + // For transposed: y is primary axis, x is computed (secondary) + let (primary, primary2, secondary, secondary2) = if is_horizontal { + ("y", "y2", "x", "x2") + } else { + ("x", "x2", "y", "y2") + }; + + // Add encodings for rule mark + // primary_min/primary_max are created by transforms (extent of the axis) + // secondary_min/secondary_max are computed via formula + encoding.insert( + primary.to_string(), + json!({ + "field": "primary_min", + "type": "quantitative" + }), + ); + encoding.insert( + primary2.to_string(), + json!({ + "field": "primary_max" + }), + ); + encoding.insert( + secondary.to_string(), + json!({ + "field": "secondary_min", + "type": "quantitative" + }), + ); + encoding.insert( + secondary2.to_string(), + json!({ + "field": "secondary_max" + }), + ); + + Ok(()) + } + + fn modify_spec( + &self, + layer_spec: &mut Value, + layer: &Layer, + context: &RenderContext, + ) -> Result<()> { + let diagonal = + layer.mappings.contains_key("slope") && layer.mappings.contains_key("intercept"); + if !diagonal { + return Ok(()); + } + + // Field names for slope and intercept (with aesthetic column prefix) + let slope_field = naming::aesthetic_column("slope"); + let intercept_field = naming::aesthetic_column("intercept"); + + // Check orientation + let is_horizontal = is_transposed(layer); + + // Get extent from appropriate axis: + // - Aligned (default): extent from pos1 (x-axis), compute y from x + // - Transposed: extent from pos2 (y-axis), compute x from y + let extent_aesthetic = if is_horizontal { "pos2" } else { "pos1" }; + let (primary_min, primary_max) = context.get_extent(extent_aesthetic)?; + + // Add transforms: + // 1. Create constant primary_min/primary_max fields (extent of the primary axis) + // 2. Compute secondary values at those primary positions: secondary = slope * primary + intercept + let transforms = json!([ + { + "calculate": primary_min.to_string(), + "as": "primary_min" + }, + { + "calculate": primary_max.to_string(), + "as": "primary_max" + }, + { + "calculate": format!("datum.{} * datum.primary_min + datum.{}", slope_field, intercept_field), + "as": "secondary_min" + }, + { + "calculate": format!("datum.{} * datum.primary_max + datum.{}", slope_field, intercept_field), + "as": "secondary_max" + } + ]); + + // Prepend to existing transforms (if any) + if let Some(existing) = layer_spec.get("transform") { + if let Some(arr) = existing.as_array() { + let mut new_transforms = transforms.as_array().unwrap().clone(); + new_transforms.extend_from_slice(arr); + layer_spec["transform"] = json!(new_transforms); + } + } else { + layer_spec["transform"] = transforms; + } + Ok(()) } } From e6e283eb07a4255bc4546f9b122ec9a167ae741a Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Fri, 20 Mar 2026 13:56:02 +0100 Subject: [PATCH 03/11] repurpose linear tests for rule --- src/writer/vegalite/layer.rs | 76 +++++++++++++++++++----------------- 1 file changed, 40 insertions(+), 36 deletions(-) diff --git a/src/writer/vegalite/layer.rs b/src/writer/vegalite/layer.rs index ebb5126f..fbb23e35 100644 --- a/src/writer/vegalite/layer.rs +++ b/src/writer/vegalite/layer.rs @@ -3387,11 +3387,11 @@ mod tests { } #[test] - fn test_linear_renderer_multiple_lines() { + fn test_rule_renderer_multiple_diagonal_lines() { use crate::reader::{DuckDBReader, Reader}; use crate::writer::{VegaLiteWriter, Writer}; - // Test that linear with 3 different coefficients renders 3 separate lines + // Test that rule with 3 different slopes renders 3 separate lines let query = r#" WITH points AS ( SELECT * FROM (VALUES (0, 5), (5, 15), (10, 25)) AS t(x, y) @@ -3401,12 +3401,12 @@ mod tests { (2, 5, 'A'), (1, 10, 'B'), (3, 0, 'C') - ) AS t(coef, intercept, line_id) + ) AS t(slope, intercept, line_id) ) SELECT * FROM points VISUALISE DRAW point MAPPING x AS x, y AS y - DRAW linear MAPPING coef AS coef, intercept AS intercept, line_id AS color FROM lines + DRAW rule MAPPING slope AS slope, intercept AS intercept, line_id AS color FROM lines "#; // Execute query @@ -3422,21 +3422,21 @@ mod tests { let vl_spec: serde_json::Value = serde_json::from_str(&vl_json).expect("Failed to parse Vega-Lite JSON"); - // Verify we have 2 layers (point + linear) + // Verify we have 2 layers (point + rule) let layers = vl_spec["layer"].as_array().expect("No layers found"); - assert_eq!(layers.len(), 2, "Should have 2 layers (point + linear)"); + assert_eq!(layers.len(), 2, "Should have 2 layers (point + rule)"); - // Get the linear layer (second layer) - let linear_layer = &layers[1]; + // Get the rule layer (second layer) + let rule_layer = &layers[1]; // Verify it's a rule mark assert_eq!( - linear_layer["mark"]["type"], "rule", - "Linear should use rule mark" + rule_layer["mark"]["type"], "rule", + "Rule should use rule mark" ); // Verify transforms exist - let transforms = linear_layer["transform"] + let transforms = rule_layer["transform"] .as_array() .expect("No transforms found"); @@ -3466,7 +3466,7 @@ mod tests { "primary_max should have calculate expression" ); - // Verify secondary_min and secondary_max transforms use coef and intercept with primary_min/primary_max + // Verify secondary_min and secondary_max transforms use slope and intercept with primary_min/primary_max let secondary_min_transform = transforms .iter() .find(|t| t["as"] == "secondary_min") @@ -3483,10 +3483,10 @@ mod tests { .as_str() .expect("secondary_max calculate should be string"); - // Should reference coef, intercept, and primary_min/primary_max + // Should reference slope, intercept, and primary_min/primary_max assert!( - secondary_min_calc.contains("__ggsql_aes_coef__"), - "secondary_min should reference coef" + secondary_min_calc.contains("__ggsql_aes_slope__"), + "secondary_min should reference slope" ); assert!( secondary_min_calc.contains("__ggsql_aes_intercept__"), @@ -3497,8 +3497,8 @@ mod tests { "secondary_min should reference datum.primary_min" ); assert!( - secondary_max_calc.contains("__ggsql_aes_coef__"), - "secondary_max should reference coef" + secondary_max_calc.contains("__ggsql_aes_slope__"), + "secondary_max should reference slope" ); assert!( secondary_max_calc.contains("__ggsql_aes_intercept__"), @@ -3510,7 +3510,7 @@ mod tests { ); // Verify encoding has x, x2, y, y2 with consistent field names - let encoding = linear_layer["encoding"] + let encoding = rule_layer["encoding"] .as_object() .expect("No encoding found"); @@ -3543,52 +3543,56 @@ mod tests { "Should have stroke encoding for line_id" ); - // Verify data has 3 linear rows (one per coef) + // Verify data has 3 rule rows (one per slope) let data_values = vl_spec["data"]["values"] .as_array() .expect("No data values found"); - let linear_rows: Vec<_> = data_values + let rule_rows: Vec<_> = data_values .iter() .filter(|row| { row["__ggsql_source__"] == "__ggsql_layer_1__" - && row["__ggsql_aes_coef__"].is_number() + && row["__ggsql_aes_slope__"].is_number() }) .collect(); assert_eq!( - linear_rows.len(), + rule_rows.len(), 3, - "Should have 3 linear rows (3 different coefficients)" + "Should have 3 rule rows (3 different slopes)" ); - // Verify we have coefs 1, 2, 3 - let mut coefs: Vec = linear_rows + // Verify we have slopes 1, 2, 3 + let mut slopes: Vec = rule_rows .iter() - .map(|row| row["__ggsql_aes_coef__"].as_f64().unwrap()) + .map(|row| row["__ggsql_aes_slope__"].as_f64().unwrap()) .collect(); - coefs.sort_by(|a, b| a.partial_cmp(b).unwrap()); + slopes.sort_by(|a, b| a.partial_cmp(b).unwrap()); - assert_eq!(coefs, vec![1.0, 2.0, 3.0], "Should have coefs 1, 2, and 3"); + assert_eq!( + slopes, + vec![1.0, 2.0, 3.0], + "Should have slopes 1, 2, and 3" + ); } #[test] - fn test_linear_renderer_transposed_orientation() { + fn test_sloped_rule_renderer_transposed_orientation() { use crate::reader::{DuckDBReader, Reader}; use crate::writer::{VegaLiteWriter, Writer}; - // Test that linear with transposed orientation swaps x/y axes + // Test that sloped rule with transposed orientation swaps x/y axes let query = r#" WITH points AS ( SELECT * FROM (VALUES (0, 5), (5, 15), (10, 25)) AS t(x, y) ), lines AS ( - SELECT * FROM (VALUES (0.4, -1, 'A')) AS t(coef, intercept, line_id) + SELECT * FROM (VALUES (0.4, -1, 'A')) AS t(slope, intercept, line_id) ) SELECT * FROM points VISUALISE DRAW point MAPPING x AS x, y AS y - DRAW linear MAPPING coef AS coef, intercept AS intercept, line_id AS color FROM lines SETTING orientation => 'transposed' + DRAW rule MAPPING slope AS slope, intercept AS intercept, line_id AS color FROM lines SETTING orientation => 'transposed' "#; // Execute query @@ -3604,12 +3608,12 @@ mod tests { let vl_spec: serde_json::Value = serde_json::from_str(&vl_json).expect("Failed to parse Vega-Lite JSON"); - // Get the linear layer (second layer) + // Get the rule layer (second layer) let layers = vl_spec["layer"].as_array().expect("No layers found"); - let linear_layer = &layers[1]; + let rule_layer = &layers[1]; // Verify transforms exist - let transforms = linear_layer["transform"] + let transforms = rule_layer["transform"] .as_array() .expect("No transforms found"); @@ -3634,7 +3638,7 @@ mod tests { ); // Verify encoding has y as primary axis (mapped to primary_min/max) - let encoding = linear_layer["encoding"] + let encoding = rule_layer["encoding"] .as_object() .expect("No encoding found"); From d2ecbfea5d992032b3b759084ce5a92fbb884852 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Fri, 20 Mar 2026 13:58:22 +0100 Subject: [PATCH 04/11] repurpose docs --- doc/index.qmd | 4 +-- doc/syntax/layer/type/rule.qmd | 46 +++++++++++++++++++++++++++++++++- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/doc/index.qmd b/doc/index.qmd index ca61e912..38e1360d 100644 --- a/doc/index.qmd +++ b/doc/index.qmd @@ -46,8 +46,8 @@ WHERE island = 'Biscoe' -- Followed by visualization declaration VISUALISE bill_len AS x, bill_dep AS y, body_mass AS fill DRAW point -DRAW linear - MAPPING 0.4 AS coef, -1 AS intercept +PLACE rule + SETTING 0.4 AS coef, -1 AS intercept SCALE BINNED fill LABEL title => 'Relationship between bill dimensions in 3 species of penguins', diff --git a/doc/syntax/layer/type/rule.qmd b/doc/syntax/layer/type/rule.qmd index dbfacd72..994de41f 100644 --- a/doc/syntax/layer/type/rule.qmd +++ b/doc/syntax/layer/type/rule.qmd @@ -5,6 +5,15 @@ title: "Rule" > 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. The rule layer is used to draw reference lines perpendicular to an axis at specified values. This is useful for adding thresholds, means, medians, event markers, cutoff dates or other guides to the plot. The lines span the full width or height of the panels. +Additionally, the rule layer is used to draw diagonal reference lines based on a coefficient (slope) and intercept. This is useful for adding regression lines, diagonal guides, or mathematical functions to plots. + +The parameters are named for the following formula: + +$$ +y = a + \beta x +$$ + +Where $a$ is the `intercept` and $\beta$ is the `slope`. > The rule layer doesn't have a natural extent along the secondary axis. The secondary scale range can thus not be deduced from rule layers. If the rule layer is the only layer in the visualisation, you must specify the position scale range manually. @@ -13,6 +22,10 @@ The following aesthetics are recognised by the rule layer. ### Required * Primary axis (e.g. `x` or `y`): Position along the primary axis. +* `slope` +* `intercept` + +Note: you should use either the primary axis, or `slope` together with `intercept`, not both. ### Optional * `colour`/`stroke`: The colour of the line @@ -21,13 +34,16 @@ The following aesthetics are recognised by the rule layer. * `linetype`: The type of the line, i.e. the dashing pattern ## Settings -The rule layer has no additional settings. +* `orientation`: The orientation of the layer, see the [Orientation section](#orientation). One of the following: + * `'aligned'` to align the layer's primary axis with the coordinate system's first axis. + * `'transposed'` to align the layer's primary axis with the coordinate system's second axis. ## Data transformation The rule layer does not transform its data but passes it through unchanged. ## Orientation Rules are drawn perpendicular to their primary axis. The orientation is deduced directly from the mapping. To create a horizontal rule you map the values to `y` instead of `x` (assuming a default Cartesian coordinate system). +If rule layers use the `slope`/`intercept` formulation, the predictor ($x$) for their primary axis and the response $y$ for its secondary axis. Since the primary axis cannot be deduced from the mapping it must be specified using the `orientation` setting. E.g. to create a vertical linear plot, you need to set `orientation => 'transposed'` to indicate that the primary layer axis follows the second axis of the coordinate system. ## Examples @@ -69,3 +85,31 @@ VISUALISE DRAW line MAPPING date AS x, temperature AS y DRAW rule MAPPING value AS y, label AS colour FROM thresholds ``` + +Add a diagnoal reference line to a scatterplot. Note we use `slope` and `intercept` instead of `x` or `y`. + +```{ggsql} +VISUALISE FROM ggsql:penguins + DRAW point MAPPING bill_len AS x, bill_dep AS y + PLACE rule SETTING slope => 0.4, intercept => -1 +``` + +Add multiple reference lines with different colors from a separate dataset. Note we're mapping from data here, so we use `DRAW` instead of `PLACE`. + +```{ggsql} +WITH lines AS ( + SELECT * FROM (VALUES + (0.4, -1, 'Line A'), + (0.2, 8, 'Line B'), + (0.8, -19, 'Line C') + ) AS t(slope, intercept, label) +) +VISUALISE FROM ggsql:penguins + DRAW point MAPPING bill_len AS x, bill_dep AS y + DRAW rule + MAPPING + slope AS slope, + intercept AS intercept, + label AS colour + FROM lines +``` \ No newline at end of file From 7809f7c4fa4425b09f8e3e7517d1abcc8ec510f4 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Fri, 20 Mar 2026 14:34:13 +0100 Subject: [PATCH 05/11] eradicate linear layer --- CLAUDE.md | 4 +- doc/ggsql.xml | 1 - doc/syntax/index.qmd | 1 - doc/syntax/layer/type/linear.qmd | 70 ----------- ggsql-vscode/syntaxes/ggsql.tmLanguage.json | 4 +- src/parser/builder.rs | 1 - src/plot/layer/geom/linear.rs | 45 ------- src/plot/layer/geom/mod.rs | 14 +-- src/plot/main.rs | 8 +- src/writer/vegalite/layer.rs | 133 -------------------- tree-sitter-ggsql/grammar.js | 2 +- tree-sitter-ggsql/queries/highlights.scm | 1 - 12 files changed, 7 insertions(+), 277 deletions(-) delete mode 100644 doc/syntax/layer/type/linear.qmd delete mode 100644 src/plot/layer/geom/linear.rs diff --git a/CLAUDE.md b/CLAUDE.md index e24b68b8..344f3172 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -336,7 +336,7 @@ pub enum Geom { // Statistical geoms Histogram, Density, Smooth, Boxplot, Violin, // Annotation geoms - Text, Segment, Arrow, Rule, Linear, ErrorBar, + Text, Segment, Arrow, Rule, ErrorBar, } pub enum DataSource { @@ -1219,7 +1219,7 @@ All clauses (MAPPING, SETTING, PARTITION BY, FILTER) are optional. - **Basic**: `point`, `line`, `path`, `bar`, `col`, `area`, `rect`, `polygon`, `ribbon` - **Statistical**: `histogram`, `density`, `smooth`, `boxplot`, `violin` -- **Annotation**: `text`, `label`, `segment`, `arrow`, `rule`, `linear`, `errorbar` +- **Annotation**: `text`, `label`, `segment`, `arrow`, `rule`, `errorbar` **MAPPING Clause** (Aesthetic Mappings): diff --git a/doc/ggsql.xml b/doc/ggsql.xml index 39437676..fa23e8c5 100644 --- a/doc/ggsql.xml +++ b/doc/ggsql.xml @@ -143,7 +143,6 @@ segment arrow rule - linear errorbar diff --git a/doc/syntax/index.qmd b/doc/syntax/index.qmd index 8b2f8837..b54a17f6 100644 --- a/doc/syntax/index.qmd +++ b/doc/syntax/index.qmd @@ -21,7 +21,6 @@ There are many different layers to choose from when visualising your data. Some - [`line`](layer/type/line.qmd) is used to produce lineplots with the data sorted along the x axis. - [`path`](layer/type/path.qmd) is like `line` above but does not sort the data but plot it according to its own order. - [`segment`](layer/type/segment.qmd) connects two points with a line segment. -- [`linear`](layer/type/linear.qmd) draws a long line parameterised by a coefficient and intercept. - [`rule`](layer/type/rule.qmd) draws horizontal and vertical reference lines. - [`area`](layer/type/area.qmd) is used to display series as an area chart. - [`ribbon`](layer/type/ribbon.qmd) is used to display series extrema. diff --git a/doc/syntax/layer/type/linear.qmd b/doc/syntax/layer/type/linear.qmd deleted file mode 100644 index 92f60459..00000000 --- a/doc/syntax/layer/type/linear.qmd +++ /dev/null @@ -1,70 +0,0 @@ ---- -title: "Linear line" ---- - -> 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. - -The linear layer is used to draw diagonal reference lines based on a coefficient and intercept. This is useful for adding regression lines, diagonal guides, or mathematical functions to plots. The lines extend across the full extent of the x-axis, regardless of the data range. -The layer is named for the following formula: - -$$ -y = a + \beta x -$$ - -Where $a$ is the `intercept` and $\beta$ is the `coef`. - -> The linear layer doesn't have a natural extend along either axes and the scale ranges can thus not be deduced from it. If the linear layer is the only layer in the visualisation you must specify the position scale ranges manually. - -## Aesthetics -The following aesthetics are recognised by the abline layer. - -### Required -* `coef`: The coefficient/slope of the line i.e. the amount $y$ increases for every unit of $x$. -* `intercept`: The intercept where the line crosses the y-axis at $x = 0$. - -### Optional -* `colour`/`stroke`: The colour of the line -* `opacity`: The opacity of the line -* `linewidth`: The width of the line -* `linetype`: The type of the line, i.e. the dashing pattern - -## Settings -* `orientation`: The orientation of the layer, see the [Orientation section](#orientation). One of the following: - * `'aligned'` to align the layer's primary axis with the coordinate system's first axis. - * `'transposed'` to align the layer's primary axis with the coordinate system's second axis. - -## Data transformation -The linear layer does not transform its data but passes it through unchanged. - -## Orientation -Linear layers use the predictor ($x$) for their primary axis and the response $y$ for its secondary axis. Since the primary axis cannot be deduced from the mapping it must be specified using the `orientation` setting. E.g. to create a vertical linear plot, you need to set `orientation => 'transposed'` to indicate that the primary layer axis follows the second axis of the coordinate system. - -## Examples - -Add a simple reference line to a scatterplot: - -```{ggsql} -VISUALISE FROM ggsql:penguins - DRAW point MAPPING bill_len AS x, bill_dep AS y - PLACE linear SETTING coef => 0.4, intercept => -1 -``` - -Add multiple reference lines with different colors from a separate dataset. Note we're mapping from data here, so we use `DRAW` instead of `PLACE`. - -```{ggsql} -WITH lines AS ( - SELECT * FROM (VALUES - (0.4, -1, 'Line A'), - (0.2, 8, 'Line B'), - (0.8, -19, 'Line C') - ) AS t(coef, intercept, label) -) -VISUALISE FROM ggsql:penguins - DRAW point MAPPING bill_len AS x, bill_dep AS y - DRAW linear - MAPPING - coef AS coef, - intercept AS intercept, - label AS colour - FROM lines -``` diff --git a/ggsql-vscode/syntaxes/ggsql.tmLanguage.json b/ggsql-vscode/syntaxes/ggsql.tmLanguage.json index 788cd42c..43e1d722 100644 --- a/ggsql-vscode/syntaxes/ggsql.tmLanguage.json +++ b/ggsql-vscode/syntaxes/ggsql.tmLanguage.json @@ -295,7 +295,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|rule|linear|errorbar)\\b" + "match": "\\b(point|line|path|bar|col|area|tile|polygon|ribbon|histogram|density|smooth|boxplot|violin|text|label|segment|arrow|rule|errorbar)\\b" }, { "include": "#common-clause-patterns" } ] @@ -309,7 +309,7 @@ "patterns": [ { "name": "support.type.geom.ggsql", - "match": "\\b(point|line|path|bar|col|area|rect|polygon|ribbon|histogram|density|smooth|boxplot|violin|text|label|segment|arrow|rule|linear|errorbar)\\b" + "match": "\\b(point|line|path|bar|col|area|rect|polygon|ribbon|histogram|density|smooth|boxplot|violin|text|label|segment|arrow|rule|errorbar)\\b" }, { "include": "#common-clause-patterns" } ] diff --git a/src/parser/builder.rs b/src/parser/builder.rs index 2c9d17fd..23bd7695 100644 --- a/src/parser/builder.rs +++ b/src/parser/builder.rs @@ -638,7 +638,6 @@ fn parse_geom_type(text: &str) -> Result { "segment" => Ok(Geom::segment()), "arrow" => Ok(Geom::arrow()), "rule" => Ok(Geom::rule()), - "linear" => Ok(Geom::linear()), "errorbar" => Ok(Geom::errorbar()), _ => Err(GgsqlError::ParseError(format!( "Unknown geom type: {}", diff --git a/src/plot/layer/geom/linear.rs b/src/plot/layer/geom/linear.rs deleted file mode 100644 index 1ae4dcb1..00000000 --- a/src/plot/layer/geom/linear.rs +++ /dev/null @@ -1,45 +0,0 @@ -//! Linear geom implementation - -use super::{ - DefaultAesthetics, GeomTrait, GeomType, ParamConstraint, ParamDefinition, DefaultParamValue, -}; -use crate::plot::layer::orientation::{ALIGNED, ORIENTATION_VALUES}; -use crate::plot::types::DefaultAestheticValue; - -/// Linear geom - lines with coefficient and intercept -#[derive(Debug, Clone, Copy)] -pub struct Linear; - -impl GeomTrait for Linear { - fn geom_type(&self) -> GeomType { - GeomType::Linear - } - - fn aesthetics(&self) -> DefaultAesthetics { - DefaultAesthetics { - defaults: &[ - ("coef", DefaultAestheticValue::Required), - ("intercept", DefaultAestheticValue::Required), - ("stroke", DefaultAestheticValue::String("black")), - ("linewidth", DefaultAestheticValue::Number(1.0)), - ("opacity", DefaultAestheticValue::Number(1.0)), - ("linetype", DefaultAestheticValue::String("solid")), - ], - } - } - - fn default_params(&self) -> &'static [ParamDefinition] { - const PARAMS: &[ParamDefinition] = &[ParamDefinition { - name: "orientation", - default: DefaultParamValue::String(ALIGNED), - constraint: ParamConstraint::string_option(ORIENTATION_VALUES), - }]; - PARAMS - } -} - -impl std::fmt::Display for Linear { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "linear") - } -} diff --git a/src/plot/layer/geom/mod.rs b/src/plot/layer/geom/mod.rs index 79820530..f992c43d 100644 --- a/src/plot/layer/geom/mod.rs +++ b/src/plot/layer/geom/mod.rs @@ -36,7 +36,6 @@ mod density; mod errorbar; mod histogram; mod line; -mod linear; mod path; mod point; mod polygon; @@ -50,7 +49,7 @@ mod violin; // Re-export types pub use types::{ - DefaultAesthetics, ParamConstraint, ParamDefinition, DefaultParamValue, StatResult, + DefaultAesthetics, DefaultParamValue, ParamConstraint, ParamDefinition, StatResult, }; // Re-export geom structs for direct access if needed @@ -62,7 +61,6 @@ pub use density::Density; pub use errorbar::ErrorBar; pub use histogram::Histogram; pub use line::Line; -pub use linear::Linear; pub use path::Path; pub use point::Point; pub use polygon::Polygon; @@ -98,7 +96,6 @@ pub enum GeomType { Segment, Arrow, Rule, - Linear, ErrorBar, } @@ -122,7 +119,6 @@ impl std::fmt::Display for GeomType { GeomType::Segment => "segment", GeomType::Arrow => "arrow", GeomType::Rule => "rule", - GeomType::Linear => "linear", GeomType::ErrorBar => "errorbar", }; write!(f, "{}", s) @@ -324,11 +320,6 @@ impl Geom { Self(Arc::new(Rule)) } - /// Create an Linear geom - pub fn linear() -> Self { - Self(Arc::new(Linear)) - } - /// Create an ErrorBar geom pub fn errorbar() -> Self { Self(Arc::new(ErrorBar)) @@ -354,7 +345,6 @@ impl Geom { GeomType::Segment => Self::segment(), GeomType::Arrow => Self::arrow(), GeomType::Rule => Self::rule(), - GeomType::Linear => Self::linear(), GeomType::ErrorBar => Self::errorbar(), } } @@ -553,7 +543,6 @@ mod tests { GeomType::Segment, GeomType::Arrow, GeomType::Rule, - GeomType::Linear, GeomType::ErrorBar, ]; @@ -577,7 +566,6 @@ mod tests { | GeomType::Segment | GeomType::Arrow | GeomType::Rule - | GeomType::Linear | GeomType::ErrorBar => {} }; diff --git a/src/plot/main.rs b/src/plot/main.rs index d37a418b..8e61d92b 100644 --- a/src/plot/main.rs +++ b/src/plot/main.rs @@ -32,7 +32,7 @@ pub use super::types::{ // Re-export Geom and related types from the layer::geom module pub use super::layer::geom::{ - DefaultAesthetics, Geom, GeomTrait, GeomType, ParamDefinition, DefaultParamValue, StatResult, + DefaultAesthetics, DefaultParamValue, Geom, GeomTrait, GeomType, ParamDefinition, StatResult, }; // Re-export Layer from the layer module @@ -538,12 +538,6 @@ mod tests { // Segment/arrow require endpoints assert_eq!(Geom::segment().aesthetics().required(), &["pos1", "pos2"]); - // Reference lines - assert_eq!( - Geom::linear().aesthetics().required(), - &["coef", "intercept"] - ); - // ErrorBar has no strict requirements assert_eq!(Geom::errorbar().aesthetics().required(), &[] as &[&str]); } diff --git a/src/writer/vegalite/layer.rs b/src/writer/vegalite/layer.rs index fbb23e35..8ea869e1 100644 --- a/src/writer/vegalite/layer.rs +++ b/src/writer/vegalite/layer.rs @@ -43,7 +43,6 @@ pub fn geom_to_mark(geom: &Geom) -> Value { GeomType::Segment => "rule", GeomType::Smooth => "line", GeomType::Rule => "rule", - GeomType::Linear => "rule", GeomType::ErrorBar => "rule", _ => "point", // Default fallback }; @@ -531,137 +530,6 @@ impl GeomRenderer for RuleRenderer { } } -// ============================================================================= -// Linear Renderer -// ============================================================================= - -/// Renderer for linear geom - draws lines based on coefficient and intercept -pub struct LinearRenderer; - -impl GeomRenderer for LinearRenderer { - fn prepare_data( - &self, - df: &DataFrame, - _layer: &Layer, - _data_key: &str, - _binned_columns: &HashMap>, - ) -> Result { - // Just convert DataFrame to JSON values - // No need to add xmin/xmax - they'll be encoded as literal values - let values = dataframe_to_values(df)?; - Ok(PreparedData::Single { values }) - } - - fn modify_encoding( - &self, - encoding: &mut Map, - layer: &Layer, - _context: &RenderContext, - ) -> Result<()> { - // Remove coefficient and intercept from encoding - they're only used in transforms - encoding.remove("coef"); - encoding.remove("intercept"); - - // Check orientation - let is_horizontal = is_transposed(layer); - - // For aligned (default): x is primary axis, y is computed (secondary) - // For transposed: y is primary axis, x is computed (secondary) - let (primary, primary2, secondary, secondary2) = if is_horizontal { - ("y", "y2", "x", "x2") - } else { - ("x", "x2", "y", "y2") - }; - - // Add encodings for rule mark - // primary_min/primary_max are created by transforms (extent of the axis) - // secondary_min/secondary_max are computed via formula - encoding.insert( - primary.to_string(), - json!({ - "field": "primary_min", - "type": "quantitative" - }), - ); - encoding.insert( - primary2.to_string(), - json!({ - "field": "primary_max" - }), - ); - encoding.insert( - secondary.to_string(), - json!({ - "field": "secondary_min", - "type": "quantitative" - }), - ); - encoding.insert( - secondary2.to_string(), - json!({ - "field": "secondary_max" - }), - ); - - Ok(()) - } - - fn modify_spec( - &self, - layer_spec: &mut Value, - layer: &Layer, - context: &RenderContext, - ) -> Result<()> { - // Field names for coef and intercept (with aesthetic column prefix) - let coef_field = naming::aesthetic_column("coef"); - let intercept_field = naming::aesthetic_column("intercept"); - - // Check orientation - let is_horizontal = is_transposed(layer); - - // Get extent from appropriate axis: - // - Aligned (default): extent from pos1 (x-axis), compute y from x - // - Transposed: extent from pos2 (y-axis), compute x from y - let extent_aesthetic = if is_horizontal { "pos2" } else { "pos1" }; - let (primary_min, primary_max) = context.get_extent(extent_aesthetic)?; - - // Add transforms: - // 1. Create constant primary_min/primary_max fields (extent of the primary axis) - // 2. Compute secondary values at those primary positions: secondary = coef * primary + intercept - let transforms = json!([ - { - "calculate": primary_min.to_string(), - "as": "primary_min" - }, - { - "calculate": primary_max.to_string(), - "as": "primary_max" - }, - { - "calculate": format!("datum.{} * datum.primary_min + datum.{}", coef_field, intercept_field), - "as": "secondary_min" - }, - { - "calculate": format!("datum.{} * datum.primary_max + datum.{}", coef_field, intercept_field), - "as": "secondary_max" - } - ]); - - // Prepend to existing transforms (if any) - if let Some(existing) = layer_spec.get("transform") { - if let Some(arr) = existing.as_array() { - let mut new_transforms = transforms.as_array().unwrap().clone(); - new_transforms.extend_from_slice(arr); - layer_spec["transform"] = json!(new_transforms); - } - } else { - layer_spec["transform"] = transforms; - } - - Ok(()) - } -} - // ============================================================================= // Text Renderer // ============================================================================= @@ -2017,7 +1885,6 @@ pub fn get_renderer(geom: &Geom) -> Box { GeomType::Violin => Box::new(ViolinRenderer), GeomType::Text => Box::new(TextRenderer), GeomType::Segment => Box::new(SegmentRenderer), - GeomType::Linear => Box::new(LinearRenderer), GeomType::ErrorBar => Box::new(ErrorBarRenderer), GeomType::Rule => Box::new(RuleRenderer), // All other geoms (Point, Area, Density, Tile, etc.) use the default renderer diff --git a/tree-sitter-ggsql/grammar.js b/tree-sitter-ggsql/grammar.js index 1291c4fa..ea2ba5cf 100644 --- a/tree-sitter-ggsql/grammar.js +++ b/tree-sitter-ggsql/grammar.js @@ -501,7 +501,7 @@ module.exports = grammar({ geom_type: $ => choice( 'point', 'line', 'path', 'bar', 'area', 'rect', 'polygon', 'ribbon', 'histogram', 'density', 'smooth', 'boxplot', 'violin', - 'text', 'label', 'segment', 'arrow', 'rule', 'linear', 'errorbar' + 'text', 'label', 'segment', 'arrow', 'rule', 'errorbar' ), // MAPPING clause for aesthetic mappings: MAPPING col AS x, "blue" AS color [FROM source] diff --git a/tree-sitter-ggsql/queries/highlights.scm b/tree-sitter-ggsql/queries/highlights.scm index 82209424..6efd00cb 100644 --- a/tree-sitter-ggsql/queries/highlights.scm +++ b/tree-sitter-ggsql/queries/highlights.scm @@ -25,7 +25,6 @@ "segment" "arrow" "rule" - "linear" "errorbar" ] @type.builtin From e7697845bdd7d1a658d37d4b170ba281962fb1aa Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Fri, 20 Mar 2026 14:59:51 +0100 Subject: [PATCH 06/11] grammar/highlighting fixes --- doc/ggsql.xml | 2 ++ doc/index.qmd | 2 +- ggsql-vscode/syntaxes/ggsql.tmLanguage.json | 2 +- src/plot/layer/geom/rule.rs | 2 +- tree-sitter-ggsql/grammar.js | 2 +- tree-sitter-ggsql/queries/highlights.scm | 2 ++ 6 files changed, 8 insertions(+), 4 deletions(-) diff --git a/doc/ggsql.xml b/doc/ggsql.xml index fa23e8c5..436f2c58 100644 --- a/doc/ggsql.xml +++ b/doc/ggsql.xml @@ -173,6 +173,8 @@ italic hjust vjust + slope + intercept diff --git a/doc/index.qmd b/doc/index.qmd index 38e1360d..6ff719dd 100644 --- a/doc/index.qmd +++ b/doc/index.qmd @@ -47,7 +47,7 @@ WHERE island = 'Biscoe' VISUALISE bill_len AS x, bill_dep AS y, body_mass AS fill DRAW point PLACE rule - SETTING 0.4 AS coef, -1 AS intercept + SETTING slope => 0.4, intercept => -1 SCALE BINNED fill LABEL title => 'Relationship between bill dimensions in 3 species of penguins', diff --git a/ggsql-vscode/syntaxes/ggsql.tmLanguage.json b/ggsql-vscode/syntaxes/ggsql.tmLanguage.json index 43e1d722..1c1e23e7 100644 --- a/ggsql-vscode/syntaxes/ggsql.tmLanguage.json +++ b/ggsql-vscode/syntaxes/ggsql.tmLanguage.json @@ -250,7 +250,7 @@ "patterns": [ { "name": "support.type.aesthetic.ggsql", - "match": "\\b(x|y|xmin|xmax|ymin|ymax|xend|yend|weight|color|colour|fill|stroke|opacity|size|shape|linetype|linewidth|width|height|label|typeface|fontweight|italic|hjust|vjust|panel|row|column)\\b" + "match": "\\b(x|y|xmin|xmax|ymin|ymax|xend|yend|weight|color|colour|fill|stroke|opacity|size|shape|linetype|linewidth|width|height|label|typeface|fontweight|italic|hjust|vjust|slope|intercept|panel|row|column)\\b" } ] }, diff --git a/src/plot/layer/geom/rule.rs b/src/plot/layer/geom/rule.rs index ef0f780c..a836db88 100644 --- a/src/plot/layer/geom/rule.rs +++ b/src/plot/layer/geom/rule.rs @@ -59,7 +59,7 @@ impl GeomTrait for Rule { crate::plot::ParameterValue::Number(n) => *n, _ => { return Err(GgsqlError::ValidationError(format!( - "Rule {} must be a number, not {:?}", + "Rule '{}' aesthetic must be a number, not {:?}.", aesthetic, value ))) } diff --git a/tree-sitter-ggsql/grammar.js b/tree-sitter-ggsql/grammar.js index ea2ba5cf..4b6b3f77 100644 --- a/tree-sitter-ggsql/grammar.js +++ b/tree-sitter-ggsql/grammar.js @@ -683,7 +683,7 @@ module.exports = grammar({ // Text aesthetics 'label', 'typeface', 'fontweight', 'italic', 'fontsize', 'hjust', 'vjust', 'rotation', // Specialty aesthetics, - 'coef', 'intercept', + 'slope', 'intercept', // Facet aesthetics 'panel', 'row', 'column', // Computed variables diff --git a/tree-sitter-ggsql/queries/highlights.scm b/tree-sitter-ggsql/queries/highlights.scm index 6efd00cb..44e780ec 100644 --- a/tree-sitter-ggsql/queries/highlights.scm +++ b/tree-sitter-ggsql/queries/highlights.scm @@ -57,6 +57,8 @@ "hjust" "vjust" "rotation" + "slope" + "intercept" "panel" "row" "column" From 808e9d00720fb3bcc1d6de3edfd4c97e7cc52923 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Fri, 20 Mar 2026 16:33:02 +0100 Subject: [PATCH 07/11] remove `intercept` --- doc/ggsql.xml | 1 - doc/index.qmd | 2 +- ggsql-vscode/syntaxes/ggsql.tmLanguage.json | 2 +- src/plot/layer/geom/rule.rs | 63 +------- src/writer/vegalite/layer.rs | 156 ++++++++++++++------ tree-sitter-ggsql/grammar.js | 2 +- tree-sitter-ggsql/queries/highlights.scm | 1 - 7 files changed, 112 insertions(+), 115 deletions(-) diff --git a/doc/ggsql.xml b/doc/ggsql.xml index 436f2c58..1d777ab9 100644 --- a/doc/ggsql.xml +++ b/doc/ggsql.xml @@ -174,7 +174,6 @@ hjust vjust slope - intercept diff --git a/doc/index.qmd b/doc/index.qmd index de29b01f..439beca1 100644 --- a/doc/index.qmd +++ b/doc/index.qmd @@ -45,7 +45,7 @@ WHERE island = 'Biscoe' VISUALISE bill_len AS x, bill_dep AS y, body_mass AS fill DRAW point PLACE rule - SETTING slope => 0.4, intercept => -1 + SETTING slope => 0.4, y => -1 SCALE BINNED fill LABEL title => 'Relationship between bill dimensions in 3 species of penguins', diff --git a/ggsql-vscode/syntaxes/ggsql.tmLanguage.json b/ggsql-vscode/syntaxes/ggsql.tmLanguage.json index 1c1e23e7..ba5962cd 100644 --- a/ggsql-vscode/syntaxes/ggsql.tmLanguage.json +++ b/ggsql-vscode/syntaxes/ggsql.tmLanguage.json @@ -250,7 +250,7 @@ "patterns": [ { "name": "support.type.aesthetic.ggsql", - "match": "\\b(x|y|xmin|xmax|ymin|ymax|xend|yend|weight|color|colour|fill|stroke|opacity|size|shape|linetype|linewidth|width|height|label|typeface|fontweight|italic|hjust|vjust|slope|intercept|panel|row|column)\\b" + "match": "\\b(x|y|xmin|xmax|ymin|ymax|xend|yend|weight|color|colour|fill|stroke|opacity|size|shape|linetype|linewidth|width|height|label|typeface|fontweight|italic|hjust|vjust|slope|panel|row|column)\\b" } ] }, diff --git a/src/plot/layer/geom/rule.rs b/src/plot/layer/geom/rule.rs index a836db88..a43b290e 100644 --- a/src/plot/layer/geom/rule.rs +++ b/src/plot/layer/geom/rule.rs @@ -1,11 +1,7 @@ //! Rule geom implementation use super::{DefaultAesthetics, GeomTrait, GeomType}; -use crate::plot::{ - orientation::{ALIGNED, ORIENTATION_VALUES}, - types::DefaultAestheticValue, - DefaultParamValue, ParamConstraint, ParamDefinition, -}; +use crate::plot::types::DefaultAestheticValue; /// Rule geom - horizontal and vertical reference lines #[derive(Debug, Clone, Copy)] @@ -20,8 +16,7 @@ impl GeomTrait for Rule { DefaultAesthetics { defaults: &[ ("pos1", DefaultAestheticValue::Null), - ("slope", DefaultAestheticValue::Null), - ("intercept", DefaultAestheticValue::Null), + ("slope", DefaultAestheticValue::Number(0.0)), ("stroke", DefaultAestheticValue::String("black")), ("linewidth", DefaultAestheticValue::Number(1.0)), ("opacity", DefaultAestheticValue::Number(1.0)), @@ -30,60 +25,6 @@ impl GeomTrait for Rule { } } - fn default_params(&self) -> &'static [ParamDefinition] { - const PARAMS: &[ParamDefinition] = &[ParamDefinition { - name: "orientation", - default: DefaultParamValue::String(ALIGNED), - constraint: ParamConstraint::string_option(ORIENTATION_VALUES), - }]; - PARAMS - } - - fn post_process( - &self, - df: crate::DataFrame, - parameters: &std::collections::HashMap, - ) -> crate::Result { - use crate::{naming, GgsqlError}; - use polars::prelude::{IntoColumn, NamedFrom, Series}; - - let mut result = df; - let row_count = result.height(); - - // For diagonal rules (slope + intercept), add these as DataFrame columns - // The Vega-Lite writer needs them as columns to create transform calculations - for aesthetic in &["slope", "intercept"] { - if let Some(value) = parameters.get(*aesthetic) { - // Only accept numeric values for slope and intercept - let n = match value { - crate::plot::ParameterValue::Number(n) => *n, - _ => { - return Err(GgsqlError::ValidationError(format!( - "Rule '{}' aesthetic must be a number, not {:?}.", - aesthetic, value - ))) - } - }; - - // Create a column with the aesthetic's prefixed name - let col_name = naming::aesthetic_column(aesthetic); - let series = Series::new(col_name.clone().into(), vec![n; row_count]); - - // Add the column to the DataFrame - result = result - .with_column(series.into_column()) - .map_err(|e| { - GgsqlError::InternalError(format!( - "Failed to add {} column: {}", - aesthetic, e - )) - })? - .clone(); - } - } - - Ok(result) - } } impl std::fmt::Display for Rule { diff --git a/src/writer/vegalite/layer.rs b/src/writer/vegalite/layer.rs index 8ea869e1..e6a9fbe0 100644 --- a/src/writer/vegalite/layer.rs +++ b/src/writer/vegalite/layer.rs @@ -404,7 +404,20 @@ impl GeomRenderer for RuleRenderer { ) -> Result<()> { let has_x = encoding.contains_key("x"); let has_y = encoding.contains_key("y"); - let diagonal = encoding.contains_key("slope") && encoding.contains_key("intercept"); + + // Remove slope from encoding (it's never a visual encoding, only metadata) + // and check if it's non-zero (diagonal line) + let diagonal = if let Some(slope_enc) = encoding.remove("slope") { + slope_enc.get("field").is_some() // Field reference - assume non-zero + || slope_enc + .get("value") + .and_then(|v| v.as_f64()) + .map(|v| v != 0.0) + .unwrap_or(false) // Literal value - check if non-zero + } else { + false + }; + if !has_x && !has_y && !diagonal { return Err(GgsqlError::ValidationError( "The `rule` layer requires the `x` or `y` aesthetic. It currently has neither." @@ -420,15 +433,13 @@ impl GeomRenderer for RuleRenderer { return Ok(()); } - // Remove slope and intercept from encoding - they're only used in transforms - encoding.remove("slope"); - encoding.remove("intercept"); - - // Check orientation - let is_horizontal = is_transposed(layer); + // Determine orientation from which aesthetic is mapped: + // - If y is mapped: y is intercept, x varies (primary axis is x) + // - If x is mapped: x is intercept, y varies (primary axis is y, "horizontal") + let is_horizontal = has_x && !has_y; - // For aligned (default): x is primary axis, y is computed (secondary) - // For transposed: y is primary axis, x is computed (secondary) + // For y mapped (not horizontal): x is primary axis (varies), y is computed (secondary) + // For x mapped (horizontal): y is primary axis (varies), x is computed (secondary) let (primary, primary2, secondary, secondary2) = if is_horizontal { ("y", "y2", "x", "x2") } else { @@ -474,28 +485,56 @@ impl GeomRenderer for RuleRenderer { layer: &Layer, context: &RenderContext, ) -> Result<()> { - let diagonal = - layer.mappings.contains_key("slope") && layer.mappings.contains_key("intercept"); - if !diagonal { - return Ok(()); - } + // Determine slope expression: either a literal value or a field reference + let slope_expr = match layer.mappings.get("slope") { + Some(AestheticValue::Literal(ParameterValue::Number(n))) if *n == 0.0 => { + // Slope is 0 - no diagonal + None + } + Some(AestheticValue::Literal(ParameterValue::Number(n))) => { + // Literal non-zero slope - inline the value + Some(n.to_string()) + } + Some(AestheticValue::Column { .. }) | Some(AestheticValue::AnnotationColumn { .. }) => { + // Column-based slope - reference the field + let slope_field = naming::aesthetic_column("slope"); + Some(format!("datum.{}", slope_field)) + } + _ => { + // No slope mapping - no diagonal + None + } + }; - // Field names for slope and intercept (with aesthetic column prefix) - let slope_field = naming::aesthetic_column("slope"); - let intercept_field = naming::aesthetic_column("intercept"); + let Some(slope_expr) = slope_expr else { + return Ok(()); + }; - // Check orientation - let is_horizontal = is_transposed(layer); + // Determine orientation from which positional aesthetic is mapped: + // By this point, x/y have been renamed to pos1/pos2 by resolve_aesthetic + // - If pos2 is mapped (from y): pos2 is intercept, pos1 varies (primary axis is pos1) + // - If pos1 is mapped (from x): pos1 is intercept, pos2 varies (primary axis is pos2, "horizontal") + let has_pos1 = layer.mappings.contains_key("pos1"); + let has_pos2 = layer.mappings.contains_key("pos2"); + let is_horizontal = has_pos1 && !has_pos2; + + // Get the intercept field (pos1 for x, pos2 for y) + let intercept_field = if is_horizontal { + naming::aesthetic_column("pos1") // x is intercept + } else { + naming::aesthetic_column("pos2") // y is intercept + }; // Get extent from appropriate axis: - // - Aligned (default): extent from pos1 (x-axis), compute y from x - // - Transposed: extent from pos2 (y-axis), compute x from y + // - y mapped (not horizontal): x is primary axis (varies), y is computed + // - x mapped (horizontal): y is primary axis (varies), x is computed let extent_aesthetic = if is_horizontal { "pos2" } else { "pos1" }; let (primary_min, primary_max) = context.get_extent(extent_aesthetic)?; // Add transforms: // 1. Create constant primary_min/primary_max fields (extent of the primary axis) // 2. Compute secondary values at those primary positions: secondary = slope * primary + intercept + // (where intercept is pos1 for x-mapped or pos2 for y-mapped) let transforms = json!([ { "calculate": primary_min.to_string(), @@ -506,11 +545,11 @@ impl GeomRenderer for RuleRenderer { "as": "primary_max" }, { - "calculate": format!("datum.{} * datum.primary_min + datum.{}", slope_field, intercept_field), + "calculate": format!("{} * datum.primary_min + datum.{}", slope_expr, intercept_field), "as": "secondary_min" }, { - "calculate": format!("datum.{} * datum.primary_max + datum.{}", slope_field, intercept_field), + "calculate": format!("{} * datum.primary_max + datum.{}", slope_expr, intercept_field), "as": "secondary_max" } ]); @@ -3268,12 +3307,12 @@ mod tests { (2, 5, 'A'), (1, 10, 'B'), (3, 0, 'C') - ) AS t(slope, intercept, line_id) + ) AS t(slope, y, line_id) ) SELECT * FROM points VISUALISE DRAW point MAPPING x AS x, y AS y - DRAW rule MAPPING slope AS slope, intercept AS intercept, line_id AS color FROM lines + DRAW rule MAPPING slope AS slope, y AS y, line_id AS color FROM lines "#; // Execute query @@ -3350,26 +3389,18 @@ mod tests { .as_str() .expect("secondary_max calculate should be string"); - // Should reference slope, intercept, and primary_min/primary_max + // Should reference slope, pos2 (acting as intercept for y-mapped rules), and primary_min/primary_max assert!( - secondary_min_calc.contains("__ggsql_aes_slope__"), - "secondary_min should reference slope" - ); - assert!( - secondary_min_calc.contains("__ggsql_aes_intercept__"), - "secondary_min should reference intercept" + secondary_min_calc.contains("__ggsql_aes_pos2__"), + "secondary_min should reference pos2 (y intercept)" ); assert!( secondary_min_calc.contains("datum.primary_min"), "secondary_min should reference datum.primary_min" ); assert!( - secondary_max_calc.contains("__ggsql_aes_slope__"), - "secondary_max should reference slope" - ); - assert!( - secondary_max_calc.contains("__ggsql_aes_intercept__"), - "secondary_max should reference intercept" + secondary_max_calc.contains("__ggsql_aes_pos2__"), + "secondary_max should reference pos2 (y intercept)" ); assert!( secondary_max_calc.contains("datum.primary_max"), @@ -3444,22 +3475,22 @@ mod tests { } #[test] - fn test_sloped_rule_renderer_transposed_orientation() { + fn test_sloped_rule_renderer_horizontal_orientation() { use crate::reader::{DuckDBReader, Reader}; use crate::writer::{VegaLiteWriter, Writer}; - // Test that sloped rule with transposed orientation swaps x/y axes + // Test that sloped rule with x mapping (horizontal) infers y varies let query = r#" WITH points AS ( SELECT * FROM (VALUES (0, 5), (5, 15), (10, 25)) AS t(x, y) ), lines AS ( - SELECT * FROM (VALUES (0.4, -1, 'A')) AS t(slope, intercept, line_id) + SELECT * FROM (VALUES (0.4, -1, 'A')) AS t(slope, x, line_id) ) SELECT * FROM points VISUALISE DRAW point MAPPING x AS x, y AS y - DRAW rule MAPPING slope AS slope, intercept AS intercept, line_id AS color FROM lines SETTING orientation => 'transposed' + DRAW rule MAPPING slope AS slope, x AS x, line_id AS color FROM lines "#; // Execute query @@ -3484,7 +3515,7 @@ mod tests { .as_array() .expect("No transforms found"); - // Verify primary_min/max use pos2 extent (y-axis) for transposed orientation + // Verify primary_min/max use pos2 extent (y-axis) for horizontal (x-mapped) orientation let primary_min_transform = transforms .iter() .find(|t| t["as"] == "primary_min") @@ -3494,7 +3525,7 @@ mod tests { .find(|t| t["as"] == "primary_max") .expect("primary_max transform not found"); - // The primary extent should come from the y-axis for transposed + // The primary extent should come from the y-axis for horizontal orientation assert!( primary_min_transform["calculate"].is_string(), "primary_min should have calculate expression" @@ -3504,27 +3535,54 @@ mod tests { "primary_max should have calculate expression" ); + // Verify secondary_min and secondary_max use pos1 (x intercept) for horizontal orientation + let secondary_min_transform = transforms + .iter() + .find(|t| t["as"] == "secondary_min") + .expect("secondary_min transform not found"); + let secondary_max_transform = transforms + .iter() + .find(|t| t["as"] == "secondary_max") + .expect("secondary_max transform not found"); + + let secondary_min_calc = secondary_min_transform["calculate"] + .as_str() + .expect("secondary_min calculate should be string"); + let secondary_max_calc = secondary_max_transform["calculate"] + .as_str() + .expect("secondary_max calculate should be string"); + + // Should reference pos1 (x intercept) for horizontal orientation + assert!( + secondary_min_calc.contains("__ggsql_aes_pos1__"), + "secondary_min should reference pos1 (x intercept)" + ); + assert!( + secondary_max_calc.contains("__ggsql_aes_pos1__"), + "secondary_max should reference pos1 (x intercept)" + ); + // Verify encoding has y as primary axis (mapped to primary_min/max) let encoding = rule_layer["encoding"] .as_object() .expect("No encoding found"); - // For transposed orientation: y is primary (uses primary_min/max), x is secondary + // For horizontal orientation (x-mapped): y is primary (uses primary_min/max), x is secondary assert_eq!( encoding["y"]["field"], "primary_min", - "y should reference primary_min field for transposed" + "y should reference primary_min field for horizontal orientation" ); assert_eq!( encoding["y2"]["field"], "primary_max", - "y2 should reference primary_max field for transposed" + "y2 should reference primary_max field for horizontal orientation" ); assert_eq!( encoding["x"]["field"], "secondary_min", - "x should reference secondary_min field for transposed" + "x should reference secondary_min field for horizontal orientation" ); assert_eq!( encoding["x2"]["field"], "secondary_max", - "x2 should reference secondary_max field for transposed" + "x2 should reference secondary_max field for horizontal orientation" ); } diff --git a/tree-sitter-ggsql/grammar.js b/tree-sitter-ggsql/grammar.js index 4b6b3f77..1386ad6d 100644 --- a/tree-sitter-ggsql/grammar.js +++ b/tree-sitter-ggsql/grammar.js @@ -683,7 +683,7 @@ module.exports = grammar({ // Text aesthetics 'label', 'typeface', 'fontweight', 'italic', 'fontsize', 'hjust', 'vjust', 'rotation', // Specialty aesthetics, - 'slope', 'intercept', + 'slope', // Facet aesthetics 'panel', 'row', 'column', // Computed variables diff --git a/tree-sitter-ggsql/queries/highlights.scm b/tree-sitter-ggsql/queries/highlights.scm index 44e780ec..c7da26fa 100644 --- a/tree-sitter-ggsql/queries/highlights.scm +++ b/tree-sitter-ggsql/queries/highlights.scm @@ -58,7 +58,6 @@ "vjust" "rotation" "slope" - "intercept" "panel" "row" "column" From b6918bc30c07a1ca6168a9d5baa55949774abc6a Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Fri, 20 Mar 2026 16:47:49 +0100 Subject: [PATCH 08/11] adapt docs --- doc/syntax/layer/type/rule.qmd | 32 ++++++++++++++------------------ 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/doc/syntax/layer/type/rule.qmd b/doc/syntax/layer/type/rule.qmd index 994de41f..96a27020 100644 --- a/doc/syntax/layer/type/rule.qmd +++ b/doc/syntax/layer/type/rule.qmd @@ -5,15 +5,8 @@ title: "Rule" > 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. The rule layer is used to draw reference lines perpendicular to an axis at specified values. This is useful for adding thresholds, means, medians, event markers, cutoff dates or other guides to the plot. The lines span the full width or height of the panels. -Additionally, the rule layer is used to draw diagonal reference lines based on a coefficient (slope) and intercept. This is useful for adding regression lines, diagonal guides, or mathematical functions to plots. -The parameters are named for the following formula: - -$$ -y = a + \beta x -$$ - -Where $a$ is the `intercept` and $\beta$ is the `slope`. +Additionally, the rule layer can draw diagonal reference lines by specifying a `slope` along with a position (`x` or `y`). The position acts as the intercept term in the linear equation. This is useful for adding regression lines, diagonal guides, or mathematical functions to plots. > The rule layer doesn't have a natural extent along the secondary axis. The secondary scale range can thus not be deduced from rule layers. If the rule layer is the only layer in the visualisation, you must specify the position scale range manually. @@ -22,12 +15,9 @@ The following aesthetics are recognised by the rule layer. ### Required * Primary axis (e.g. `x` or `y`): Position along the primary axis. -* `slope` -* `intercept` - -Note: you should use either the primary axis, or `slope` together with `intercept`, not both. ### Optional +* `slope` The $\beta$ in the equations described below. Defaults to 0. * `colour`/`stroke`: The colour of the line * `opacity`: The opacity of the line * `linewidth`: The width of the line @@ -39,11 +29,17 @@ Note: you should use either the primary axis, or `slope` together with `intercep * `'transposed'` to align the layer's primary axis with the coordinate system's second axis. ## Data transformation -The rule layer does not transform its data but passes it through unchanged. + +For diagonal lines, the position aesthetic determines the intercept: + +* `y = a, slope = β` creates the line: $y = a + \beta x$ (line passes through $(0, a)$) +* `x = a, slope = β` creates the line: $x = a + \beta y$ (line passes through $(a, 0)$) + +Note that `slope` represents $\Delta x/\Delta y$ (change in x per unit change in y) when using `x` mapping, not the traditional $\Delta y/\Delta x$. +The default slope is 0, creating horizontal lines when `y` is given and vertical lines when `x` is given. ## Orientation -Rules are drawn perpendicular to their primary axis. The orientation is deduced directly from the mapping. To create a horizontal rule you map the values to `y` instead of `x` (assuming a default Cartesian coordinate system). -If rule layers use the `slope`/`intercept` formulation, the predictor ($x$) for their primary axis and the response $y$ for its secondary axis. Since the primary axis cannot be deduced from the mapping it must be specified using the `orientation` setting. E.g. to create a vertical linear plot, you need to set `orientation => 'transposed'` to indicate that the primary layer axis follows the second axis of the coordinate system. +The orientation is deduced directly from the mapping. See the ['Data transformation'](#data-transformation) section for details. ## Examples @@ -86,12 +82,12 @@ VISUALISE DRAW rule MAPPING value AS y, label AS colour FROM thresholds ``` -Add a diagnoal reference line to a scatterplot. Note we use `slope` and `intercept` instead of `x` or `y`. +Add a diagnoal reference line to a scatterplot by using `slope` ```{ggsql} VISUALISE FROM ggsql:penguins DRAW point MAPPING bill_len AS x, bill_dep AS y - PLACE rule SETTING slope => 0.4, intercept => -1 + PLACE rule SETTING slope => 0.4, y => -1 ``` Add multiple reference lines with different colors from a separate dataset. Note we're mapping from data here, so we use `DRAW` instead of `PLACE`. @@ -109,7 +105,7 @@ VISUALISE FROM ggsql:penguins DRAW rule MAPPING slope AS slope, - intercept AS intercept, + intercept AS y, label AS colour FROM lines ``` \ No newline at end of file From 3bf275dad926b173696c39b8542b0745c2967c03 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Fri, 20 Mar 2026 17:12:06 +0100 Subject: [PATCH 09/11] create `setup_layer()` method --- src/execute/mod.rs | 8 ++++++++ src/plot/layer/geom/mod.rs | 22 +++++++++++++++++++++ src/plot/layer/geom/rule.rs | 38 +++++++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+) diff --git a/src/execute/mod.rs b/src/execute/mod.rs index f4426a3a..00c550bc 100644 --- a/src/execute/mod.rs +++ b/src/execute/mod.rs @@ -1017,6 +1017,14 @@ pub fn prepare_data_with_reader(query: &str, reader: &R) -> Result, + ) -> Result<()> { + Ok(()) + } + /// Returns valid parameter names for SETTING clause. /// /// Combines supported aesthetics with non-aesthetic parameters from default_params. @@ -416,6 +429,15 @@ impl Geom { self.0.post_process(df, parameters) } + /// Adjust layer mappings and parameters based on geom-specific logic + pub fn setup_layer( + &self, + mappings: &mut Mappings, + parameters: &mut HashMap, + ) -> Result<()> { + self.0.setup_layer(mappings, parameters) + } + /// Get valid settings pub fn valid_settings(&self) -> Vec<&'static str> { self.0.valid_settings() diff --git a/src/plot/layer/geom/rule.rs b/src/plot/layer/geom/rule.rs index a43b290e..f6dd706e 100644 --- a/src/plot/layer/geom/rule.rs +++ b/src/plot/layer/geom/rule.rs @@ -25,6 +25,44 @@ impl GeomTrait for Rule { } } + fn setup_layer( + &self, + mappings: &mut crate::plot::layer::Mappings, + parameters: &mut std::collections::HashMap, + ) -> crate::Result<()> { + use crate::plot::layer::AestheticValue; + use crate::plot::ParameterValue; + + // For diagonal rules (slope present), convert position aesthetics to AnnotationColumn + // so they don't participate in scale training. The position value is the intercept, + // not the actual extent of the line. + + // Check if slope is present and non-zero (in either mappings or parameters) + let has_diagonal_slope = mappings.get("slope").is_some_and(|mapping| { + !matches!(mapping, AestheticValue::Literal(ParameterValue::Number(n)) if *n == 0.0) + }) || parameters.get("slope").is_some_and(|param| { + !matches!(param, ParameterValue::Number(n) if *n == 0.0) + }); + + if !has_diagonal_slope { + return Ok(()); + } + + // For diagonal rules, convert pos1/pos2 to AnnotationColumn so they don't participate in scale training + // The position value is the intercept, not the actual extent of the line + for aesthetic in ["pos1", "pos2"] { + if let Some(mapping) = mappings.aesthetics.get_mut(aesthetic) { + // Convert Column to AnnotationColumn + if let AestheticValue::Column { name, .. } = &*mapping { + let name = name.clone(); + *mapping = AestheticValue::AnnotationColumn { name }; + } + } + } + + Ok(()) + } + } impl std::fmt::Display for Rule { From 7056db76b0db590a525732a34b1c3a52834a54fb Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Fri, 20 Mar 2026 18:09:04 +0100 Subject: [PATCH 10/11] fix issue with expanding scales --- src/writer/vegalite/layer.rs | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/writer/vegalite/layer.rs b/src/writer/vegalite/layer.rs index e6a9fbe0..71c1348d 100644 --- a/src/writer/vegalite/layer.rs +++ b/src/writer/vegalite/layer.rs @@ -399,8 +399,8 @@ impl GeomRenderer for RuleRenderer { fn modify_encoding( &self, encoding: &mut Map, - layer: &Layer, - _context: &RenderContext, + _layer: &Layer, + context: &RenderContext, ) -> Result<()> { let has_x = encoding.contains_key("x"); let has_y = encoding.contains_key("y"); @@ -446,16 +446,19 @@ impl GeomRenderer for RuleRenderer { ("x", "x2", "y", "y2") }; + let mut primary_enco = json!({"field": "primary_min", "type": "quantitative"}); + + // Get primary axis extent from context to set explicit scale domain + // This prevents an axis drift + let extent_aesthetic = if is_horizontal { "pos2" } else { "pos1" }; + if let Ok((min, max)) = context.get_extent(extent_aesthetic) { + primary_enco["scale"] = json!({"domain": [min, max]}) + }; + // Add encodings for rule mark // primary_min/primary_max are created by transforms (extent of the axis) // secondary_min/secondary_max are computed via formula - encoding.insert( - primary.to_string(), - json!({ - "field": "primary_min", - "type": "quantitative" - }), - ); + encoding.insert(primary.to_string(), primary_enco); encoding.insert( primary2.to_string(), json!({ From 9d911a943e1e75045b122ae009a6f11fa9d0762c Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Fri, 20 Mar 2026 18:44:40 +0100 Subject: [PATCH 11/11] cargo fmt --- src/execute/mod.rs | 4 +++- src/plot/layer/geom/rule.rs | 1 - 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/execute/mod.rs b/src/execute/mod.rs index 00c550bc..ae5d5153 100644 --- a/src/execute/mod.rs +++ b/src/execute/mod.rs @@ -1021,7 +1021,9 @@ pub fn prepare_data_with_reader(query: &str, reader: &R) -> Result