Skip to content

Commit ec3edcd

Browse files
committed
allow dummy axes for relevant layers
1 parent d9f744d commit ec3edcd

25 files changed

Lines changed: 672 additions & 125 deletions

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@
99
- Added panel decorations (grid lines, axes, background) for polar coordinates (#156).
1010
- Added `radar` setting to polar coordinates for making radar plots (#418).
1111

12+
### Changed
13+
14+
- `boxplot`, `violin`, and `range` now support omitting the categorical
15+
aesthetic, matching `bar`. `point` now treats both position aesthetics as
16+
optional.
17+
1218
## 0.3.2 - 2026-05-05
1319

1420
### Fixed

doc/syntax/layer/type/boxplot.qmd

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,12 @@ Boxplots display a summary of a continuous distribution. In the style of Tukey,
99
The following aesthetics are recognised by the boxplot layer.
1010

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

1514
### Optional
15+
* Primary axis (e.g. `x`): The categorical variable to group by. If omitted a
16+
single boxplot is drawn for the whole distribution and the (one-tick)
17+
categorical axis is hidden.
1618
* `stroke`: The colour of the box contours, whiskers, median line and outliers.
1719
* `fill`: The colour of the box interior.
1820
* `colour`: Shorthand for setting `stroke` and `fill` simultaneously. Note that the median line will have bad visibility if `stroke` and `fill` are the same.
@@ -91,3 +93,12 @@ VISUALISE FROM ggsql:penguins
9193
DRAW boxplot
9294
MAPPING species AS y, bill_len AS x
9395
```
96+
97+
Omit the categorical axis to summarise the whole distribution as a single
98+
boxplot:
99+
100+
```{ggsql}
101+
VISUALISE FROM ggsql:penguins
102+
DRAW boxplot
103+
MAPPING bill_len AS y
104+
```

doc/syntax/layer/type/point.qmd

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,15 @@ The point layer is used to create scatterplots. The scatterplot is most useful f
1010
The following aesthetics are recognised by the point layer.
1111

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

1615
### Optional
16+
* Primary axis (e.g. `x`): Position along the primary axis. If omitted, all
17+
points are drawn at a single discrete primary-axis position (a strip plot)
18+
and the categorical axis is hidden.
19+
* Secondary axis (e.g. `y`): Position along the secondary axis. Same dummy-axis
20+
treatment as the primary. If both axes are omitted, all rows pile up at a
21+
single point — only useful in combination with `aggregate`.
1722
* `size`: The size of each point
1823
* `colour`: The default colour of each point
1924
* `stroke`: The colour of the stroke around each point (if any). Overrides `colour`

doc/syntax/layer/type/range.qmd

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@ The range layer displays an interval between two values along the secondary axis
1010
The following aesthetics are recognised by the range layer.
1111

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

1716
### Optional
17+
* Primary axis (e.g. `x`): Position along the primary axis. If omitted a
18+
single interval is drawn over the whole dataset and the (one-tick)
19+
categorical axis is hidden.
1820
* `stroke`/`colour`: The colour of the lines in the range.
1921
* `opacity`: The opacity of the colour.
2022
* `linewidth`: The width of the lines in the range.

doc/syntax/layer/type/violin.qmd

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,12 @@ The violins are mirrored kernel density estimates, similar to the [density](dens
1111
The following aesthetics are recognised by the violin layer.
1212

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

1716
### Optional
17+
* Primary axis (e.g. `x`): The categorical variable for grouping. If omitted
18+
a single violin is drawn for the whole distribution and the (one-tick)
19+
categorical axis is hidden.
1820
* `stroke`: The colour of the contour lines.
1921
* `fill`: The colour of the inner area.
2022
* `colour`: Shorthand for setting `stroke` and `fill` simultaneously.

src/execute/mod.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3048,11 +3048,12 @@ mod tests {
30483048
)
30493049
.unwrap();
30503050

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

30583059
let result = prepare_data_with_reader(query, &reader);

src/plot/layer/geom/area.rs

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,6 @@ impl GeomTrait for Area {
6060
Some(&["pos1"])
6161
}
6262

63-
fn needs_stat_transform(&self, _aesthetics: &Mappings) -> bool {
64-
true
65-
}
66-
6763
fn apply_stat_transform(
6864
&self,
6965
query: &str,

src/plot/layer/geom/bar.rs

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use std::collections::HashMap;
44
use std::collections::HashSet;
55

66
use super::stat_aggregate;
7-
use super::types::{get_column_name, POSITION_VALUES};
7+
use super::types::{get_column_name, wrap_stat_with_dummy_pos1, POSITION_VALUES};
88
use super::{
99
has_aggregate_param, DefaultAesthetics, DefaultParamValue, GeomTrait, GeomType,
1010
ParamConstraint, ParamDefinition, StatResult,
@@ -85,10 +85,6 @@ impl GeomTrait for Bar {
8585
Some(&[])
8686
}
8787

88-
fn needs_stat_transform(&self, _aesthetics: &Mappings) -> bool {
89-
true // Bar stat decides COUNT vs identity based on y mapping
90-
}
91-
9288
fn apply_stat_transform(
9389
&self,
9490
query: &str,
@@ -101,7 +97,7 @@ impl GeomTrait for Bar {
10197
aesthetic_ctx: &crate::plot::aesthetic::AestheticContext,
10298
) -> Result<StatResult> {
10399
if has_aggregate_param(parameters) {
104-
return stat_aggregate::apply(
100+
let aggregated = stat_aggregate::apply(
105101
query,
106102
schema,
107103
aesthetics,
@@ -110,7 +106,16 @@ impl GeomTrait for Bar {
110106
dialect,
111107
aesthetic_ctx,
112108
self.aggregate_domain_aesthetics().unwrap_or(&[]),
113-
);
109+
)?;
110+
// When the user omits the categorical axis, decorate the aggregate
111+
// output with the dummy pos1 column so the writer suppresses the
112+
// (otherwise meaningless) one-tick axis. Composes with whatever
113+
// shape the aggregate stat produced.
114+
return if get_column_name(aesthetics, "pos1").is_none() {
115+
Ok(wrap_stat_with_dummy_pos1(query, aggregated))
116+
} else {
117+
Ok(aggregated)
118+
};
114119
}
115120
stat_bar_count(query, schema, aesthetics, group_by)
116121
}

src/plot/layer/geom/boxplot.rs

Lines changed: 83 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
33
use std::collections::HashMap;
44

5-
use super::types::POSITION_VALUES;
5+
use super::types::{wrap_with_dummy_axis, POSITION_VALUES};
66
use super::{DefaultAesthetics, GeomTrait, GeomType};
77
use crate::{
88
naming,
@@ -26,7 +26,10 @@ impl GeomTrait for Boxplot {
2626
fn aesthetics(&self) -> DefaultAesthetics {
2727
DefaultAesthetics {
2828
defaults: &[
29-
("pos1", DefaultAestheticValue::Required),
29+
// pos1 is optional - if omitted, stat_boxplot synthesises a
30+
// dummy categorical axis so the geom renders a single boxplot
31+
// of the whole pos2 distribution.
32+
("pos1", DefaultAestheticValue::Null),
3033
("pos2", DefaultAestheticValue::Required),
3134
("stroke", DefaultAestheticValue::String("black")),
3235
("fill", DefaultAestheticValue::String("white")),
@@ -46,10 +49,6 @@ impl GeomTrait for Boxplot {
4649
&["pos2"]
4750
}
4851

49-
fn needs_stat_transform(&self, _aesthetics: &Mappings) -> bool {
50-
true
51-
}
52-
5352
fn default_params(&self) -> &'static [super::ParamDefinition] {
5453
const PARAMS: &[ParamDefinition] = &[
5554
ParamDefinition {
@@ -79,6 +78,7 @@ impl GeomTrait for Boxplot {
7978
fn default_remappings(&self) -> DefaultAesthetics {
8079
DefaultAesthetics {
8180
defaults: &[
81+
("pos1", DefaultAestheticValue::Column("pos1")),
8282
("pos2", DefaultAestheticValue::Column("value")),
8383
("pos2end", DefaultAestheticValue::Column("value2")),
8484
("type", DefaultAestheticValue::Column("type")),
@@ -117,9 +117,17 @@ fn stat_boxplot(
117117
let y = get_column_name(aesthetics, "pos2").ok_or_else(|| {
118118
GgsqlError::ValidationError("Boxplot requires 'y' aesthetic mapping".to_string())
119119
})?;
120-
let x = get_column_name(aesthetics, "pos1").ok_or_else(|| {
121-
GgsqlError::ValidationError("Boxplot requires 'x' aesthetic mapping".to_string())
122-
})?;
120+
121+
// pos1 is optional. When the user omits it, wrap the input query with a
122+
// synthetic dummy categorical column and group by that column, so the
123+
// existing GROUP BY / summary pipeline collapses to a single boxplot.
124+
let (working_query, x, use_dummy) = match get_column_name(aesthetics, "pos1") {
125+
Some(col) => (query.to_string(), col, false),
126+
None => {
127+
let dummy_col = naming::stat_column("pos1");
128+
(wrap_with_dummy_axis(query, "pos1"), dummy_col, true)
129+
}
130+
};
123131

124132
// Get coef parameter (validated by ParamConstraint::number_min)
125133
let ParameterValue::Number(coef) = parameters.get("coef").unwrap() else {
@@ -148,17 +156,26 @@ fn stat_boxplot(
148156
}
149157

150158
// Query for boxplot summary statistics
151-
let summary = boxplot_sql_compute_summary(query, &groups, &value_col, coef, dialect);
152-
let stats_query = boxplot_sql_append_outliers(&summary, &groups, &value_col, query, outliers);
159+
let summary =
160+
boxplot_sql_compute_summary(&working_query, &groups, &value_col, coef, dialect);
161+
let stats_query =
162+
boxplot_sql_append_outliers(&summary, &groups, &value_col, &working_query, outliers);
163+
164+
let mut stat_columns = vec![
165+
"type".to_string(),
166+
"value".to_string(),
167+
"value2".to_string(),
168+
];
169+
let mut dummy_columns: Vec<String> = vec![];
170+
if use_dummy {
171+
stat_columns.push("pos1".to_string());
172+
dummy_columns.push("pos1".to_string());
173+
}
153174

154175
Ok(StatResult::Transformed {
155176
query: stats_query,
156-
stat_columns: vec![
157-
"type".to_string(),
158-
"value".to_string(),
159-
"value2".to_string(),
160-
],
161-
dummy_columns: vec![],
177+
stat_columns,
178+
dummy_columns,
162179
consumed_aesthetics: vec!["pos2".to_string()],
163180
})
164181
}
@@ -517,9 +534,10 @@ mod tests {
517534
let boxplot = Boxplot;
518535
let aes = boxplot.aesthetics();
519536

520-
assert!(aes.is_required("pos1"));
537+
// pos1 is optional (omit → dummy categorical axis); pos2 is required.
538+
assert!(!aes.is_required("pos1"));
521539
assert!(aes.is_required("pos2"));
522-
assert_eq!(aes.required().len(), 2);
540+
assert_eq!(aes.required(), vec!["pos2"]);
523541
}
524542

525543
#[test]
@@ -575,7 +593,10 @@ mod tests {
575593
let boxplot = Boxplot;
576594
let remappings = boxplot.default_remappings();
577595

578-
assert_eq!(remappings.defaults.len(), 3);
596+
assert_eq!(remappings.defaults.len(), 4);
597+
assert!(remappings
598+
.defaults
599+
.contains(&("pos1", DefaultAestheticValue::Column("pos1"))));
579600
assert!(remappings
580601
.defaults
581602
.contains(&("pos2", DefaultAestheticValue::Column("value"))));
@@ -587,6 +608,48 @@ mod tests {
587608
.contains(&("type", DefaultAestheticValue::Column("type"))));
588609
}
589610

611+
#[test]
612+
fn test_boxplot_dummy_pos1_when_unmapped() {
613+
use crate::plot::AestheticValue;
614+
let mut aesthetics = Mappings::new();
615+
aesthetics.insert(
616+
"pos2".to_string(),
617+
AestheticValue::standard_column("value".to_string()),
618+
);
619+
let mut parameters: HashMap<String, ParameterValue> = HashMap::new();
620+
parameters.insert("coef".to_string(), ParameterValue::Number(1.5));
621+
parameters.insert("outliers".to_string(), ParameterValue::Boolean(true));
622+
623+
let result = stat_boxplot(
624+
"SELECT * FROM data",
625+
&aesthetics,
626+
&[],
627+
&parameters,
628+
&AnsiDialect,
629+
)
630+
.expect("stat_boxplot should succeed without pos1");
631+
632+
match result {
633+
StatResult::Transformed {
634+
query,
635+
stat_columns,
636+
dummy_columns,
637+
consumed_aesthetics,
638+
} => {
639+
// The wrapped input introduces a synthetic pos1 column that the
640+
// GROUP BY then collapses to a single boxplot.
641+
assert!(query.contains("__ggsql_stat_dummy"));
642+
assert!(query.contains("__ggsql_stat_pos1"));
643+
assert!(stat_columns.contains(&"pos1".to_string()));
644+
assert!(stat_columns.contains(&"type".to_string()));
645+
assert!(stat_columns.contains(&"value".to_string()));
646+
assert_eq!(dummy_columns, vec!["pos1".to_string()]);
647+
assert_eq!(consumed_aesthetics, vec!["pos2".to_string()]);
648+
}
649+
_ => panic!("expected Transformed"),
650+
}
651+
}
652+
590653
#[test]
591654
fn test_boxplot_stat_consumed_aesthetics() {
592655
let boxplot = Boxplot;
@@ -596,13 +659,6 @@ mod tests {
596659
assert_eq!(consumed[0], "pos2");
597660
}
598661

599-
#[test]
600-
fn test_boxplot_needs_stat_transform() {
601-
let boxplot = Boxplot;
602-
let aesthetics = Mappings::new();
603-
assert!(boxplot.needs_stat_transform(&aesthetics));
604-
}
605-
606662
#[test]
607663
fn test_boxplot_display() {
608664
let boxplot = Boxplot;

src/plot/layer/geom/density.rs

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,6 @@ impl GeomTrait for Density {
5454
}
5555
}
5656

57-
fn needs_stat_transform(&self, _aesthetics: &Mappings) -> bool {
58-
true
59-
}
60-
6157
fn default_params(&self) -> &'static [ParamDefinition] {
6258
const PARAMS: &[ParamDefinition] = &[
6359
ParamDefinition {

0 commit comments

Comments
 (0)