From ea1df2c020388136f644149992e8c09bbb4f52a9 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Mon, 27 Apr 2026 11:59:08 +0200 Subject: [PATCH 01/35] introduce ProjectionRenderer trait for modular projection handling Replace CoordKind match arms throughout the Vega-Lite writer with a ProjectionRenderer trait. Each projection type (cartesian, polar) now owns its channel mapping and spec transformation logic, making it straightforward to add map projections in the future. Co-Authored-By: Claude Opus 4.6 --- src/writer/vegalite/encoding.rs | 67 ++++---- src/writer/vegalite/layer.rs | 13 +- src/writer/vegalite/mod.rs | 178 +++++++------------- src/writer/vegalite/projection.rs | 266 +++++++++++++++++++++++++++--- 4 files changed, 339 insertions(+), 185 deletions(-) diff --git a/src/writer/vegalite/encoding.rs b/src/writer/vegalite/encoding.rs index 4475896f..4fed722b 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; @@ -997,11 +997,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 +1020,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 // ============================================================================= @@ -1064,27 +1041,39 @@ pub struct RenderContext<'a> { } impl<'a> RenderContext<'a> { - /// Create a new render context - pub fn new(scales: &'a [crate::Scale], coord_kind: CoordKind) -> 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()), - }; + /// Create a new render context from a projection renderer + pub fn new( + scales: &'a [crate::Scale], + renderer: &dyn super::projection::ProjectionRenderer, + ) -> Self { + 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(), + ), } } #[cfg(test)] pub fn default_for_test() -> Self { - Self::new(&[], CoordKind::Cartesian) + let renderer = super::projection::get_projection_renderer(None); + Self::new(&[], renderer.as_ref()) } /// Find a scale by aesthetic name diff --git a/src/writer/vegalite/layer.rs b/src/writer/vegalite/layer.rs index 23fdf741..2f8a2377 100644 --- a/src/writer/vegalite/layer.rs +++ b/src/writer/vegalite/layer.rs @@ -2128,7 +2128,7 @@ pub fn get_renderer(geom: &Geom) -> Box { #[cfg(test)] mod tests { use super::*; - use crate::plot::projection::CoordKind; + #[test] fn test_violin_detail_encoding() { @@ -3608,6 +3608,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); // Test success case: continuous scale with numeric range let scales = vec![Scale { @@ -3623,13 +3626,13 @@ mod tests { label_mapping: None, label_template: "{}".to_string(), }]; - let context = RenderContext::new(&scales, CoordKind::Cartesian); + let context = RenderContext::new(&scales, cartesian.as_ref()); let result = context.get_extent("x"); assert!(result.is_ok()); assert_eq!(result.unwrap(), (0.0, 10.0)); // Test error case: scale not found - let context = RenderContext::new(&scales, CoordKind::Cartesian); + let context = RenderContext::new(&scales, cartesian.as_ref()); let result = context.get_extent("y"); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("no scale found")); @@ -3648,7 +3651,7 @@ mod tests { label_mapping: None, label_template: "{}".to_string(), }]; - let context = RenderContext::new(&scales, CoordKind::Cartesian); + let context = RenderContext::new(&scales, cartesian.as_ref()); let result = context.get_extent("x"); assert!(result.is_err()); assert!(result @@ -3673,7 +3676,7 @@ mod tests { label_mapping: None, label_template: "{}".to_string(), }]; - let context = RenderContext::new(&scales, CoordKind::Cartesian); + let context = RenderContext::new(&scales, cartesian.as_ref()); let result = context.get_extent("x"); assert!(result.is_err()); assert!(result diff --git a/src/writer/vegalite/mod.rs b/src/writer/vegalite/mod.rs index 69cf307d..ba5fca99 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::{apply_project_transforms, get_projection_renderer, ProjectionRenderer}; /// Conversion factor from points to pixels (CSS standard: 96 DPI, 72 points/inch) /// 1 point = 96/72 pixels = 1.333 @@ -162,7 +162,7 @@ fn prepare_layer_data( /// 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, @@ -171,12 +171,12 @@ fn build_layers( 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); + let context = encoding::RenderContext::new(&spec.scales, projection); for (layer_idx, layer) in spec.layers.iter().enumerate() { let data_key = &layer_data_keys[layer_idx]; @@ -207,8 +207,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 (pass free scales and projection) + let mut encoding = build_layer_encoding(layer, df, spec, free_scales, 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 @@ -249,14 +249,14 @@ fn build_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 (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(); @@ -304,7 +304,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(); @@ -331,7 +331,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})); } } @@ -343,7 +343,7 @@ fn build_layer_encoding( } // Build context for renderer (also provides resolved position channel names) - let context = encoding::RenderContext::new(&spec.scales, coord_kind); + let context = encoding::RenderContext::new(&spec.scales, projection); let (_, _, pos1_offset, pos2, _, pos2_offset) = &context.channels; // Add pos1 offset encoding for dodged positions (pos1offset column) @@ -413,7 +413,7 @@ fn apply_faceting( facet: &crate::plot::Facet, facet_df: &DataFrame, scales: &[Scale], - coord_kind: CoordKind, + projection: &dyn ProjectionRenderer, ) { use crate::plot::FacetLayout; @@ -458,7 +458,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: _ } => { @@ -509,7 +509,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); } } @@ -622,24 +622,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 @@ -1092,13 +1089,10 @@ impl Writer for VegaLiteWriter { // 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); + let proj = get_projection_renderer(spec.project.as_ref()); + if let Some((w, h)) = proj.panel_size() { + vl_spec["width"] = json!(w); + vl_spec["height"] = json!(h); } } @@ -1137,14 +1131,10 @@ 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); + // 9. Get projection renderer (default to Cartesian if no project) + let projection = get_projection_renderer(spec.project.as_ref()); - // 10. Build layers (pass free scales and coord kind for domain handling) + // 10. Build layers (pass free scales and projection for domain handling) let layers = build_layers( spec, data, @@ -1152,7 +1142,7 @@ impl Writer for VegaLiteWriter { &prep.renderers, &prep.prepared, free_scales, - coord_kind, + projection.as_ref(), )?; vl_spec["layer"] = json!(layers); @@ -1163,7 +1153,7 @@ impl Writer for VegaLiteWriter { // 11. 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) @@ -1396,96 +1386,54 @@ mod tests { fn test_aesthetic_name_mapping() { use crate::plot::AestheticContext; - // Test with cartesian coord kind + use crate::plot::projection::{Coord, Projection}; + + // Test with cartesian projection (None = default cartesian) let ctx = AestheticContext::from_static(&["x", "y"], &[]); + let cartesian = get_projection_renderer(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 { + coord: Coord::polar(), + aesthetics: vec!["radius".into(), "angle".into()], + properties: std::collections::HashMap::new(), + }; + let polar = get_projection_renderer(Some(&polar_proj)); + 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..5a44d898 100644 --- a/src/writer/vegalite/projection.rs +++ b/src/writer/vegalite/projection.rs @@ -1,7 +1,9 @@ -//! 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}; @@ -9,34 +11,187 @@ use serde_json::{json, Value}; use super::DEFAULT_POLAR_SIZE; -/// Apply projection transformations to the spec and data +// ============================================================================= +// 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: Send + Sync { + /// 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); + + /// Explicit (width, height) panel dimensions for faceted specs, if needed. + /// + /// Polar projections need this because arc mark radius ranges depend on + /// known dimensions; cartesian uses `"container"` sizing and returns `None`. + fn panel_size(&self) -> Option<(f64, f64)> { + None + } + + /// Apply projection-specific transformations to the VL spec. + /// + /// Called after layers are built but before faceting. May return a + /// transformed DataFrame (e.g., polar currently clones it unchanged). + fn apply( + &self, + project: &Projection, + spec: &Plot, + data: &DataFrame, + vl_spec: &mut Value, + ) -> Result>; +} + +// ============================================================================= +// 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>, +) -> Box { + match project.map(|p| p.coord.coord_kind()) { + Some(CoordKind::Polar) => Box::new(PolarProjection), + Some(CoordKind::Cartesian) | None => Box::new(CartesianProjection), + } +} + +// ============================================================================= +// Public entry point +// ============================================================================= + +/// Apply projection transformations to the spec and data. +/// +/// Delegates to the appropriate `ProjectionRenderer` based on coord kind, +/// then applies cross-cutting concerns (clip) that are shared by all projections. /// 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)?), - }; + let Some(ref project) = spec.project else { + return Ok(None); + }; - // 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); - } + let renderer = get_projection_renderer(Some(project)); + let result = renderer.apply(project, spec, data, vl_spec)?; - Ok(result) - } else { + // 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); + } + + Ok(result) +} + +// ============================================================================= +// 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; + +impl ProjectionRenderer for CartesianProjection { + fn position_channels(&self) -> (&'static str, &'static str) { + ("x", "y") + } + + fn offset_channels(&self) -> (&'static str, &'static str) { + ("xOffset", "yOffset") + } + + /// Apply Cartesian projection properties + fn apply( + &self, + _project: &Projection, + _spec: &Plot, + _data: &DataFrame, + _vl_spec: &mut Value, + ) -> Result> { + // ratio - not yet implemented Ok(None) } } +// ============================================================================= +// PolarProjection +// ============================================================================= + +/// Polar projection — radius/theta coordinates for pie charts, rose plots, etc. +struct PolarProjection; + +impl ProjectionRenderer for PolarProjection { + 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<(f64, f64)> { + Some((DEFAULT_POLAR_SIZE, DEFAULT_POLAR_SIZE)) + } + + fn apply( + &self, + project: &Projection, + spec: &Plot, + data: &DataFrame, + vl_spec: &mut Value, + ) -> Result> { + apply_polar_project(project, spec, data, vl_spec) + } +} + +// ============================================================================= +// Shared helpers +// ============================================================================= + /// Get mutable reference to the layers array, handling both flat and faceted specs. /// /// In a flat spec: `vl_spec["layer"]` @@ -70,11 +225,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 implementation +// ============================================================================= /// Apply Polar projection transformation (bar->arc, point->arc with radius) /// @@ -88,7 +241,7 @@ fn apply_polar_project( spec: &Plot, data: &DataFrame, vl_spec: &mut Value, -) -> Result { +) -> Result> { // Get start angle in degrees (defaults to 0 = 12 o'clock) let start_degrees = project .properties @@ -127,7 +280,7 @@ fn apply_polar_project( convert_geoms_to_polar(spec, vl_spec, start_radians, end_radians, inner)?; // No DataFrame transformation needed - Vega-Lite handles polar math - Ok(data.clone()) + Ok(Some(data.clone())) } /// Convert geoms to polar equivalents (bar->arc) and apply angle range + inner radius @@ -563,4 +716,65 @@ mod tests { assert_eq!(range[0]["expr"].as_str().unwrap(), "350/2*0"); assert_eq!(range[1]["expr"].as_str().unwrap(), "350/2"); } + + #[test] + fn test_map_position_to_vegalite_cartesian() { + let renderer = CartesianProjection; + 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(), None); + } + + #[test] + fn test_map_position_to_vegalite_polar() { + let renderer = PolarProjection; + 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((DEFAULT_POLAR_SIZE, DEFAULT_POLAR_SIZE))); + } + + #[test] + fn test_get_projection_renderer() { + let cartesian = get_projection_renderer(None); + assert_eq!(cartesian.position_channels(), ("x", "y")); + + let polar_proj = Projection { + coord: crate::plot::projection::Coord::polar(), + aesthetics: vec!["radius".into(), "angle".into()], + properties: std::collections::HashMap::new(), + }; + let polar = get_projection_renderer(Some(&polar_proj)); + assert_eq!(polar.position_channels(), ("radius", "theta")); + } } From 6483456a7882ca1f94658ca6fd70834e484226ef Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Mon, 27 Apr 2026 12:11:24 +0200 Subject: [PATCH 02/35] add Projection::cartesian() and Projection::polar() constructors Reduces boilerplate at call sites that use default aesthetics and empty properties. Co-Authored-By: Claude Opus 4.6 --- src/plot/main.rs | 16 ++++------------ src/plot/projection/resolve.rs | 6 +----- src/plot/projection/types.rs | 23 +++++++++++++++++++++++ src/writer/vegalite/mod.rs | 6 +----- src/writer/vegalite/projection.rs | 6 +----- 5 files changed, 30 insertions(+), 27 deletions(-) diff --git a/src/plot/main.rs b/src/plot/main.rs index c99a3b26..6973cfc0 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/resolve.rs b/src/plot/projection/resolve.rs index 603ef99a..cb77c1d5 100644 --- a/src/plot/projection/resolve.rs +++ b/src/plot/projection/resolve.rs @@ -148,11 +148,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![]; 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/writer/vegalite/mod.rs b/src/writer/vegalite/mod.rs index ba5fca99..0baa7c29 100644 --- a/src/writer/vegalite/mod.rs +++ b/src/writer/vegalite/mod.rs @@ -1415,11 +1415,7 @@ mod tests { // Test with polar projection - internal position maps to radius/theta // regardless of the context's user-facing names - let polar_proj = Projection { - coord: Coord::polar(), - aesthetics: vec!["radius".into(), "angle".into()], - properties: std::collections::HashMap::new(), - }; + let polar_proj = Projection::polar(); let polar = get_projection_renderer(Some(&polar_proj)); let pol = polar.as_ref(); diff --git a/src/writer/vegalite/projection.rs b/src/writer/vegalite/projection.rs index 5a44d898..8270259e 100644 --- a/src/writer/vegalite/projection.rs +++ b/src/writer/vegalite/projection.rs @@ -769,11 +769,7 @@ mod tests { let cartesian = get_projection_renderer(None); assert_eq!(cartesian.position_channels(), ("x", "y")); - let polar_proj = Projection { - coord: crate::plot::projection::Coord::polar(), - aesthetics: vec!["radius".into(), "angle".into()], - properties: std::collections::HashMap::new(), - }; + let polar_proj = Projection::polar(); let polar = get_projection_renderer(Some(&polar_proj)); assert_eq!(polar.position_channels(), ("radius", "theta")); } From c66828ba47eb0ae360cd36c1007a56cec77d171f Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Mon, 27 Apr 2026 14:40:26 +0200 Subject: [PATCH 03/35] remove spurious Send + Sync from traits --- src/writer/vegalite/layer.rs | 2 +- src/writer/vegalite/projection.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/writer/vegalite/layer.rs b/src/writer/vegalite/layer.rs index 2f8a2377..76902a74 100644 --- a/src/writer/vegalite/layer.rs +++ b/src/writer/vegalite/layer.rs @@ -134,7 +134,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. diff --git a/src/writer/vegalite/projection.rs b/src/writer/vegalite/projection.rs index 8270259e..9b4f1184 100644 --- a/src/writer/vegalite/projection.rs +++ b/src/writer/vegalite/projection.rs @@ -22,7 +22,7 @@ use super::DEFAULT_POLAR_SIZE; /// 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: Send + Sync { +pub(super) trait ProjectionRenderer { /// Primary and secondary VL channel names for this projection. /// /// Returns `(pos1_channel, pos2_channel)`, e.g. `("x", "y")` for cartesian, From 84055d873d6b069b4d7c6c3221a33d83e06d9d86 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Mon, 27 Apr 2026 16:13:35 +0200 Subject: [PATCH 04/35] Add panel decoration plumbing to ProjectionRenderer background_layers() and foreground_layers() let projections prepend/append VL layers around the data layers (e.g. grid lines, axis ticks). Both receive resolved scales and the theme config so implementations can derive decoration from break positions and style tokens. Also moves apply_project_transforms and apply_panel_decor from free functions into default methods on the trait (apply_transforms, apply_panel_decor), removing the redundant renderer construction. Co-Authored-By: Claude Opus 4.6 --- src/writer/vegalite/mod.rs | 12 +++-- src/writer/vegalite/projection.rs | 86 ++++++++++++++++++++----------- 2 files changed, 63 insertions(+), 35 deletions(-) diff --git a/src/writer/vegalite/mod.rs b/src/writer/vegalite/mod.rs index 0baa7c29..c428b697 100644 --- a/src/writer/vegalite/mod.rs +++ b/src/writer/vegalite/mod.rs @@ -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, get_projection_renderer, ProjectionRenderer}; +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 @@ -1148,7 +1148,7 @@ impl Writer for VegaLiteWriter { // 10. Apply projection transforms let first_df = data.get(&layer_data_keys[0]).unwrap(); - apply_project_transforms(spec, first_df, &mut vl_spec)?; + projection.apply_transforms(spec, first_df, &mut vl_spec)?; // 11. Apply faceting if let Some(facet) = &spec.facet { @@ -1156,10 +1156,12 @@ impl Writer for VegaLiteWriter { 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(); + // 12. Build theme config and apply panel decoration + let mut theme = self.default_theme_config(); + projection.apply_panel_decor(spec, &mut theme, &mut vl_spec); + vl_spec["config"] = theme; - // 13. Serialize + // 14. Serialize serde_json::to_string_pretty(&vl_spec).map_err(|e| { GgsqlError::WriterError(format!("Failed to serialize Vega-Lite JSON: {}", e)) }) diff --git a/src/writer/vegalite/projection.rs b/src/writer/vegalite/projection.rs index 9b4f1184..21454183 100644 --- a/src/writer/vegalite/projection.rs +++ b/src/writer/vegalite/projection.rs @@ -5,7 +5,7 @@ //! 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::plot::{CoordKind, ParameterValue, Projection, Scale}; use crate::{DataFrame, GgsqlError, Plot, Result}; use serde_json::{json, Value}; @@ -53,6 +53,61 @@ pub(super) trait ProjectionRenderer { data: &DataFrame, vl_spec: &mut Value, ) -> Result>; + + /// Vega-Lite layers to prepend before the data layers. + /// + /// Called after faceting, before the theme config is applied. Receives + /// the resolved scales so implementations can derive grid lines, axis + /// ticks, or other decorations from scale breaks and domains. + fn background_layers(&self, _scales: &[Scale], _theme: &mut Value) -> Vec { + Vec::new() + } + + /// Vega-Lite layers to append after the data layers. + /// + /// Same timing and access as [`background_layers`]. + fn foreground_layers(&self, _scales: &[Scale], _theme: &mut Value) -> Vec { + Vec::new() + } + + /// Apply projection-specific transformations and cross-cutting concerns (clip). + fn apply_transforms( + &self, + spec: &Plot, + data: &DataFrame, + vl_spec: &mut Value, + ) -> Result> { + let Some(ref project) = spec.project else { + return Ok(None); + }; + + let result = self.apply(project, spec, data, vl_spec)?; + + if let Some(ParameterValue::Boolean(clip)) = project.properties.get("clip") { + apply_clip_to_layers(vl_spec, *clip); + } + + Ok(result) + } + + /// Prepend background and append foreground decoration layers. + /// + /// Called after faceting so that decoration layers appear in both faceted + /// and non-faceted specs. + fn apply_panel_decor(&self, spec: &Plot, theme: &mut Value, vl_spec: &mut Value) { + let bg = self.background_layers(&spec.scales, theme); + let fg = self.foreground_layers(&spec.scales, theme); + if bg.is_empty() && fg.is_empty() { + return; + } + 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); + } + } } // ============================================================================= @@ -72,35 +127,6 @@ pub(super) fn get_projection_renderer( } } -// ============================================================================= -// Public entry point -// ============================================================================= - -/// Apply projection transformations to the spec and data. -/// -/// Delegates to the appropriate `ProjectionRenderer` based on coord kind, -/// then applies cross-cutting concerns (clip) that are shared by all projections. -/// Returns (possibly transformed DataFrame, possibly modified spec) -pub(super) fn apply_project_transforms( - spec: &Plot, - data: &DataFrame, - vl_spec: &mut Value, -) -> Result> { - let Some(ref project) = spec.project else { - return Ok(None); - }; - - let renderer = get_projection_renderer(Some(project)); - let result = renderer.apply(project, spec, data, vl_spec)?; - - // 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); - } - - Ok(result) -} - // ============================================================================= // Channel mapping helpers (used by encoding.rs via the trait) // ============================================================================= From 191fff96a1cd8f8ef4abf01ee8fdea44a9c13d3d Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Mon, 27 Apr 2026 16:57:32 +0200 Subject: [PATCH 05/35] Tag decoration layers with description and filter them in tests Decoration layers inserted by apply_panel_decor() now get "description": "background" or "foreground" automatically. Tests use a new data_layer() helper that filters these out by index, so they remain stable regardless of whether a projection adds decoration. Co-Authored-By: Claude Opus 4.6 --- src/reader/mod.rs | 43 +++++++++++++++++++++---------- src/writer/vegalite/projection.rs | 6 +++++ 2 files changed, 35 insertions(+), 14 deletions(-) diff --git a/src/reader/mod.rs b/src/reader/mod.rs index 6646ada1..d337fe6c 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"]; @@ -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/projection.rs b/src/writer/vegalite/projection.rs index 21454183..0debd121 100644 --- a/src/writer/vegalite/projection.rs +++ b/src/writer/vegalite/projection.rs @@ -100,6 +100,12 @@ pub(super) trait ProjectionRenderer { if bg.is_empty() && fg.is_empty() { return; } + 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()); From fd14b4d6f6c3f4cb8a0cabfa92a96dca42bb3bb7 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Tue, 28 Apr 2026 10:18:09 +0200 Subject: [PATCH 06/35] Compute polar point marks in pixel space to align with arc marks Non-arc marks in polar projection (point, line) now compute x/y in the same pixel coordinate space arc marks use: center at (width/2, height/2) with outerRadius = min(width,height)/2. Encodings use scale:null so Vega-Lite treats values as raw positions. Also filters null position values via isValid(), since scale:null bypasses VL's implicit null handling. Co-Authored-By: Claude Opus 4.6 --- src/writer/vegalite/projection.rs | 111 ++++++++++++++++++++++++++---- 1 file changed, 99 insertions(+), 12 deletions(-) diff --git a/src/writer/vegalite/projection.rs b/src/writer/vegalite/projection.rs index 0debd121..fa1f1ef7 100644 --- a/src/writer/vegalite/projection.rs +++ b/src/writer/vegalite/projection.rs @@ -95,8 +95,8 @@ pub(super) trait ProjectionRenderer { /// Called after faceting so that decoration layers appear in both faceted /// and non-faceted specs. fn apply_panel_decor(&self, spec: &Plot, theme: &mut Value, vl_spec: &mut Value) { - let bg = self.background_layers(&spec.scales, theme); - let fg = self.foreground_layers(&spec.scales, theme); + 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() { return; } @@ -124,9 +124,7 @@ pub(super) trait ProjectionRenderer { /// /// 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>, -) -> Box { +pub(super) fn get_projection_renderer(project: Option<&Projection>) -> Box { match project.map(|p| p.coord.coord_kind()) { Some(CoordKind::Polar) => Box::new(PolarProjection), Some(CoordKind::Cartesian) | None => Box::new(CartesianProjection), @@ -419,6 +417,14 @@ fn convert_polar_to_cartesian( let mut polar_transforms: Vec = Vec::new(); + // 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!({ + "filter": format!( + "isValid(datum['{r_field}']) && isValid(datum['{theta_field}'])" + ) + })); + // Normalize theta to [start_radians, end_radians] if (theta_max - theta_min).abs() > f64::EPSILON { polar_transforms.push(json!({ @@ -458,13 +464,14 @@ fn convert_polar_to_cartesian( })); } - // Convert to cartesian: x = r * sin(θ), y = r * cos(θ) + // Convert to pixel coordinates, matching the arc mark's layout: + // center = (width/2, height/2), outerRadius = min(width,height)/2 polar_transforms.push(json!({ - "calculate": "datum.__polar_r__ * sin(datum.__polar_theta__)", + "calculate": "width/2 + min(width,height)/2 * datum.__polar_r__ * sin(datum.__polar_theta__)", "as": "__polar_x__" })); polar_transforms.push(json!({ - "calculate": "datum.__polar_r__ * cos(datum.__polar_theta__)", + "calculate": "height/2 - min(width,height)/2 * datum.__polar_r__ * cos(datum.__polar_theta__)", "as": "__polar_y__" })); @@ -486,17 +493,16 @@ fn convert_polar_to_cartesian( encoding.remove("radius"); encoding.remove("theta"); - 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 }); @@ -793,7 +799,88 @@ mod tests { Some("theta2".to_string()) ); assert_eq!(renderer.offset_channels(), ("radiusOffset", "thetaOffset")); - assert_eq!(renderer.panel_size(), Some((DEFAULT_POLAR_SIZE, DEFAULT_POLAR_SIZE))); + assert_eq!( + renderer.panel_size(), + Some((DEFAULT_POLAR_SIZE, DEFAULT_POLAR_SIZE)) + ); + } + + 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 start = 0.0; + let end = 2.0 * std::f64::consts::PI; + + convert_polar_to_cartesian(&mut layer, start, end, 0.0).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 full_circle = 2.0 * std::f64::consts::PI; + + convert_polar_to_cartesian(&mut layer, 0.0, full_circle, 0.0).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] From eb584d9d20c08fc28bfcc0203e4d68406a80c8d0 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Tue, 28 Apr 2026 12:25:34 +0200 Subject: [PATCH 07/35] Centralise polar coordinate math into shared expression helpers Extracts five VL expression builders (expr_normalize_radius, expr_normalize_theta, expr_polar_x, expr_polar_y, expr_polar_radius) that are now used by data-layer transforms, arc mark radius ranges, and decoration layers. Introduces POLAR_OUTER const for the normalised outer radius. Also extracts polar_properties() from the inline parsing that was duplicated in apply_polar_project. Co-Authored-By: Claude Opus 4.6 --- src/reader/mod.rs | 4 +- src/writer/vegalite/projection.rs | 217 +++++++++++++++++------------- 2 files changed, 129 insertions(+), 92 deletions(-) diff --git a/src/reader/mod.rs b/src/reader/mod.rs index d337fe6c..1547b93d 100644 --- a/src/reader/mod.rs +++ b/src/reader/mod.rs @@ -1142,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] ); } diff --git a/src/writer/vegalite/projection.rs b/src/writer/vegalite/projection.rs index fa1f1ef7..bad682db 100644 --- a/src/writer/vegalite/projection.rs +++ b/src/writer/vegalite/projection.rs @@ -191,6 +191,9 @@ impl ProjectionRenderer for CartesianProjection { // PolarProjection // ============================================================================= +/// Normalized outer radius (proportion of `min(width, height) / 2`). +const POLAR_OUTER: f64 = 1.0; + /// Polar projection — radius/theta coordinates for pie charts, rose plots, etc. struct PolarProjection; @@ -259,6 +262,71 @@ fn apply_clip_to_layers(vl_spec: &mut Value, clip: bool) { // Polar projection implementation // ============================================================================= +/// Extract (start_radians, end_radians, inner) from a Projection. +/// +/// Defaults: start=0°, end=start+360°, inner=0. +fn polar_properties(project: Option<&Projection>) -> (f64, f64, f64) { + 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 inner = prop("inner").unwrap_or(0.0); + ( + start_degrees * std::f64::consts::PI / 180.0, + end_degrees * std::f64::consts::PI / 180.0, + inner, + ) +} + +// ============================================================================= +// Polar expression helpers +// ============================================================================= +// Vega-Lite expression strings for polar ↔ pixel coordinate math. +// Used by both data-layer transforms and decoration layers. + +/// Normalize a value from `[domain_min, domain_max]` to `[inner, POLAR_OUTER]`. +fn expr_normalize_radius(value: &str, domain_min: f64, domain_max: f64, inner: f64) -> String { + let scale = (POLAR_OUTER - inner) / (domain_max - domain_min); + format!("{inner} + {scale} * ({value} - {domain_min})") +} + +/// Normalize a value from `[domain_min, domain_max]` to `[start, end]` radians. +fn expr_normalize_theta( + value: &str, + domain_min: f64, + domain_max: f64, + start: f64, + end: f64, +) -> String { + let scale = (end - start) / (domain_max - domain_min); + format!("{start} + {scale} * ({value} - {domain_min})") +} + +/// Pixel x-coordinate from a normalized radius expression and theta expression. +fn expr_polar_x(r: &str, theta: &str) -> String { + format!("width / 2 + min(width, height) / 2 * {r} * sin({theta})") +} + +/// Pixel y-coordinate from a normalized radius expression and theta expression. +fn expr_polar_y(r: &str, theta: &str) -> String { + format!("height / 2 - min(width, height) / 2 * {r} * cos({theta})") +} + +/// Pixel radius from a normalized radius expression. +fn expr_polar_radius(r: &str) -> String { + format!("min(width, height) / 2 * ({r})") +} + +// ============================================================================= +// Polar projection transformation +// ============================================================================= + /// Apply Polar projection transformation (bar->arc, point->arc with radius) /// /// Encoding channel names (theta/radius) are already set correctly by `map_aesthetic_name()` @@ -272,39 +340,7 @@ fn apply_polar_project( 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; + let (start_radians, end_radians, inner) = polar_properties(Some(project)); // Convert geoms to polar equivalents and apply angle range + inner radius convert_geoms_to_polar(spec, vl_spec, start_radians, end_radians, inner)?; @@ -407,11 +443,6 @@ fn convert_polar_to_cartesian( ) }; - // 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; @@ -425,53 +456,32 @@ fn convert_polar_to_cartesian( ) })); - // 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__" - })); + let theta_expr = if (theta_max - theta_min).abs() > f64::EPSILON { + expr_normalize_theta( + &format!("datum['{theta_field}']"), + theta_min, + theta_max, + start_radians, + end_radians, + ) } 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__" - })); + format!("{start_radians}") + }; + polar_transforms.push(json!({"calculate": theta_expr, "as": "__polar_theta__"})); + + let r_expr = if (r_max - r_min).abs() > f64::EPSILON { + expr_normalize_radius(&format!("datum['{r_field}']"), r_min, r_max, inner) } else { - polar_transforms.push(json!({ - "calculate": format!("{}", (1.0 + inner) / 2.0), - "as": "__polar_r__" - })); - } + format!("{}", (POLAR_OUTER + inner) / 2.0) + }; + polar_transforms.push(json!({"calculate": r_expr, "as": "__polar_r__"})); - // Convert to pixel coordinates, matching the arc mark's layout: - // center = (width/2, height/2), outerRadius = min(width,height)/2 polar_transforms.push(json!({ - "calculate": "width/2 + min(width,height)/2 * datum.__polar_r__ * sin(datum.__polar_theta__)", + "calculate": expr_polar_x("datum.__polar_r__", "datum.__polar_theta__"), "as": "__polar_x__" })); polar_transforms.push(json!({ - "calculate": "height/2 - min(width,height)/2 * datum.__polar_r__ * cos(datum.__polar_theta__)", + "calculate": expr_polar_y("datum.__polar_r__", "datum.__polar_theta__"), "as": "__polar_y__" })); @@ -647,16 +657,12 @@ fn apply_polar_radius_range(encoding: &mut Value, inner: f64, size: Option) .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(), - ) - } + None => ( + expr_polar_radius(&format!("{inner}")), + expr_polar_radius(&format!("{POLAR_OUTER}")), + ), }; let range_value = json!([{"expr": inner_expr}, {"expr": outer_expr}]); @@ -711,9 +717,12 @@ mod tests { 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] @@ -840,7 +849,7 @@ mod tests { .unwrap(); let x_expr = x_calc["calculate"].as_str().unwrap(); assert!( - x_expr.contains("width/2") && x_expr.contains("min(width,height)/2"), + x_expr.contains("width / 2") && x_expr.contains("min(width, height) / 2"), "x should use pixel coordinates, got: {x_expr}" ); @@ -850,7 +859,7 @@ mod tests { .unwrap(); let y_expr = y_calc["calculate"].as_str().unwrap(); assert!( - y_expr.contains("height/2") && y_expr.contains("min(width,height)/2"), + y_expr.contains("height / 2") && y_expr.contains("min(width, height) / 2"), "y should use pixel coordinates, got: {y_expr}" ); @@ -892,4 +901,32 @@ mod tests { let polar = get_projection_renderer(Some(&polar_proj)); assert_eq!(polar.position_channels(), ("radius", "theta")); } + + #[test] + fn test_expr_normalize_radius() { + // domain [0, 10], inner 0.2 → scale = (1.0 - 0.2) / (10 - 0) = 0.08 + let expr = expr_normalize_radius("datum.v", 0.0, 10.0, 0.2); + 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 + let expr = expr_normalize_radius("datum.x", 5.0, 15.0, 0.0); + assert!(expr.contains("0.1"), "scale factor should be 0.1, 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 start = PI / 2.0; + let end = 3.0 * PI / 2.0; + let expr = expr_normalize_theta("datum.v", 0.0, 100.0, start, end); + // 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}" + ); + } } From 7bda1a9afe73d332cacaf1ae9941d3bd9192fc26 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Tue, 28 Apr 2026 13:00:42 +0200 Subject: [PATCH 08/35] Add polar panel background, grid rings, and grid spokes Implements background decoration layers for polar projections: a filled panel arc, concentric grid rings at radius breaks, and radial grid spokes at theta breaks. Moves numeric break/domain extraction to Scale methods for reuse across the codebase. Co-Authored-By: Claude Opus 4.6 --- src/plot/scale/types.rs | 22 +++ src/writer/vegalite/encoding.rs | 12 +- src/writer/vegalite/layer.rs | 1 - src/writer/vegalite/mod.rs | 8 +- src/writer/vegalite/projection.rs | 246 +++++++++++++++++++++++++++++- 5 files changed, 275 insertions(+), 14 deletions(-) diff --git a/src/plot/scale/types.rs b/src/plot/scale/types.rs index df9ca6af..ff36882d 100644 --- a/src/plot/scale/types.rs +++ b/src/plot/scale/types.rs @@ -89,6 +89,28 @@ impl Scale { label_template: "{}".to_string(), } } + + // TODO: generalise for discrete/binned scales (see memory: project_discrete_polar_grid) + + /// Numeric break positions (after resolution). Currently only meaningful + /// for continuous scales. + pub fn numeric_breaks(&self) -> Vec { + match self.properties.get("breaks") { + Some(ParameterValue::Array(breaks)) => { + breaks.iter().filter_map(|b| b.to_f64()).collect() + } + _ => Vec::new(), + } + } + + /// Numeric domain as `(min, max)` from the resolved input range. Currently + /// only meaningful for continuous scales. + pub fn numeric_domain(&self) -> Option<(f64, f64)> { + 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) diff --git a/src/writer/vegalite/encoding.rs b/src/writer/vegalite/encoding.rs index 4fed722b..04183a2c 100644 --- a/src/writer/vegalite/encoding.rs +++ b/src/writer/vegalite/encoding.rs @@ -1046,14 +1046,10 @@ impl<'a> RenderContext<'a> { scales: &'a [crate::Scale], renderer: &dyn super::projection::ProjectionRenderer, ) -> Self { - 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 = 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(); diff --git a/src/writer/vegalite/layer.rs b/src/writer/vegalite/layer.rs index 76902a74..762f135d 100644 --- a/src/writer/vegalite/layer.rs +++ b/src/writer/vegalite/layer.rs @@ -2129,7 +2129,6 @@ pub fn get_renderer(geom: &Geom) -> Box { mod tests { use super::*; - #[test] fn test_violin_detail_encoding() { let renderer = ViolinRenderer; diff --git a/src/writer/vegalite/mod.rs b/src/writer/vegalite/mod.rs index c428b697..0dfc1e67 100644 --- a/src/writer/vegalite/mod.rs +++ b/src/writer/vegalite/mod.rs @@ -1153,7 +1153,13 @@ impl Writer for VegaLiteWriter { // 11. 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, projection.as_ref()); + apply_faceting( + &mut vl_spec, + facet, + facet_df, + &spec.scales, + projection.as_ref(), + ); } // 12. Build theme config and apply panel decoration diff --git a/src/writer/vegalite/projection.rs b/src/writer/vegalite/projection.rs index bad682db..19a333d3 100644 --- a/src/writer/vegalite/projection.rs +++ b/src/writer/vegalite/projection.rs @@ -59,14 +59,24 @@ pub(super) trait ProjectionRenderer { /// Called after faceting, before the theme config is applied. Receives /// the resolved scales so implementations can derive grid lines, axis /// ticks, or other decorations from scale breaks and domains. - fn background_layers(&self, _scales: &[Scale], _theme: &mut Value) -> Vec { + fn background_layers( + &self, + _scales: &[Scale], + _project: Option<&Projection>, + _theme: &mut Value, + ) -> Vec { Vec::new() } /// Vega-Lite layers to append after the data layers. /// /// Same timing and access as [`background_layers`]. - fn foreground_layers(&self, _scales: &[Scale], _theme: &mut Value) -> Vec { + fn foreground_layers( + &self, + _scales: &[Scale], + _project: Option<&Projection>, + _theme: &mut Value, + ) -> Vec { Vec::new() } @@ -95,8 +105,8 @@ pub(super) trait ProjectionRenderer { /// Called after faceting so that decoration layers appear in both faceted /// and non-faceted specs. fn apply_panel_decor(&self, spec: &Plot, theme: &mut Value, vl_spec: &mut Value) { - let mut bg = self.background_layers(&spec.scales, theme); - let mut fg = self.foreground_layers(&spec.scales, theme); + let mut bg = self.background_layers(&spec.scales, spec.project.as_ref(), theme); + let mut fg = self.foreground_layers(&spec.scales, spec.project.as_ref(), theme); if bg.is_empty() && fg.is_empty() { return; } @@ -219,6 +229,153 @@ impl ProjectionRenderer for PolarProjection { ) -> Result> { apply_polar_project(project, spec, data, vl_spec) } + + fn background_layers( + &self, + scales: &[Scale], + project: Option<&Projection>, + theme: &mut Value, + ) -> Vec { + let mut layers = Vec::new(); + layers.extend(self.panel_arc(project, theme)); + layers.extend(self.grid_rings(scales, project, theme)); + layers.extend(self.grid_spokes(scales, project, theme)); + layers + } +} + +impl PolarProjection { + fn grid_rings( + &self, + scales: &[Scale], + project: Option<&Projection>, + theme: &Value, + ) -> Vec { + let Some(scale) = scales.iter().find(|s| s.aesthetic == "pos1") else { + return Vec::new(); + }; + let breaks = scale.numeric_breaks(); + let Some((domain_min, domain_max)) = scale.numeric_domain() else { + return Vec::new(); + }; + if breaks.is_empty() || (domain_max - domain_min).abs() < f64::EPSILON { + 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 (start, end, inner) = polar_properties(project); + + let values: Vec = breaks.iter().map(|&b| json!({"v": b})).collect(); + let r_norm = expr_normalize_radius("datum.v", domain_min, domain_max, inner); + let radius_expr = expr_polar_radius(&r_norm); + + vec![json!({ + "data": {"values": values}, + "mark": { + "type": "arc", + "fill": null, + "stroke": color, + "strokeWidth": width, + "theta": start, + "theta2": end, + }, + "encoding": { + "radius": { + "value": {"expr": radius_expr} + } + } + })] + } + + fn grid_spokes( + &self, + scales: &[Scale], + project: Option<&Projection>, + theme: &Value, + ) -> Vec { + let Some(scale) = scales.iter().find(|s| s.aesthetic == "pos2") else { + return Vec::new(); + }; + let breaks = scale.numeric_breaks(); + let Some((domain_min, domain_max)) = scale.numeric_domain() else { + return Vec::new(); + }; + if breaks.is_empty() || (domain_max - domain_min).abs() < f64::EPSILON { + 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 (start, end, inner) = polar_properties(project); + + let values: Vec = breaks.iter().map(|&b| json!({"v": b})).collect(); + let theta = expr_normalize_theta("datum.v", domain_min, domain_max, start, end); + let inner_s = format!("{inner}"); + + vec![json!({ + "data": {"values": values}, + "mark": { + "type": "rule", + "stroke": color, + "strokeWidth": width, + }, + "transform": [ + {"calculate": expr_polar_x(&inner_s, &theta), "as": "x"}, + {"calculate": expr_polar_y(&inner_s, &theta), "as": "y"}, + {"calculate": expr_polar_x(&format!("{POLAR_OUTER}"), &theta), "as": "x2"}, + {"calculate": expr_polar_y(&format!("{POLAR_OUTER}"), &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 panel_arc(&self, project: Option<&Projection>, 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); + + // We need a null-stroke otherwise it'll show up as a gray line + view.insert("stroke".to_string(), Value::Null); + + let (start, end, inner) = polar_properties(project); + + let mut mark = json!({ + "type": "arc", + "fill": fill, + "stroke": stroke, + "theta": start, + "theta2": end, + }); + if inner > 0.0 { + mark["innerRadius"] = json!({"expr": expr_polar_radius(&format!("{inner}"))}); + } + mark["outerRadius"] = json!({"expr": expr_polar_radius(&format!("{POLAR_OUTER}"))}); + + vec![json!({ + "data": {"values": [{}]}, + "mark": mark + })] + } } // ============================================================================= @@ -929,4 +1086,85 @@ mod tests { "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 = PolarProjection; + let mut theme = json!({"axis": {"gridColor": "#FFF", "gridWidth": 2}}); + + let layers = proj.grid_rings(&scales, None, &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 = PolarProjection; + let mut theme = json!({"axis": {"gridColor": "#CCC", "gridWidth": 1}}); + + let layers = proj.grid_spokes(&scales, None, &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)); + } } From cddcf15b16b8901c256cf59f90ba9fe377c4dbd5 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Tue, 28 Apr 2026 14:11:52 +0200 Subject: [PATCH 09/35] Add radial axis to polar foreground decoration Draws axis line, tick marks, and labels along the start angle for the radius (pos1) scale. Ticks are centered on full circles and extend outward on partial arcs. Fixes operator precedence in expr_polar_x/y by parenthesising the radius expression. Co-Authored-By: Claude Opus 4.6 --- src/writer/vegalite/projection.rs | 245 ++++++++++++++++++++++++++++-- 1 file changed, 235 insertions(+), 10 deletions(-) diff --git a/src/writer/vegalite/projection.rs b/src/writer/vegalite/projection.rs index 19a333d3..668f906a 100644 --- a/src/writer/vegalite/projection.rs +++ b/src/writer/vegalite/projection.rs @@ -242,6 +242,17 @@ impl ProjectionRenderer for PolarProjection { layers.extend(self.grid_spokes(scales, project, theme)); layers } + + fn foreground_layers( + &self, + scales: &[Scale], + project: Option<&Projection>, + theme: &mut Value, + ) -> Vec { + let mut layers = Vec::new(); + layers.extend(self.radial_axis(scales, project, theme)); + layers + } } impl PolarProjection { @@ -347,6 +358,144 @@ impl PolarProjection { })] } + fn radial_axis( + &self, + scales: &[Scale], + project: Option<&Projection>, + theme: &Value, + ) -> Vec { + let Some(scale) = scales.iter().find(|s| s.aesthetic == "pos1") else { + return Vec::new(); + }; + let breaks = scale.numeric_breaks(); + let Some((domain_min, domain_max)) = scale.numeric_domain() else { + return Vec::new(); + }; + if (domain_max - domain_min).abs() < f64::EPSILON { + 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 (start, end, inner) = polar_properties(project); + let mut layers = Vec::new(); + + // Axis line: rule from inner to outer at start angle + let inner_s = format!("{inner}"); + let start_s = format!("{start}"); + layers.push(json!({ + "data": {"values": [{}]}, + "mark": { + "type": "rule", + "stroke": line_color, + }, + "transform": [ + {"calculate": expr_polar_x(&inner_s, &start_s), "as": "x"}, + {"calculate": expr_polar_y(&inner_s, &start_s), "as": "y"}, + {"calculate": expr_polar_x(&format!("{POLAR_OUTER}"), &start_s), "as": "x2"}, + {"calculate": expr_polar_y(&format!("{POLAR_OUTER}"), &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 breaks.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 = breaks.iter().map(|&b| json!({"v": b})).collect(); + let r_norm = expr_normalize_radius("datum.v", domain_min, domain_max, inner); + + let is_full_circle = (end - start - 2.0 * std::f64::consts::PI).abs() < f64::EPSILON; + let tick_just: f64 = if is_full_circle { 0.5 } else { 0.0 }; + let (sin_start, cos_start) = 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 = expr_polar_x(&r_norm, &start_s); + let cy = expr_polar_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": "v", "type": "quantitative"}, + } + })); + + layers + } + fn panel_arc(&self, project: Option<&Projection>, theme: &mut Value) -> Vec { let Some(view) = theme.get_mut("view").and_then(|v| v.as_object_mut()) else { return Vec::new(); @@ -467,12 +616,12 @@ fn expr_normalize_theta( /// Pixel x-coordinate from a normalized radius expression and theta expression. fn expr_polar_x(r: &str, theta: &str) -> String { - format!("width / 2 + min(width, height) / 2 * {r} * sin({theta})") + format!("width / 2 + min(width, height) / 2 * ({r}) * sin({theta})") } /// Pixel y-coordinate from a normalized radius expression and theta expression. fn expr_polar_y(r: &str, theta: &str) -> String { - format!("height / 2 - min(width, height) / 2 * {r} * cos({theta})") + format!("height / 2 - min(width, height) / 2 * ({r}) * cos({theta})") } /// Pixel radius from a normalized radius expression. @@ -1063,12 +1212,21 @@ mod tests { fn test_expr_normalize_radius() { // domain [0, 10], inner 0.2 → scale = (1.0 - 0.2) / (10 - 0) = 0.08 let expr = expr_normalize_radius("datum.v", 0.0, 10.0, 0.2); - assert!(expr.contains("0.08"), "scale factor should be 0.08, got: {expr}"); - assert!(expr.contains("datum.v"), "should reference value, got: {expr}"); + 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 let expr = expr_normalize_radius("datum.x", 5.0, 15.0, 0.0); - assert!(expr.contains("0.1"), "scale factor should be 0.1, got: {expr}"); + assert!( + expr.contains("0.1"), + "scale factor should be 0.1, got: {expr}" + ); } #[test] @@ -1103,7 +1261,11 @@ mod tests { #[test] fn test_grid_rings() { - let scales = vec![scale_with_breaks("pos1", (0.0, 100.0), vec![25.0, 50.0, 75.0])]; + let scales = vec![scale_with_breaks( + "pos1", + (0.0, 100.0), + vec![25.0, 50.0, 75.0], + )]; let proj = PolarProjection; let mut theme = json!({"axis": {"gridColor": "#FFF", "gridWidth": 2}}); @@ -1157,14 +1319,77 @@ mod tests { // 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(); + 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 = PolarProjection; + let theme = json!({ + "axis": { + "tickColor": "#333", + "tickSize": 6, + "labelColor": "#4D4D4D", + "labelFontSize": 12, + } + }); + + let layers = proj.radial_axis(&scales, None, &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"], "v"); + 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 = PolarProjection; + let theme = json!({"axis": {}}); + + let layers = proj.radial_axis(&scales, None, &theme); + assert_eq!( + layers.len(), + 1, + "should produce only the axis line when no breaks" + ); + assert_eq!(layers[0]["mark"]["type"], "rule"); + } } From 040fb7094500244a5dc0efea39969fcafc589b97 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Tue, 28 Apr 2026 14:41:28 +0200 Subject: [PATCH 10/35] Add angular axis to polar foreground decoration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Draws axis arc along the outer edge, radial tick marks at each theta break, and centered text labels beyond the ticks. Label alignment uses center/middle for now — per-datum alignment needs a different approach in Vega-Lite. Co-Authored-By: Claude Opus 4.6 --- src/writer/vegalite/projection.rs | 205 ++++++++++++++++++++++++++++++ 1 file changed, 205 insertions(+) diff --git a/src/writer/vegalite/projection.rs b/src/writer/vegalite/projection.rs index 668f906a..d949d8d6 100644 --- a/src/writer/vegalite/projection.rs +++ b/src/writer/vegalite/projection.rs @@ -251,6 +251,7 @@ impl ProjectionRenderer for PolarProjection { ) -> Vec { let mut layers = Vec::new(); layers.extend(self.radial_axis(scales, project, theme)); + layers.extend(self.angular_axis(scales, project, theme)); layers } } @@ -496,6 +497,143 @@ impl PolarProjection { layers } + fn angular_axis( + &self, + scales: &[Scale], + project: Option<&Projection>, + theme: &Value, + ) -> Vec { + let Some(scale) = scales.iter().find(|s| s.aesthetic == "pos2") else { + return Vec::new(); + }; + let breaks = scale.numeric_breaks(); + let Some((domain_min, domain_max)) = scale.numeric_domain() else { + return Vec::new(); + }; + if (domain_max - domain_min).abs() < f64::EPSILON { + 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 (start, end, inner) = polar_properties(project); + let mut layers = Vec::new(); + + // Axis arc along the outer edge + let radius_expr = expr_polar_radius(&format!("{POLAR_OUTER}")); + layers.push(json!({ + "data": {"values": [{}]}, + "mark": { + "type": "arc", + "fill": null, + "stroke": line_color, + "theta": start, + "theta2": end, + }, + "encoding": { + "radius": { + "value": {"expr": radius_expr} + } + } + })); + + if breaks.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 = breaks.iter().map(|&b| json!({"v": b})).collect(); + let theta = expr_normalize_theta("datum.v", domain_min, domain_max, start, end); + let outer_s = format!("{POLAR_OUTER}"); + + let is_full_circle = (end - start - 2.0 * std::f64::consts::PI).abs() < f64::EPSILON; + let tick_just: f64 = if is_full_circle { 0.5 } else { 0.0 }; + + let outer_cx = expr_polar_x(&outer_s, &theta); + let outer_cy = expr_polar_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: text positioned beyond the outer end of the tick. + // Alignment depends on the angle: top/bottom/left/right around the circle. + let label_pad = 2.0; + let label_offset = format!("{}", (1.0 - tick_just) * tick_size + label_pad); + + layers.push(json!({ + "data": {"values": values}, + "mark": { + "type": "text", + "color": label_color, + "fontSize": label_font_size, + "align": "center", + "baseline": "middle", + }, + "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": "v", "type": "quantitative"}, + } + })); + + layers + } + fn panel_arc(&self, project: Option<&Projection>, theme: &mut Value) -> Vec { let Some(view) = theme.get_mut("view").and_then(|v| v.as_object_mut()) else { return Vec::new(); @@ -1392,4 +1530,71 @@ mod tests { ); 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 = PolarProjection; + let theme = json!({ + "axis": { + "tickColor": "#333", + "tickSize": 6, + "labelColor": "#4D4D4D", + "labelFontSize": 12, + } + }); + + let layers = proj.angular_axis(&scales, None, &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: labels + let labels = &layers[2]; + assert_eq!(labels["mark"]["type"], "text"); + assert_eq!(labels["mark"]["align"], "center"); + assert_eq!(labels["mark"]["baseline"], "middle"); + assert_eq!(labels["data"]["values"].as_array().unwrap().len(), 3); + assert_eq!(labels["encoding"]["text"]["field"], "v"); + } + + #[test] + fn test_angular_axis_no_breaks() { + let scales = vec![scale_with_breaks("pos2", (0.0, 60.0), vec![])]; + let proj = PolarProjection; + let theme = json!({"axis": {}}); + + let layers = proj.angular_axis(&scales, None, &theme); + assert_eq!( + layers.len(), + 1, + "should produce only the axis arc when no breaks" + ); + assert_eq!(layers[0]["mark"]["type"], "arc"); + } } From a907c386c92f18a48f4ce5b9f5ae1d4043b2185d Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Tue, 28 Apr 2026 15:11:10 +0200 Subject: [PATCH 11/35] Use nested layers for angular axis labels to set per-label text alignment Vega-Lite text marks only support a single align/baseline per layer. Bucket breaks by their computed (align, baseline) in Rust, tag each data row with an _ab field, and emit a sub-layer per unique tag that filters on it and sets the correct mark properties. Also fix clippy warnings (unused variable, unused import, unused mut). Co-Authored-By: Claude Opus 4.6 --- src/writer/vegalite/mod.rs | 2 +- src/writer/vegalite/projection.rs | 96 +++++++++++++++++++++++-------- 2 files changed, 74 insertions(+), 24 deletions(-) diff --git a/src/writer/vegalite/mod.rs b/src/writer/vegalite/mod.rs index 0dfc1e67..38bdb8b1 100644 --- a/src/writer/vegalite/mod.rs +++ b/src/writer/vegalite/mod.rs @@ -1394,7 +1394,7 @@ mod tests { fn test_aesthetic_name_mapping() { use crate::plot::AestheticContext; - use crate::plot::projection::{Coord, Projection}; + use crate::plot::projection::Projection; // Test with cartesian projection (None = default cartesian) let ctx = AestheticContext::from_static(&["x", "y"], &[]); diff --git a/src/writer/vegalite/projection.rs b/src/writer/vegalite/projection.rs index d949d8d6..b2b73776 100644 --- a/src/writer/vegalite/projection.rs +++ b/src/writer/vegalite/projection.rs @@ -535,7 +535,7 @@ impl PolarProjection { .cloned() .unwrap_or(Value::Null); - let (start, end, inner) = polar_properties(project); + let (start, end, _inner) = polar_properties(project); let mut layers = Vec::new(); // Axis arc along the outer edge @@ -603,20 +603,59 @@ impl PolarProjection { } })); - // Labels: text positioned beyond the outer end of the tick. - // Alignment depends on the angle: top/bottom/left/right around the circle. + // Labels: one sub-layer per (align, baseline) combination. + // This is cope for text layers not allowing multiple properties per layer. + // 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 = (end - start) / (domain_max - domain_min); + + let mut label_values = Vec::new(); + let mut alignment_keys = std::collections::BTreeSet::new(); + for &b in &breaks { + let angle = start + theta_scale * (b - 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": b, "_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": values}, - "mark": { - "type": "text", - "color": label_color, - "fontSize": label_font_size, - "align": "center", - "baseline": "middle", - }, + "data": {"values": label_values}, "transform": [ {"calculate": &theta, "as": "theta"}, {"calculate": outer_cx, "as": "cx"}, @@ -628,7 +667,8 @@ impl PolarProjection { "x": {"field": "x", "type": "quantitative", "scale": null, "axis": null}, "y": {"field": "y", "type": "quantitative", "scale": null, "axis": null}, "text": {"field": "v", "type": "quantitative"}, - } + }, + "layer": sub_layers, })); layers @@ -1405,7 +1445,7 @@ mod tests { vec![25.0, 50.0, 75.0], )]; let proj = PolarProjection; - let mut theme = json!({"axis": {"gridColor": "#FFF", "gridWidth": 2}}); + let theme = json!({"axis": {"gridColor": "#FFF", "gridWidth": 2}}); let layers = proj.grid_rings(&scales, None, &theme); assert_eq!(layers.len(), 1, "should produce one layer"); @@ -1439,7 +1479,7 @@ mod tests { fn test_grid_spokes() { let scales = vec![scale_with_breaks("pos2", (0.0, 60.0), vec![20.0, 40.0])]; let proj = PolarProjection; - let mut theme = json!({"axis": {"gridColor": "#CCC", "gridWidth": 1}}); + let theme = json!({"axis": {"gridColor": "#CCC", "gridWidth": 1}}); let layers = proj.grid_spokes(&scales, None, &theme); assert_eq!(layers.len(), 1, "should produce one layer"); @@ -1569,18 +1609,28 @@ mod tests { .iter() .filter_map(|t| t["as"].as_str()) .collect(); - assert_eq!( - tick_fields, - vec!["theta", "cx", "cy", "x", "y", "x2", "y2"] - ); + assert_eq!(tick_fields, vec!["theta", "cx", "cy", "x", "y", "x2", "y2"]); - // Layer 2: labels + // Layer 2: nested label layer with shared data/transforms/encoding let labels = &layers[2]; - assert_eq!(labels["mark"]["type"], "text"); - assert_eq!(labels["mark"]["align"], "center"); - assert_eq!(labels["mark"]["baseline"], "middle"); - assert_eq!(labels["data"]["values"].as_array().unwrap().len(), 3); assert_eq!(labels["encoding"]["text"]["field"], "v"); + 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] From dcb562faa7a1ad50eae6f66373a9dcd6081b9095 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Wed, 29 Apr 2026 08:46:26 +0200 Subject: [PATCH 12/35] Consolidate projection trait into single apply_projection() entry point Replaces separate apply_transforms() and apply_panel_decor() calls with one apply_projection() method. Moves faceting before projection so decoration layers work correctly in faceted specs. Renames the unclear apply() to transform_layers(). Co-Authored-By: Claude Opus 4.6 --- src/writer/vegalite/mod.rs | 11 ++---- src/writer/vegalite/projection.rs | 66 ++++++++++++++----------------- 2 files changed, 34 insertions(+), 43 deletions(-) diff --git a/src/writer/vegalite/mod.rs b/src/writer/vegalite/mod.rs index 38bdb8b1..73543a1c 100644 --- a/src/writer/vegalite/mod.rs +++ b/src/writer/vegalite/mod.rs @@ -1146,11 +1146,7 @@ impl Writer for VegaLiteWriter { )?; vl_spec["layer"] = json!(layers); - // 10. Apply projection transforms - let first_df = data.get(&layer_data_keys[0]).unwrap(); - projection.apply_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( @@ -1162,9 +1158,10 @@ impl Writer for VegaLiteWriter { ); } - // 12. Build theme config and apply panel decoration + // 11. Apply projection (transforms + panel decoration) + let first_df = data.get(&layer_data_keys[0]).unwrap(); let mut theme = self.default_theme_config(); - projection.apply_panel_decor(spec, &mut theme, &mut vl_spec); + projection.apply_projection(spec, first_df, &mut theme, &mut vl_spec)?; vl_spec["config"] = theme; // 14. Serialize diff --git a/src/writer/vegalite/projection.rs b/src/writer/vegalite/projection.rs index b2b73776..f44527b4 100644 --- a/src/writer/vegalite/projection.rs +++ b/src/writer/vegalite/projection.rs @@ -46,7 +46,7 @@ pub(super) trait ProjectionRenderer { /// /// Called after layers are built but before faceting. May return a /// transformed DataFrame (e.g., polar currently clones it unchanged). - fn apply( + fn transform_layers( &self, project: &Projection, spec: &Plot, @@ -80,49 +80,43 @@ pub(super) trait ProjectionRenderer { Vec::new() } - /// Apply projection-specific transformations and cross-cutting concerns (clip). - fn apply_transforms( + /// Apply all projection-specific work: transforms, clip, and panel decoration. + fn apply_projection( &self, spec: &Plot, data: &DataFrame, + theme: &mut Value, vl_spec: &mut Value, ) -> Result> { - let Some(ref project) = spec.project else { - return Ok(None); + let result = if let Some(ref project) = spec.project { + let r = self.transform_layers(project, spec, data, vl_spec)?; + if let Some(ParameterValue::Boolean(clip)) = project.properties.get("clip") { + apply_clip_to_layers(vl_spec, *clip); + } + r + } else { + None }; - let result = self.apply(project, spec, data, vl_spec)?; - - if let Some(ParameterValue::Boolean(clip)) = project.properties.get("clip") { - apply_clip_to_layers(vl_spec, *clip); - } - - Ok(result) - } - - /// Prepend background and append foreground decoration layers. - /// - /// Called after faceting so that decoration layers appear in both faceted - /// and non-faceted specs. - fn apply_panel_decor(&self, spec: &Plot, theme: &mut Value, vl_spec: &mut Value) { let mut bg = self.background_layers(&spec.scales, spec.project.as_ref(), theme); let mut fg = self.foreground_layers(&spec.scales, spec.project.as_ref(), theme); - if bg.is_empty() && fg.is_empty() { - return; - } - 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); + 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(result) } } @@ -185,7 +179,7 @@ impl ProjectionRenderer for CartesianProjection { } /// Apply Cartesian projection properties - fn apply( + fn transform_layers( &self, _project: &Projection, _spec: &Plot, @@ -220,7 +214,7 @@ impl ProjectionRenderer for PolarProjection { Some((DEFAULT_POLAR_SIZE, DEFAULT_POLAR_SIZE)) } - fn apply( + fn transform_layers( &self, project: &Projection, spec: &Plot, From d04aea5882b93d84b7a02722e75d63d938ea2542 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Wed, 29 Apr 2026 10:47:04 +0200 Subject: [PATCH 13/35] Add PolarPanel struct to centralise polar geometry and expression helpers PolarProjection now holds a PolarPanel with pre-computed angular range, radius bounds, and VL expression strings (signal-based for non-faceted, literal pixels for faceted). Expression helpers are methods on PolarPanel, replacing the free functions. All private methods read from self.panel instead of taking Projection parameters. Also adds is_faceted() to the ProjectionRenderer trait with a default panel_size() that returns container sizing, letting the call site in mod.rs delegate sizing entirely to the projection. Co-Authored-By: Claude Opus 4.6 --- src/writer/vegalite/encoding.rs | 2 +- src/writer/vegalite/layer.rs | 2 +- src/writer/vegalite/mod.rs | 30 +- src/writer/vegalite/projection.rs | 595 ++++++++++++++---------------- 4 files changed, 297 insertions(+), 332 deletions(-) diff --git a/src/writer/vegalite/encoding.rs b/src/writer/vegalite/encoding.rs index 04183a2c..24571984 100644 --- a/src/writer/vegalite/encoding.rs +++ b/src/writer/vegalite/encoding.rs @@ -1068,7 +1068,7 @@ impl<'a> RenderContext<'a> { #[cfg(test)] pub fn default_for_test() -> Self { - let renderer = super::projection::get_projection_renderer(None); + let renderer = super::projection::get_projection_renderer(None, false); Self::new(&[], renderer.as_ref()) } diff --git a/src/writer/vegalite/layer.rs b/src/writer/vegalite/layer.rs index 762f135d..7cb347ac 100644 --- a/src/writer/vegalite/layer.rs +++ b/src/writer/vegalite/layer.rs @@ -3609,7 +3609,7 @@ mod tests { use crate::plot::{ArrayElement, Scale}; use crate::writer::vegalite::projection::get_projection_renderer; - let cartesian = get_projection_renderer(None); + let cartesian = get_projection_renderer(None, false); // Test success case: continuous scale with numeric range let scales = vec![Scale { diff --git a/src/writer/vegalite/mod.rs b/src/writer/vegalite/mod.rs index 73543a1c..c8ceb13c 100644 --- a/src/writer/vegalite/mod.rs +++ b/src/writer/vegalite/mod.rs @@ -1080,20 +1080,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 proj = get_projection_renderer(spec.project.as_ref()); - if let Some((w, h)) = proj.panel_size() { - vl_spec["width"] = json!(w); - vl_spec["height"] = json!(h); - } + // Get projection renderer (single instance used throughout) + let is_faceted = spec.facet.as_ref().is_some_and(|f| !f.get_variables().is_empty()); + let projection = get_projection_renderer(spec.project.as_ref(), is_faceted); + + if let Some((w, h)) = projection.panel_size() { + vl_spec["width"] = w; + vl_spec["height"] = h; } if let Some(labels) = &spec.labels { @@ -1131,10 +1124,7 @@ impl Writer for VegaLiteWriter { let unified_data = unify_datasets(&prep.datasets)?; vl_spec["data"] = json!({"values": unified_data}); - // 9. Get projection renderer (default to Cartesian if no project) - let projection = get_projection_renderer(spec.project.as_ref()); - - // 10. Build layers (pass free scales and projection for domain handling) + // 9. Build layers (pass free scales and projection for domain handling) let layers = build_layers( spec, data, @@ -1395,7 +1385,7 @@ mod tests { // Test with cartesian projection (None = default cartesian) let ctx = AestheticContext::from_static(&["x", "y"], &[]); - let cartesian = get_projection_renderer(None); + let cartesian = get_projection_renderer(None, false); let cart = cartesian.as_ref(); // Internal position names should map to Vega-Lite channel names based on projection @@ -1421,7 +1411,7 @@ mod tests { // 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)); + let polar = get_projection_renderer(Some(&polar_proj), false); let pol = polar.as_ref(); let polar_ctx = AestheticContext::from_static(&["radius", "theta"], &[]); diff --git a/src/writer/vegalite/projection.rs b/src/writer/vegalite/projection.rs index f44527b4..5df7d8a7 100644 --- a/src/writer/vegalite/projection.rs +++ b/src/writer/vegalite/projection.rs @@ -23,6 +23,9 @@ use super::DEFAULT_POLAR_SIZE; /// 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, @@ -34,12 +37,15 @@ pub(super) trait ProjectionRenderer { /// Returns `(pos1_offset, pos2_offset)`, e.g. `("xOffset", "yOffset")`. fn offset_channels(&self) -> (&'static str, &'static str); - /// Explicit (width, height) panel dimensions for faceted specs, if needed. + /// Panel dimensions as VL values (`"container"` or explicit pixels). /// - /// Polar projections need this because arc mark radius ranges depend on - /// known dimensions; cartesian uses `"container"` sizing and returns `None`. - fn panel_size(&self) -> Option<(f64, f64)> { - None + /// 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. @@ -48,35 +54,20 @@ pub(super) trait ProjectionRenderer { /// transformed DataFrame (e.g., polar currently clones it unchanged). fn transform_layers( &self, - project: &Projection, - spec: &Plot, - data: &DataFrame, - vl_spec: &mut Value, - ) -> Result>; + _spec: &Plot, + _data: &DataFrame, + _vl_spec: &mut Value, + ) -> Result> { + Ok(None) + } /// Vega-Lite layers to prepend before the data layers. - /// - /// Called after faceting, before the theme config is applied. Receives - /// the resolved scales so implementations can derive grid lines, axis - /// ticks, or other decorations from scale breaks and domains. - fn background_layers( - &self, - _scales: &[Scale], - _project: Option<&Projection>, - _theme: &mut Value, - ) -> Vec { + fn background_layers(&self, _scales: &[Scale], _theme: &mut Value) -> Vec { Vec::new() } /// Vega-Lite layers to append after the data layers. - /// - /// Same timing and access as [`background_layers`]. - fn foreground_layers( - &self, - _scales: &[Scale], - _project: Option<&Projection>, - _theme: &mut Value, - ) -> Vec { + fn foreground_layers(&self, _scales: &[Scale], _theme: &mut Value) -> Vec { Vec::new() } @@ -88,18 +79,16 @@ pub(super) trait ProjectionRenderer { theme: &mut Value, vl_spec: &mut Value, ) -> Result> { - let result = if let Some(ref project) = spec.project { - let r = self.transform_layers(project, spec, data, vl_spec)?; + let result = self.transform_layers(spec, data, 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); } - r - } else { - None - }; + } - let mut bg = self.background_layers(&spec.scales, spec.project.as_ref(), theme); - let mut fg = self.foreground_layers(&spec.scales, spec.project.as_ref(), theme); + 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"); @@ -128,10 +117,15 @@ pub(super) trait ProjectionRenderer { /// /// 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>) -> Box { +pub(super) fn get_projection_renderer( + project: Option<&Projection>, + is_faceted: bool, +) -> Box { match project.map(|p| p.coord.coord_kind()) { - Some(CoordKind::Polar) => Box::new(PolarProjection), - Some(CoordKind::Cartesian) | None => Box::new(CartesianProjection), + Some(CoordKind::Polar) => Box::new(PolarProjection { + panel: PolarPanel::new(project, is_faceted), + }), + Some(CoordKind::Cartesian) | None => Box::new(CartesianProjection { is_faceted }), } } @@ -167,9 +161,15 @@ pub(super) fn map_position_to_vegalite( // ============================================================================= /// Cartesian projection — standard x/y coordinates. -struct CartesianProjection; +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") } @@ -177,18 +177,6 @@ impl ProjectionRenderer for CartesianProjection { fn offset_channels(&self) -> (&'static str, &'static str) { ("xOffset", "yOffset") } - - /// Apply Cartesian projection properties - fn transform_layers( - &self, - _project: &Projection, - _spec: &Plot, - _data: &DataFrame, - _vl_spec: &mut Value, - ) -> Result> { - // ratio - not yet implemented - Ok(None) - } } // ============================================================================= @@ -198,10 +186,96 @@ impl ProjectionRenderer for CartesianProjection { /// Normalized outer radius (proportion of `min(width, height) / 2`). const POLAR_OUTER: f64 = 1.0; +/// Pre-computed panel geometry for polar specs. +/// +/// Holds angular range, radius bounds, and VL expression strings for the +/// panel centre and radius. In non-faceted specs these reference the +/// `width`/`height` signals; in faceted specs they are literal pixel values +/// (VL signals don't resolve inside faceted inner specs). +struct PolarPanel { + is_faceted: bool, + start: f64, + end: f64, + inner: f64, + outer: f64, + size: f64, + cx: String, + cy: String, + radius: String, +} + +impl PolarPanel { + fn new(project: Option<&Projection>, is_faceted: bool) -> Self { + 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 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(), + ) + }; + Self { + is_faceted, + start, + end, + inner, + outer: POLAR_OUTER, + size, + cx, + cy, + radius, + } + } + + 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, domain_min: f64, domain_max: f64) -> String { + let scale = (self.outer - self.inner) / (domain_max - domain_min); + format!("{} + {} * ({} - {})", self.inner, scale, value, domain_min) + } + + fn expr_normalize_theta(&self, value: &str, domain_min: f64, domain_max: f64) -> String { + let scale = (self.end - self.start) / (domain_max - domain_min); + format!("{} + {} * ({} - {})", self.start, scale, value, domain_min) + } +} + /// Polar projection — radius/theta coordinates for pie charts, rose plots, etc. -struct PolarProjection; +struct PolarProjection { + panel: PolarPanel, +} impl ProjectionRenderer for PolarProjection { + fn is_faceted(&self) -> bool { + self.panel.is_faceted + } + fn position_channels(&self) -> (&'static str, &'static str) { ("radius", "theta") } @@ -210,53 +284,38 @@ impl ProjectionRenderer for PolarProjection { ("radiusOffset", "thetaOffset") } - fn panel_size(&self) -> Option<(f64, f64)> { - Some((DEFAULT_POLAR_SIZE, DEFAULT_POLAR_SIZE)) + fn panel_size(&self) -> Option<(Value, Value)> { + let size = self.panel.size; + Some((json!(size), json!(size))) } fn transform_layers( &self, - project: &Projection, spec: &Plot, data: &DataFrame, vl_spec: &mut Value, ) -> Result> { - apply_polar_project(project, spec, data, vl_spec) + apply_polar_project(&self.panel, spec, data, vl_spec) } - fn background_layers( - &self, - scales: &[Scale], - project: Option<&Projection>, - theme: &mut Value, - ) -> Vec { + fn background_layers(&self, scales: &[Scale], theme: &mut Value) -> Vec { let mut layers = Vec::new(); - layers.extend(self.panel_arc(project, theme)); - layers.extend(self.grid_rings(scales, project, theme)); - layers.extend(self.grid_spokes(scales, project, theme)); + layers.extend(self.panel_arc(theme)); + layers.extend(self.grid_rings(scales, theme)); + layers.extend(self.grid_spokes(scales, theme)); layers } - fn foreground_layers( - &self, - scales: &[Scale], - project: Option<&Projection>, - theme: &mut Value, - ) -> Vec { + fn foreground_layers(&self, scales: &[Scale], theme: &mut Value) -> Vec { let mut layers = Vec::new(); - layers.extend(self.radial_axis(scales, project, theme)); - layers.extend(self.angular_axis(scales, project, theme)); + layers.extend(self.radial_axis(scales, theme)); + layers.extend(self.angular_axis(scales, theme)); layers } } impl PolarProjection { - fn grid_rings( - &self, - scales: &[Scale], - project: Option<&Projection>, - theme: &Value, - ) -> Vec { + fn grid_rings(&self, scales: &[Scale], theme: &Value) -> Vec { let Some(scale) = scales.iter().find(|s| s.aesthetic == "pos1") else { return Vec::new(); }; @@ -276,11 +335,11 @@ impl PolarProjection { .pointer("/axis/gridWidth") .cloned() .unwrap_or(json!(1)); - let (start, end, inner) = polar_properties(project); + let p = &self.panel; let values: Vec = breaks.iter().map(|&b| json!({"v": b})).collect(); - let r_norm = expr_normalize_radius("datum.v", domain_min, domain_max, inner); - let radius_expr = expr_polar_radius(&r_norm); + let r_norm = p.expr_normalize_radius("datum.v", domain_min, domain_max); + let radius_expr = p.expr_radius(&r_norm); vec![json!({ "data": {"values": values}, @@ -289,8 +348,8 @@ impl PolarProjection { "fill": null, "stroke": color, "strokeWidth": width, - "theta": start, - "theta2": end, + "theta": p.start, + "theta2": p.end, }, "encoding": { "radius": { @@ -300,12 +359,7 @@ impl PolarProjection { })] } - fn grid_spokes( - &self, - scales: &[Scale], - project: Option<&Projection>, - theme: &Value, - ) -> Vec { + fn grid_spokes(&self, scales: &[Scale], theme: &Value) -> Vec { let Some(scale) = scales.iter().find(|s| s.aesthetic == "pos2") else { return Vec::new(); }; @@ -325,11 +379,12 @@ impl PolarProjection { .pointer("/axis/gridWidth") .cloned() .unwrap_or(json!(1)); - let (start, end, inner) = polar_properties(project); + let p = &self.panel; let values: Vec = breaks.iter().map(|&b| json!({"v": b})).collect(); - let theta = expr_normalize_theta("datum.v", domain_min, domain_max, start, end); - let inner_s = format!("{inner}"); + let theta = p.expr_normalize_theta("datum.v", domain_min, domain_max); + let inner_s = format!("{}", p.inner); + let outer_s = format!("{}", p.outer); vec![json!({ "data": {"values": values}, @@ -339,10 +394,10 @@ impl PolarProjection { "strokeWidth": width, }, "transform": [ - {"calculate": expr_polar_x(&inner_s, &theta), "as": "x"}, - {"calculate": expr_polar_y(&inner_s, &theta), "as": "y"}, - {"calculate": expr_polar_x(&format!("{POLAR_OUTER}"), &theta), "as": "x2"}, - {"calculate": expr_polar_y(&format!("{POLAR_OUTER}"), &theta), "as": "y2"}, + {"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}, @@ -353,12 +408,7 @@ impl PolarProjection { })] } - fn radial_axis( - &self, - scales: &[Scale], - project: Option<&Projection>, - theme: &Value, - ) -> Vec { + fn radial_axis(&self, scales: &[Scale], theme: &Value) -> Vec { let Some(scale) = scales.iter().find(|s| s.aesthetic == "pos1") else { return Vec::new(); }; @@ -391,12 +441,13 @@ impl PolarProjection { .cloned() .unwrap_or(Value::Null); - let (start, end, inner) = polar_properties(project); + let p = &self.panel; let mut layers = Vec::new(); // Axis line: rule from inner to outer at start angle - let inner_s = format!("{inner}"); - let start_s = format!("{start}"); + let inner_s = format!("{}", p.inner); + let start_s = format!("{}", p.start); + let outer_s = format!("{}", p.outer); layers.push(json!({ "data": {"values": [{}]}, "mark": { @@ -404,10 +455,10 @@ impl PolarProjection { "stroke": line_color, }, "transform": [ - {"calculate": expr_polar_x(&inner_s, &start_s), "as": "x"}, - {"calculate": expr_polar_y(&inner_s, &start_s), "as": "y"}, - {"calculate": expr_polar_x(&format!("{POLAR_OUTER}"), &start_s), "as": "x2"}, - {"calculate": expr_polar_y(&format!("{POLAR_OUTER}"), &start_s), "as": "y2"}, + {"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}, @@ -427,18 +478,18 @@ impl PolarProjection { // 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 = breaks.iter().map(|&b| json!({"v": b})).collect(); - let r_norm = expr_normalize_radius("datum.v", domain_min, domain_max, inner); + let r_norm = p.expr_normalize_radius("datum.v", domain_min, domain_max); - let is_full_circle = (end - start - 2.0 * std::f64::consts::PI).abs() < f64::EPSILON; + let is_full_circle = (p.end - p.start - 2.0 * std::f64::consts::PI).abs() < f64::EPSILON; let tick_just: f64 = if is_full_circle { 0.5 } else { 0.0 }; - let (sin_start, cos_start) = start.sin_cos(); + 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 = expr_polar_x(&r_norm, &start_s); - let cy = expr_polar_y(&r_norm, &start_s); + 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()}, @@ -491,12 +542,7 @@ impl PolarProjection { layers } - fn angular_axis( - &self, - scales: &[Scale], - project: Option<&Projection>, - theme: &Value, - ) -> Vec { + fn angular_axis(&self, scales: &[Scale], theme: &Value) -> Vec { let Some(scale) = scales.iter().find(|s| s.aesthetic == "pos2") else { return Vec::new(); }; @@ -529,19 +575,20 @@ impl PolarProjection { .cloned() .unwrap_or(Value::Null); - let (start, end, _inner) = polar_properties(project); + let p = &self.panel; let mut layers = Vec::new(); // Axis arc along the outer edge - let radius_expr = expr_polar_radius(&format!("{POLAR_OUTER}")); + let outer_s = format!("{}", p.outer); + let radius_expr = p.expr_radius(&outer_s); layers.push(json!({ "data": {"values": [{}]}, "mark": { "type": "arc", "fill": null, "stroke": line_color, - "theta": start, - "theta2": end, + "theta": p.start, + "theta2": p.end, }, "encoding": { "radius": { @@ -558,14 +605,13 @@ impl PolarProjection { // The tick direction at angle θ is along the radius vector: // unit = (sin(θ), -cos(θ)) in pixel space. let values: Vec = breaks.iter().map(|&b| json!({"v": b})).collect(); - let theta = expr_normalize_theta("datum.v", domain_min, domain_max, start, end); - let outer_s = format!("{POLAR_OUTER}"); + let theta = p.expr_normalize_theta("datum.v", domain_min, domain_max); - let is_full_circle = (end - start - 2.0 * std::f64::consts::PI).abs() < f64::EPSILON; + let is_full_circle = (p.end - p.start - 2.0 * std::f64::consts::PI).abs() < f64::EPSILON; let tick_just: f64 = if is_full_circle { 0.5 } else { 0.0 }; - let outer_cx = expr_polar_x(&outer_s, &theta); - let outer_cy = expr_polar_y(&outer_s, &theta); + 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 @@ -603,12 +649,12 @@ impl PolarProjection { // 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 = (end - start) / (domain_max - domain_min); + 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 &b in &breaks { - let angle = start + theta_scale * (b - domain_min); + let angle = p.start + theta_scale * (b - domain_min); let (sin_a, cos_a) = angle.sin_cos(); let align = if sin_a > 0.1 { "left" @@ -668,7 +714,7 @@ impl PolarProjection { layers } - fn panel_arc(&self, project: Option<&Projection>, theme: &mut Value) -> Vec { + 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(); }; @@ -678,19 +724,22 @@ impl PolarProjection { // We need a null-stroke otherwise it'll show up as a gray line view.insert("stroke".to_string(), Value::Null); - let (start, end, inner) = polar_properties(project); + let p = &self.panel; + + let inner_s = format!("{}", p.inner); + let outer_s = format!("{}", p.outer); let mut mark = json!({ "type": "arc", "fill": fill, "stroke": stroke, - "theta": start, - "theta2": end, + "theta": p.start, + "theta2": p.end, }); - if inner > 0.0 { - mark["innerRadius"] = json!({"expr": expr_polar_radius(&format!("{inner}"))}); + if p.inner > 0.0 { + mark["innerRadius"] = json!({"expr": p.expr_radius(&inner_s)}); } - mark["outerRadius"] = json!({"expr": expr_polar_radius(&format!("{POLAR_OUTER}"))}); + mark["outerRadius"] = json!({"expr": p.expr_radius(&outer_s)}); vec![json!({ "data": {"values": [{}]}, @@ -736,71 +785,6 @@ fn apply_clip_to_layers(vl_spec: &mut Value, clip: bool) { } } -// ============================================================================= -// Polar projection implementation -// ============================================================================= - -/// Extract (start_radians, end_radians, inner) from a Projection. -/// -/// Defaults: start=0°, end=start+360°, inner=0. -fn polar_properties(project: Option<&Projection>) -> (f64, f64, f64) { - 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 inner = prop("inner").unwrap_or(0.0); - ( - start_degrees * std::f64::consts::PI / 180.0, - end_degrees * std::f64::consts::PI / 180.0, - inner, - ) -} - -// ============================================================================= -// Polar expression helpers -// ============================================================================= -// Vega-Lite expression strings for polar ↔ pixel coordinate math. -// Used by both data-layer transforms and decoration layers. - -/// Normalize a value from `[domain_min, domain_max]` to `[inner, POLAR_OUTER]`. -fn expr_normalize_radius(value: &str, domain_min: f64, domain_max: f64, inner: f64) -> String { - let scale = (POLAR_OUTER - inner) / (domain_max - domain_min); - format!("{inner} + {scale} * ({value} - {domain_min})") -} - -/// Normalize a value from `[domain_min, domain_max]` to `[start, end]` radians. -fn expr_normalize_theta( - value: &str, - domain_min: f64, - domain_max: f64, - start: f64, - end: f64, -) -> String { - let scale = (end - start) / (domain_max - domain_min); - format!("{start} + {scale} * ({value} - {domain_min})") -} - -/// Pixel x-coordinate from a normalized radius expression and theta expression. -fn expr_polar_x(r: &str, theta: &str) -> String { - format!("width / 2 + min(width, height) / 2 * ({r}) * sin({theta})") -} - -/// Pixel y-coordinate from a normalized radius expression and theta expression. -fn expr_polar_y(r: &str, theta: &str) -> String { - format!("height / 2 - min(width, height) / 2 * ({r}) * cos({theta})") -} - -/// Pixel radius from a normalized radius expression. -fn expr_polar_radius(r: &str) -> String { - format!("min(width, height) / 2 * ({r})") -} - // ============================================================================= // Polar projection transformation // ============================================================================= @@ -813,15 +797,13 @@ fn expr_polar_radius(r: &str) -> String { /// 2. Applies start/end angle range from PROJECT clause /// 3. Applies inner radius for donut charts fn apply_polar_project( - project: &Projection, + panel: &PolarPanel, spec: &Plot, data: &DataFrame, vl_spec: &mut Value, ) -> Result> { - let (start_radians, end_radians, inner) = polar_properties(Some(project)); - // Convert geoms to polar equivalents and apply angle range + inner radius - convert_geoms_to_polar(spec, vl_spec, start_radians, end_radians, inner)?; + convert_geoms_to_polar(panel, spec, vl_spec)?; // No DataFrame transformation needed - Vega-Lite handles polar math Ok(Some(data.clone())) @@ -838,33 +820,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: &PolarPanel, 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") { @@ -875,12 +831,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)?; } } } @@ -896,12 +852,7 @@ 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: &PolarPanel) -> Result<()> { // Phase 1: Extract info from encoding (immutable read) let (r_field, r_domain, r_title, theta_field, theta_domain, theta_title) = { let encoding = layer @@ -935,31 +886,25 @@ fn convert_polar_to_cartesian( })); let theta_expr = if (theta_max - theta_min).abs() > f64::EPSILON { - expr_normalize_theta( - &format!("datum['{theta_field}']"), - theta_min, - theta_max, - start_radians, - end_radians, - ) + panel.expr_normalize_theta(&format!("datum['{theta_field}']"), theta_min, theta_max) } else { - format!("{start_radians}") + format!("{}", panel.start) }; polar_transforms.push(json!({"calculate": theta_expr, "as": "__polar_theta__"})); let r_expr = if (r_max - r_min).abs() > f64::EPSILON { - expr_normalize_radius(&format!("datum['{r_field}']"), r_min, r_max, inner) + panel.expr_normalize_radius(&format!("datum['{r_field}']"), r_min, r_max) } else { - format!("{}", (POLAR_OUTER + inner) / 2.0) + format!("{}", (panel.outer + panel.inner) / 2.0) }; polar_transforms.push(json!({"calculate": r_expr, "as": "__polar_r__"})); polar_transforms.push(json!({ - "calculate": expr_polar_x("datum.__polar_r__", "datum.__polar_theta__"), + "calculate": panel.expr_x("datum.__polar_r__", "datum.__polar_theta__"), "as": "__polar_x__" })); polar_transforms.push(json!({ - "calculate": expr_polar_y("datum.__polar_r__", "datum.__polar_theta__"), + "calculate": panel.expr_y("datum.__polar_r__", "datum.__polar_theta__"), "as": "__polar_y__" })); @@ -1086,14 +1031,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: &PolarPanel) -> 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(()); } @@ -1108,14 +1049,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] }), ); } @@ -1130,18 +1071,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: &PolarPanel) -> Result<()> { let enc_obj = encoding .as_object_mut() .ok_or_else(|| GgsqlError::WriterError("Encoding is not an object".to_string()))?; - let (inner_expr, outer_expr) = match size { - Some(dim) => (format!("{}/2*{}", dim, inner), format!("{}/2", dim)), - None => ( - expr_polar_radius(&format!("{inner}")), - expr_polar_radius(&format!("{POLAR_OUTER}")), - ), - }; + 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}]); @@ -1189,7 +1127,11 @@ 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 = PolarPanel::new(Some(&proj), false); + apply_polar_radius_range(&mut encoding, &panel).unwrap(); let range = encoding["radius"]["scale"]["range"].as_array().unwrap(); assert_eq!(range.len(), 2); @@ -1214,12 +1156,18 @@ 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 panel = PolarPanel::new(Some(&proj), true); + 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] @@ -1233,18 +1181,22 @@ 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 panel = PolarPanel::new(Some(&proj), true); + 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; + let renderer = CartesianProjection { is_faceted: false }; assert_eq!( map_position_to_vegalite("pos1", &renderer), Some("x".to_string()) @@ -1263,12 +1215,17 @@ mod tests { ); assert_eq!(map_position_to_vegalite("color", &renderer), None); assert_eq!(renderer.offset_channels(), ("xOffset", "yOffset")); - assert_eq!(renderer.panel_size(), None); + assert_eq!( + renderer.panel_size(), + Some((json!("container"), json!("container"))) + ); } #[test] fn test_map_position_to_vegalite_polar() { - let renderer = PolarProjection; + let renderer = PolarProjection { + panel: PolarPanel::new(None, false), + }; assert_eq!( map_position_to_vegalite("pos1", &renderer), Some("radius".to_string()) @@ -1288,7 +1245,7 @@ mod tests { assert_eq!(renderer.offset_channels(), ("radiusOffset", "thetaOffset")); assert_eq!( renderer.panel_size(), - Some((DEFAULT_POLAR_SIZE, DEFAULT_POLAR_SIZE)) + Some((json!(DEFAULT_POLAR_SIZE), json!(DEFAULT_POLAR_SIZE))) ); } @@ -1313,10 +1270,9 @@ mod tests { #[test] fn test_polar_to_cartesian_pixel_coordinates() { let mut layer = polar_point_layer(); - let start = 0.0; - let end = 2.0 * std::f64::consts::PI; + let panel = PolarPanel::new(None, false); - convert_polar_to_cartesian(&mut layer, start, end, 0.0).unwrap(); + convert_polar_to_cartesian(&mut layer, &panel).unwrap(); let transforms = layer["transform"].as_array().unwrap(); @@ -1353,9 +1309,9 @@ mod tests { #[test] fn test_polar_to_cartesian_filters_nulls() { let mut layer = polar_point_layer(); - let full_circle = 2.0 * std::f64::consts::PI; + let panel = PolarPanel::new(None, false); - convert_polar_to_cartesian(&mut layer, 0.0, full_circle, 0.0).unwrap(); + convert_polar_to_cartesian(&mut layer, &panel).unwrap(); let transforms = layer["transform"].as_array().unwrap(); let filter = transforms @@ -1372,18 +1328,23 @@ mod tests { #[test] fn test_get_projection_renderer() { - let cartesian = get_projection_renderer(None); + let cartesian = get_projection_renderer(None, false); assert_eq!(cartesian.position_channels(), ("x", "y")); let polar_proj = Projection::polar(); - let polar = get_projection_renderer(Some(&polar_proj)); + let polar = get_projection_renderer(Some(&polar_proj), false); assert_eq!(polar.position_channels(), ("radius", "theta")); } #[test] fn test_expr_normalize_radius() { - // domain [0, 10], inner 0.2 → scale = (1.0 - 0.2) / (10 - 0) = 0.08 - let expr = expr_normalize_radius("datum.v", 0.0, 10.0, 0.2); + let panel = PolarPanel::new(None, false); + + // domain [0, 10], inner 0.2 — build a panel with inner=0.2 + let mut p = panel; + p.inner = 0.2; + // scale = (1.0 - 0.2) / (10 - 0) = 0.08 + let expr = p.expr_normalize_radius("datum.v", 0.0, 10.0); assert!( expr.contains("0.08"), "scale factor should be 0.08, got: {expr}" @@ -1394,7 +1355,8 @@ mod tests { ); // domain [5, 15], inner 0 → scale = 1.0 / 10 = 0.1 - let expr = expr_normalize_radius("datum.x", 5.0, 15.0, 0.0); + p.inner = 0.0; + let expr = p.expr_normalize_radius("datum.x", 5.0, 15.0); assert!( expr.contains("0.1"), "scale factor should be 0.1, got: {expr}" @@ -1406,9 +1368,10 @@ mod tests { use std::f64::consts::PI; // domain [0, 100], partial circle 90°–270° (π/2 to 3π/2) - let start = PI / 2.0; - let end = 3.0 * PI / 2.0; - let expr = expr_normalize_theta("datum.v", 0.0, 100.0, start, end); + let mut panel = PolarPanel::new(None, false); + panel.start = PI / 2.0; + panel.end = 3.0 * PI / 2.0; + let expr = panel.expr_normalize_theta("datum.v", 0.0, 100.0); // scale = (3π/2 - π/2) / (100 - 0) = π / 100 ≈ 0.031416 let expected_scale = PI / 100.0; assert!( @@ -1438,10 +1401,12 @@ mod tests { (0.0, 100.0), vec![25.0, 50.0, 75.0], )]; - let proj = PolarProjection; + let proj = PolarProjection { + panel: PolarPanel::new(None, false), + }; let theme = json!({"axis": {"gridColor": "#FFF", "gridWidth": 2}}); - let layers = proj.grid_rings(&scales, None, &theme); + let layers = proj.grid_rings(&scales, &theme); assert_eq!(layers.len(), 1, "should produce one layer"); let layer = &layers[0]; @@ -1472,10 +1437,12 @@ mod tests { #[test] fn test_grid_spokes() { let scales = vec![scale_with_breaks("pos2", (0.0, 60.0), vec![20.0, 40.0])]; - let proj = PolarProjection; + let proj = PolarProjection { + panel: PolarPanel::new(None, false), + }; let theme = json!({"axis": {"gridColor": "#CCC", "gridWidth": 1}}); - let layers = proj.grid_spokes(&scales, None, &theme); + let layers = proj.grid_spokes(&scales, &theme); assert_eq!(layers.len(), 1, "should produce one layer"); let layer = &layers[0]; @@ -1506,7 +1473,9 @@ mod tests { (0.0, 100.0), vec![25.0, 50.0, 75.0], )]; - let proj = PolarProjection; + let proj = PolarProjection { + panel: PolarPanel::new(None, false), + }; let theme = json!({ "axis": { "tickColor": "#333", @@ -1516,7 +1485,7 @@ mod tests { } }); - let layers = proj.radial_axis(&scales, None, &theme); + let layers = proj.radial_axis(&scales, &theme); assert_eq!( layers.len(), 3, @@ -1553,10 +1522,12 @@ mod tests { #[test] fn test_radial_axis_no_breaks() { let scales = vec![scale_with_breaks("pos1", (0.0, 100.0), vec![])]; - let proj = PolarProjection; + let proj = PolarProjection { + panel: PolarPanel::new(None, false), + }; let theme = json!({"axis": {}}); - let layers = proj.radial_axis(&scales, None, &theme); + let layers = proj.radial_axis(&scales, &theme); assert_eq!( layers.len(), 1, @@ -1572,7 +1543,9 @@ mod tests { (0.0, 60.0), vec![15.0, 30.0, 45.0], )]; - let proj = PolarProjection; + let proj = PolarProjection { + panel: PolarPanel::new(None, false), + }; let theme = json!({ "axis": { "tickColor": "#333", @@ -1582,7 +1555,7 @@ mod tests { } }); - let layers = proj.angular_axis(&scales, None, &theme); + let layers = proj.angular_axis(&scales, &theme); assert_eq!( layers.len(), 3, @@ -1630,10 +1603,12 @@ mod tests { #[test] fn test_angular_axis_no_breaks() { let scales = vec![scale_with_breaks("pos2", (0.0, 60.0), vec![])]; - let proj = PolarProjection; + let proj = PolarProjection { + panel: PolarPanel::new(None, false), + }; let theme = json!({"axis": {}}); - let layers = proj.angular_axis(&scales, None, &theme); + let layers = proj.angular_axis(&scales, &theme); assert_eq!( layers.len(), 1, From bda8f7521479469b44a4a1a3bdc166ed264735ec Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Wed, 29 Apr 2026 12:33:20 +0200 Subject: [PATCH 14/35] Add numeric_breaks() and numeric_domain() to ScaleTypeTrait Discrete and ordinal scales now synthesize numeric positions from their categorical input ranges (breaks [1..n], domain [0.5, n+0.5]) so that polar grid decoration can work uniformly across all scale types. Co-Authored-By: Claude Opus 4.6 --- src/plot/scale/scale_type/discrete.rs | 10 ++ src/plot/scale/scale_type/mod.rs | 34 +++++ src/plot/scale/scale_type/ordinal.rs | 10 ++ src/plot/scale/types.rs | 173 +++++++++++++++++++++++--- 4 files changed, 212 insertions(+), 15 deletions(-) diff --git a/src/plot/scale/scale_type/discrete.rs b/src/plot/scale/scale_type/discrete.rs index 97c31344..e4d6f555 100644 --- a/src/plot/scale/scale_type/discrete.rs +++ b/src/plot/scale/scale_type/discrete.rs @@ -57,6 +57,16 @@ impl ScaleTypeTrait for Discrete { true } + fn numeric_breaks(&self, scale: &super::super::Scale) -> Vec { + let n = scale.input_range.as_ref().map_or(0, |r| r.len()); + (1..=n).map(|i| i as f64).collect() + } + + fn numeric_domain(&self, scale: &super::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 } + } + 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..f77759a8 100644 --- a/src/plot/scale/scale_type/mod.rs +++ b/src/plot/scale/scale_type/mod.rs @@ -796,6 +796,30 @@ 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(), + } + } + + /// 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: @@ -1248,6 +1272,16 @@ 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) + } + /// 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..ca833617 100644 --- a/src/plot/scale/scale_type/ordinal.rs +++ b/src/plot/scale/scale_type/ordinal.rs @@ -64,6 +64,16 @@ impl ScaleTypeTrait for Ordinal { true // Collects unique values like Discrete } + fn numeric_breaks(&self, scale: &super::super::Scale) -> Vec { + let n = scale.input_range.as_ref().map_or(0, |r| r.len()); + (1..=n).map(|i| i as f64).collect() + } + + fn numeric_domain(&self, scale: &super::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 } + } + 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 ff36882d..09120fe6 100644 --- a/src/plot/scale/types.rs +++ b/src/plot/scale/types.rs @@ -90,26 +90,37 @@ impl Scale { } } - // TODO: generalise for discrete/binned scales (see memory: project_discrete_polar_grid) - - /// Numeric break positions (after resolution). Currently only meaningful - /// for continuous scales. + /// 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.properties.get("breaks") { - Some(ParameterValue::Array(breaks)) => { - breaks.iter().filter_map(|b| b.to_f64()).collect() - } - _ => Vec::new(), + 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(), + }, } } - /// Numeric domain as `(min, max)` from the resolved input range. Currently - /// only meaningful for continuous scales. + /// 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)> { - let range = self.input_range.as_ref()?; - let min = range.first()?.to_f64()?; - let max = range.last()?.to_f64()?; - Some((min, max)) + 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)) + } + } } } @@ -122,3 +133,135 @@ 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))); + } +} From eea14eca5333b2ef7517d4169a75a3770b4ed7ff Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Wed, 29 Apr 2026 14:43:55 +0200 Subject: [PATCH 15/35] =?UTF-8?q?Support=20discrete=20scales,=20secondary?= =?UTF-8?q?=20channels,=20and=20offsets=20in=20polar=E2=86=92cartesian=20c?= =?UTF-8?q?onversion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends convert_polar_to_cartesian for non-arc polar marks: - Discrete theta/radius domains generate indexof() VL expressions - radius2/theta2 channels converted to x2/y2 using primary domain - Offset channels (radiusOffset/thetaOffset) normalized into polar space when they carry a scale domain, or applied as raw pixel displacements along the radial/tangential directions otherwise - Discrete offsets narrowed by band fraction (0.9) to leave angular gaps between adjacent categories, matching VL band scale padding Co-Authored-By: Claude Opus 4.6 --- src/writer/vegalite/projection.rs | 503 ++++++++++++++++++++++++++++-- 1 file changed, 470 insertions(+), 33 deletions(-) diff --git a/src/writer/vegalite/projection.rs b/src/writer/vegalite/projection.rs index 5df7d8a7..0793dd4a 100644 --- a/src/writer/vegalite/projection.rs +++ b/src/writer/vegalite/projection.rs @@ -186,6 +186,10 @@ impl ProjectionRenderer for CartesianProjection { /// 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; + /// Pre-computed panel geometry for polar specs. /// /// Holds angular range, radius bounds, and VL expression strings for the @@ -854,21 +858,29 @@ fn convert_geoms_to_polar(panel: &PolarPanel, spec: &Plot, vl_spec: &mut Value) /// 3. Replace radius/theta with x/y encoding channels fn convert_polar_to_cartesian(layer: &mut Value, panel: &PolarPanel) -> 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_domain, r_title, r_discrete, + theta_val, theta_field, theta_domain, 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_domain, r_title, r_disc) = + extract_polar_channel(encoding, "radius")?; + let (theta_val, theta_field, theta_domain, 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_field, - r_domain, - r_title, - theta_field, - theta_domain, - theta_title, + 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"), ) }; @@ -886,27 +898,120 @@ fn convert_polar_to_cartesian(layer: &mut Value, panel: &PolarPanel) -> Result<( })); let theta_expr = if (theta_max - theta_min).abs() > f64::EPSILON { - panel.expr_normalize_theta(&format!("datum['{theta_field}']"), theta_min, theta_max) + panel.expr_normalize_theta(&theta_val, theta_min, theta_max) } else { format!("{}", panel.start) }; polar_transforms.push(json!({"calculate": theta_expr, "as": "__polar_theta__"})); let r_expr = if (r_max - r_min).abs() > f64::EPSILON { - panel.expr_normalize_radius(&format!("datum['{r_field}']"), r_min, r_max) + panel.expr_normalize_radius(&r_val, r_min, r_max) } else { format!("{}", (panel.outer + panel.inner) / 2.0) }; polar_transforms.push(json!({"calculate": r_expr, "as": "__polar_r__"})); - polar_transforms.push(json!({ - "calculate": panel.expr_x("datum.__polar_r__", "datum.__polar_theta__"), - "as": "__polar_x__" - })); - polar_transforms.push(json!({ - "calculate": panel.expr_y("datum.__polar_r__", "datum.__polar_theta__"), - "as": "__polar_y__" - })); + // 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 = if (r_max - r_min).abs() > f64::EPSILON { + (panel.outer - panel.inner) / (r_max - r_min) + } else { + 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 = if (theta_max - theta_min).abs() > f64::EPSILON { + (panel.end - panel.start) / (theta_max - theta_min) + } else { + 0.0 + }; + let bw = if theta_discrete { POLAR_BAND_FRACTION } else { 1.0 }; + 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); + + // Raw pixel offsets applied after polar→cartesian conversion + 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 { + if (r_max - r_min).abs() > f64::EPSILON { + panel.expr_normalize_radius(&format!("datum['{}']", f), r_min, r_max) + } else { + format!("{}", (panel.outer + panel.inner) / 2.0) + } + } else { + "datum.__polar_r__".to_string() + }; + let theta2_expr = if let Some(ref f) = theta2_field { + if (theta_max - theta_min).abs() > f64::EPSILON { + panel.expr_normalize_theta(&format!("datum['{}']", f), theta_min, theta_max) + } else { + format!("{}", panel.start) + } + } 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") { @@ -925,6 +1030,10 @@ fn convert_polar_to_cartesian(layer: &mut Value, panel: &PolarPanel) -> Result<( encoding.remove("radius"); encoding.remove("theta"); + encoding.remove("radius2"); + encoding.remove("theta2"); + encoding.remove("radiusOffset"); + encoding.remove("thetaOffset"); let mut x_enc = json!({ "field": "__polar_x__", @@ -949,15 +1058,25 @@ fn convert_polar_to_cartesian(layer: &mut Value, panel: &PolarPanel) -> Result<( 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, (domain_min, domain_max), optional_title, is_discrete)`. +/// For continuous scales `value_expr` is `datum['field']`. +/// For discrete scales it is `indexof([...], datum['field']) + 1` with a +/// synthesized numeric domain `(0.5, n + 0.5)`. fn extract_polar_channel( encoding: &serde_json::Map, channel: &str, -) -> Result<(String, (f64, f64), Option)> { +) -> Result<(String, String, (f64, f64), Option, bool)> { let channel_enc = encoding.get(channel).ok_or_else(|| { GgsqlError::WriterError(format!( "Polar projection requires '{}' encoding channel", @@ -971,21 +1090,33 @@ 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 + if let Some((min, max)) = domain_arr.and_then(|arr| { + Some((arr.first()?.as_f64()?, arr.get(1)?.as_f64()?)) + }) { + return Ok((format!("datum['{}']", field), field, (min, max), title, false)); + } + + // Discrete domain: string array → indexof + synthesized numeric domain + if let Some(arr) = domain_arr { + let strings: Vec<&str> = arr.iter().filter_map(|v| v.as_str()).collect(); + if !strings.is_empty() { + let n = strings.len(); + let literal: String = strings.iter().map(|s| format!("'{}'", s)).collect::>().join(","); + let expr = format!("indexof([{}], datum['{}']) + 1", literal, field); + return Ok((expr, field, (0.5, n as f64 + 0.5), title, true)); + } + } - Ok((field, domain, title)) + // Fallback + Ok((format!("datum['{}']", field), field, (0.0, 1.0), title, false)) } /// Convert a mark type to its polar equivalent @@ -1616,4 +1747,310 @@ mod tests { ); assert_eq!(layers[0]["mark"]["type"], "arc"); } + + // ========================================================================= + // 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 panel = PolarPanel::new(None, false); + + 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}" + ); + } + + #[test] + fn test_discrete_theta_synthesizes_domain() { + let mut layer = discrete_theta_layer(); + let panel = PolarPanel::new(None, false); + + 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 = PolarPanel::new(None, false); + + 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 = PolarPanel::new(None, false); + + 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 = PolarPanel::new(None, false); + + 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 = PolarPanel::new(None, false); + + 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 panel = PolarPanel::new(None, false); + + 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 = PolarPanel::new(None, false); + + 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}" + ); + } } From 83480fadb1fa2cb3178e03f2f0861aa1ea2cfd9e Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Wed, 29 Apr 2026 15:05:07 +0200 Subject: [PATCH 16/35] Add break_labels() for display-ready axis labels in polar coordinates Adds break_labels() to ScaleTypeTrait, returning (position, label) pairs. Discrete and ordinal scales pair integer positions with input-range category names; continuous scales format break values as strings. Scale.break_labels() applies label_mapping overrides on top (renaming or suppressing to empty string). Polar radial and angular axes now use break_labels() so discrete categories show their names instead of numeric positions. Co-Authored-By: Claude Opus 4.6 --- src/plot/scale/scale_type/discrete.rs | 15 ++++++ src/plot/scale/scale_type/mod.rs | 17 +++++++ src/plot/scale/scale_type/ordinal.rs | 15 ++++++ src/plot/scale/types.rs | 69 +++++++++++++++++++++++++++ src/writer/vegalite/projection.rs | 33 +++++++------ 5 files changed, 135 insertions(+), 14 deletions(-) diff --git a/src/plot/scale/scale_type/discrete.rs b/src/plot/scale/scale_type/discrete.rs index e4d6f555..c9b6edda 100644 --- a/src/plot/scale/scale_type/discrete.rs +++ b/src/plot/scale/scale_type/discrete.rs @@ -67,6 +67,21 @@ impl ScaleTypeTrait for Discrete { if n > 0 { Some((0.5, n as f64 + 0.5)) } else { None } } + fn break_labels(&self, scale: &super::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 + } + 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 f77759a8..d4e30e1e 100644 --- a/src/plot/scale/scale_type/mod.rs +++ b/src/plot/scale/scale_type/mod.rs @@ -809,6 +809,18 @@ pub trait ScaleTypeTrait: std::fmt::Debug + std::fmt::Display + Send + Sync { } } + /// 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. @@ -1282,6 +1294,11 @@ impl ScaleType { 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 ca833617..10c28e02 100644 --- a/src/plot/scale/scale_type/ordinal.rs +++ b/src/plot/scale/scale_type/ordinal.rs @@ -74,6 +74,21 @@ impl ScaleTypeTrait for Ordinal { if n > 0 { Some((0.5, n as f64 + 0.5)) } else { None } } + fn break_labels(&self, scale: &super::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 + } + 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 09120fe6..6c9599a4 100644 --- a/src/plot/scale/types.rs +++ b/src/plot/scale/types.rs @@ -106,6 +106,31 @@ impl Scale { } } + /// 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 @@ -264,4 +289,48 @@ mod tests { 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/writer/vegalite/projection.rs b/src/writer/vegalite/projection.rs index 0793dd4a..f23c8511 100644 --- a/src/writer/vegalite/projection.rs +++ b/src/writer/vegalite/projection.rs @@ -416,7 +416,7 @@ impl PolarProjection { let Some(scale) = scales.iter().find(|s| s.aesthetic == "pos1") else { return Vec::new(); }; - let breaks = scale.numeric_breaks(); + let break_labels = scale.break_labels(); let Some((domain_min, domain_max)) = scale.numeric_domain() else { return Vec::new(); }; @@ -472,7 +472,7 @@ impl PolarProjection { } })); - if breaks.is_empty() { + if break_labels.is_empty() { return layers; } @@ -481,7 +481,10 @@ impl PolarProjection { // 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 = breaks.iter().map(|&b| json!({"v": b})).collect(); + let values: Vec = break_labels + .iter() + .map(|(v, label)| json!({"v": v, "label": label})) + .collect(); let r_norm = p.expr_normalize_radius("datum.v", domain_min, domain_max); let is_full_circle = (p.end - p.start - 2.0 * std::f64::consts::PI).abs() < f64::EPSILON; @@ -539,7 +542,7 @@ impl PolarProjection { "encoding": { "x": {"field": "x", "type": "quantitative", "scale": null, "axis": null}, "y": {"field": "y", "type": "quantitative", "scale": null, "axis": null}, - "text": {"field": "v", "type": "quantitative"}, + "text": {"field": "label", "type": "nominal"}, } })); @@ -550,7 +553,7 @@ impl PolarProjection { let Some(scale) = scales.iter().find(|s| s.aesthetic == "pos2") else { return Vec::new(); }; - let breaks = scale.numeric_breaks(); + let break_labels = scale.break_labels(); let Some((domain_min, domain_max)) = scale.numeric_domain() else { return Vec::new(); }; @@ -601,14 +604,17 @@ impl PolarProjection { } })); - if breaks.is_empty() { + if break_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 = breaks.iter().map(|&b| json!({"v": b})).collect(); + let values: Vec = break_labels + .iter() + .map(|(v, label)| json!({"v": v, "label": label})) + .collect(); let theta = p.expr_normalize_theta("datum.v", domain_min, domain_max); let is_full_circle = (p.end - p.start - 2.0 * std::f64::consts::PI).abs() < f64::EPSILON; @@ -648,7 +654,6 @@ impl PolarProjection { })); // Labels: one sub-layer per (align, baseline) combination. - // This is cope for text layers not allowing multiple properties per layer. // 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; @@ -657,8 +662,8 @@ impl PolarProjection { let mut label_values = Vec::new(); let mut alignment_keys = std::collections::BTreeSet::new(); - for &b in &breaks { - let angle = p.start + theta_scale * (b - domain_min); + for &(v, ref label) in &break_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" @@ -676,7 +681,7 @@ impl PolarProjection { }; let ab = format!("{align}/{baseline}"); alignment_keys.insert(ab.clone()); - label_values.push(json!({"v": b, "_ab": ab})); + label_values.push(json!({"v": v, "label": label, "_ab": ab})); } let sub_layers: Vec = alignment_keys @@ -710,7 +715,7 @@ impl PolarProjection { "encoding": { "x": {"field": "x", "type": "quantitative", "scale": null, "axis": null}, "y": {"field": "y", "type": "quantitative", "scale": null, "axis": null}, - "text": {"field": "v", "type": "quantitative"}, + "text": {"field": "label", "type": "nominal"}, }, "layer": sub_layers, })); @@ -1646,7 +1651,7 @@ mod tests { 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"], "v"); + assert_eq!(labels["encoding"]["text"]["field"], "label"); assert_eq!(labels["encoding"]["x"]["scale"], json!(null)); } @@ -1711,7 +1716,7 @@ mod tests { // Layer 2: nested label layer with shared data/transforms/encoding let labels = &layers[2]; - assert_eq!(labels["encoding"]["text"]["field"], "v"); + 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!( From c169c2efb6d61c535321c12682fc6fddcacb5747 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Wed, 29 Apr 2026 15:10:31 +0200 Subject: [PATCH 17/35] fix panel size bug --- src/writer/vegalite/projection.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/writer/vegalite/projection.rs b/src/writer/vegalite/projection.rs index f23c8511..672c43b0 100644 --- a/src/writer/vegalite/projection.rs +++ b/src/writer/vegalite/projection.rs @@ -289,8 +289,12 @@ impl ProjectionRenderer for PolarProjection { } fn panel_size(&self) -> Option<(Value, Value)> { - let size = self.panel.size; - Some((json!(size), json!(size))) + if self.panel.is_faceted { + let size = self.panel.size; + Some((json!(size), json!(size))) + } else { + Some((json!("container"), json!("container"))) + } } fn transform_layers( @@ -1381,7 +1385,7 @@ mod tests { assert_eq!(renderer.offset_channels(), ("radiusOffset", "thetaOffset")); assert_eq!( renderer.panel_size(), - Some((json!(DEFAULT_POLAR_SIZE), json!(DEFAULT_POLAR_SIZE))) + Some((json!("container"), json!("container"))) ); } From aa9a75b2ac9ab17008842a50ef654bfd1c2949fa Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Wed, 29 Apr 2026 15:21:38 +0200 Subject: [PATCH 18/35] Fix discrete polar indexof: escape quotes and map OOB values to null indexof() returns -1 for values not in the domain array, which previously produced position 0 (outside the synthesized domain). Now maps to null so VL drops the row. Also escapes single quotes in category names to prevent broken VL expressions. Co-Authored-By: Claude Opus 4.6 --- src/writer/vegalite/projection.rs | 51 +++++++++++++++++++++++++++++-- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/src/writer/vegalite/projection.rs b/src/writer/vegalite/projection.rs index 672c43b0..083a52d7 100644 --- a/src/writer/vegalite/projection.rs +++ b/src/writer/vegalite/projection.rs @@ -1118,8 +1118,18 @@ fn extract_polar_channel( let strings: Vec<&str> = arr.iter().filter_map(|v| v.as_str()).collect(); if !strings.is_empty() { let n = strings.len(); - let literal: String = strings.iter().map(|s| format!("'{}'", s)).collect::>().join(","); - let expr = format!("indexof([{}], datum['{}']) + 1", literal, field); + let literal: String = strings + .iter() + .map(|s| format!("'{}'", s.replace('\'', "\\'"))) + .collect::>() + .join(","); + // indexof returns -1 for values not in the domain; map those to null + 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, (0.5, n as f64 + 0.5), title, true)); } } @@ -1796,6 +1806,43 @@ mod tests { 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 panel = PolarPanel::new(None, false); + + 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] From b5359bf97258b583a18b6e51835851dc8a377478 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Wed, 29 Apr 2026 15:26:27 +0200 Subject: [PATCH 19/35] add some missing tests --- src/writer/vegalite/projection.rs | 162 ++++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) diff --git a/src/writer/vegalite/projection.rs b/src/writer/vegalite/projection.rs index 083a52d7..51c1cd6c 100644 --- a/src/writer/vegalite/projection.rs +++ b/src/writer/vegalite/projection.rs @@ -2109,4 +2109,166 @@ mod tests { "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 = PolarProjection { + panel: PolarPanel::new(None, false), + }; + let theme = json!({"axis": {}}); + + let layers = proj.radial_axis(&scales, &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 = PolarProjection { + panel: PolarPanel::new(None, false), + }; + let theme = json!({"axis": {}}); + + let layers = proj.angular_axis(&scales, &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 = PolarProjection { + panel: PolarPanel::new(None, false), + }; + let theme = json!({"axis": {}}); + + let layers = proj.grid_spokes(&scales, &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 = PolarProjection { + panel: PolarPanel::new(None, false), + }; + let theme = json!({"axis": {}}); + + let layers = proj.angular_axis(&scales, &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 panel = PolarPanel::new(Some(&proj), true); + + // 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 proj = PolarProjection { + panel: PolarPanel::new(Some(&proj_spec), true), + }; + let theme = json!({"axis": {}}); + + let layers = proj.grid_rings(&scales, &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 renderer = PolarProjection { + panel: PolarPanel::new(Some(&proj), true), + }; + assert_eq!( + renderer.panel_size(), + Some((json!(DEFAULT_POLAR_SIZE), json!(DEFAULT_POLAR_SIZE))) + ); + } } From 524005539bb984748e2a200d2bca918a6ab8abce Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Thu, 30 Apr 2026 10:50:35 +0200 Subject: [PATCH 20/35] Suppress polar axes and grid lines for dummy scales Co-Authored-By: Claude Opus 4.6 --- src/plot/scale/types.rs | 12 ++++++++++++ src/writer/vegalite/projection.rs | 12 ++++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/plot/scale/types.rs b/src/plot/scale/types.rs index 6c9599a4..9e0b70c5 100644 --- a/src/plot/scale/types.rs +++ b/src/plot/scale/types.rs @@ -90,6 +90,18 @@ impl Scale { } } + /// 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 diff --git a/src/writer/vegalite/projection.rs b/src/writer/vegalite/projection.rs index 51c1cd6c..db4dd2a1 100644 --- a/src/writer/vegalite/projection.rs +++ b/src/writer/vegalite/projection.rs @@ -327,6 +327,9 @@ impl PolarProjection { let Some(scale) = scales.iter().find(|s| s.aesthetic == "pos1") else { return Vec::new(); }; + if scale.is_dummy() { + return Vec::new(); + } let breaks = scale.numeric_breaks(); let Some((domain_min, domain_max)) = scale.numeric_domain() else { return Vec::new(); @@ -371,6 +374,9 @@ impl PolarProjection { let Some(scale) = scales.iter().find(|s| s.aesthetic == "pos2") else { return Vec::new(); }; + if scale.is_dummy() { + return Vec::new(); + } let breaks = scale.numeric_breaks(); let Some((domain_min, domain_max)) = scale.numeric_domain() else { return Vec::new(); @@ -420,6 +426,9 @@ impl PolarProjection { let Some(scale) = scales.iter().find(|s| s.aesthetic == "pos1") else { return Vec::new(); }; + if scale.is_dummy() { + return Vec::new(); + } let break_labels = scale.break_labels(); let Some((domain_min, domain_max)) = scale.numeric_domain() else { return Vec::new(); @@ -557,6 +566,9 @@ impl PolarProjection { let Some(scale) = scales.iter().find(|s| s.aesthetic == "pos2") else { return Vec::new(); }; + if scale.is_dummy() { + return Vec::new(); + } let break_labels = scale.break_labels(); let Some((domain_min, domain_max)) = scale.numeric_domain() else { return Vec::new(); From 5fb58b1e551a6b2fab4f37fe5e84c1971ee4dde5 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Thu, 30 Apr 2026 12:44:08 +0200 Subject: [PATCH 21/35] Suppress polar decorations for free facet scales Polar grid lines and axes are drawn as manual VL layers positioned from the global scale domain. With free scales each panel has its own domain, so the global positions would be wrong. Suppress them rather than render misleading decorations. Also adds Facet::is_free() and removes the free_scales plumbing from EncodingContext/ScaleContext in favour of reading spec.facet directly. get_projection_renderer() now takes Option<&Facet> instead of a bool. Co-Authored-By: Claude Opus 4.6 --- src/plot/facet/types.rs | 60 +++++++ src/writer/vegalite/encoding.rs | 49 +----- src/writer/vegalite/layer.rs | 2 +- src/writer/vegalite/mod.rs | 51 +----- src/writer/vegalite/projection.rs | 273 +++++++++++++++++++++++------- 5 files changed, 291 insertions(+), 144 deletions(-) diff --git a/src/plot/facet/types.rs b/src/plot/facet/types.rs index 565802a9..cb25bbc2 100644 --- a/src/plot/facet/types.rs +++ b/src/plot/facet/types.rs @@ -64,6 +64,23 @@ impl Facet { pub fn is_grid(&self) -> bool { self.layout.is_grid() } + + /// Whether the given position aesthetic has free (independent) scales. + /// + /// Accepts internal position names and their variants: + /// `"pos1"`, `"pos1min"`, `"pos1end"`, `"pos2"`, `"pos2max"`, `"pos3"`, etc. + pub fn is_free(&self, aesthetic: &str) -> bool { + use crate::plot::ArrayElement; + let Some(ParameterValue::Array(arr)) = self.properties.get("free") else { + return false; + }; + for (idx, prefix) in ["pos1", "pos2", "pos3"].iter().enumerate() { + if aesthetic.starts_with(prefix) { + return matches!(arr.get(idx), Some(ArrayElement::Boolean(true))); + } + } + false + } } impl FacetLayout { @@ -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/writer/vegalite/encoding.rs b/src/writer/vegalite/encoding.rs index a5be8ef2..fa4c74b8 100644 --- a/src/writer/vegalite/encoding.rs +++ b/src/writer/vegalite/encoding.rs @@ -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 @@ -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)); } @@ -1073,7 +1040,7 @@ impl<'a> RenderContext<'a> { #[cfg(test)] pub fn default_for_test() -> Self { - let renderer = super::projection::get_projection_renderer(None, false); + let renderer = super::projection::get_projection_renderer(None, None); Self::new( &[], renderer.as_ref(), @@ -1267,7 +1234,7 @@ mod tests { let scales: Vec = vec![]; let ctx = RenderContext::new( &scales, - get_projection_renderer(None, false).as_ref(), + get_projection_renderer(None, None).as_ref(), AestheticContext::from_static(&["x", "y"], &[]), ); let err = ctx.get_extent("pos1").unwrap_err().to_string(); @@ -1282,7 +1249,7 @@ mod tests { let scales: Vec = vec![]; let ctx = RenderContext::new( &scales, - get_projection_renderer(None, false).as_ref(), + get_projection_renderer(None, None).as_ref(), AestheticContext::from_static(&["angle", "radius"], &[]), ); let err = ctx.get_extent("pos1").unwrap_err().to_string(); @@ -1299,7 +1266,7 @@ mod tests { let scales = vec![discrete_scale("pos2")]; let ctx = RenderContext::new( &scales, - get_projection_renderer(None, false).as_ref(), + 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 17acddb7..150c79ef 100644 --- a/src/writer/vegalite/layer.rs +++ b/src/writer/vegalite/layer.rs @@ -3636,7 +3636,7 @@ mod tests { use crate::plot::{ArrayElement, Scale}; use crate::writer::vegalite::projection::get_projection_renderer; - let cartesian = get_projection_renderer(None, false); + let cartesian = get_projection_renderer(None, None); // Test success case: continuous scale with numeric range let scales = vec![Scale { diff --git a/src/writer/vegalite/mod.rs b/src/writer/vegalite/mod.rs index 49b14a65..83e3b86f 100644 --- a/src/writer/vegalite/mod.rs +++ b/src/writer/vegalite/mod.rs @@ -159,9 +159,6 @@ 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 `projection` determines how internal position aesthetics are mapped to /// Vega-Lite encoding channel names. fn build_layers( @@ -170,7 +167,6 @@ fn build_layers( layer_data_keys: &[String], layer_renderers: &[Box], prepared_data: &[PreparedData], - free_scales: Option<&[crate::plot::ArrayElement]>, projection: &dyn ProjectionRenderer, ) -> Result> { let mut layers = Vec::new(); @@ -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 projection) - let mut encoding = build_layer_encoding(layer, df, spec, free_scales, projection)?; + // 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,16 +243,12 @@ 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 `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]>, 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 @@ -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: @@ -1046,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() @@ -1084,11 +1052,7 @@ impl Writer for VegaLiteWriter { "$schema": self.schema }); // Get projection renderer (single instance used throughout) - let is_faceted = spec - .facet - .as_ref() - .is_some_and(|f| !f.get_variables().is_empty()); - let projection = get_projection_renderer(spec.project.as_ref(), is_faceted); + let projection = get_projection_renderer(spec.project.as_ref(), spec.facet.as_ref()); if let Some((w, h)) = projection.panel_size() { vl_spec["width"] = w; @@ -1130,14 +1094,13 @@ impl Writer for VegaLiteWriter { let unified_data = unify_datasets(&prep.datasets)?; vl_spec["data"] = json!({"values": unified_data}); - // 9. Build layers (pass free scales and projection for domain handling) + // 9. Build layers let layers = build_layers( spec, data, &layer_data_keys, &prep.renderers, &prep.prepared, - free_scales, projection.as_ref(), )?; vl_spec["layer"] = json!(layers); @@ -1391,7 +1354,7 @@ mod tests { // Test with cartesian projection (None = default cartesian) let ctx = AestheticContext::from_static(&["x", "y"], &[]); - let cartesian = get_projection_renderer(None, false); + let cartesian = get_projection_renderer(None, None); let cart = cartesian.as_ref(); // Internal position names should map to Vega-Lite channel names based on projection @@ -1417,7 +1380,7 @@ mod tests { // 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), false); + let polar = get_projection_renderer(Some(&polar_proj), None); let pol = polar.as_ref(); let polar_ctx = AestheticContext::from_static(&["radius", "theta"], &[]); diff --git a/src/writer/vegalite/projection.rs b/src/writer/vegalite/projection.rs index db4dd2a1..b2629893 100644 --- a/src/writer/vegalite/projection.rs +++ b/src/writer/vegalite/projection.rs @@ -119,11 +119,12 @@ pub(super) trait ProjectionRenderer { /// or a Cartesian renderer if no projection is specified. pub(super) fn get_projection_renderer( project: Option<&Projection>, - is_faceted: bool, + facet: Option<&crate::plot::Facet>, ) -> 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: PolarPanel::new(project, is_faceted), + panel: PolarPanel::new(project, facet), }), Some(CoordKind::Cartesian) | None => Box::new(CartesianProjection { is_faceted }), } @@ -197,19 +198,27 @@ const POLAR_BAND_FRACTION: f64 = 0.9; /// `width`/`height` signals; in faceted specs they are literal pixel values /// (VL signals don't resolve inside faceted inner specs). struct PolarPanel { - is_faceted: bool, + // Panel shape start: f64, end: f64, inner: f64, outer: f64, + // Placement details size: f64, cx: String, cy: String, radius: String, + // Facet state + is_faceted: bool, + /// pos1 (radius) has free/independent scales across facet panels + free_pos1: bool, + /// pos2 (theta) has free/independent scales across facet panels + free_pos2: bool, } impl PolarPanel { - fn new(project: Option<&Projection>, is_faceted: bool) -> Self { + fn new(project: Option<&Projection>, facet: Option<&crate::plot::Facet>) -> 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)) @@ -234,6 +243,8 @@ impl PolarPanel { "min(width, height) / 2".to_string(), ) }; + let free_pos1 = facet.is_some_and(|f| f.is_free("pos1")); + let free_pos2 = facet.is_some_and(|f| f.is_free("pos2")); Self { is_faceted, start, @@ -244,6 +255,8 @@ impl PolarPanel { cx, cy, radius, + free_pos1, + free_pos2, } } @@ -322,12 +335,16 @@ impl ProjectionRenderer for PolarProjection { } } +// 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, scales: &[Scale], theme: &Value) -> Vec { let Some(scale) = scales.iter().find(|s| s.aesthetic == "pos1") else { return Vec::new(); }; - if scale.is_dummy() { + if scale.is_dummy() || self.panel.free_pos1 { return Vec::new(); } let breaks = scale.numeric_breaks(); @@ -374,7 +391,7 @@ impl PolarProjection { let Some(scale) = scales.iter().find(|s| s.aesthetic == "pos2") else { return Vec::new(); }; - if scale.is_dummy() { + if scale.is_dummy() || self.panel.free_pos2 { return Vec::new(); } let breaks = scale.numeric_breaks(); @@ -426,7 +443,7 @@ impl PolarProjection { let Some(scale) = scales.iter().find(|s| s.aesthetic == "pos1") else { return Vec::new(); }; - if scale.is_dummy() { + if scale.is_dummy() || self.panel.free_pos1 { return Vec::new(); } let break_labels = scale.break_labels(); @@ -566,7 +583,7 @@ impl PolarProjection { let Some(scale) = scales.iter().find(|s| s.aesthetic == "pos2") else { return Vec::new(); }; - if scale.is_dummy() { + if scale.is_dummy() || self.panel.free_pos2 { return Vec::new(); } let break_labels = scale.break_labels(); @@ -879,9 +896,22 @@ fn convert_geoms_to_polar(panel: &PolarPanel, spec: &Plot, vl_spec: &mut Value) /// 3. Replace radius/theta with x/y encoding channels fn convert_polar_to_cartesian(layer: &mut Value, panel: &PolarPanel) -> Result<()> { // Phase 1: Extract info from encoding (immutable read) - let (r_val, r_field, r_domain, r_title, r_discrete, - theta_val, theta_field, theta_domain, theta_title, theta_discrete, - r2_field, theta2_field, r_offset_field, theta_offset_field) = { + let ( + r_val, + r_field, + r_domain, + r_title, + r_discrete, + theta_val, + theta_field, + theta_domain, + 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()) @@ -892,16 +922,27 @@ fn convert_polar_to_cartesian(layer: &mut Value, panel: &PolarPanel) -> Result<( let (theta_val, theta_field, theta_domain, theta_title, theta_disc) = extract_polar_channel(encoding, "theta")?; let field_of = |channel: &str| { - encoding.get(channel) + 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"), + 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"), ) }; @@ -959,7 +1000,10 @@ fn convert_polar_to_cartesian(layer: &mut Value, panel: &PolarPanel) -> Result<( 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 + r_scale * bw, + f, + off_min, + off_max - off_min ); } else { pixel_offsets.push((f.clone(), true)); @@ -972,10 +1016,17 @@ fn convert_polar_to_cartesian(layer: &mut Value, panel: &PolarPanel) -> Result<( } else { 0.0 }; - let bw = if theta_discrete { POLAR_BAND_FRACTION } else { 1.0 }; + let bw = if theta_discrete { + POLAR_BAND_FRACTION + } else { + 1.0 + }; theta_final = format!( "datum.__polar_theta__ + {} * ((datum['{}'] - {}) / {} - 0.5)", - t_scale * bw, f, off_min, off_max - off_min + t_scale * bw, + f, + off_min, + off_max - off_min ); } else { pixel_offsets.push((f.clone(), false)); @@ -1119,10 +1170,16 @@ fn extract_polar_channel( .and_then(|d| d.as_array()); // Try numeric domain first - if let Some((min, max)) = domain_arr.and_then(|arr| { - Some((arr.first()?.as_f64()?, arr.get(1)?.as_f64()?)) - }) { - return Ok((format!("datum['{}']", field), field, (min, max), title, false)); + if let Some((min, max)) = + domain_arr.and_then(|arr| Some((arr.first()?.as_f64()?, arr.get(1)?.as_f64()?))) + { + return Ok(( + format!("datum['{}']", field), + field, + (min, max), + title, + false, + )); } // Discrete domain: string array → indexof + synthesized numeric domain @@ -1147,7 +1204,13 @@ fn extract_polar_channel( } // Fallback - Ok((format!("datum['{}']", field), field, (0.0, 1.0), title, false)) + Ok(( + format!("datum['{}']", field), + field, + (0.0, 1.0), + title, + false, + )) } /// Convert a mark type to its polar equivalent @@ -1277,6 +1340,13 @@ fn apply_polar_radius_range(encoding: &mut Value, panel: &PolarPanel) -> Result< #[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() { @@ -1292,7 +1362,7 @@ mod tests { let mut proj = Projection::polar(); proj.properties .insert("inner".to_string(), ParameterValue::Number(0.5)); - let panel = PolarPanel::new(Some(&proj), false); + let panel = PolarPanel::new(Some(&proj), None); apply_polar_radius_range(&mut encoding, &panel).unwrap(); let range = encoding["radius"]["scale"]["range"].as_array().unwrap(); @@ -1323,7 +1393,8 @@ mod tests { .insert("inner".to_string(), ParameterValue::Number(0.5)); proj.properties .insert("size".to_string(), ParameterValue::Number(350.0)); - let panel = PolarPanel::new(Some(&proj), true); + let f = faceted(); + let panel = PolarPanel::new(Some(&proj), Some(&f)); apply_polar_radius_range(&mut encoding, &panel).unwrap(); let range = encoding["radius"]["scale"]["range"].as_array().unwrap(); @@ -1346,7 +1417,8 @@ mod tests { let mut proj = Projection::polar(); proj.properties .insert("size".to_string(), ParameterValue::Number(350.0)); - let panel = PolarPanel::new(Some(&proj), true); + let f = faceted(); + let panel = PolarPanel::new(Some(&proj), Some(&f)); apply_polar_radius_range(&mut encoding, &panel).unwrap(); // Range should be [0, 350/2] for full pie @@ -1386,7 +1458,7 @@ mod tests { #[test] fn test_map_position_to_vegalite_polar() { let renderer = PolarProjection { - panel: PolarPanel::new(None, false), + panel: PolarPanel::new(None, None), }; assert_eq!( map_position_to_vegalite("pos1", &renderer), @@ -1432,7 +1504,7 @@ mod tests { #[test] fn test_polar_to_cartesian_pixel_coordinates() { let mut layer = polar_point_layer(); - let panel = PolarPanel::new(None, false); + let panel = PolarPanel::new(None, None); convert_polar_to_cartesian(&mut layer, &panel).unwrap(); @@ -1471,7 +1543,7 @@ mod tests { #[test] fn test_polar_to_cartesian_filters_nulls() { let mut layer = polar_point_layer(); - let panel = PolarPanel::new(None, false); + let panel = PolarPanel::new(None, None); convert_polar_to_cartesian(&mut layer, &panel).unwrap(); @@ -1490,17 +1562,17 @@ mod tests { #[test] fn test_get_projection_renderer() { - let cartesian = get_projection_renderer(None, false); + 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), false); + let polar = get_projection_renderer(Some(&polar_proj), None); assert_eq!(polar.position_channels(), ("radius", "theta")); } #[test] fn test_expr_normalize_radius() { - let panel = PolarPanel::new(None, false); + let panel = PolarPanel::new(None, None); // domain [0, 10], inner 0.2 — build a panel with inner=0.2 let mut p = panel; @@ -1530,7 +1602,7 @@ mod tests { use std::f64::consts::PI; // domain [0, 100], partial circle 90°–270° (π/2 to 3π/2) - let mut panel = PolarPanel::new(None, false); + let mut panel = PolarPanel::new(None, None); panel.start = PI / 2.0; panel.end = 3.0 * PI / 2.0; let expr = panel.expr_normalize_theta("datum.v", 0.0, 100.0); @@ -1564,7 +1636,7 @@ mod tests { vec![25.0, 50.0, 75.0], )]; let proj = PolarProjection { - panel: PolarPanel::new(None, false), + panel: PolarPanel::new(None, None), }; let theme = json!({"axis": {"gridColor": "#FFF", "gridWidth": 2}}); @@ -1600,7 +1672,7 @@ mod tests { fn test_grid_spokes() { let scales = vec![scale_with_breaks("pos2", (0.0, 60.0), vec![20.0, 40.0])]; let proj = PolarProjection { - panel: PolarPanel::new(None, false), + panel: PolarPanel::new(None, None), }; let theme = json!({"axis": {"gridColor": "#CCC", "gridWidth": 1}}); @@ -1636,7 +1708,7 @@ mod tests { vec![25.0, 50.0, 75.0], )]; let proj = PolarProjection { - panel: PolarPanel::new(None, false), + panel: PolarPanel::new(None, None), }; let theme = json!({ "axis": { @@ -1685,7 +1757,7 @@ mod tests { fn test_radial_axis_no_breaks() { let scales = vec![scale_with_breaks("pos1", (0.0, 100.0), vec![])]; let proj = PolarProjection { - panel: PolarPanel::new(None, false), + panel: PolarPanel::new(None, None), }; let theme = json!({"axis": {}}); @@ -1706,7 +1778,7 @@ mod tests { vec![15.0, 30.0, 45.0], )]; let proj = PolarProjection { - panel: PolarPanel::new(None, false), + panel: PolarPanel::new(None, None), }; let theme = json!({ "axis": { @@ -1766,7 +1838,7 @@ mod tests { fn test_angular_axis_no_breaks() { let scales = vec![scale_with_breaks("pos2", (0.0, 60.0), vec![])]; let proj = PolarProjection { - panel: PolarPanel::new(None, false), + panel: PolarPanel::new(None, None), }; let theme = json!({"axis": {}}); @@ -1779,6 +1851,80 @@ mod tests { 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: PolarPanel::new(None, Some(&f)), + }; + let theme = json!({"axis": {}}); + + assert!(proj.grid_rings(&scales, &theme).is_empty()); + assert!(proj.radial_axis(&scales, &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: PolarPanel::new(None, Some(&f)), + }; + let theme = json!({"axis": {}}); + + assert!(proj.grid_spokes(&scales, &theme).is_empty()); + assert!(proj.angular_axis(&scales, &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: PolarPanel::new(None, Some(&f)), + }; + let theme = json!({"axis": {}}); + + assert!(!proj.grid_rings(&scales, &theme).is_empty()); + assert!(!proj.grid_spokes(&scales, &theme).is_empty()); + assert!(!proj.radial_axis(&scales, &theme).is_empty()); + assert!(!proj.angular_axis(&scales, &theme).is_empty()); + } + // ========================================================================= // Discrete channel: indexof expression // ========================================================================= @@ -1804,7 +1950,7 @@ mod tests { #[test] fn test_discrete_theta_uses_indexof() { let mut layer = discrete_theta_layer(); - let panel = PolarPanel::new(None, false); + let panel = PolarPanel::new(None, None); convert_polar_to_cartesian(&mut layer, &panel).unwrap(); @@ -1841,7 +1987,7 @@ mod tests { } } }); - let panel = PolarPanel::new(None, false); + let panel = PolarPanel::new(None, None); convert_polar_to_cartesian(&mut layer, &panel).unwrap(); @@ -1860,7 +2006,7 @@ mod tests { #[test] fn test_discrete_theta_synthesizes_domain() { let mut layer = discrete_theta_layer(); - let panel = PolarPanel::new(None, false); + let panel = PolarPanel::new(None, None); convert_polar_to_cartesian(&mut layer, &panel).unwrap(); @@ -1902,7 +2048,7 @@ mod tests { } } }); - let panel = PolarPanel::new(None, false); + let panel = PolarPanel::new(None, None); convert_polar_to_cartesian(&mut layer, &panel).unwrap(); @@ -1939,7 +2085,7 @@ mod tests { } } }); - let panel = PolarPanel::new(None, false); + let panel = PolarPanel::new(None, None); convert_polar_to_cartesian(&mut layer, &panel).unwrap(); @@ -1983,7 +2129,7 @@ mod tests { } } }); - let panel = PolarPanel::new(None, false); + let panel = PolarPanel::new(None, None); convert_polar_to_cartesian(&mut layer, &panel).unwrap(); @@ -2021,7 +2167,7 @@ mod tests { } } }); - let panel = PolarPanel::new(None, false); + let panel = PolarPanel::new(None, None); convert_polar_to_cartesian(&mut layer, &panel).unwrap(); @@ -2062,7 +2208,7 @@ mod tests { } } }); - let panel = PolarPanel::new(None, false); + let panel = PolarPanel::new(None, None); convert_polar_to_cartesian(&mut layer, &panel).unwrap(); @@ -2102,7 +2248,7 @@ mod tests { } } }); - let panel = PolarPanel::new(None, false); + let panel = PolarPanel::new(None, None); convert_polar_to_cartesian(&mut layer, &panel).unwrap(); @@ -2148,12 +2294,16 @@ mod tests { fn test_radial_axis_discrete_labels() { let scales = vec![discrete_scale_for_axis("pos1", &["low", "mid", "high"])]; let proj = PolarProjection { - panel: PolarPanel::new(None, false), + panel: PolarPanel::new(None, None), }; let theme = json!({"axis": {}}); let layers = proj.radial_axis(&scales, &theme); - assert_eq!(layers.len(), 3, "should produce axis line, ticks, and labels"); + 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]; @@ -2177,12 +2327,16 @@ mod tests { fn test_angular_axis_discrete_labels() { let scales = vec![discrete_scale_for_axis("pos2", &["Mon", "Tue", "Wed"])]; let proj = PolarProjection { - panel: PolarPanel::new(None, false), + panel: PolarPanel::new(None, None), }; let theme = json!({"axis": {}}); let layers = proj.angular_axis(&scales, &theme); - assert_eq!(layers.len(), 3, "should produce axis arc, ticks, and labels"); + assert_eq!( + layers.len(), + 3, + "should produce axis arc, ticks, and labels" + ); // Label data should carry category names let labels = &layers[2]; @@ -2201,7 +2355,7 @@ mod tests { fn test_single_category_discrete_grid_spokes() { let scales = vec![discrete_scale_for_axis("pos2", &["only"])]; let proj = PolarProjection { - panel: PolarPanel::new(None, false), + panel: PolarPanel::new(None, None), }; let theme = json!({"axis": {}}); @@ -2217,7 +2371,7 @@ mod tests { fn test_single_category_discrete_angular_axis() { let scales = vec![discrete_scale_for_axis("pos2", &["only"])]; let proj = PolarProjection { - panel: PolarPanel::new(None, false), + panel: PolarPanel::new(None, None), }; let theme = json!({"axis": {}}); @@ -2239,7 +2393,8 @@ mod tests { let mut proj = Projection::polar(); proj.properties .insert("size".to_string(), ParameterValue::Number(300.0)); - let panel = PolarPanel::new(Some(&proj), true); + let f = faceted(); + let panel = PolarPanel::new(Some(&proj), Some(&f)); // Faceted panel should use literal pixel values, not width/height signals assert_eq!(panel.cx, "150"); @@ -2254,8 +2409,9 @@ mod tests { proj_spec .properties .insert("size".to_string(), ParameterValue::Number(300.0)); + let f = faceted(); let proj = PolarProjection { - panel: PolarPanel::new(Some(&proj_spec), true), + panel: PolarPanel::new(Some(&proj_spec), Some(&f)), }; let theme = json!({"axis": {}}); @@ -2275,8 +2431,9 @@ mod tests { #[test] fn test_faceted_polar_panel_size() { let proj = Projection::polar(); + let f = faceted(); let renderer = PolarProjection { - panel: PolarPanel::new(Some(&proj), true), + panel: PolarPanel::new(Some(&proj), Some(&f)), }; assert_eq!( renderer.panel_size(), From 34c13d9b3f9151519b0d1936ced233c1ebf0b5dc Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Thu, 30 Apr 2026 13:56:52 +0200 Subject: [PATCH 22/35] Add radar property to polar projection Nullable boolean `radar` setting on PROJECT TO polar. When null (default), auto-detects from theta scale discreteness. When explicitly true, validates that the angle scale is discrete. Resolved after scale resolution in resolve_projection_properties() so downstream code can read it as a plain boolean. Co-Authored-By: Claude Opus 4.6 --- src/execute/mod.rs | 8 ++ src/plot/projection/coord/polar.rs | 8 +- src/plot/projection/mod.rs | 2 +- src/plot/projection/resolve.rs | 139 ++++++++++++++++++++++++++++- src/writer/vegalite/projection.rs | 14 +++ 5 files changed, 166 insertions(+), 5 deletions(-) diff --git a/src/execute/mod.rs b/src/execute/mod.rs index 8a1a3911..f08e22e0 100644 --- a/src/execute/mod.rs +++ b/src/execute/mod.rs @@ -26,6 +26,7 @@ use crate::naming; 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::projection::resolve_projection_properties; use crate::plot::layer::is_transposed; use crate::plot::{AestheticValue, Layer, Scale, ScaleTypeKind, Schema}; use crate::{DataFrame, DataSource, GgsqlError, Plot, Result}; @@ -1413,6 +1414,13 @@ pub fn prepare_data_with_reader(query: &str, reader: &dyn Reader) -> Result &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_is_discrete = scales + .iter() + .find(|s| s.aesthetic == "pos2") + .and_then(|s| s.scale_type.as_ref()) + .is_some_and(|st| { + matches!( + st.scale_type_kind(), + ScaleTypeKind::Discrete | ScaleTypeKind::Ordinal + ) + }); + + 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(_)) => { + // Explicit true (valid) or false — keep as-is + } + _ => { + // Null / absent — auto-detect + project.properties.insert( + "radar".to_string(), + ParameterValue::Boolean(theta_is_discrete), + ); + } + } + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; - use crate::plot::AestheticValue; + use crate::plot::{AestheticValue, ScaleType}; /// Helper to create Mappings with given aesthetic names fn mappings_with(aesthetics: &[&str]) -> Mappings { @@ -349,6 +400,88 @@ 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 + } + + #[test] + fn test_radar_auto_true_for_discrete_theta() { + 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_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_explicit_true_with_discrete_ok() { + let mut proj = Projection::polar(); + proj.properties + .insert("radar".to_string(), ParameterValue::Boolean(true)); + 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_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.get("radar").is_none()); + } + #[test] fn test_strip_position_suffix() { assert_eq!(strip_position_suffix("x"), "x"); diff --git a/src/writer/vegalite/projection.rs b/src/writer/vegalite/projection.rs index b2629893..630cd134 100644 --- a/src/writer/vegalite/projection.rs +++ b/src/writer/vegalite/projection.rs @@ -203,6 +203,8 @@ struct PolarPanel { 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, @@ -231,6 +233,13 @@ impl PolarPanel { 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 { @@ -251,6 +260,7 @@ impl PolarPanel { end, inner, outer: POLAR_OUTER, + radar, size, cx, cy, @@ -260,6 +270,10 @@ impl PolarPanel { } } + 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) } From 727983d0a818095bc24b7057e28fc78a89402b9a Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Thu, 30 Apr 2026 14:34:01 +0200 Subject: [PATCH 23/35] Render radar polygons for polar decorations When radar mode is active (discrete theta), panel background, grid rings, and angular axis outline use straight-segment polygons instead of circular arcs. Shared helpers: arc_ring, polygon_ring, theta_breaks. Donut panels (inner > 0) trace outer vertices forward then inner reversed so the fill rule leaves the centre hole empty. Co-Authored-By: Claude Opus 4.6 --- src/writer/vegalite/projection.rs | 359 +++++++++++++++++++++++++++--- 1 file changed, 327 insertions(+), 32 deletions(-) diff --git a/src/writer/vegalite/projection.rs b/src/writer/vegalite/projection.rs index 630cd134..eb8b709f 100644 --- a/src/writer/vegalite/projection.rs +++ b/src/writer/vegalite/projection.rs @@ -274,6 +274,11 @@ impl PolarPanel { matches!(self.radar, Some(true)) } + fn numeric_normalize_theta(&self, value: f64, domain_min: f64, domain_max: f64) -> f64 { + let scale = (self.end - self.start) / (domain_max - domain_min); + self.start + scale * (value - domain_min) + } + fn expr_x(&self, r: &str, theta: &str) -> String { format!("{} + {} * ({}) * sin({})", self.cx, self.radius, r, theta) } @@ -335,7 +340,7 @@ impl ProjectionRenderer for PolarProjection { fn background_layers(&self, scales: &[Scale], theme: &mut Value) -> Vec { let mut layers = Vec::new(); - layers.extend(self.panel_arc(theme)); + layers.extend(self.panel_arc(scales, theme)); layers.extend(self.grid_rings(scales, theme)); layers.extend(self.grid_spokes(scales, theme)); layers @@ -379,6 +384,22 @@ impl PolarProjection { .unwrap_or(json!(1)); let p = &self.panel; + if p.is_radar() { + let thetas = theta_breaks(p, scales); + if thetas.is_empty() { + return Vec::new(); + } + return 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, &thetas, Value::Null, color.clone()); + layer["mark"]["strokeWidth"] = width.clone(); + layer + }) + .collect(); + } + let values: Vec = breaks.iter().map(|&b| json!({"v": b})).collect(); let r_norm = p.expr_normalize_radius("datum.v", domain_min, domain_max); let radius_expr = p.expr_radius(&r_norm); @@ -632,24 +653,23 @@ impl PolarProjection { let p = &self.panel; let mut layers = Vec::new(); - // Axis arc along the outer edge + // Axis line along the outer edge let outer_s = format!("{}", p.outer); - let radius_expr = p.expr_radius(&outer_s); - layers.push(json!({ - "data": {"values": [{}]}, - "mark": { - "type": "arc", - "fill": null, - "stroke": line_color, - "theta": p.start, - "theta2": p.end, - }, - "encoding": { - "radius": { - "value": {"expr": radius_expr} - } + if p.is_radar() { + let thetas = theta_breaks(p, scales); + if !thetas.is_empty() { + layers.push(polygon_ring( + p, + p.outer, + None, + &thetas, + Value::Null, + line_color.clone(), + )); } - })); + } else { + layers.push(arc_ring(p, &outer_s, None, Value::Null, line_color.clone())); + } if break_labels.is_empty() { return layers; @@ -770,7 +790,7 @@ impl PolarProjection { layers } - fn panel_arc(&self, theme: &mut Value) -> Vec { + fn panel_arc(&self, scales: &[Scale], theme: &mut Value) -> Vec { let Some(view) = theme.get_mut("view").and_then(|v| v.as_object_mut()) else { return Vec::new(); }; @@ -784,24 +804,156 @@ impl PolarProjection { 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 + }; - let mut mark = json!({ - "type": "arc", - "fill": fill, - "stroke": stroke, - "theta": p.start, - "theta2": p.end, - }); - if p.inner > 0.0 { - mark["innerRadius"] = json!({"expr": p.expr_radius(&inner_s)}); + if p.is_radar() { + let thetas = theta_breaks(p, scales); + if thetas.is_empty() { + return Vec::new(); + } + return vec![polygon_ring( + p, + p.outer, + inner.map(|_| p.inner), + &thetas, + fill, + stroke, + )]; } - mark["outerRadius"] = json!({"expr": p.expr_radius(&outer_s)}); - vec![json!({ - "data": {"values": [{}]}, - "mark": mark - })] + vec![arc_ring(p, &outer_s, inner, fill, stroke)] + } +} + +// ============================================================================= +// Shared helpers +// ============================================================================= + +// ============================================================================= +// Polar decoration helpers +// ============================================================================= + +/// Convert pos2 scale breaks to radian angles. +fn theta_breaks(panel: &PolarPanel, scales: &[Scale]) -> Vec { + let Some(scale) = scales.iter().find(|s| s.aesthetic == "pos2") else { + return Vec::new(); + }; + let breaks = scale.numeric_breaks(); + let Some((domain_min, domain_max)) = scale.numeric_domain() else { + return Vec::new(); + }; + if breaks.is_empty() || (domain_max - domain_min).abs() < f64::EPSILON { + return Vec::new(); + } + breaks + .iter() + .map(|&b| panel.numeric_normalize_theta(b, domain_min, domain_max)) + .collect() +} + +/// 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: &PolarPanel, + 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: &PolarPanel, + outer_radius: f64, + inner_radius: Option, + thetas: &[f64], + fill: Value, + stroke: Value, +) -> Value { + // A full circle needs to repeat 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 is_full_circle = + (panel.end - panel.start - 2.0 * std::f64::consts::PI).abs() < f64::EPSILON; + + let mut vertices: Vec<(f64, f64)> = Vec::new(); + + // Outer ring — forward + for &theta in thetas { + vertices.push((outer_radius, theta)); + } + if is_full_circle { + if let Some(&first) = thetas.first() { + vertices.push((outer_radius, first)); + } + } + + // Inner ring — reversed (donut mode) + if let Some(inner) = inner_radius { + for &theta in thetas.iter().rev() { + vertices.push((inner, theta)); + } + if is_full_circle { + if let Some(&last) = thetas.last() { + vertices.push((inner, last)); + } + } } + + 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"}, + } + }) } // ============================================================================= @@ -2454,4 +2606,147 @@ mod tests { Some((json!(DEFAULT_POLAR_SIZE), json!(DEFAULT_POLAR_SIZE))) ); } + + // ========================================================================= + // Radar decoration helpers + // ========================================================================= + + fn radar_panel() -> PolarPanel { + let mut proj = Projection::polar(); + proj.properties + .insert("radar".to_string(), ParameterValue::Boolean(true)); + PolarPanel::new(Some(&proj), None) + } + + #[test] + fn test_theta_breaks_from_discrete_scale() { + use std::f64::consts::PI; + let panel = PolarPanel::new(None, None); + let scales = vec![discrete_scale_for_axis("pos2", &["A", "B", "C"])]; + let thetas = theta_breaks(&panel, &scales); + 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_theta_breaks_empty_without_pos2() { + let panel = PolarPanel::new(None, None); + let scales = vec![scale_with_breaks("pos1", (0.0, 10.0), vec![5.0])]; + assert!(theta_breaks(&panel, &scales).is_empty()); + } + + #[test] + fn test_polygon_ring_closes_for_full_circle() { + let panel = PolarPanel::new(None, None); + let thetas = vec![1.0, 2.0, 3.0]; + let layer = polygon_ring(&panel, POLAR_OUTER, None, &thetas, 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_open_for_partial_arc() { + 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 panel = PolarPanel::new(Some(&proj), None); + let thetas = vec![0.0, PI / 4.0, PI / 2.0]; + let layer = polygon_ring(&panel, POLAR_OUTER, None, &thetas, Value::Null, json!("red")); + let values = layer["data"]["values"].as_array().unwrap(); + // No closing vertex for partial arc + assert_eq!(values.len(), 3); + } + + #[test] + fn test_polygon_ring_donut_has_both_rings() { + let panel = PolarPanel::new(None, None); + let thetas = vec![1.0, 2.0, 3.0]; + let layer = polygon_ring(&panel, POLAR_OUTER, Some(0.3), &thetas, 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 = PolarPanel::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 = PolarPanel::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 panel = radar_panel(); + let scales = vec![ + scale_with_breaks("pos1", (0.0, 100.0), vec![50.0]), + discrete_scale_for_axis("pos2", &["A", "B", "C"]), + ]; + let proj = PolarProjection { panel }; + let theme = json!({"axis": {}}); + let layers = proj.grid_rings(&scales, &theme); + assert_eq!(layers.len(), 1); + assert_eq!(layers[0]["mark"]["type"], "line"); + } + + #[test] + fn test_radar_panel_arc_produces_line_mark() { + let panel = radar_panel(); + let scales = vec![discrete_scale_for_axis("pos2", &["A", "B", "C"])]; + let proj = PolarProjection { panel }; + let mut theme = json!({"view": {"fill": "#EEE", "stroke": null}}); + let layers = proj.panel_arc(&scales, &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 panel = radar_panel(); + let scales = vec![discrete_scale_for_axis("pos2", &["A", "B", "C"])]; + let proj = PolarProjection { panel }; + let theme = json!({"axis": {"domainColor": "#333"}}); + let layers = proj.angular_axis(&scales, &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 panel = PolarPanel::new(None, None); + let scales = vec![scale_with_breaks( + "pos1", + (0.0, 100.0), + vec![50.0], + )]; + let proj = PolarProjection { panel }; + let theme = json!({"axis": {}}); + let layers = proj.grid_rings(&scales, &theme); + assert_eq!(layers.len(), 1); + assert_eq!(layers[0]["mark"]["type"], "arc"); + } } From 0c1b0801687758f269f123011aab4ced4091aee0 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Thu, 30 Apr 2026 14:51:34 +0200 Subject: [PATCH 24/35] Close partial-arc radar polygons with radial edges For partial circles (start != end - 360), polygon_ring now adds vertices at the start and end angles and traces back through the centre (or inner radius) to form a closed wedge shape. Co-Authored-By: Claude Opus 4.6 --- src/writer/vegalite/projection.rs | 55 ++++++++++++++++++++++--------- 1 file changed, 39 insertions(+), 16 deletions(-) diff --git a/src/writer/vegalite/projection.rs b/src/writer/vegalite/projection.rs index eb8b709f..7629079a 100644 --- a/src/writer/vegalite/projection.rs +++ b/src/writer/vegalite/projection.rs @@ -907,28 +907,48 @@ fn polygon_ring( let is_full_circle = (panel.end - panel.start - 2.0 * std::f64::consts::PI).abs() < f64::EPSILON; + let inner = inner_radius.unwrap_or(0.0); let mut vertices: Vec<(f64, f64)> = Vec::new(); - // Outer ring — forward - for &theta in thetas { - vertices.push((outer_radius, theta)); - } if 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)); } - } - - // Inner ring — reversed (donut mode) - if let Some(inner) = inner_radius { - for &theta in thetas.iter().rev() { - vertices.push((inner, theta)); - } - if is_full_circle { + // 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 { + // Partial arc: outer ring from start angle through breaks to + // end angle, then return via inner radius (or centre). + vertices.push((outer_radius, panel.start)); + for &theta in thetas { + vertices.push((outer_radius, theta)); + } + vertices.push((outer_radius, panel.end)); + + // Return path along inner radius (or single centre point) + if inner_radius.is_some() { + vertices.push((inner, panel.end)); + for &theta in thetas.iter().rev() { + vertices.push((inner, theta)); + } + vertices.push((inner, panel.start)); + } else { + vertices.push((inner, panel.end)); + vertices.push((inner, panel.start)); + } + // Close back to first vertex + vertices.push((outer_radius, panel.start)); } let values: Vec = vertices @@ -2652,7 +2672,7 @@ mod tests { } #[test] - fn test_polygon_ring_open_for_partial_arc() { + fn test_polygon_ring_closed_for_partial_arc() { use std::f64::consts::PI; let mut proj = Projection::polar(); proj.properties @@ -2660,11 +2680,14 @@ mod tests { proj.properties .insert("end".to_string(), ParameterValue::Number(180.0)); let panel = PolarPanel::new(Some(&proj), None); - let thetas = vec![0.0, PI / 4.0, PI / 2.0]; + let thetas = vec![0.5, 1.0, 1.5]; let layer = polygon_ring(&panel, POLAR_OUTER, None, &thetas, Value::Null, json!("red")); let values = layer["data"]["values"].as_array().unwrap(); - // No closing vertex for partial arc - assert_eq!(values.len(), 3); + // 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] From 30728af65df5cbca7b349ed3421222f6580ac651 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Thu, 30 Apr 2026 15:19:57 +0200 Subject: [PATCH 25/35] Correct radial axis placement for full-circle radar polygons The start angle bisects a polygon face, which sits at cos(half_span) of the circumscribed radius. Scale the axis line, ticks, and labels inward so they land on the polygon edge rather than beyond it. Co-Authored-By: Claude Opus 4.6 --- src/writer/vegalite/projection.rs | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/writer/vegalite/projection.rs b/src/writer/vegalite/projection.rs index 7629079a..5ae56479 100644 --- a/src/writer/vegalite/projection.rs +++ b/src/writer/vegalite/projection.rs @@ -512,11 +512,22 @@ impl PolarProjection { let p = &self.panel; let mut layers = Vec::new(); + let is_full_circle = (p.end - p.start - 2.0 * std::f64::consts::PI).abs() < f64::EPSILON; + + // In a full-circle radar, the start angle bisects a polygon face + // that is closer to the centre than the circumscribed radius. + // Scale radii by cos(half_span) so the axis lands on the edge. + let r_correction = if p.is_radar() && is_full_circle { + let thetas = theta_breaks(p, scales); + thetas.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); + let inner_s = format!("{}", p.inner * r_correction); let start_s = format!("{}", p.start); - let outer_s = format!("{}", p.outer); + let outer_s = format!("{}", p.outer * r_correction); layers.push(json!({ "data": {"values": [{}]}, "mark": { @@ -550,9 +561,13 @@ impl PolarProjection { .iter() .map(|(v, label)| json!({"v": v, "label": label})) .collect(); - let r_norm = p.expr_normalize_radius("datum.v", domain_min, domain_max); + let r_norm_raw = p.expr_normalize_radius("datum.v", domain_min, domain_max); + let r_norm = if r_correction < 1.0 { + format!("({r_norm_raw}) * {r_correction}") + } else { + r_norm_raw + }; - let is_full_circle = (p.end - p.start - 2.0 * std::f64::consts::PI).abs() < f64::EPSILON; let tick_just: f64 = if 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); From d5c935dc04ea872ce50477cfecba4ff5f47d171c Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Thu, 30 Apr 2026 15:47:19 +0200 Subject: [PATCH 26/35] Lerp theta offsets along polygon edges in radar mode In radar mode, theta offsets (e.g. jitter) now interpolate linearly toward the adjacent spoke instead of following a circular arc. This keeps displaced points inside the polygon panel boundaries. Co-Authored-By: Claude Opus 4.6 --- src/writer/vegalite/projection.rs | 64 +++++++++++++++++++++++++++---- 1 file changed, 56 insertions(+), 8 deletions(-) diff --git a/src/writer/vegalite/projection.rs b/src/writer/vegalite/projection.rs index 5ae56479..fd241b3f 100644 --- a/src/writer/vegalite/projection.rs +++ b/src/writer/vegalite/projection.rs @@ -1222,13 +1222,47 @@ fn convert_polar_to_cartesian(layer: &mut Value, panel: &PolarPanel) -> Result<( } else { 1.0 }; - theta_final = format!( - "datum.__polar_theta__ + {} * ((datum['{}'] - {}) / {} - 0.5)", - t_scale * bw, - f, - off_min, - off_max - off_min - ); + 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!( + "datum.__polar_theta__ + (datum.__polar_theta_off_t__ >= 0 ? {} : -{})", + step, step + ); + 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)); } @@ -1237,7 +1271,21 @@ fn convert_polar_to_cartesian(layer: &mut Value, panel: &PolarPanel) -> Result<( let mut x_expr = panel.expr_x(&r_final, &theta_final); let mut y_expr = panel.expr_y(&r_final, &theta_final); - // Raw pixel offsets applied after polar→cartesian conversion + // 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__)"); From 5cf4b21e635ef5bb89667661b1ba83abbc27f952 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Thu, 30 Apr 2026 16:04:28 +0200 Subject: [PATCH 27/35] Correct radar polygon and axis radii at partial-arc boundaries Partial-arc start/end vertices are now pulled inward by cos(angle_to_nearest_break) so boundary faces are flush with inter-break edges. The radial axis correction is extended from full-circle-only to all radar panels. Theta offset lerp targets are clamped to [start, end] so boundary spokes lerp toward the panel edge. Co-Authored-By: Claude Opus 4.6 --- src/writer/vegalite/projection.rs | 63 ++++++++++++++++++++++++------- 1 file changed, 49 insertions(+), 14 deletions(-) diff --git a/src/writer/vegalite/projection.rs b/src/writer/vegalite/projection.rs index fd241b3f..bf713a9d 100644 --- a/src/writer/vegalite/projection.rs +++ b/src/writer/vegalite/projection.rs @@ -514,10 +514,11 @@ impl PolarProjection { let mut layers = Vec::new(); let is_full_circle = (p.end - p.start - 2.0 * std::f64::consts::PI).abs() < f64::EPSILON; - // In a full-circle radar, the start angle bisects a polygon face - // that is closer to the centre than the circumscribed radius. - // Scale radii by cos(half_span) so the axis lands on the edge. - let r_correction = if p.is_radar() && is_full_circle { + // 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() { let thetas = theta_breaks(p, scales); thetas.first().map(|&t| (t - p.start).cos()).unwrap_or(1.0) } else { @@ -945,25 +946,36 @@ fn polygon_ring( } else { // Partial arc: outer ring from start angle through breaks to // end angle, then return via inner radius (or centre). - vertices.push((outer_radius, panel.start)); + // 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, panel.end)); + 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, panel.end)); + vertices.push((inner * end_correction, panel.end)); for &theta in thetas.iter().rev() { vertices.push((inner, theta)); } - vertices.push((inner, panel.start)); + vertices.push((inner * start_correction, panel.start)); } else { - vertices.push((inner, panel.end)); - vertices.push((inner, panel.start)); + vertices.push((inner * end_correction, panel.end)); + vertices.push((inner * start_correction, panel.start)); } // Close back to first vertex - vertices.push((outer_radius, panel.start)); + vertices.push((outer_radius * start_correction, panel.start)); } let values: Vec = vertices @@ -1238,8 +1250,8 @@ fn convert_polar_to_cartesian(layer: &mut Value, panel: &PolarPanel) -> Result<( // polygon edge — both endpoints are vertices. let step = t_scale; let target_theta = format!( - "datum.__polar_theta__ + (datum.__polar_theta_off_t__ >= 0 ? {} : -{})", - step, step + "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__"}), @@ -2736,7 +2748,6 @@ mod tests { #[test] fn test_polygon_ring_closed_for_partial_arc() { - use std::f64::consts::PI; let mut proj = Projection::polar(); proj.properties .insert("start".to_string(), ParameterValue::Number(0.0)); @@ -2753,6 +2764,30 @@ mod tests { 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 panel = PolarPanel::new(Some(&proj), None); + // One break at π/2 — half-step from both start (0) and end (π) + let thetas = vec![PI / 2.0]; + let layer = polygon_ring(&panel, POLAR_OUTER, None, &thetas, 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 panel = PolarPanel::new(None, None); From 4717c593054e430a1ac2be2fb4ab6d7d77e168c5 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Fri, 1 May 2026 09:23:23 +0200 Subject: [PATCH 28/35] add to docs --- doc/get_started/the_rest.qmd | 3 ++- doc/syntax/coord/polar.qmd | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) 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..987d825b 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 rader chart is having discrete data for the `angle` aesthetic. +You can turn off the radar chart by using `PROJECT TO polar SETTING radar => false`. \ No newline at end of file From b2937acf292fc93f1d1829f57d4fcbb33fe6bdce Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Fri, 1 May 2026 09:59:22 +0200 Subject: [PATCH 29/35] Require >2 angle categories for radar mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Radar plots with only 1–2 categories degenerate into a line or single axis, so suppress auto-detection and reject explicit `radar => true` when the theta scale has ≤2 levels. Co-Authored-By: Claude Opus 4.6 --- src/plot/projection/resolve.rs | 103 +++++++++++++++++++++++++++++---- 1 file changed, 92 insertions(+), 11 deletions(-) diff --git a/src/plot/projection/resolve.rs b/src/plot/projection/resolve.rs index 71061e13..87fe6319 100644 --- a/src/plot/projection/resolve.rs +++ b/src/plot/projection/resolve.rs @@ -145,9 +145,9 @@ pub fn resolve_projection_properties( return Ok(()); } - let theta_is_discrete = scales - .iter() - .find(|s| s.aesthetic == "pos2") + 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!( @@ -156,6 +156,10 @@ pub fn resolve_projection_properties( ) }); + 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( @@ -164,15 +168,22 @@ pub fn resolve_projection_properties( .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 - project.properties.insert( - "radar".to_string(), - ParameterValue::Boolean(theta_is_discrete), - ); + // 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)); } } @@ -182,7 +193,7 @@ pub fn resolve_projection_properties( #[cfg(test)] mod tests { use super::*; - use crate::plot::{AestheticValue, ScaleType}; + use crate::plot::{AestheticValue, ArrayElement, ScaleType}; /// Helper to create Mappings with given aesthetic names fn mappings_with(aesthetics: &[&str]) -> Mappings { @@ -414,10 +425,21 @@ mod tests { 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![scale_with_type("pos2", true)]; + let scales = vec![discrete_scale_with_n("pos2", 5)]; resolve_projection_properties(&mut proj, &scales).unwrap(); assert_eq!( proj.properties.get("radar"), @@ -436,12 +458,56 @@ mod tests { ); } + #[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![scale_with_type("pos2", true)]; + let scales = vec![discrete_scale_with_n("pos2", 4)]; resolve_projection_properties(&mut proj, &scales).unwrap(); assert_eq!( proj.properties.get("radar"), @@ -449,6 +515,21 @@ mod tests { ); } + #[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(); From 482ad82b7ad7960b8de51fc10bc32ee21ed98517 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Fri, 1 May 2026 10:57:28 +0200 Subject: [PATCH 30/35] bit of polishing --- doc/syntax/coord/polar.qmd | 4 +- src/writer/vegalite/mod.rs | 2 +- src/writer/vegalite/projection.rs | 157 +++++++++++++++++------------- 3 files changed, 95 insertions(+), 68 deletions(-) diff --git a/doc/syntax/coord/polar.qmd b/doc/syntax/coord/polar.qmd index 987d825b..24e8af75 100644 --- a/doc/syntax/coord/polar.qmd +++ b/doc/syntax/coord/polar.qmd @@ -121,5 +121,5 @@ WITH data(angle, radius) AS (VALUES VISUALISE angle, radius FROM data DRAW polygon ``` -The key to drawing a rader chart is having discrete data for the `angle` aesthetic. -You can turn off the radar chart by using `PROJECT TO polar SETTING radar => false`. \ No newline at end of file +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/writer/vegalite/mod.rs b/src/writer/vegalite/mod.rs index 83e3b86f..8229c4e2 100644 --- a/src/writer/vegalite/mod.rs +++ b/src/writer/vegalite/mod.rs @@ -1123,7 +1123,7 @@ impl Writer for VegaLiteWriter { projection.apply_projection(spec, first_df, &mut theme, &mut vl_spec)?; vl_spec["config"] = theme; - // 14. Serialize + // 12. Serialize serde_json::to_string_pretty(&vl_spec).map_err(|e| { GgsqlError::WriterError(format!("Failed to serialize Vega-Lite JSON: {}", e)) }) diff --git a/src/writer/vegalite/projection.rs b/src/writer/vegalite/projection.rs index bf713a9d..d311ad59 100644 --- a/src/writer/vegalite/projection.rs +++ b/src/writer/vegalite/projection.rs @@ -339,17 +339,19 @@ impl ProjectionRenderer for PolarProjection { } fn background_layers(&self, scales: &[Scale], theme: &mut Value) -> Vec { + let thetas = theta_breaks(&self.panel, scales); let mut layers = Vec::new(); - layers.extend(self.panel_arc(scales, theme)); - layers.extend(self.grid_rings(scales, theme)); + layers.extend(self.panel_arc(&thetas, theme)); + layers.extend(self.grid_rings(scales, &thetas, theme)); layers.extend(self.grid_spokes(scales, theme)); layers } fn foreground_layers(&self, scales: &[Scale], theme: &mut Value) -> Vec { + let thetas = theta_breaks(&self.panel, scales); let mut layers = Vec::new(); - layers.extend(self.radial_axis(scales, theme)); - layers.extend(self.angular_axis(scales, theme)); + layers.extend(self.radial_axis(scales, &thetas, theme)); + layers.extend(self.angular_axis(scales, &thetas, theme)); layers } } @@ -359,7 +361,7 @@ impl ProjectionRenderer for PolarProjection { // 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, scales: &[Scale], theme: &Value) -> Vec { + fn grid_rings(&self, scales: &[Scale], thetas: &[f64], theme: &Value) -> Vec { let Some(scale) = scales.iter().find(|s| s.aesthetic == "pos1") else { return Vec::new(); }; @@ -385,14 +387,14 @@ impl PolarProjection { let p = &self.panel; if p.is_radar() { - let thetas = theta_breaks(p, scales); if thetas.is_empty() { return Vec::new(); } return breaks .iter() .map(|&b| { - let r = p.inner + (p.outer - p.inner) * (b - domain_min) / (domain_max - domain_min); + let r = p.inner + + (p.outer - p.inner) * (b - domain_min) / (domain_max - domain_min); let mut layer = polygon_ring(p, r, None, &thetas, Value::Null, color.clone()); layer["mark"]["strokeWidth"] = width.clone(); layer @@ -474,7 +476,7 @@ impl PolarProjection { })] } - fn radial_axis(&self, scales: &[Scale], theme: &Value) -> Vec { + fn radial_axis(&self, scales: &[Scale], thetas: &[f64], theme: &Value) -> Vec { let Some(scale) = scales.iter().find(|s| s.aesthetic == "pos1") else { return Vec::new(); }; @@ -519,7 +521,6 @@ impl PolarProjection { // radius. Scale radii by cos(angle to nearest break) so the axis // lands on the edge. let r_correction = if p.is_radar() { - let thetas = theta_breaks(p, scales); thetas.first().map(|&t| (t - p.start).cos()).unwrap_or(1.0) } else { 1.0 @@ -630,7 +631,7 @@ impl PolarProjection { layers } - fn angular_axis(&self, scales: &[Scale], theme: &Value) -> Vec { + fn angular_axis(&self, scales: &[Scale], thetas: &[f64], theme: &Value) -> Vec { let Some(scale) = scales.iter().find(|s| s.aesthetic == "pos2") else { return Vec::new(); }; @@ -672,13 +673,12 @@ impl PolarProjection { // Axis line along the outer edge let outer_s = format!("{}", p.outer); if p.is_radar() { - let thetas = theta_breaks(p, scales); if !thetas.is_empty() { layers.push(polygon_ring( p, p.outer, None, - &thetas, + thetas, Value::Null, line_color.clone(), )); @@ -806,7 +806,7 @@ impl PolarProjection { layers } - fn panel_arc(&self, scales: &[Scale], theme: &mut Value) -> Vec { + fn panel_arc(&self, thetas: &[f64], theme: &mut Value) -> Vec { let Some(view) = theme.get_mut("view").and_then(|v| v.as_object_mut()) else { return Vec::new(); }; @@ -827,7 +827,6 @@ impl PolarProjection { }; if p.is_radar() { - let thetas = theta_breaks(p, scales); if thetas.is_empty() { return Vec::new(); } @@ -835,7 +834,7 @@ impl PolarProjection { p, p.outer, inner.map(|_| p.inner), - &thetas, + thetas, fill, stroke, )]; @@ -845,10 +844,6 @@ impl PolarProjection { } } -// ============================================================================= -// Shared helpers -// ============================================================================= - // ============================================================================= // Polar decoration helpers // ============================================================================= @@ -952,10 +947,7 @@ fn polygon_ring( .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); + 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 { @@ -1240,11 +1232,12 @@ fn convert_polar_to_cartesian(layer: &mut Value, panel: &PolarPanel) -> Result<( // 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__"}), + 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. @@ -1253,19 +1246,13 @@ fn convert_polar_to_cartesian(layer: &mut Value, panel: &PolarPanel) -> Result<( "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__"}), - ); + 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__"}), - ); + 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)", @@ -1901,7 +1888,8 @@ mod tests { }; let theme = json!({"axis": {"gridColor": "#FFF", "gridWidth": 2}}); - let layers = proj.grid_rings(&scales, &theme); + let thetas = theta_breaks(&proj.panel, &scales); + let layers = proj.grid_rings(&scales, &thetas, &theme); assert_eq!(layers.len(), 1, "should produce one layer"); let layer = &layers[0]; @@ -1980,7 +1968,8 @@ mod tests { } }); - let layers = proj.radial_axis(&scales, &theme); + let thetas = theta_breaks(&proj.panel, &scales); + let layers = proj.radial_axis(&scales, &thetas, &theme); assert_eq!( layers.len(), 3, @@ -2022,7 +2011,8 @@ mod tests { }; let theme = json!({"axis": {}}); - let layers = proj.radial_axis(&scales, &theme); + let thetas = theta_breaks(&proj.panel, &scales); + let layers = proj.radial_axis(&scales, &thetas, &theme); assert_eq!( layers.len(), 1, @@ -2050,7 +2040,8 @@ mod tests { } }); - let layers = proj.angular_axis(&scales, &theme); + let thetas = theta_breaks(&proj.panel, &scales); + let layers = proj.angular_axis(&scales, &thetas, &theme); assert_eq!( layers.len(), 3, @@ -2103,7 +2094,8 @@ mod tests { }; let theme = json!({"axis": {}}); - let layers = proj.angular_axis(&scales, &theme); + let thetas = theta_breaks(&proj.panel, &scales); + let layers = proj.angular_axis(&scales, &thetas, &theme); assert_eq!( layers.len(), 1, @@ -2147,8 +2139,9 @@ mod tests { }; let theme = json!({"axis": {}}); - assert!(proj.grid_rings(&scales, &theme).is_empty()); - assert!(proj.radial_axis(&scales, &theme).is_empty()); + let thetas = theta_breaks(&proj.panel, &scales); + assert!(proj.grid_rings(&scales, &thetas, &theme).is_empty()); + assert!(proj.radial_axis(&scales, &thetas, &theme).is_empty()); } #[test] @@ -2164,8 +2157,9 @@ mod tests { }; let theme = json!({"axis": {}}); + let thetas = theta_breaks(&proj.panel, &scales); assert!(proj.grid_spokes(&scales, &theme).is_empty()); - assert!(proj.angular_axis(&scales, &theme).is_empty()); + assert!(proj.angular_axis(&scales, &thetas, &theme).is_empty()); } #[test] @@ -2180,10 +2174,11 @@ mod tests { }; let theme = json!({"axis": {}}); - assert!(!proj.grid_rings(&scales, &theme).is_empty()); + let thetas = theta_breaks(&proj.panel, &scales); + assert!(!proj.grid_rings(&scales, &thetas, &theme).is_empty()); assert!(!proj.grid_spokes(&scales, &theme).is_empty()); - assert!(!proj.radial_axis(&scales, &theme).is_empty()); - assert!(!proj.angular_axis(&scales, &theme).is_empty()); + assert!(!proj.radial_axis(&scales, &thetas, &theme).is_empty()); + assert!(!proj.angular_axis(&scales, &thetas, &theme).is_empty()); } // ========================================================================= @@ -2559,7 +2554,8 @@ mod tests { }; let theme = json!({"axis": {}}); - let layers = proj.radial_axis(&scales, &theme); + let thetas = theta_breaks(&proj.panel, &scales); + let layers = proj.radial_axis(&scales, &thetas, &theme); assert_eq!( layers.len(), 3, @@ -2592,7 +2588,8 @@ mod tests { }; let theme = json!({"axis": {}}); - let layers = proj.angular_axis(&scales, &theme); + let thetas = theta_breaks(&proj.panel, &scales); + let layers = proj.angular_axis(&scales, &thetas, &theme); assert_eq!( layers.len(), 3, @@ -2636,7 +2633,8 @@ mod tests { }; let theme = json!({"axis": {}}); - let layers = proj.angular_axis(&scales, &theme); + let thetas = theta_breaks(&proj.panel, &scales); + let layers = proj.angular_axis(&scales, &thetas, &theme); assert_eq!(layers.len(), 3, "should produce arc, tick, and label"); let labels = &layers[2]; @@ -2676,7 +2674,8 @@ mod tests { }; let theme = json!({"axis": {}}); - let layers = proj.grid_rings(&scales, &theme); + let thetas = theta_breaks(&proj.panel, &scales); + let layers = proj.grid_rings(&scales, &thetas, &theme); assert_eq!(layers.len(), 1); // Radius expression should use literal pixels (150), not signals @@ -2739,7 +2738,14 @@ mod tests { fn test_polygon_ring_closes_for_full_circle() { let panel = PolarPanel::new(None, None); let thetas = vec![1.0, 2.0, 3.0]; - let layer = polygon_ring(&panel, POLAR_OUTER, None, &thetas, Value::Null, json!("red")); + let layer = polygon_ring( + &panel, + POLAR_OUTER, + None, + &thetas, + Value::Null, + json!("red"), + ); let values = layer["data"]["values"].as_array().unwrap(); // 3 thetas + 1 closing vertex = 4 assert_eq!(values.len(), 4); @@ -2755,7 +2761,14 @@ mod tests { .insert("end".to_string(), ParameterValue::Number(180.0)); let panel = PolarPanel::new(Some(&proj), None); let thetas = vec![0.5, 1.0, 1.5]; - let layer = polygon_ring(&panel, POLAR_OUTER, None, &thetas, Value::Null, json!("red")); + let layer = polygon_ring( + &panel, + POLAR_OUTER, + None, + &thetas, + 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); @@ -2775,7 +2788,14 @@ mod tests { let panel = PolarPanel::new(Some(&proj), None); // One break at π/2 — half-step from both start (0) and end (π) let thetas = vec![PI / 2.0]; - let layer = polygon_ring(&panel, POLAR_OUTER, None, &thetas, Value::Null, json!("red")); + let layer = polygon_ring( + &panel, + POLAR_OUTER, + None, + &thetas, + 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(); @@ -2792,7 +2812,14 @@ mod tests { fn test_polygon_ring_donut_has_both_rings() { let panel = PolarPanel::new(None, None); let thetas = vec![1.0, 2.0, 3.0]; - let layer = polygon_ring(&panel, POLAR_OUTER, Some(0.3), &thetas, json!("white"), Value::Null); + let layer = polygon_ring( + &panel, + POLAR_OUTER, + Some(0.3), + &thetas, + 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); @@ -2827,7 +2854,8 @@ mod tests { ]; let proj = PolarProjection { panel }; let theme = json!({"axis": {}}); - let layers = proj.grid_rings(&scales, &theme); + let thetas = theta_breaks(&proj.panel, &scales); + let layers = proj.grid_rings(&scales, &thetas, &theme); assert_eq!(layers.len(), 1); assert_eq!(layers[0]["mark"]["type"], "line"); } @@ -2838,7 +2866,8 @@ mod tests { let scales = vec![discrete_scale_for_axis("pos2", &["A", "B", "C"])]; let proj = PolarProjection { panel }; let mut theme = json!({"view": {"fill": "#EEE", "stroke": null}}); - let layers = proj.panel_arc(&scales, &mut theme); + let thetas = theta_breaks(&proj.panel, &scales); + let layers = proj.panel_arc(&thetas, &mut theme); assert_eq!(layers.len(), 1); assert_eq!(layers[0]["mark"]["type"], "line"); assert_eq!(layers[0]["mark"]["fill"], "#EEE"); @@ -2850,7 +2879,8 @@ mod tests { let scales = vec![discrete_scale_for_axis("pos2", &["A", "B", "C"])]; let proj = PolarProjection { panel }; let theme = json!({"axis": {"domainColor": "#333"}}); - let layers = proj.angular_axis(&scales, &theme); + let thetas = theta_breaks(&proj.panel, &scales); + let layers = proj.angular_axis(&scales, &thetas, &theme); assert!(!layers.is_empty()); // First layer should be the polygon outline, not an arc assert_eq!(layers[0]["mark"]["type"], "line"); @@ -2859,14 +2889,11 @@ mod tests { #[test] fn test_non_radar_grid_rings_still_use_arc() { let panel = PolarPanel::new(None, None); - let scales = vec![scale_with_breaks( - "pos1", - (0.0, 100.0), - vec![50.0], - )]; + let scales = vec![scale_with_breaks("pos1", (0.0, 100.0), vec![50.0])]; let proj = PolarProjection { panel }; let theme = json!({"axis": {}}); - let layers = proj.grid_rings(&scales, &theme); + let thetas = theta_breaks(&proj.panel, &scales); + let layers = proj.grid_rings(&scales, &thetas, &theme); assert_eq!(layers.len(), 1); assert_eq!(layers[0]["mark"]["type"], "arc"); } From c6dffcbb1b23b4ccb6f5409931f6b57a79d97528 Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Fri, 1 May 2026 13:31:34 +0200 Subject: [PATCH 31/35] Centralise scale info on PolarContext via AxisInfo struct MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce AxisInfo (domain, breaks, labels, is_free) built from scales at construction time. Zero-range domains normalize to None upfront, eliminating downstream guards. Add is_full_circle and angle_breaks_radians as derived fields on PolarContext. This simplifies expr_normalize_radius/theta, all decoration methods (grid_rings, grid_spokes, radial_axis, angular_axis, panel_arc), convert_polar_to_cartesian, and polygon_ring — which no longer need scale/domain/thetas parameters passed through call chains. Co-Authored-By: Claude Opus 4.6 --- src/writer/vegalite/encoding.rs | 8 +- src/writer/vegalite/layer.rs | 2 +- src/writer/vegalite/mod.rs | 6 +- src/writer/vegalite/projection.rs | 665 ++++++++++++++---------------- 4 files changed, 325 insertions(+), 356 deletions(-) diff --git a/src/writer/vegalite/encoding.rs b/src/writer/vegalite/encoding.rs index fa4c74b8..e7f3b53d 100644 --- a/src/writer/vegalite/encoding.rs +++ b/src/writer/vegalite/encoding.rs @@ -1040,7 +1040,7 @@ impl<'a> RenderContext<'a> { #[cfg(test)] pub fn default_for_test() -> Self { - let renderer = super::projection::get_projection_renderer(None, None); + let renderer = super::projection::get_projection_renderer(None, None, &[]); Self::new( &[], renderer.as_ref(), @@ -1234,7 +1234,7 @@ mod tests { let scales: Vec = vec![]; let ctx = RenderContext::new( &scales, - get_projection_renderer(None, None).as_ref(), + get_projection_renderer(None, None, &[]).as_ref(), AestheticContext::from_static(&["x", "y"], &[]), ); let err = ctx.get_extent("pos1").unwrap_err().to_string(); @@ -1249,7 +1249,7 @@ mod tests { let scales: Vec = vec![]; let ctx = RenderContext::new( &scales, - get_projection_renderer(None, None).as_ref(), + get_projection_renderer(None, None, &[]).as_ref(), AestheticContext::from_static(&["angle", "radius"], &[]), ); let err = ctx.get_extent("pos1").unwrap_err().to_string(); @@ -1266,7 +1266,7 @@ mod tests { let scales = vec![discrete_scale("pos2")]; let ctx = RenderContext::new( &scales, - get_projection_renderer(None, None).as_ref(), + 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 150c79ef..30c799a5 100644 --- a/src/writer/vegalite/layer.rs +++ b/src/writer/vegalite/layer.rs @@ -3636,7 +3636,7 @@ mod tests { use crate::plot::{ArrayElement, Scale}; use crate::writer::vegalite::projection::get_projection_renderer; - let cartesian = get_projection_renderer(None, None); + let cartesian = get_projection_renderer(None, None, &[]); // Test success case: continuous scale with numeric range let scales = vec![Scale { diff --git a/src/writer/vegalite/mod.rs b/src/writer/vegalite/mod.rs index 8229c4e2..927d364f 100644 --- a/src/writer/vegalite/mod.rs +++ b/src/writer/vegalite/mod.rs @@ -1052,7 +1052,7 @@ impl Writer for VegaLiteWriter { "$schema": self.schema }); // Get projection renderer (single instance used throughout) - let projection = get_projection_renderer(spec.project.as_ref(), spec.facet.as_ref()); + 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; @@ -1354,7 +1354,7 @@ mod tests { // Test with cartesian projection (None = default cartesian) let ctx = AestheticContext::from_static(&["x", "y"], &[]); - let cartesian = get_projection_renderer(None, None); + let cartesian = get_projection_renderer(None, None, &[]); let cart = cartesian.as_ref(); // Internal position names should map to Vega-Lite channel names based on projection @@ -1380,7 +1380,7 @@ mod tests { // 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 polar = get_projection_renderer(Some(&polar_proj), None, &[]); let pol = polar.as_ref(); let polar_ctx = AestheticContext::from_static(&["radius", "theta"], &[]); diff --git a/src/writer/vegalite/projection.rs b/src/writer/vegalite/projection.rs index d311ad59..05e1ba38 100644 --- a/src/writer/vegalite/projection.rs +++ b/src/writer/vegalite/projection.rs @@ -120,11 +120,12 @@ pub(super) trait ProjectionRenderer { 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: PolarPanel::new(project, facet), + panel: PolarContext::new(project, facet, scales), }), Some(CoordKind::Cartesian) | None => Box::new(CartesianProjection { is_faceted }), } @@ -191,13 +192,35 @@ const POLAR_OUTER: f64 = 1.0; /// `1 - paddingInner` for band scales, which is ~0.9). const POLAR_BAND_FRACTION: f64 = 0.9; -/// Pre-computed panel geometry for polar specs. +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, and VL expression strings for the -/// panel centre and radius. In non-faceted specs these reference the -/// `width`/`height` signals; in faceted specs they are literal pixel values -/// (VL signals don't resolve inside faceted inner specs). -struct PolarPanel { +/// 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, @@ -212,14 +235,21 @@ struct PolarPanel { radius: String, // Facet state is_faceted: bool, - /// pos1 (radius) has free/independent scales across facet panels - free_pos1: bool, - /// pos2 (theta) has free/independent scales across facet panels - free_pos2: bool, + radial: AxisInfo, + angle: AxisInfo, + + /// Angle break positions in radians (derived from angle breaks + domain). + angle_breaks_radians: Vec, + + is_full_circle: bool, } -impl PolarPanel { - fn new(project: Option<&Projection>, facet: Option<&crate::plot::Facet>) -> Self { +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 @@ -252,8 +282,23 @@ impl PolarPanel { "min(width, height) / 2".to_string(), ) }; - let free_pos1 = facet.is_some_and(|f| f.is_free("pos1")); - let free_pos2 = facet.is_some_and(|f| f.is_free("pos2")); + 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, @@ -265,8 +310,10 @@ impl PolarPanel { cx, cy, radius, - free_pos1, - free_pos2, + radial, + angle, + angle_breaks_radians, + is_full_circle, } } @@ -274,11 +321,6 @@ impl PolarPanel { matches!(self.radar, Some(true)) } - fn numeric_normalize_theta(&self, value: f64, domain_min: f64, domain_max: f64) -> f64 { - let scale = (self.end - self.start) / (domain_max - domain_min); - self.start + scale * (value - domain_min) - } - fn expr_x(&self, r: &str, theta: &str) -> String { format!("{} + {} * ({}) * sin({})", self.cx, self.radius, r, theta) } @@ -291,20 +333,30 @@ impl PolarPanel { format!("{} * ({})", self.radius, r) } - fn expr_normalize_radius(&self, value: &str, domain_min: f64, domain_max: f64) -> String { - let scale = (self.outer - self.inner) / (domain_max - domain_min); - format!("{} + {} * ({} - {})", self.inner, scale, value, domain_min) + 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, domain_min: f64, domain_max: f64) -> String { - let scale = (self.end - self.start) / (domain_max - domain_min); - format!("{} + {} * ({} - {})", self.start, scale, value, domain_min) + 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: PolarPanel, + panel: PolarContext, } impl ProjectionRenderer for PolarProjection { @@ -338,20 +390,18 @@ impl ProjectionRenderer for PolarProjection { apply_polar_project(&self.panel, spec, data, vl_spec) } - fn background_layers(&self, scales: &[Scale], theme: &mut Value) -> Vec { - let thetas = theta_breaks(&self.panel, scales); + fn background_layers(&self, _scales: &[Scale], theme: &mut Value) -> Vec { let mut layers = Vec::new(); - layers.extend(self.panel_arc(&thetas, theme)); - layers.extend(self.grid_rings(scales, &thetas, theme)); - layers.extend(self.grid_spokes(scales, theme)); + 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 thetas = theta_breaks(&self.panel, scales); + fn foreground_layers(&self, _scales: &[Scale], theme: &mut Value) -> Vec { let mut layers = Vec::new(); - layers.extend(self.radial_axis(scales, &thetas, theme)); - layers.extend(self.angular_axis(scales, &thetas, theme)); + layers.extend(self.radial_axis(theme)); + layers.extend(self.angular_axis(theme)); layers } } @@ -361,18 +411,15 @@ impl ProjectionRenderer for PolarProjection { // 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, scales: &[Scale], thetas: &[f64], theme: &Value) -> Vec { - let Some(scale) = scales.iter().find(|s| s.aesthetic == "pos1") else { - return Vec::new(); - }; - if scale.is_dummy() || self.panel.free_pos1 { + fn grid_rings(&self, theme: &Value) -> Vec { + let p = &self.panel; + if p.radial.is_free { return Vec::new(); } - let breaks = scale.numeric_breaks(); - let Some((domain_min, domain_max)) = scale.numeric_domain() else { + let Some((domain_min, domain_max)) = p.radial.domain else { return Vec::new(); }; - if breaks.is_empty() || (domain_max - domain_min).abs() < f64::EPSILON { + if p.radial.breaks.is_empty() { return Vec::new(); } @@ -384,26 +431,33 @@ impl PolarProjection { .pointer("/axis/gridWidth") .cloned() .unwrap_or(json!(1)); - let p = &self.panel; if p.is_radar() { - if thetas.is_empty() { + if p.angle_breaks_radians.is_empty() { return Vec::new(); } - return breaks + 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, &thetas, Value::Null, color.clone()); + let mut layer = polygon_ring( + p, + r, + None, + Value::Null, + color.clone(), + ); layer["mark"]["strokeWidth"] = width.clone(); layer }) .collect(); } - let values: Vec = breaks.iter().map(|&b| json!({"v": b})).collect(); - let r_norm = p.expr_normalize_radius("datum.v", domain_min, domain_max); + 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!({ @@ -424,18 +478,12 @@ impl PolarProjection { })] } - fn grid_spokes(&self, scales: &[Scale], theme: &Value) -> Vec { - let Some(scale) = scales.iter().find(|s| s.aesthetic == "pos2") else { - return Vec::new(); - }; - if scale.is_dummy() || self.panel.free_pos2 { + fn grid_spokes(&self, theme: &Value) -> Vec { + let p = &self.panel; + if p.angle.is_free || p.angle.domain.is_none() { return Vec::new(); } - let breaks = scale.numeric_breaks(); - let Some((domain_min, domain_max)) = scale.numeric_domain() else { - return Vec::new(); - }; - if breaks.is_empty() || (domain_max - domain_min).abs() < f64::EPSILON { + if p.angle.breaks.is_empty() { return Vec::new(); } @@ -447,10 +495,9 @@ impl PolarProjection { .pointer("/axis/gridWidth") .cloned() .unwrap_or(json!(1)); - let p = &self.panel; - let values: Vec = breaks.iter().map(|&b| json!({"v": b})).collect(); - let theta = p.expr_normalize_theta("datum.v", domain_min, domain_max); + 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); @@ -476,18 +523,12 @@ impl PolarProjection { })] } - fn radial_axis(&self, scales: &[Scale], thetas: &[f64], theme: &Value) -> Vec { - let Some(scale) = scales.iter().find(|s| s.aesthetic == "pos1") else { - return Vec::new(); - }; - if scale.is_dummy() || self.panel.free_pos1 { + fn radial_axis(&self, theme: &Value) -> Vec { + let p = &self.panel; + if p.radial.is_free { return Vec::new(); } - let break_labels = scale.break_labels(); - let Some((domain_min, domain_max)) = scale.numeric_domain() else { - return Vec::new(); - }; - if (domain_max - domain_min).abs() < f64::EPSILON { + if p.radial.domain.is_none() { return Vec::new(); } @@ -512,16 +553,17 @@ impl PolarProjection { .cloned() .unwrap_or(Value::Null); - let p = &self.panel; let mut layers = Vec::new(); - let is_full_circle = (p.end - p.start - 2.0 * std::f64::consts::PI).abs() < f64::EPSILON; // 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() { - thetas.first().map(|&t| (t - p.start).cos()).unwrap_or(1.0) + p.angle_breaks_radians + .first() + .map(|&t| (t - p.start).cos()) + .unwrap_or(1.0) } else { 1.0 }; @@ -550,7 +592,7 @@ impl PolarProjection { } })); - if break_labels.is_empty() { + if p.radial.labels.is_empty() { return layers; } @@ -559,18 +601,20 @@ impl PolarProjection { // 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 = break_labels + 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", domain_min, domain_max); + 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 is_full_circle { 0.5 } else { 0.0 }; + 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); @@ -631,20 +675,14 @@ impl PolarProjection { layers } - fn angular_axis(&self, scales: &[Scale], thetas: &[f64], theme: &Value) -> Vec { - let Some(scale) = scales.iter().find(|s| s.aesthetic == "pos2") else { - return Vec::new(); - }; - if scale.is_dummy() || self.panel.free_pos2 { + fn angular_axis(&self, theme: &Value) -> Vec { + let p = &self.panel; + if p.angle.is_free { return Vec::new(); } - let break_labels = scale.break_labels(); - let Some((domain_min, domain_max)) = scale.numeric_domain() else { + let Some((domain_min, domain_max)) = p.angle.domain else { return Vec::new(); }; - if (domain_max - domain_min).abs() < f64::EPSILON { - return Vec::new(); - } let tick_color = theme .pointer("/axis/tickColor") @@ -667,18 +705,16 @@ impl PolarProjection { .cloned() .unwrap_or(Value::Null); - let p = &self.panel; let mut layers = Vec::new(); // Axis line along the outer edge let outer_s = format!("{}", p.outer); if p.is_radar() { - if !thetas.is_empty() { + if !p.angle_breaks_radians.is_empty() { layers.push(polygon_ring( p, p.outer, None, - thetas, Value::Null, line_color.clone(), )); @@ -687,21 +723,22 @@ impl PolarProjection { layers.push(arc_ring(p, &outer_s, None, Value::Null, line_color.clone())); } - if break_labels.is_empty() { + 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 = break_labels + let values: Vec = p + .angle + .labels .iter() .map(|(v, label)| json!({"v": v, "label": label})) .collect(); - let theta = p.expr_normalize_theta("datum.v", domain_min, domain_max); + let theta = p.expr_normalize_theta("datum.v"); - let is_full_circle = (p.end - p.start - 2.0 * std::f64::consts::PI).abs() < f64::EPSILON; - let tick_just: f64 = if is_full_circle { 0.5 } else { 0.0 }; + 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); @@ -745,7 +782,7 @@ impl PolarProjection { let mut label_values = Vec::new(); let mut alignment_keys = std::collections::BTreeSet::new(); - for &(v, ref label) in &break_labels { + 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 { @@ -806,7 +843,7 @@ impl PolarProjection { layers } - fn panel_arc(&self, thetas: &[f64], theme: &mut Value) -> Vec { + 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(); }; @@ -827,14 +864,13 @@ impl PolarProjection { }; if p.is_radar() { - if thetas.is_empty() { + if p.angle_breaks_radians.is_empty() { return Vec::new(); } return vec![polygon_ring( p, p.outer, inner.map(|_| p.inner), - thetas, fill, stroke, )]; @@ -844,34 +880,12 @@ impl PolarProjection { } } -// ============================================================================= -// Polar decoration helpers -// ============================================================================= - -/// Convert pos2 scale breaks to radian angles. -fn theta_breaks(panel: &PolarPanel, scales: &[Scale]) -> Vec { - let Some(scale) = scales.iter().find(|s| s.aesthetic == "pos2") else { - return Vec::new(); - }; - let breaks = scale.numeric_breaks(); - let Some((domain_min, domain_max)) = scale.numeric_domain() else { - return Vec::new(); - }; - if breaks.is_empty() || (domain_max - domain_min).abs() < f64::EPSILON { - return Vec::new(); - } - breaks - .iter() - .map(|&b| panel.numeric_normalize_theta(b, domain_min, domain_max)) - .collect() -} - /// 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: &PolarPanel, + panel: &PolarContext, outer_radius: &str, inner_radius: Option<&str>, fill: Value, @@ -904,24 +918,22 @@ fn arc_ring( /// For a full circle the first vertex is repeated to close each ring. /// A partial arc leaves the endpoints unconnected. fn polygon_ring( - panel: &PolarPanel, + panel: &PolarContext, outer_radius: f64, inner_radius: Option, - thetas: &[f64], fill: Value, stroke: Value, ) -> Value { - // A full circle needs to repeat the first vertex to close the polygon. + // 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 is_full_circle = - (panel.end - panel.start - 2.0 * std::f64::consts::PI).abs() < f64::EPSILON; + let thetas = &panel.angle_breaks_radians; let inner = inner_radius.unwrap_or(0.0); let mut vertices: Vec<(f64, f64)> = Vec::new(); - if is_full_circle { + if panel.is_full_circle { // Outer ring at theta breaks, then repeat first to close for &theta in thetas { vertices.push((outer_radius, theta)); @@ -1044,7 +1056,7 @@ fn apply_clip_to_layers(vl_spec: &mut Value, clip: bool) { /// 2. Applies start/end angle range from PROJECT clause /// 3. Applies inner radius for donut charts fn apply_polar_project( - panel: &PolarPanel, + panel: &PolarContext, spec: &Plot, data: &DataFrame, vl_spec: &mut Value, @@ -1067,7 +1079,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(panel: &PolarPanel, spec: &Plot, vl_spec: &mut Value) -> Result<()> { +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") { @@ -1099,17 +1111,15 @@ fn convert_geoms_to_polar(panel: &PolarPanel, spec: &Plot, vl_spec: &mut Value) /// 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, panel: &PolarPanel) -> Result<()> { +fn convert_polar_to_cartesian(layer: &mut Value, panel: &PolarContext) -> Result<()> { // Phase 1: Extract info from encoding (immutable read) let ( r_val, r_field, - r_domain, r_title, r_discrete, theta_val, theta_field, - theta_domain, theta_title, theta_discrete, r2_field, @@ -1122,9 +1132,8 @@ fn convert_polar_to_cartesian(layer: &mut Value, panel: &PolarPanel) -> Result<( .and_then(|e| e.as_object()) .ok_or_else(|| GgsqlError::WriterError("Layer has no encoding object".to_string()))?; - let (r_val, r_field, r_domain, r_title, r_disc) = - extract_polar_channel(encoding, "radius")?; - let (theta_val, theta_field, theta_domain, theta_title, theta_disc) = + 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 @@ -1136,12 +1145,10 @@ fn convert_polar_to_cartesian(layer: &mut Value, panel: &PolarPanel) -> Result<( ( r_val, r_field, - r_domain, r_title, r_disc, theta_val, theta_field, - theta_domain, theta_title, theta_disc, field_of("radius2"), @@ -1151,9 +1158,6 @@ fn convert_polar_to_cartesian(layer: &mut Value, panel: &PolarPanel) -> Result<( ) }; - let (theta_min, theta_max) = theta_domain; - let (r_min, r_max) = r_domain; - let mut polar_transforms: Vec = Vec::new(); // Drop rows with null positions — Vega-Lite does this implicitly for @@ -1164,18 +1168,10 @@ fn convert_polar_to_cartesian(layer: &mut Value, panel: &PolarPanel) -> Result<( ) })); - let theta_expr = if (theta_max - theta_min).abs() > f64::EPSILON { - panel.expr_normalize_theta(&theta_val, theta_min, theta_max) - } else { - format!("{}", panel.start) - }; + let theta_expr = panel.expr_normalize_theta(&theta_val); polar_transforms.push(json!({"calculate": theta_expr, "as": "__polar_theta__"})); - let r_expr = if (r_max - r_min).abs() > f64::EPSILON { - panel.expr_normalize_radius(&r_val, r_min, r_max) - } else { - format!("{}", (panel.outer + panel.inner) / 2.0) - }; + 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. @@ -1197,10 +1193,9 @@ fn convert_polar_to_cartesian(layer: &mut Value, panel: &PolarPanel) -> Result<( if let Some(ref f) = r_offset_field { if let Some((off_min, off_max)) = offset_domain("radiusOffset") { - let r_scale = if (r_max - r_min).abs() > f64::EPSILON { - (panel.outer - panel.inner) / (r_max - r_min) - } else { - 0.0 + 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!( @@ -1216,10 +1211,9 @@ fn convert_polar_to_cartesian(layer: &mut Value, panel: &PolarPanel) -> Result<( } if let Some(ref f) = theta_offset_field { if let Some((off_min, off_max)) = offset_domain("thetaOffset") { - let t_scale = if (theta_max - theta_min).abs() > f64::EPSILON { - (panel.end - panel.start) / (theta_max - theta_min) - } else { - 0.0 + 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 @@ -1304,20 +1298,12 @@ fn convert_polar_to_cartesian(layer: &mut Value, panel: &PolarPanel) -> Result<( let has_theta2 = theta2_field.is_some(); if has_r2 || has_theta2 { let r2_expr = if let Some(ref f) = r2_field { - if (r_max - r_min).abs() > f64::EPSILON { - panel.expr_normalize_radius(&format!("datum['{}']", f), r_min, r_max) - } else { - format!("{}", (panel.outer + panel.inner) / 2.0) - } + panel.expr_normalize_radius(&format!("datum['{}']", f)) } else { "datum.__polar_r__".to_string() }; let theta2_expr = if let Some(ref f) = theta2_field { - if (theta_max - theta_min).abs() > f64::EPSILON { - panel.expr_normalize_theta(&format!("datum['{}']", f), theta_min, theta_max) - } else { - format!("{}", panel.start) - } + panel.expr_normalize_theta(&format!("datum['{}']", f)) } else { "datum.__polar_theta__".to_string() }; @@ -1389,14 +1375,13 @@ fn convert_polar_to_cartesian(layer: &mut Value, panel: &PolarPanel) -> Result<( /// Extract field name, numeric value expression, scale domain, and title from /// a polar encoding channel. /// -/// Returns `(value_expr, field, (domain_min, domain_max), optional_title, is_discrete)`. +/// 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` with a -/// synthesized numeric domain `(0.5, n + 0.5)`. +/// For discrete scales it is `indexof([...], datum['field']) + 1`. fn extract_polar_channel( encoding: &serde_json::Map, channel: &str, -) -> Result<(String, String, (f64, f64), Option, bool)> { +) -> Result<(String, String, Option, bool)> { let channel_enc = encoding.get(channel).ok_or_else(|| { GgsqlError::WriterError(format!( "Polar projection requires '{}' encoding channel", @@ -1417,48 +1402,35 @@ fn extract_polar_channel( .and_then(|s| s.get("domain")) .and_then(|d| d.as_array()); - // Try numeric domain first - if let Some((min, max)) = - domain_arr.and_then(|arr| Some((arr.first()?.as_f64()?, arr.get(1)?.as_f64()?))) + // 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, - (min, max), - title, - false, - )); + return Ok((format!("datum['{}']", field), field, title, false)); } - // Discrete domain: string array → indexof + synthesized numeric domain + // 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 n = strings.len(); let literal: String = strings .iter() .map(|s| format!("'{}'", s.replace('\'', "\\'"))) .collect::>() .join(","); - // indexof returns -1 for values not in the domain; map those to null 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, (0.5, n as f64 + 0.5), title, true)); + return Ok((expr, field, title, true)); } } // Fallback - Ok(( - format!("datum['{}']", field), - field, - (0.0, 1.0), - title, - false, - )) + Ok((format!("datum['{}']", field), field, title, false)) } /// Convert a mark type to its polar equivalent @@ -1504,7 +1476,7 @@ 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, panel: &PolarPanel) -> Result<()> { +fn apply_polar_angle_range(encoding: &mut Value, panel: &PolarContext) -> Result<()> { // Skip if default range (0 to 2π) let is_default = panel.start.abs() <= f64::EPSILON && (panel.end - 2.0 * std::f64::consts::PI).abs() <= f64::EPSILON; @@ -1544,7 +1516,7 @@ fn apply_polar_angle_range(encoding: &mut Value, panel: &PolarPanel) -> Result<( /// 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, panel: &PolarPanel) -> 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()))?; @@ -1610,7 +1582,7 @@ mod tests { let mut proj = Projection::polar(); proj.properties .insert("inner".to_string(), ParameterValue::Number(0.5)); - let panel = PolarPanel::new(Some(&proj), None); + let panel = PolarContext::new(Some(&proj), None, &[]); apply_polar_radius_range(&mut encoding, &panel).unwrap(); let range = encoding["radius"]["scale"]["range"].as_array().unwrap(); @@ -1642,7 +1614,7 @@ mod tests { proj.properties .insert("size".to_string(), ParameterValue::Number(350.0)); let f = faceted(); - let panel = PolarPanel::new(Some(&proj), Some(&f)); + 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(); @@ -1666,7 +1638,7 @@ mod tests { proj.properties .insert("size".to_string(), ParameterValue::Number(350.0)); let f = faceted(); - let panel = PolarPanel::new(Some(&proj), Some(&f)); + 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 @@ -1706,7 +1678,7 @@ mod tests { #[test] fn test_map_position_to_vegalite_polar() { let renderer = PolarProjection { - panel: PolarPanel::new(None, None), + panel: PolarContext::new(None, None, &[]), }; assert_eq!( map_position_to_vegalite("pos1", &renderer), @@ -1731,6 +1703,19 @@ mod tests { ); } + 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", @@ -1752,7 +1737,7 @@ mod tests { #[test] fn test_polar_to_cartesian_pixel_coordinates() { let mut layer = polar_point_layer(); - let panel = PolarPanel::new(None, None); + let mut panel = continuous_panel(); convert_polar_to_cartesian(&mut layer, &panel).unwrap(); @@ -1791,7 +1776,7 @@ mod tests { #[test] fn test_polar_to_cartesian_filters_nulls() { let mut layer = polar_point_layer(); - let panel = PolarPanel::new(None, None); + let mut panel = continuous_panel(); convert_polar_to_cartesian(&mut layer, &panel).unwrap(); @@ -1810,23 +1795,23 @@ mod tests { #[test] fn test_get_projection_renderer() { - let cartesian = get_projection_renderer(None, None); + 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); + let polar = get_projection_renderer(Some(&polar_proj), None, &[]); assert_eq!(polar.position_channels(), ("radius", "theta")); } #[test] fn test_expr_normalize_radius() { - let panel = PolarPanel::new(None, None); + let mut p = PolarContext::new(None, None, &[]); - // domain [0, 10], inner 0.2 — build a panel with inner=0.2 - let mut p = panel; + // 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", 0.0, 10.0); + let expr = p.expr_normalize_radius("datum.v"); assert!( expr.contains("0.08"), "scale factor should be 0.08, got: {expr}" @@ -1838,11 +1823,20 @@ mod tests { // domain [5, 15], inner 0 → scale = 1.0 / 10 = 0.1 p.inner = 0.0; - let expr = p.expr_normalize_radius("datum.x", 5.0, 15.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] @@ -1850,10 +1844,11 @@ mod tests { use std::f64::consts::PI; // domain [0, 100], partial circle 90°–270° (π/2 to 3π/2) - let mut panel = PolarPanel::new(None, None); + let mut panel = PolarContext::new(None, None, &[]); panel.start = PI / 2.0; panel.end = 3.0 * PI / 2.0; - let expr = panel.expr_normalize_theta("datum.v", 0.0, 100.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!( @@ -1883,13 +1878,10 @@ mod tests { (0.0, 100.0), vec![25.0, 50.0, 75.0], )]; - let proj = PolarProjection { - panel: PolarPanel::new(None, None), - }; + let proj = polar_proj(&scales); let theme = json!({"axis": {"gridColor": "#FFF", "gridWidth": 2}}); - let thetas = theta_breaks(&proj.panel, &scales); - let layers = proj.grid_rings(&scales, &thetas, &theme); + let layers = proj.grid_rings(&theme); assert_eq!(layers.len(), 1, "should produce one layer"); let layer = &layers[0]; @@ -1920,12 +1912,10 @@ mod tests { #[test] fn test_grid_spokes() { let scales = vec![scale_with_breaks("pos2", (0.0, 60.0), vec![20.0, 40.0])]; - let proj = PolarProjection { - panel: PolarPanel::new(None, None), - }; + let proj = polar_proj(&scales); let theme = json!({"axis": {"gridColor": "#CCC", "gridWidth": 1}}); - let layers = proj.grid_spokes(&scales, &theme); + let layers = proj.grid_spokes(&theme); assert_eq!(layers.len(), 1, "should produce one layer"); let layer = &layers[0]; @@ -1956,9 +1946,7 @@ mod tests { (0.0, 100.0), vec![25.0, 50.0, 75.0], )]; - let proj = PolarProjection { - panel: PolarPanel::new(None, None), - }; + let proj = polar_proj(&scales); let theme = json!({ "axis": { "tickColor": "#333", @@ -1968,8 +1956,7 @@ mod tests { } }); - let thetas = theta_breaks(&proj.panel, &scales); - let layers = proj.radial_axis(&scales, &thetas, &theme); + let layers = proj.radial_axis(&theme); assert_eq!( layers.len(), 3, @@ -2006,13 +1993,10 @@ mod tests { #[test] fn test_radial_axis_no_breaks() { let scales = vec![scale_with_breaks("pos1", (0.0, 100.0), vec![])]; - let proj = PolarProjection { - panel: PolarPanel::new(None, None), - }; - let theme = json!({"axis": {}}); + let proj = polar_proj(&scales); + let theme = Value::Null; - let thetas = theta_breaks(&proj.panel, &scales); - let layers = proj.radial_axis(&scales, &thetas, &theme); + let layers = proj.radial_axis(&theme); assert_eq!( layers.len(), 1, @@ -2028,9 +2012,7 @@ mod tests { (0.0, 60.0), vec![15.0, 30.0, 45.0], )]; - let proj = PolarProjection { - panel: PolarPanel::new(None, None), - }; + let proj = polar_proj(&scales); let theme = json!({ "axis": { "tickColor": "#333", @@ -2040,8 +2022,7 @@ mod tests { } }); - let thetas = theta_breaks(&proj.panel, &scales); - let layers = proj.angular_axis(&scales, &thetas, &theme); + let layers = proj.angular_axis(&theme); assert_eq!( layers.len(), 3, @@ -2089,13 +2070,10 @@ mod tests { #[test] fn test_angular_axis_no_breaks() { let scales = vec![scale_with_breaks("pos2", (0.0, 60.0), vec![])]; - let proj = PolarProjection { - panel: PolarPanel::new(None, None), - }; - let theme = json!({"axis": {}}); + let proj = polar_proj(&scales); + let theme = Value::Null; - let thetas = theta_breaks(&proj.panel, &scales); - let layers = proj.angular_axis(&scales, &thetas, &theme); + let layers = proj.angular_axis(&theme); assert_eq!( layers.len(), 1, @@ -2135,13 +2113,12 @@ mod tests { )]; let f = facet_with_free(vec![true, false]); let proj = PolarProjection { - panel: PolarPanel::new(None, Some(&f)), + panel: PolarContext::new(None, Some(&f), &scales), }; - let theme = json!({"axis": {}}); + let theme = Value::Null; - let thetas = theta_breaks(&proj.panel, &scales); - assert!(proj.grid_rings(&scales, &thetas, &theme).is_empty()); - assert!(proj.radial_axis(&scales, &thetas, &theme).is_empty()); + assert!(proj.grid_rings(&theme).is_empty()); + assert!(proj.radial_axis(&theme).is_empty()); } #[test] @@ -2153,13 +2130,12 @@ mod tests { )]; let f = facet_with_free(vec![false, true]); let proj = PolarProjection { - panel: PolarPanel::new(None, Some(&f)), + panel: PolarContext::new(None, Some(&f), &scales), }; - let theme = json!({"axis": {}}); + let theme = Value::Null; - let thetas = theta_breaks(&proj.panel, &scales); - assert!(proj.grid_spokes(&scales, &theme).is_empty()); - assert!(proj.angular_axis(&scales, &thetas, &theme).is_empty()); + assert!(proj.grid_spokes(&theme).is_empty()); + assert!(proj.angular_axis(&theme).is_empty()); } #[test] @@ -2170,15 +2146,14 @@ mod tests { ]; let f = facet_with_free(vec![false, false]); let proj = PolarProjection { - panel: PolarPanel::new(None, Some(&f)), + panel: PolarContext::new(None, Some(&f), &scales), }; - let theme = json!({"axis": {}}); + let theme = Value::Null; - let thetas = theta_breaks(&proj.panel, &scales); - assert!(!proj.grid_rings(&scales, &thetas, &theme).is_empty()); - assert!(!proj.grid_spokes(&scales, &theme).is_empty()); - assert!(!proj.radial_axis(&scales, &thetas, &theme).is_empty()); - assert!(!proj.angular_axis(&scales, &thetas, &theme).is_empty()); + 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()); } // ========================================================================= @@ -2206,7 +2181,9 @@ mod tests { #[test] fn test_discrete_theta_uses_indexof() { let mut layer = discrete_theta_layer(); - let panel = PolarPanel::new(None, None); + 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(); @@ -2243,7 +2220,9 @@ mod tests { } } }); - let panel = PolarPanel::new(None, None); + 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(); @@ -2262,7 +2241,9 @@ mod tests { #[test] fn test_discrete_theta_synthesizes_domain() { let mut layer = discrete_theta_layer(); - let panel = PolarPanel::new(None, None); + 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(); @@ -2304,7 +2285,7 @@ mod tests { } } }); - let panel = PolarPanel::new(None, None); + let mut panel = continuous_panel(); convert_polar_to_cartesian(&mut layer, &panel).unwrap(); @@ -2341,7 +2322,7 @@ mod tests { } } }); - let panel = PolarPanel::new(None, None); + let mut panel = continuous_panel(); convert_polar_to_cartesian(&mut layer, &panel).unwrap(); @@ -2385,7 +2366,7 @@ mod tests { } } }); - let panel = PolarPanel::new(None, None); + let mut panel = continuous_panel(); convert_polar_to_cartesian(&mut layer, &panel).unwrap(); @@ -2423,7 +2404,7 @@ mod tests { } } }); - let panel = PolarPanel::new(None, None); + let mut panel = continuous_panel(); convert_polar_to_cartesian(&mut layer, &panel).unwrap(); @@ -2464,7 +2445,9 @@ mod tests { } } }); - let panel = PolarPanel::new(None, None); + 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(); @@ -2504,7 +2487,7 @@ mod tests { } } }); - let panel = PolarPanel::new(None, None); + let mut panel = continuous_panel(); convert_polar_to_cartesian(&mut layer, &panel).unwrap(); @@ -2549,13 +2532,10 @@ mod tests { #[test] fn test_radial_axis_discrete_labels() { let scales = vec![discrete_scale_for_axis("pos1", &["low", "mid", "high"])]; - let proj = PolarProjection { - panel: PolarPanel::new(None, None), - }; - let theme = json!({"axis": {}}); + let proj = polar_proj(&scales); + let theme = Value::Null; - let thetas = theta_breaks(&proj.panel, &scales); - let layers = proj.radial_axis(&scales, &thetas, &theme); + let layers = proj.radial_axis(&theme); assert_eq!( layers.len(), 3, @@ -2583,13 +2563,10 @@ mod tests { #[test] fn test_angular_axis_discrete_labels() { let scales = vec![discrete_scale_for_axis("pos2", &["Mon", "Tue", "Wed"])]; - let proj = PolarProjection { - panel: PolarPanel::new(None, None), - }; - let theme = json!({"axis": {}}); + let proj = polar_proj(&scales); + let theme = Value::Null; - let thetas = theta_breaks(&proj.panel, &scales); - let layers = proj.angular_axis(&scales, &thetas, &theme); + let layers = proj.angular_axis(&theme); assert_eq!( layers.len(), 3, @@ -2612,12 +2589,10 @@ mod tests { #[test] fn test_single_category_discrete_grid_spokes() { let scales = vec![discrete_scale_for_axis("pos2", &["only"])]; - let proj = PolarProjection { - panel: PolarPanel::new(None, None), - }; - let theme = json!({"axis": {}}); + let proj = polar_proj(&scales); + let theme = Value::Null; - let layers = proj.grid_spokes(&scales, &theme); + let layers = proj.grid_spokes(&theme); assert_eq!(layers.len(), 1, "should produce one spoke"); let values = layers[0]["data"]["values"].as_array().unwrap(); @@ -2628,13 +2603,10 @@ mod tests { #[test] fn test_single_category_discrete_angular_axis() { let scales = vec![discrete_scale_for_axis("pos2", &["only"])]; - let proj = PolarProjection { - panel: PolarPanel::new(None, None), - }; - let theme = json!({"axis": {}}); + let proj = polar_proj(&scales); + let theme = Value::Null; - let thetas = theta_breaks(&proj.panel, &scales); - let layers = proj.angular_axis(&scales, &thetas, &theme); + let layers = proj.angular_axis(&theme); assert_eq!(layers.len(), 3, "should produce arc, tick, and label"); let labels = &layers[2]; @@ -2653,7 +2625,7 @@ mod tests { proj.properties .insert("size".to_string(), ParameterValue::Number(300.0)); let f = faceted(); - let panel = PolarPanel::new(Some(&proj), Some(&f)); + let panel = PolarContext::new(Some(&proj), Some(&f), &[]); // Faceted panel should use literal pixel values, not width/height signals assert_eq!(panel.cx, "150"); @@ -2670,12 +2642,11 @@ mod tests { .insert("size".to_string(), ParameterValue::Number(300.0)); let f = faceted(); let proj = PolarProjection { - panel: PolarPanel::new(Some(&proj_spec), Some(&f)), + panel: PolarContext::new(Some(&proj_spec), Some(&f), &scales), }; - let theme = json!({"axis": {}}); + let theme = Value::Null; - let thetas = theta_breaks(&proj.panel, &scales); - let layers = proj.grid_rings(&scales, &thetas, &theme); + let layers = proj.grid_rings(&theme); assert_eq!(layers.len(), 1); // Radius expression should use literal pixels (150), not signals @@ -2693,7 +2664,7 @@ mod tests { let proj = Projection::polar(); let f = faceted(); let renderer = PolarProjection { - panel: PolarPanel::new(Some(&proj), Some(&f)), + panel: PolarContext::new(Some(&proj), Some(&f), &[]), }; assert_eq!( renderer.panel_size(), @@ -2705,19 +2676,12 @@ mod tests { // Radar decoration helpers // ========================================================================= - fn radar_panel() -> PolarPanel { - let mut proj = Projection::polar(); - proj.properties - .insert("radar".to_string(), ParameterValue::Boolean(true)); - PolarPanel::new(Some(&proj), None) - } - #[test] - fn test_theta_breaks_from_discrete_scale() { + fn test_angle_breaks_radians_from_discrete_scale() { use std::f64::consts::PI; - let panel = PolarPanel::new(None, None); let scales = vec![discrete_scale_for_axis("pos2", &["A", "B", "C"])]; - let thetas = theta_breaks(&panel, &scales); + 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) @@ -2728,21 +2692,20 @@ mod tests { } #[test] - fn test_theta_breaks_empty_without_pos2() { - let panel = PolarPanel::new(None, None); + fn test_angle_breaks_radians_empty_without_pos2() { let scales = vec![scale_with_breaks("pos1", (0.0, 10.0), vec![5.0])]; - assert!(theta_breaks(&panel, &scales).is_empty()); + let panel = PolarContext::new(None, None, &scales); + assert!(panel.angle_breaks_radians.is_empty()); } #[test] fn test_polygon_ring_closes_for_full_circle() { - let panel = PolarPanel::new(None, None); - let thetas = vec![1.0, 2.0, 3.0]; + 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, - &thetas, Value::Null, json!("red"), ); @@ -2759,13 +2722,12 @@ mod tests { .insert("start".to_string(), ParameterValue::Number(0.0)); proj.properties .insert("end".to_string(), ParameterValue::Number(180.0)); - let panel = PolarPanel::new(Some(&proj), None); - let thetas = vec![0.5, 1.0, 1.5]; + 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, - &thetas, Value::Null, json!("red"), ); @@ -2785,14 +2747,12 @@ mod tests { .insert("start".to_string(), ParameterValue::Number(0.0)); proj.properties .insert("end".to_string(), ParameterValue::Number(180.0)); - let panel = PolarPanel::new(Some(&proj), None); - // One break at π/2 — half-step from both start (0) and end (π) - let thetas = vec![PI / 2.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, - &thetas, Value::Null, json!("red"), ); @@ -2810,13 +2770,12 @@ mod tests { #[test] fn test_polygon_ring_donut_has_both_rings() { - let panel = PolarPanel::new(None, None); - let thetas = vec![1.0, 2.0, 3.0]; + 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), - &thetas, json!("white"), Value::Null, ); @@ -2829,7 +2788,7 @@ mod tests { #[test] fn test_arc_ring_basic() { - let panel = PolarPanel::new(None, None); + 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"); @@ -2838,7 +2797,7 @@ mod tests { #[test] fn test_arc_ring_with_inner_radius() { - let panel = PolarPanel::new(None, None); + 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()); @@ -2847,27 +2806,35 @@ mod tests { #[test] fn test_radar_grid_rings_produce_line_marks() { - let panel = radar_panel(); let scales = vec![ scale_with_breaks("pos1", (0.0, 100.0), vec![50.0]), discrete_scale_for_axis("pos2", &["A", "B", "C"]), ]; - let proj = PolarProjection { panel }; - let theme = json!({"axis": {}}); - let thetas = theta_breaks(&proj.panel, &scales); - let layers = proj.grid_rings(&scales, &thetas, &theme); + 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 panel = radar_panel(); let scales = vec![discrete_scale_for_axis("pos2", &["A", "B", "C"])]; - let proj = PolarProjection { panel }; + 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 thetas = theta_breaks(&proj.panel, &scales); - let layers = proj.panel_arc(&thetas, &mut theme); + 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"); @@ -2875,12 +2842,16 @@ mod tests { #[test] fn test_radar_angular_axis_produces_polygon_outline() { - let panel = radar_panel(); let scales = vec![discrete_scale_for_axis("pos2", &["A", "B", "C"])]; - let proj = PolarProjection { panel }; + 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 thetas = theta_breaks(&proj.panel, &scales); - let layers = proj.angular_axis(&scales, &thetas, &theme); + 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"); @@ -2888,12 +2859,10 @@ mod tests { #[test] fn test_non_radar_grid_rings_still_use_arc() { - let panel = PolarPanel::new(None, None); let scales = vec![scale_with_breaks("pos1", (0.0, 100.0), vec![50.0])]; - let proj = PolarProjection { panel }; - let theme = json!({"axis": {}}); - let thetas = theta_breaks(&proj.panel, &scales); - let layers = proj.grid_rings(&scales, &thetas, &theme); + 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"); } From e94d1ae50fa38d56fbea32c14e9e4293a7a20dde Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Fri, 1 May 2026 13:36:58 +0200 Subject: [PATCH 32/35] Remove dead DataFrame clone from apply_polar_project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The polar projection never transforms the DataFrame — it only modifies the VL spec. Drop the data parameter and Option return from transform_layers and apply_projection. Also fix 7 unnecessary mut bindings in tests. Co-Authored-By: Claude Opus 4.6 --- src/writer/vegalite/mod.rs | 3 +- src/writer/vegalite/projection.rs | 57 ++++++++++--------------------- 2 files changed, 19 insertions(+), 41 deletions(-) diff --git a/src/writer/vegalite/mod.rs b/src/writer/vegalite/mod.rs index 927d364f..14a00645 100644 --- a/src/writer/vegalite/mod.rs +++ b/src/writer/vegalite/mod.rs @@ -1118,9 +1118,8 @@ impl Writer for VegaLiteWriter { } // 11. Apply projection (transforms + panel decoration) - let first_df = data.get(&layer_data_keys[0]).unwrap(); let mut theme = self.default_theme_config(); - projection.apply_projection(spec, first_df, &mut theme, &mut vl_spec)?; + projection.apply_projection(spec, &mut theme, &mut vl_spec)?; vl_spec["config"] = theme; // 12. Serialize diff --git a/src/writer/vegalite/projection.rs b/src/writer/vegalite/projection.rs index 05e1ba38..7ac5f3c9 100644 --- a/src/writer/vegalite/projection.rs +++ b/src/writer/vegalite/projection.rs @@ -6,7 +6,7 @@ //! and the spec-level transformations for that projection. use crate::plot::{CoordKind, ParameterValue, Projection, Scale}; -use crate::{DataFrame, GgsqlError, Plot, Result}; +use crate::{GgsqlError, Plot, Result}; use serde_json::{json, Value}; use super::DEFAULT_POLAR_SIZE; @@ -50,15 +50,9 @@ pub(super) trait ProjectionRenderer { /// Apply projection-specific transformations to the VL spec. /// - /// Called after layers are built but before faceting. May return a - /// transformed DataFrame (e.g., polar currently clones it unchanged). - fn transform_layers( - &self, - _spec: &Plot, - _data: &DataFrame, - _vl_spec: &mut Value, - ) -> Result> { - Ok(None) + /// 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. @@ -75,11 +69,10 @@ pub(super) trait ProjectionRenderer { fn apply_projection( &self, spec: &Plot, - data: &DataFrame, theme: &mut Value, vl_spec: &mut Value, - ) -> Result> { - let result = self.transform_layers(spec, data, vl_spec)?; + ) -> Result<()> { + self.transform_layers(spec, vl_spec)?; if let Some(ref project) = spec.project { if let Some(ParameterValue::Boolean(clip)) = project.properties.get("clip") { @@ -105,7 +98,7 @@ pub(super) trait ProjectionRenderer { } } - Ok(result) + Ok(()) } } @@ -381,13 +374,8 @@ impl ProjectionRenderer for PolarProjection { } } - fn transform_layers( - &self, - spec: &Plot, - data: &DataFrame, - vl_spec: &mut Value, - ) -> Result> { - apply_polar_project(&self.panel, spec, data, vl_spec) + 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 { @@ -1055,17 +1043,8 @@ fn apply_clip_to_layers(vl_spec: &mut Value, clip: bool) { /// 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( - panel: &PolarContext, - spec: &Plot, - data: &DataFrame, - vl_spec: &mut Value, -) -> Result> { - // Convert geoms to polar equivalents and apply angle range + inner radius - convert_geoms_to_polar(panel, spec, vl_spec)?; - - // No DataFrame transformation needed - Vega-Lite handles polar math - Ok(Some(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 @@ -1737,7 +1716,7 @@ mod tests { #[test] fn test_polar_to_cartesian_pixel_coordinates() { let mut layer = polar_point_layer(); - let mut panel = continuous_panel(); + let panel = continuous_panel(); convert_polar_to_cartesian(&mut layer, &panel).unwrap(); @@ -1776,7 +1755,7 @@ mod tests { #[test] fn test_polar_to_cartesian_filters_nulls() { let mut layer = polar_point_layer(); - let mut panel = continuous_panel(); + let panel = continuous_panel(); convert_polar_to_cartesian(&mut layer, &panel).unwrap(); @@ -2285,7 +2264,7 @@ mod tests { } } }); - let mut panel = continuous_panel(); + let panel = continuous_panel(); convert_polar_to_cartesian(&mut layer, &panel).unwrap(); @@ -2322,7 +2301,7 @@ mod tests { } } }); - let mut panel = continuous_panel(); + let panel = continuous_panel(); convert_polar_to_cartesian(&mut layer, &panel).unwrap(); @@ -2366,7 +2345,7 @@ mod tests { } } }); - let mut panel = continuous_panel(); + let panel = continuous_panel(); convert_polar_to_cartesian(&mut layer, &panel).unwrap(); @@ -2404,7 +2383,7 @@ mod tests { } } }); - let mut panel = continuous_panel(); + let panel = continuous_panel(); convert_polar_to_cartesian(&mut layer, &panel).unwrap(); @@ -2487,7 +2466,7 @@ mod tests { } } }); - let mut panel = continuous_panel(); + let panel = continuous_panel(); convert_polar_to_cartesian(&mut layer, &panel).unwrap(); From 3a6b87f0c2c0832a8dfe25bc5b64fcae8648429d Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Fri, 1 May 2026 13:41:03 +0200 Subject: [PATCH 33/35] Use shared escape_vega_string for all Vega expression escaping encoding.rs escaped single quotes but not backslashes in label remap expressions. Consolidate all three call sites to use the existing escape_vega_string helper, which handles both. Co-Authored-By: Claude Opus 4.6 --- src/writer/vegalite/encoding.rs | 4 ++-- src/writer/vegalite/mod.rs | 2 +- src/writer/vegalite/projection.rs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/writer/vegalite/encoding.rs b/src/writer/vegalite/encoding.rs index e7f3b53d..3538ccd7 100644 --- a/src/writer/vegalite/encoding.rs +++ b/src/writer/vegalite/encoding.rs @@ -61,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()) { @@ -72,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 => { diff --git a/src/writer/vegalite/mod.rs b/src/writer/vegalite/mod.rs index 14a00645..2b6fbe09 100644 --- a/src/writer/vegalite/mod.rs +++ b/src/writer/vegalite/mod.rs @@ -896,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('\'', "\\'") } diff --git a/src/writer/vegalite/projection.rs b/src/writer/vegalite/projection.rs index 7ac5f3c9..db7a3ddc 100644 --- a/src/writer/vegalite/projection.rs +++ b/src/writer/vegalite/projection.rs @@ -1395,7 +1395,7 @@ fn extract_polar_channel( if !strings.is_empty() { let literal: String = strings .iter() - .map(|s| format!("'{}'", s.replace('\'', "\\'"))) + .map(|s| format!("'{}'", super::escape_vega_string(s))) .collect::>() .join(","); let arr_expr = format!("[{}]", literal); From 627eee356ad864697e609a1b2939cc31c80fc64b Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Fri, 1 May 2026 13:46:16 +0200 Subject: [PATCH 34/35] Deduplicate discrete/ordinal scale methods into shared helpers Extract categorical_numeric_breaks, categorical_numeric_domain, and categorical_break_labels into scale_type/mod.rs. Both Discrete and Ordinal now delegate to these instead of duplicating the logic. Co-Authored-By: Claude Opus 4.6 --- src/plot/scale/scale_type/discrete.rs | 19 +++--------------- src/plot/scale/scale_type/mod.rs | 28 +++++++++++++++++++++++++++ src/plot/scale/scale_type/ordinal.rs | 19 +++--------------- 3 files changed, 34 insertions(+), 32 deletions(-) diff --git a/src/plot/scale/scale_type/discrete.rs b/src/plot/scale/scale_type/discrete.rs index c9b6edda..58a8e8e7 100644 --- a/src/plot/scale/scale_type/discrete.rs +++ b/src/plot/scale/scale_type/discrete.rs @@ -58,28 +58,15 @@ impl ScaleTypeTrait for Discrete { } fn numeric_breaks(&self, scale: &super::super::Scale) -> Vec { - let n = scale.input_range.as_ref().map_or(0, |r| r.len()); - (1..=n).map(|i| i as f64).collect() + super::categorical_numeric_breaks(scale) } fn numeric_domain(&self, scale: &super::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 } + super::categorical_numeric_domain(scale) } fn break_labels(&self, scale: &super::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 + super::categorical_break_labels(scale) } fn default_properties(&self) -> &'static [ParamDefinition] { diff --git a/src/plot/scale/scale_type/mod.rs b/src/plot/scale/scale_type/mod.rs index d4e30e1e..f205766d 100644 --- a/src/plot/scale/scale_type/mod.rs +++ b/src/plot/scale/scale_type/mod.rs @@ -1102,6 +1102,34 @@ 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 diff --git a/src/plot/scale/scale_type/ordinal.rs b/src/plot/scale/scale_type/ordinal.rs index 10c28e02..0b103ba7 100644 --- a/src/plot/scale/scale_type/ordinal.rs +++ b/src/plot/scale/scale_type/ordinal.rs @@ -65,28 +65,15 @@ impl ScaleTypeTrait for Ordinal { } fn numeric_breaks(&self, scale: &super::super::Scale) -> Vec { - let n = scale.input_range.as_ref().map_or(0, |r| r.len()); - (1..=n).map(|i| i as f64).collect() + super::categorical_numeric_breaks(scale) } fn numeric_domain(&self, scale: &super::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 } + super::categorical_numeric_domain(scale) } fn break_labels(&self, scale: &super::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 + super::categorical_break_labels(scale) } fn allowed_transforms(&self) -> &'static [TransformKind] { From 488f978965dd005957f7e66c9f83a183c44312ce Mon Sep 17 00:00:00 2001 From: Teun van den Brand Date: Fri, 1 May 2026 14:10:00 +0200 Subject: [PATCH 35/35] cargo fmt + clippy warnings --- src/execute/mod.rs | 2 +- src/plot/projection/resolve.rs | 7 ++-- src/plot/scale/scale_type/mod.rs | 6 +++- src/plot/scale/types.rs | 43 ++++++++++++++++++------ src/writer/vegalite/encoding.rs | 2 +- src/writer/vegalite/mod.rs | 3 +- src/writer/vegalite/projection.rs | 54 +++++++------------------------ 7 files changed, 59 insertions(+), 58 deletions(-) diff --git a/src/execute/mod.rs b/src/execute/mod.rs index f08e22e0..46483f03 100644 --- a/src/execute/mod.rs +++ b/src/execute/mod.rs @@ -26,8 +26,8 @@ use crate::naming; 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::projection::resolve_projection_properties; 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}; diff --git a/src/plot/projection/resolve.rs b/src/plot/projection/resolve.rs index 87fe6319..e08646e7 100644 --- a/src/plot/projection/resolve.rs +++ b/src/plot/projection/resolve.rs @@ -539,7 +539,10 @@ mod tests { 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}"); + assert!( + err.contains("discrete"), + "error should mention discrete: {err}" + ); } #[test] @@ -560,7 +563,7 @@ mod tests { let mut proj = Projection::cartesian(); let scales = vec![scale_with_type("pos2", true)]; resolve_projection_properties(&mut proj, &scales).unwrap(); - assert!(proj.properties.get("radar").is_none()); + assert!(!proj.properties.contains_key("radar")); } #[test] diff --git a/src/plot/scale/scale_type/mod.rs b/src/plot/scale/scale_type/mod.rs index f205766d..32e10cb7 100644 --- a/src/plot/scale/scale_type/mod.rs +++ b/src/plot/scale/scale_type/mod.rs @@ -1111,7 +1111,11 @@ pub(super) fn categorical_numeric_breaks(scale: &super::Scale) -> Vec { /// 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 } + if n > 0 { + Some((0.5, n as f64 + 0.5)) + } else { + None + } } /// Labelled breaks for categorical scales: pairs position indices with category names. diff --git a/src/plot/scale/types.rs b/src/plot/scale/types.rs index 9e0b70c5..13d22818 100644 --- a/src/plot/scale/types.rs +++ b/src/plot/scale/types.rs @@ -192,14 +192,24 @@ mod tests { 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.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.input_range = Some( + values + .iter() + .map(|v| ArrayElement::String(v.to_string())) + .collect(), + ); s } @@ -290,10 +300,7 @@ mod tests { #[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.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)]), @@ -311,7 +318,11 @@ mod tests { 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())] + vec![ + (25.0, "25".to_string()), + (50.0, "50".to_string()), + (75.0, "75".to_string()) + ] ); } @@ -320,7 +331,11 @@ mod tests { 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())] + vec![ + (1.0, "A".to_string()), + (2.0, "B".to_string()), + (3.0, "C".to_string()) + ] ); } @@ -329,7 +344,11 @@ mod tests { 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())] + vec![ + (1.0, "low".to_string()), + (2.0, "mid".to_string()), + (3.0, "high".to_string()) + ] ); } @@ -342,7 +361,11 @@ mod tests { 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())] + vec![ + (1.0, "Alpha".to_string()), + (2.0, "B".to_string()), + (3.0, String::new()) + ] ); } } diff --git a/src/writer/vegalite/encoding.rs b/src/writer/vegalite/encoding.rs index 3538ccd7..b6c640ac 100644 --- a/src/writer/vegalite/encoding.rs +++ b/src/writer/vegalite/encoding.rs @@ -1208,10 +1208,10 @@ mod tests { // ========================================================================= mod get_extent_translation_tests { - use crate::writer::vegalite::projection::get_projection_renderer; use super::*; use crate::plot::aesthetic::AestheticContext; use crate::plot::{ArrayElement, Scale}; + use crate::writer::vegalite::projection::get_projection_renderer; fn discrete_scale(aesthetic: &str) -> Scale { Scale { diff --git a/src/writer/vegalite/mod.rs b/src/writer/vegalite/mod.rs index 2b6fbe09..bc51e0d3 100644 --- a/src/writer/vegalite/mod.rs +++ b/src/writer/vegalite/mod.rs @@ -1052,7 +1052,8 @@ impl Writer for VegaLiteWriter { "$schema": self.schema }); // Get projection renderer (single instance used throughout) - let projection = get_projection_renderer(spec.project.as_ref(), spec.facet.as_ref(), &spec.scales); + 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; diff --git a/src/writer/vegalite/projection.rs b/src/writer/vegalite/projection.rs index db7a3ddc..23771537 100644 --- a/src/writer/vegalite/projection.rs +++ b/src/writer/vegalite/projection.rs @@ -66,12 +66,7 @@ pub(super) trait ProjectionRenderer { } /// Apply all projection-specific work: transforms, clip, and panel decoration. - fn apply_projection( - &self, - spec: &Plot, - theme: &mut Value, - vl_spec: &mut Value, - ) -> Result<()> { + 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 { @@ -202,7 +197,12 @@ impl AxisInfo { 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 } + Self { + domain, + breaks, + labels, + is_free, + } } } @@ -431,13 +431,7 @@ impl PolarProjection { .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(), - ); + let mut layer = polygon_ring(p, r, None, Value::Null, color.clone()); layer["mark"]["strokeWidth"] = width.clone(); layer }) @@ -2681,13 +2675,7 @@ mod tests { 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 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); @@ -2703,13 +2691,7 @@ mod tests { .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 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); @@ -2728,13 +2710,7 @@ mod tests { .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 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(); @@ -2751,13 +2727,7 @@ mod tests { 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 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);