diff --git a/doc/get_started/the_rest.qmd b/doc/get_started/the_rest.qmd index 6222d20a..08cd57b5 100644 --- a/doc/get_started/the_rest.qmd +++ b/doc/get_started/the_rest.qmd @@ -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: diff --git a/doc/syntax/coord/polar.qmd b/doc/syntax/coord/polar.qmd index ed4ab1d0..24e8af75 100644 --- a/doc/syntax/coord/polar.qmd +++ b/doc/syntax/coord/polar.qmd @@ -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 @@ -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} @@ -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`. diff --git a/src/execute/mod.rs b/src/execute/mod.rs index 8a1a3911..46483f03 100644 --- a/src/execute/mod.rs +++ b/src/execute/mod.rs @@ -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}; @@ -1413,6 +1414,13 @@ pub fn prepare_data_with_reader(query: &str, reader: &dyn Reader) -> Result 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 { @@ -221,3 +238,46 @@ impl FacetLayout { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::plot::ArrayElement; + + fn facet_with_free(free: Vec) -> 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")); + } +} diff --git a/src/plot/main.rs b/src/plot/main.rs index c6cd37a4..07018e0c 100644 --- a/src/plot/main.rs +++ b/src/plot/main.rs @@ -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())), @@ -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())), diff --git a/src/plot/projection/coord/polar.rs b/src/plot/projection/coord/polar.rs index 3f76d1bd..3312c97d 100644 --- a/src/plot/projection/coord/polar.rs +++ b/src/plot/projection/coord/polar.rs @@ -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 } @@ -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] diff --git a/src/plot/projection/mod.rs b/src/plot/projection/mod.rs index 2baaabde..59b5aebb 100644 --- a/src/plot/projection/mod.rs +++ b/src/plot/projection/mod.rs @@ -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; diff --git a/src/plot/projection/resolve.rs b/src/plot/projection/resolve.rs index 603ef99a..e08646e7 100644 --- a/src/plot/projection/resolve.rs +++ b/src/plot/projection/resolve.rs @@ -1,13 +1,16 @@ //! Coordinate system resolution //! -//! Resolves the default coordinate system by inspecting aesthetic mappings. +//! Resolves the default coordinate system by inspecting aesthetic mappings, +//! and post-scale resolution of projection properties like `radar`. use std::collections::HashMap; use super::coord::{Coord, CoordKind}; use super::Projection; use crate::plot::aesthetic::{MATERIAL_AESTHETICS, POSITION_SUFFIXES}; -use crate::plot::Mappings; +use crate::plot::scale::ScaleTypeKind; +use crate::plot::{Mappings, ParameterValue, Scale}; +use crate::GgsqlError; /// Cartesian primary aesthetic names const CARTESIAN_PRIMARIES: &[&str] = &["x", "y"]; @@ -128,10 +131,69 @@ fn strip_position_suffix(name: &str) -> &str { name } +/// Resolve projection properties that depend on scale types. +/// +/// Called after `resolve_scales()`. Currently handles: +/// - **`radar`** (polar only): When null (auto), sets to `true` if the theta +/// (pos2) scale is discrete/ordinal. When explicitly `true`, validates that +/// the theta scale is indeed discrete. +pub fn resolve_projection_properties( + project: &mut Projection, + scales: &[Scale], +) -> crate::Result<()> { + if project.coord.coord_kind() != CoordKind::Polar { + return Ok(()); + } + + let theta_scale = scales.iter().find(|s| s.aesthetic == "pos2"); + + let theta_is_discrete = theta_scale + .and_then(|s| s.scale_type.as_ref()) + .is_some_and(|st| { + matches!( + st.scale_type_kind(), + ScaleTypeKind::Discrete | ScaleTypeKind::Ordinal + ) + }); + + let too_few_categories = theta_scale + .and_then(|s| s.input_range.as_ref()) + .is_some_and(|r| r.len() <= 2); + + match project.properties.get("radar") { + Some(ParameterValue::Boolean(true)) if !theta_is_discrete => { + return Err(GgsqlError::ValidationError( + "SETTING radar => true requires a discrete angle scale, \ + but the angle aesthetic is continuous" + .to_string(), + )); + } + Some(ParameterValue::Boolean(true)) if too_few_categories => { + return Err(GgsqlError::ValidationError( + "SETTING radar => true requires more than 2 categories \ + on the angle aesthetic" + .to_string(), + )); + } + Some(ParameterValue::Boolean(_)) => { + // Explicit true (valid) or false — keep as-is + } + _ => { + // Null / absent — auto-detect: discrete with >2 categories + let use_radar = theta_is_discrete && !too_few_categories; + project + .properties + .insert("radar".to_string(), ParameterValue::Boolean(use_radar)); + } + } + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; - use crate::plot::AestheticValue; + use crate::plot::{AestheticValue, ArrayElement, ScaleType}; /// Helper to create Mappings with given aesthetic names fn mappings_with(aesthetics: &[&str]) -> Mappings { @@ -148,11 +210,7 @@ mod tests { #[test] fn test_resolve_keeps_explicit_project() { - let project = Projection { - coord: Coord::cartesian(), - aesthetics: vec!["x".to_string(), "y".to_string()], - properties: HashMap::new(), - }; + let project = Projection::cartesian(); let global = mappings_with(&["angle", "radius"]); // Would infer polar let layers: Vec<&Mappings> = vec![]; @@ -353,6 +411,161 @@ mod tests { // Test: Helper functions // ======================================== + // ======================================== + // Test: resolve_projection_properties + // ======================================== + + fn scale_with_type(aesthetic: &str, discrete: bool) -> Scale { + let mut s = Scale::new(aesthetic); + s.scale_type = Some(if discrete { + ScaleType::discrete() + } else { + ScaleType::continuous() + }); + s + } + + fn discrete_scale_with_n(aesthetic: &str, n: usize) -> Scale { + let mut s = Scale::new(aesthetic); + s.scale_type = Some(ScaleType::discrete()); + s.input_range = Some( + (0..n) + .map(|i| ArrayElement::String(format!("cat{i}"))) + .collect(), + ); + s + } + + #[test] + fn test_radar_auto_true_for_discrete_theta() { + let mut proj = Projection::polar(); + let scales = vec![discrete_scale_with_n("pos2", 5)]; + resolve_projection_properties(&mut proj, &scales).unwrap(); + assert_eq!( + proj.properties.get("radar"), + Some(&ParameterValue::Boolean(true)) + ); + } + + #[test] + fn test_radar_auto_false_for_continuous_theta() { + let mut proj = Projection::polar(); + let scales = vec![scale_with_type("pos2", false)]; + resolve_projection_properties(&mut proj, &scales).unwrap(); + assert_eq!( + proj.properties.get("radar"), + Some(&ParameterValue::Boolean(false)) + ); + } + + #[test] + fn test_radar_auto_true_for_discrete_theta_no_range() { + let mut proj = Projection::polar(); + let scales = vec![scale_with_type("pos2", true)]; + resolve_projection_properties(&mut proj, &scales).unwrap(); + assert_eq!( + proj.properties.get("radar"), + Some(&ParameterValue::Boolean(true)) + ); + } + + #[test] + fn test_radar_auto_false_for_discrete_theta_2_categories() { + let mut proj = Projection::polar(); + let scales = vec![discrete_scale_with_n("pos2", 2)]; + resolve_projection_properties(&mut proj, &scales).unwrap(); + assert_eq!( + proj.properties.get("radar"), + Some(&ParameterValue::Boolean(false)) + ); + } + + #[test] + fn test_radar_auto_false_for_discrete_theta_1_category() { + let mut proj = Projection::polar(); + let scales = vec![discrete_scale_with_n("pos2", 1)]; + resolve_projection_properties(&mut proj, &scales).unwrap(); + assert_eq!( + proj.properties.get("radar"), + Some(&ParameterValue::Boolean(false)) + ); + } + + #[test] + fn test_radar_auto_true_for_discrete_theta_3_categories() { + let mut proj = Projection::polar(); + let scales = vec![discrete_scale_with_n("pos2", 3)]; + resolve_projection_properties(&mut proj, &scales).unwrap(); + assert_eq!( + proj.properties.get("radar"), + Some(&ParameterValue::Boolean(true)) + ); + } + + #[test] + fn test_radar_explicit_true_with_discrete_ok() { + let mut proj = Projection::polar(); + proj.properties + .insert("radar".to_string(), ParameterValue::Boolean(true)); + let scales = vec![discrete_scale_with_n("pos2", 4)]; + resolve_projection_properties(&mut proj, &scales).unwrap(); + assert_eq!( + proj.properties.get("radar"), + Some(&ParameterValue::Boolean(true)) + ); + } + + #[test] + fn test_radar_explicit_true_with_2_categories_errors() { + let mut proj = Projection::polar(); + proj.properties + .insert("radar".to_string(), ParameterValue::Boolean(true)); + let scales = vec![discrete_scale_with_n("pos2", 2)]; + let result = resolve_projection_properties(&mut proj, &scales); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("more than 2"), + "error should mention 'more than 2': {err}" + ); + } + + #[test] + fn test_radar_explicit_true_with_continuous_errors() { + let mut proj = Projection::polar(); + proj.properties + .insert("radar".to_string(), ParameterValue::Boolean(true)); + let scales = vec![scale_with_type("pos2", false)]; + let result = resolve_projection_properties(&mut proj, &scales); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("discrete"), + "error should mention discrete: {err}" + ); + } + + #[test] + fn test_radar_explicit_false_with_discrete_preserved() { + let mut proj = Projection::polar(); + proj.properties + .insert("radar".to_string(), ParameterValue::Boolean(false)); + let scales = vec![scale_with_type("pos2", true)]; + resolve_projection_properties(&mut proj, &scales).unwrap(); + assert_eq!( + proj.properties.get("radar"), + Some(&ParameterValue::Boolean(false)) + ); + } + + #[test] + fn test_radar_noop_for_cartesian() { + let mut proj = Projection::cartesian(); + let scales = vec![scale_with_type("pos2", true)]; + resolve_projection_properties(&mut proj, &scales).unwrap(); + assert!(!proj.properties.contains_key("radar")); + } + #[test] fn test_strip_position_suffix() { assert_eq!(strip_position_suffix("x"), "x"); diff --git a/src/plot/projection/types.rs b/src/plot/projection/types.rs index bb6a55ce..9edfeea8 100644 --- a/src/plot/projection/types.rs +++ b/src/plot/projection/types.rs @@ -23,6 +23,29 @@ pub struct Projection { } impl Projection { + /// Create a default Cartesian projection (x, y). + pub fn cartesian() -> Self { + Self::with_defaults(Coord::cartesian()) + } + + /// Create a default Polar projection (radius, angle). + pub fn polar() -> Self { + Self::with_defaults(Coord::polar()) + } + + fn with_defaults(coord: Coord) -> Self { + let aesthetics = coord + .position_aesthetic_names() + .iter() + .map(|s| s.to_string()) + .collect(); + Self { + coord, + aesthetics, + properties: HashMap::new(), + } + } + /// Get the position aesthetic names as string slices. /// (aesthetics are always resolved at build time) pub fn position_names(&self) -> Vec<&str> { diff --git a/src/plot/scale/scale_type/discrete.rs b/src/plot/scale/scale_type/discrete.rs index 97c31344..58a8e8e7 100644 --- a/src/plot/scale/scale_type/discrete.rs +++ b/src/plot/scale/scale_type/discrete.rs @@ -57,6 +57,18 @@ impl ScaleTypeTrait for Discrete { true } + fn numeric_breaks(&self, scale: &super::super::Scale) -> Vec { + super::categorical_numeric_breaks(scale) + } + + fn numeric_domain(&self, scale: &super::super::Scale) -> Option<(f64, f64)> { + super::categorical_numeric_domain(scale) + } + + fn break_labels(&self, scale: &super::super::Scale) -> Vec<(f64, String)> { + super::categorical_break_labels(scale) + } + fn default_properties(&self) -> &'static [ParamDefinition] { // Discrete scales always censor OOB values (no OOB setting needed) const PARAMS: &[ParamDefinition] = &[ParamDefinition { diff --git a/src/plot/scale/scale_type/mod.rs b/src/plot/scale/scale_type/mod.rs index db0654da..32e10cb7 100644 --- a/src/plot/scale/scale_type/mod.rs +++ b/src/plot/scale/scale_type/mod.rs @@ -796,6 +796,42 @@ pub trait ScaleTypeTrait: std::fmt::Debug + std::fmt::Display + Send + Sync { ) } + /// Numeric break positions from a resolved scale. + /// + /// Default: reads the `breaks` property and converts to f64. + /// Discrete overrides to synthesize `[1, 2, …, n]` from input range length. + fn numeric_breaks(&self, scale: &super::Scale) -> Vec { + match scale.properties.get("breaks") { + Some(ParameterValue::Array(breaks)) => { + breaks.iter().filter_map(|b| b.to_f64()).collect() + } + _ => Vec::new(), + } + } + + /// Labelled breaks: `(numeric_position, display_label)` pairs. + /// + /// Default: pairs each `numeric_breaks()` value with its string form. + /// Discrete/ordinal override to pair position indices with input-range + /// category names. `label_mapping` overrides are applied by the caller. + fn break_labels(&self, scale: &super::Scale) -> Vec<(f64, String)> { + self.numeric_breaks(scale) + .into_iter() + .map(|v| (v, format!("{v}"))) + .collect() + } + + /// Numeric domain `(min, max)` from a resolved scale. + /// + /// Default: reads the input range endpoints as f64. + /// Discrete overrides to synthesize `(0.5, n + 0.5)`. + fn numeric_domain(&self, scale: &super::Scale) -> Option<(f64, f64)> { + let range = scale.input_range.as_ref()?; + let min = range.first()?.to_f64()?; + let max = range.last()?.to_f64()?; + Some((min, max)) + } + /// Resolve scale properties from data context. /// /// Called ONCE per scale, either: @@ -1066,6 +1102,38 @@ pub trait ScaleTypeTrait: std::fmt::Debug + std::fmt::Display + Send + Sync { } } +/// Numeric breaks for categorical scales: `[1, 2, …, n]` from input range length. +pub(super) fn categorical_numeric_breaks(scale: &super::Scale) -> Vec { + let n = scale.input_range.as_ref().map_or(0, |r| r.len()); + (1..=n).map(|i| i as f64).collect() +} + +/// Numeric domain for categorical scales: `(0.5, n + 0.5)`. +pub(super) fn categorical_numeric_domain(scale: &super::Scale) -> Option<(f64, f64)> { + let n = scale.input_range.as_ref()?.len(); + if n > 0 { + Some((0.5, n as f64 + 0.5)) + } else { + None + } +} + +/// Labelled breaks for categorical scales: pairs position indices with category names. +pub(super) fn categorical_break_labels(scale: &super::Scale) -> Vec<(f64, String)> { + let Some(range) = scale.input_range.as_ref() else { + return Vec::new(); + }; + let mut out = Vec::with_capacity(range.len()); + for (i, elem) in range.iter().enumerate() { + let label = match elem { + ArrayElement::String(s) => s.clone(), + other => format!("{}", other.to_json()), + }; + out.push(((i + 1) as f64, label)); + } + out +} + /// Wrapper struct for scale type trait objects /// /// This provides a convenient interface for working with scale types while hiding @@ -1248,6 +1316,21 @@ impl ScaleType { self.0.supports_breaks() } + /// Numeric break positions from a resolved scale. + pub fn numeric_breaks(&self, scale: &super::Scale) -> Vec { + self.0.numeric_breaks(scale) + } + + /// Numeric domain `(min, max)` from a resolved scale. + pub fn numeric_domain(&self, scale: &super::Scale) -> Option<(f64, f64)> { + self.0.numeric_domain(scale) + } + + /// Labelled breaks: `(numeric_position, display_label)` pairs. + pub fn break_labels(&self, scale: &super::Scale) -> Vec<(f64, String)> { + self.0.break_labels(scale) + } + /// Resolve scale properties from data context. /// /// Called ONCE per scale, either: diff --git a/src/plot/scale/scale_type/ordinal.rs b/src/plot/scale/scale_type/ordinal.rs index 80044c57..0b103ba7 100644 --- a/src/plot/scale/scale_type/ordinal.rs +++ b/src/plot/scale/scale_type/ordinal.rs @@ -64,6 +64,18 @@ impl ScaleTypeTrait for Ordinal { true // Collects unique values like Discrete } + fn numeric_breaks(&self, scale: &super::super::Scale) -> Vec { + super::categorical_numeric_breaks(scale) + } + + fn numeric_domain(&self, scale: &super::super::Scale) -> Option<(f64, f64)> { + super::categorical_numeric_domain(scale) + } + + fn break_labels(&self, scale: &super::super::Scale) -> Vec<(f64, String)> { + super::categorical_break_labels(scale) + } + fn allowed_transforms(&self) -> &'static [TransformKind] { // Categorical transforms plus Integer for ordered numeric categories &[ diff --git a/src/plot/scale/types.rs b/src/plot/scale/types.rs index df9ca6af..13d22818 100644 --- a/src/plot/scale/types.rs +++ b/src/plot/scale/types.rs @@ -89,6 +89,76 @@ impl Scale { label_template: "{}".to_string(), } } + + /// Whether the scale's domain consists solely of the stat-dummy sentinel. + /// + /// Dummy scales are injected when a stat requires a position channel that + /// the user didn't map (e.g., a bar chart's categorical axis for a + /// pie chart). Axes for dummy scales should be suppressed. + pub fn is_dummy(&self) -> bool { + matches!( + self.input_range.as_deref(), + Some([ArrayElement::String(s)]) if s == &crate::naming::stat_column("dummy") + ) + } + + /// Numeric break positions (after resolution). + /// + /// Delegates to the scale type for type-specific logic (e.g. discrete + /// scales synthesize `[1, 2, …, n]` from the input range length). + pub fn numeric_breaks(&self) -> Vec { + match &self.scale_type { + Some(st) => st.numeric_breaks(self), + None => match self.properties.get("breaks") { + Some(ParameterValue::Array(breaks)) => { + breaks.iter().filter_map(|b| b.to_f64()).collect() + } + _ => Vec::new(), + }, + } + } + + /// Labelled breaks: `(numeric_position, display_label)` pairs. + /// + /// Delegates to the scale type, then applies `label_mapping` overrides. + /// Suppressed labels (`None` in the mapping) become empty strings. + pub fn break_labels(&self) -> Vec<(f64, String)> { + let raw = match &self.scale_type { + Some(st) => st.break_labels(self), + None => self + .numeric_breaks() + .into_iter() + .map(|v| (v, format!("{v}"))) + .collect(), + }; + let mappings = self.label_mapping.as_ref(); + let mut out = Vec::with_capacity(raw.len()); + for (pos, label) in raw { + match mappings.and_then(|m| m.get(&label)) { + Some(Some(renamed)) => out.push((pos, renamed.clone())), + Some(None) => out.push((pos, String::new())), + None => out.push((pos, label)), + } + } + out + } + + /// Numeric domain as `(min, max)` from the resolved input range. + /// + /// Delegates to the scale type for type-specific logic (e.g. discrete + /// scales synthesize `(0.5, n + 0.5)` so integer positions sit at + /// category centres). + pub fn numeric_domain(&self) -> Option<(f64, f64)> { + match &self.scale_type { + Some(st) => st.numeric_domain(self), + None => { + let range = self.input_range.as_ref()?; + let min = range.first()?.to_f64()?; + let max = range.last()?.to_f64()?; + Some((min, max)) + } + } + } } /// Output range specification (TO clause) @@ -100,3 +170,202 @@ pub enum OutputRange { /// Named palette identifier: TO viridis Palette(String), } + +#[cfg(test)] +mod tests { + use super::*; + + fn continuous_scale(domain: (f64, f64), breaks: Vec) -> Scale { + let mut s = Scale::new("pos1"); + s.scale_type = Some(ScaleType::continuous()); + s.input_range = Some(vec![ + ArrayElement::Number(domain.0), + ArrayElement::Number(domain.1), + ]); + s.properties.insert( + "breaks".to_string(), + ParameterValue::Array(breaks.into_iter().map(ArrayElement::Number).collect()), + ); + s + } + + fn discrete_scale(values: &[&str]) -> Scale { + let mut s = Scale::new("pos2"); + s.scale_type = Some(ScaleType::discrete()); + s.input_range = Some( + values + .iter() + .map(|v| ArrayElement::String(v.to_string())) + .collect(), + ); + s + } + + fn ordinal_scale(values: &[&str]) -> Scale { + let mut s = Scale::new("pos1"); + s.scale_type = Some(ScaleType::ordinal()); + s.input_range = Some( + values + .iter() + .map(|v| ArrayElement::String(v.to_string())) + .collect(), + ); + s + } + + // ========================================================================= + // Continuous + // ========================================================================= + + #[test] + fn test_continuous_numeric_breaks() { + let s = continuous_scale((0.0, 100.0), vec![25.0, 50.0, 75.0]); + assert_eq!(s.numeric_breaks(), vec![25.0, 50.0, 75.0]); + } + + #[test] + fn test_continuous_numeric_domain() { + let s = continuous_scale((0.0, 100.0), vec![]); + assert_eq!(s.numeric_domain(), Some((0.0, 100.0))); + } + + #[test] + fn test_continuous_no_breaks() { + let s = continuous_scale((0.0, 100.0), vec![]); + assert_eq!(s.numeric_breaks(), Vec::::new()); + } + + // ========================================================================= + // Discrete + // ========================================================================= + + #[test] + fn test_discrete_numeric_breaks() { + let s = discrete_scale(&["A", "B", "C"]); + assert_eq!(s.numeric_breaks(), vec![1.0, 2.0, 3.0]); + } + + #[test] + fn test_discrete_numeric_domain() { + let s = discrete_scale(&["A", "B", "C"]); + assert_eq!(s.numeric_domain(), Some((0.5, 3.5))); + } + + #[test] + fn test_discrete_single_category() { + let s = discrete_scale(&["only"]); + assert_eq!(s.numeric_breaks(), vec![1.0]); + assert_eq!(s.numeric_domain(), Some((0.5, 1.5))); + } + + #[test] + fn test_discrete_empty() { + let s = discrete_scale(&[]); + assert_eq!(s.numeric_breaks(), Vec::::new()); + assert_eq!(s.numeric_domain(), None); + } + + // ========================================================================= + // Ordinal + // ========================================================================= + + #[test] + fn test_ordinal_numeric_breaks() { + let s = ordinal_scale(&["low", "mid", "high"]); + assert_eq!(s.numeric_breaks(), vec![1.0, 2.0, 3.0]); + } + + #[test] + fn test_ordinal_numeric_domain() { + let s = ordinal_scale(&["low", "mid", "high"]); + assert_eq!(s.numeric_domain(), Some((0.5, 3.5))); + } + + // ========================================================================= + // Identity / no scale type + // ========================================================================= + + #[test] + fn test_identity_string_returns_empty() { + let mut s = Scale::new("color"); + s.scale_type = Some(ScaleType::identity()); + s.input_range = Some(vec![ + ArrayElement::String("red".to_string()), + ArrayElement::String("blue".to_string()), + ]); + assert_eq!(s.numeric_breaks(), Vec::::new()); + assert_eq!(s.numeric_domain(), None); + } + + #[test] + fn test_no_scale_type_falls_back() { + let mut s = Scale::new("pos1"); + s.input_range = Some(vec![ArrayElement::Number(10.0), ArrayElement::Number(50.0)]); + s.properties.insert( + "breaks".to_string(), + ParameterValue::Array(vec![ArrayElement::Number(20.0), ArrayElement::Number(40.0)]), + ); + assert_eq!(s.numeric_breaks(), vec![20.0, 40.0]); + assert_eq!(s.numeric_domain(), Some((10.0, 50.0))); + } + + // ========================================================================= + // break_labels + // ========================================================================= + + #[test] + fn test_continuous_break_labels() { + let s = continuous_scale((0.0, 100.0), vec![25.0, 50.0, 75.0]); + assert_eq!( + s.break_labels(), + vec![ + (25.0, "25".to_string()), + (50.0, "50".to_string()), + (75.0, "75".to_string()) + ] + ); + } + + #[test] + fn test_discrete_break_labels() { + let s = discrete_scale(&["A", "B", "C"]); + assert_eq!( + s.break_labels(), + vec![ + (1.0, "A".to_string()), + (2.0, "B".to_string()), + (3.0, "C".to_string()) + ] + ); + } + + #[test] + fn test_ordinal_break_labels() { + let s = ordinal_scale(&["low", "mid", "high"]); + assert_eq!( + s.break_labels(), + vec![ + (1.0, "low".to_string()), + (2.0, "mid".to_string()), + (3.0, "high".to_string()) + ] + ); + } + + #[test] + fn test_break_labels_with_mapping() { + let mut s = discrete_scale(&["A", "B", "C"]); + let mut mapping = HashMap::new(); + mapping.insert("A".to_string(), Some("Alpha".to_string())); + mapping.insert("C".to_string(), None); + s.label_mapping = Some(mapping); + assert_eq!( + s.break_labels(), + vec![ + (1.0, "Alpha".to_string()), + (2.0, "B".to_string()), + (3.0, String::new()) + ] + ); + } +} diff --git a/src/reader/mod.rs b/src/reader/mod.rs index 6646ada1..1547b93d 100644 --- a/src/reader/mod.rs +++ b/src/reader/mod.rs @@ -549,6 +549,21 @@ mod tests { use crate::df; use crate::writer::{VegaLiteWriter, Writer}; + fn data_layer(json: &serde_json::Value, index: usize) -> &serde_json::Value { + json["layer"] + .as_array() + .unwrap() + .iter() + .filter(|l| { + !matches!( + l.get("description").and_then(|d| d.as_str()), + Some("background" | "foreground") + ) + }) + .nth(index) + .expect("data layer not found at index") + } + #[test] fn test_execute_and_render() { let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); @@ -638,7 +653,7 @@ mod tests { // The encoding should have a theta channel with a scale range offset by 90 degrees // 90 degrees = π/2 radians - let layer = json["layer"].as_array().unwrap().first().unwrap(); + let layer = data_layer(&json, 0); let theta = &layer["encoding"]["theta"]; assert!(theta.is_object(), "theta encoding should exist"); @@ -683,7 +698,7 @@ mod tests { let json: serde_json::Value = serde_json::from_str(&result).unwrap(); // The theta encoding should NOT have a scale with range when start is 0 (default) - let layer = json["layer"].as_array().unwrap().first().unwrap(); + let layer = data_layer(&json, 0); let theta = &layer["encoding"]["theta"]; assert!(theta.is_object(), "theta encoding should exist"); @@ -711,7 +726,7 @@ mod tests { let result = writer.render(&spec).unwrap(); let json: serde_json::Value = serde_json::from_str(&result).unwrap(); - let layer = json["layer"].as_array().unwrap().first().unwrap(); + let layer = data_layer(&json, 0); let theta = &layer["encoding"]["theta"]; let range = theta["scale"]["range"].as_array().unwrap(); @@ -746,7 +761,7 @@ mod tests { let result = writer.render(&spec).unwrap(); let json: serde_json::Value = serde_json::from_str(&result).unwrap(); - let layer = json["layer"].as_array().unwrap().first().unwrap(); + let layer = data_layer(&json, 0); let theta = &layer["encoding"]["theta"]; let range = theta["scale"]["range"].as_array().unwrap(); @@ -774,7 +789,7 @@ mod tests { // Helper to check encoding keys fn check_encoding_keys(json: &serde_json::Value, test_name: &str) { - let layer = json["layer"].as_array().unwrap().first().unwrap(); + let layer = data_layer(json, 0); assert!( layer["encoding"].get("theta").is_some(), "{} should produce theta encoding, got keys: {:?}", @@ -853,7 +868,7 @@ mod tests { let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap(); fn check_cartesian_keys(json: &serde_json::Value, test_name: &str) { - let layer = json["layer"].as_array().unwrap().first().unwrap(); + let layer = data_layer(json, 0); assert!( layer["encoding"].get("x").is_some(), "{} should produce x encoding, got keys: {:?}", @@ -1108,7 +1123,7 @@ mod tests { let result = writer.render(&spec).unwrap(); let json: serde_json::Value = serde_json::from_str(&result).unwrap(); - let layer = json["layer"].as_array().unwrap().first().unwrap(); + let layer = data_layer(&json, 0); // Check radius scale has range with expressions let radius = &layer["encoding"]["radius"]; @@ -1127,8 +1142,8 @@ mod tests { range[1]["expr"] .as_str() .unwrap() - .contains("min(width,height)/2"), - "Outer radius expression should be min(width,height)/2, got: {:?}", + .contains("min(width, height) / 2"), + "Outer radius expression should contain min(width, height) / 2, got: {:?}", range[1] ); } @@ -1154,7 +1169,7 @@ mod tests { let result = writer.render(&spec).unwrap(); let json: serde_json::Value = serde_json::from_str(&result).unwrap(); - let layer = json["layer"].as_array().unwrap().first().unwrap(); + let layer = data_layer(&json, 0); // Verify y and y2 encodings exist (stacked bars use y/y2 for range) let encoding = &layer["encoding"]; @@ -1187,7 +1202,7 @@ mod tests { let result = writer.render(&spec).unwrap(); let json: serde_json::Value = serde_json::from_str(&result).unwrap(); - let layer = json["layer"].as_array().unwrap().first().unwrap(); + let layer = data_layer(&json, 0); // Verify y and y2 encodings exist (stacked bars use y/y2 for range) let encoding = &layer["encoding"]; @@ -1225,7 +1240,7 @@ mod tests { // Should succeed without "discrete scale does not support SETTING 'expand'" error let json: serde_json::Value = serde_json::from_str(&result).unwrap(); - let layer = json["layer"].as_array().unwrap().first().unwrap(); + let layer = data_layer(&json, 0); // Verify stacking works (y2 encoding exists for stacked bars) let encoding = &layer["encoding"]; @@ -1257,7 +1272,7 @@ mod tests { let result = writer.render(&spec).unwrap(); let json: serde_json::Value = serde_json::from_str(&result).unwrap(); - let layer = json["layer"].as_array().unwrap().first().unwrap(); + let layer = data_layer(&json, 0); // Verify xOffset encoding exists (dodged bars use xOffset for displacement) let encoding = &layer["encoding"]; @@ -1302,7 +1317,7 @@ mod tests { let result = writer.render(&spec).unwrap(); let json: serde_json::Value = serde_json::from_str(&result).unwrap(); - let layer = json["layer"].as_array().unwrap().first().unwrap(); + let layer = data_layer(&json, 0); // Verify no xOffset encoding (identity position) let encoding = &layer["encoding"]; @@ -1330,7 +1345,7 @@ mod tests { let result = writer.render(&spec).unwrap(); let json: serde_json::Value = serde_json::from_str(&result).unwrap(); - let layer = json["layer"].as_array().unwrap().first().unwrap(); + let layer = data_layer(&json, 0); let encoding = &layer["encoding"]; // With PROJECT y, x TO cartesian: @@ -1371,7 +1386,7 @@ mod tests { let result = writer.render(&spec).unwrap(); let json: serde_json::Value = serde_json::from_str(&result).unwrap(); - let layer = json["layer"].as_array().unwrap().first().unwrap(); + let layer = data_layer(&json, 0); let encoding = &layer["encoding"]; // Verify theta encoding has the label diff --git a/src/writer/vegalite/encoding.rs b/src/writer/vegalite/encoding.rs index 04a29c52..b6c640ac 100644 --- a/src/writer/vegalite/encoding.rs +++ b/src/writer/vegalite/encoding.rs @@ -6,7 +6,7 @@ use crate::array_util::as_str; use crate::plot::aesthetic::{is_position_aesthetic, AestheticContext}; use crate::plot::scale::{linetype_to_stroke_dash, shape_to_svg_path, ScaleTypeKind}; -use crate::plot::{CoordKind, ParameterValue}; +use crate::plot::ParameterValue; use crate::{AestheticValue, DataFrame, GgsqlError, Plot, Result}; use arrow::array::Array; use arrow::datatypes::DataType; @@ -16,36 +16,8 @@ use std::collections::{HashMap, HashSet}; use super::{POINTS_TO_AREA, POINTS_TO_PIXELS}; /// Check if a position aesthetic has free scales enabled. -/// -/// Maps aesthetic names to position indices: -/// - pos1, pos1min, pos1max, pos1end -> index 0 -/// - pos2, pos2min, pos2max, pos2end -> index 1 -/// - etc. -/// -/// Returns false for material aesthetics or if no free_scales array is provided. -fn is_position_free_for_aesthetic( - aesthetic: &str, - free_scales: Option<&[crate::plot::ArrayElement]>, -) -> bool { - let Some(free_arr) = free_scales else { - return false; - }; - - // Extract position index from aesthetic name (pos1 -> 0, pos2 -> 1, etc.) - let pos_index = if aesthetic.starts_with("pos1") { - Some(0) - } else if aesthetic.starts_with("pos2") { - Some(1) - } else if aesthetic.starts_with("pos3") { - Some(2) - } else { - None - }; - - pos_index - .and_then(|idx| free_arr.get(idx)) - .map(|e| matches!(e, crate::plot::ArrayElement::Boolean(true))) - .unwrap_or(false) +fn is_free(aesthetic: &str, facet: Option<&crate::plot::Facet>) -> bool { + facet.is_some_and(|f| f.is_free(aesthetic)) } /// Build a Vega-Lite labelExpr from label mappings @@ -89,7 +61,7 @@ pub(super) fn build_label_expr( let mut parts: Vec = mappings .iter() .map(|(from, to)| { - let from_escaped = from.replace('\'', "\\'"); + let from_escaped = super::escape_vega_string(from); // For threshold scales, the first terminal uses null instead of string comparison let condition = if null_key == Some(from.as_str()) { @@ -100,7 +72,7 @@ pub(super) fn build_label_expr( match to { Some(label) => { - let to_escaped = label.replace('\'', "\\'"); + let to_escaped = super::escape_vega_string(label); format!("{} ? '{}'", condition, to_escaped) } None => { @@ -465,8 +437,6 @@ struct ScaleContext<'a> { is_binned_legend: bool, #[allow(dead_code)] spec: &'a Plot, // Reserved for future use (e.g., multi-scale legend decisions) - /// Free scales array from facet (position-indexed booleans) - free_scales: Option<&'a [crate::plot::ArrayElement]>, } /// Build scale properties from SCALE clause @@ -485,7 +455,7 @@ fn build_scale_properties( // When using free scales, Vega-Lite computes independent domains per facet panel. // Setting an explicit domain would override this behavior. // Note: aesthetics are in internal format (pos1, pos2) at this stage - let skip_domain = is_position_free_for_aesthetic(ctx.aesthetic, ctx.free_scales); + let skip_domain = is_free(ctx.aesthetic, ctx.spec.facet.as_ref()); // Apply domain from input_range (FROM clause) // Skip for threshold scales - they use internal breaks as domain instead @@ -804,8 +774,6 @@ pub(super) struct EncodingContext<'a> { pub spec: &'a Plot, pub titled_families: &'a mut HashSet, pub primary_aesthetics: &'a HashSet, - /// Free scales array from facet (position-indexed booleans) - pub free_scales: Option<&'a [crate::plot::ArrayElement]>, } /// Build encoding channel from aesthetic mapping @@ -898,7 +866,6 @@ fn build_column_encoding( aesthetic, spec: ctx.spec, is_binned_legend, - free_scales: ctx.free_scales, }; let (scale_obj, needs_gradient) = build_scale_properties(scale, &scale_ctx); @@ -927,7 +894,7 @@ fn build_column_encoding( // the domain from data values. Setting zero:false in that case can exclude // 0 from the domain, breaking charts with pre-computed stacking (y2/theta2 // starts at 0). Let VL's defaults handle it instead. - let is_free = is_position_free_for_aesthetic(aesthetic, ctx.free_scales); + let is_free = is_free(aesthetic, ctx.spec.facet.as_ref()); if aesthetic_ctx.is_primary_internal(aesthetic) && !is_free { scale_obj.insert("zero".to_string(), json!(false)); } @@ -997,11 +964,11 @@ fn build_literal_encoding(aesthetic: &str, lit: &ParameterValue) -> Result String { // For internal position aesthetics, map directly to Vega-Lite channel names // based on coord type (ignoring user-facing names) - if let Some(vl_channel) = map_position_to_vegalite(aesthetic, coord_kind) { + if let Some(vl_channel) = super::projection::map_position_to_vegalite(aesthetic, renderer) { return vl_channel; } @@ -1020,29 +987,6 @@ pub(super) fn map_aesthetic_name( } } -/// Map internal position aesthetic to Vega-Lite channel name based on coord type. -/// -/// Returns `Some(channel_name)` for internal position aesthetics (pos1, pos2, etc.), -/// or `None` for material aesthetics. -pub(super) fn map_position_to_vegalite(aesthetic: &str, coord_kind: CoordKind) -> Option { - let (primary, secondary) = match coord_kind { - CoordKind::Cartesian => ("x", "y"), - CoordKind::Polar => ("radius", "theta"), - }; - - // Match internal position aesthetic patterns - // Convention: min → primary channel (x/y), max → secondary channel (x2/y2) - match aesthetic { - // Primary position and min variants - "pos1" | "pos1min" => Some(primary.to_string()), - "pos2" | "pos2min" => Some(secondary.to_string()), - // End and max variants (Vega-Lite uses x2/y2/theta2/radius2) - "pos1end" | "pos1max" => Some(format!("{}2", primary)), - "pos2end" | "pos2max" => Some(format!("{}2", secondary)), - _ => None, - } -} - // ============================================================================= // RenderContext // ============================================================================= @@ -1070,31 +1014,36 @@ impl<'a> RenderContext<'a> { /// Create a new render context pub fn new( scales: &'a [crate::Scale], - coord_kind: CoordKind, + renderer: &dyn super::projection::ProjectionRenderer, aesthetic_context: crate::plot::aesthetic::AestheticContext, ) -> Self { - let pos1 = map_position_to_vegalite("pos1", coord_kind).unwrap(); - let pos1_end = map_position_to_vegalite("pos1end", coord_kind).unwrap(); - let pos2 = map_position_to_vegalite("pos2", coord_kind).unwrap(); - let pos2_end = map_position_to_vegalite("pos2end", coord_kind).unwrap(); - - let (pos1_offset, pos2_offset) = match coord_kind { - CoordKind::Cartesian => ("xOffset".to_string(), "yOffset".to_string()), - CoordKind::Polar => ("radiusOffset".to_string(), "thetaOffset".to_string()), - }; + let pos1 = super::projection::map_position_to_vegalite("pos1", renderer).unwrap(); + let pos1_end = super::projection::map_position_to_vegalite("pos1end", renderer).unwrap(); + let pos2 = super::projection::map_position_to_vegalite("pos2", renderer).unwrap(); + let pos2_end = super::projection::map_position_to_vegalite("pos2end", renderer).unwrap(); + + let (pos1_offset, pos2_offset) = renderer.offset_channels(); Self { scales, - channels: (pos1, pos1_end, pos1_offset, pos2, pos2_end, pos2_offset), + channels: ( + pos1, + pos1_end, + pos1_offset.to_string(), + pos2, + pos2_end, + pos2_offset.to_string(), + ), aesthetic_context, } } #[cfg(test)] pub fn default_for_test() -> Self { + let renderer = super::projection::get_projection_renderer(None, None, &[]); Self::new( &[], - CoordKind::Cartesian, + renderer.as_ref(), crate::plot::aesthetic::AestheticContext::from_static(&["x", "y"], &[]), ) } @@ -1261,8 +1210,8 @@ mod tests { mod get_extent_translation_tests { use super::*; use crate::plot::aesthetic::AestheticContext; - use crate::plot::projection::CoordKind; use crate::plot::{ArrayElement, Scale}; + use crate::writer::vegalite::projection::get_projection_renderer; fn discrete_scale(aesthetic: &str) -> Scale { Scale { @@ -1285,7 +1234,7 @@ mod tests { let scales: Vec = vec![]; let ctx = RenderContext::new( &scales, - CoordKind::Cartesian, + get_projection_renderer(None, None, &[]).as_ref(), AestheticContext::from_static(&["x", "y"], &[]), ); let err = ctx.get_extent("pos1").unwrap_err().to_string(); @@ -1300,7 +1249,7 @@ mod tests { let scales: Vec = vec![]; let ctx = RenderContext::new( &scales, - CoordKind::Polar, + get_projection_renderer(None, None, &[]).as_ref(), AestheticContext::from_static(&["angle", "radius"], &[]), ); let err = ctx.get_extent("pos1").unwrap_err().to_string(); @@ -1317,7 +1266,7 @@ mod tests { let scales = vec![discrete_scale("pos2")]; let ctx = RenderContext::new( &scales, - CoordKind::Cartesian, + get_projection_renderer(None, None, &[]).as_ref(), AestheticContext::from_static(&["x", "y"], &[]), ); let err = ctx.get_extent("pos2").unwrap_err().to_string(); diff --git a/src/writer/vegalite/layer.rs b/src/writer/vegalite/layer.rs index 8f0a7e58..30c799a5 100644 --- a/src/writer/vegalite/layer.rs +++ b/src/writer/vegalite/layer.rs @@ -145,7 +145,7 @@ pub enum PreparedData { /// /// Most geoms use the default implementations. Only geoms with special requirements /// (bar width, path ordering, boxplot decomposition) need to override specific methods. -pub trait GeomRenderer: Send + Sync { +pub trait GeomRenderer { // === Phase 1: Data Preparation === /// Prepare data for this layer. @@ -2155,7 +2155,6 @@ pub fn get_renderer(geom: &Geom) -> Box { #[cfg(test)] mod tests { use super::*; - use crate::plot::projection::CoordKind; #[test] fn test_violin_detail_encoding() { @@ -3635,6 +3634,9 @@ mod tests { #[test] fn test_render_context_get_extent() { use crate::plot::{ArrayElement, Scale}; + use crate::writer::vegalite::projection::get_projection_renderer; + + let cartesian = get_projection_renderer(None, None, &[]); // Test success case: continuous scale with numeric range let scales = vec![Scale { @@ -3652,7 +3654,7 @@ mod tests { }]; let context = RenderContext::new( &scales, - CoordKind::Cartesian, + cartesian.as_ref(), AestheticContext::from_static(&["x", "y"], &[]), ); let result = context.get_extent("x"); @@ -3662,7 +3664,7 @@ mod tests { // Test error case: scale not found let context = RenderContext::new( &scales, - CoordKind::Cartesian, + cartesian.as_ref(), AestheticContext::from_static(&["x", "y"], &[]), ); let result = context.get_extent("y"); @@ -3685,7 +3687,7 @@ mod tests { }]; let context = RenderContext::new( &scales, - CoordKind::Cartesian, + cartesian.as_ref(), AestheticContext::from_static(&["x", "y"], &[]), ); let result = context.get_extent("x"); @@ -3714,7 +3716,7 @@ mod tests { }]; let context = RenderContext::new( &scales, - CoordKind::Cartesian, + cartesian.as_ref(), AestheticContext::from_static(&["x", "y"], &[]), ); let result = context.get_extent("x"); diff --git a/src/writer/vegalite/mod.rs b/src/writer/vegalite/mod.rs index 325fe088..bc51e0d3 100644 --- a/src/writer/vegalite/mod.rs +++ b/src/writer/vegalite/mod.rs @@ -26,7 +26,7 @@ mod layer; mod projection; use crate::plot::ArrayElement; -use crate::plot::{CoordKind, ParameterValue, Scale, ScaleTypeKind}; +use crate::plot::{ParameterValue, Scale, ScaleTypeKind}; use crate::writer::Writer; use crate::{naming, AestheticValue, DataFrame, GgsqlError, Plot, Result}; use serde_json::{json, Value}; @@ -38,7 +38,7 @@ use encoding::{ build_detail_encoding, build_encoding_channel, infer_field_type, map_aesthetic_name, }; use layer::{geom_to_mark, get_renderer, validate_layer_columns, GeomRenderer, PreparedData}; -use projection::apply_project_transforms; +use projection::{get_projection_renderer, ProjectionRenderer}; /// Conversion factor from points to pixels (CSS standard: 96 DPI, 72 points/inch) /// 1 point = 96/72 pixels = 1.333 @@ -159,10 +159,7 @@ fn prepare_layer_data( /// - Applies geom-specific modifications via renderer /// - Finalizes layers (may expand composite geoms into multiple layers) /// -/// The `free_scales` array indicates which position aesthetics have independent scales -/// per facet panel. When a position is free, explicit domains should not be set. -/// -/// The `coord_kind` determines how internal position aesthetics are mapped to +/// The `projection` determines how internal position aesthetics are mapped to /// Vega-Lite encoding channel names. fn build_layers( spec: &Plot, @@ -170,14 +167,13 @@ fn build_layers( layer_data_keys: &[String], layer_renderers: &[Box], prepared_data: &[PreparedData], - free_scales: Option<&[crate::plot::ArrayElement]>, - coord_kind: CoordKind, + projection: &dyn ProjectionRenderer, ) -> Result> { let mut layers = Vec::new(); // Build context once for all layers let context = - encoding::RenderContext::new(&spec.scales, coord_kind, spec.get_aesthetic_context()); + encoding::RenderContext::new(&spec.scales, projection, spec.get_aesthetic_context()); for (layer_idx, layer) in spec.layers.iter().enumerate() { let data_key = &layer_data_keys[layer_idx]; @@ -208,8 +204,8 @@ fn build_layers( // Set transform array on layer spec layer_spec["transform"] = json!(transforms); - // Build encoding for this layer (pass free scales and coord kind) - let mut encoding = build_layer_encoding(layer, df, spec, free_scales, coord_kind)?; + // Build encoding for this layer + let mut encoding = build_layer_encoding(layer, df, spec, projection)?; // For point marks, remove fill: null from encoding — Vega-Lite point marks // are unfilled by default, so omitting it achieves the same visual result @@ -247,17 +243,13 @@ fn build_layers( /// - Detail encoding for partition_by columns /// - Geom-specific encoding modifications via renderer /// -/// The `free_scales` array indicates which position aesthetics have independent scales -/// per facet panel. When a position is free, explicit domains should not be set. -/// -/// The `coord_kind` determines how internal position aesthetics (pos1, pos2) are +/// The `projection` determines how internal position aesthetics (pos1, pos2) are /// mapped to Vega-Lite encoding channel names (x/y for cartesian, theta/radius for polar). fn build_layer_encoding( layer: &crate::plot::Layer, df: &DataFrame, spec: &Plot, - free_scales: Option<&[crate::plot::ArrayElement]>, - coord_kind: CoordKind, + projection: &dyn ProjectionRenderer, ) -> Result> { let mut encoding = serde_json::Map::new(); @@ -288,7 +280,6 @@ fn build_layer_encoding( spec, titled_families: &mut titled_families, primary_aesthetics: &primary_aesthetics, - free_scales, }; // Build encoding channels for each aesthetic mapping @@ -305,7 +296,7 @@ fn build_layer_encoding( continue; } - let mut channel_name = map_aesthetic_name(aesthetic, &aesthetic_ctx, coord_kind); + let mut channel_name = map_aesthetic_name(aesthetic, &aesthetic_ctx, projection); // Opacity is retargeted to the fill when fill is supported if channel_name == "opacity" && layer.mappings.contains_key("fill") { channel_name = "fillOpacity".to_string(); @@ -332,7 +323,7 @@ fn build_layer_encoding( if let AestheticValue::Column { name: col, .. } = value { let end_col = naming::bin_end_column(col); let end_aesthetic = format!("{}end", aesthetic); // "pos1end" or "pos2end" - let end_channel = map_aesthetic_name(&end_aesthetic, &aesthetic_ctx, coord_kind); // maps to "x2" or "y2" (or theta2/radius2 for polar) + let end_channel = map_aesthetic_name(&end_aesthetic, &aesthetic_ctx, projection); // maps to "x2" or "y2" (or theta2/radius2 for polar) encoding.insert(end_channel, json!({"field": end_col})); } } @@ -345,7 +336,7 @@ fn build_layer_encoding( // Build context for renderer (also provides resolved position channel names) let context = - encoding::RenderContext::new(&spec.scales, coord_kind, spec.get_aesthetic_context()); + encoding::RenderContext::new(&spec.scales, projection, spec.get_aesthetic_context()); let (_, _, pos1_offset, pos2, _, pos2_offset) = &context.channels; // Add pos1 offset encoding for dodged positions (pos1offset column) @@ -415,7 +406,7 @@ fn apply_faceting( facet: &crate::plot::Facet, facet_df: &DataFrame, scales: &[Scale], - coord_kind: CoordKind, + projection: &dyn ProjectionRenderer, ) { use crate::plot::FacetLayout; @@ -460,7 +451,7 @@ fn apply_faceting( obj.remove("width"); obj.remove("height"); - apply_facet_scale_resolution(vl_spec, &facet.properties, coord_kind); + apply_facet_scale_resolution(vl_spec, &facet.properties, projection); apply_facet_properties(vl_spec, &facet.properties, true); } FacetLayout::Grid { row: _, column: _ } => { @@ -511,7 +502,7 @@ fn apply_faceting( obj.remove("width"); obj.remove("height"); - apply_facet_scale_resolution(vl_spec, &facet.properties, coord_kind); + apply_facet_scale_resolution(vl_spec, &facet.properties, projection); apply_facet_properties(vl_spec, &facet.properties, false); } } @@ -599,23 +590,6 @@ fn apply_facet_ordering(facet_def: &mut Value, scale: Option<&Scale>) { } } -/// Extract free scales from facet properties as a boolean vector -/// -/// After facet resolution, the `free` property is normalized to a boolean array: -/// - `[true, false]` = free pos1, fixed pos2 -/// - `[false, true]` = fixed pos1, free pos2 -/// - `[true, true]` = both free -/// - `[false, false]` = both fixed (default) -/// -/// Returns reference to the free scales array from facet properties. -fn get_free_scales(facet: Option<&crate::plot::Facet>) -> Option<&[crate::plot::ArrayElement]> { - let facet = facet?; - match facet.properties.get("free") { - Some(ParameterValue::Array(arr)) => Some(arr.as_slice()), - _ => None, - } -} - /// Apply scale resolution to Vega-Lite spec based on facet free property /// /// Maps ggsql free property (boolean array) to Vega-Lite resolve.scale configuration: @@ -624,24 +598,21 @@ fn get_free_scales(facet: Option<&crate::plot::Facet>) -> Option<&[crate::plot:: /// - `[false, true]`: shared pos1 scale, independent pos2 scale (y or theta) /// - `[true, true]`: independent scales for both axes /// -/// The channel names depend on coord_kind: +/// The channel names depend on the projection: /// - Cartesian: pos1 -> "x", pos2 -> "y" /// - Polar: pos1 -> "radius", pos2 -> "theta" fn apply_facet_scale_resolution( vl_spec: &mut Value, properties: &HashMap, - coord_kind: CoordKind, + projection: &dyn ProjectionRenderer, ) { let Some(ParameterValue::Array(arr)) = properties.get("free") else { // No free property means fixed/shared scales (Vega-Lite default) return; }; - // Determine channel names based on coord kind - let (pos1_channel, pos2_channel) = match coord_kind { - CoordKind::Cartesian => ("x", "y"), - CoordKind::Polar => ("radius", "theta"), - }; + // Determine channel names from the projection renderer + let (pos1_channel, pos2_channel) = projection.position_channels(); // Extract booleans from the array (position-indexed) let free_pos1 = arr @@ -925,7 +896,7 @@ fn build_discrete_facet_label_expr( } /// Escape a string for use in Vega expressions -fn escape_vega_string(s: &str) -> String { +pub(super) fn escape_vega_string(s: &str) -> String { s.replace('\\', "\\\\").replace('\'', "\\'") } @@ -1049,13 +1020,7 @@ impl Writer for VegaLiteWriter { // 1. Validate spec self.validate(spec)?; - // 2. Get free scales array (if any) - // When using free scales, Vega-Lite computes independent domains per facet panel. - // We must not set explicit domains (from SCALE or COORD) as they would override this. - // The free property is a boolean array [pos1_free, pos2_free, ...]. - let free_scales = get_free_scales(spec.facet.as_ref()); - - // 3. Determine layer data keys + // 2. Determine layer data keys let layer_data_keys: Vec = spec .layers .iter() @@ -1086,23 +1051,13 @@ impl Writer for VegaLiteWriter { let mut vl_spec = json!({ "$schema": self.schema }); - // Container sizing doesn't work with faceting in Vega-Lite, so only apply it - // for non-faceted charts - if spec.facet.is_none() { - vl_spec["width"] = json!("container"); - vl_spec["height"] = json!("container"); - } else { - // Faceted charts need explicit numeric dimensions (moved into inner spec - // by apply_faceting). Arc marks especially need this since their radius - // range is [0, min(width, height) / 2] — without dimensions, arcs are invisible. - let is_polar = spec - .project - .as_ref() - .is_some_and(|p| p.coord.coord_kind() == CoordKind::Polar); - if is_polar { - vl_spec["width"] = json!(DEFAULT_POLAR_SIZE); - vl_spec["height"] = json!(DEFAULT_POLAR_SIZE); - } + // Get projection renderer (single instance used throughout) + let projection = + get_projection_renderer(spec.project.as_ref(), spec.facet.as_ref(), &spec.scales); + + if let Some((w, h)) = projection.panel_size() { + vl_spec["width"] = w; + vl_spec["height"] = h; } if let Some(labels) = &spec.labels { @@ -1140,39 +1095,35 @@ impl Writer for VegaLiteWriter { let unified_data = unify_datasets(&prep.datasets)?; vl_spec["data"] = json!({"values": unified_data}); - // 9. Get coord kind (default to Cartesian if no project) - let coord_kind = spec - .project - .as_ref() - .map(|p| p.coord.coord_kind()) - .unwrap_or(CoordKind::Cartesian); - - // 10. Build layers (pass free scales and coord kind for domain handling) + // 9. Build layers let layers = build_layers( spec, data, &layer_data_keys, &prep.renderers, &prep.prepared, - free_scales, - coord_kind, + projection.as_ref(), )?; vl_spec["layer"] = json!(layers); - // 10. Apply projection transforms - let first_df = data.get(&layer_data_keys[0]).unwrap(); - apply_project_transforms(spec, first_df, &mut vl_spec)?; - - // 11. Apply faceting + // 10. Apply faceting if let Some(facet) = &spec.facet { let facet_df = data.get(&layer_data_keys[0]).unwrap(); - apply_faceting(&mut vl_spec, facet, facet_df, &spec.scales, coord_kind); + apply_faceting( + &mut vl_spec, + facet, + facet_df, + &spec.scales, + projection.as_ref(), + ); } - // 12. Add default theme config (ggplot2-like gray theme) - vl_spec["config"] = self.default_theme_config(); + // 11. Apply projection (transforms + panel decoration) + let mut theme = self.default_theme_config(); + projection.apply_projection(spec, &mut theme, &mut vl_spec)?; + vl_spec["config"] = theme; - // 13. Serialize + // 12. Serialize serde_json::to_string_pretty(&vl_spec).map_err(|e| { GgsqlError::WriterError(format!("Failed to serialize Vega-Lite JSON: {}", e)) }) @@ -1399,96 +1350,50 @@ mod tests { fn test_aesthetic_name_mapping() { use crate::plot::AestheticContext; - // Test with cartesian coord kind + use crate::plot::projection::Projection; + + // Test with cartesian projection (None = default cartesian) let ctx = AestheticContext::from_static(&["x", "y"], &[]); + let cartesian = get_projection_renderer(None, None, &[]); + let cart = cartesian.as_ref(); - // Internal position names should map to Vega-Lite channel names based on coord kind - assert_eq!(map_aesthetic_name("pos1", &ctx, CoordKind::Cartesian), "x"); - assert_eq!(map_aesthetic_name("pos2", &ctx, CoordKind::Cartesian), "y"); - assert_eq!( - map_aesthetic_name("pos1end", &ctx, CoordKind::Cartesian), - "x2" - ); - assert_eq!( - map_aesthetic_name("pos2end", &ctx, CoordKind::Cartesian), - "y2" - ); + // Internal position names should map to Vega-Lite channel names based on projection + assert_eq!(map_aesthetic_name("pos1", &ctx, cart), "x"); + assert_eq!(map_aesthetic_name("pos2", &ctx, cart), "y"); + assert_eq!(map_aesthetic_name("pos1end", &ctx, cart), "x2"); + assert_eq!(map_aesthetic_name("pos2end", &ctx, cart), "y2"); // Material aesthetics pass through directly - assert_eq!( - map_aesthetic_name("color", &ctx, CoordKind::Cartesian), - "color" - ); - assert_eq!( - map_aesthetic_name("fill", &ctx, CoordKind::Cartesian), - "fill" - ); - assert_eq!( - map_aesthetic_name("stroke", &ctx, CoordKind::Cartesian), - "stroke" - ); - assert_eq!( - map_aesthetic_name("opacity", &ctx, CoordKind::Cartesian), - "opacity" - ); - assert_eq!( - map_aesthetic_name("size", &ctx, CoordKind::Cartesian), - "size" - ); - assert_eq!( - map_aesthetic_name("shape", &ctx, CoordKind::Cartesian), - "shape" - ); + assert_eq!(map_aesthetic_name("color", &ctx, cart), "color"); + assert_eq!(map_aesthetic_name("fill", &ctx, cart), "fill"); + assert_eq!(map_aesthetic_name("stroke", &ctx, cart), "stroke"); + assert_eq!(map_aesthetic_name("opacity", &ctx, cart), "opacity"); + assert_eq!(map_aesthetic_name("size", &ctx, cart), "size"); + assert_eq!(map_aesthetic_name("shape", &ctx, cart), "shape"); // Other mapped aesthetics - assert_eq!( - map_aesthetic_name("linetype", &ctx, CoordKind::Cartesian), - "strokeDash" - ); - assert_eq!( - map_aesthetic_name("linewidth", &ctx, CoordKind::Cartesian), - "strokeWidth" - ); - assert_eq!( - map_aesthetic_name("label", &ctx, CoordKind::Cartesian), - "text" - ); - assert_eq!( - map_aesthetic_name("fontsize", &ctx, CoordKind::Cartesian), - "size" - ); + assert_eq!(map_aesthetic_name("linetype", &ctx, cart), "strokeDash"); + assert_eq!(map_aesthetic_name("linewidth", &ctx, cart), "strokeWidth"); + assert_eq!(map_aesthetic_name("label", &ctx, cart), "text"); + assert_eq!(map_aesthetic_name("fontsize", &ctx, cart), "size"); - // Test with polar coord kind - internal position maps to radius/theta + // Test with polar projection - internal position maps to radius/theta // regardless of the context's user-facing names + let polar_proj = Projection::polar(); + let polar = get_projection_renderer(Some(&polar_proj), None, &[]); + let pol = polar.as_ref(); + let polar_ctx = AestheticContext::from_static(&["radius", "theta"], &[]); - assert_eq!( - map_aesthetic_name("pos1", &polar_ctx, CoordKind::Polar), - "radius" - ); - assert_eq!( - map_aesthetic_name("pos2", &polar_ctx, CoordKind::Polar), - "theta" - ); - assert_eq!( - map_aesthetic_name("pos1end", &polar_ctx, CoordKind::Polar), - "radius2" - ); - assert_eq!( - map_aesthetic_name("pos2end", &polar_ctx, CoordKind::Polar), - "theta2" - ); + assert_eq!(map_aesthetic_name("pos1", &polar_ctx, pol), "radius"); + assert_eq!(map_aesthetic_name("pos2", &polar_ctx, pol), "theta"); + assert_eq!(map_aesthetic_name("pos1end", &polar_ctx, pol), "radius2"); + assert_eq!(map_aesthetic_name("pos2end", &polar_ctx, pol), "theta2"); // Even with custom position names (e.g., PROJECT y, x TO polar), // internal pos1/pos2 should still map to radius/theta for Vega-Lite let custom_ctx = AestheticContext::from_static(&["y", "x"], &[]); - assert_eq!( - map_aesthetic_name("pos1", &custom_ctx, CoordKind::Polar), - "radius" - ); - assert_eq!( - map_aesthetic_name("pos2", &custom_ctx, CoordKind::Polar), - "theta" - ); + assert_eq!(map_aesthetic_name("pos1", &custom_ctx, pol), "radius"); + assert_eq!(map_aesthetic_name("pos2", &custom_ctx, pol), "theta"); } #[test] diff --git a/src/writer/vegalite/projection.rs b/src/writer/vegalite/projection.rs index aebafe4a..23771537 100644 --- a/src/writer/vegalite/projection.rs +++ b/src/writer/vegalite/projection.rs @@ -1,42 +1,998 @@ -//! Projection transformations for Vega-Lite writer +//! Projection rendering for Vega-Lite writer //! -//! This module handles projection transformations (cartesian, polar) -//! that modify the Vega-Lite spec structure based on the PROJECT clause. +//! This module provides a trait-based design for projection rendering. +//! Each projection type (cartesian, polar, and future map projections) +//! implements `ProjectionRenderer`, which owns both the VL channel mapping +//! and the spec-level transformations for that projection. -use crate::plot::{CoordKind, ParameterValue, Projection}; -use crate::{DataFrame, GgsqlError, Plot, Result}; +use crate::plot::{CoordKind, ParameterValue, Projection, Scale}; +use crate::{GgsqlError, Plot, Result}; use serde_json::{json, Value}; use super::DEFAULT_POLAR_SIZE; -/// Apply projection transformations to the spec and data -/// Returns (possibly transformed DataFrame, possibly modified spec) -pub(super) fn apply_project_transforms( - spec: &Plot, - data: &DataFrame, - vl_spec: &mut Value, -) -> Result> { - if let Some(ref project) = spec.project { - // Apply coord-specific transformations - let result = match project.coord.coord_kind() { - CoordKind::Cartesian => { - apply_cartesian_project(project, vl_spec)?; - None - } - CoordKind::Polar => Some(apply_polar_project(project, spec, data, vl_spec)?), +// ============================================================================= +// ProjectionRenderer trait +// ============================================================================= + +/// Trait defining how a projection type maps to Vega-Lite. +/// +/// Each implementation owns two concerns: +/// 1. **Channel mapping** — translating internal position aesthetics (pos1, pos2, …) +/// to Vega-Lite encoding channel names. +/// 2. **Spec transformation** — modifying the Vega-Lite spec after layers are built +/// (e.g., converting marks to arcs for polar). +pub(super) trait ProjectionRenderer { + /// Whether the spec uses faceting. + fn is_faceted(&self) -> bool; + + /// Primary and secondary VL channel names for this projection. + /// + /// Returns `(pos1_channel, pos2_channel)`, e.g. `("x", "y")` for cartesian, + /// `("radius", "theta")` for polar. + fn position_channels(&self) -> (&'static str, &'static str); + + /// Offset channel names for this projection. + /// + /// Returns `(pos1_offset, pos2_offset)`, e.g. `("xOffset", "yOffset")`. + fn offset_channels(&self) -> (&'static str, &'static str); + + /// Panel dimensions as VL values (`"container"` or explicit pixels). + /// + /// Returns `None` for faceted cartesian (VL handles sizing). + fn panel_size(&self) -> Option<(Value, Value)> { + if self.is_faceted() { + None + } else { + Some((json!("container"), json!("container"))) + } + } + + /// Apply projection-specific transformations to the VL spec. + /// + /// Called after layers are built but before faceting. + fn transform_layers(&self, _spec: &Plot, _vl_spec: &mut Value) -> Result<()> { + Ok(()) + } + + /// Vega-Lite layers to prepend before the data layers. + fn background_layers(&self, _scales: &[Scale], _theme: &mut Value) -> Vec { + Vec::new() + } + + /// Vega-Lite layers to append after the data layers. + fn foreground_layers(&self, _scales: &[Scale], _theme: &mut Value) -> Vec { + Vec::new() + } + + /// Apply all projection-specific work: transforms, clip, and panel decoration. + fn apply_projection(&self, spec: &Plot, theme: &mut Value, vl_spec: &mut Value) -> Result<()> { + self.transform_layers(spec, vl_spec)?; + + if let Some(ref project) = spec.project { + if let Some(ParameterValue::Boolean(clip)) = project.properties.get("clip") { + apply_clip_to_layers(vl_spec, *clip); + } + } + + let mut bg = self.background_layers(&spec.scales, theme); + let mut fg = self.foreground_layers(&spec.scales, theme); + if !(bg.is_empty() && fg.is_empty()) { + for layer in &mut bg { + layer["description"] = json!("background"); + } + for layer in &mut fg { + layer["description"] = json!("foreground"); + } + if let Some(layers) = get_layers_mut(vl_spec) { + let data_layers = std::mem::take(layers); + layers.reserve(bg.len() + data_layers.len() + fg.len()); + layers.extend(bg); + layers.extend(data_layers); + layers.extend(fg); + } + } + + Ok(()) + } +} + +// ============================================================================= +// Factory +// ============================================================================= + +/// Get the projection renderer for a projection spec. +/// +/// Returns the appropriate renderer based on the projection's coord kind, +/// or a Cartesian renderer if no projection is specified. +pub(super) fn get_projection_renderer( + project: Option<&Projection>, + facet: Option<&crate::plot::Facet>, + scales: &[Scale], +) -> Box { + let is_faceted = facet.is_some_and(|f| !f.get_variables().is_empty()); + match project.map(|p| p.coord.coord_kind()) { + Some(CoordKind::Polar) => Box::new(PolarProjection { + panel: PolarContext::new(project, facet, scales), + }), + Some(CoordKind::Cartesian) | None => Box::new(CartesianProjection { is_faceted }), + } +} + +// ============================================================================= +// Channel mapping helpers (used by encoding.rs via the trait) +// ============================================================================= + +/// Map internal position aesthetic to Vega-Lite channel name using the renderer. +/// +/// Returns `Some(channel_name)` for internal position aesthetics (pos1, pos2, etc.), +/// or `None` for material aesthetics. +pub(super) fn map_position_to_vegalite( + aesthetic: &str, + renderer: &dyn ProjectionRenderer, +) -> Option { + let (primary, secondary) = renderer.position_channels(); + + // Match internal position aesthetic patterns + // Convention: min → primary channel (x/y), max → secondary channel (x2/y2) + match aesthetic { + // Primary position and min variants + "pos1" | "pos1min" => Some(primary.to_string()), + "pos2" | "pos2min" => Some(secondary.to_string()), + // End and max variants (Vega-Lite uses x2/y2/theta2/radius2) + "pos1end" | "pos1max" => Some(format!("{}2", primary)), + "pos2end" | "pos2max" => Some(format!("{}2", secondary)), + _ => None, + } +} + +// ============================================================================= +// CartesianProjection +// ============================================================================= + +/// Cartesian projection — standard x/y coordinates. +struct CartesianProjection { + is_faceted: bool, +} + +impl ProjectionRenderer for CartesianProjection { + fn is_faceted(&self) -> bool { + self.is_faceted + } + + fn position_channels(&self) -> (&'static str, &'static str) { + ("x", "y") + } + + fn offset_channels(&self) -> (&'static str, &'static str) { + ("xOffset", "yOffset") + } +} + +// ============================================================================= +// PolarProjection +// ============================================================================= + +/// Normalized outer radius (proportion of `min(width, height) / 2`). +const POLAR_OUTER: f64 = 1.0; + +/// Bandwidth fraction for discrete polar offsets (mirrors VL's default +/// `1 - paddingInner` for band scales, which is ~0.9). +const POLAR_BAND_FRACTION: f64 = 0.9; + +struct AxisInfo { + domain: Option<(f64, f64)>, + breaks: Vec, + labels: Vec<(f64, String)>, + is_free: bool, +} + +impl AxisInfo { + fn new(aesthetic: &str, scales: &[Scale], facet: Option<&crate::plot::Facet>) -> Self { + let (domain, labels) = match scales.iter().find(|s| s.aesthetic == aesthetic) { + Some(s) => (s.numeric_domain(), s.break_labels()), + None => (None, Vec::new()), + }; + // Set domain to None if zero-range + let domain = domain.filter(|(min, max)| (max - min).abs() > f64::EPSILON); + let breaks = labels.iter().map(|(v, _)| *v).collect(); + let is_free = facet.is_some_and(|f| f.is_free(aesthetic)); + Self { + domain, + breaks, + labels, + is_free, + } + } +} + +/// Resolved geometry and scale context for polar specs. +/// +/// Holds angular range, radius bounds, VL expression strings for the panel +/// centre and radius, and pre-extracted scale domains / breaks / labels for +/// both position channels. In non-faceted specs the expression strings +/// reference `width`/`height` signals; in faceted specs they are literal +/// pixel values (VL signals don't resolve inside faceted inner specs). +struct PolarContext { + // Panel shape + start: f64, + end: f64, + inner: f64, + outer: f64, + /// Explicit radar setting from PROJECT: true, false, or null (auto-detect) + radar: Option, + // Placement details + size: f64, + cx: String, + cy: String, + radius: String, + // Facet state + is_faceted: bool, + radial: AxisInfo, + angle: AxisInfo, + + /// Angle break positions in radians (derived from angle breaks + domain). + angle_breaks_radians: Vec, + + is_full_circle: bool, +} + +impl PolarContext { + fn new( + project: Option<&Projection>, + facet: Option<&crate::plot::Facet>, + scales: &[Scale], + ) -> Self { + let is_faceted = facet.is_some_and(|f| !f.get_variables().is_empty()); + let prop = |name| { + project + .and_then(|p| p.properties.get(name)) + .and_then(|v| match v { + ParameterValue::Number(n) => Some(*n), + _ => None, + }) + }; + let start_degrees = prop("start").unwrap_or(0.0); + let end_degrees = prop("end").unwrap_or(start_degrees + 360.0); + let start = start_degrees * std::f64::consts::PI / 180.0; + let end = end_degrees * std::f64::consts::PI / 180.0; + let radar = if let Some(ParameterValue::Boolean(b)) = + project.and_then(|p| p.properties.get("radar")) + { + Some(*b) + } else { + None + }; + let inner = prop("inner").unwrap_or(0.0); + let size = prop("size").unwrap_or(DEFAULT_POLAR_SIZE); + let (cx, cy, radius) = if is_faceted { + let half = size / 2.0; + (format!("{half}"), format!("{half}"), format!("{half}")) + } else { + ( + "width / 2".to_string(), + "height / 2".to_string(), + "min(width, height) / 2".to_string(), + ) + }; + let radial = AxisInfo::new("pos1", scales, facet); + let angle = AxisInfo::new("pos2", scales, facet); + + let is_full_circle = (end - start - 2.0 * std::f64::consts::PI).abs() < f64::EPSILON; + + let angle_breaks_radians = match angle.domain { + Some((d_min, d_max)) if !angle.breaks.is_empty() => { + let scale = (end - start) / (d_max - d_min); + angle + .breaks + .iter() + .map(|&b| start + scale * (b - d_min)) + .collect() + } + _ => Vec::new(), + }; + + Self { + is_faceted, + start, + end, + inner, + outer: POLAR_OUTER, + radar, + size, + cx, + cy, + radius, + radial, + angle, + angle_breaks_radians, + is_full_circle, + } + } + + fn is_radar(&self) -> bool { + matches!(self.radar, Some(true)) + } + + fn expr_x(&self, r: &str, theta: &str) -> String { + format!("{} + {} * ({}) * sin({})", self.cx, self.radius, r, theta) + } + + fn expr_y(&self, r: &str, theta: &str) -> String { + format!("{} - {} * ({}) * cos({})", self.cy, self.radius, r, theta) + } + + fn expr_radius(&self, r: &str) -> String { + format!("{} * ({})", self.radius, r) + } + + fn expr_normalize_radius(&self, value: &str) -> String { + match self.radial.domain { + Some((min, max)) => { + let scale = (self.outer - self.inner) / (max - min); + format!("{} + {} * ({} - {})", self.inner, scale, value, min) + } + None => format!("{}", (self.outer + self.inner) / 2.0), + } + } + + fn expr_normalize_theta(&self, value: &str) -> String { + match self.angle.domain { + Some((min, max)) => { + let scale = (self.end - self.start) / (max - min); + format!("{} + {} * ({} - {})", self.start, scale, value, min) + } + None => format!("{}", self.start), + } + } +} + +/// Polar projection — radius/theta coordinates for pie charts, rose plots, etc. +struct PolarProjection { + panel: PolarContext, +} + +impl ProjectionRenderer for PolarProjection { + fn is_faceted(&self) -> bool { + self.panel.is_faceted + } + + fn position_channels(&self) -> (&'static str, &'static str) { + ("radius", "theta") + } + + fn offset_channels(&self) -> (&'static str, &'static str) { + ("radiusOffset", "thetaOffset") + } + + fn panel_size(&self) -> Option<(Value, Value)> { + if self.panel.is_faceted { + let size = self.panel.size; + Some((json!(size), json!(size))) + } else { + Some((json!("container"), json!("container"))) + } + } + + fn transform_layers(&self, spec: &Plot, vl_spec: &mut Value) -> Result<()> { + apply_polar_project(&self.panel, spec, vl_spec) + } + + fn background_layers(&self, _scales: &[Scale], theme: &mut Value) -> Vec { + let mut layers = Vec::new(); + layers.extend(self.panel_arc(theme)); + layers.extend(self.grid_rings(theme)); + layers.extend(self.grid_spokes(theme)); + layers + } + + fn foreground_layers(&self, _scales: &[Scale], theme: &mut Value) -> Vec { + let mut layers = Vec::new(); + layers.extend(self.radial_axis(theme)); + layers.extend(self.angular_axis(theme)); + layers + } +} + +// Decoration positions are computed from the global scale domain, so they +// cannot represent per-panel domains under free scales. We suppress them +// rather than rendering misleading grid lines / axes. Per-panel decorations +// would require computing per-group domains — not yet implemented. +impl PolarProjection { + fn grid_rings(&self, theme: &Value) -> Vec { + let p = &self.panel; + if p.radial.is_free { + return Vec::new(); + } + let Some((domain_min, domain_max)) = p.radial.domain else { + return Vec::new(); + }; + if p.radial.breaks.is_empty() { + return Vec::new(); + } + + let color = theme + .pointer("/axis/gridColor") + .cloned() + .unwrap_or(json!("#FFFFFF")); + let width = theme + .pointer("/axis/gridWidth") + .cloned() + .unwrap_or(json!(1)); + + if p.is_radar() { + if p.angle_breaks_radians.is_empty() { + return Vec::new(); + } + return p + .radial + .breaks + .iter() + .map(|&b| { + let r = p.inner + + (p.outer - p.inner) * (b - domain_min) / (domain_max - domain_min); + let mut layer = polygon_ring(p, r, None, Value::Null, color.clone()); + layer["mark"]["strokeWidth"] = width.clone(); + layer + }) + .collect(); + } + + let values: Vec = p.radial.breaks.iter().map(|&b| json!({"v": b})).collect(); + let r_norm = p.expr_normalize_radius("datum.v"); + let radius_expr = p.expr_radius(&r_norm); + + vec![json!({ + "data": {"values": values}, + "mark": { + "type": "arc", + "fill": null, + "stroke": color, + "strokeWidth": width, + "theta": p.start, + "theta2": p.end, + }, + "encoding": { + "radius": { + "value": {"expr": radius_expr} + } + } + })] + } + + fn grid_spokes(&self, theme: &Value) -> Vec { + let p = &self.panel; + if p.angle.is_free || p.angle.domain.is_none() { + return Vec::new(); + } + if p.angle.breaks.is_empty() { + return Vec::new(); + } + + let color = theme + .pointer("/axis/gridColor") + .cloned() + .unwrap_or(json!("#FFFFFF")); + let width = theme + .pointer("/axis/gridWidth") + .cloned() + .unwrap_or(json!(1)); + + let values: Vec = p.angle.breaks.iter().map(|&b| json!({"v": b})).collect(); + let theta = p.expr_normalize_theta("datum.v"); + let inner_s = format!("{}", p.inner); + let outer_s = format!("{}", p.outer); + + vec![json!({ + "data": {"values": values}, + "mark": { + "type": "rule", + "stroke": color, + "strokeWidth": width, + }, + "transform": [ + {"calculate": p.expr_x(&inner_s, &theta), "as": "x"}, + {"calculate": p.expr_y(&inner_s, &theta), "as": "y"}, + {"calculate": p.expr_x(&outer_s, &theta), "as": "x2"}, + {"calculate": p.expr_y(&outer_s, &theta), "as": "y2"}, + ], + "encoding": { + "x": {"field": "x", "type": "quantitative", "scale": null, "axis": null}, + "y": {"field": "y", "type": "quantitative", "scale": null, "axis": null}, + "x2": {"field": "x2"}, + "y2": {"field": "y2"}, + } + })] + } + + fn radial_axis(&self, theme: &Value) -> Vec { + let p = &self.panel; + if p.radial.is_free { + return Vec::new(); + } + if p.radial.domain.is_none() { + return Vec::new(); + } + + let tick_color = theme + .pointer("/axis/tickColor") + .cloned() + .unwrap_or(json!("#333333")); + let tick_size = theme + .pointer("/axis/tickSize") + .and_then(|v| v.as_f64()) + .unwrap_or(4.0); + let label_color = theme + .pointer("/axis/labelColor") + .cloned() + .unwrap_or(json!("#4D4D4D")); + let label_font_size = theme + .pointer("/axis/labelFontSize") + .cloned() + .unwrap_or(json!(12)); + let line_color = theme + .pointer("/axis/domainColor") + .cloned() + .unwrap_or(Value::Null); + + let mut layers = Vec::new(); + + // In radar mode, the start angle doesn't coincide with a spoke, + // so the polygon edge is closer to the centre than the circumscribed + // radius. Scale radii by cos(angle to nearest break) so the axis + // lands on the edge. + let r_correction = if p.is_radar() { + p.angle_breaks_radians + .first() + .map(|&t| (t - p.start).cos()) + .unwrap_or(1.0) + } else { + 1.0 + }; + + // Axis line: rule from inner to outer at start angle + let inner_s = format!("{}", p.inner * r_correction); + let start_s = format!("{}", p.start); + let outer_s = format!("{}", p.outer * r_correction); + layers.push(json!({ + "data": {"values": [{}]}, + "mark": { + "type": "rule", + "stroke": line_color, + }, + "transform": [ + {"calculate": p.expr_x(&inner_s, &start_s), "as": "x"}, + {"calculate": p.expr_y(&inner_s, &start_s), "as": "y"}, + {"calculate": p.expr_x(&outer_s, &start_s), "as": "x2"}, + {"calculate": p.expr_y(&outer_s, &start_s), "as": "y2"}, + ], + "encoding": { + "x": {"field": "x", "type": "quantitative", "scale": null, "axis": null}, + "y": {"field": "y", "type": "quantitative", "scale": null, "axis": null}, + "x2": {"field": "x2"}, + "y2": {"field": "y2"}, + } + })); + + if p.radial.labels.is_empty() { + return layers; + } + + // Tick marks: short perpendicular segments at each break. + // The radial axis is at `start`, so ticks extend in the tangential + // direction. We offset by ±tick_size pixels from the axis line. + // In pixel space, the tangential unit vector at angle θ is + // (cos(θ), sin(θ)), so we shift by that times half the tick size. + let values: Vec = p + .radial + .labels + .iter() + .map(|(v, label)| json!({"v": v, "label": label})) + .collect(); + let r_norm_raw = p.expr_normalize_radius("datum.v"); + let r_norm = if r_correction < 1.0 { + format!("({r_norm_raw}) * {r_correction}") + } else { + r_norm_raw + }; + + let tick_just: f64 = if p.is_full_circle { 0.5 } else { 0.0 }; + let (sin_start, cos_start) = p.start.sin_cos(); + let dx_out = format!("{}", (1.0 - tick_just) * tick_size * cos_start); + let dy_out = format!("{}", (1.0 - tick_just) * tick_size * sin_start); + let dx_in = format!("{}", tick_just * tick_size * cos_start); + let dy_in = format!("{}", tick_just * tick_size * sin_start); + + let cx = p.expr_x(&r_norm, &start_s); + let cy = p.expr_y(&r_norm, &start_s); + + layers.push(json!({ + "data": {"values": values.clone()}, + "mark": { + "type": "rule", + "stroke": tick_color, + }, + "transform": [ + {"calculate": cx, "as": "cx"}, + {"calculate": cy, "as": "cy"}, + {"calculate": format!("datum.cx - {dx_out}"), "as": "x"}, + {"calculate": format!("datum.cy - {dy_out}"), "as": "y"}, + {"calculate": format!("datum.cx + {dx_in}"), "as": "x2"}, + {"calculate": format!("datum.cy + {dy_in}"), "as": "y2"}, + ], + "encoding": { + "x": {"field": "x", "type": "quantitative", "scale": null, "axis": null}, + "y": {"field": "y", "type": "quantitative", "scale": null, "axis": null}, + "x2": {"field": "x2"}, + "y2": {"field": "y2"}, + } + })); + + // Labels: text positioned beyond the outer end of the tick + let label_pad = 2.0; + let label_offset = (1.0 - tick_just) * tick_size + label_pad; + let lx = format!("{}", -label_offset * cos_start); + let ly = format!("{}", -label_offset * sin_start); + + layers.push(json!({ + "data": {"values": values}, + "mark": { + "type": "text", + "color": label_color, + "fontSize": label_font_size, + "align": if cos_start > 0.1 { "right" } else if cos_start < -0.1 { "left" } else { "center" }, + "baseline": if sin_start > 0.1 { "bottom" } else if sin_start < -0.1 { "top" } else { "middle" }, + }, + "transform": [ + {"calculate": format!("{cx} + {lx}"), "as": "x"}, + {"calculate": format!("{cy} + {ly}"), "as": "y"}, + ], + "encoding": { + "x": {"field": "x", "type": "quantitative", "scale": null, "axis": null}, + "y": {"field": "y", "type": "quantitative", "scale": null, "axis": null}, + "text": {"field": "label", "type": "nominal"}, + } + })); + + layers + } + + fn angular_axis(&self, theme: &Value) -> Vec { + let p = &self.panel; + if p.angle.is_free { + return Vec::new(); + } + let Some((domain_min, domain_max)) = p.angle.domain else { + return Vec::new(); + }; + + let tick_color = theme + .pointer("/axis/tickColor") + .cloned() + .unwrap_or(json!("#333333")); + let tick_size = theme + .pointer("/axis/tickSize") + .and_then(|v| v.as_f64()) + .unwrap_or(4.0); + let label_color = theme + .pointer("/axis/labelColor") + .cloned() + .unwrap_or(json!("#4D4D4D")); + let label_font_size = theme + .pointer("/axis/labelFontSize") + .cloned() + .unwrap_or(json!(12)); + let line_color = theme + .pointer("/axis/domainColor") + .cloned() + .unwrap_or(Value::Null); + + let mut layers = Vec::new(); + + // Axis line along the outer edge + let outer_s = format!("{}", p.outer); + if p.is_radar() { + if !p.angle_breaks_radians.is_empty() { + layers.push(polygon_ring( + p, + p.outer, + None, + Value::Null, + line_color.clone(), + )); + } + } else { + layers.push(arc_ring(p, &outer_s, None, Value::Null, line_color.clone())); + } + + if p.angle.labels.is_empty() { + return layers; + } + + // Ticks: short radial segments at each theta break, pointing inward. + // The tick direction at angle θ is along the radius vector: + // unit = (sin(θ), -cos(θ)) in pixel space. + let values: Vec = p + .angle + .labels + .iter() + .map(|(v, label)| json!({"v": v, "label": label})) + .collect(); + let theta = p.expr_normalize_theta("datum.v"); + + let tick_just: f64 = if p.is_full_circle { 0.5 } else { 0.0 }; + + let outer_cx = p.expr_x(&outer_s, &theta); + let outer_cy = p.expr_y(&outer_s, &theta); + + // Radial unit vector at angle θ is (sin(θ), -cos(θ)) in pixel space, + // scaled by min(width,height)/2. Since the tick is small, we use the + // precomputed center and offset by fixed pixel amounts via the + // normalized radius direction. + let inward = format!("{}", tick_just * tick_size); + let outward = format!("{}", (1.0 - tick_just) * tick_size); + + layers.push(json!({ + "data": {"values": values.clone()}, + "mark": { + "type": "rule", + "stroke": tick_color, + }, + "transform": [ + {"calculate": &theta, "as": "theta"}, + {"calculate": outer_cx, "as": "cx"}, + {"calculate": outer_cy, "as": "cy"}, + {"calculate": format!("datum.cx + {outward} * sin(datum.theta)"), "as": "x"}, + {"calculate": format!("datum.cy - {outward} * cos(datum.theta)"), "as": "y"}, + {"calculate": format!("datum.cx - {inward} * sin(datum.theta)"), "as": "x2"}, + {"calculate": format!("datum.cy + {inward} * cos(datum.theta)"), "as": "y2"}, + ], + "encoding": { + "x": {"field": "x", "type": "quantitative", "scale": null, "axis": null}, + "y": {"field": "y", "type": "quantitative", "scale": null, "axis": null}, + "x2": {"field": "x2"}, + "y2": {"field": "y2"}, + } + })); + + // Labels: one sub-layer per (align, baseline) combination. + // All break values live in the parent data with an `_ab` tag; each + // child filters on its tag and sets the corresponding mark alignment. + let label_pad = 2.0; + let label_offset = format!("{}", (1.0 - tick_just) * tick_size + label_pad); + let theta_scale = (p.end - p.start) / (domain_max - domain_min); + + let mut label_values = Vec::new(); + let mut alignment_keys = std::collections::BTreeSet::new(); + for &(v, ref label) in &p.angle.labels { + let angle = p.start + theta_scale * (v - domain_min); + let (sin_a, cos_a) = angle.sin_cos(); + let align = if sin_a > 0.1 { + "left" + } else if sin_a < -0.1 { + "right" + } else { + "center" + }; + let baseline = if cos_a > 0.1 { + "bottom" + } else if cos_a < -0.1 { + "top" + } else { + "middle" + }; + let ab = format!("{align}/{baseline}"); + alignment_keys.insert(ab.clone()); + label_values.push(json!({"v": v, "label": label, "_ab": ab})); + } + + let sub_layers: Vec = alignment_keys + .into_iter() + .map(|ab| { + let (align, baseline) = ab.split_once('/').unwrap(); + json!({ + "transform": [ + {"filter": {"field": "_ab", "equal": ab}}, + ], + "mark": { + "type": "text", + "color": label_color, + "fontSize": label_font_size, + "align": align, + "baseline": baseline, + }, + }) + }) + .collect(); + + layers.push(json!({ + "data": {"values": label_values}, + "transform": [ + {"calculate": &theta, "as": "theta"}, + {"calculate": outer_cx, "as": "cx"}, + {"calculate": outer_cy, "as": "cy"}, + {"calculate": format!("datum.cx + {label_offset} * sin(datum.theta)"), "as": "x"}, + {"calculate": format!("datum.cy - {label_offset} * cos(datum.theta)"), "as": "y"}, + ], + "encoding": { + "x": {"field": "x", "type": "quantitative", "scale": null, "axis": null}, + "y": {"field": "y", "type": "quantitative", "scale": null, "axis": null}, + "text": {"field": "label", "type": "nominal"}, + }, + "layer": sub_layers, + })); + + layers + } + + fn panel_arc(&self, theme: &mut Value) -> Vec { + let Some(view) = theme.get_mut("view").and_then(|v| v.as_object_mut()) else { + return Vec::new(); }; + let fill = view.remove("fill").unwrap_or(Value::Null); + let stroke = view.remove("stroke").unwrap_or(Value::Null); - // Apply clip setting (applies to all projection types) - if let Some(ParameterValue::Boolean(clip)) = project.properties.get("clip") { - apply_clip_to_layers(vl_spec, *clip); + // We need a null-stroke otherwise it'll show up as a gray line + view.insert("stroke".to_string(), Value::Null); + + let p = &self.panel; + + let inner_s = format!("{}", p.inner); + let outer_s = format!("{}", p.outer); + let inner = if p.inner > 0.0 { + Some(inner_s.as_str()) + } else { + None + }; + + if p.is_radar() { + if p.angle_breaks_radians.is_empty() { + return Vec::new(); + } + return vec![polygon_ring( + p, + p.outer, + inner.map(|_| p.inner), + fill, + stroke, + )]; } - Ok(result) + vec![arc_ring(p, &outer_s, inner, fill, stroke)] + } +} + +/// Circular arc layer at a given radius. +/// +/// When `inner_radius` is provided, produces a donut arc with both inner +/// and outer radius set on the mark. +fn arc_ring( + panel: &PolarContext, + outer_radius: &str, + inner_radius: Option<&str>, + fill: Value, + stroke: Value, +) -> Value { + let outer_expr = panel.expr_radius(outer_radius); + let mut mark = json!({ + "type": "arc", + "fill": fill, + "stroke": stroke, + "theta": panel.start, + "theta2": panel.end, + }); + mark["outerRadius"] = json!({"expr": outer_expr}); + if let Some(inner) = inner_radius { + mark["innerRadius"] = json!({"expr": panel.expr_radius(inner)}); + } + json!({ + "data": {"values": [{}]}, + "mark": mark, + }) +} + +/// Straight-segment polygon layer through theta breaks at a given radius. +/// +/// When `inner_radius` is provided, traces the outer ring forward and inner +/// ring reversed to create a donut shape. The seam at the start angle +/// causes the fill rule to leave the centre hole empty. +/// +/// For a full circle the first vertex is repeated to close each ring. +/// A partial arc leaves the endpoints unconnected. +fn polygon_ring( + panel: &PolarContext, + outer_radius: f64, + inner_radius: Option, + fill: Value, + stroke: Value, +) -> Value { + // A full circle repeats the first vertex to close the polygon. + // A partial arc leaves the endpoints unconnected — the start/end edges + // are straight radial lines, not segments between the first and last + // theta break. + + let thetas = &panel.angle_breaks_radians; + let inner = inner_radius.unwrap_or(0.0); + let mut vertices: Vec<(f64, f64)> = Vec::new(); + + if panel.is_full_circle { + // Outer ring at theta breaks, then repeat first to close + for &theta in thetas { + vertices.push((outer_radius, theta)); + } + if let Some(&first) = thetas.first() { + vertices.push((outer_radius, first)); + } + // Donut: trace inner ring reversed, closing back to start + if inner_radius.is_some() { + for &theta in thetas.iter().rev() { + vertices.push((inner, theta)); + } + if let Some(&last) = thetas.last() { + vertices.push((inner, last)); + } + } } else { - Ok(None) + // Partial arc: outer ring from start angle through breaks to + // end angle, then return via inner radius (or centre). + // The start/end vertices are corrected inward so they sit on + // the plane of the adjacent polygon edge. + let start_correction = thetas + .first() + .map(|&t| (t - panel.start).cos()) + .unwrap_or(1.0); + let end_correction = thetas.last().map(|&t| (panel.end - t).cos()).unwrap_or(1.0); + + vertices.push((outer_radius * start_correction, panel.start)); + for &theta in thetas { + vertices.push((outer_radius, theta)); + } + vertices.push((outer_radius * end_correction, panel.end)); + + // Return path along inner radius (or single centre point) + if inner_radius.is_some() { + vertices.push((inner * end_correction, panel.end)); + for &theta in thetas.iter().rev() { + vertices.push((inner, theta)); + } + vertices.push((inner * start_correction, panel.start)); + } else { + vertices.push((inner * end_correction, panel.end)); + vertices.push((inner * start_correction, panel.start)); + } + // Close back to first vertex + vertices.push((outer_radius * start_correction, panel.start)); } + + let values: Vec = vertices + .iter() + .enumerate() + .map(|(i, &(r, theta))| json!({"theta": theta, "r": r, "order": i})) + .collect(); + + json!({ + "data": {"values": values}, + "mark": { + "type": "line", + "fill": fill, + "stroke": stroke, + }, + "transform": [ + {"calculate": panel.expr_x("datum.r", "datum.theta"), "as": "x"}, + {"calculate": panel.expr_y("datum.r", "datum.theta"), "as": "y"}, + ], + "encoding": { + "x": {"field": "x", "type": "quantitative", "scale": null, "axis": null}, + "y": {"field": "y", "type": "quantitative", "scale": null, "axis": null}, + "order": {"field": "order", "type": "quantitative"}, + } + }) } +// ============================================================================= +// Shared helpers +// ============================================================================= + /// Get mutable reference to the layers array, handling both flat and faceted specs. /// /// In a flat spec: `vl_spec["layer"]` @@ -70,11 +1026,9 @@ fn apply_clip_to_layers(vl_spec: &mut Value, clip: bool) { } } -/// Apply Cartesian projection properties -fn apply_cartesian_project(_project: &Projection, _vl_spec: &mut Value) -> Result<()> { - // ratio - not yet implemented - Ok(()) -} +// ============================================================================= +// Polar projection transformation +// ============================================================================= /// Apply Polar projection transformation (bar->arc, point->arc with radius) /// @@ -83,51 +1037,8 @@ fn apply_cartesian_project(_project: &Projection, _vl_spec: &mut Value) -> Resul /// 1. Converts mark types to polar equivalents (bar → arc) /// 2. Applies start/end angle range from PROJECT clause /// 3. Applies inner radius for donut charts -fn apply_polar_project( - project: &Projection, - spec: &Plot, - data: &DataFrame, - vl_spec: &mut Value, -) -> Result { - // Get start angle in degrees (defaults to 0 = 12 o'clock) - let start_degrees = project - .properties - .get("start") - .and_then(|v| match v { - ParameterValue::Number(n) => Some(*n), - _ => None, - }) - .unwrap_or(0.0); - - // Get end angle in degrees (defaults to start + 360 = full circle) - let end_degrees = project - .properties - .get("end") - .and_then(|v| match v { - ParameterValue::Number(n) => Some(*n), - _ => None, - }) - .unwrap_or(start_degrees + 360.0); - - // Get inner radius proportion (0.0 to 1.0, defaults to 0 = full pie) - let inner = project - .properties - .get("inner") - .and_then(|v| match v { - ParameterValue::Number(n) => Some(*n), - _ => None, - }) - .unwrap_or(0.0); - - // Convert degrees to radians for Vega-Lite - let start_radians = start_degrees * std::f64::consts::PI / 180.0; - let end_radians = end_degrees * std::f64::consts::PI / 180.0; - - // Convert geoms to polar equivalents and apply angle range + inner radius - convert_geoms_to_polar(spec, vl_spec, start_radians, end_radians, inner)?; - - // No DataFrame transformation needed - Vega-Lite handles polar math - Ok(data.clone()) +fn apply_polar_project(panel: &PolarContext, spec: &Plot, vl_spec: &mut Value) -> Result<()> { + convert_geoms_to_polar(panel, spec, vl_spec) } /// Convert geoms to polar equivalents (bar->arc) and apply angle range + inner radius @@ -141,33 +1052,7 @@ fn apply_polar_project( /// 2. **Non-arc marks** (point, line): Vega-Lite only supports radius/theta channels /// for arc and text marks. For other marks, we convert polar→cartesian using /// calculate transforms and x/y encoding channels. -fn convert_geoms_to_polar( - spec: &Plot, - vl_spec: &mut Value, - start_radians: f64, - end_radians: f64, - inner: f64, -) -> Result<()> { - let is_faceted = match &spec.facet { - Some(facet) => !facet.get_variables().is_empty(), - _ => false, - }; - - let size = if is_faceted { - // Try to grab size from spec if available - let height = vl_spec.get("height").and_then(|h| h.as_f64()); - let width = vl_spec.get("width").and_then(|w| w.as_f64()); - - Some(match (height, width) { - (Some(h), Some(w)) => h.min(w), - (Some(h), None) => h, - (None, Some(w)) => w, - _ => DEFAULT_POLAR_SIZE, // Fallback - }) - } else { - None - }; - +fn convert_geoms_to_polar(panel: &PolarContext, spec: &Plot, vl_spec: &mut Value) -> Result<()> { if let Some(layers_arr) = get_layers_mut(vl_spec) { for layer in layers_arr { if let Some(mark) = layer.get_mut("mark") { @@ -178,12 +1063,12 @@ fn convert_geoms_to_polar( if is_arc { // Arc marks natively support radius/theta channels if let Some(encoding) = layer.get_mut("encoding") { - apply_polar_angle_range(encoding, start_radians, end_radians)?; - apply_polar_radius_range(encoding, inner, size)?; + apply_polar_angle_range(encoding, panel)?; + apply_polar_radius_range(encoding, panel)?; } } else { // Non-arc marks (point, line): convert polar to cartesian - convert_polar_to_cartesian(layer, start_radians, end_radians, inner)?; + convert_polar_to_cartesian(layer, panel)?; } } } @@ -199,94 +1084,218 @@ fn convert_geoms_to_polar( /// 1. Extract field names and scale domains from the radius/theta encoding /// 2. Add calculate transforms to normalize and convert polar→cartesian /// 3. Replace radius/theta with x/y encoding channels -fn convert_polar_to_cartesian( - layer: &mut Value, - start_radians: f64, - end_radians: f64, - inner: f64, -) -> Result<()> { +fn convert_polar_to_cartesian(layer: &mut Value, panel: &PolarContext) -> Result<()> { // Phase 1: Extract info from encoding (immutable read) - let (r_field, r_domain, r_title, theta_field, theta_domain, theta_title) = { + let ( + r_val, + r_field, + r_title, + r_discrete, + theta_val, + theta_field, + theta_title, + theta_discrete, + r2_field, + theta2_field, + r_offset_field, + theta_offset_field, + ) = { let encoding = layer .get("encoding") .and_then(|e| e.as_object()) .ok_or_else(|| GgsqlError::WriterError("Layer has no encoding object".to_string()))?; - let (r_field, r_domain, r_title) = extract_polar_channel(encoding, "radius")?; - let (theta_field, theta_domain, theta_title) = extract_polar_channel(encoding, "theta")?; + let (r_val, r_field, r_title, r_disc) = extract_polar_channel(encoding, "radius")?; + let (theta_val, theta_field, theta_title, theta_disc) = + extract_polar_channel(encoding, "theta")?; + let field_of = |channel: &str| { + encoding + .get(channel) + .and_then(|e| e.get("field")) + .and_then(|f| f.as_str()) + .map(|s| s.to_string()) + }; ( + r_val, r_field, - r_domain, r_title, + r_disc, + theta_val, theta_field, - theta_domain, theta_title, + theta_disc, + field_of("radius2"), + field_of("theta2"), + field_of("radiusOffset"), + field_of("thetaOffset"), ) }; - // Phase 2: Build calculate transforms for polar→cartesian conversion - // - // theta convention: 0 = 12 o'clock (top), increases clockwise - // cartesian: x = r * sin(θ), y = r * cos(θ) - let angle_span = end_radians - start_radians; - let (theta_min, theta_max) = theta_domain; - let (r_min, r_max) = r_domain; - let mut polar_transforms: Vec = Vec::new(); - // Normalize theta to [start_radians, end_radians] - if (theta_max - theta_min).abs() > f64::EPSILON { - polar_transforms.push(json!({ - "calculate": format!( - "{start} + (datum['{field}'] - {min}) / ({max} - {min}) * {span}", - start = start_radians, - field = theta_field, - min = theta_min, - max = theta_max, - span = angle_span, - ), - "as": "__polar_theta__" - })); - } else { - polar_transforms.push(json!({ - "calculate": format!("{}", start_radians), - "as": "__polar_theta__" - })); - } - - // Normalize radius to [inner, 1.0] - if (r_max - r_min).abs() > f64::EPSILON { - polar_transforms.push(json!({ - "calculate": format!( - "{inner} + (1 - {inner}) * (datum['{field}'] - {min}) / ({max} - {min})", - inner = inner, - field = r_field, - min = r_min, - max = r_max, - ), - "as": "__polar_r__" - })); - } else { - polar_transforms.push(json!({ - "calculate": format!("{}", (1.0 + inner) / 2.0), - "as": "__polar_r__" - })); - } - - // Convert to cartesian: x = r * sin(θ), y = r * cos(θ) - polar_transforms.push(json!({ - "calculate": "datum.__polar_r__ * sin(datum.__polar_theta__)", - "as": "__polar_x__" - })); + // Drop rows with null positions — Vega-Lite does this implicitly for + // scaled channels, but with scale:null we handle it ourselves. polar_transforms.push(json!({ - "calculate": "datum.__polar_r__ * cos(datum.__polar_theta__)", - "as": "__polar_y__" + "filter": format!( + "isValid(datum['{r_field}']) && isValid(datum['{theta_field}'])" + ) })); - // Phase 3: Mutate the layer — append transforms - if let Some(existing) = layer.get_mut("transform") { - if let Some(arr) = existing.as_array_mut() { - arr.extend(polar_transforms); + let theta_expr = panel.expr_normalize_theta(&theta_val); + polar_transforms.push(json!({"calculate": theta_expr, "as": "__polar_theta__"})); + + let r_expr = panel.expr_normalize_radius(&r_val); + polar_transforms.push(json!({"calculate": r_expr, "as": "__polar_r__"})); + + // Offsets: fold into the normalized r/theta before computing pixel x/y. + // If the offset has a scale domain, normalize it into the primary channel's + // space. If no domain, treat as raw pixel displacement after conversion. + let encoding_obj = layer.get("encoding").and_then(|e| e.as_object()); + let mut r_final = "datum.__polar_r__".to_string(); + let mut theta_final = "datum.__polar_theta__".to_string(); + let mut pixel_offsets: Vec<(String, bool)> = Vec::new(); // (field, is_radial) + + let offset_domain = |channel: &str| -> Option<(f64, f64)> { + let arr = encoding_obj? + .get(channel)? + .get("scale")? + .get("domain")? + .as_array()?; + Some((arr.first()?.as_f64()?, arr.get(1)?.as_f64()?)) + }; + + if let Some(ref f) = r_offset_field { + if let Some((off_min, off_max)) = offset_domain("radiusOffset") { + let r_scale = match panel.radial.domain { + Some((min, max)) => (panel.outer - panel.inner) / (max - min), + None => 0.0, + }; + let bw = if r_discrete { POLAR_BAND_FRACTION } else { 1.0 }; + r_final = format!( + "datum.__polar_r__ + {} * ((datum['{}'] - {}) / {} - 0.5)", + r_scale * bw, + f, + off_min, + off_max - off_min + ); + } else { + pixel_offsets.push((f.clone(), true)); + } + } + if let Some(ref f) = theta_offset_field { + if let Some((off_min, off_max)) = offset_domain("thetaOffset") { + let t_scale = match panel.angle.domain { + Some((min, max)) => (panel.end - panel.start) / (max - min), + None => 0.0, + }; + let bw = if theta_discrete { + POLAR_BAND_FRACTION + } else { + 1.0 + }; + if panel.is_radar() { + // In radar mode, interpolate linearly toward the adjacent + // spoke instead of displacing along a circular arc. + // The offset is normalised to [-0.5, 0.5] within the band. + let off_norm = format!( + "(datum['{}'] - {}) / {} - 0.5", + f, + off_min, + off_max - off_min + ); + polar_transforms + .push(json!({"calculate": off_norm, "as": "__polar_theta_off_t__"})); + // Target: the adjacent spoke (one full step away). + // Lerping between two spoke positions traces the straight + // polygon edge — both endpoints are vertices. + let step = t_scale; + let target_theta = format!( + "clamp(datum.__polar_theta__ + (datum.__polar_theta_off_t__ >= 0 ? {} : -{}), {}, {})", + step, step, panel.start, panel.end + ); + polar_transforms + .push(json!({"calculate": target_theta, "as": "__polar_theta_target__"})); + // At max offset (±0.5) we reach bw/2 of the way to the + // adjacent spoke — half because the spoke is a full step + // away but the band edge is only half a step. + let lerp = format!("abs(datum.__polar_theta_off_t__) * {}", bw); + polar_transforms.push(json!({"calculate": lerp, "as": "__polar_theta_lerp__"})); + } else { + theta_final = format!( + "datum.__polar_theta__ + {} * ((datum['{}'] - {}) / {} - 0.5)", + t_scale * bw, + f, + off_min, + off_max - off_min + ); + } + } else { + pixel_offsets.push((f.clone(), false)); + } + } + + let mut x_expr = panel.expr_x(&r_final, &theta_final); + let mut y_expr = panel.expr_y(&r_final, &theta_final); + + // In radar mode, lerp between the base spoke and the adjacent spoke + // so the offset follows the straight polygon edge. + if panel.is_radar() && theta_offset_field.is_some() { + let x_target = panel.expr_x(&r_final, "datum.__polar_theta_target__"); + let y_target = panel.expr_y(&r_final, "datum.__polar_theta_target__"); + x_expr = format!( + "(1 - datum.__polar_theta_lerp__) * ({x_expr}) + datum.__polar_theta_lerp__ * ({x_target})" + ); + y_expr = format!( + "(1 - datum.__polar_theta_lerp__) * ({y_expr}) + datum.__polar_theta_lerp__ * ({y_target})" + ); + } + + // Raw pixel offsets applied after polar→cartesian conversion. + // Tangential: along (cos θ, sin θ). Radial: along (sin θ, -cos θ). + for (f, is_radial) in &pixel_offsets { + if *is_radial { + x_expr = format!("({x_expr}) + datum['{f}'] * sin(datum.__polar_theta__)"); + y_expr = format!("({y_expr}) - datum['{f}'] * cos(datum.__polar_theta__)"); + } else { + x_expr = format!("({x_expr}) + datum['{f}'] * cos(datum.__polar_theta__)"); + y_expr = format!("({y_expr}) + datum['{f}'] * sin(datum.__polar_theta__)"); + } + } + + polar_transforms.push(json!({"calculate": x_expr, "as": "__polar_x__"})); + polar_transforms.push(json!({"calculate": y_expr, "as": "__polar_y__"})); + + // Secondary channels (radius2 → x2/y2, theta2 → x2/y2) share the + // primary channel's domain, so we reuse the same normalization parameters. + let has_r2 = r2_field.is_some(); + let has_theta2 = theta2_field.is_some(); + if has_r2 || has_theta2 { + let r2_expr = if let Some(ref f) = r2_field { + panel.expr_normalize_radius(&format!("datum['{}']", f)) + } else { + "datum.__polar_r__".to_string() + }; + let theta2_expr = if let Some(ref f) = theta2_field { + panel.expr_normalize_theta(&format!("datum['{}']", f)) + } else { + "datum.__polar_theta__".to_string() + }; + polar_transforms.push(json!({"calculate": r2_expr, "as": "__polar_r2__"})); + polar_transforms.push(json!({"calculate": theta2_expr, "as": "__polar_theta2__"})); + polar_transforms.push(json!({ + "calculate": panel.expr_x("datum.__polar_r2__", "datum.__polar_theta2__"), + "as": "__polar_x2__" + })); + polar_transforms.push(json!({ + "calculate": panel.expr_y("datum.__polar_r2__", "datum.__polar_theta2__"), + "as": "__polar_y2__" + })); + } + + // Phase 3: Mutate the layer — append transforms + if let Some(existing) = layer.get_mut("transform") { + if let Some(arr) = existing.as_array_mut() { + arr.extend(polar_transforms); } } else { layer["transform"] = json!(polar_transforms); @@ -300,18 +1309,21 @@ fn convert_polar_to_cartesian( encoding.remove("radius"); encoding.remove("theta"); + encoding.remove("radius2"); + encoding.remove("theta2"); + encoding.remove("radiusOffset"); + encoding.remove("thetaOffset"); - let padding = 1.08; let mut x_enc = json!({ "field": "__polar_x__", "type": "quantitative", - "scale": {"domain": [-padding, padding]}, + "scale": null, "axis": null }); let mut y_enc = json!({ "field": "__polar_y__", "type": "quantitative", - "scale": {"domain": [-padding, padding]}, + "scale": null, "axis": null }); @@ -325,15 +1337,24 @@ fn convert_polar_to_cartesian( encoding.insert("x".to_string(), x_enc); encoding.insert("y".to_string(), y_enc); + if has_r2 || has_theta2 { + encoding.insert("x2".to_string(), json!({"field": "__polar_x2__"})); + encoding.insert("y2".to_string(), json!({"field": "__polar_y2__"})); + } + Ok(()) } -/// Extract field name, scale domain, and title from a polar encoding channel. -/// Returns (field_name, (domain_min, domain_max), optional_title). +/// Extract field name, numeric value expression, scale domain, and title from +/// a polar encoding channel. +/// +/// Returns `(value_expr, field, optional_title, is_discrete)`. +/// For continuous scales `value_expr` is `datum['field']`. +/// For discrete scales it is `indexof([...], datum['field']) + 1`. fn extract_polar_channel( encoding: &serde_json::Map, channel: &str, -) -> Result<(String, (f64, f64), Option)> { +) -> Result<(String, String, Option, bool)> { let channel_enc = encoding.get(channel).ok_or_else(|| { GgsqlError::WriterError(format!( "Polar projection requires '{}' encoding channel", @@ -347,21 +1368,42 @@ fn extract_polar_channel( .ok_or_else(|| GgsqlError::WriterError(format!("'{}' encoding missing 'field'", channel)))? .to_string(); - // Extract domain from scale, with fallback to [0, 1] - let domain = channel_enc + let title = channel_enc.get("title").cloned(); + + let domain_arr = channel_enc .get("scale") .and_then(|s| s.get("domain")) - .and_then(|d| d.as_array()) - .and_then(|arr| { - let min = arr.first()?.as_f64()?; - let max = arr.get(1)?.as_f64()?; - Some((min, max)) - }) - .unwrap_or((0.0, 1.0)); + .and_then(|d| d.as_array()); - let title = channel_enc.get("title").cloned(); + // Try numeric domain first — continuous scale + if domain_arr + .and_then(|arr| Some((arr.first()?.as_f64()?, arr.get(1)?.as_f64()?))) + .is_some() + { + return Ok((format!("datum['{}']", field), field, title, false)); + } + + // Discrete domain: string array → indexof expression + if let Some(arr) = domain_arr { + let strings: Vec<&str> = arr.iter().filter_map(|v| v.as_str()).collect(); + if !strings.is_empty() { + let literal: String = strings + .iter() + .map(|s| format!("'{}'", super::escape_vega_string(s))) + .collect::>() + .join(","); + let arr_expr = format!("[{}]", literal); + let expr = format!( + "indexof({arr}, datum['{field}']) < 0 ? null : indexof({arr}, datum['{field}']) + 1", + arr = arr_expr, + field = field, + ); + return Ok((expr, field, title, true)); + } + } - Ok((field, domain, title)) + // Fallback + Ok((format!("datum['{}']", field), field, title, false)) } /// Convert a mark type to its polar equivalent @@ -407,14 +1449,10 @@ fn convert_mark_to_polar(mark: &Value, _spec: &Plot) -> Result { /// The encoding channels are already correctly named (theta/radius) by /// `map_aesthetic_name()` based on coord kind. This function only applies /// the optional start/end angle range from the PROJECT clause. -fn apply_polar_angle_range( - encoding: &mut Value, - start_radians: f64, - end_radians: f64, -) -> Result<()> { +fn apply_polar_angle_range(encoding: &mut Value, panel: &PolarContext) -> Result<()> { // Skip if default range (0 to 2π) - let is_default = start_radians.abs() <= f64::EPSILON - && (end_radians - 2.0 * std::f64::consts::PI).abs() <= f64::EPSILON; + let is_default = panel.start.abs() <= f64::EPSILON + && (panel.end - 2.0 * std::f64::consts::PI).abs() <= f64::EPSILON; if is_default { return Ok(()); } @@ -429,14 +1467,14 @@ fn apply_polar_angle_range( // Merge range into existing scale object (preserving domain from expansion) if let Some(scale_val) = theta_obj.get_mut("scale") { if let Some(scale_obj) = scale_val.as_object_mut() { - scale_obj.insert("range".to_string(), json!([start_radians, end_radians])); + scale_obj.insert("range".to_string(), json!([panel.start, panel.end])); } } else { // No existing scale, create new one with just range theta_obj.insert( "scale".to_string(), json!({ - "range": [start_radians, end_radians] + "range": [panel.start, panel.end] }), ); } @@ -451,22 +1489,15 @@ fn apply_polar_angle_range( /// Sets the radius scale range using Vega-Lite expressions for proportional sizing. /// The inner parameter (0.0 to 1.0) specifies the inner radius as a proportion /// of the outer radius, creating a donut hole. -fn apply_polar_radius_range(encoding: &mut Value, inner: f64, size: Option) -> Result<()> { +fn apply_polar_radius_range(encoding: &mut Value, panel: &PolarContext) -> Result<()> { let enc_obj = encoding .as_object_mut() .ok_or_else(|| GgsqlError::WriterError("Encoding is not an object".to_string()))?; - // Use expressions for proportional sizing - let (inner_expr, outer_expr) = match size { - Some(dim) => (format!("{}/2*{}", dim, inner), format!("{}/2", dim)), - _ => { - // min(width,height)/2 is the default max radius in Vega-Lite - ( - format!("min(width,height)/2*{}", inner), - "min(width,height)/2".to_string(), - ) - } - }; + let inner_s = format!("{}", panel.inner); + let outer_s = format!("{}", panel.outer); + let inner_expr = panel.expr_radius(&inner_s); + let outer_expr = panel.expr_radius(&outer_s); let range_value = json!([{"expr": inner_expr}, {"expr": outer_expr}]); @@ -502,6 +1533,13 @@ fn apply_polar_radius_range(encoding: &mut Value, inner: f64, size: Option) #[cfg(test)] mod tests { use super::*; + use crate::plot::{Facet, FacetLayout}; + + fn faceted() -> Facet { + Facet::new(FacetLayout::Wrap { + variables: vec!["g".to_string()], + }) + } #[test] fn test_polar_inner_radius_non_faceted() { @@ -514,15 +1552,22 @@ mod tests { } }); - apply_polar_radius_range(&mut encoding, 0.5, None).unwrap(); + let mut proj = Projection::polar(); + proj.properties + .insert("inner".to_string(), ParameterValue::Number(0.5)); + let panel = PolarContext::new(Some(&proj), None, &[]); + apply_polar_radius_range(&mut encoding, &panel).unwrap(); let range = encoding["radius"]["scale"]["range"].as_array().unwrap(); assert_eq!(range.len(), 2); assert_eq!( range[0]["expr"].as_str().unwrap(), - "min(width,height)/2*0.5" + "min(width, height) / 2 * (0.5)" + ); + assert_eq!( + range[1]["expr"].as_str().unwrap(), + "min(width, height) / 2 * (1)" ); - assert_eq!(range[1]["expr"].as_str().unwrap(), "min(width,height)/2"); } #[test] @@ -536,12 +1581,19 @@ mod tests { } }); - apply_polar_radius_range(&mut encoding, 0.5, Some(350.0)).unwrap(); + let mut proj = Projection::polar(); + proj.properties + .insert("inner".to_string(), ParameterValue::Number(0.5)); + proj.properties + .insert("size".to_string(), ParameterValue::Number(350.0)); + let f = faceted(); + let panel = PolarContext::new(Some(&proj), Some(&f), &[]); + apply_polar_radius_range(&mut encoding, &panel).unwrap(); let range = encoding["radius"]["scale"]["range"].as_array().unwrap(); assert_eq!(range.len(), 2); - assert_eq!(range[0]["expr"].as_str().unwrap(), "350/2*0.5"); - assert_eq!(range[1]["expr"].as_str().unwrap(), "350/2"); + assert_eq!(range[0]["expr"].as_str().unwrap(), "175 * (0.5)"); + assert_eq!(range[1]["expr"].as_str().unwrap(), "175 * (1)"); } #[test] @@ -555,12 +1607,1212 @@ mod tests { } }); - apply_polar_radius_range(&mut encoding, 0.0, Some(350.0)).unwrap(); + let mut proj = Projection::polar(); + proj.properties + .insert("size".to_string(), ParameterValue::Number(350.0)); + let f = faceted(); + let panel = PolarContext::new(Some(&proj), Some(&f), &[]); + apply_polar_radius_range(&mut encoding, &panel).unwrap(); // Range should be [0, 350/2] for full pie let range = encoding["radius"]["scale"]["range"].as_array().unwrap(); assert_eq!(range.len(), 2); - assert_eq!(range[0]["expr"].as_str().unwrap(), "350/2*0"); - assert_eq!(range[1]["expr"].as_str().unwrap(), "350/2"); + assert_eq!(range[0]["expr"].as_str().unwrap(), "175 * (0)"); + assert_eq!(range[1]["expr"].as_str().unwrap(), "175 * (1)"); + } + + #[test] + fn test_map_position_to_vegalite_cartesian() { + let renderer = CartesianProjection { is_faceted: false }; + assert_eq!( + map_position_to_vegalite("pos1", &renderer), + Some("x".to_string()) + ); + assert_eq!( + map_position_to_vegalite("pos2", &renderer), + Some("y".to_string()) + ); + assert_eq!( + map_position_to_vegalite("pos1end", &renderer), + Some("x2".to_string()) + ); + assert_eq!( + map_position_to_vegalite("pos2end", &renderer), + Some("y2".to_string()) + ); + assert_eq!(map_position_to_vegalite("color", &renderer), None); + assert_eq!(renderer.offset_channels(), ("xOffset", "yOffset")); + assert_eq!( + renderer.panel_size(), + Some((json!("container"), json!("container"))) + ); + } + + #[test] + fn test_map_position_to_vegalite_polar() { + let renderer = PolarProjection { + panel: PolarContext::new(None, None, &[]), + }; + assert_eq!( + map_position_to_vegalite("pos1", &renderer), + Some("radius".to_string()) + ); + assert_eq!( + map_position_to_vegalite("pos2", &renderer), + Some("theta".to_string()) + ); + assert_eq!( + map_position_to_vegalite("pos1end", &renderer), + Some("radius2".to_string()) + ); + assert_eq!( + map_position_to_vegalite("pos2end", &renderer), + Some("theta2".to_string()) + ); + assert_eq!(renderer.offset_channels(), ("radiusOffset", "thetaOffset")); + assert_eq!( + renderer.panel_size(), + Some((json!("container"), json!("container"))) + ); + } + + fn continuous_panel() -> PolarContext { + let mut panel = PolarContext::new(None, None, &[]); + panel.radial.domain = Some((0.0, 10.0)); + panel.angle.domain = Some((0.0, 100.0)); + panel + } + + fn polar_proj(scales: &[Scale]) -> PolarProjection { + PolarProjection { + panel: PolarContext::new(None, None, scales), + } + } + + fn polar_point_layer() -> Value { + json!({ + "mark": "point", + "encoding": { + "radius": { + "field": "r_col", + "type": "quantitative", + "scale": {"domain": [0.0, 10.0]} + }, + "theta": { + "field": "t_col", + "type": "quantitative", + "scale": {"domain": [0.0, 100.0]} + } + } + }) + } + + #[test] + fn test_polar_to_cartesian_pixel_coordinates() { + let mut layer = polar_point_layer(); + let panel = continuous_panel(); + + convert_polar_to_cartesian(&mut layer, &panel).unwrap(); + + let transforms = layer["transform"].as_array().unwrap(); + + // Should contain pixel-coordinate expressions using width/height signals + let x_calc = transforms + .iter() + .find(|t| t["as"] == "__polar_x__") + .unwrap(); + let x_expr = x_calc["calculate"].as_str().unwrap(); + assert!( + x_expr.contains("width / 2") && x_expr.contains("min(width, height) / 2"), + "x should use pixel coordinates, got: {x_expr}" + ); + + let y_calc = transforms + .iter() + .find(|t| t["as"] == "__polar_y__") + .unwrap(); + let y_expr = y_calc["calculate"].as_str().unwrap(); + assert!( + y_expr.contains("height / 2") && y_expr.contains("min(width, height) / 2"), + "y should use pixel coordinates, got: {y_expr}" + ); + + // Encoding should use scale:null (raw pixel positions) + assert_eq!(layer["encoding"]["x"]["scale"], json!(null)); + assert_eq!(layer["encoding"]["y"]["scale"], json!(null)); + + // Original polar channels should be removed + assert!(layer["encoding"].get("radius").is_none()); + assert!(layer["encoding"].get("theta").is_none()); + } + + #[test] + fn test_polar_to_cartesian_filters_nulls() { + let mut layer = polar_point_layer(); + let panel = continuous_panel(); + + convert_polar_to_cartesian(&mut layer, &panel).unwrap(); + + let transforms = layer["transform"].as_array().unwrap(); + let filter = transforms + .iter() + .find(|t| t.get("filter").is_some()) + .expect("should have a filter transform"); + + let expr = filter["filter"].as_str().unwrap(); + assert!( + expr.contains("isValid") && expr.contains("r_col") && expr.contains("t_col"), + "filter should check both position fields, got: {expr}" + ); + } + + #[test] + fn test_get_projection_renderer() { + let cartesian = get_projection_renderer(None, None, &[]); + assert_eq!(cartesian.position_channels(), ("x", "y")); + + let polar_proj = Projection::polar(); + let polar = get_projection_renderer(Some(&polar_proj), None, &[]); + assert_eq!(polar.position_channels(), ("radius", "theta")); + } + + #[test] + fn test_expr_normalize_radius() { + let mut p = PolarContext::new(None, None, &[]); + + // domain [0, 10], inner 0.2 + p.inner = 0.2; + p.radial.domain = Some((0.0, 10.0)); + // scale = (1.0 - 0.2) / (10 - 0) = 0.08 + let expr = p.expr_normalize_radius("datum.v"); + assert!( + expr.contains("0.08"), + "scale factor should be 0.08, got: {expr}" + ); + assert!( + expr.contains("datum.v"), + "should reference value, got: {expr}" + ); + + // domain [5, 15], inner 0 → scale = 1.0 / 10 = 0.1 + p.inner = 0.0; + p.radial.domain = Some((5.0, 15.0)); + let expr = p.expr_normalize_radius("datum.x"); + assert!( + expr.contains("0.1"), + "scale factor should be 0.1, got: {expr}" + ); + + // None domain → fallback to midpoint + p.radial.domain = None; + let expr = p.expr_normalize_radius("datum.v"); + assert!( + !expr.contains("datum.v"), + "should not reference value when domain is None, got: {expr}" + ); + } + + #[test] + fn test_expr_normalize_theta() { + use std::f64::consts::PI; + + // domain [0, 100], partial circle 90°–270° (π/2 to 3π/2) + let mut panel = PolarContext::new(None, None, &[]); + panel.start = PI / 2.0; + panel.end = 3.0 * PI / 2.0; + panel.angle.domain = Some((0.0, 100.0)); + let expr = panel.expr_normalize_theta("datum.v"); + // scale = (3π/2 - π/2) / (100 - 0) = π / 100 ≈ 0.031416 + let expected_scale = PI / 100.0; + assert!( + expr.contains(&format!("{expected_scale}")), + "scale factor should be π/100, got: {expr}" + ); + } + + fn scale_with_breaks(aesthetic: &str, domain: (f64, f64), breaks: Vec) -> Scale { + use crate::plot::types::ArrayElement; + let mut scale = Scale::new(aesthetic); + scale.input_range = Some(vec![ + ArrayElement::Number(domain.0), + ArrayElement::Number(domain.1), + ]); + scale.properties.insert( + "breaks".to_string(), + ParameterValue::Array(breaks.into_iter().map(ArrayElement::Number).collect()), + ); + scale + } + + #[test] + fn test_grid_rings() { + let scales = vec![scale_with_breaks( + "pos1", + (0.0, 100.0), + vec![25.0, 50.0, 75.0], + )]; + let proj = polar_proj(&scales); + let theme = json!({"axis": {"gridColor": "#FFF", "gridWidth": 2}}); + + let layers = proj.grid_rings(&theme); + assert_eq!(layers.len(), 1, "should produce one layer"); + + let layer = &layers[0]; + + // Data should contain the break values + let values = layer["data"]["values"].as_array().unwrap(); + assert_eq!(values.len(), 3); + assert_eq!(values[0]["v"], json!(25.0)); + assert_eq!(values[1]["v"], json!(50.0)); + assert_eq!(values[2]["v"], json!(75.0)); + + // Mark should be a stroke-only arc + assert_eq!(layer["mark"]["type"], "arc"); + assert_eq!(layer["mark"]["fill"], json!(null)); + assert_eq!(layer["mark"]["stroke"], "#FFF"); + assert_eq!(layer["mark"]["strokeWidth"], 2.0); + + // Radius encoding should use an expression + let radius_expr = layer["encoding"]["radius"]["value"]["expr"] + .as_str() + .unwrap(); + assert!( + radius_expr.contains("min(width, height) / 2"), + "radius should use expr_polar_radius, got: {radius_expr}" + ); + } + + #[test] + fn test_grid_spokes() { + let scales = vec![scale_with_breaks("pos2", (0.0, 60.0), vec![20.0, 40.0])]; + let proj = polar_proj(&scales); + let theme = json!({"axis": {"gridColor": "#CCC", "gridWidth": 1}}); + + let layers = proj.grid_spokes(&theme); + assert_eq!(layers.len(), 1, "should produce one layer"); + + let layer = &layers[0]; + + // Data should contain the break values + let values = layer["data"]["values"].as_array().unwrap(); + assert_eq!(values.len(), 2); + + // Mark should be a rule + assert_eq!(layer["mark"]["type"], "rule"); + assert_eq!(layer["mark"]["stroke"], "#CCC"); + + // Should have calculate transforms for x, y, x2, y2 + let transforms = layer["transform"].as_array().unwrap(); + assert_eq!(transforms.len(), 4); + let field_names: Vec<&str> = transforms.iter().filter_map(|t| t["as"].as_str()).collect(); + assert_eq!(field_names, vec!["x", "y", "x2", "y2"]); + + // Encoding should use scale:null for pixel positions + assert_eq!(layer["encoding"]["x"]["scale"], json!(null)); + assert_eq!(layer["encoding"]["y"]["scale"], json!(null)); + } + + #[test] + fn test_radial_axis() { + let scales = vec![scale_with_breaks( + "pos1", + (0.0, 100.0), + vec![25.0, 50.0, 75.0], + )]; + let proj = polar_proj(&scales); + let theme = json!({ + "axis": { + "tickColor": "#333", + "tickSize": 6, + "labelColor": "#4D4D4D", + "labelFontSize": 12, + } + }); + + let layers = proj.radial_axis(&theme); + assert_eq!( + layers.len(), + 3, + "should produce axis line, ticks, and labels" + ); + + // Layer 0: axis line (single rule from inner to outer) + let line = &layers[0]; + assert_eq!(line["mark"]["type"], "rule"); + assert_eq!(line["data"]["values"].as_array().unwrap().len(), 1); + let transforms = line["transform"].as_array().unwrap(); + let fields: Vec<&str> = transforms.iter().filter_map(|t| t["as"].as_str()).collect(); + assert_eq!(fields, vec!["x", "y", "x2", "y2"]); + + // Layer 1: ticks (one per break) + let ticks = &layers[1]; + assert_eq!(ticks["mark"]["type"], "rule"); + assert_eq!(ticks["data"]["values"].as_array().unwrap().len(), 3); + let tick_transforms = ticks["transform"].as_array().unwrap(); + let tick_fields: Vec<&str> = tick_transforms + .iter() + .filter_map(|t| t["as"].as_str()) + .collect(); + assert_eq!(tick_fields, vec!["cx", "cy", "x", "y", "x2", "y2"]); + + // Layer 2: labels (one per break) + let labels = &layers[2]; + assert_eq!(labels["mark"]["type"], "text"); + assert_eq!(labels["data"]["values"].as_array().unwrap().len(), 3); + assert_eq!(labels["encoding"]["text"]["field"], "label"); + assert_eq!(labels["encoding"]["x"]["scale"], json!(null)); + } + + #[test] + fn test_radial_axis_no_breaks() { + let scales = vec![scale_with_breaks("pos1", (0.0, 100.0), vec![])]; + let proj = polar_proj(&scales); + let theme = Value::Null; + + let layers = proj.radial_axis(&theme); + assert_eq!( + layers.len(), + 1, + "should produce only the axis line when no breaks" + ); + assert_eq!(layers[0]["mark"]["type"], "rule"); + } + + #[test] + fn test_angular_axis() { + let scales = vec![scale_with_breaks( + "pos2", + (0.0, 60.0), + vec![15.0, 30.0, 45.0], + )]; + let proj = polar_proj(&scales); + let theme = json!({ + "axis": { + "tickColor": "#333", + "tickSize": 6, + "labelColor": "#4D4D4D", + "labelFontSize": 12, + } + }); + + let layers = proj.angular_axis(&theme); + assert_eq!( + layers.len(), + 3, + "should produce axis arc, ticks, and labels" + ); + + // Layer 0: axis arc along outer edge + let arc = &layers[0]; + assert_eq!(arc["mark"]["type"], "arc"); + assert_eq!(arc["mark"]["fill"], json!(null)); + + // Layer 1: ticks (one per break) + let ticks = &layers[1]; + assert_eq!(ticks["mark"]["type"], "rule"); + assert_eq!(ticks["data"]["values"].as_array().unwrap().len(), 3); + let tick_transforms = ticks["transform"].as_array().unwrap(); + let tick_fields: Vec<&str> = tick_transforms + .iter() + .filter_map(|t| t["as"].as_str()) + .collect(); + assert_eq!(tick_fields, vec!["theta", "cx", "cy", "x", "y", "x2", "y2"]); + + // Layer 2: nested label layer with shared data/transforms/encoding + let labels = &layers[2]; + assert_eq!(labels["encoding"]["text"]["field"], "label"); + assert_eq!(labels["data"]["values"].as_array().unwrap().len(), 3); + let sub_layers = labels["layer"].as_array().unwrap(); + assert!( + !sub_layers.is_empty(), + "should have at least one label sub-layer" + ); + for sub in sub_layers { + assert_eq!(sub["mark"]["type"], "text"); + assert!(sub["mark"]["align"].is_string()); + assert!(sub["mark"]["baseline"].is_string()); + // Each sub-layer filters by alignment tag + assert!(sub["transform"] + .as_array() + .unwrap() + .iter() + .any(|t| t.get("filter").is_some())); + } + } + + #[test] + fn test_angular_axis_no_breaks() { + let scales = vec![scale_with_breaks("pos2", (0.0, 60.0), vec![])]; + let proj = polar_proj(&scales); + let theme = Value::Null; + + let layers = proj.angular_axis(&theme); + assert_eq!( + layers.len(), + 1, + "should produce only the axis arc when no breaks" + ); + assert_eq!(layers[0]["mark"]["type"], "arc"); + } + + // ========================================================================= + // Free scales suppress polar decorations + // + // Polar grid lines and axes are drawn as manual VL layers whose positions + // are computed from the global scale domain. With free scales each facet + // panel has its own domain, so the global positions would be wrong. + // Rather than rendering misleading decorations we suppress them entirely. + // Proper per-panel decorations would require computing per-group domains + // and threading them into the decoration data — a significant lift that + // is not yet implemented. + // ========================================================================= + + fn facet_with_free(free: Vec) -> Facet { + use crate::plot::ArrayElement; + let mut f = faceted(); + f.properties.insert( + "free".to_string(), + ParameterValue::Array(free.into_iter().map(ArrayElement::Boolean).collect()), + ); + f + } + + #[test] + fn free_pos1_suppresses_radial_axis_and_grid_rings() { + let scales = vec![scale_with_breaks( + "pos1", + (0.0, 100.0), + vec![25.0, 50.0, 75.0], + )]; + let f = facet_with_free(vec![true, false]); + let proj = PolarProjection { + panel: PolarContext::new(None, Some(&f), &scales), + }; + let theme = Value::Null; + + assert!(proj.grid_rings(&theme).is_empty()); + assert!(proj.radial_axis(&theme).is_empty()); + } + + #[test] + fn free_pos2_suppresses_angular_axis_and_grid_spokes() { + let scales = vec![scale_with_breaks( + "pos2", + (0.0, 360.0), + vec![90.0, 180.0, 270.0], + )]; + let f = facet_with_free(vec![false, true]); + let proj = PolarProjection { + panel: PolarContext::new(None, Some(&f), &scales), + }; + let theme = Value::Null; + + assert!(proj.grid_spokes(&theme).is_empty()); + assert!(proj.angular_axis(&theme).is_empty()); + } + + #[test] + fn fixed_scales_still_draw_decorations() { + let scales = vec![ + scale_with_breaks("pos1", (0.0, 100.0), vec![50.0]), + scale_with_breaks("pos2", (0.0, 360.0), vec![180.0]), + ]; + let f = facet_with_free(vec![false, false]); + let proj = PolarProjection { + panel: PolarContext::new(None, Some(&f), &scales), + }; + let theme = Value::Null; + + assert!(!proj.grid_rings(&theme).is_empty()); + assert!(!proj.grid_spokes(&theme).is_empty()); + assert!(!proj.radial_axis(&theme).is_empty()); + assert!(!proj.angular_axis(&theme).is_empty()); + } + + // ========================================================================= + // Discrete channel: indexof expression + // ========================================================================= + + fn discrete_theta_layer() -> Value { + json!({ + "mark": "point", + "encoding": { + "radius": { + "field": "r_col", + "type": "quantitative", + "scale": {"domain": [0.0, 10.0]} + }, + "theta": { + "field": "cat", + "type": "nominal", + "scale": {"domain": ["A", "B", "C"]} + } + } + }) + } + + #[test] + fn test_discrete_theta_uses_indexof() { + let mut layer = discrete_theta_layer(); + let mut panel = PolarContext::new(None, None, &[]); + panel.radial.domain = Some((0.0, 10.0)); + panel.angle.domain = Some((0.5, 3.5)); + + convert_polar_to_cartesian(&mut layer, &panel).unwrap(); + + let transforms = layer["transform"].as_array().unwrap(); + let theta_calc = transforms + .iter() + .find(|t| t["as"] == "__polar_theta__") + .unwrap(); + let expr = theta_calc["calculate"].as_str().unwrap(); + assert!( + expr.contains("indexof") && expr.contains("'A'") && expr.contains("datum['cat']"), + "theta should use indexof for discrete domain, got: {expr}" + ); + assert!( + expr.contains("null"), + "OOB values should map to null, got: {expr}" + ); + } + + #[test] + fn test_discrete_indexof_escapes_quotes() { + let mut layer = json!({ + "mark": "point", + "encoding": { + "radius": { + "field": "r_col", + "type": "quantitative", + "scale": {"domain": [0.0, 10.0]} + }, + "theta": { + "field": "cat", + "type": "nominal", + "scale": {"domain": ["it's", "fine"]} + } + } + }); + let mut panel = PolarContext::new(None, None, &[]); + panel.radial.domain = Some((0.0, 10.0)); + panel.angle.domain = Some((0.5, 2.5)); + + convert_polar_to_cartesian(&mut layer, &panel).unwrap(); + + let transforms = layer["transform"].as_array().unwrap(); + let theta_calc = transforms + .iter() + .find(|t| t["as"] == "__polar_theta__") + .unwrap(); + let expr = theta_calc["calculate"].as_str().unwrap(); + assert!( + expr.contains("it\\'s"), + "single quotes in category names should be escaped, got: {expr}" + ); + } + + #[test] + fn test_discrete_theta_synthesizes_domain() { + let mut layer = discrete_theta_layer(); + let mut panel = PolarContext::new(None, None, &[]); + panel.radial.domain = Some((0.0, 10.0)); + panel.angle.domain = Some((0.5, 3.5)); + + convert_polar_to_cartesian(&mut layer, &panel).unwrap(); + + // 3 categories → domain (0.5, 3.5), full circle → scale = 2π / 3.0 + let transforms = layer["transform"].as_array().unwrap(); + let theta_calc = transforms + .iter() + .find(|t| t["as"] == "__polar_theta__") + .unwrap(); + let expr = theta_calc["calculate"].as_str().unwrap(); + let expected_scale = 2.0 * std::f64::consts::PI / 3.0; + assert!( + expr.contains(&format!("{expected_scale}")), + "theta scale should be 2π/3 ≈ {expected_scale}, got: {expr}" + ); + } + + // ========================================================================= + // Secondary channels: radius2 / theta2 + // ========================================================================= + + #[test] + fn test_radius2_generates_x2_y2() { + let mut layer = json!({ + "mark": "rule", + "encoding": { + "radius": { + "field": "r_start", + "type": "quantitative", + "scale": {"domain": [0.0, 10.0]} + }, + "radius2": { + "field": "r_end" + }, + "theta": { + "field": "angle", + "type": "quantitative", + "scale": {"domain": [0.0, 100.0]} + } + } + }); + let panel = continuous_panel(); + + convert_polar_to_cartesian(&mut layer, &panel).unwrap(); + + let transforms = layer["transform"].as_array().unwrap(); + let has_r2 = transforms.iter().any(|t| t["as"] == "__polar_r2__"); + let has_x2 = transforms.iter().any(|t| t["as"] == "__polar_x2__"); + let has_y2 = transforms.iter().any(|t| t["as"] == "__polar_y2__"); + assert!(has_r2, "should compute __polar_r2__"); + assert!(has_x2, "should compute __polar_x2__"); + assert!(has_y2, "should compute __polar_y2__"); + + assert!(layer["encoding"].get("x2").is_some()); + assert!(layer["encoding"].get("y2").is_some()); + assert!(layer["encoding"].get("radius2").is_none()); + } + + #[test] + fn test_theta2_generates_x2_y2() { + let mut layer = json!({ + "mark": "rule", + "encoding": { + "radius": { + "field": "r_col", + "type": "quantitative", + "scale": {"domain": [0.0, 10.0]} + }, + "theta": { + "field": "t_start", + "type": "quantitative", + "scale": {"domain": [0.0, 100.0]} + }, + "theta2": { + "field": "t_end" + } + } + }); + let panel = continuous_panel(); + + convert_polar_to_cartesian(&mut layer, &panel).unwrap(); + + let transforms = layer["transform"].as_array().unwrap(); + let theta2_calc = transforms + .iter() + .find(|t| t["as"] == "__polar_theta2__") + .unwrap(); + let expr = theta2_calc["calculate"].as_str().unwrap(); + assert!( + expr.contains("datum['t_end']"), + "theta2 should use its own field, got: {expr}" + ); + + assert!(layer["encoding"].get("x2").is_some()); + assert!(layer["encoding"].get("theta2").is_none()); + } + + // ========================================================================= + // Offset channels: scaled domain + // ========================================================================= + + #[test] + fn test_theta_offset_with_domain() { + let mut layer = json!({ + "mark": "point", + "encoding": { + "radius": { + "field": "r_col", + "type": "quantitative", + "scale": {"domain": [0.0, 10.0]} + }, + "theta": { + "field": "t_col", + "type": "quantitative", + "scale": {"domain": [0.0, 100.0]} + }, + "thetaOffset": { + "field": "grp", + "scale": {"domain": [0.0, 4.0]} + } + } + }); + let panel = continuous_panel(); + + convert_polar_to_cartesian(&mut layer, &panel).unwrap(); + + let transforms = layer["transform"].as_array().unwrap(); + let x_calc = transforms + .iter() + .find(|t| t["as"] == "__polar_x__") + .unwrap(); + let expr = x_calc["calculate"].as_str().unwrap(); + assert!( + expr.contains("datum['grp']"), + "x should incorporate thetaOffset field, got: {expr}" + ); + + assert!(layer["encoding"].get("thetaOffset").is_none()); + } + + #[test] + fn test_radius_offset_without_domain_is_pixel() { + let mut layer = json!({ + "mark": "point", + "encoding": { + "radius": { + "field": "r_col", + "type": "quantitative", + "scale": {"domain": [0.0, 10.0]} + }, + "theta": { + "field": "t_col", + "type": "quantitative", + "scale": {"domain": [0.0, 100.0]} + }, + "radiusOffset": { + "field": "jitter" + } + } + }); + let panel = continuous_panel(); + + convert_polar_to_cartesian(&mut layer, &panel).unwrap(); + + let transforms = layer["transform"].as_array().unwrap(); + let x_calc = transforms + .iter() + .find(|t| t["as"] == "__polar_x__") + .unwrap(); + let expr = x_calc["calculate"].as_str().unwrap(); + assert!( + expr.contains("datum['jitter']") && expr.contains("sin"), + "pixel offset should apply along radial direction, got: {expr}" + ); + } + + // ========================================================================= + // Discrete offset band fraction + // ========================================================================= + + #[test] + fn test_discrete_theta_offset_applies_band_fraction() { + let mut layer = json!({ + "mark": "point", + "encoding": { + "radius": { + "field": "r_col", + "type": "quantitative", + "scale": {"domain": [0.0, 10.0]} + }, + "theta": { + "field": "cat", + "type": "nominal", + "scale": {"domain": ["A", "B", "C"]} + }, + "thetaOffset": { + "field": "grp", + "scale": {"domain": [0.0, 2.0]} + } + } + }); + let mut panel = PolarContext::new(None, None, &[]); + panel.radial.domain = Some((0.0, 10.0)); + panel.angle.domain = Some((0.5, 3.5)); + + convert_polar_to_cartesian(&mut layer, &panel).unwrap(); + + // 3 categories → domain (0.5, 3.5), scale = 2π/3 + // With band fraction 0.9: effective scale = 2π/3 * 0.9 + let expected = 2.0 * std::f64::consts::PI / 3.0 * POLAR_BAND_FRACTION; + let transforms = layer["transform"].as_array().unwrap(); + let x_calc = transforms + .iter() + .find(|t| t["as"] == "__polar_x__") + .unwrap(); + let expr = x_calc["calculate"].as_str().unwrap(); + assert!( + expr.contains(&format!("{expected}")), + "offset scale should include band fraction ({expected}), got: {expr}" + ); + } + + #[test] + fn test_continuous_theta_offset_no_band_fraction() { + let mut layer = json!({ + "mark": "point", + "encoding": { + "radius": { + "field": "r_col", + "type": "quantitative", + "scale": {"domain": [0.0, 10.0]} + }, + "theta": { + "field": "t_col", + "type": "quantitative", + "scale": {"domain": [0.0, 100.0]} + }, + "thetaOffset": { + "field": "grp", + "scale": {"domain": [0.0, 2.0]} + } + } + }); + let panel = continuous_panel(); + + convert_polar_to_cartesian(&mut layer, &panel).unwrap(); + + // Continuous → full scale = 2π/100, no band fraction + let full_scale = 2.0 * std::f64::consts::PI / 100.0; + let with_band = full_scale * POLAR_BAND_FRACTION; + let transforms = layer["transform"].as_array().unwrap(); + let x_calc = transforms + .iter() + .find(|t| t["as"] == "__polar_x__") + .unwrap(); + let expr = x_calc["calculate"].as_str().unwrap(); + assert!( + expr.contains(&format!("{full_scale}")) + && !expr.contains(&format!("{with_band}")), + "continuous offset should use full scale ({full_scale}), not banded ({with_band}), got: {expr}" + ); + } + + // ========================================================================= + // Discrete scale helpers for axis/grid tests + // ========================================================================= + + fn discrete_scale_for_axis(aesthetic: &str, values: &[&str]) -> Scale { + use crate::plot::scale::ScaleType; + use crate::plot::types::ArrayElement; + let mut scale = Scale::new(aesthetic); + scale.scale_type = Some(ScaleType::discrete()); + scale.input_range = Some( + values + .iter() + .map(|v| ArrayElement::String(v.to_string())) + .collect(), + ); + scale + } + + // ========================================================================= + // Discrete radial axis labels + // ========================================================================= + + #[test] + fn test_radial_axis_discrete_labels() { + let scales = vec![discrete_scale_for_axis("pos1", &["low", "mid", "high"])]; + let proj = polar_proj(&scales); + let theme = Value::Null; + + let layers = proj.radial_axis(&theme); + assert_eq!( + layers.len(), + 3, + "should produce axis line, ticks, and labels" + ); + + // Label data should carry category names, not numeric positions + let labels = &layers[2]; + let values = labels["data"]["values"].as_array().unwrap(); + assert_eq!(values.len(), 3); + assert_eq!(values[0]["label"], "low"); + assert_eq!(values[1]["label"], "mid"); + assert_eq!(values[2]["label"], "high"); + + // Numeric positions should be 1, 2, 3 + assert_eq!(values[0]["v"], 1.0); + assert_eq!(values[1]["v"], 2.0); + assert_eq!(values[2]["v"], 3.0); + } + + // ========================================================================= + // Discrete angular axis labels + // ========================================================================= + + #[test] + fn test_angular_axis_discrete_labels() { + let scales = vec![discrete_scale_for_axis("pos2", &["Mon", "Tue", "Wed"])]; + let proj = polar_proj(&scales); + let theme = Value::Null; + + let layers = proj.angular_axis(&theme); + assert_eq!( + layers.len(), + 3, + "should produce axis arc, ticks, and labels" + ); + + // Label data should carry category names + let labels = &layers[2]; + let values = labels["data"]["values"].as_array().unwrap(); + assert_eq!(values.len(), 3); + assert_eq!(values[0]["label"], "Mon"); + assert_eq!(values[1]["label"], "Tue"); + assert_eq!(values[2]["label"], "Wed"); + } + + // ========================================================================= + // Single-category discrete scale in polar + // ========================================================================= + + #[test] + fn test_single_category_discrete_grid_spokes() { + let scales = vec![discrete_scale_for_axis("pos2", &["only"])]; + let proj = polar_proj(&scales); + let theme = Value::Null; + + let layers = proj.grid_spokes(&theme); + assert_eq!(layers.len(), 1, "should produce one spoke"); + + let values = layers[0]["data"]["values"].as_array().unwrap(); + assert_eq!(values.len(), 1); + assert_eq!(values[0]["v"], 1.0); + } + + #[test] + fn test_single_category_discrete_angular_axis() { + let scales = vec![discrete_scale_for_axis("pos2", &["only"])]; + let proj = polar_proj(&scales); + let theme = Value::Null; + + let layers = proj.angular_axis(&theme); + assert_eq!(layers.len(), 3, "should produce arc, tick, and label"); + + let labels = &layers[2]; + let values = labels["data"]["values"].as_array().unwrap(); + assert_eq!(values.len(), 1); + assert_eq!(values[0]["label"], "only"); + } + + // ========================================================================= + // Faceted polar with discrete scales + // ========================================================================= + + #[test] + fn test_faceted_polar_discrete_uses_pixel_size() { + let mut proj = Projection::polar(); + proj.properties + .insert("size".to_string(), ParameterValue::Number(300.0)); + let f = faceted(); + let panel = PolarContext::new(Some(&proj), Some(&f), &[]); + + // Faceted panel should use literal pixel values, not width/height signals + assert_eq!(panel.cx, "150"); + assert_eq!(panel.cy, "150"); + assert_eq!(panel.radius, "150"); + } + + #[test] + fn test_faceted_polar_discrete_grid_rings() { + let scales = vec![discrete_scale_for_axis("pos1", &["A", "B", "C"])]; + let mut proj_spec = Projection::polar(); + proj_spec + .properties + .insert("size".to_string(), ParameterValue::Number(300.0)); + let f = faceted(); + let proj = PolarProjection { + panel: PolarContext::new(Some(&proj_spec), Some(&f), &scales), + }; + let theme = Value::Null; + + let layers = proj.grid_rings(&theme); + assert_eq!(layers.len(), 1); + + // Radius expression should use literal pixels (150), not signals + let radius_expr = layers[0]["encoding"]["radius"]["value"]["expr"] + .as_str() + .unwrap(); + assert!( + radius_expr.contains("150") && !radius_expr.contains("width"), + "faceted grid rings should use pixel values, got: {radius_expr}" + ); + } + + #[test] + fn test_faceted_polar_panel_size() { + let proj = Projection::polar(); + let f = faceted(); + let renderer = PolarProjection { + panel: PolarContext::new(Some(&proj), Some(&f), &[]), + }; + assert_eq!( + renderer.panel_size(), + Some((json!(DEFAULT_POLAR_SIZE), json!(DEFAULT_POLAR_SIZE))) + ); + } + + // ========================================================================= + // Radar decoration helpers + // ========================================================================= + + #[test] + fn test_angle_breaks_radians_from_discrete_scale() { + use std::f64::consts::PI; + let scales = vec![discrete_scale_for_axis("pos2", &["A", "B", "C"])]; + let panel = PolarContext::new(None, None, &scales); + let thetas = &panel.angle_breaks_radians; + assert_eq!(thetas.len(), 3); + // 3 categories → domain (0.5, 3.5), breaks at 1, 2, 3 + // theta = 0 + 2π/3 * (break - 0.5) + let scale = 2.0 * PI / 3.0; + assert!((thetas[0] - scale * 0.5).abs() < 1e-10); + assert!((thetas[1] - scale * 1.5).abs() < 1e-10); + assert!((thetas[2] - scale * 2.5).abs() < 1e-10); + } + + #[test] + fn test_angle_breaks_radians_empty_without_pos2() { + let scales = vec![scale_with_breaks("pos1", (0.0, 10.0), vec![5.0])]; + let panel = PolarContext::new(None, None, &scales); + assert!(panel.angle_breaks_radians.is_empty()); + } + + #[test] + fn test_polygon_ring_closes_for_full_circle() { + let mut panel = PolarContext::new(None, None, &[]); + panel.angle_breaks_radians = vec![1.0, 2.0, 3.0]; + let layer = polygon_ring(&panel, POLAR_OUTER, None, Value::Null, json!("red")); + let values = layer["data"]["values"].as_array().unwrap(); + // 3 thetas + 1 closing vertex = 4 + assert_eq!(values.len(), 4); + assert_eq!(values[0]["theta"], values[3]["theta"]); + } + + #[test] + fn test_polygon_ring_closed_for_partial_arc() { + let mut proj = Projection::polar(); + proj.properties + .insert("start".to_string(), ParameterValue::Number(0.0)); + proj.properties + .insert("end".to_string(), ParameterValue::Number(180.0)); + let mut panel = PolarContext::new(Some(&proj), None, &[]); + panel.angle_breaks_radians = vec![0.5, 1.0, 1.5]; + let layer = polygon_ring(&panel, POLAR_OUTER, None, Value::Null, json!("red")); + let values = layer["data"]["values"].as_array().unwrap(); + // start + 3 breaks + end + centre(end) + centre(start) + close = 8 + assert_eq!(values.len(), 8); + // First and last vertex should be at the same position (closed path) + assert_eq!(values[0]["theta"], values[7]["theta"]); + assert_eq!(values[0]["r"], values[7]["r"]); + } + + #[test] + fn test_polygon_ring_partial_arc_corrects_boundary_radii() { + use std::f64::consts::PI; + let mut proj = Projection::polar(); + proj.properties + .insert("start".to_string(), ParameterValue::Number(0.0)); + proj.properties + .insert("end".to_string(), ParameterValue::Number(180.0)); + let mut panel = PolarContext::new(Some(&proj), None, &[]); + panel.angle_breaks_radians = vec![PI / 2.0]; + let layer = polygon_ring(&panel, POLAR_OUTER, None, Value::Null, json!("red")); + let values = layer["data"]["values"].as_array().unwrap(); + let r_start = values[0]["r"].as_f64().unwrap(); + let r_break = values[1]["r"].as_f64().unwrap(); + let r_end = values[2]["r"].as_f64().unwrap(); + // Break vertex at full radius + assert!((r_break - POLAR_OUTER).abs() < 1e-10); + // Start/end vertices corrected inward by cos(π/2) + let expected = POLAR_OUTER * (PI / 2.0).cos(); + assert!((r_start - expected).abs() < 1e-10); + assert!((r_end - expected).abs() < 1e-10); + } + + #[test] + fn test_polygon_ring_donut_has_both_rings() { + let mut panel = PolarContext::new(None, None, &[]); + panel.angle_breaks_radians = vec![1.0, 2.0, 3.0]; + let layer = polygon_ring(&panel, POLAR_OUTER, Some(0.3), json!("white"), Value::Null); + let values = layer["data"]["values"].as_array().unwrap(); + // Outer: 3 + 1 closing, Inner: 3 + 1 closing = 8 + assert_eq!(values.len(), 8); + assert_eq!(layer["mark"]["type"], "line"); + assert_eq!(layer["mark"]["fill"], "white"); + } + + #[test] + fn test_arc_ring_basic() { + let panel = PolarContext::new(None, None, &[]); + let layer = arc_ring(&panel, "1", None, Value::Null, json!("red")); + assert_eq!(layer["mark"]["type"], "arc"); + assert_eq!(layer["mark"]["stroke"], "red"); + assert!(layer["mark"].get("innerRadius").is_none()); + } + + #[test] + fn test_arc_ring_with_inner_radius() { + let panel = PolarContext::new(None, None, &[]); + let layer = arc_ring(&panel, "1", Some("0.5"), json!("white"), json!("gray")); + assert_eq!(layer["mark"]["type"], "arc"); + assert!(layer["mark"]["innerRadius"].is_object()); + assert!(layer["mark"]["outerRadius"].is_object()); + } + + #[test] + fn test_radar_grid_rings_produce_line_marks() { + let scales = vec![ + scale_with_breaks("pos1", (0.0, 100.0), vec![50.0]), + discrete_scale_for_axis("pos2", &["A", "B", "C"]), + ]; + let mut proj_spec = Projection::polar(); + proj_spec + .properties + .insert("radar".to_string(), ParameterValue::Boolean(true)); + let proj = PolarProjection { + panel: PolarContext::new(Some(&proj_spec), None, &scales), + }; + let theme = Value::Null; + let layers = proj.grid_rings(&theme); + assert_eq!(layers.len(), 1); + assert_eq!(layers[0]["mark"]["type"], "line"); + } + + #[test] + fn test_radar_panel_arc_produces_line_mark() { + let scales = vec![discrete_scale_for_axis("pos2", &["A", "B", "C"])]; + let mut proj_spec = Projection::polar(); + proj_spec + .properties + .insert("radar".to_string(), ParameterValue::Boolean(true)); + let proj = PolarProjection { + panel: PolarContext::new(Some(&proj_spec), None, &scales), + }; + let mut theme = json!({"view": {"fill": "#EEE", "stroke": null}}); + let layers = proj.panel_arc(&mut theme); + assert_eq!(layers.len(), 1); + assert_eq!(layers[0]["mark"]["type"], "line"); + assert_eq!(layers[0]["mark"]["fill"], "#EEE"); + } + + #[test] + fn test_radar_angular_axis_produces_polygon_outline() { + let scales = vec![discrete_scale_for_axis("pos2", &["A", "B", "C"])]; + let mut proj_spec = Projection::polar(); + proj_spec + .properties + .insert("radar".to_string(), ParameterValue::Boolean(true)); + let proj = PolarProjection { + panel: PolarContext::new(Some(&proj_spec), None, &scales), + }; + let theme = json!({"axis": {"domainColor": "#333"}}); + let layers = proj.angular_axis(&theme); + assert!(!layers.is_empty()); + // First layer should be the polygon outline, not an arc + assert_eq!(layers[0]["mark"]["type"], "line"); + } + + #[test] + fn test_non_radar_grid_rings_still_use_arc() { + let scales = vec![scale_with_breaks("pos1", (0.0, 100.0), vec![50.0])]; + let proj = polar_proj(&scales); + let theme = Value::Null; + let layers = proj.grid_rings(&theme); + assert_eq!(layers.len(), 1); + assert_eq!(layers[0]["mark"]["type"], "arc"); } }