From 4331aa3aabc9e299bf3df7b3401a25db0f6bc07c Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Wed, 25 Feb 2026 16:00:21 +0100 Subject: [PATCH 01/12] Implement segments --- src/plot/layer/geom/segment.rs | 4 ++-- src/writer/vegalite/layer.rs | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/plot/layer/geom/segment.rs b/src/plot/layer/geom/segment.rs index 163ca795..cefb1854 100644 --- a/src/plot/layer/geom/segment.rs +++ b/src/plot/layer/geom/segment.rs @@ -17,8 +17,8 @@ impl GeomTrait for Segment { defaults: &[ ("x", DefaultAestheticValue::Required), ("y", DefaultAestheticValue::Required), - ("xend", DefaultAestheticValue::Required), - ("yend", DefaultAestheticValue::Required), + ("xend", DefaultAestheticValue::Null), + ("yend", DefaultAestheticValue::Null), ("stroke", DefaultAestheticValue::String("black")), ("linewidth", DefaultAestheticValue::Number(1.0)), ("opacity", DefaultAestheticValue::Number(1.0)), diff --git a/src/writer/vegalite/layer.rs b/src/writer/vegalite/layer.rs index ee2a225a..99641897 100644 --- a/src/writer/vegalite/layer.rs +++ b/src/writer/vegalite/layer.rs @@ -39,6 +39,7 @@ pub fn geom_to_mark(geom: &Geom) -> Value { GeomType::Boxplot => "boxplot", GeomType::Text => "text", GeomType::Label => "text", + GeomType::Segment => "rule", _ => "point", // Default fallback }; json!({ @@ -222,6 +223,36 @@ impl GeomRenderer for PathRenderer { } } +// ============================================================================= +// Segment Renderer +// ============================================================================= + +pub struct SegmentRenderer; + +impl GeomRenderer for SegmentRenderer { + fn modify_encoding(&self, encoding: &mut Map, _layer: &Layer) -> Result<()> { + let has_x2 = encoding.contains_key("x2"); + let has_y2 = encoding.contains_key("y2"); + if !has_x2 && !has_y2 { + return Err(GgsqlError::ValidationError( + "The `segment` layer requires at least one of the `xend` or `yend` aesthetics." + .to_string(), + )); + } + if !has_x2 { + if let Some(x) = encoding.get("x").cloned() { + encoding.insert("x2".to_string(), x); + } + } + if !has_y2 { + if let Some(y) = encoding.get("y").cloned() { + encoding.insert("y2".to_string(), y); + } + } + Ok(()) + } +} + // ============================================================================= // Ribbon Renderer // ============================================================================= @@ -791,6 +822,7 @@ pub fn get_renderer(geom: &Geom) -> Box { GeomType::Boxplot => Box::new(BoxplotRenderer), GeomType::Density => Box::new(AreaRenderer), GeomType::Violin => Box::new(ViolinRenderer), + GeomType::Segment => Box::new(SegmentRenderer), // All other geoms (Point, Line, Tile, etc.) use the default renderer _ => Box::new(DefaultRenderer), } From db978b9629194cf3b5d7edc32d5a3a44158c943e Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Wed, 25 Feb 2026 18:57:45 +0100 Subject: [PATCH 02/12] docs for segment --- doc/syntax/index.qmd | 17 +++---- doc/syntax/layer/segment.qmd | 91 ++++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 8 deletions(-) create mode 100644 doc/syntax/layer/segment.qmd diff --git a/doc/syntax/index.qmd b/doc/syntax/index.qmd index cbcddd26..ed9aa042 100644 --- a/doc/syntax/index.qmd +++ b/doc/syntax/index.qmd @@ -15,17 +15,18 @@ ggsql augments the standard SQL syntax with a number of new clauses to describe ## Layers There are many different layers to choose from when visualising your data. Some are straightforward translations of your data into visual marks such as a point layer, while others perform more or less complicated calculations like e.g. the histogram layer. A layer is selected by providing the layer name after the `DRAW` clause -- [`point`](layer/point.qmd) is used to create a scatterplot layer -- [`line`](layer/line.qmd) is used to produce lineplots with the data sorted along the x axis -- [`path`](layer/path.qmd) is like `line` above but does not sort the data but plot it according to its own order +- [`point`](layer/point.qmd) is used to create a scatterplot layer. +- [`line`](layer/line.qmd) is used to produce lineplots with the data sorted along the x axis. +- [`path`](layer/path.qmd) is like `line` above but does not sort the data but plot it according to its own order. +- [`segment`](layer/segment.qmd) connects two points with a line segment. - [`area`](layer/area.qmd) is used to display series as an area chart. - [`ribbon`](layer/ribbon.qmd) is used to display series extrema. - [`polygon`](layer/polygon.qmd) is used to display arbitrary shapes as polygons. -- [`bar`](layer/bar.qmd) creates a bar chart, optionally calculating y from the number of records in each bar -- [`density`](layer/density.qmd) creates univariate kernel density estimates, showing the distribution of a variable -- [`violin`](layer/violin.qmd) displays a rotated kernel density estimate -- [`histogram`](layer/histogram.qmd) bins the data along the x axis and produces a bar for each bin showing the number of records in it -- [`boxplot`](layer/boxplot.qmd) displays continuous variables as 5-number summaries +- [`bar`](layer/bar.qmd) creates a bar chart, optionally calculating y from the number of records in each bar. +- [`density`](layer/density.qmd) creates univariate kernel density estimates, showing the distribution of a variable. +- [`violin`](layer/violin.qmd) displays a rotated kernel density estimate. +- [`histogram`](layer/histogram.qmd) bins the data along the x axis and produces a bar for each bin showing the number of records in it. +- [`boxplot`](layer/boxplot.qmd) displays continuous variables as 5-number summaries. ## Scales diff --git a/doc/syntax/layer/segment.qmd b/doc/syntax/layer/segment.qmd new file mode 100644 index 00000000..95ad8087 --- /dev/null +++ b/doc/syntax/layer/segment.qmd @@ -0,0 +1,91 @@ +--- +title: "Segment" +--- + +> 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 segment layer is used to create line segments between two endpoints. If differs from [lines](line.qmd) and [paths](path.qmd) in that it connects just two points rather than many. Data is expected to be in a different shape, with 4 coordinates for the start (x, y) and end (xend, yend) points on a single row. + +## Aesthetics +The following aesthetics are recognised by the segment layer. + +### Required +* `x`: Position along the x-axis of the start point. +* `y`: Position along the y-axis of the end point. +* `xend`\*: Position along the x-axis of the end point. +* `yend`\*: Position along the y-axis of the end point. + +\* Only one of `xend` and `yend` is required. +If one is missing, it takes on the value of the start point. + +### 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 +The segment layer has no additional settings. + +## Data transformation +The segment layer does not transform its data but passes it through unchanged. + +## Data transformation + +## Examples + +Segments are useful when you have known start and end points of the data. For example in a graph + +```{ggsql} +WITH edges AS ( + SELECT * FROM (VALUES + (0, 0, 1, 1, 'A'), + (1, 1, 2, 1, 'A'), + (2, 1, 3, 0, 'A'), + (0, 3, 1, 2, 'B'), + (1, 2, 2, 2, 'B'), + (2, 2, 3, 3, 'B'), + (1, 1, 1, 2, 'C'), + (2, 2, 2, 1, 'C') + ) AS t(x, y, xend, yend, type) +) +VISUALISE x, y, xend, yend FROM edges + DRAW segment MAPPING type AS stroke +``` + +You can use segments as part of a lollipop chart by anchoring one of the ends to 0. +Note that `xend` is missing and has taken up the value of `x`. + +```{ggsql} +SELECT ROUND(bill_dep) AS bill_dep, COUNT(*) AS n + FROM ggsql:penguins + GROUP BY ROUND(bill_dep) + +VISUALISE bill_dep AS x, n AS y + DRAW segment MAPPING 0 AS yend + DRAW point +``` + +By overlaying a thick line on a thin line, you can create a candlestick chart. + +```{ggsql} +SELECT + FIRST(Date) AS date, + FIRST(temp) AS open, + LAST(temp) AS close, + MAX(temp) AS high, + MIN(temp) AS low, + CASE + WHEN FIRST(temp) > LAST(temp) THEN 'colder' + ELSE 'warmer' + END AS trend +FROM ggsql:airquality +GROUP BY WEEKOFYEAR(Date) + +VISUALISE date AS x, trend AS colour + DRAW segment + MAPPING open AS y, close AS yend + SETTING linewidth => 5 + DRAW segment + MAPPING low AS y, high AS yend +``` \ No newline at end of file From cb814a074a55638e2d62567c5c5cc96345a9f5a0 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Wed, 25 Feb 2026 19:54:29 +0100 Subject: [PATCH 03/12] enable hline/vline --- src/plot/layer/geom/hline.rs | 2 +- src/plot/layer/geom/vline.rs | 2 +- src/writer/vegalite/layer.rs | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/plot/layer/geom/hline.rs b/src/plot/layer/geom/hline.rs index d6ad6951..06c41a6e 100644 --- a/src/plot/layer/geom/hline.rs +++ b/src/plot/layer/geom/hline.rs @@ -15,7 +15,7 @@ impl GeomTrait for HLine { fn aesthetics(&self) -> DefaultAesthetics { DefaultAesthetics { defaults: &[ - ("yintercept", DefaultAestheticValue::Required), + ("y", DefaultAestheticValue::Required), ("stroke", DefaultAestheticValue::String("black")), ("linewidth", DefaultAestheticValue::Number(1.0)), ("opacity", DefaultAestheticValue::Number(1.0)), diff --git a/src/plot/layer/geom/vline.rs b/src/plot/layer/geom/vline.rs index 2b12cf1d..ba73cb04 100644 --- a/src/plot/layer/geom/vline.rs +++ b/src/plot/layer/geom/vline.rs @@ -15,7 +15,7 @@ impl GeomTrait for VLine { fn aesthetics(&self) -> DefaultAesthetics { DefaultAesthetics { defaults: &[ - ("xintercept", DefaultAestheticValue::Required), + ("x", DefaultAestheticValue::Required), ("stroke", DefaultAestheticValue::String("black")), ("linewidth", DefaultAestheticValue::Number(1.0)), ("opacity", DefaultAestheticValue::Number(1.0)), diff --git a/src/writer/vegalite/layer.rs b/src/writer/vegalite/layer.rs index 99641897..ec989421 100644 --- a/src/writer/vegalite/layer.rs +++ b/src/writer/vegalite/layer.rs @@ -40,6 +40,8 @@ pub fn geom_to_mark(geom: &Geom) -> Value { GeomType::Text => "text", GeomType::Label => "text", GeomType::Segment => "rule", + GeomType::HLine => "rule", + GeomType::VLine => "rule", _ => "point", // Default fallback }; json!({ From 34d2bbf69458db77c0d2f576c8cf18fcb845517a Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Thu, 26 Feb 2026 14:05:00 +0100 Subject: [PATCH 04/12] Add RenderContext to provide renderers with read-access to scales Introduce RenderContext struct to pass scale information to renderers during layer preparation. This allows renderers to access resolved scale properties (such as input ranges/extents) when preparing data or building encoding/spec modifications. Changes: - Add RenderContext struct with scales field and helper methods: - new() - constructor - find_scale() - lookup scale by aesthetic name - get_extent() - extract numeric min/max from continuous scales - Update GeomRenderer trait signatures to accept RenderContext: - prepare_data() now takes context parameter - modify_encoding() now takes context parameter - modify_spec() now takes context parameter - Update all existing renderer implementations for new signatures (BarRenderer, PathRenderer, SegmentRenderer, RibbonRenderer, AreaRenderer, PolygonRenderer, ViolinRenderer, BoxplotRenderer) - Update call sites in mod.rs to create and pass context - Add unit test for RenderContext::get_extent() with 4 test cases Co-Authored-By: Claude Sonnet 4.5 --- src/writer/vegalite/layer.rs | 237 ++++++++++++++++++++++++++++++++--- src/writer/vegalite/mod.rs | 13 +- 2 files changed, 231 insertions(+), 19 deletions(-) diff --git a/src/writer/vegalite/layer.rs b/src/writer/vegalite/layer.rs index ec989421..1cf624e8 100644 --- a/src/writer/vegalite/layer.rs +++ b/src/writer/vegalite/layer.rs @@ -115,6 +115,62 @@ pub enum PreparedData { }, } +// ============================================================================= +// RenderContext +// ============================================================================= + +/// Context information available to renderers during layer preparation +pub struct RenderContext<'a> { + /// Scale definitions (for extent and properties) + pub scales: &'a [crate::Scale], +} + +impl<'a> RenderContext<'a> { + /// Create a new render context + pub fn new(scales: &'a [crate::Scale]) -> Self { + Self { scales } + } + + /// Find a scale by aesthetic name + pub fn find_scale(&self, aesthetic: &str) -> Option<&crate::Scale> { + self.scales.iter().find(|s| s.aesthetic == aesthetic) + } + + /// Get the numeric extent (min, max) for a given aesthetic from its scale + pub fn get_extent(&self, aesthetic: &str) -> Result<(f64, f64)> { + use crate::plot::ArrayElement; + + // Find the scale for this aesthetic + let scale = self.find_scale(aesthetic).ok_or_else(|| { + GgsqlError::ValidationError(format!( + "Cannot determine extent for aesthetic '{}': no scale found", + aesthetic + )) + })?; + + // Extract continuous range from input_range + if let Some(range) = &scale.input_range { + if range.len() >= 2 { + match (&range[0], &range[1]) { + (ArrayElement::Number(min), ArrayElement::Number(max)) => { + return Ok((*min, *max)); + } + _ => {} + } + } + } + + Err(GgsqlError::ValidationError(format!( + "Cannot determine extent for aesthetic '{}': scale has no valid numeric range", + aesthetic + ))) + } +} + +// ============================================================================= +// GeomRenderer Trait System +// ============================================================================= + /// Trait for rendering ggsql geoms to Vega-Lite layers /// /// Provides a three-phase rendering pipeline: @@ -134,6 +190,7 @@ pub trait GeomRenderer: Send + Sync { df: &DataFrame, _data_key: &str, binned_columns: &HashMap>, + _context: &RenderContext, ) -> Result { let values = if binned_columns.is_empty() { dataframe_to_values(df)? @@ -147,13 +204,23 @@ pub trait GeomRenderer: Send + Sync { /// Modify the encoding map for this geom. /// Default: no modifications - fn modify_encoding(&self, _encoding: &mut Map, _layer: &Layer) -> Result<()> { + fn modify_encoding( + &self, + _encoding: &mut Map, + _layer: &Layer, + _context: &RenderContext, + ) -> Result<()> { Ok(()) } /// Modify the mark/layer spec for this geom. /// Default: no modifications - fn modify_spec(&self, _layer_spec: &mut Value, _layer: &Layer) -> Result<()> { + fn modify_spec( + &self, + _layer_spec: &mut Value, + _layer: &Layer, + _context: &RenderContext, + ) -> Result<()> { Ok(()) } @@ -196,7 +263,12 @@ impl GeomRenderer for DefaultRenderer {} pub struct BarRenderer; impl GeomRenderer for BarRenderer { - fn modify_spec(&self, layer_spec: &mut Value, layer: &Layer) -> Result<()> { + fn modify_spec( + &self, + layer_spec: &mut Value, + layer: &Layer, + _context: &RenderContext, + ) -> Result<()> { let width = match layer.parameters.get("width") { Some(ParameterValue::Number(w)) => *w, _ => 0.9, @@ -218,7 +290,12 @@ impl GeomRenderer for BarRenderer { pub struct PathRenderer; impl GeomRenderer for PathRenderer { - fn modify_encoding(&self, encoding: &mut Map, _layer: &Layer) -> Result<()> { + fn modify_encoding( + &self, + encoding: &mut Map, + _layer: &Layer, + _context: &RenderContext, + ) -> Result<()> { // Use the natural data order encoding.insert("order".to_string(), json!({"value": Value::Null})); Ok(()) @@ -232,7 +309,12 @@ impl GeomRenderer for PathRenderer { pub struct SegmentRenderer; impl GeomRenderer for SegmentRenderer { - fn modify_encoding(&self, encoding: &mut Map, _layer: &Layer) -> Result<()> { + fn modify_encoding( + &self, + encoding: &mut Map, + _layer: &Layer, + _context: &RenderContext, + ) -> Result<()> { let has_x2 = encoding.contains_key("x2"); let has_y2 = encoding.contains_key("y2"); if !has_x2 && !has_y2 { @@ -263,7 +345,12 @@ impl GeomRenderer for SegmentRenderer { pub struct RibbonRenderer; impl GeomRenderer for RibbonRenderer { - fn modify_encoding(&self, encoding: &mut Map, _layer: &Layer) -> Result<()> { + fn modify_encoding( + &self, + encoding: &mut Map, + _layer: &Layer, + _context: &RenderContext, + ) -> Result<()> { if let Some(ymax) = encoding.remove("ymax") { encoding.insert("y".to_string(), ymax); } @@ -282,7 +369,12 @@ impl GeomRenderer for RibbonRenderer { pub struct AreaRenderer; impl GeomRenderer for AreaRenderer { - fn modify_encoding(&self, encoding: &mut Map, layer: &Layer) -> Result<()> { + fn modify_encoding( + &self, + encoding: &mut Map, + layer: &Layer, + _context: &RenderContext, + ) -> Result<()> { if let Some(mut y) = encoding.remove("y") { let stack_value; if let Some(ParameterValue::String(stack)) = layer.parameters.get("stacking") { @@ -315,7 +407,12 @@ impl GeomRenderer for AreaRenderer { pub struct PolygonRenderer; impl GeomRenderer for PolygonRenderer { - fn modify_encoding(&self, encoding: &mut Map, _layer: &Layer) -> Result<()> { + fn modify_encoding( + &self, + encoding: &mut Map, + _layer: &Layer, + _context: &RenderContext, + ) -> Result<()> { // Polygon needs both `fill` and `stroke` independently, but map_aesthetic_name() // converts fill -> color (which works for most geoms). For closed line marks, // we need actual `fill` and `stroke` channels, so we undo the mapping here. @@ -327,7 +424,12 @@ impl GeomRenderer for PolygonRenderer { Ok(()) } - fn modify_spec(&self, layer_spec: &mut Value, _layer: &Layer) -> Result<()> { + fn modify_spec( + &self, + layer_spec: &mut Value, + _layer: &Layer, + _context: &RenderContext, + ) -> Result<()> { layer_spec["mark"] = json!({ "type": "line", "interpolate": "linear-closed" @@ -344,7 +446,12 @@ impl GeomRenderer for PolygonRenderer { pub struct ViolinRenderer; impl GeomRenderer for ViolinRenderer { - fn modify_spec(&self, layer_spec: &mut Value, _layer: &Layer) -> Result<()> { + fn modify_spec( + &self, + layer_spec: &mut Value, + _layer: &Layer, + _context: &RenderContext, + ) -> Result<()> { layer_spec["mark"] = json!({ "type": "line", "filled": true @@ -399,7 +506,12 @@ impl GeomRenderer for ViolinRenderer { Ok(()) } - fn modify_encoding(&self, encoding: &mut Map, _layer: &Layer) -> Result<()> { + fn modify_encoding( + &self, + encoding: &mut Map, + _layer: &Layer, + _context: &RenderContext, + ) -> Result<()> { // Ensure x is in detail encoding to create separate violins per x category // This is needed because line marks with filled:true require detail to create separate paths let x_field = encoding @@ -764,6 +876,7 @@ impl GeomRenderer for BoxplotRenderer { df: &DataFrame, _data_key: &str, binned_columns: &HashMap>, + _context: &RenderContext, ) -> Result { let (components, grouping_cols, has_outliers) = self.prepare_components(df, binned_columns)?; @@ -845,7 +958,10 @@ mod tests { "x".to_string(), json!({"field": "species", "type": "nominal"}), ); - renderer.modify_encoding(&mut encoding, &layer).unwrap(); + let context = RenderContext::new(&[]); + renderer + .modify_encoding(&mut encoding, &layer, &context) + .unwrap(); assert_eq!( encoding.get("detail"), Some(&json!({"field": "species", "type": "nominal"})) @@ -861,7 +977,10 @@ mod tests { "detail".to_string(), json!({"field": "island", "type": "nominal"}), ); - renderer.modify_encoding(&mut encoding, &layer).unwrap(); + let context = RenderContext::new(&[]); + renderer + .modify_encoding(&mut encoding, &layer, &context) + .unwrap(); assert_eq!( encoding.get("detail"), Some(&json!([ @@ -880,7 +999,10 @@ mod tests { "detail".to_string(), json!({"field": "species", "type": "nominal"}), ); - renderer.modify_encoding(&mut encoding, &layer).unwrap(); + let context = RenderContext::new(&[]); + renderer + .modify_encoding(&mut encoding, &layer, &context) + .unwrap(); assert_eq!( encoding.get("detail"), Some(&json!({"field": "species", "type": "nominal"})) @@ -896,7 +1018,10 @@ mod tests { "detail".to_string(), json!([{"field": "island", "type": "nominal"}]), ); - renderer.modify_encoding(&mut encoding, &layer).unwrap(); + let context = RenderContext::new(&[]); + renderer + .modify_encoding(&mut encoding, &layer, &context) + .unwrap(); assert_eq!( encoding.get("detail"), Some(&json!([ @@ -918,7 +1043,10 @@ mod tests { {"field": "species", "type": "nominal"} ]), ); - renderer.modify_encoding(&mut encoding, &layer).unwrap(); + let context = RenderContext::new(&[]); + renderer + .modify_encoding(&mut encoding, &layer, &context) + .unwrap(); assert_eq!( encoding.get("detail"), Some(&json!([ @@ -927,4 +1055,81 @@ mod tests { ])) ); } + + #[test] + fn test_render_context_get_extent() { + use crate::plot::{ArrayElement, Scale}; + + // Test success case: continuous scale with numeric range + let scales = vec![Scale { + aesthetic: "x".to_string(), + scale_type: None, + input_range: Some(vec![ArrayElement::Number(0.0), ArrayElement::Number(10.0)]), + explicit_input_range: false, + output_range: None, + transform: None, + explicit_transform: false, + properties: std::collections::HashMap::new(), + resolved: false, + label_mapping: None, + label_template: "{}".to_string(), + }]; + let context = RenderContext::new(&scales); + let result = context.get_extent("x"); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), (0.0, 10.0)); + + // Test error case: scale not found + let context = RenderContext::new(&scales); + let result = context.get_extent("y"); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("no scale found")); + + // Test error case: scale with no range + let scales = vec![Scale { + aesthetic: "x".to_string(), + scale_type: None, + input_range: None, + explicit_input_range: false, + output_range: None, + transform: None, + explicit_transform: false, + properties: std::collections::HashMap::new(), + resolved: false, + label_mapping: None, + label_template: "{}".to_string(), + }]; + let context = RenderContext::new(&scales); + let result = context.get_extent("x"); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("no valid numeric range")); + + // Test error case: scale with non-numeric range + let scales = vec![Scale { + aesthetic: "x".to_string(), + scale_type: None, + input_range: Some(vec![ + ArrayElement::String("A".to_string()), + ArrayElement::String("B".to_string()), + ]), + explicit_input_range: false, + output_range: None, + transform: None, + explicit_transform: false, + properties: std::collections::HashMap::new(), + resolved: false, + label_mapping: None, + label_template: "{}".to_string(), + }]; + let context = RenderContext::new(&scales); + let result = context.get_extent("x"); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("no valid numeric range")); + } } diff --git a/src/writer/vegalite/mod.rs b/src/writer/vegalite/mod.rs index 09801aa0..a17a233a 100644 --- a/src/writer/vegalite/mod.rs +++ b/src/writer/vegalite/mod.rs @@ -86,6 +86,9 @@ fn prepare_layer_data( let mut layer_renderers: Vec> = Vec::new(); let mut prepared_data: Vec = Vec::new(); + // Build context once for all layers + let context = layer::RenderContext::new(&spec.scales); + for (layer_idx, layer) in spec.layers.iter().enumerate() { let data_key = &layer_data_keys[layer_idx]; let df = data.get(data_key).ok_or_else(|| { @@ -100,7 +103,7 @@ fn prepare_layer_data( let renderer = get_renderer(&layer.geom); // Prepare data using the renderer (handles both standard and composite cases) - let prepared = renderer.prepare_data(df, data_key, binned_columns)?; + let prepared = renderer.prepare_data(df, data_key, binned_columns, &context)?; // Add data to individual datasets based on prepared type match &prepared { @@ -153,6 +156,9 @@ fn build_layers( ) -> Result> { let mut layers = Vec::new(); + // Build context once for all layers + let context = layer::RenderContext::new(&spec.scales); + for (layer_idx, layer) in spec.layers.iter().enumerate() { let data_key = &layer_data_keys[layer_idx]; let df = data.get(data_key).unwrap(); @@ -187,7 +193,7 @@ fn build_layers( layer_spec["encoding"] = Value::Object(encoding); // Apply geom-specific spec modifications via renderer - renderer.modify_spec(&mut layer_spec, layer)?; + renderer.modify_spec(&mut layer_spec, layer, &context)?; // Finalize the layer (may expand into multiple layers for composite geoms) let final_layers = renderer.finalize(layer_spec, layer, data_key, prepared)?; @@ -282,7 +288,8 @@ fn build_layer_encoding( // Apply geom-specific encoding modifications via renderer let renderer = get_renderer(&layer.geom); - renderer.modify_encoding(&mut encoding, layer)?; + let context = layer::RenderContext::new(&spec.scales); + renderer.modify_encoding(&mut encoding, layer, &context)?; Ok(encoding) } From 026e5c820b39a122c6eff3cd808d17da0c47aab4 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Thu, 26 Feb 2026 14:32:16 +0100 Subject: [PATCH 05/12] Implement ABLineRenderer for slope-intercept lines Add ABLineRenderer that draws lines based on slope and intercept parameters using a clipping-based approach: - Uses global x-axis extent from scales (not per-panel) - Creates calculate transforms for x_min, x_max, y_min, y_max - Relies on Vega-Lite clipping for faceted plots - Uses rule mark for independent line segments - Removes slope/intercept from encoding (used in transforms only) Key implementation details: - modify_spec(): Creates transforms for constant x extent and computed y values based on slope * x + intercept - modify_encoding(): Removes slope/intercept, adds x/x2/y/y2 encodings using x_min/x_max/y_min/y_max fields - geom_to_mark(): Uses "rule" mark for AbLine - get_renderer(): Dispatches AbLine to ABLineRenderer Naming convention: x_min, x_max, y_min, y_max (consistent underscores) Grammar update: Add 'slope' and 'intercept' as valid aesthetic names for ABLine geom support. Includes integration test with 3 lines of different slopes, verifying transforms, encodings, and stroke encoding for color. Co-Authored-By: Claude Sonnet 4.5 --- src/writer/vegalite/layer.rs | 306 +++++++++++++++++++++++++++++++++++ tree-sitter-ggsql/grammar.js | 2 + 2 files changed, 308 insertions(+) diff --git a/src/writer/vegalite/layer.rs b/src/writer/vegalite/layer.rs index 1cf624e8..c6b0aa6e 100644 --- a/src/writer/vegalite/layer.rs +++ b/src/writer/vegalite/layer.rs @@ -42,6 +42,7 @@ pub fn geom_to_mark(geom: &Geom) -> Value { GeomType::Segment => "rule", GeomType::HLine => "rule", GeomType::VLine => "rule", + GeomType::AbLine => "rule", _ => "point", // Default fallback }; json!({ @@ -337,6 +338,119 @@ impl GeomRenderer for SegmentRenderer { } } +// ============================================================================= +// ABLine Renderer +// ============================================================================= + +/// Renderer for abline geom - draws lines based on slope and intercept +pub struct ABLineRenderer; + +impl GeomRenderer for ABLineRenderer { + fn prepare_data( + &self, + df: &DataFrame, + _data_key: &str, + _binned_columns: &HashMap>, + _context: &RenderContext, + ) -> 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 slope and intercept from encoding - they're only used in transforms + encoding.remove("slope"); + encoding.remove("intercept"); + + // Add x/x2/y/y2 encodings for rule mark + // All are fields - x_min/x_max are created by transforms, y_min/y_max are computed + encoding.insert( + "x".to_string(), + json!({ + "field": "x_min", + "type": "quantitative" + }), + ); + encoding.insert( + "x2".to_string(), + json!({ + "field": "x_max" + }), + ); + encoding.insert( + "y".to_string(), + json!({ + "field": "y_min", + "type": "quantitative" + }), + ); + encoding.insert( + "y2".to_string(), + json!({ + "field": "y_max" + }), + ); + + Ok(()) + } + + fn modify_spec( + &self, + layer_spec: &mut Value, + _layer: &Layer, + context: &RenderContext, + ) -> Result<()> { + // Field names for slope and intercept (with aesthetic column prefix) + let slope_field = naming::aesthetic_column("slope"); + let intercept_field = naming::aesthetic_column("intercept"); + + // Get x extent from scale + let (x_min, x_max) = context.get_extent("x")?; + + // Add transforms: + // 1. Create constant x_min/x_max fields + // 2. Compute y values at those x positions + let transforms = json!([ + { + "calculate": x_min.to_string(), + "as": "x_min" + }, + { + "calculate": x_max.to_string(), + "as": "x_max" + }, + { + "calculate": format!("datum.{} * datum.x_min + datum.{}", slope_field, intercept_field), + "as": "y_min" + }, + { + "calculate": format!("datum.{} * datum.x_max + datum.{}", slope_field, intercept_field), + "as": "y_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(()) + } +} + // ============================================================================= // Ribbon Renderer // ============================================================================= @@ -938,6 +1052,7 @@ pub fn get_renderer(geom: &Geom) -> Box { GeomType::Density => Box::new(AreaRenderer), GeomType::Violin => Box::new(ViolinRenderer), GeomType::Segment => Box::new(SegmentRenderer), + GeomType::AbLine => Box::new(ABLineRenderer), // All other geoms (Point, Line, Tile, etc.) use the default renderer _ => Box::new(DefaultRenderer), } @@ -1132,4 +1247,195 @@ mod tests { .to_string() .contains("no valid numeric range")); } + + #[test] + fn test_abline_renderer_multiple_lines() { + use crate::reader::{DuckDBReader, Reader}; + use crate::writer::{VegaLiteWriter, Writer}; + + // Test that abline 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) + ), + lines AS ( + SELECT * FROM (VALUES + (2, 5, 'A'), + (1, 10, 'B'), + (3, 0, 'C') + ) AS t(slope, intercept, line_id) + ) + SELECT * FROM points + VISUALISE + DRAW point MAPPING x AS x, y AS y + DRAW abline MAPPING slope AS slope, intercept AS intercept, line_id AS color FROM lines + "#; + + // Execute query + let reader = DuckDBReader::from_connection_string("duckdb://memory") + .expect("Failed to create reader"); + let spec = reader.execute(query).expect("Failed to execute query"); + + // Render to Vega-Lite + let writer = VegaLiteWriter::new(); + let vl_json = writer.render(&spec).expect("Failed to render spec"); + + // Parse JSON + 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 + abline) + let layers = vl_spec["layer"].as_array().expect("No layers found"); + assert_eq!(layers.len(), 2, "Should have 2 layers (point + abline)"); + + // Get the abline layer (second layer) + let abline_layer = &layers[1]; + + // Verify it's a rule mark + assert_eq!( + abline_layer["mark"]["type"], + "rule", + "ABLine should use rule mark" + ); + + // Verify transforms exist + let transforms = abline_layer["transform"] + .as_array() + .expect("No transforms found"); + + // Should have 4 calculate transforms + 1 filter = 5 total + assert_eq!( + transforms.len(), + 5, + "Should have 5 transforms (x_min, x_max, y_min, y_max, filter)" + ); + + // Verify x_min/x_max transforms exist with consistent naming + let x_min_transform = transforms + .iter() + .find(|t| t["as"] == "x_min") + .expect("x_min transform not found"); + let x_max_transform = transforms + .iter() + .find(|t| t["as"] == "x_max") + .expect("x_max transform not found"); + + assert!( + x_min_transform["calculate"].is_string(), + "x_min should have calculate expression" + ); + assert!( + x_max_transform["calculate"].is_string(), + "x_max should have calculate expression" + ); + + // Verify y_min and y_max transforms use slope and intercept with x_min/x_max + let y_min_transform = transforms + .iter() + .find(|t| t["as"] == "y_min") + .expect("y_min transform not found"); + let y_max_transform = transforms + .iter() + .find(|t| t["as"] == "y_max") + .expect("y_max transform not found"); + + let y_min_calc = y_min_transform["calculate"] + .as_str() + .expect("y_min calculate should be string"); + let y_max_calc = y_max_transform["calculate"] + .as_str() + .expect("y_max calculate should be string"); + + // Should reference slope, intercept, and x_min/x_max + assert!( + y_min_calc.contains("__ggsql_aes_slope__"), + "y_min should reference slope" + ); + assert!( + y_min_calc.contains("__ggsql_aes_intercept__"), + "y_min should reference intercept" + ); + assert!( + y_min_calc.contains("datum.x_min"), + "y_min should reference datum.x_min" + ); + assert!( + y_max_calc.contains("__ggsql_aes_slope__"), + "y_max should reference slope" + ); + assert!( + y_max_calc.contains("__ggsql_aes_intercept__"), + "y_max should reference intercept" + ); + assert!( + y_max_calc.contains("datum.x_max"), + "y_max should reference datum.x_max" + ); + + // Verify encoding has x, x2, y, y2 with consistent field names + let encoding = abline_layer["encoding"] + .as_object() + .expect("No encoding found"); + + assert!(encoding.contains_key("x"), "Should have x encoding"); + assert!(encoding.contains_key("x2"), "Should have x2 encoding"); + assert!(encoding.contains_key("y"), "Should have y encoding"); + assert!(encoding.contains_key("y2"), "Should have y2 encoding"); + + // Verify consistent naming: x_min, x_max, y_min, y_max + assert_eq!( + encoding["x"]["field"], "x_min", + "x should reference x_min field" + ); + assert_eq!( + encoding["x2"]["field"], "x_max", + "x2 should reference x_max field" + ); + assert_eq!( + encoding["y"]["field"], "y_min", + "y should reference y_min field" + ); + assert_eq!( + encoding["y2"]["field"], "y_max", + "y2 should reference y_max field" + ); + + // Verify stroke encoding exists for line_id (color aesthetic becomes stroke for rule mark) + assert!( + encoding.contains_key("stroke"), + "Should have stroke encoding for line_id" + ); + + // Verify data has 3 abline rows (one per slope) + let data_values = vl_spec["data"]["values"] + .as_array() + .expect("No data values found"); + + let abline_rows: Vec<_> = data_values + .iter() + .filter(|row| { + row["__ggsql_source__"] == "__ggsql_layer_1__" + && row["__ggsql_aes_slope__"].is_number() + }) + .collect(); + + assert_eq!( + abline_rows.len(), + 3, + "Should have 3 abline rows (3 different slopes)" + ); + + // Verify we have slopes 1, 2, 3 + let mut slopes: Vec = abline_rows + .iter() + .map(|row| row["__ggsql_aes_slope__"].as_f64().unwrap()) + .collect(); + slopes.sort_by(|a, b| a.partial_cmp(b).unwrap()); + + assert_eq!( + slopes, + vec![1.0, 2.0, 3.0], + "Should have slopes 1, 2, and 3" + ); + } } diff --git a/tree-sitter-ggsql/grammar.js b/tree-sitter-ggsql/grammar.js index 1977eb24..5b561d50 100644 --- a/tree-sitter-ggsql/grammar.js +++ b/tree-sitter-ggsql/grammar.js @@ -648,6 +648,8 @@ module.exports = grammar({ 'size', 'shape', 'linetype', 'linewidth', 'width', 'height', // Text aesthetics 'label', 'family', 'fontface', 'hjust', 'vjust', + // Specialty aesthetics, + 'slope', 'intercept', // Facet aesthetics 'panel', 'row', 'column', // Computed variables From d0bb7e5238d8ea4ea07ed7913dddd5976432fa47 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Thu, 26 Feb 2026 14:51:59 +0100 Subject: [PATCH 06/12] update test expectations --- src/plot/main.rs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/plot/main.rs b/src/plot/main.rs index 6772ff08..3e9d5a3c 100644 --- a/src/plot/main.rs +++ b/src/plot/main.rs @@ -417,15 +417,12 @@ mod tests { &["x", "ymin", "ymax"] ); - // Segment/arrow require endpoints - assert_eq!( - Geom::segment().aesthetics().required(), - &["x", "y", "xend", "yend"] - ); + // Segment requires x and y (xend/yend optional, default to x/y) + assert_eq!(Geom::segment().aesthetics().required(), &["x", "y"]); // Reference lines - assert_eq!(Geom::hline().aesthetics().required(), &["yintercept"]); - assert_eq!(Geom::vline().aesthetics().required(), &["xintercept"]); + assert_eq!(Geom::hline().aesthetics().required(), &["y"]); + assert_eq!(Geom::vline().aesthetics().required(), &["x"]); assert_eq!( Geom::abline().aesthetics().required(), &["slope", "intercept"] From 79a751b9e8bb405414fef4b06cafbc3345c939d4 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Thu, 26 Feb 2026 16:05:36 +0100 Subject: [PATCH 07/12] add docs --- doc/syntax/index.qmd | 3 ++ doc/syntax/layer/abline.qmd | 63 +++++++++++++++++++++++++++++++++++++ doc/syntax/layer/hline.qmd | 58 ++++++++++++++++++++++++++++++++++ doc/syntax/layer/vline.qmd | 50 +++++++++++++++++++++++++++++ 4 files changed, 174 insertions(+) create mode 100644 doc/syntax/layer/abline.qmd create mode 100644 doc/syntax/layer/hline.qmd create mode 100644 doc/syntax/layer/vline.qmd diff --git a/doc/syntax/index.qmd b/doc/syntax/index.qmd index ed9aa042..704b33bd 100644 --- a/doc/syntax/index.qmd +++ b/doc/syntax/index.qmd @@ -19,6 +19,9 @@ There are many different layers to choose from when visualising your data. Some - [`line`](layer/line.qmd) is used to produce lineplots with the data sorted along the x axis. - [`path`](layer/path.qmd) is like `line` above but does not sort the data but plot it according to its own order. - [`segment`](layer/segment.qmd) connects two points with a line segment. +- [`abline`](layer/abline.qmd) draws a long line parameterised by slope and intercept. +- [`hline`](layer/hline.qmd) draws a long horizontal line. +- [`vline`](layer/vline.qmd) draws a long vertical line. - [`area`](layer/area.qmd) is used to display series as an area chart. - [`ribbon`](layer/ribbon.qmd) is used to display series extrema. - [`polygon`](layer/polygon.qmd) is used to display arbitrary shapes as polygons. diff --git a/doc/syntax/layer/abline.qmd b/doc/syntax/layer/abline.qmd new file mode 100644 index 00000000..9c7edb68 --- /dev/null +++ b/doc/syntax/layer/abline.qmd @@ -0,0 +1,63 @@ +--- +title: "AB 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 abline layer is used to draw diagonal reference lines based on a slope 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 + bx +$$ + +Where $a$ is the `intercept` and $b$ is the `slope`. + +## Aesthetics +The following aesthetics are recognised by the abline layer. + +### Required +* `slope`: The 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 +The abline layer has no additional settings. + +## Data transformation +The abline layer does not transform its data but passes it through unchanged. + +## 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 + DRAW abline MAPPING 0.4 AS slope, -1 AS intercept +``` + +Add multiple reference lines with different colors from a separate dataset: + +```{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 abline + MAPPING + slope AS slope, + intercept AS intercept, + label AS colour + FROM lines +``` diff --git a/doc/syntax/layer/hline.qmd b/doc/syntax/layer/hline.qmd new file mode 100644 index 00000000..b4b25a0e --- /dev/null +++ b/doc/syntax/layer/hline.qmd @@ -0,0 +1,58 @@ +--- +title: "H 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 hline layer is used to draw horizontal reference lines at specified y-values. This is useful for adding thresholds, means, medians, or other horizontal guides to plots. The lines extend across the full extent of the x-axis, regardless of the data range. + +## Aesthetics +The following aesthetics are recognised by the hline layer. + +### Required +* `y`: The y-coordinate for the horizontal line + +### 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 +The hline layer has no additional settings. + +## Data transformation +The hline layer does not transform its data but passes it through unchanged. + +## Examples + +Add a horizontal threshold line to a time series plot: + +```{ggsql} +SELECT Date AS date, temp AS temperature +FROM ggsql:airquality +WHERE Month = 5 + +VISUALISE date AS x, temperature AS y + DRAW line + DRAW hline MAPPING 70 AS y +``` + +Add multiple threshold lines with different colors: + +```{ggsql} +WITH thresholds AS ( + SELECT * FROM (VALUES + (70, 'Target'), + (80, 'Warning'), + (90, 'Critical') + ) AS t(value, label) +) +SELECT Date AS date, temp AS temperature +FROM ggsql:airquality +WHERE Month = 5 + +VISUALISE date AS x, temperature AS y + DRAW line + DRAW hline MAPPING value AS y, label AS colour FROM thresholds +``` diff --git a/doc/syntax/layer/vline.qmd b/doc/syntax/layer/vline.qmd new file mode 100644 index 00000000..ef3e0936 --- /dev/null +++ b/doc/syntax/layer/vline.qmd @@ -0,0 +1,50 @@ +--- +title: "V 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 vline layer is used to draw vertical reference lines at specified x-values. This is useful for adding event markers, cutoff dates, or other vertical guides to plots. The lines extend across the full extent of the y-axis, regardless of the data range. + +## Aesthetics +The following aesthetics are recognised by the vline layer. + +### Required +* `x`: The x-coordinate for the vertical line + +### 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 +The vline layer has no additional settings. + +## Data transformation +The hline layer does not transform its data but passes it through unchanged. + +## Examples + +Add a vertical line to mark a specific value: + +```{ggsql} +VISUALISE FROM ggsql:penguins + DRAW point MAPPING bill_len AS x, bill_dep AS y + DRAW vline MAPPING 45 AS x +``` + +Add multiple reference lines with different colors: + +```{ggsql} +WITH markers AS ( + SELECT * FROM (VALUES + (40, 'Threshold 1'), + (45, 'Threshold 2'), + (50, 'Threshold 3') + ) AS t(position, label) +) +VISUALISE FROM ggsql:penguins + DRAW point MAPPING bill_len AS x, bill_dep AS y + DRAW vline MAPPING position AS x, label AS colour FROM markers +``` From 757a69ebc4d244ea62271ecd17f1a8ae403cee2c Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Thu, 26 Feb 2026 16:16:37 +0100 Subject: [PATCH 08/12] fix clippy warning --- src/writer/vegalite/layer.rs | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/writer/vegalite/layer.rs b/src/writer/vegalite/layer.rs index c6b0aa6e..e75a8f59 100644 --- a/src/writer/vegalite/layer.rs +++ b/src/writer/vegalite/layer.rs @@ -152,11 +152,10 @@ impl<'a> RenderContext<'a> { // Extract continuous range from input_range if let Some(range) = &scale.input_range { if range.len() >= 2 { - match (&range[0], &range[1]) { - (ArrayElement::Number(min), ArrayElement::Number(max)) => { - return Ok((*min, *max)); - } - _ => {} + if let (ArrayElement::Number(min), ArrayElement::Number(max)) = + (&range[0], &range[1]) + { + return Ok((*min, *max)); } } } @@ -1293,8 +1292,7 @@ mod tests { // Verify it's a rule mark assert_eq!( - abline_layer["mark"]["type"], - "rule", + abline_layer["mark"]["type"], "rule", "ABLine should use rule mark" ); From 02eb20535fc38a1a4a97c815f7a46930061aebd0 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Fri, 27 Feb 2026 11:28:37 +0100 Subject: [PATCH 09/12] implement errorbar --- src/plot/layer/geom/errorbar.rs | 12 +- src/writer/vegalite/layer.rs | 287 ++++++++++++++++++++++++++++++++ 2 files changed, 297 insertions(+), 2 deletions(-) diff --git a/src/plot/layer/geom/errorbar.rs b/src/plot/layer/geom/errorbar.rs index 3e23876c..2cdb6990 100644 --- a/src/plot/layer/geom/errorbar.rs +++ b/src/plot/layer/geom/errorbar.rs @@ -1,7 +1,7 @@ //! ErrorBar geom implementation use super::{DefaultAesthetics, GeomTrait, GeomType}; -use crate::plot::types::DefaultAestheticValue; +use crate::plot::{types::DefaultAestheticValue, DefaultParam, DefaultParamValue}; /// ErrorBar geom - error bars (confidence intervals) #[derive(Debug, Clone, Copy)] @@ -22,11 +22,19 @@ impl GeomTrait for ErrorBar { ("xmin", DefaultAestheticValue::Null), ("xmax", DefaultAestheticValue::Null), ("stroke", DefaultAestheticValue::String("black")), - ("linewidth", DefaultAestheticValue::Number(1.0)), ("opacity", DefaultAestheticValue::Number(1.0)), + ("linewidth", DefaultAestheticValue::Number(1.0)), + ("linetype", DefaultAestheticValue::String("solid")), ], } } + + fn default_params(&self) -> &'static [super::DefaultParam] { + &[DefaultParam { + name: "width", + default: DefaultParamValue::Number(10.0), + }] + } } impl std::fmt::Display for ErrorBar { diff --git a/src/writer/vegalite/layer.rs b/src/writer/vegalite/layer.rs index e75a8f59..ddef02c0 100644 --- a/src/writer/vegalite/layer.rs +++ b/src/writer/vegalite/layer.rs @@ -9,6 +9,7 @@ use crate::plot::layer::geom::GeomType; use crate::plot::ParameterValue; +use crate::writer::vegalite::POINTS_TO_PIXELS; use crate::{naming, AestheticValue, DataFrame, Geom, GgsqlError, Layer, Result}; use polars::prelude::ChunkCompareEq; use serde_json::{json, Map, Value}; @@ -43,6 +44,7 @@ pub fn geom_to_mark(geom: &Geom) -> Value { GeomType::HLine => "rule", GeomType::VLine => "rule", GeomType::AbLine => "rule", + GeomType::ErrorBar => "rule", _ => "point", // Default fallback }; json!({ @@ -705,6 +707,113 @@ impl GeomRenderer for ViolinRenderer { } } +// ============================================================================= +// Errorbar Renderer +// ============================================================================= + +struct ErrorBarRenderer; + +impl GeomRenderer for ErrorBarRenderer { + fn modify_encoding( + &self, + encoding: &mut Map, + _layer: &Layer, + _context: &RenderContext, + ) -> Result<()> { + // Check combinations of aesthetics + let has_x = encoding.contains_key("x"); + let has_y = encoding.contains_key("y"); + if has_x && has_y { + Err(GgsqlError::ValidationError( + "In errorbar layer, the `x` and `y` aesthetics are mutually exclusive".to_string(), + )) + } else if has_x && (encoding.contains_key("xmin") || encoding.contains_key("xmax")) { + Err(GgsqlError::ValidationError("In errorbar layer, cannot use `x` aesthetic with `xmin` and `xmax`. `x` must be used with `ymin` and `ymax`.".to_string())) + } else if has_y && (encoding.contains_key("ymin") || encoding.contains_key("ymax")) { + Err(GgsqlError::ValidationError("In errorbar layer, cannot use `y` aesthetic with `ymin` and `ymax`. `y` must be used with `xmin` and `xmax`.".to_string())) + } else if has_x { + if let Some(ymax) = encoding.remove("ymax") { + encoding.insert("y".to_string(), ymax); + } + if let Some(ymin) = encoding.remove("ymin") { + encoding.insert("y2".to_string(), ymin); + } + Ok(()) + } else if has_y { + if let Some(xmax) = encoding.remove("xmax") { + encoding.insert("x".to_string(), xmax); + } + if let Some(xmin) = encoding.remove("xmin") { + encoding.insert("x2".to_string(), xmin); + } + Ok(()) + } else { + Err(GgsqlError::ValidationError( + "In errorbar layer, aesthetics are incomplete. Either use `x`/`ymin`/`ymax` or `y`/`xmin`/`xmax` combinations.".to_string() + )) + } + } + + fn finalize( + &self, + layer_spec: Value, + layer: &Layer, + _data_key: &str, + _prepared: &PreparedData, + ) -> Result> { + // Get width parameter (in points) + let width = if let Some(ParameterValue::Number(num)) = layer.parameters.get("width") { + (*num) * POINTS_TO_PIXELS + } else { + // If no width specified, return just the main error bar without hinges + return Ok(vec![layer_spec]); + }; + + let mut layers = vec![layer_spec.clone()]; + + // Determine if this is a vertical or horizontal error bar and set up parameters + let is_vertical = layer_spec["encoding"]["x2"].is_null(); + let (orient, position, min_field, max_field) = if is_vertical { + ( + "horizontal", + "y", + naming::aesthetic_column("ymin"), + naming::aesthetic_column("ymax"), + ) + } else { + ( + "vertical", + "x", + naming::aesthetic_column("xmin"), + naming::aesthetic_column("xmax"), + ) + }; + + // First hinge (at min position) + let mut hinge = layer_spec.clone(); + hinge["mark"] = json!({ + "type": "tick", + "orient": orient, + "size": width, + "thickness": 0, + "clip": true + }); + hinge["encoding"][position]["field"] = json!(min_field); + // Remove x2 and y2 (not needed for tick mark) + if let Some(e) = hinge["encoding"].as_object_mut() { + e.remove("x2"); + e.remove("y2"); + } + layers.push(hinge.clone()); + + // Second hinge (at max position) - reuse first hinge and only change position field + hinge["encoding"][position]["field"] = json!(max_field); + layers.push(hinge); + + Ok(layers) + } +} + // ============================================================================= // Boxplot Renderer // ============================================================================= @@ -1052,6 +1161,7 @@ pub fn get_renderer(geom: &Geom) -> Box { GeomType::Violin => Box::new(ViolinRenderer), GeomType::Segment => Box::new(SegmentRenderer), GeomType::AbLine => Box::new(ABLineRenderer), + GeomType::ErrorBar => Box::new(ErrorBarRenderer), // All other geoms (Point, Line, Tile, etc.) use the default renderer _ => Box::new(DefaultRenderer), } @@ -1436,4 +1546,181 @@ mod tests { "Should have slopes 1, 2, and 3" ); } + + #[test] + fn test_errorbar_encoding() { + let renderer = ErrorBarRenderer; + let layer = Layer::new(crate::plot::Geom::errorbar()); + let context = RenderContext::new(&[]); + + // Case 1: Vertical errorbar (x + ymin + ymax) + // Should map ymax → y and ymin → y2 + let mut encoding = serde_json::Map::new(); + encoding.insert( + "x".to_string(), + json!({"field": "species", "type": "nominal"}), + ); + encoding.insert( + "ymin".to_string(), + json!({"field": "low", "type": "quantitative"}), + ); + encoding.insert( + "ymax".to_string(), + json!({"field": "high", "type": "quantitative"}), + ); + + renderer + .modify_encoding(&mut encoding, &layer, &context) + .unwrap(); + + assert_eq!( + encoding.get("y"), + Some(&json!({"field": "high", "type": "quantitative"})), + "ymax should be mapped to y" + ); + assert_eq!( + encoding.get("y2"), + Some(&json!({"field": "low", "type": "quantitative"})), + "ymin should be mapped to y2" + ); + assert!(!encoding.contains_key("ymin"), "ymin should be removed"); + assert!(!encoding.contains_key("ymax"), "ymax should be removed"); + + // Case 2: Horizontal errorbar (y + xmin + xmax) + // Should map xmax → x and xmin → x2 + let mut encoding = serde_json::Map::new(); + encoding.insert( + "y".to_string(), + json!({"field": "species", "type": "nominal"}), + ); + encoding.insert( + "xmin".to_string(), + json!({"field": "low", "type": "quantitative"}), + ); + encoding.insert( + "xmax".to_string(), + json!({"field": "high", "type": "quantitative"}), + ); + + renderer + .modify_encoding(&mut encoding, &layer, &context) + .unwrap(); + + assert_eq!( + encoding.get("x"), + Some(&json!({"field": "high", "type": "quantitative"})), + "xmax should be mapped to x" + ); + assert_eq!( + encoding.get("x2"), + Some(&json!({"field": "low", "type": "quantitative"})), + "xmin should be mapped to x2" + ); + assert!(!encoding.contains_key("xmin"), "xmin should be removed"); + assert!(!encoding.contains_key("xmax"), "xmax should be removed"); + + // Case 3: Error - neither x nor y is present + let mut encoding = serde_json::Map::new(); + encoding.insert( + "xmin".to_string(), + json!({"field": "low", "type": "quantitative"}), + ); + encoding.insert( + "xmax".to_string(), + json!({"field": "high", "type": "quantitative"}), + ); + + let result = renderer.modify_encoding(&mut encoding, &layer, &context); + assert!( + result.is_err(), + "Should error when neither x nor y is present" + ); + assert!( + result + .unwrap_err() + .to_string() + .contains("aesthetics are incomplete"), + "Error message should mention incomplete aesthetics" + ); + + // Case 4: Error - both x and y present + let mut encoding = serde_json::Map::new(); + encoding.insert( + "x".to_string(), + json!({"field": "x_col", "type": "quantitative"}), + ); + encoding.insert( + "y".to_string(), + json!({"field": "y_col", "type": "quantitative"}), + ); + + let result = renderer.modify_encoding(&mut encoding, &layer, &context); + assert!( + result.is_err(), + "Should error when both x and y are present" + ); + assert!( + result + .unwrap_err() + .to_string() + .contains("mutually exclusive"), + "Error message should mention mutual exclusivity" + ); + + // Case 5: Error - x with xmin/xmax + let mut encoding = serde_json::Map::new(); + encoding.insert( + "x".to_string(), + json!({"field": "species", "type": "nominal"}), + ); + encoding.insert( + "xmin".to_string(), + json!({"field": "low", "type": "quantitative"}), + ); + encoding.insert( + "xmax".to_string(), + json!({"field": "high", "type": "quantitative"}), + ); + + let result = renderer.modify_encoding(&mut encoding, &layer, &context); + assert!( + result.is_err(), + "Should error when x is used with xmin/xmax" + ); + assert!( + result + .unwrap_err() + .to_string() + .contains("cannot use `x` aesthetic with `xmin` and `xmax`"), + "Error message should mention conflicting aesthetics" + ); + + // Case 6: Error - y with ymin/ymax + let mut encoding = serde_json::Map::new(); + encoding.insert( + "y".to_string(), + json!({"field": "species", "type": "nominal"}), + ); + encoding.insert( + "ymin".to_string(), + json!({"field": "low", "type": "quantitative"}), + ); + encoding.insert( + "ymax".to_string(), + json!({"field": "high", "type": "quantitative"}), + ); + + let result = renderer.modify_encoding(&mut encoding, &layer, &context); + assert!( + result.is_err(), + "Should error when y is used with ymin/ymax" + ); + assert!( + result + .unwrap_err() + .to_string() + .contains("cannot use `y` aesthetic with `ymin` and `ymax`"), + "Error message should mention conflicting aesthetics" + ); + } } From 3a3885f772a68f1a8113f7d1d618f9f3c51fbb70 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Fri, 27 Feb 2026 12:17:46 +0100 Subject: [PATCH 10/12] add docs for errorbar --- doc/syntax/index.qmd | 1 + doc/syntax/layer/errorbar.qmd | 71 +++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 doc/syntax/layer/errorbar.qmd diff --git a/doc/syntax/index.qmd b/doc/syntax/index.qmd index 704b33bd..c4ce7c2b 100644 --- a/doc/syntax/index.qmd +++ b/doc/syntax/index.qmd @@ -30,6 +30,7 @@ There are many different layers to choose from when visualising your data. Some - [`violin`](layer/violin.qmd) displays a rotated kernel density estimate. - [`histogram`](layer/histogram.qmd) bins the data along the x axis and produces a bar for each bin showing the number of records in it. - [`boxplot`](layer/boxplot.qmd) displays continuous variables as 5-number summaries. +- [`errorbar`](layer/errorbar.qmd) a line segment with hinges at the endpoints. ## Scales diff --git a/doc/syntax/layer/errorbar.qmd b/doc/syntax/layer/errorbar.qmd new file mode 100644 index 00000000..bfcfcc71 --- /dev/null +++ b/doc/syntax/layer/errorbar.qmd @@ -0,0 +1,71 @@ +--- +title: "Errorbar" +--- + +> 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. + +Errorbars are used to display paired metrics, typically some interval, for a variable. It is displayed as a line between the two values, often with hinges at the ends. + +## Aesthetics +The following aesthetics are recognised by the errorbar layer. + +### Required +* `x` or `y`: Position on the x- or y-axis. These are mutually exclusive. +* `xmin` or `ymin`: Position of one of the interval ends orthogonal to the main position. These are also mutually exclusive. +* `xmax` or `ymax`: Position of the other interval end orthogonal to the main position. These are also mutually exclusive. + +Note that the required aesthetics is either a set of {`x`, `ymin`, `ymax`} or {`y`, `xmin`, `xmax`} and *not* a combination of the two. + +### Optional +* `stroke`/`colour`: The colour of the lines in the errorbar. +* `opacity`: The opacity of the colour. +* `linewidth`: The width of the lines in the errorbar. +* `linetype`: The dash pattern of the lines in the errorbar. + +## Settings +* `width`: The width of the hinges in points. Can be set to `null` to not display hinges. + +## Data transformation +The errorbar layer does not transform its data but passes it through unchanged. + +## Examples + +```{ggsql} +#| code-fold: true +#| code-summary: "Create example data" +CREATE TABLE penguin_summary AS +SELECT + species, + MEAN(bill_dep) - STDDEV(bill_dep) AS low, + MEAN(bill_dep) AS mean, + MEAN(bill_dep) + STDDEV(bill_dep) AS high +FROM ggsql:penguins +GROUP BY species +``` + +Classic errorbar with point at centre. + +```{ggsql} +VISUALISE species AS x FROM penguin_summary + DRAW errorbar MAPPING low AS ymax, high AS ymin + DRAW point MAPPING mean AS y +``` + +Dynamite plot using bars instead of points, using extra wide hinges. + +```{ggsql} +VISUALISE species AS x FROM penguin_summary + DRAW errorbar + MAPPING low AS ymax, high AS ymin + SETTING width => 40 + DRAW bar MAPPING mean AS y +``` + +The hinges can be omitted by setting `null` as width. + +```{ggsql} +VISUALISE species AS x FROM penguin_summary + DRAW errorbar + MAPPING low AS ymax, high AS ymin + SETTING width => null +``` \ No newline at end of file From cafd6c23e5a1f8d73f0ff46668890aaf37ab2db5 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Mon, 2 Mar 2026 13:46:39 +0100 Subject: [PATCH 11/12] unite hline/vline into rule --- CLAUDE.md | 4 +- doc/ggsql.xml | 3 +- doc/syntax/index.qmd | 3 +- doc/syntax/layer/{hline.qmd => rule.qmd} | 31 ++++++++----- doc/syntax/layer/vline.qmd | 50 --------------------- ggsql-vscode/syntaxes/ggsql.tmLanguage.json | 2 +- src/parser/builder.rs | 3 +- src/plot/layer/geom/mod.rs | 26 ++++------- src/plot/layer/geom/{hline.rs => rule.rs} | 17 +++---- src/plot/layer/geom/vline.rs | 32 ------------- src/plot/main.rs | 2 - src/writer/vegalite/layer.rs | 34 +++++++++++++- tree-sitter-ggsql/grammar.js | 2 +- tree-sitter-ggsql/queries/highlights.scm | 3 +- 14 files changed, 78 insertions(+), 134 deletions(-) rename doc/syntax/layer/{hline.qmd => rule.qmd} (51%) delete mode 100644 doc/syntax/layer/vline.qmd rename src/plot/layer/geom/{hline.rs => rule.rs} (67%) delete mode 100644 src/plot/layer/geom/vline.rs diff --git a/CLAUDE.md b/CLAUDE.md index 1d4473e6..ee39965b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -334,7 +334,7 @@ pub enum Geom { // Statistical geoms Histogram, Density, Smooth, Boxplot, Violin, // Annotation geoms - Text, Label, Segment, Arrow, HLine, VLine, AbLine, ErrorBar, + Text, Label, Segment, Arrow, Rule, AbLine, ErrorBar, } pub enum AestheticValue { @@ -1202,7 +1202,7 @@ All clauses (MAPPING, SETTING, PARTITION BY, FILTER) are optional. - **Basic**: `point`, `line`, `path`, `bar`, `col`, `area`, `tile`, `polygon`, `ribbon` - **Statistical**: `histogram`, `density`, `smooth`, `boxplot`, `violin` -- **Annotation**: `text`, `label`, `segment`, `arrow`, `hline`, `vline`, `abline`, `errorbar` +- **Annotation**: `text`, `label`, `segment`, `arrow`, `rule`, `abline`, `errorbar` **MAPPING Clause** (Aesthetic Mappings): diff --git a/doc/ggsql.xml b/doc/ggsql.xml index 6667c50b..2d121711 100644 --- a/doc/ggsql.xml +++ b/doc/ggsql.xml @@ -141,8 +141,7 @@ label segment arrow - hline - vline + rule abline errorbar diff --git a/doc/syntax/index.qmd b/doc/syntax/index.qmd index c4ce7c2b..786450ab 100644 --- a/doc/syntax/index.qmd +++ b/doc/syntax/index.qmd @@ -20,8 +20,7 @@ There are many different layers to choose from when visualising your data. Some - [`path`](layer/path.qmd) is like `line` above but does not sort the data but plot it according to its own order. - [`segment`](layer/segment.qmd) connects two points with a line segment. - [`abline`](layer/abline.qmd) draws a long line parameterised by slope and intercept. -- [`hline`](layer/hline.qmd) draws a long horizontal line. -- [`vline`](layer/vline.qmd) draws a long vertical line. +- [`rule`](layer/rule.qmd) draws horizontal and vertical reference lines. - [`area`](layer/area.qmd) is used to display series as an area chart. - [`ribbon`](layer/ribbon.qmd) is used to display series extrema. - [`polygon`](layer/polygon.qmd) is used to display arbitrary shapes as polygons. diff --git a/doc/syntax/layer/hline.qmd b/doc/syntax/layer/rule.qmd similarity index 51% rename from doc/syntax/layer/hline.qmd rename to doc/syntax/layer/rule.qmd index b4b25a0e..b2f1fc98 100644 --- a/doc/syntax/layer/hline.qmd +++ b/doc/syntax/layer/rule.qmd @@ -4,13 +4,16 @@ title: "H 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 hline layer is used to draw horizontal reference lines at specified y-values. This is useful for adding thresholds, means, medians, or other horizontal guides to plots. The lines extend across the full extent of the x-axis, regardless of the data range. +The rule layer is used to draw horizontal or vertical reference lines at specified values. This is useful for adding thresholds, means, medians, avent markers, cutoff dates or other guides to the plot. The lines span the full width or height of the panels. ## Aesthetics The following aesthetics are recognised by the hline layer. ### Required -* `y`: The y-coordinate for the horizontal line +* `x`\*: The x-coordinate for the vertical line. +* `y`\*: The y-coordinate for the horizontal line + +\* Exactly one of `x` or `y` is required, not both. ### Optional * `colour`/`stroke`: The colour of the line @@ -19,10 +22,10 @@ The following aesthetics are recognised by the hline layer. * `linetype`: The type of the line, i.e. the dashing pattern ## Settings -The hline layer has no additional settings. +The rule layer has no additional settings. ## Data transformation -The hline layer does not transform its data but passes it through unchanged. +The rule layer does not transform its data but passes it through unchanged. ## Examples @@ -33,9 +36,17 @@ SELECT Date AS date, temp AS temperature FROM ggsql:airquality WHERE Month = 5 -VISUALISE date AS x, temperature AS y - DRAW line - DRAW hline MAPPING 70 AS y +VISUALISE + DRAW line MAPPING date AS x, temperature AS y + DRAW rule MAPPING 70 AS y +``` + +Add a vertical line to mark a specific value: + +```{ggsql} +VISUALISE FROM ggsql:penguins + DRAW point MAPPING bill_len AS x, bill_dep AS y + DRAW rule MAPPING 45 AS x ``` Add multiple threshold lines with different colors: @@ -52,7 +63,7 @@ SELECT Date AS date, temp AS temperature FROM ggsql:airquality WHERE Month = 5 -VISUALISE date AS x, temperature AS y - DRAW line - DRAW hline MAPPING value AS y, label AS colour FROM thresholds +VISUALISE + DRAW line MAPPING date AS x, temperature AS y + DRAW rule MAPPING value AS y, label AS colour FROM thresholds ``` diff --git a/doc/syntax/layer/vline.qmd b/doc/syntax/layer/vline.qmd deleted file mode 100644 index ef3e0936..00000000 --- a/doc/syntax/layer/vline.qmd +++ /dev/null @@ -1,50 +0,0 @@ ---- -title: "V 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 vline layer is used to draw vertical reference lines at specified x-values. This is useful for adding event markers, cutoff dates, or other vertical guides to plots. The lines extend across the full extent of the y-axis, regardless of the data range. - -## Aesthetics -The following aesthetics are recognised by the vline layer. - -### Required -* `x`: The x-coordinate for the vertical line - -### 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 -The vline layer has no additional settings. - -## Data transformation -The hline layer does not transform its data but passes it through unchanged. - -## Examples - -Add a vertical line to mark a specific value: - -```{ggsql} -VISUALISE FROM ggsql:penguins - DRAW point MAPPING bill_len AS x, bill_dep AS y - DRAW vline MAPPING 45 AS x -``` - -Add multiple reference lines with different colors: - -```{ggsql} -WITH markers AS ( - SELECT * FROM (VALUES - (40, 'Threshold 1'), - (45, 'Threshold 2'), - (50, 'Threshold 3') - ) AS t(position, label) -) -VISUALISE FROM ggsql:penguins - DRAW point MAPPING bill_len AS x, bill_dep AS y - DRAW vline MAPPING position AS x, label AS colour FROM markers -``` diff --git a/ggsql-vscode/syntaxes/ggsql.tmLanguage.json b/ggsql-vscode/syntaxes/ggsql.tmLanguage.json index 7055f3be..b87c0c19 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|tile|polygon|ribbon|histogram|density|smooth|boxplot|violin|text|label|segment|arrow|rule|abline|errorbar)\\b" }, { "include": "#common-clause-patterns" } ] diff --git a/src/parser/builder.rs b/src/parser/builder.rs index 0cc4ec68..930a5fc9 100644 --- a/src/parser/builder.rs +++ b/src/parser/builder.rs @@ -576,8 +576,7 @@ fn parse_geom_type(text: &str) -> Result { "label" => Ok(Geom::label()), "segment" => Ok(Geom::segment()), "arrow" => Ok(Geom::arrow()), - "hline" => Ok(Geom::hline()), - "vline" => Ok(Geom::vline()), + "rule" => Ok(Geom::rule()), "abline" => Ok(Geom::abline()), "errorbar" => Ok(Geom::errorbar()), _ => Err(GgsqlError::ParseError(format!( diff --git a/src/plot/layer/geom/mod.rs b/src/plot/layer/geom/mod.rs index 38aa90ca..76be05e2 100644 --- a/src/plot/layer/geom/mod.rs +++ b/src/plot/layer/geom/mod.rs @@ -36,19 +36,18 @@ mod boxplot; mod density; mod errorbar; mod histogram; -mod hline; mod label; mod line; mod path; mod point; mod polygon; mod ribbon; +mod rule; mod segment; mod smooth; mod text; mod tile; mod violin; -mod vline; // Re-export types pub use types::{DefaultAesthetics, DefaultParam, DefaultParamValue, StatResult}; @@ -65,19 +64,18 @@ pub use boxplot::Boxplot; pub use density::Density; pub use errorbar::ErrorBar; pub use histogram::Histogram; -pub use hline::HLine; pub use label::Label; pub use line::Line; pub use path::Path; pub use point::Point; pub use polygon::Polygon; pub use ribbon::Ribbon; +pub use rule::Rule; pub use segment::Segment; pub use smooth::Smooth; pub use text::Text; pub use tile::Tile; pub use violin::Violin; -pub use vline::VLine; use crate::plot::types::{DefaultAestheticValue, ParameterValue, Schema}; @@ -102,8 +100,7 @@ pub enum GeomType { Label, Segment, Arrow, - HLine, - VLine, + Rule, AbLine, ErrorBar, } @@ -128,8 +125,7 @@ impl std::fmt::Display for GeomType { GeomType::Label => "label", GeomType::Segment => "segment", GeomType::Arrow => "arrow", - GeomType::HLine => "hline", - GeomType::VLine => "vline", + GeomType::Rule => "rule", GeomType::AbLine => "abline", GeomType::ErrorBar => "errorbar", }; @@ -314,14 +310,9 @@ impl Geom { Self(Arc::new(Arrow)) } - /// Create an HLine geom - pub fn hline() -> Self { - Self(Arc::new(HLine)) - } - - /// Create a VLine geom - pub fn vline() -> Self { - Self(Arc::new(VLine)) + /// Create an Rule geom + pub fn rule() -> Self { + Self(Arc::new(Rule)) } /// Create an AbLine geom @@ -354,8 +345,7 @@ impl Geom { GeomType::Label => Self::label(), GeomType::Segment => Self::segment(), GeomType::Arrow => Self::arrow(), - GeomType::HLine => Self::hline(), - GeomType::VLine => Self::vline(), + GeomType::Rule => Self::rule(), GeomType::AbLine => Self::abline(), GeomType::ErrorBar => Self::errorbar(), } diff --git a/src/plot/layer/geom/hline.rs b/src/plot/layer/geom/rule.rs similarity index 67% rename from src/plot/layer/geom/hline.rs rename to src/plot/layer/geom/rule.rs index 06c41a6e..302d84c7 100644 --- a/src/plot/layer/geom/hline.rs +++ b/src/plot/layer/geom/rule.rs @@ -1,21 +1,22 @@ -//! HLine geom implementation +//! Rule geom implementation use super::{DefaultAesthetics, GeomTrait, GeomType}; use crate::plot::types::DefaultAestheticValue; -/// HLine geom - horizontal reference lines +/// Rule geom - horizontal and vertical reference lines #[derive(Debug, Clone, Copy)] -pub struct HLine; +pub struct Rule; -impl GeomTrait for HLine { +impl GeomTrait for Rule { fn geom_type(&self) -> GeomType { - GeomType::HLine + GeomType::Rule } fn aesthetics(&self) -> DefaultAesthetics { DefaultAesthetics { defaults: &[ - ("y", DefaultAestheticValue::Required), + ("x", DefaultAestheticValue::Null), + ("y", DefaultAestheticValue::Null), ("stroke", DefaultAestheticValue::String("black")), ("linewidth", DefaultAestheticValue::Number(1.0)), ("opacity", DefaultAestheticValue::Number(1.0)), @@ -25,8 +26,8 @@ impl GeomTrait for HLine { } } -impl std::fmt::Display for HLine { +impl std::fmt::Display for Rule { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "hline") + write!(f, "rule") } } diff --git a/src/plot/layer/geom/vline.rs b/src/plot/layer/geom/vline.rs deleted file mode 100644 index ba73cb04..00000000 --- a/src/plot/layer/geom/vline.rs +++ /dev/null @@ -1,32 +0,0 @@ -//! VLine geom implementation - -use super::{DefaultAesthetics, GeomTrait, GeomType}; -use crate::plot::types::DefaultAestheticValue; - -/// VLine geom - vertical reference lines -#[derive(Debug, Clone, Copy)] -pub struct VLine; - -impl GeomTrait for VLine { - fn geom_type(&self) -> GeomType { - GeomType::VLine - } - - fn aesthetics(&self) -> DefaultAesthetics { - DefaultAesthetics { - defaults: &[ - ("x", DefaultAestheticValue::Required), - ("stroke", DefaultAestheticValue::String("black")), - ("linewidth", DefaultAestheticValue::Number(1.0)), - ("opacity", DefaultAestheticValue::Number(1.0)), - ("linetype", DefaultAestheticValue::String("solid")), - ], - } - } -} - -impl std::fmt::Display for VLine { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "vline") - } -} diff --git a/src/plot/main.rs b/src/plot/main.rs index 3e9d5a3c..a934530a 100644 --- a/src/plot/main.rs +++ b/src/plot/main.rs @@ -421,8 +421,6 @@ mod tests { assert_eq!(Geom::segment().aesthetics().required(), &["x", "y"]); // Reference lines - assert_eq!(Geom::hline().aesthetics().required(), &["y"]); - assert_eq!(Geom::vline().aesthetics().required(), &["x"]); assert_eq!( Geom::abline().aesthetics().required(), &["slope", "intercept"] diff --git a/src/writer/vegalite/layer.rs b/src/writer/vegalite/layer.rs index ddef02c0..a17a11ee 100644 --- a/src/writer/vegalite/layer.rs +++ b/src/writer/vegalite/layer.rs @@ -41,8 +41,7 @@ pub fn geom_to_mark(geom: &Geom) -> Value { GeomType::Text => "text", GeomType::Label => "text", GeomType::Segment => "rule", - GeomType::HLine => "rule", - GeomType::VLine => "rule", + GeomType::Rule => "rule", GeomType::AbLine => "rule", GeomType::ErrorBar => "rule", _ => "point", // Default fallback @@ -339,6 +338,36 @@ impl GeomRenderer for SegmentRenderer { } } +// ============================================================================= +// Rule Renderer +// ============================================================================= + +pub struct RuleRenderer; + +impl GeomRenderer for RuleRenderer { + fn modify_encoding( + &self, + encoding: &mut Map, + _layer: &Layer, + _context: &RenderContext, + ) -> Result<()> { + let has_x = encoding.contains_key("x"); + let has_y = encoding.contains_key("y"); + if !has_x && !has_y { + 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 { + return Err(GgsqlError::ValidationError( + "The `rule` layer requires exactly one of the `x` or `y` aesthetic, not both." + .to_string(), + )); + } + Ok(()) + } +} + // ============================================================================= // ABLine Renderer // ============================================================================= @@ -1162,6 +1191,7 @@ pub fn get_renderer(geom: &Geom) -> Box { GeomType::Segment => Box::new(SegmentRenderer), GeomType::AbLine => Box::new(ABLineRenderer), GeomType::ErrorBar => Box::new(ErrorBarRenderer), + GeomType::Rule => Box::new(RuleRenderer), // All other geoms (Point, Line, Tile, etc.) use the default renderer _ => Box::new(DefaultRenderer), } diff --git a/tree-sitter-ggsql/grammar.js b/tree-sitter-ggsql/grammar.js index 5b561d50..64e45255 100644 --- a/tree-sitter-ggsql/grammar.js +++ b/tree-sitter-ggsql/grammar.js @@ -472,7 +472,7 @@ module.exports = grammar({ geom_type: $ => choice( 'point', 'line', 'path', 'bar', 'area', 'tile', 'polygon', 'ribbon', 'histogram', 'density', 'smooth', 'boxplot', 'violin', - 'text', 'label', 'segment', 'arrow', 'hline', 'vline', 'abline', 'errorbar' + 'text', 'label', 'segment', 'arrow', 'rule', 'abline', '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 30e40dda..6b62a8d7 100644 --- a/tree-sitter-ggsql/queries/highlights.scm +++ b/tree-sitter-ggsql/queries/highlights.scm @@ -24,8 +24,7 @@ "label" "segment" "arrow" - "hline" - "vline" + "rule" "abline" "errorbar" ] @type.builtin From cdac6a1a326b98ab8167a2b49d7b2b5218a5d1c8 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Tue, 3 Mar 2026 10:57:25 +0100 Subject: [PATCH 12/12] rename abline/slope to linear/coef --- CLAUDE.md | 4 +- doc/ggsql.xml | 2 +- doc/syntax/index.qmd | 2 +- doc/syntax/layer/{abline.qmd => linear.qmd} | 22 ++--- ggsql-vscode/syntaxes/ggsql.tmLanguage.json | 2 +- src/parser/builder.rs | 2 +- src/plot/layer/geom/{abline.rs => linear.rs} | 16 ++-- src/plot/layer/geom/mod.rs | 16 ++-- src/plot/main.rs | 4 +- src/writer/vegalite/layer.rs | 84 ++++++++++---------- tree-sitter-ggsql/grammar.js | 4 +- tree-sitter-ggsql/queries/highlights.scm | 2 +- 12 files changed, 78 insertions(+), 82 deletions(-) rename doc/syntax/layer/{abline.qmd => linear.qmd} (62%) rename src/plot/layer/geom/{abline.rs => linear.rs} (72%) diff --git a/CLAUDE.md b/CLAUDE.md index 9d7d6a88..b1a43363 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -334,7 +334,7 @@ pub enum Geom { // Statistical geoms Histogram, Density, Smooth, Boxplot, Violin, // Annotation geoms - Text, Label, Segment, Arrow, Rule, AbLine, ErrorBar, + Text, Label, Segment, Arrow, Rule, Linear, ErrorBar, } pub enum AestheticValue { @@ -1202,7 +1202,7 @@ All clauses (MAPPING, SETTING, PARTITION BY, FILTER) are optional. - **Basic**: `point`, `line`, `path`, `bar`, `col`, `area`, `tile`, `polygon`, `ribbon` - **Statistical**: `histogram`, `density`, `smooth`, `boxplot`, `violin` -- **Annotation**: `text`, `label`, `segment`, `arrow`, `rule`, `abline`, `errorbar` +- **Annotation**: `text`, `label`, `segment`, `arrow`, `rule`, `linear`, `errorbar` **MAPPING Clause** (Aesthetic Mappings): diff --git a/doc/ggsql.xml b/doc/ggsql.xml index e1c6fead..36769a9e 100644 --- a/doc/ggsql.xml +++ b/doc/ggsql.xml @@ -142,7 +142,7 @@ segment arrow rule - abline + linear errorbar diff --git a/doc/syntax/index.qmd b/doc/syntax/index.qmd index b521c03f..9ce4bcc8 100644 --- a/doc/syntax/index.qmd +++ b/doc/syntax/index.qmd @@ -19,7 +19,7 @@ There are many different layers to choose from when visualising your data. Some - [`line`](layer/line.qmd) is used to produce lineplots with the data sorted along the x axis. - [`path`](layer/path.qmd) is like `line` above but does not sort the data but plot it according to its own order. - [`segment`](layer/segment.qmd) connects two points with a line segment. -- [`abline`](layer/abline.qmd) draws a long line parameterised by slope and intercept. +- [`linear`](layer/linear.qmd) draws a long line parameterised by a coefficient and intercept. - [`rule`](layer/rule.qmd) draws horizontal and vertical reference lines. - [`area`](layer/area.qmd) is used to display series as an area chart. - [`ribbon`](layer/ribbon.qmd) is used to display series extrema. diff --git a/doc/syntax/layer/abline.qmd b/doc/syntax/layer/linear.qmd similarity index 62% rename from doc/syntax/layer/abline.qmd rename to doc/syntax/layer/linear.qmd index 9c7edb68..fa9871f0 100644 --- a/doc/syntax/layer/abline.qmd +++ b/doc/syntax/layer/linear.qmd @@ -1,23 +1,23 @@ --- -title: "AB Line" +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 abline layer is used to draw diagonal reference lines based on a slope 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 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 + bx +y = a + \beta x $$ -Where $a$ is the `intercept` and $b$ is the `slope`. +Where $a$ is the `intercept` and $\beta$ is the `coef`. ## Aesthetics The following aesthetics are recognised by the abline layer. ### Required -* `slope`: The slope of the line i.e. the amount $y$ increases for every unit of $x$. +* `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 @@ -27,10 +27,10 @@ The following aesthetics are recognised by the abline layer. * `linetype`: The type of the line, i.e. the dashing pattern ## Settings -The abline layer has no additional settings. +The linear layer has no additional settings. ## Data transformation -The abline layer does not transform its data but passes it through unchanged. +The linear layer does not transform its data but passes it through unchanged. ## Examples @@ -39,7 +39,7 @@ Add a simple reference line to a scatterplot: ```{ggsql} VISUALISE FROM ggsql:penguins DRAW point MAPPING bill_len AS x, bill_dep AS y - DRAW abline MAPPING 0.4 AS slope, -1 AS intercept + DRAW linear MAPPING 0.4 AS coef, -1 AS intercept ``` Add multiple reference lines with different colors from a separate dataset: @@ -50,13 +50,13 @@ WITH lines AS ( (0.4, -1, 'Line A'), (0.2, 8, 'Line B'), (0.8, -19, 'Line C') - ) AS t(slope, intercept, label) + ) AS t(coef, intercept, label) ) VISUALISE FROM ggsql:penguins DRAW point MAPPING bill_len AS x, bill_dep AS y - DRAW abline + DRAW linear MAPPING - slope AS slope, + 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 fb735d6e..55a37530 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|rule|abline|errorbar)\\b" + "match": "\\b(point|line|path|bar|col|area|tile|polygon|ribbon|histogram|density|smooth|boxplot|violin|text|label|segment|arrow|rule|linear|errorbar)\\b" }, { "include": "#common-clause-patterns" } ] diff --git a/src/parser/builder.rs b/src/parser/builder.rs index f940923b..49fbc257 100644 --- a/src/parser/builder.rs +++ b/src/parser/builder.rs @@ -596,7 +596,7 @@ fn parse_geom_type(text: &str) -> Result { "segment" => Ok(Geom::segment()), "arrow" => Ok(Geom::arrow()), "rule" => Ok(Geom::rule()), - "abline" => Ok(Geom::abline()), + "linear" => Ok(Geom::linear()), "errorbar" => Ok(Geom::errorbar()), _ => Err(GgsqlError::ParseError(format!( "Unknown geom type: {}", diff --git a/src/plot/layer/geom/abline.rs b/src/plot/layer/geom/linear.rs similarity index 72% rename from src/plot/layer/geom/abline.rs rename to src/plot/layer/geom/linear.rs index ff335dc6..7569cff1 100644 --- a/src/plot/layer/geom/abline.rs +++ b/src/plot/layer/geom/linear.rs @@ -1,21 +1,21 @@ -//! AbLine geom implementation +//! Linear geom implementation use super::{DefaultAesthetics, GeomTrait, GeomType}; use crate::plot::types::DefaultAestheticValue; -/// AbLine geom - lines with slope and intercept +/// Linear geom - lines with coefficient and intercept #[derive(Debug, Clone, Copy)] -pub struct AbLine; +pub struct Linear; -impl GeomTrait for AbLine { +impl GeomTrait for Linear { fn geom_type(&self) -> GeomType { - GeomType::AbLine + GeomType::Linear } fn aesthetics(&self) -> DefaultAesthetics { DefaultAesthetics { defaults: &[ - ("slope", DefaultAestheticValue::Required), + ("coef", DefaultAestheticValue::Required), ("intercept", DefaultAestheticValue::Required), ("stroke", DefaultAestheticValue::String("black")), ("linewidth", DefaultAestheticValue::Number(1.0)), @@ -26,8 +26,8 @@ impl GeomTrait for AbLine { } } -impl std::fmt::Display for AbLine { +impl std::fmt::Display for Linear { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "abline") + write!(f, "linear") } } diff --git a/src/plot/layer/geom/mod.rs b/src/plot/layer/geom/mod.rs index 3533a263..828bbb61 100644 --- a/src/plot/layer/geom/mod.rs +++ b/src/plot/layer/geom/mod.rs @@ -28,7 +28,6 @@ use std::sync::Arc; pub mod types; // Geom implementations -mod abline; mod area; mod arrow; mod bar; @@ -38,6 +37,7 @@ mod errorbar; mod histogram; mod label; mod line; +mod linear; mod path; mod point; mod polygon; @@ -53,7 +53,6 @@ mod violin; pub use types::{DefaultAesthetics, DefaultParam, DefaultParamValue, StatResult}; // Re-export geom structs for direct access if needed -pub use abline::AbLine; pub use area::Area; pub use arrow::Arrow; pub use bar::Bar; @@ -63,6 +62,7 @@ pub use errorbar::ErrorBar; pub use histogram::Histogram; pub use label::Label; pub use line::Line; +pub use linear::Linear; pub use path::Path; pub use point::Point; pub use polygon::Polygon; @@ -98,7 +98,7 @@ pub enum GeomType { Segment, Arrow, Rule, - AbLine, + Linear, ErrorBar, } @@ -123,7 +123,7 @@ impl std::fmt::Display for GeomType { GeomType::Segment => "segment", GeomType::Arrow => "arrow", GeomType::Rule => "rule", - GeomType::AbLine => "abline", + GeomType::Linear => "linear", GeomType::ErrorBar => "errorbar", }; write!(f, "{}", s) @@ -312,9 +312,9 @@ impl Geom { Self(Arc::new(Rule)) } - /// Create an AbLine geom - pub fn abline() -> Self { - Self(Arc::new(AbLine)) + /// Create an Linear geom + pub fn linear() -> Self { + Self(Arc::new(Linear)) } /// Create an ErrorBar geom @@ -343,7 +343,7 @@ impl Geom { GeomType::Segment => Self::segment(), GeomType::Arrow => Self::arrow(), GeomType::Rule => Self::rule(), - GeomType::AbLine => Self::abline(), + GeomType::Linear => Self::linear(), GeomType::ErrorBar => Self::errorbar(), } } diff --git a/src/plot/main.rs b/src/plot/main.rs index 9c70efa4..d7d63a26 100644 --- a/src/plot/main.rs +++ b/src/plot/main.rs @@ -510,8 +510,8 @@ mod tests { // Reference lines assert_eq!( - Geom::abline().aesthetics().required(), - &["slope", "intercept"] + Geom::linear().aesthetics().required(), + &["coef", "intercept"] ); // ErrorBar has no strict requirements diff --git a/src/writer/vegalite/layer.rs b/src/writer/vegalite/layer.rs index 7948613b..34409c55 100644 --- a/src/writer/vegalite/layer.rs +++ b/src/writer/vegalite/layer.rs @@ -42,7 +42,7 @@ pub fn geom_to_mark(geom: &Geom) -> Value { GeomType::Label => "text", GeomType::Segment => "rule", GeomType::Rule => "rule", - GeomType::AbLine => "rule", + GeomType::Linear => "rule", GeomType::ErrorBar => "rule", _ => "point", // Default fallback }; @@ -369,13 +369,13 @@ impl GeomRenderer for RuleRenderer { } // ============================================================================= -// ABLine Renderer +// Linear Renderer // ============================================================================= -/// Renderer for abline geom - draws lines based on slope and intercept -pub struct ABLineRenderer; +/// Renderer for linear geom - draws lines based on coefficient and intercept +pub struct LinearRenderer; -impl GeomRenderer for ABLineRenderer { +impl GeomRenderer for LinearRenderer { fn prepare_data( &self, df: &DataFrame, @@ -395,8 +395,8 @@ impl GeomRenderer for ABLineRenderer { _layer: &Layer, _context: &RenderContext, ) -> Result<()> { - // Remove slope and intercept from encoding - they're only used in transforms - encoding.remove("slope"); + // Remove coefficient and intercept from encoding - they're only used in transforms + encoding.remove("coef"); encoding.remove("intercept"); // Add x/x2/y/y2 encodings for rule mark @@ -437,8 +437,8 @@ impl GeomRenderer for ABLineRenderer { _layer: &Layer, context: &RenderContext, ) -> Result<()> { - // Field names for slope and intercept (with aesthetic column prefix) - let slope_field = naming::aesthetic_column("slope"); + // Field names for coef and intercept (with aesthetic column prefix) + let coef_field = naming::aesthetic_column("coef"); let intercept_field = naming::aesthetic_column("intercept"); // Get x extent from scale (use pos1, the internal name for the first positional aesthetic) @@ -457,11 +457,11 @@ impl GeomRenderer for ABLineRenderer { "as": "x_max" }, { - "calculate": format!("datum.{} * datum.x_min + datum.{}", slope_field, intercept_field), + "calculate": format!("datum.{} * datum.x_min + datum.{}", coef_field, intercept_field), "as": "y_min" }, { - "calculate": format!("datum.{} * datum.x_max + datum.{}", slope_field, intercept_field), + "calculate": format!("datum.{} * datum.x_max + datum.{}", coef_field, intercept_field), "as": "y_max" } ]); @@ -1189,7 +1189,7 @@ pub fn get_renderer(geom: &Geom) -> Box { GeomType::Density => Box::new(AreaRenderer), GeomType::Violin => Box::new(ViolinRenderer), GeomType::Segment => Box::new(SegmentRenderer), - GeomType::AbLine => Box::new(ABLineRenderer), + GeomType::Linear => Box::new(LinearRenderer), GeomType::ErrorBar => Box::new(ErrorBarRenderer), GeomType::Rule => Box::new(RuleRenderer), // All other geoms (Point, Line, Tile, etc.) use the default renderer @@ -1388,11 +1388,11 @@ mod tests { } #[test] - fn test_abline_renderer_multiple_lines() { + fn test_linear_renderer_multiple_lines() { use crate::reader::{DuckDBReader, Reader}; use crate::writer::{VegaLiteWriter, Writer}; - // Test that abline with 3 different slopes renders 3 separate lines + // Test that linear with 3 different coefficients renders 3 separate lines let query = r#" WITH points AS ( SELECT * FROM (VALUES (0, 5), (5, 15), (10, 25)) AS t(x, y) @@ -1402,12 +1402,12 @@ mod tests { (2, 5, 'A'), (1, 10, 'B'), (3, 0, 'C') - ) AS t(slope, intercept, line_id) + ) AS t(coef, intercept, line_id) ) SELECT * FROM points VISUALISE DRAW point MAPPING x AS x, y AS y - DRAW abline MAPPING slope AS slope, intercept AS intercept, line_id AS color FROM lines + DRAW linear MAPPING coef AS coef, intercept AS intercept, line_id AS color FROM lines "#; // Execute query @@ -1423,21 +1423,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 + abline) + // Verify we have 2 layers (point + linear) let layers = vl_spec["layer"].as_array().expect("No layers found"); - assert_eq!(layers.len(), 2, "Should have 2 layers (point + abline)"); + assert_eq!(layers.len(), 2, "Should have 2 layers (point + linear)"); - // Get the abline layer (second layer) - let abline_layer = &layers[1]; + // Get the linear layer (second layer) + let linear_layer = &layers[1]; // Verify it's a rule mark assert_eq!( - abline_layer["mark"]["type"], "rule", - "ABLine should use rule mark" + linear_layer["mark"]["type"], "rule", + "Linear should use rule mark" ); // Verify transforms exist - let transforms = abline_layer["transform"] + let transforms = linear_layer["transform"] .as_array() .expect("No transforms found"); @@ -1467,7 +1467,7 @@ mod tests { "x_max should have calculate expression" ); - // Verify y_min and y_max transforms use slope and intercept with x_min/x_max + // Verify y_min and y_max transforms use coef and intercept with x_min/x_max let y_min_transform = transforms .iter() .find(|t| t["as"] == "y_min") @@ -1484,10 +1484,10 @@ mod tests { .as_str() .expect("y_max calculate should be string"); - // Should reference slope, intercept, and x_min/x_max + // Should reference coef, intercept, and x_min/x_max assert!( - y_min_calc.contains("__ggsql_aes_slope__"), - "y_min should reference slope" + y_min_calc.contains("__ggsql_aes_coef__"), + "y_min should reference coef" ); assert!( y_min_calc.contains("__ggsql_aes_intercept__"), @@ -1498,8 +1498,8 @@ mod tests { "y_min should reference datum.x_min" ); assert!( - y_max_calc.contains("__ggsql_aes_slope__"), - "y_max should reference slope" + y_max_calc.contains("__ggsql_aes_coef__"), + "y_max should reference coef" ); assert!( y_max_calc.contains("__ggsql_aes_intercept__"), @@ -1511,7 +1511,7 @@ mod tests { ); // Verify encoding has x, x2, y, y2 with consistent field names - let encoding = abline_layer["encoding"] + let encoding = linear_layer["encoding"] .as_object() .expect("No encoding found"); @@ -1544,37 +1544,33 @@ mod tests { "Should have stroke encoding for line_id" ); - // Verify data has 3 abline rows (one per slope) + // Verify data has 3 linear rows (one per coef) let data_values = vl_spec["data"]["values"] .as_array() .expect("No data values found"); - let abline_rows: Vec<_> = data_values + let linear_rows: Vec<_> = data_values .iter() .filter(|row| { row["__ggsql_source__"] == "__ggsql_layer_1__" - && row["__ggsql_aes_slope__"].is_number() + && row["__ggsql_aes_coef__"].is_number() }) .collect(); assert_eq!( - abline_rows.len(), + linear_rows.len(), 3, - "Should have 3 abline rows (3 different slopes)" + "Should have 3 linear rows (3 different coefficients)" ); - // Verify we have slopes 1, 2, 3 - let mut slopes: Vec = abline_rows + // Verify we have coefs 1, 2, 3 + let mut coefs: Vec = linear_rows .iter() - .map(|row| row["__ggsql_aes_slope__"].as_f64().unwrap()) + .map(|row| row["__ggsql_aes_coef__"].as_f64().unwrap()) .collect(); - slopes.sort_by(|a, b| a.partial_cmp(b).unwrap()); + coefs.sort_by(|a, b| a.partial_cmp(b).unwrap()); - assert_eq!( - slopes, - vec![1.0, 2.0, 3.0], - "Should have slopes 1, 2, and 3" - ); + assert_eq!(coefs, vec![1.0, 2.0, 3.0], "Should have coefs 1, 2, and 3"); } #[test] diff --git a/tree-sitter-ggsql/grammar.js b/tree-sitter-ggsql/grammar.js index ef22d8a4..e6583468 100644 --- a/tree-sitter-ggsql/grammar.js +++ b/tree-sitter-ggsql/grammar.js @@ -472,7 +472,7 @@ module.exports = grammar({ geom_type: $ => choice( 'point', 'line', 'path', 'bar', 'area', 'tile', 'polygon', 'ribbon', 'histogram', 'density', 'smooth', 'boxplot', 'violin', - 'text', 'label', 'segment', 'arrow', 'rule', 'abline', 'errorbar' + 'text', 'label', 'segment', 'arrow', 'rule', 'linear', 'errorbar' ), // MAPPING clause for aesthetic mappings: MAPPING col AS x, "blue" AS color [FROM source] @@ -654,7 +654,7 @@ module.exports = grammar({ // Text aesthetics 'label', 'family', 'fontface', 'hjust', 'vjust', // Specialty aesthetics, - 'slope', 'intercept', + 'coef', '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 25b7b8ab..576b19f1 100644 --- a/tree-sitter-ggsql/queries/highlights.scm +++ b/tree-sitter-ggsql/queries/highlights.scm @@ -25,7 +25,7 @@ "segment" "arrow" "rule" - "abline" + "linear" "errorbar" ] @type.builtin