Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
ea1df2c
introduce ProjectionRenderer trait for modular projection handling
teunbrand Apr 27, 2026
6483456
add Projection::cartesian() and Projection::polar() constructors
teunbrand Apr 27, 2026
8700c84
Merge branch 'main' into polar_panel_decor
teunbrand Apr 27, 2026
c66828b
remove spurious Send + Sync from traits
teunbrand Apr 27, 2026
84055d8
Add panel decoration plumbing to ProjectionRenderer
teunbrand Apr 27, 2026
191fff9
Tag decoration layers with description and filter them in tests
teunbrand Apr 27, 2026
fd14b4d
Compute polar point marks in pixel space to align with arc marks
teunbrand Apr 28, 2026
eb584d9
Centralise polar coordinate math into shared expression helpers
teunbrand Apr 28, 2026
7bda1a9
Add polar panel background, grid rings, and grid spokes
teunbrand Apr 28, 2026
cddcf15
Add radial axis to polar foreground decoration
teunbrand Apr 28, 2026
040fb70
Add angular axis to polar foreground decoration
teunbrand Apr 28, 2026
a907c38
Use nested layers for angular axis labels to set per-label text align…
teunbrand Apr 28, 2026
dcb562f
Consolidate projection trait into single apply_projection() entry point
teunbrand Apr 29, 2026
d04aea5
Add PolarPanel struct to centralise polar geometry and expression hel…
teunbrand Apr 29, 2026
bda8f75
Add numeric_breaks() and numeric_domain() to ScaleTypeTrait
teunbrand Apr 29, 2026
eea14ec
Support discrete scales, secondary channels, and offsets in polar→car…
teunbrand Apr 29, 2026
83480fa
Add break_labels() for display-ready axis labels in polar coordinates
teunbrand Apr 29, 2026
c169c2e
fix panel size bug
teunbrand Apr 29, 2026
aa9a75b
Fix discrete polar indexof: escape quotes and map OOB values to null
teunbrand Apr 29, 2026
b5359bf
add some missing tests
teunbrand Apr 29, 2026
5a112ee
Merge branch 'main' into polar_panel_decor
teunbrand Apr 30, 2026
5240055
Suppress polar axes and grid lines for dummy scales
teunbrand Apr 30, 2026
5fb58b1
Suppress polar decorations for free facet scales
teunbrand Apr 30, 2026
34c13d9
Add radar property to polar projection
teunbrand Apr 30, 2026
727983d
Render radar polygons for polar decorations
teunbrand Apr 30, 2026
0c1b080
Close partial-arc radar polygons with radial edges
teunbrand Apr 30, 2026
30728af
Correct radial axis placement for full-circle radar polygons
teunbrand Apr 30, 2026
d5c935d
Lerp theta offsets along polygon edges in radar mode
teunbrand Apr 30, 2026
5cf4b21
Correct radar polygon and axis radii at partial-arc boundaries
teunbrand Apr 30, 2026
4717c59
add to docs
teunbrand May 1, 2026
b2937ac
Require >2 angle categories for radar mode
teunbrand May 1, 2026
482ad82
bit of polishing
teunbrand May 1, 2026
c6dffcb
Centralise scale info on PolarContext via AxisInfo struct
teunbrand May 1, 2026
e94d1ae
Remove dead DataFrame clone from apply_polar_project
teunbrand May 1, 2026
3a6b87f
Use shared escape_vega_string for all Vega expression escaping
teunbrand May 1, 2026
627eee3
Deduplicate discrete/ordinal scale methods into shared helpers
teunbrand May 1, 2026
488f978
cargo fmt + clippy warnings
teunbrand May 1, 2026
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
3 changes: 2 additions & 1 deletion doc/get_started/the_rest.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,12 @@ DRAW bar

See how we didn't have to specify the polar coordinate system in the last example because we have a mapping to radius, allowing ggsql to deduce the coordinate system automatically.

If we instead map the species to angle we end up with a rose plot
If we instead map the species to angle we end up with a rose plot. We turn off the automatic conversion to radar plots for discrete angle categories here.

