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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,12 @@
- Added panel decorations (grid lines, axes, background) for polar coordinates (#156).
- Added `radar` setting to polar coordinates for making radar plots (#418).

### Changed

- `boxplot`, `violin`, and `range` now support omitting the categorical
aesthetic, matching `bar`. `point` now treats both position aesthetics as
optional.

## 0.3.2 - 2026-05-05

### Fixed
Expand Down
13 changes: 12 additions & 1 deletion doc/syntax/layer/type/boxplot.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,12 @@ Boxplots display a summary of a continuous distribution. In the style of Tukey,
The following aesthetics are recognised by the boxplot layer.

### Required
* Primary axis (e.g. `x`): The categorical variable to group by
* Secondary axis (e.g. `y`): The continuous variable to summarize

### Optional
* Primary axis (e.g. `x`): The categorical variable to group by. If omitted a
single boxplot is drawn for the whole distribution and the (one-tick)
categorical axis is hidden.
* `stroke`: The colour of the box contours, whiskers, median line and outliers.
* `fill`: The colour of the box interior.
* `colour`: Shorthand for setting `stroke` and `fill` simultaneously. Note that the median line will have bad visibility if `stroke` and `fill` are the same.
Expand Down Expand Up @@ -91,3 +93,12 @@ VISUALISE FROM ggsql:penguins
DRAW boxplot
MAPPING species AS y, bill_len AS x
```

Omit the categorical axis to summarise the whole distribution as a single
boxplot:

```{ggsql}
VISUALISE FROM ggsql:penguins
DRAW boxplot
MAPPING bill_len AS y
```
9 changes: 7 additions & 2 deletions doc/syntax/layer/type/point.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,15 @@ The point layer is used to create scatterplots. The scatterplot is most useful f
The following aesthetics are recognised by the point layer.

### Required
* Primary axis (e.g. `x`): Position along the primary axis.
* Secondary axis (e.g. `y`): Position along the secondary axis.
The point layer has no required aesthetics.

### Optional
* Primary axis (e.g. `x`): Position along the primary axis. If omitted, all
points are drawn at a single discrete primary-axis position (a strip plot)
and the categorical axis is hidden.
* Secondary axis (e.g. `y`): Position along the secondary axis. Same dummy-axis
treatment as the primary. If both axes are omitted, all rows pile up at a
single point — only useful in combination with `aggregate`.
* `size`: The size of each point
* `colour`: The default colour of each point
* `stroke`: The colour of the stroke around each point (if any). Overrides `colour`
Expand Down
4 changes: 3 additions & 1 deletion doc/syntax/layer/type/range.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ The range layer displays an interval between two values along the secondary axis
The following aesthetics are recognised by the range layer.

### Required
* Primary axis (e.g. `x`): Position along the primary axis.
* Secondary axis minimum (e.g. `ymin`): Lower position along the secondary axis.
* Secondary axis maximum (e.g. `ymax`): Upper position along the secondary axis.

### Optional
* Primary axis (e.g. `x`): Position along the primary axis. If omitted a
single interval is drawn over the whole dataset and the (one-tick)
categorical axis is hidden.
* `stroke`/`colour`: The colour of the lines in the range.
* `opacity`: The opacity of the colour.
* `linewidth`: The width of the lines in the range.
Expand Down
4 changes: 3 additions & 1 deletion doc/syntax/layer/type/violin.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ The violins are mirrored kernel density estimates, similar to the [density](dens
The following aesthetics are recognised by the violin layer.

### Required
* Primary axis (e.g. `x`): The categorical variable for grouping.
* Secondary axis (e.g. `y`): The continuous variable to compute density for.

### Optional
* Primary axis (e.g. `x`): The categorical variable for grouping. If omitted
a single violin is drawn for the whole distribution and the (one-tick)
categorical axis is hidden.
* `stroke`: The colour of the contour lines.
* `fill`: The colour of the inner area.
* `colour`: Shorthand for setting `stroke` and `fill` simultaneously.
Expand Down
5 changes: 3 additions & 2 deletions src/execute/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3048,11 +3048,12 @@ mod tests {
)
.unwrap();

// Query missing required aesthetic 'y' - should show 'y' not 'pos2'
// Query missing required aesthetic 'y' - should show 'y' not 'pos2'.
// Use line, which still requires both x and y (point's x is optional).
let query = r#"
SELECT * FROM test_data
VISUALISE
DRAW point MAPPING a AS x
DRAW line MAPPING a AS x
"#;

let result = prepare_data_with_reader(query, &reader);
Expand Down
4 changes: 0 additions & 4 deletions src/plot/layer/geom/area.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,6 @@ impl GeomTrait for Area {
Some(&["pos1"])
}

fn needs_stat_transform(&self, _aesthetics: &Mappings) -> bool {
true
}

fn apply_stat_transform(
&self,
query: &str,
Expand Down
19 changes: 12 additions & 7 deletions src/plot/layer/geom/bar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use std::collections::HashMap;
use std::collections::HashSet;

use super::stat_aggregate;
use super::types::{get_column_name, POSITION_VALUES};
use super::types::{get_column_name, wrap_stat_with_dummy_pos1, POSITION_VALUES};
use super::{
has_aggregate_param, DefaultAesthetics, DefaultParamValue, GeomTrait, GeomType,
ParamConstraint, ParamDefinition, StatResult,
Expand Down Expand Up @@ -85,10 +85,6 @@ impl GeomTrait for Bar {
Some(&[])
}

fn needs_stat_transform(&self, _aesthetics: &Mappings) -> bool {
true // Bar stat decides COUNT vs identity based on y mapping
}

fn apply_stat_transform(
&self,
query: &str,
Expand All @@ -101,7 +97,7 @@ impl GeomTrait for Bar {
aesthetic_ctx: &crate::plot::aesthetic::AestheticContext,
) -> Result<StatResult> {
if has_aggregate_param(parameters) {
return stat_aggregate::apply(
let aggregated = stat_aggregate::apply(
query,
schema,
aesthetics,
Expand All @@ -110,7 +106,16 @@ impl GeomTrait for Bar {
dialect,
aesthetic_ctx,
self.aggregate_domain_aesthetics().unwrap_or(&[]),
);
)?;
// When the user omits the categorical axis, decorate the aggregate
// output with the dummy pos1 column so the writer suppresses the
// (otherwise meaningless) one-tick axis. Composes with whatever
// shape the aggregate stat produced.
return if get_column_name(aesthetics, "pos1").is_none() {
Ok(wrap_stat_with_dummy_pos1(query, aggregated))
} else {
Ok(aggregated)
};
}
stat_bar_count(query, schema, aesthetics, group_by)
}
Expand Down
109 changes: 82 additions & 27 deletions src/plot/layer/geom/boxplot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

use std::collections::HashMap;

use super::types::POSITION_VALUES;
use super::types::{wrap_with_dummy_axis, POSITION_VALUES};
use super::{DefaultAesthetics, GeomTrait, GeomType};
use crate::{
naming,
Expand All @@ -26,7 +26,10 @@ impl GeomTrait for Boxplot {
fn aesthetics(&self) -> DefaultAesthetics {
DefaultAesthetics {
defaults: &[
("pos1", DefaultAestheticValue::Required),
// pos1 is optional - if omitted, stat_boxplot synthesises a
// dummy categorical axis so the geom renders a single boxplot
// of the whole pos2 distribution.
("pos1", DefaultAestheticValue::Null),
("pos2", DefaultAestheticValue::Required),
("stroke", DefaultAestheticValue::String("black")),
("fill", DefaultAestheticValue::String("white")),
Expand All @@ -46,10 +49,6 @@ impl GeomTrait for Boxplot {
&["pos2"]
}

fn needs_stat_transform(&self, _aesthetics: &Mappings) -> bool {
true
}

fn default_params(&self) -> &'static [super::ParamDefinition] {
const PARAMS: &[ParamDefinition] = &[
ParamDefinition {
Expand Down Expand Up @@ -79,6 +78,7 @@ impl GeomTrait for Boxplot {
fn default_remappings(&self) -> DefaultAesthetics {
DefaultAesthetics {
defaults: &[
("pos1", DefaultAestheticValue::Column("pos1")),
("pos2", DefaultAestheticValue::Column("value")),
("pos2end", DefaultAestheticValue::Column("value2")),
("type", DefaultAestheticValue::Column("type")),
Expand Down Expand Up @@ -117,9 +117,17 @@ fn stat_boxplot(
let y = get_column_name(aesthetics, "pos2").ok_or_else(|| {
GgsqlError::ValidationError("Boxplot requires 'y' aesthetic mapping".to_string())
})?;
let x = get_column_name(aesthetics, "pos1").ok_or_else(|| {
GgsqlError::ValidationError("Boxplot requires 'x' aesthetic mapping".to_string())
})?;

// pos1 is optional. When the user omits it, wrap the input query with a
// synthetic dummy categorical column and group by that column, so the
// existing GROUP BY / summary pipeline collapses to a single boxplot.
let (working_query, x, use_dummy) = match get_column_name(aesthetics, "pos1") {
Some(col) => (query.to_string(), col, false),
None => {
let dummy_col = naming::stat_column("pos1");
(wrap_with_dummy_axis(query, "pos1"), dummy_col, true)
}
};

// Get coef parameter (validated by ParamConstraint::number_min)
let ParameterValue::Number(coef) = parameters.get("coef").unwrap() else {
Expand Down Expand Up @@ -148,17 +156,25 @@ fn stat_boxplot(
}

// Query for boxplot summary statistics
let summary = boxplot_sql_compute_summary(query, &groups, &value_col, coef, dialect);
let stats_query = boxplot_sql_append_outliers(&summary, &groups, &value_col, query, outliers);
let summary = boxplot_sql_compute_summary(&working_query, &groups, &value_col, coef, dialect);
let stats_query =
boxplot_sql_append_outliers(&summary, &groups, &value_col, &working_query, outliers);

let mut stat_columns = vec![
"type".to_string(),
"value".to_string(),
"value2".to_string(),
];
let mut dummy_columns: Vec<String> = vec![];
if use_dummy {
stat_columns.push("pos1".to_string());
dummy_columns.push("pos1".to_string());
}

Ok(StatResult::Transformed {
query: stats_query,
stat_columns: vec![
"type".to_string(),
"value".to_string(),
"value2".to_string(),
],
dummy_columns: vec![],
stat_columns,
dummy_columns,
consumed_aesthetics: vec!["pos2".to_string()],
})
}
Expand Down Expand Up @@ -517,9 +533,10 @@ mod tests {
let boxplot = Boxplot;
let aes = boxplot.aesthetics();

assert!(aes.is_required("pos1"));
// pos1 is optional (omit → dummy categorical axis); pos2 is required.
assert!(!aes.is_required("pos1"));
assert!(aes.is_required("pos2"));
assert_eq!(aes.required().len(), 2);
assert_eq!(aes.required(), vec!["pos2"]);
}

#[test]
Expand Down Expand Up @@ -575,7 +592,10 @@ mod tests {
let boxplot = Boxplot;
let remappings = boxplot.default_remappings();

assert_eq!(remappings.defaults.len(), 3);
assert_eq!(remappings.defaults.len(), 4);
assert!(remappings
.defaults
.contains(&("pos1", DefaultAestheticValue::Column("pos1"))));
assert!(remappings
.defaults
.contains(&("pos2", DefaultAestheticValue::Column("value"))));
Expand All @@ -587,6 +607,48 @@ mod tests {
.contains(&("type", DefaultAestheticValue::Column("type"))));
}

#[test]
fn test_boxplot_dummy_pos1_when_unmapped() {
use crate::plot::AestheticValue;
let mut aesthetics = Mappings::new();
aesthetics.insert(
"pos2".to_string(),
AestheticValue::standard_column("value".to_string()),
);
let mut parameters: HashMap<String, ParameterValue> = HashMap::new();
parameters.insert("coef".to_string(), ParameterValue::Number(1.5));
parameters.insert("outliers".to_string(), ParameterValue::Boolean(true));

let result = stat_boxplot(
"SELECT * FROM data",
&aesthetics,
&[],
&parameters,
&AnsiDialect,
)
.expect("stat_boxplot should succeed without pos1");

match result {
StatResult::Transformed {
query,
stat_columns,
dummy_columns,
consumed_aesthetics,
} => {
// The wrapped input introduces a synthetic pos1 column that the
// GROUP BY then collapses to a single boxplot.
assert!(query.contains("__ggsql_stat_dummy"));
assert!(query.contains("__ggsql_stat_pos1"));
assert!(stat_columns.contains(&"pos1".to_string()));
assert!(stat_columns.contains(&"type".to_string()));
assert!(stat_columns.contains(&"value".to_string()));
assert_eq!(dummy_columns, vec!["pos1".to_string()]);
assert_eq!(consumed_aesthetics, vec!["pos2".to_string()]);
}
_ => panic!("expected Transformed"),
}
}

#[test]
fn test_boxplot_stat_consumed_aesthetics() {
let boxplot = Boxplot;
Expand All @@ -596,13 +658,6 @@ mod tests {
assert_eq!(consumed[0], "pos2");
}

#[test]
fn test_boxplot_needs_stat_transform() {
let boxplot = Boxplot;
let aesthetics = Mappings::new();
assert!(boxplot.needs_stat_transform(&aesthetics));
}

#[test]
fn test_boxplot_display() {
let boxplot = Boxplot;
Expand Down
4 changes: 0 additions & 4 deletions src/plot/layer/geom/density.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,6 @@ impl GeomTrait for Density {
}
}

fn needs_stat_transform(&self, _aesthetics: &Mappings) -> bool {
true
}

fn default_params(&self) -> &'static [ParamDefinition] {
const PARAMS: &[ParamDefinition] = &[
ParamDefinition {
Expand Down
4 changes: 0 additions & 4 deletions src/plot/layer/geom/histogram.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,10 +84,6 @@ impl GeomTrait for Histogram {
&["pos1"]
}

fn needs_stat_transform(&self, _aesthetics: &Mappings) -> bool {
true
}

fn apply_stat_transform(
&self,
query: &str,
Expand Down
4 changes: 0 additions & 4 deletions src/plot/layer/geom/line.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,6 @@ impl GeomTrait for Line {
Some(&["pos1"])
}

fn needs_stat_transform(&self, _aesthetics: &Mappings) -> bool {
true
}

fn apply_stat_transform(
&self,
query: &str,
Expand Down
Loading
Loading