diff --git a/CLAUDE.md b/CLAUDE.md
index 07ea0b0c..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, HLine, VLine, 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`, `hline`, `vline`, `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 d244edd8..36769a9e 100644
--- a/doc/ggsql.xml
+++ b/doc/ggsql.xml
@@ -141,9 +141,8 @@
- label
- segment
- arrow
- - hline
- - vline
- - abline
+ - rule
+ - linear
- errorbar
diff --git a/doc/syntax/index.qmd b/doc/syntax/index.qmd
index 1400280f..9ce4bcc8 100644
--- a/doc/syntax/index.qmd
+++ b/doc/syntax/index.qmd
@@ -15,17 +15,21 @@ 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.
+- [`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.
- [`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.
+- [`errorbar`](layer/errorbar.qmd) a line segment with hinges at the endpoints.
## Scales
A scale is responsible for translating a data value to an aesthetic literal, e.g. a specific color for the fill aesthetic, or a radius in points for the size aesthetic. A scale is a combination of a specific aesthetic and a scale type
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
diff --git a/doc/syntax/layer/linear.qmd b/doc/syntax/layer/linear.qmd
new file mode 100644
index 00000000..fa9871f0
--- /dev/null
+++ b/doc/syntax/layer/linear.qmd
@@ -0,0 +1,63 @@
+---
+title: "Linear line"
+---
+
+> Layers are declared with the [`DRAW` clause](../clause/draw.qmd). Read the documentation for this clause for a thorough description of how to use it.
+
+The linear layer is used to draw diagonal reference lines based on a coefficient and intercept. This is useful for adding regression lines, diagonal guides, or mathematical functions to plots. The lines extend across the full extent of the x-axis, regardless of the data range.
+The layer is named for the following formula:
+
+$$
+y = a + \beta x
+$$
+
+Where $a$ is the `intercept` and $\beta$ is the `coef`.
+
+## Aesthetics
+The following aesthetics are recognised by the abline layer.
+
+### Required
+* `coef`: The coefficient/slope of the line i.e. the amount $y$ increases for every unit of $x$.
+* `intercept`: The intercept where the line crosses the y-axis at $x = 0$.
+
+### Optional
+* `colour`/`stroke`: The colour of the line
+* `opacity`: The opacity of the line
+* `linewidth`: The width of the line
+* `linetype`: The type of the line, i.e. the dashing pattern
+
+## Settings
+The linear layer has no additional settings.
+
+## Data transformation
+The linear 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 linear MAPPING 0.4 AS coef, -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(coef, intercept, label)
+)
+VISUALISE FROM ggsql:penguins
+ DRAW point MAPPING bill_len AS x, bill_dep AS y
+ DRAW linear
+ MAPPING
+ coef AS coef,
+ intercept AS intercept,
+ label AS colour
+ FROM lines
+```
diff --git a/doc/syntax/layer/rule.qmd b/doc/syntax/layer/rule.qmd
new file mode 100644
index 00000000..b2f1fc98
--- /dev/null
+++ b/doc/syntax/layer/rule.qmd
@@ -0,0 +1,69 @@
+---
+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 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
+* `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
+* `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 rule layer has no additional settings.
+
+## Data transformation
+The rule 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
+ 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:
+
+```{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
+ 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/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
diff --git a/ggsql-vscode/syntaxes/ggsql.tmLanguage.json b/ggsql-vscode/syntaxes/ggsql.tmLanguage.json
index b8966255..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|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|linear|errorbar)\\b"
},
{ "include": "#common-clause-patterns" }
]
diff --git a/src/parser/builder.rs b/src/parser/builder.rs
index f7bc9cb1..49fbc257 100644
--- a/src/parser/builder.rs
+++ b/src/parser/builder.rs
@@ -595,9 +595,8 @@ 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()),
- "abline" => Ok(Geom::abline()),
+ "rule" => Ok(Geom::rule()),
+ "linear" => Ok(Geom::linear()),
"errorbar" => Ok(Geom::errorbar()),
_ => Err(GgsqlError::ParseError(format!(
"Unknown geom type: {}",
diff --git a/src/plot/layer/geom/errorbar.rs b/src/plot/layer/geom/errorbar.rs
index 423d56a1..e945e8ec 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 {
("pos1min", DefaultAestheticValue::Null),
("pos1max", 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/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 c953c9f7..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;
@@ -36,25 +35,24 @@ mod boxplot;
mod density;
mod errorbar;
mod histogram;
-mod hline;
mod label;
mod line;
+mod linear;
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};
// 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;
@@ -62,19 +60,19 @@ 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 linear::Linear;
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};
@@ -99,9 +97,8 @@ pub enum GeomType {
Label,
Segment,
Arrow,
- HLine,
- VLine,
- AbLine,
+ Rule,
+ Linear,
ErrorBar,
}
@@ -125,9 +122,8 @@ impl std::fmt::Display for GeomType {
GeomType::Label => "label",
GeomType::Segment => "segment",
GeomType::Arrow => "arrow",
- GeomType::HLine => "hline",
- GeomType::VLine => "vline",
- GeomType::AbLine => "abline",
+ GeomType::Rule => "rule",
+ GeomType::Linear => "linear",
GeomType::ErrorBar => "errorbar",
};
write!(f, "{}", s)
@@ -311,19 +307,14 @@ impl Geom {
Self(Arc::new(Arrow))
}
- /// Create an HLine geom
- pub fn hline() -> Self {
- Self(Arc::new(HLine))
+ /// Create an Rule geom
+ pub fn rule() -> Self {
+ Self(Arc::new(Rule))
}
- /// Create a VLine geom
- pub fn vline() -> Self {
- Self(Arc::new(VLine))
- }
-
- /// 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
@@ -351,9 +342,8 @@ impl Geom {
GeomType::Label => Self::label(),
GeomType::Segment => Self::segment(),
GeomType::Arrow => Self::arrow(),
- GeomType::HLine => Self::hline(),
- GeomType::VLine => Self::vline(),
- GeomType::AbLine => Self::abline(),
+ GeomType::Rule => Self::rule(),
+ GeomType::Linear => Self::linear(),
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 e3338c83..6b34e836 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: &[
- ("pos2", DefaultAestheticValue::Required), // y position for horizontal line
+ ("pos1", DefaultAestheticValue::Null),
+ ("pos2", 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/segment.rs b/src/plot/layer/geom/segment.rs
index eb60a520..e22c34d0 100644
--- a/src/plot/layer/geom/segment.rs
+++ b/src/plot/layer/geom/segment.rs
@@ -17,8 +17,8 @@ impl GeomTrait for Segment {
defaults: &[
("pos1", DefaultAestheticValue::Required),
("pos2", DefaultAestheticValue::Required),
- ("pos1end", DefaultAestheticValue::Required),
- ("pos2end", DefaultAestheticValue::Required),
+ ("pos1end", DefaultAestheticValue::Null),
+ ("pos2end", DefaultAestheticValue::Null),
("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
deleted file mode 100644
index 37ec2058..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: &[
- ("pos1", DefaultAestheticValue::Required), // x position for vertical line
- ("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 a211ac34..d7d63a26 100644
--- a/src/plot/main.rs
+++ b/src/plot/main.rs
@@ -506,17 +506,12 @@ mod tests {
);
// Segment/arrow require endpoints
- assert_eq!(
- Geom::segment().aesthetics().required(),
- &["pos1", "pos2", "pos1end", "pos2end"]
- );
+ assert_eq!(Geom::segment().aesthetics().required(), &["pos1", "pos2"]);
// Reference lines
- assert_eq!(Geom::hline().aesthetics().required(), &["pos2"]);
- assert_eq!(Geom::vline().aesthetics().required(), &["pos1"]);
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 311b3381..34409c55 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};
@@ -39,6 +40,10 @@ pub fn geom_to_mark(geom: &Geom) -> Value {
GeomType::Boxplot => "boxplot",
GeomType::Text => "text",
GeomType::Label => "text",
+ GeomType::Segment => "rule",
+ GeomType::Rule => "rule",
+ GeomType::Linear => "rule",
+ GeomType::ErrorBar => "rule",
_ => "point", // Default fallback
};
json!({
@@ -112,6 +117,61 @@ 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 {
+ if let (ArrayElement::Number(min), ArrayElement::Number(max)) =
+ (&range[0], &range[1])
+ {
+ 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:
@@ -131,6 +191,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)?
@@ -144,13 +205,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(())
}
@@ -193,7 +264,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,
@@ -215,13 +291,196 @@ 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(())
}
}
+// =============================================================================
+// Segment Renderer
+// =============================================================================
+
+pub struct SegmentRenderer;
+
+impl GeomRenderer for SegmentRenderer {
+ 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 {
+ 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(())
+ }
+}
+
+// =============================================================================
+// 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(())
+ }
+}
+
+// =============================================================================
+// Linear Renderer
+// =============================================================================
+
+/// Renderer for linear geom - draws lines based on coefficient and intercept
+pub struct LinearRenderer;
+
+impl GeomRenderer for LinearRenderer {
+ fn prepare_data(
+ &self,
+ df: &DataFrame,
+ _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 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
+ // 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 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)
+ let (x_min, x_max) = context.get_extent("pos1")?;
+
+ // 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.{}", coef_field, intercept_field),
+ "as": "y_min"
+ },
+ {
+ "calculate": format!("datum.{} * datum.x_max + datum.{}", coef_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
// =============================================================================
@@ -230,7 +489,12 @@ impl GeomRenderer for PathRenderer {
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);
}
@@ -249,7 +513,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") {
@@ -282,7 +551,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.
@@ -294,7 +568,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"
@@ -311,7 +590,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
@@ -366,7 +650,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
@@ -447,6 +736,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
// =============================================================================
@@ -731,6 +1127,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)?;
@@ -791,6 +1188,10 @@ 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),
+ 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
_ => Box::new(DefaultRenderer),
}
@@ -811,7 +1212,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"}))
@@ -827,7 +1231,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!([
@@ -846,7 +1253,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"}))
@@ -862,7 +1272,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!([
@@ -884,7 +1297,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!([
@@ -893,4 +1309,444 @@ 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"));
+ }
+
+ #[test]
+ fn test_linear_renderer_multiple_lines() {
+ use crate::reader::{DuckDBReader, Reader};
+ use crate::writer::{VegaLiteWriter, Writer};
+
+ // 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)
+ ),
+ lines AS (
+ SELECT * FROM (VALUES
+ (2, 5, 'A'),
+ (1, 10, 'B'),
+ (3, 0, 'C')
+ ) AS t(coef, intercept, line_id)
+ )
+ SELECT * FROM points
+ VISUALISE
+ DRAW point MAPPING x AS x, y AS y
+ DRAW linear MAPPING coef AS coef, intercept AS intercept, line_id AS color FROM lines
+ "#;
+
+ // 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 + linear)
+ let layers = vl_spec["layer"].as_array().expect("No layers found");
+ assert_eq!(layers.len(), 2, "Should have 2 layers (point + linear)");
+
+ // Get the linear layer (second layer)
+ let linear_layer = &layers[1];
+
+ // Verify it's a rule mark
+ assert_eq!(
+ linear_layer["mark"]["type"], "rule",
+ "Linear should use rule mark"
+ );
+
+ // Verify transforms exist
+ let transforms = linear_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 coef 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 coef, intercept, and x_min/x_max
+ assert!(
+ y_min_calc.contains("__ggsql_aes_coef__"),
+ "y_min should reference coef"
+ );
+ 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_coef__"),
+ "y_max should reference coef"
+ );
+ 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 = linear_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 linear rows (one per coef)
+ let data_values = vl_spec["data"]["values"]
+ .as_array()
+ .expect("No data values found");
+
+ let linear_rows: Vec<_> = data_values
+ .iter()
+ .filter(|row| {
+ row["__ggsql_source__"] == "__ggsql_layer_1__"
+ && row["__ggsql_aes_coef__"].is_number()
+ })
+ .collect();
+
+ assert_eq!(
+ linear_rows.len(),
+ 3,
+ "Should have 3 linear rows (3 different coefficients)"
+ );
+
+ // Verify we have coefs 1, 2, 3
+ let mut coefs: Vec = linear_rows
+ .iter()
+ .map(|row| row["__ggsql_aes_coef__"].as_f64().unwrap())
+ .collect();
+ coefs.sort_by(|a, b| a.partial_cmp(b).unwrap());
+
+ assert_eq!(coefs, vec![1.0, 2.0, 3.0], "Should have coefs 1, 2, and 3");
+ }
+
+ #[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"
+ );
+ }
}
diff --git a/src/writer/vegalite/mod.rs b/src/writer/vegalite/mod.rs
index 7e5c7ef6..f30bb1e7 100644
--- a/src/writer/vegalite/mod.rs
+++ b/src/writer/vegalite/mod.rs
@@ -83,6 +83,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(|| {
@@ -97,7 +100,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)?;
@@ -293,7 +299,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)
}
diff --git a/tree-sitter-ggsql/grammar.js b/tree-sitter-ggsql/grammar.js
index 9302f195..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', 'hline', 'vline', 'abline', 'errorbar'
+ 'text', 'label', 'segment', 'arrow', 'rule', 'linear', 'errorbar'
),
// MAPPING clause for aesthetic mappings: MAPPING col AS x, "blue" AS color [FROM source]
@@ -653,6 +653,8 @@ module.exports = grammar({
'size', 'shape', 'linetype', 'linewidth', 'width', 'height',
// Text aesthetics
'label', 'family', 'fontface', 'hjust', 'vjust',
+ // Specialty aesthetics,
+ '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 7066685d..576b19f1 100644
--- a/tree-sitter-ggsql/queries/highlights.scm
+++ b/tree-sitter-ggsql/queries/highlights.scm
@@ -24,9 +24,8 @@
"label"
"segment"
"arrow"
- "hline"
- "vline"
- "abline"
+ "rule"
+ "linear"
"errorbar"
] @type.builtin