Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,7 @@ pub enum Geom {
// Statistical geoms
Histogram, Density, Smooth, Boxplot, Violin,
// Annotation geoms
Text, Segment, Arrow, Rule, Linear, ErrorBar,
Text, Segment, Arrow, Rule, ErrorBar,
}

pub enum DataSource {
Expand Down Expand Up @@ -1219,7 +1219,7 @@ All clauses (MAPPING, SETTING, PARTITION BY, FILTER) are optional.

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

**MAPPING Clause** (Aesthetic Mappings):

Expand Down
2 changes: 1 addition & 1 deletion doc/ggsql.xml
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,6 @@
<item>segment</item>
<item>arrow</item>
<item>rule</item>
<item>linear</item>
<item>errorbar</item>
</list>

Expand Down Expand Up @@ -174,6 +173,7 @@
<item>italic</item>
<item>hjust</item>
<item>vjust</item>
<item>slope</item>
</list>

<!-- Scale Types (only in SCALE context) -->
Expand Down
4 changes: 2 additions & 2 deletions doc/index.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@ WHERE island = 'Biscoe'
-- Followed by visualization declaration
VISUALISE bill_len AS x, bill_dep AS y, body_mass AS fill
DRAW point
DRAW linear
MAPPING 0.4 AS coef, -1 AS intercept
PLACE rule
SETTING slope => 0.4, y => -1
SCALE BINNED fill
LABEL
title => 'Relationship between bill dimensions in 3 species of penguins',
Expand Down
1 change: 0 additions & 1 deletion doc/syntax/index.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ There are many different layers to choose from when visualising your data. Some
- [`line`](layer/type/line.qmd) is used to produce lineplots with the data sorted along the x axis.
- [`path`](layer/type/path.qmd) is like `line` above but does not sort the data but plot it according to its own order.
- [`segment`](layer/type/segment.qmd) connects two points with a line segment.
- [`linear`](layer/type/linear.qmd) draws a long line parameterised by a coefficient and intercept.
- [`rule`](layer/type/rule.qmd) draws horizontal and vertical reference lines.
- [`area`](layer/type/area.qmd) is used to display series as an area chart.
- [`ribbon`](layer/type/ribbon.qmd) is used to display series extrema.
Expand Down
70 changes: 0 additions & 70 deletions doc/syntax/layer/type/linear.qmd

This file was deleted.

46 changes: 43 additions & 3 deletions doc/syntax/layer/type/rule.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ title: "Rule"

The rule layer is used to draw reference lines perpendicular to an axis at specified values. This is useful for adding thresholds, means, medians, event markers, cutoff dates or other guides to the plot. The lines span the full width or height of the panels.

Additionally, the rule layer can draw diagonal reference lines by specifying a `slope` along with a position (`x` or `y`). The position acts as the intercept term in the linear equation. This is useful for adding regression lines, diagonal guides, or mathematical functions to plots.

> The rule layer doesn't have a natural extent along the secondary axis. The secondary scale range can thus not be deduced from rule layers. If the rule layer is the only layer in the visualisation, you must specify the position scale range manually.

## Aesthetics
Expand All @@ -15,19 +17,29 @@ The following aesthetics are recognised by the rule layer.
* Primary axis (e.g. `x` or `y`): Position along the primary axis.

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

## Settings
The rule layer has no additional settings.
* `orientation`: The orientation of the layer, see the [Orientation section](#orientation). One of the following:
* `'aligned'` to align the layer's primary axis with the coordinate system's first axis.
* `'transposed'` to align the layer's primary axis with the coordinate system's second axis.

## Data transformation
The rule layer does not transform its data but passes it through unchanged.

For diagonal lines, the position aesthetic determines the intercept:

* `y = a, slope = β` creates the line: $y = a + \beta x$ (line passes through $(0, a)$)
* `x = a, slope = β` creates the line: $x = a + \beta y$ (line passes through $(a, 0)$)

Note that `slope` represents $\Delta x/\Delta y$ (change in x per unit change in y) when using `x` mapping, not the traditional $\Delta y/\Delta x$.
The default slope is 0, creating horizontal lines when `y` is given and vertical lines when `x` is given.

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

## Examples

Expand Down Expand Up @@ -69,3 +81,31 @@ VISUALISE
DRAW line MAPPING date AS x, temperature AS y
DRAW rule MAPPING value AS y, label AS colour FROM thresholds
```

Add a diagnoal reference line to a scatterplot by using `slope`

```{ggsql}
VISUALISE FROM ggsql:penguins
DRAW point MAPPING bill_len AS x, bill_dep AS y
PLACE rule SETTING slope => 0.4, y => -1
```

Add multiple reference lines with different colors from a separate dataset. Note we're mapping from data here, so we use `DRAW` instead of `PLACE`.

```{ggsql}
WITH lines AS (
SELECT * FROM (VALUES
(0.4, -1, 'Line A'),
(0.2, 8, 'Line B'),
(0.8, -19, 'Line C')
) AS t(slope, intercept, label)
)
VISUALISE FROM ggsql:penguins
DRAW point MAPPING bill_len AS x, bill_dep AS y
DRAW rule
MAPPING
slope AS slope,
intercept AS y,
label AS colour
FROM lines
```
6 changes: 3 additions & 3 deletions ggsql-vscode/syntaxes/ggsql.tmLanguage.json
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@
"patterns": [
{
"name": "support.type.aesthetic.ggsql",
"match": "\\b(x|y|xmin|xmax|ymin|ymax|xend|yend|weight|color|colour|fill|stroke|opacity|size|shape|linetype|linewidth|width|height|label|typeface|fontweight|italic|hjust|vjust|panel|row|column)\\b"
"match": "\\b(x|y|xmin|xmax|ymin|ymax|xend|yend|weight|color|colour|fill|stroke|opacity|size|shape|linetype|linewidth|width|height|label|typeface|fontweight|italic|hjust|vjust|slope|panel|row|column)\\b"
}
]
},
Expand Down Expand Up @@ -295,7 +295,7 @@
"patterns": [
{
"name": "support.type.geom.ggsql",
"match": "\\b(point|line|path|bar|col|area|tile|polygon|ribbon|histogram|density|smooth|boxplot|violin|text|label|segment|arrow|rule|linear|errorbar)\\b"
"match": "\\b(point|line|path|bar|col|area|tile|polygon|ribbon|histogram|density|smooth|boxplot|violin|text|label|segment|arrow|rule|errorbar)\\b"
},
{ "include": "#common-clause-patterns" }
]
Expand All @@ -309,7 +309,7 @@
"patterns": [
{
"name": "support.type.geom.ggsql",
"match": "\\b(point|line|path|bar|col|area|rect|polygon|ribbon|histogram|density|smooth|boxplot|violin|text|label|segment|arrow|rule|linear|errorbar)\\b"
"match": "\\b(point|line|path|bar|col|area|rect|polygon|ribbon|histogram|density|smooth|boxplot|violin|text|label|segment|arrow|rule|errorbar)\\b"
},
{ "include": "#common-clause-patterns" }
]
Expand Down
77 changes: 73 additions & 4 deletions src/execute/layer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -717,7 +717,15 @@ fn process_annotation_layer(layer: &mut Layer) -> Result<String> {
}
}

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

// Step 3: Determine max array length from all annotation parameters
let mut max_length = 1;

for (aesthetic, value) in &annotation_params {
Expand All @@ -743,7 +751,7 @@ fn process_annotation_layer(layer: &mut Layer) -> Result<String> {
}
}

// Step 3: Build VALUES clause and create final mappings simultaneously
// Step 4: Build VALUES clause and create final mappings simultaneously
let mut columns: Vec<Vec<ArrayElement>> = Vec::new();
let mut column_names = Vec::new();

Expand All @@ -766,6 +774,11 @@ fn process_annotation_layer(layer: &mut Layer) -> Result<String> {
// the same column→aesthetic renaming pipeline as regular layers
column_names.push(aesthetic.clone());

// Skip creating mappings for dummy columns (they're just for valid SQL)
if aesthetic == "__ggsql_dummy__" {
continue;
}

// Create final mapping directly (no intermediate Literal step)
let is_positional = crate::plot::aesthetic::is_positional_aesthetic(aesthetic);
let mapping_value = if is_positional {
Expand All @@ -787,14 +800,14 @@ fn process_annotation_layer(layer: &mut Layer) -> Result<String> {
layer.parameters.remove(aesthetic);
}

// Step 4: Build VALUES rows
// Step 5: Build VALUES rows
let mut rows = Vec::new();
for i in 0..max_length {
let values: Vec<String> = columns.iter().map(|col| col[i].to_sql()).collect();
rows.push(format!("({})", values.join(", ")));
}

// Step 5: Build complete SQL query
// Step 6: Build complete SQL query
let values_clause = rows.join(", ");
let column_list = column_names
.iter()
Expand Down Expand Up @@ -1106,4 +1119,60 @@ mod tests {
);
assert_eq!(series.len(), 2);
}

#[test]
fn test_annotation_no_required_aesthetics() {
// Rule geom has no required aesthetics, only optional ones
let mut layer = Layer::new(Geom::rule());
layer.source = Some(DataSource::Annotation);
// Only non-positional, non-required scalar parameters
layer.parameters.insert(
"stroke".to_string(),
ParameterValue::String("red".to_string()),
);
layer
.parameters
.insert("linewidth".to_string(), ParameterValue::Number(2.0));

let result = process_annotation_layer(&mut layer);

// Should generate valid SQL with a dummy column
match result {
Ok(sql) => {
// Check that SQL is valid (has VALUES with at least one column)
assert!(
!sql.contains("(VALUES ) AS t()"),
"Should not generate empty VALUES clause"
);
assert!(
sql.contains("VALUES") && sql.contains("AS t("),
"Should have VALUES with at least one column"
);
// Should contain the dummy column
assert!(
sql.contains("__ggsql_dummy__"),
"Should have dummy column when no data columns exist"
);
}
Err(e) => {
panic!("Unexpected error: {}", e);
}
}

// Verify that stroke and linewidth remain in parameters (not moved to mappings)
assert!(
layer.parameters.contains_key("stroke"),
"Non-positional, non-required parameters should stay in parameters"
);
assert!(
layer.parameters.contains_key("linewidth"),
"Non-positional, non-required parameters should stay in parameters"
);

// Verify dummy column is not in mappings
assert!(
!layer.mappings.contains_key("__ggsql_dummy__"),
"Dummy column should not be added to mappings"
);
}
}
Loading
Loading