Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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/layer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -567,7 +567,8 @@ where
// Apply literal default remappings from geom defaults (e.g., y2 => 0.0 for bar baseline).
// These apply regardless of stat transform, but only if user hasn't overridden them.
// Defaults are always in aligned orientation.
for (aesthetic, default_value) in layer.geom.default_remappings().defaults {
let implicit_remappings = layer.geom.implicit_default_remappings();
for (aesthetic, default_value) in &implicit_remappings {
// Only process literal values here (Column values are handled in Transformed branch)
if !matches!(default_value, DefaultAestheticValue::Column(_)) {
// Only add if user hasn't already specified this aesthetic in remappings or mappings
Expand All @@ -591,7 +592,7 @@ where
// Build stat column -> aesthetic mappings from geom defaults for renaming
let mut final_remappings: HashMap<String, String> = HashMap::new();

for (aesthetic, default_value) in layer.geom.default_remappings().defaults {
for (aesthetic, default_value) in &implicit_remappings {
if let DefaultAestheticValue::Column(stat_col) = default_value {
// Stat column mapping: stat_col -> aesthetic (for rename)
final_remappings.insert(stat_col.to_string(), aesthetic.to_string());
Expand Down
7 changes: 4 additions & 3 deletions src/execute/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ fn validate(
// Validate remapping source columns are valid stat columns for this geom.
// Geoms that opt into the Aggregate stat (`supports_aggregate`) also accept
// `aggregate`, `count`, and any position aesthetic name as a stat source.
let valid_stat_columns = layer.geom.valid_stat_columns();
let valid_stat_columns = layer.geom.implicit_valid_stat_columns();
let supports_aggregate = layer.geom.supports_aggregate();
for stat_value in layer.remappings.aesthetics.values() {
if let Some(stat_col) = stat_value.column_name() {
Expand Down 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
32 changes: 19 additions & 13 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 @@ -35,8 +35,8 @@ impl GeomTrait for Bar {
// if we ever want to make 'width' an aesthetic, we'd probably need to
// translate it to 'size'.
defaults: &[
("pos1", DefaultAestheticValue::Null), // Optional - stat may provide
("pos2", DefaultAestheticValue::Null), // Optional - stat may compute
("pos1", DefaultAestheticValue::Dummy), // Optional - stat synthesises a dummy if omitted
("pos2", DefaultAestheticValue::Null), // Optional - stat computes count when omitted
("pos2end", DefaultAestheticValue::Delayed),
("weight", DefaultAestheticValue::Null),
("fill", DefaultAestheticValue::String("black")),
Expand All @@ -50,14 +50,13 @@ impl GeomTrait for Bar {
DefaultAesthetics {
defaults: &[
("pos2", DefaultAestheticValue::Column("count")),
("pos1", DefaultAestheticValue::Column("pos1")),
("pos2end", DefaultAestheticValue::Number(0.0)),
],
}
}

fn valid_stat_columns(&self) -> &'static [&'static str] {
&["count", "pos1", "proportion"]
&["count", "proportion"]
}

fn default_params(&self) -> &'static [ParamDefinition] {
Expand Down Expand Up @@ -85,10 +84,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 @@ -100,8 +95,8 @@ impl GeomTrait for Bar {
dialect: &dyn SqlDialect,
aesthetic_ctx: &crate::plot::aesthetic::AestheticContext,
) -> Result<StatResult> {
if has_aggregate_param(parameters) {
return stat_aggregate::apply(
let inner = if has_aggregate_param(parameters) {
stat_aggregate::apply(
query,
schema,
aesthetics,
Expand All @@ -110,9 +105,20 @@ impl GeomTrait for Bar {
dialect,
aesthetic_ctx,
self.aggregate_domain_aesthetics().unwrap_or(&[]),
);
)?
} else {
stat_bar_count(query, schema, aesthetics, group_by)?
};
// When the user omits the categorical axis, post-wrap with the dummy
// pos1 column so the writer suppresses the one-tick axis. Composes
// with both the aggregate and identity-path outputs (the `count`
// branch of stat_bar_count already injects its own dummy column —
// wrap_stat_with_dummy_pos1's idempotency keeps that path correct).
if get_column_name(aesthetics, "pos1").is_none() {
Ok(wrap_stat_with_dummy_pos1(query, inner))
} else {
Ok(inner)
}
stat_bar_count(query, schema, aesthetics, group_by)
}
}

Expand Down
105 changes: 79 additions & 26 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 dummy-able. `stat_boxplot` handles the synthesis
// itself by pre-wrapping the input so the existing GROUP BY
// collapses to a single boxplot of the whole pos2 distribution.
("pos1", DefaultAestheticValue::Dummy),
("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 @@ -117,9 +116,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 +155,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 +532,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,6 +591,8 @@ mod tests {
let boxplot = Boxplot;
let remappings = boxplot.default_remappings();

// pos1 is `Dummy` in aesthetics() so the `Geom` wrapper auto-derives
// its remapping. The trait method returns only the explicit entries.
assert_eq!(remappings.defaults.len(), 3);
assert!(remappings
.defaults
Expand All @@ -587,6 +605,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 +656,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
Loading
Loading