Skip to content

Commit 6ddab63

Browse files
teunbrandclaude
andauthored
Unify rule and linear layers (#252)
* Fix annotation layer query generation for geoms with no required aesthetics When annotation layers have no required aesthetics (e.g., rule geom) and only non-positional scalar parameters, process_annotation_layer now adds a dummy column to generate valid SQL instead of malformed empty VALUES clause. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * adopt linear layer functionality into rule layer * repurpose linear tests for rule * repurpose docs * eradicate linear layer * grammar/highlighting fixes * remove `intercept` * adapt docs * create `setup_layer()` method * fix issue with expanding scales * cargo fmt * account for #249 * replace orientation blurb with position blurb * compute decisions up front * clarifying comment --------- Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 72c1580 commit 6ddab63

17 files changed

Lines changed: 378 additions & 272 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -337,7 +337,7 @@ pub enum Geom {
337337
// Statistical geoms
338338
Histogram, Density, Smooth, Boxplot, Violin,
339339
// Annotation geoms
340-
Text, Segment, Arrow, Rule, Linear, ErrorBar,
340+
Text, Segment, Arrow, Rule, ErrorBar,
341341
}
342342

343343
pub enum DataSource {
@@ -1221,7 +1221,7 @@ All clauses (MAPPING, SETTING, PARTITION BY, FILTER) are optional.
12211221

12221222
- **Basic**: `point`, `line`, `path`, `bar`, `col`, `area`, `rect`, `polygon`, `ribbon`
12231223
- **Statistical**: `histogram`, `density`, `smooth`, `boxplot`, `violin`
1224-
- **Annotation**: `text`, `label`, `segment`, `arrow`, `rule`, `linear`, `errorbar`
1224+
- **Annotation**: `text`, `label`, `segment`, `arrow`, `rule`, `errorbar`
12251225

12261226
**MAPPING Clause** (Aesthetic Mappings):
12271227

doc/ggsql.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,6 @@
143143
<item>segment</item>
144144
<item>arrow</item>
145145
<item>rule</item>
146-
<item>linear</item>
147146
<item>errorbar</item>
148147
</list>
149148

@@ -174,6 +173,7 @@
174173
<item>italic</item>
175174
<item>hjust</item>
176175
<item>vjust</item>
176+
<item>slope</item>
177177
</list>
178178

179179
<!-- Scale Types (only in SCALE context) -->

doc/index.qmd

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,8 @@ WHERE island = 'Biscoe'
4444
-- Followed by visualization declaration
4545
VISUALISE bill_len AS x, bill_dep AS y, body_mass AS fill
4646
DRAW point
47-
DRAW linear
48-
MAPPING 0.4 AS coef, -1 AS intercept
47+
PLACE rule
48+
SETTING slope => 0.4, y => -1
4949
SCALE BINNED fill
5050
LABEL
5151
title => 'Relationship between bill dimensions in 3 species of penguins',

doc/syntax/index.qmd

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ There are many different layers to choose from when visualising your data. Some
2121
- [`line`](layer/type/line.qmd) is used to produce lineplots with the data sorted along the x axis.
2222
- [`path`](layer/type/path.qmd) is like `line` above but does not sort the data but plot it according to its own order.
2323
- [`segment`](layer/type/segment.qmd) connects two points with a line segment.
24-
- [`linear`](layer/type/linear.qmd) draws a long line parameterised by a coefficient and intercept.
2524
- [`rule`](layer/type/rule.qmd) draws horizontal and vertical reference lines.
2625
- [`area`](layer/type/area.qmd) is used to display series as an area chart.
2726
- [`ribbon`](layer/type/ribbon.qmd) is used to display series extrema.

doc/syntax/layer/type/linear.qmd

Lines changed: 0 additions & 67 deletions
This file was deleted.

doc/syntax/layer/type/rule.qmd

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ title: "Rule"
66
77
The rule layer is used to draw reference lines perpendicular to an axis at specified values. This is useful for adding thresholds, means, medians, event markers, cutoff dates or other guides to the plot. The lines span the full width or height of the panels.
88

9+
Additionally, the rule layer can draw diagonal reference lines by specifying a `slope` along with a position (`x` or `y`). The position acts as the intercept term in the linear equation. This is useful for adding regression lines, diagonal guides, or mathematical functions to plots.
10+
911
> The rule layer doesn't have a natural extent along the secondary axis. The secondary scale range can thus not be deduced from rule layers. If the rule layer is the only layer in the visualisation, you must specify the position scale range manually.
1012
1113
## Aesthetics
@@ -15,19 +17,27 @@ The following aesthetics are recognised by the rule layer.
1517
* Primary axis (e.g. `x` or `y`): Position along the primary axis.
1618

1719
### Optional
20+
* `slope` The $\beta$ in the equations described below. Defaults to 0.
1821
* `colour`/`stroke`: The colour of the line
1922
* `opacity`: The opacity of the line
2023
* `linewidth`: The width of the line
2124
* `linetype`: The type of the line, i.e. the dashing pattern
2225

2326
## Settings
24-
The rule layer has no additional settings.
27+
* `position`: Position adjustment. One of `'identity'` (default), `'stack'`, `'dodge'`, or `'jitter'`
2528

2629
## Data transformation
27-
The rule layer does not transform its data but passes it through unchanged.
30+
31+
For diagonal lines, the position aesthetic determines the intercept:
32+
33+
* `y = a, slope = β` creates the line: $y = a + \beta x$ (line passes through $(0, a)$)
34+
* `x = a, slope = β` creates the line: $x = a + \beta y$ (line passes through $(a, 0)$)
35+
36+
Note that `slope` represents $\Delta x/\Delta y$ (change in x per unit change in y) when using `x` mapping, not the traditional $\Delta y/\Delta x$.
37+
The default slope is 0, creating horizontal lines when `y` is given and vertical lines when `x` is given.
2838

2939
## Orientation
30-
Rules are drawn perpendicular to their primary axis. The orientation is deduced directly from the mapping. To create a horizontal rule you map the values to `y` instead of `x` (assuming a default Cartesian coordinate system).
40+
The orientation is deduced directly from the mapping. See the ['Data transformation'](#data-transformation) section for details.
3141

3242
## Examples
3343

@@ -73,3 +83,31 @@ DRAW line
7383
DRAW rule
7484
MAPPING value AS y, label AS colour FROM thresholds
7585
```
86+
87+
Add a diagnoal reference line to a scatterplot by using `slope`
88+
89+
```{ggsql}
90+
VISUALISE FROM ggsql:penguins
91+
DRAW point MAPPING bill_len AS x, bill_dep AS y
92+
PLACE rule SETTING slope => 0.4, y => -1
93+
```
94+
95+
Add multiple reference lines with different colors from a separate dataset. Note we're mapping from data here, so we use `DRAW` instead of `PLACE`.
96+
97+
```{ggsql}
98+
WITH lines AS (
99+
SELECT * FROM (VALUES
100+
(0.4, -1, 'Line A'),
101+
(0.2, 8, 'Line B'),
102+
(0.8, -19, 'Line C')
103+
) AS t(slope, intercept, label)
104+
)
105+
VISUALISE FROM ggsql:penguins
106+
DRAW point MAPPING bill_len AS x, bill_dep AS y
107+
DRAW rule
108+
MAPPING
109+
slope AS slope,
110+
intercept AS y,
111+
label AS colour
112+
FROM lines
113+
```

ggsql-vscode/syntaxes/ggsql.tmLanguage.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@
250250
"patterns": [
251251
{
252252
"name": "support.type.aesthetic.ggsql",
253-
"match": "\\b(x|y|xmin|xmax|ymin|ymax|xend|yend|weight|color|colour|fill|stroke|opacity|size|shape|linetype|linewidth|width|height|label|typeface|fontweight|italic|hjust|vjust|panel|row|column)\\b"
253+
"match": "\\b(x|y|xmin|xmax|ymin|ymax|xend|yend|weight|color|colour|fill|stroke|opacity|size|shape|linetype|linewidth|width|height|label|typeface|fontweight|italic|hjust|vjust|slope|panel|row|column)\\b"
254254
}
255255
]
256256
},
@@ -295,7 +295,7 @@
295295
"patterns": [
296296
{
297297
"name": "support.type.geom.ggsql",
298-
"match": "\\b(point|line|path|bar|col|area|tile|polygon|ribbon|histogram|density|smooth|boxplot|violin|text|label|segment|arrow|rule|linear|errorbar)\\b"
298+
"match": "\\b(point|line|path|bar|col|area|tile|polygon|ribbon|histogram|density|smooth|boxplot|violin|text|label|segment|arrow|rule|errorbar)\\b"
299299
},
300300
{ "include": "#common-clause-patterns" }
301301
]
@@ -309,7 +309,7 @@
309309
"patterns": [
310310
{
311311
"name": "support.type.geom.ggsql",
312-
"match": "\\b(point|line|path|bar|col|area|rect|polygon|ribbon|histogram|density|smooth|boxplot|violin|text|label|segment|arrow|rule|linear|errorbar)\\b"
312+
"match": "\\b(point|line|path|bar|col|area|rect|polygon|ribbon|histogram|density|smooth|boxplot|violin|text|label|segment|arrow|rule|errorbar)\\b"
313313
},
314314
{ "include": "#common-clause-patterns" }
315315
]

src/execute/layer.rs

Lines changed: 73 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -714,7 +714,15 @@ fn process_annotation_layer(layer: &mut Layer, dialect: &dyn SqlDialect) -> Resu
714714
}
715715
}
716716

717-
// Step 2: Determine max array length from all annotation parameters
717+
// Step 2: Handle empty annotation_params by adding a dummy column
718+
// This occurs when geoms have no required aesthetics and user provides only
719+
// non-positional scalar parameters (e.g., PLACE rule SETTING stroke => 'red')
720+
if annotation_params.is_empty() {
721+
// Add a dummy column so we can generate a valid VALUES clause
722+
annotation_params.push(("__ggsql_dummy__".to_string(), ParameterValue::Number(1.0)));
723+
}
724+
725+
// Step 3: Determine max array length from all annotation parameters
718726
let mut max_length = 1;
719727

720728
for (aesthetic, value) in &annotation_params {
@@ -740,7 +748,7 @@ fn process_annotation_layer(layer: &mut Layer, dialect: &dyn SqlDialect) -> Resu
740748
}
741749
}
742750

743-
// Step 3: Build VALUES clause and create final mappings simultaneously
751+
// Step 4: Build VALUES clause and create final mappings simultaneously
744752
let mut columns: Vec<Vec<ArrayElement>> = Vec::new();
745753
let mut column_names = Vec::new();
746754

@@ -763,6 +771,11 @@ fn process_annotation_layer(layer: &mut Layer, dialect: &dyn SqlDialect) -> Resu
763771
// the same column→aesthetic renaming pipeline as regular layers
764772
column_names.push(aesthetic.clone());
765773

774+
// Skip creating mappings for dummy columns (they're just for valid SQL)
775+
if aesthetic == "__ggsql_dummy__" {
776+
continue;
777+
}
778+
766779
// Create final mapping directly (no intermediate Literal step)
767780
let is_positional = crate::plot::aesthetic::is_positional_aesthetic(aesthetic);
768781
let mapping_value = if is_positional {
@@ -784,7 +797,7 @@ fn process_annotation_layer(layer: &mut Layer, dialect: &dyn SqlDialect) -> Resu
784797
layer.parameters.remove(aesthetic);
785798
}
786799

787-
// Step 4: Build VALUES rows
800+
// Step 5: Build VALUES rows
788801
let values_clause = (0..max_length)
789802
.map(|i| {
790803
let row: Vec<String> = columns.iter().map(|col| col[i].to_sql(dialect)).collect();
@@ -793,7 +806,7 @@ fn process_annotation_layer(layer: &mut Layer, dialect: &dyn SqlDialect) -> Resu
793806
.collect::<Vec<_>>()
794807
.join(", ");
795808

796-
// Step 5: Build complete SQL query
809+
// Step 6: Build complete SQL query
797810
let column_list = column_names
798811
.iter()
799812
.map(|c| format!("\"{}\"", c))
@@ -1105,4 +1118,60 @@ mod tests {
11051118
);
11061119
assert_eq!(series.len(), 2);
11071120
}
1121+
1122+
#[test]
1123+
fn test_annotation_no_required_aesthetics() {
1124+
// Rule geom has no required aesthetics, only optional ones
1125+
let mut layer = Layer::new(Geom::rule());
1126+
layer.source = Some(DataSource::Annotation);
1127+
// Only non-positional, non-required scalar parameters
1128+
layer.parameters.insert(
1129+
"stroke".to_string(),
1130+
ParameterValue::String("red".to_string()),
1131+
);
1132+
layer
1133+
.parameters
1134+
.insert("linewidth".to_string(), ParameterValue::Number(2.0));
1135+
1136+
let result = process_annotation_layer(&mut layer, &AnsiDialect);
1137+
1138+
// Should generate valid SQL with a dummy column
1139+
match result {
1140+
Ok(sql) => {
1141+
// Check that SQL is valid (has VALUES with at least one column)
1142+
assert!(
1143+
!sql.contains("(VALUES ) AS t()"),
1144+
"Should not generate empty VALUES clause"
1145+
);
1146+
assert!(
1147+
sql.contains("VALUES") && sql.contains("WITH __ggsql_values__"),
1148+
"Should have VALUES with at least one column"
1149+
);
1150+
// Should contain the dummy column
1151+
assert!(
1152+
sql.contains("__ggsql_dummy__"),
1153+
"Should have dummy column when no data columns exist"
1154+
);
1155+
}
1156+
Err(e) => {
1157+
panic!("Unexpected error: {}", e);
1158+
}
1159+
}
1160+
1161+
// Verify that stroke and linewidth remain in parameters (not moved to mappings)
1162+
assert!(
1163+
layer.parameters.contains_key("stroke"),
1164+
"Non-positional, non-required parameters should stay in parameters"
1165+
);
1166+
assert!(
1167+
layer.parameters.contains_key("linewidth"),
1168+
"Non-positional, non-required parameters should stay in parameters"
1169+
);
1170+
1171+
// Verify dummy column is not in mappings
1172+
assert!(
1173+
!layer.mappings.contains_key("__ggsql_dummy__"),
1174+
"Dummy column should not be added to mappings"
1175+
);
1176+
}
11081177
}

src/execute/mod.rs

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -846,6 +846,8 @@ fn collect_layer_required_columns(layer: &Layer, spec: &Plot) -> HashSet<String>
846846
/// Prune columns from a DataFrame to only include required columns.
847847
///
848848
/// Columns that don't exist in the DataFrame are silently ignored.
849+
/// If no required columns exist in the DataFrame (e.g., annotation layers with only
850+
/// literal aesthetics), returns a 0-column DataFrame with the same row count.
849851
fn prune_dataframe(df: &DataFrame, required: &HashSet<String>) -> Result<DataFrame> {
850852
let columns_to_keep: Vec<String> = df
851853
.get_column_names()
@@ -855,10 +857,28 @@ fn prune_dataframe(df: &DataFrame, required: &HashSet<String>) -> Result<DataFra
855857
.collect();
856858

857859
if columns_to_keep.is_empty() {
858-
return Err(GgsqlError::InternalError(format!(
859-
"No columns remain after pruning. Required columns: {:?}",
860-
required
861-
)));
860+
// Return a 0-column DataFrame with the same row count
861+
// This happens for annotation layers with only literal aesthetics (e.g., PLACE rule SETTING slope => 0.4)
862+
// The row count determines how many marks to draw; aesthetics come from Literal values in mappings
863+
let row_count = df.height();
864+
865+
if row_count > 0 {
866+
// Create a 0-column DataFrame with the correct row count
867+
// We do this by creating a dummy column and then dropping it
868+
use polars::prelude::df;
869+
let with_rows = df! {
870+
"__dummy__" => vec![0i32; row_count]
871+
}
872+
.map_err(|e| GgsqlError::InternalError(format!("Failed to create DataFrame: {}", e)))?;
873+
874+
let result = with_rows.drop("__dummy__").map_err(|e| {
875+
GgsqlError::InternalError(format!("Failed to drop dummy column: {}", e))
876+
})?;
877+
return Ok(result);
878+
} else {
879+
// 0 rows - just return empty DataFrame
880+
return Ok(DataFrame::default());
881+
}
862882
}
863883

864884
df.select(&columns_to_keep)
@@ -1046,6 +1066,16 @@ pub fn prepare_data_with_reader<R: Reader>(query: &str, reader: &R) -> Result<Pr
10461066
&specs[0].aesthetic_context,
10471067
)?;
10481068

1069+
// Allow geoms to adjust mappings based on their specific logic
1070+
// (e.g., rule geom converts pos1/pos2 to AnnotationColumn when slope is present)
1071+
for spec in &mut specs {
1072+
for layer in &mut spec.layers {
1073+
layer
1074+
.geom
1075+
.setup_layer(&mut layer.mappings, &mut layer.parameters)?;
1076+
}
1077+
}
1078+
10491079
// Create scales for all mapped aesthetics that don't have explicit SCALE clauses
10501080
scale::create_missing_scales(&mut specs[0]);
10511081

0 commit comments

Comments
 (0)