Skip to content
Merged
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
8 changes: 4 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1480,7 +1480,7 @@ PROJECT [<aesthetic>, ...] TO <coord_type> [SETTING <properties>]
| Coord Type | Default Aesthetics | Description |
|------------|-------------------|-------------|
| `cartesian` | `x`, `y` | Standard x/y Cartesian coordinates |
| `polar` | `theta`, `radius` | Polar coordinates (for pie charts, rose plots) |
| `polar` | `angle`, `radius` | Polar coordinates (for pie charts, rose plots) |

**Flipping Axes**:

Expand All @@ -1505,7 +1505,7 @@ Note: For axis limits, use `SCALE x FROM [min, max]` or `SCALE y FROM [min, max]

**Polar**:

- `theta => <aesthetic>` - Which aesthetic maps to angle (defaults to `y`)
- No special properties (angle/radius aesthetics are used directly)

**Important Notes**:

Expand Down Expand Up @@ -1534,10 +1534,10 @@ PROJECT y, x TO cartesian
-- Custom aesthetic names
PROJECT myX, myY TO cartesian

-- Polar for pie chart (using default theta/radius aesthetics)
-- Polar for pie chart (using default angle/radius aesthetics)
PROJECT TO polar

-- Polar with y/x aesthetics (y becomes theta, x becomes radius)
-- Polar with y/x aesthetics (y becomes angle, x becomes radius)
PROJECT y, x TO polar

-- Polar with start angle offset (3 o'clock position)
Expand Down
2 changes: 1 addition & 1 deletion doc/ggsql.xml
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@
<item>xlim</item>
<item>ylim</item>
<item>ratio</item>
<item>theta</item>
<item>angle</item>
<item>clip</item>
</list>

Expand Down
4 changes: 2 additions & 2 deletions doc/syntax/clause/project.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,6 @@ This clause behaves much like the `SETTINGS` clause in `DRAW`, in that it allows
If you do not provide a `PROJECT` clause then the coordinate system will be picked for you based on the mappings in your query. The logic is as follows

* If `x`, `y` or any of their variants are mapped to, a Cartesian coordinate system is used
* If `theta`, `radius` or any of their variants are mapped to, a polar coordinate system is used
* If `angle`, `radius` or any of their variants are mapped to, a polar coordinate system is used
* If none of the above applies, the plot defaults to a Cartesian coordinate system
* If multiple applies (e.g. mapping to both x and theta) an error is thrown
* If multiple applies (e.g. mapping to both x and angle) an error is thrown
10 changes: 5 additions & 5 deletions doc/syntax/coord/polar.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -8,32 +8,32 @@ The polar coordinate system interprets its primary aesthetic as the angular posi
The polar coordinate system has the following default positional aesthetics which will be used if no others have been provided:

* **Primary**: `radius` (distance from center)
* **Secondary**: `theta` (angular position)
* **Secondary**: `angle` (angular position)

Users can provide their own aesthetic names if needed. For example, if using `x` and `y` aesthetics:

```ggsql
PROJECT y, x TO polar
```

This maps `y` to radius and `x` to theta (angle). This is useful when converting from a cartesian coordinate system without editing all the mappings.
This maps `y` to radius and `x` to angle. This is useful when converting from a cartesian coordinate system without editing all the mappings.

## Settings
* `clip`: Should data be removed if it appears outside the bounds of the coordinate system. Defaults to `true`
* `start`: The starting angle in degrees for the theta scale. Controls where "0" on the angular axis begins. Defaults to `0` (12 o'clock position).
* `start`: The starting angle in degrees for the angle scale. Controls where "0" on the angular axis begins. Defaults to `0` (12 o'clock position).
- `0` = 12 o'clock position (top)
- `90` = 3 o'clock position (right)
- `-90` or `270` = 9 o'clock position (left)
- `180` = 6 o'clock position (bottom)
* `end`: The ending angle in degrees for the theta scale. Defaults to `start + 360` (a full circle). Use this with `start` to create partial polar plots like gauge charts or half-circle visualizations.
* `end`: The ending angle in degrees for the angle scale. Defaults to `start + 360` (a full circle). Use this with `start` to create partial polar plots like gauge charts or half-circle visualizations.
* `inner`: The inner radius as a proportion (0 to 1) of the outer radius. Defaults to `0` (no hole). Setting this creates a donut chart where the inner portion is empty.
- `0` = full pie (no hole)
- `0.3` = donut with 30% hole
- `0.5` = donut with 50% hole

## Examples

### Pie chart using theta/radius aesthetics
### Pie chart using angle/radius aesthetics
```{ggsql}
VISUALISE species AS fill FROM ggsql:penguins
DRAW bar
Expand Down
2 changes: 1 addition & 1 deletion ggsql-vscode/syntaxes/ggsql.tmLanguage.json
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,7 @@
},
{
"name": "support.type.property.ggsql",
"match": "\\b(xlim|ylim|ratio|theta|clip)\\b"
"match": "\\b(xlim|ylim|ratio|angle|clip)\\b"
},
{ "include": "#common-clause-patterns" }
]
Expand Down
20 changes: 10 additions & 10 deletions src/parser/builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,7 @@ fn build_visualise_statement(node: &Node, source: &SourceTree) -> Result<Plot> {
// This must happen after all clauses are processed (especially PROJECT and FACET)
spec.initialize_aesthetic_context();

// Transform all aesthetic keys from user-facing (x/y or theta/radius) to internal (pos1/pos2)
// Transform all aesthetic keys from user-facing (x/y or angle/radius) to internal (pos1/pos2)
// This enables generic handling throughout the pipeline and must happen before merge
// since geom definitions use internal names for their supported/required aesthetics
spec.transform_aesthetics_to_internal();
Expand Down Expand Up @@ -1343,7 +1343,7 @@ mod tests {
fn test_project_default_aesthetics_polar() {
let query = r#"
VISUALISE
DRAW bar MAPPING category AS theta, value AS radius
DRAW bar MAPPING category AS angle, value AS radius
PROJECT TO polar
"#;

Expand All @@ -1354,7 +1354,7 @@ mod tests {
let project = specs[0].project.as_ref().unwrap();
assert_eq!(
project.aesthetics,
vec!["radius".to_string(), "theta".to_string()]
vec!["radius".to_string(), "angle".to_string()]
);
}

Expand Down Expand Up @@ -3399,8 +3399,8 @@ mod tests {
}

#[test]
fn test_infer_polar_from_theta_radius_mappings() {
let query = "VISUALISE DRAW bar MAPPING cat AS theta, val AS radius";
fn test_infer_polar_from_angle_radius_mappings() {
let query = "VISUALISE DRAW bar MAPPING cat AS angle, val AS radius";

let result = parse_test_query(query);
assert!(result.is_ok());
Expand All @@ -3409,15 +3409,15 @@ mod tests {
// Should infer polar projection
let project = specs[0].project.as_ref().unwrap();
assert_eq!(project.coord.coord_kind(), CoordKind::Polar);
assert_eq!(project.aesthetics, vec!["radius", "theta"]);
assert_eq!(project.aesthetics, vec!["radius", "angle"]);
}

#[test]
fn test_explicit_project_overrides_inference() {
// Explicitly use cartesian even though mappings use theta
// Explicitly use cartesian even though mappings use angle
let query = r#"
VISUALISE
DRAW bar MAPPING cat AS theta, val AS radius
DRAW bar MAPPING cat AS angle, val AS radius
PROJECT TO cartesian
"#;

Expand All @@ -3432,8 +3432,8 @@ mod tests {

#[test]
fn test_conflicting_aesthetics_error() {
// Using both x and theta should error
let query = "VISUALISE DRAW point MAPPING a AS x, b AS theta";
// Using both x and angle should error
let query = "VISUALISE DRAW point MAPPING a AS x, b AS angle";

let result = parse_test_query(query);
assert!(result.is_err());
Expand Down
32 changes: 16 additions & 16 deletions src/plot/aesthetic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
//! # Internal vs User-Facing Aesthetics
//!
//! The pipeline uses internal positional aesthetic names (pos1, pos2, etc.) that are
//! transformed from user-facing names (x/y or theta/radius) early in the pipeline
//! transformed from user-facing names (x/y or angle/radius) early in the pipeline
//! and transformed back for output. This is handled by `AestheticContext`.

use std::collections::HashMap;
Expand Down Expand Up @@ -87,7 +87,7 @@ pub const NON_POSITIONAL: &[&str] = &[
/// Comprehensive context for aesthetic operations.
///
/// Uses HashMaps for efficient O(1) lookups between user-facing and internal aesthetic names.
/// Used to transform between user-facing aesthetic names (x/y or theta/radius)
/// Used to transform between user-facing aesthetic names (x/y or angle/radius)
/// and internal names (pos1/pos2), as well as facet aesthetics (panel/row/column)
/// to internal facet names (facet1/facet2).
///
Expand All @@ -102,8 +102,8 @@ pub const NON_POSITIONAL: &[&str] = &[
/// assert_eq!(ctx.map_user_to_internal("ymin"), Some("pos2min"));
///
/// // For polar coords
/// let ctx = AestheticContext::from_static(&["theta", "radius"], &[]);
/// assert_eq!(ctx.map_user_to_internal("theta"), Some("pos1"));
/// let ctx = AestheticContext::from_static(&["angle", "radius"], &[]);
/// assert_eq!(ctx.map_user_to_internal("angle"), Some("pos1"));
/// assert_eq!(ctx.map_user_to_internal("radius"), Some("pos2"));
///
/// // With facets
Expand Down Expand Up @@ -212,7 +212,7 @@ impl AestheticContext {

/// Map user aesthetic (positional or facet) to internal name.
///
/// Positional: "x" → "pos1", "ymin" → "pos2min", "theta" → "pos1"
/// Positional: "x" → "pos1", "ymin" → "pos2min", "angle" → "pos1"
/// Facet: "panel" → "facet1", "row" → "facet1", "column" → "facet2"
///
/// Note: Facet mappings work regardless of whether a FACET clause exists,
Expand Down Expand Up @@ -242,7 +242,7 @@ impl AestheticContext {

/// Map internal aesthetic to user-facing name (reverse of map_user_to_internal).
///
/// Positional: "pos1" → "x", "pos2min" → "ymin", "pos1" → "theta" (for polar)
/// Positional: "pos1" → "x", "pos2min" → "ymin", "pos1" → "angle" (for polar)
/// Facet: "facet1" → "panel" (wrap), "facet1" → "row" (grid), "facet2" → "column" (grid)
/// Non-positional: "color" → "color" (unchanged)
///
Expand Down Expand Up @@ -331,7 +331,7 @@ impl AestheticContext {
&self.internal_primaries
}

/// Get user positional aesthetics (x, y or theta, radius or custom names)
/// Get user positional aesthetics (x, y or angle, radius or custom names)
pub fn user_positional(&self) -> &[String] {
&self.user_primaries
}
Expand Down Expand Up @@ -515,7 +515,7 @@ mod tests {
assert!(!is_positional_aesthetic("x"));
assert!(!is_positional_aesthetic("y"));
assert!(!is_positional_aesthetic("xmin"));
assert!(!is_positional_aesthetic("theta"));
assert!(!is_positional_aesthetic("angle"));

// Non-positional
assert!(!is_positional_aesthetic("color"));
Expand Down Expand Up @@ -549,10 +549,10 @@ mod tests {

#[test]
fn test_aesthetic_context_polar() {
let ctx = AestheticContext::from_static(&["theta", "radius"], &[]);
let ctx = AestheticContext::from_static(&["angle", "radius"], &[]);

// User positional names
assert_eq!(ctx.user_positional(), &["theta", "radius"]);
assert_eq!(ctx.user_positional(), &["angle", "radius"]);

// Primary internal names
let primary: Vec<&str> = ctx
Expand Down Expand Up @@ -586,12 +586,12 @@ mod tests {

#[test]
fn test_aesthetic_context_polar_mapping() {
let ctx = AestheticContext::from_static(&["theta", "radius"], &[]);
let ctx = AestheticContext::from_static(&["angle", "radius"], &[]);

// User to internal
assert_eq!(ctx.map_user_to_internal("theta"), Some("pos1"));
assert_eq!(ctx.map_user_to_internal("angle"), Some("pos1"));
assert_eq!(ctx.map_user_to_internal("radius"), Some("pos2"));
assert_eq!(ctx.map_user_to_internal("thetaend"), Some("pos1end"));
assert_eq!(ctx.map_user_to_internal("angleend"), Some("pos1end"));
assert_eq!(ctx.map_user_to_internal("radiusmin"), Some("pos2min"));
}

Expand Down Expand Up @@ -687,14 +687,14 @@ mod tests {

#[test]
fn test_aesthetic_context_internal_to_user_polar() {
let ctx = AestheticContext::from_static(&["theta", "radius"], &[]);
let ctx = AestheticContext::from_static(&["angle", "radius"], &[]);

// Primary aesthetics map to polar names
assert_eq!(ctx.map_internal_to_user("pos1"), "theta");
assert_eq!(ctx.map_internal_to_user("pos1"), "angle");
assert_eq!(ctx.map_internal_to_user("pos2"), "radius");

// Variants
assert_eq!(ctx.map_internal_to_user("pos1end"), "thetaend");
assert_eq!(ctx.map_internal_to_user("pos1end"), "angleend");
assert_eq!(ctx.map_internal_to_user("pos2min"), "radiusmin");
assert_eq!(ctx.map_internal_to_user("pos2max"), "radiusmax");
}
Expand Down
24 changes: 12 additions & 12 deletions src/plot/facet/resolve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ fn compute_default_ncol(num_levels: usize) -> i64 {
///
/// * `facet` - The facet to resolve
/// * `context` - Data context with unique values
/// * `positional_names` - Valid positional aesthetic names (e.g., ["x", "y"] or ["theta", "radius"])
/// * `positional_names` - Valid positional aesthetic names (e.g., ["x", "y"] or ["angle", "radius"])
pub fn resolve_properties(
facet: &mut Facet,
context: &FacetDataContext,
Expand Down Expand Up @@ -157,7 +157,7 @@ pub fn resolve_properties(
/// # Arguments
///
/// * `facet` - The facet to validate
/// * `positional_names` - Valid positional aesthetic names (e.g., ["x", "y"] or ["theta", "radius"])
/// * `positional_names` - Valid positional aesthetic names (e.g., ["x", "y"] or ["angle", "radius"])
fn validate_free_property(facet: &Facet, positional_names: &[&str]) -> Result<(), String> {
if let Some(value) = facet.properties.get("free") {
match value {
Expand Down Expand Up @@ -239,7 +239,7 @@ fn format_options(names: &[&str]) -> String {
///
/// Transforms user-provided values to a boolean vector (position-indexed):
/// - User writes: `free => 'x'` → stored as: `free => [true, false]`
/// - User writes: `free => 'theta'` → stored as: `free => [true, false]`
/// - User writes: `free => 'angle'` → stored as: `free => [true, false]`
/// - User writes: `free => ['x', 'y']` → stored as: `free => [true, true]`
/// - User writes: `free => null` or absent → stored as: `free => [false, false]`
///
Expand Down Expand Up @@ -387,7 +387,7 @@ mod tests {
/// Default positional names for cartesian coords
const CARTESIAN: &[&str] = &["x", "y"];
/// Positional names for polar coords
const POLAR: &[&str] = &["theta", "radius"];
const POLAR: &[&str] = &["angle", "radius"];

fn make_wrap_facet() -> Facet {
Facet::new(FacetLayout::Wrap {
Expand Down Expand Up @@ -868,17 +868,17 @@ mod tests {
// ========================================

#[test]
fn test_free_property_theta_valid() {
fn test_free_property_angle_valid() {
let mut facet = make_wrap_facet();
facet.properties.insert(
"free".to_string(),
ParameterValue::String("theta".to_string()),
ParameterValue::String("angle".to_string()),
);

let context = make_context(5);
let result = resolve_properties(&mut facet, &context, POLAR);
assert!(result.is_ok());
// theta is first positional -> [true, false]
// angle is first positional -> [true, false]
assert_eq!(get_free_bools(&facet), Some(vec![true, false]));
}

Expand All @@ -903,7 +903,7 @@ mod tests {
facet.properties.insert(
"free".to_string(),
ParameterValue::Array(vec![
crate::plot::ArrayElement::String("theta".to_string()),
crate::plot::ArrayElement::String("angle".to_string()),
crate::plot::ArrayElement::String("radius".to_string()),
]),
);
Expand All @@ -929,24 +929,24 @@ mod tests {
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.contains("'x'"));
assert!(err.contains("theta") || err.contains("radius"));
assert!(err.contains("angle") || err.contains("radius"));
}

#[test]
fn test_error_polar_names_in_cartesian() {
// theta/radius should not be valid for cartesian coords
// angle/radius should not be valid for cartesian coords
let mut facet = make_wrap_facet();
facet.properties.insert(
"free".to_string(),
ParameterValue::String("theta".to_string()),
ParameterValue::String("angle".to_string()),
);

let context = make_context(5);
let result = resolve_properties(&mut facet, &context, CARTESIAN);

assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.contains("'theta'"));
assert!(err.contains("'angle'"));
assert!(err.contains("'x'") || err.contains("'y'"));
}

Expand Down
6 changes: 3 additions & 3 deletions src/plot/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -809,18 +809,18 @@ mod tests {

#[test]
fn test_label_transform_with_polar_project() {
// LABEL theta/radius with polar should transform to pos1/pos2
// LABEL angle/radius with polar should transform to pos1/pos2
use crate::plot::projection::{Coord, Projection};

let mut spec = Plot::new();
spec.project = Some(Projection {
coord: Coord::polar(),
aesthetics: vec!["theta".to_string(), "radius".to_string()],
aesthetics: vec!["angle".to_string(), "radius".to_string()],
properties: HashMap::new(),
});
spec.labels = Some(Labels {
labels: HashMap::from([
("theta".to_string(), "Angle".to_string()),
("angle".to_string(), "Angle".to_string()),
("radius".to_string(), "Distance".to_string()),
]),
});
Expand Down
Loading
Loading