```{ggsql}
VISUALISE species AS angle, species AS fill FROM ggsql:penguins
DRAW bar
PROJECT TO polar SETTING radar => false
```

Moving back to the regular pie chart, we might be interested in comparing how the species distribution varies by sex. We can do this with faceting:
Expand Down
22 changes: 22 additions & 0 deletions doc/syntax/coord/polar.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ This maps `y` to radius and `x` to angle. This is useful when converting from a
- `0` = full pie (no hole)
- `0.3` = donut with 30% hole
- `0.5` = donut with 50% hole
* `radar`: Should the plot be displayed as a radar plot? Defaults to `null`.
- `null` = Detect whether there is discrete data at the angle scale with more than 2 categories, in which case this becomes `true`, otherwise `false`.
- `true` = Display discrete data as a radar plot. This is incompatible with continuous data at the angle scale.
- `false` = Display classic, circular polar coordinates.

## Examples

Expand Down Expand Up @@ -70,10 +74,12 @@ This creates a gauge chart spanning from the 9 o'clock to 3 o'clock position (a
```{ggsql}
VISUALISE species AS fill FROM ggsql:penguins
DRAW bar
SCALE angle SETTING expand => 0
PROJECT TO polar
SETTING end => 270
```
This creates a pie chart using only 270° (three-quarters of a circle), starting from 0° (12 o'clock) and ending at 270° (9 o'clock).
We have turned off the scale expansion by using `SCALE angle SETTING expand => 0`.

### Donut chart with 50% hole
```{ggsql}
Expand All @@ -97,7 +103,23 @@ This creates a donut chart with a smaller hole (30% of the radius).
```{ggsql}
VISUALISE species AS fill FROM ggsql:penguins
DRAW bar
SCALE angle SETTING expand => 0
PROJECT TO polar
SETTING start => -90, end => 90, inner => 0.5
```
This combines the `start`, `end`, and `inner` settings to create a half-circle donut chart (gauge style) spanning from 9 o'clock to 3 o'clock with a 50% hole.

### Radar chart
```{ggsql}
WITH data(angle, radius) AS (VALUES
('A', 5),
('B', 2),
('C', 4),
('D', 7),
('E', 6)
)
VISUALISE angle, radius FROM data
DRAW polygon
```
The key to drawing a radar chart is having discrete data for the `angle` aesthetic.
You can turn off the radar chart by using `PROJECT TO polar SETTING radar => false`.
8 changes: 8 additions & 0 deletions src/execute/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ use crate::parser;
use crate::plot::aesthetic::{is_position_aesthetic, AestheticContext};
use crate::plot::facet::{resolve_properties as resolve_facet_properties, FacetDataContext};
use crate::plot::layer::is_transposed;
use crate::plot::projection::resolve_projection_properties;
use crate::plot::{AestheticValue, Layer, Scale, ScaleTypeKind, Schema};
use crate::{DataFrame, DataSource, GgsqlError, Plot, Result};
use std::collections::{HashMap, HashSet};
Expand Down Expand Up @@ -1413,6 +1414,13 @@ pub fn prepare_data_with_reader(query: &str, reader: &dyn Reader) -> Result<Prep
scale::resolve_scales(spec, &mut data_map)?;
}

// Resolve projection properties that depend on scale types (e.g., radar)
for spec in &mut specs {
if let Some(ref mut project) = spec.project {
resolve_projection_properties(project, &spec.scales)?;
}
}

// Resolve facet properties (after data is available)
for spec in &mut specs {
// Get position aesthetic names from the aesthetic context (coord-specific)
Expand Down
60 changes: 60 additions & 0 deletions src/plot/facet/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,23 @@ impl Facet {
pub fn is_grid(&self) -> bool {
self.layout.is_grid()
}

/// Whether the given position aesthetic has free (independent) scales.
///
/// Accepts internal position names and their variants:
/// `"pos1"`, `"pos1min"`, `"pos1end"`, `"pos2"`, `"pos2max"`, `"pos3"`, etc.
pub fn is_free(&self, aesthetic: &str) -> bool {
use crate::plot::ArrayElement;
let Some(ParameterValue::Array(arr)) = self.properties.get("free") else {
return false;
};
for (idx, prefix) in ["pos1", "pos2", "pos3"].iter().enumerate() {
if aesthetic.starts_with(prefix) {
return matches!(arr.get(idx), Some(ArrayElement::Boolean(true)));
}
}
false
}
}

impl FacetLayout {
Expand Down Expand Up @@ -221,3 +238,46 @@ impl FacetLayout {
}
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::plot::ArrayElement;

fn facet_with_free(free: Vec<bool>) -> Facet {
let mut f = Facet::new(FacetLayout::Wrap {
variables: vec!["g".to_string()],
});
f.properties.insert(
"free".to_string(),
ParameterValue::Array(free.into_iter().map(ArrayElement::Boolean).collect()),
);
f
}

#[test]
fn is_free_checks_position_and_variants() {
let f = facet_with_free(vec![true, false]);
assert!(f.is_free("pos1"));
assert!(f.is_free("pos1min"));
assert!(f.is_free("pos1end"));
assert!(!f.is_free("pos2"));
assert!(!f.is_free("pos2max"));
}

#[test]
fn is_free_returns_false_for_material_aesthetics() {
let f = facet_with_free(vec![true, true]);
assert!(!f.is_free("color"));
assert!(!f.is_free("fill"));
}

#[test]
fn is_free_returns_false_without_free_property() {
let f = Facet::new(FacetLayout::Wrap {
variables: vec!["g".to_string()],
});
assert!(!f.is_free("pos1"));
assert!(!f.is_free("pos2"));
}
}
16 changes: 4 additions & 12 deletions src/plot/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -739,14 +739,10 @@ mod tests {
#[test]
fn test_label_transform_with_default_project() {
// LABEL x/y with default cartesian should transform to pos1/pos2
use crate::plot::projection::{Coord, Projection};
use crate::plot::projection::Projection;

let mut spec = Plot::new();
spec.project = Some(Projection {
coord: Coord::cartesian(),
aesthetics: vec!["x".to_string(), "y".to_string()],
properties: HashMap::new(),
});
spec.project = Some(Projection::cartesian());
spec.labels = Some(Labels {
labels: HashMap::from([
("x".to_string(), Some("X Axis".to_string())),
Expand Down Expand Up @@ -827,14 +823,10 @@ mod tests {
#[test]
fn test_label_transform_preserves_material() {
// LABEL title/color should be preserved unchanged
use crate::plot::projection::{Coord, Projection};
use crate::plot::projection::Projection;

let mut spec = Plot::new();
spec.project = Some(Projection {
coord: Coord::cartesian(),
aesthetics: vec!["x".to_string(), "y".to_string()],
properties: HashMap::new(),
});
spec.project = Some(Projection::cartesian());
spec.labels = Some(Labels {
labels: HashMap::from([
("title".to_string(), Some("My Chart".to_string())),
Expand Down
8 changes: 7 additions & 1 deletion src/plot/projection/coord/polar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ impl CoordTrait for Polar {
default: DefaultParamValue::Null,
constraint: ParamConstraint::number_range(0.0, 1.0),
},
ParamDefinition {
name: "radar",
default: DefaultParamValue::Null,
constraint: ParamConstraint::boolean(),
},
];
PARAMS
}
Expand Down Expand Up @@ -75,7 +80,8 @@ mod tests {
assert!(names.contains(&"start"));
assert!(names.contains(&"end"));
assert!(names.contains(&"inner"));
assert_eq!(defaults.len(), 4);
assert!(names.contains(&"radar"));
assert_eq!(defaults.len(), 5);
}

#[test]
Expand Down
2 changes: 1 addition & 1 deletion src/plot/projection/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,5 @@ mod resolve;
mod types;

pub use coord::{Coord, CoordKind, CoordTrait};
pub use resolve::resolve_coord;
pub use resolve::{resolve_coord, resolve_projection_properties};
pub use types::Projection;
Loading
Loading