Skip to content

Commit 0868fb0

Browse files
teunbrandclaude
andauthored
Decoration for polar panels (#418)
* introduce ProjectionRenderer trait for modular projection handling Replace CoordKind match arms throughout the Vega-Lite writer with a ProjectionRenderer trait. Each projection type (cartesian, polar) now owns its channel mapping and spec transformation logic, making it straightforward to add map projections in the future. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * add Projection::cartesian() and Projection::polar() constructors Reduces boilerplate at call sites that use default aesthetics and empty properties. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * remove spurious Send + Sync from traits * Add panel decoration plumbing to ProjectionRenderer background_layers() and foreground_layers() let projections prepend/append VL layers around the data layers (e.g. grid lines, axis ticks). Both receive resolved scales and the theme config so implementations can derive decoration from break positions and style tokens. Also moves apply_project_transforms and apply_panel_decor from free functions into default methods on the trait (apply_transforms, apply_panel_decor), removing the redundant renderer construction. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Tag decoration layers with description and filter them in tests Decoration layers inserted by apply_panel_decor() now get "description": "background" or "foreground" automatically. Tests use a new data_layer() helper that filters these out by index, so they remain stable regardless of whether a projection adds decoration. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Compute polar point marks in pixel space to align with arc marks Non-arc marks in polar projection (point, line) now compute x/y in the same pixel coordinate space arc marks use: center at (width/2, height/2) with outerRadius = min(width,height)/2. Encodings use scale:null so Vega-Lite treats values as raw positions. Also filters null position values via isValid(), since scale:null bypasses VL's implicit null handling. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Centralise polar coordinate math into shared expression helpers Extracts five VL expression builders (expr_normalize_radius, expr_normalize_theta, expr_polar_x, expr_polar_y, expr_polar_radius) that are now used by data-layer transforms, arc mark radius ranges, and decoration layers. Introduces POLAR_OUTER const for the normalised outer radius. Also extracts polar_properties() from the inline parsing that was duplicated in apply_polar_project. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add polar panel background, grid rings, and grid spokes Implements background decoration layers for polar projections: a filled panel arc, concentric grid rings at radius breaks, and radial grid spokes at theta breaks. Moves numeric break/domain extraction to Scale methods for reuse across the codebase. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add radial axis to polar foreground decoration Draws axis line, tick marks, and labels along the start angle for the radius (pos1) scale. Ticks are centered on full circles and extend outward on partial arcs. Fixes operator precedence in expr_polar_x/y by parenthesising the radius expression. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add angular axis to polar foreground decoration Draws axis arc along the outer edge, radial tick marks at each theta break, and centered text labels beyond the ticks. Label alignment uses center/middle for now — per-datum alignment needs a different approach in Vega-Lite. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Use nested layers for angular axis labels to set per-label text alignment Vega-Lite text marks only support a single align/baseline per layer. Bucket breaks by their computed (align, baseline) in Rust, tag each data row with an _ab field, and emit a sub-layer per unique tag that filters on it and sets the correct mark properties. Also fix clippy warnings (unused variable, unused import, unused mut). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Consolidate projection trait into single apply_projection() entry point Replaces separate apply_transforms() and apply_panel_decor() calls with one apply_projection() method. Moves faceting before projection so decoration layers work correctly in faceted specs. Renames the unclear apply() to transform_layers(). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add PolarPanel struct to centralise polar geometry and expression helpers PolarProjection now holds a PolarPanel with pre-computed angular range, radius bounds, and VL expression strings (signal-based for non-faceted, literal pixels for faceted). Expression helpers are methods on PolarPanel, replacing the free functions. All private methods read from self.panel instead of taking Projection parameters. Also adds is_faceted() to the ProjectionRenderer trait with a default panel_size() that returns container sizing, letting the call site in mod.rs delegate sizing entirely to the projection. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add numeric_breaks() and numeric_domain() to ScaleTypeTrait Discrete and ordinal scales now synthesize numeric positions from their categorical input ranges (breaks [1..n], domain [0.5, n+0.5]) so that polar grid decoration can work uniformly across all scale types. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Support discrete scales, secondary channels, and offsets in polar→cartesian conversion Extends convert_polar_to_cartesian for non-arc polar marks: - Discrete theta/radius domains generate indexof() VL expressions - radius2/theta2 channels converted to x2/y2 using primary domain - Offset channels (radiusOffset/thetaOffset) normalized into polar space when they carry a scale domain, or applied as raw pixel displacements along the radial/tangential directions otherwise - Discrete offsets narrowed by band fraction (0.9) to leave angular gaps between adjacent categories, matching VL band scale padding Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add break_labels() for display-ready axis labels in polar coordinates Adds break_labels() to ScaleTypeTrait, returning (position, label) pairs. Discrete and ordinal scales pair integer positions with input-range category names; continuous scales format break values as strings. Scale.break_labels() applies label_mapping overrides on top (renaming or suppressing to empty string). Polar radial and angular axes now use break_labels() so discrete categories show their names instead of numeric positions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix panel size bug * Fix discrete polar indexof: escape quotes and map OOB values to null indexof() returns -1 for values not in the domain array, which previously produced position 0 (outside the synthesized domain). Now maps to null so VL drops the row. Also escapes single quotes in category names to prevent broken VL expressions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * add some missing tests * Suppress polar axes and grid lines for dummy scales Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Suppress polar decorations for free facet scales Polar grid lines and axes are drawn as manual VL layers positioned from the global scale domain. With free scales each panel has its own domain, so the global positions would be wrong. Suppress them rather than render misleading decorations. Also adds Facet::is_free() and removes the free_scales plumbing from EncodingContext/ScaleContext in favour of reading spec.facet directly. get_projection_renderer() now takes Option<&Facet> instead of a bool. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add radar property to polar projection Nullable boolean `radar` setting on PROJECT TO polar. When null (default), auto-detects from theta scale discreteness. When explicitly true, validates that the angle scale is discrete. Resolved after scale resolution in resolve_projection_properties() so downstream code can read it as a plain boolean. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Render radar polygons for polar decorations When radar mode is active (discrete theta), panel background, grid rings, and angular axis outline use straight-segment polygons instead of circular arcs. Shared helpers: arc_ring, polygon_ring, theta_breaks. Donut panels (inner > 0) trace outer vertices forward then inner reversed so the fill rule leaves the centre hole empty. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Close partial-arc radar polygons with radial edges For partial circles (start != end - 360), polygon_ring now adds vertices at the start and end angles and traces back through the centre (or inner radius) to form a closed wedge shape. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Correct radial axis placement for full-circle radar polygons The start angle bisects a polygon face, which sits at cos(half_span) of the circumscribed radius. Scale the axis line, ticks, and labels inward so they land on the polygon edge rather than beyond it. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Lerp theta offsets along polygon edges in radar mode In radar mode, theta offsets (e.g. jitter) now interpolate linearly toward the adjacent spoke instead of following a circular arc. This keeps displaced points inside the polygon panel boundaries. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Correct radar polygon and axis radii at partial-arc boundaries Partial-arc start/end vertices are now pulled inward by cos(angle_to_nearest_break) so boundary faces are flush with inter-break edges. The radial axis correction is extended from full-circle-only to all radar panels. Theta offset lerp targets are clamped to [start, end] so boundary spokes lerp toward the panel edge. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * add to docs * Require >2 angle categories for radar mode Radar plots with only 1–2 categories degenerate into a line or single axis, so suppress auto-detection and reject explicit `radar => true` when the theta scale has ≤2 levels. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * bit of polishing * Centralise scale info on PolarContext via AxisInfo struct Introduce AxisInfo (domain, breaks, labels, is_free) built from scales at construction time. Zero-range domains normalize to None upfront, eliminating downstream guards. Add is_full_circle and angle_breaks_radians as derived fields on PolarContext. This simplifies expr_normalize_radius/theta, all decoration methods (grid_rings, grid_spokes, radial_axis, angular_axis, panel_arc), convert_polar_to_cartesian, and polygon_ring — which no longer need scale/domain/thetas parameters passed through call chains. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Remove dead DataFrame clone from apply_polar_project The polar projection never transforms the DataFrame — it only modifies the VL spec. Drop the data parameter and Option<DataFrame> return from transform_layers and apply_projection. Also fix 7 unnecessary mut bindings in tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Use shared escape_vega_string for all Vega expression escaping encoding.rs escaped single quotes but not backslashes in label remap expressions. Consolidate all three call sites to use the existing escape_vega_string helper, which handles both. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Deduplicate discrete/ordinal scale methods into shared helpers Extract categorical_numeric_breaks, categorical_numeric_domain, and categorical_break_labels into scale_type/mod.rs. Both Discrete and Ordinal now delegate to these instead of duplicating the logic. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * cargo fmt + clippy warnings * relax angle tolerance * add news bullets --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6bdf2a9 commit 0868fb0

19 files changed

Lines changed: 3344 additions & 513 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
## [Unreleased]
22

3+
### Added
4+
5+
- Added panel decorations (grid lines, axes, background) for polar coordinates (#156).
6+
- Added `radar` setting to polar coordinates for making radar plots (#418).
7+
38
## 0.3.2 - 2026-05-05
49

510
### Fixed

doc/get_started/the_rest.qmd

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,11 +84,12 @@ DRAW bar
8484

8585
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.
8686

87-
If we instead map the species to angle we end up with a rose plot
87+
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.
8888

8989
```{ggsql}
9090
VISUALISE species AS angle, species AS fill FROM ggsql:penguins
9191
DRAW bar
92+
PROJECT TO polar SETTING radar => false
9293
```
9394

9495
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:

doc/syntax/coord/polar.qmd

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ This maps `y` to radius and `x` to angle. This is useful when converting from a
3030
- `0` = full pie (no hole)
3131
- `0.3` = donut with 30% hole
3232
- `0.5` = donut with 50% hole
33+
* `radar`: Should the plot be displayed as a radar plot? Defaults to `null`.
34+
- `null` = Detect whether there is discrete data at the angle scale with more than 2 categories, in which case this becomes `true`, otherwise `false`.
35+
- `true` = Display discrete data as a radar plot. This is incompatible with continuous data at the angle scale.
36+
- `false` = Display classic, circular polar coordinates.
3337

3438
## Examples
3539

@@ -70,10 +74,12 @@ This creates a gauge chart spanning from the 9 o'clock to 3 o'clock position (a
7074
```{ggsql}
7175
VISUALISE species AS fill FROM ggsql:penguins
7276
DRAW bar
77+
SCALE angle SETTING expand => 0
7378
PROJECT TO polar
7479
SETTING end => 270
7580
```
7681
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).
82+
We have turned off the scale expansion by using `SCALE angle SETTING expand => 0`.
7783

7884
### Donut chart with 50% hole
7985
```{ggsql}
@@ -97,7 +103,23 @@ This creates a donut chart with a smaller hole (30% of the radius).
97103
```{ggsql}
98104
VISUALISE species AS fill FROM ggsql:penguins
99105
DRAW bar
106+
SCALE angle SETTING expand => 0
100107
PROJECT TO polar
101108
SETTING start => -90, end => 90, inner => 0.5
102109
```
103110
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.
111+
112+
### Radar chart
113+
```{ggsql}
114+
WITH data(angle, radius) AS (VALUES
115+
('A', 5),
116+
('B', 2),
117+
('C', 4),
118+
('D', 7),
119+
('E', 6)
120+
)
121+
VISUALISE angle, radius FROM data
122+
DRAW polygon
123+
```
124+
The key to drawing a radar chart is having discrete data for the `angle` aesthetic.
125+
You can turn off the radar chart by using `PROJECT TO polar SETTING radar => false`.

src/execute/mod.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ use crate::parser;
2727
use crate::plot::aesthetic::{is_position_aesthetic, AestheticContext};
2828
use crate::plot::facet::{resolve_properties as resolve_facet_properties, FacetDataContext};
2929
use crate::plot::layer::is_transposed;
30+
use crate::plot::projection::resolve_projection_properties;
3031
use crate::plot::{AestheticValue, Layer, Scale, ScaleTypeKind, Schema};
3132
use crate::{DataFrame, DataSource, GgsqlError, Plot, Result};
3233
use std::collections::{HashMap, HashSet};
@@ -1420,6 +1421,13 @@ pub fn prepare_data_with_reader(query: &str, reader: &dyn Reader) -> Result<Prep
14201421
scale::resolve_scales(spec, &mut data_map)?;
14211422
}
14221423

1424+
// Resolve projection properties that depend on scale types (e.g., radar)
1425+
for spec in &mut specs {
1426+
if let Some(ref mut project) = spec.project {
1427+
resolve_projection_properties(project, &spec.scales)?;
1428+
}
1429+
}
1430+
14231431
// Resolve facet properties (after data is available)
14241432
for spec in &mut specs {
14251433
// Get position aesthetic names from the aesthetic context (coord-specific)

src/plot/facet/types.rs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,23 @@ impl Facet {
6464
pub fn is_grid(&self) -> bool {
6565
self.layout.is_grid()
6666
}
67+
68+
/// Whether the given position aesthetic has free (independent) scales.
69+
///
70+
/// Accepts internal position names and their variants:
71+
/// `"pos1"`, `"pos1min"`, `"pos1end"`, `"pos2"`, `"pos2max"`, `"pos3"`, etc.
72+
pub fn is_free(&self, aesthetic: &str) -> bool {
73+
use crate::plot::ArrayElement;
74+
let Some(ParameterValue::Array(arr)) = self.properties.get("free") else {
75+
return false;
76+
};
77+
for (idx, prefix) in ["pos1", "pos2", "pos3"].iter().enumerate() {
78+
if aesthetic.starts_with(prefix) {
79+
return matches!(arr.get(idx), Some(ArrayElement::Boolean(true)));
80+
}
81+
}
82+
false
83+
}
6784
}
6885

6986
impl FacetLayout {
@@ -221,3 +238,46 @@ impl FacetLayout {
221238
}
222239
}
223240
}
241+
242+
#[cfg(test)]
243+
mod tests {
244+
use super::*;
245+
use crate::plot::ArrayElement;
246+
247+
fn facet_with_free(free: Vec<bool>) -> Facet {
248+
let mut f = Facet::new(FacetLayout::Wrap {
249+
variables: vec!["g".to_string()],
250+
});
251+
f.properties.insert(
252+
"free".to_string(),
253+
ParameterValue::Array(free.into_iter().map(ArrayElement::Boolean).collect()),
254+
);
255+
f
256+
}
257+
258+
#[test]
259+
fn is_free_checks_position_and_variants() {
260+
let f = facet_with_free(vec![true, false]);
261+
assert!(f.is_free("pos1"));
262+
assert!(f.is_free("pos1min"));
263+
assert!(f.is_free("pos1end"));
264+
assert!(!f.is_free("pos2"));
265+
assert!(!f.is_free("pos2max"));
266+
}
267+
268+
#[test]
269+
fn is_free_returns_false_for_material_aesthetics() {
270+
let f = facet_with_free(vec![true, true]);
271+
assert!(!f.is_free("color"));
272+
assert!(!f.is_free("fill"));
273+
}
274+
275+
#[test]
276+
fn is_free_returns_false_without_free_property() {
277+
let f = Facet::new(FacetLayout::Wrap {
278+
variables: vec!["g".to_string()],
279+
});
280+
assert!(!f.is_free("pos1"));
281+
assert!(!f.is_free("pos2"));
282+
}
283+
}

src/plot/main.rs

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -739,14 +739,10 @@ mod tests {
739739
#[test]
740740
fn test_label_transform_with_default_project() {
741741
// LABEL x/y with default cartesian should transform to pos1/pos2
742-
use crate::plot::projection::{Coord, Projection};
742+
use crate::plot::projection::Projection;
743743

744744
let mut spec = Plot::new();
745-
spec.project = Some(Projection {
746-
coord: Coord::cartesian(),
747-
aesthetics: vec!["x".to_string(), "y".to_string()],
748-
properties: HashMap::new(),
749-
});
745+
spec.project = Some(Projection::cartesian());
750746
spec.labels = Some(Labels {
751747
labels: HashMap::from([
752748
("x".to_string(), Some("X Axis".to_string())),
@@ -827,14 +823,10 @@ mod tests {
827823
#[test]
828824
fn test_label_transform_preserves_material() {
829825
// LABEL title/color should be preserved unchanged
830-
use crate::plot::projection::{Coord, Projection};
826+
use crate::plot::projection::Projection;
831827

832828
let mut spec = Plot::new();
833-
spec.project = Some(Projection {
834-
coord: Coord::cartesian(),
835-
aesthetics: vec!["x".to_string(), "y".to_string()],
836-
properties: HashMap::new(),
837-
});
829+
spec.project = Some(Projection::cartesian());
838830
spec.labels = Some(Labels {
839831
labels: HashMap::from([
840832
("title".to_string(), Some("My Chart".to_string())),

src/plot/projection/coord/polar.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ impl CoordTrait for Polar {
4242
default: DefaultParamValue::Null,
4343
constraint: ParamConstraint::number_range(0.0, 1.0),
4444
},
45+
ParamDefinition {
46+
name: "radar",
47+
default: DefaultParamValue::Null,
48+
constraint: ParamConstraint::boolean(),
49+
},
4550
];
4651
PARAMS
4752
}
@@ -75,7 +80,8 @@ mod tests {
7580
assert!(names.contains(&"start"));
7681
assert!(names.contains(&"end"));
7782
assert!(names.contains(&"inner"));
78-
assert_eq!(defaults.len(), 4);
83+
assert!(names.contains(&"radar"));
84+
assert_eq!(defaults.len(), 5);
7985
}
8086

8187
#[test]

src/plot/projection/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,5 @@ mod resolve;
77
mod types;
88

99
pub use coord::{Coord, CoordKind, CoordTrait};
10-
pub use resolve::resolve_coord;
10+
pub use resolve::{resolve_coord, resolve_projection_properties};
1111
pub use types::Projection;

0 commit comments

Comments
 (0)