@@ -7,6 +7,8 @@ use crate::plot::{CoordKind, ParameterValue, Projection};
77use crate :: { DataFrame , GgsqlError , Plot , Result } ;
88use serde_json:: { json, Value } ;
99
10+ use super :: DEFAULT_POLAR_SIZE ;
11+
1012/// Apply projection transformations to the spec and data
1113/// Returns (possibly transformed DataFrame, possibly modified spec)
1214pub ( super ) fn apply_project_transforms (
@@ -146,6 +148,26 @@ fn convert_geoms_to_polar(
146148 end_radians : f64 ,
147149 inner : f64 ,
148150) -> Result < ( ) > {
151+ let is_faceted = match & spec. facet {
152+ Some ( facet) => !facet. get_variables ( ) . is_empty ( ) ,
153+ _ => false ,
154+ } ;
155+
156+ let size = if is_faceted {
157+ // Try to grab size from spec if available
158+ let height = vl_spec. get ( "height" ) . and_then ( |h| h. as_f64 ( ) ) ;
159+ let width = vl_spec. get ( "width" ) . and_then ( |w| w. as_f64 ( ) ) ;
160+
161+ Some ( match ( height, width) {
162+ ( Some ( h) , Some ( w) ) => h. min ( w) ,
163+ ( Some ( h) , None ) => h,
164+ ( None , Some ( w) ) => w,
165+ _ => DEFAULT_POLAR_SIZE , // Fallback
166+ } )
167+ } else {
168+ None
169+ } ;
170+
149171 if let Some ( layers_arr) = get_layers_mut ( vl_spec) {
150172 for layer in layers_arr {
151173 if let Some ( mark) = layer. get_mut ( "mark" ) {
@@ -156,13 +178,8 @@ fn convert_geoms_to_polar(
156178 if is_arc {
157179 // Arc marks natively support radius/theta channels
158180 if let Some ( encoding) = layer. get_mut ( "encoding" ) {
159- // Remove dummy radius encoding — for pie charts the bar stat
160- // creates a dummy pos1 column (all rows same value). VL's arc
161- // mark uses full available radius by default, and a nominal
162- // radius with a single dummy value breaks faceted arc rendering.
163- strip_dummy_radius ( encoding) ;
164181 apply_polar_angle_range ( encoding, start_radians, end_radians) ?;
165- apply_polar_radius_range ( encoding, inner) ?;
182+ apply_polar_radius_range ( encoding, inner, size ) ?;
166183 }
167184 } else {
168185 // Non-arc marks (point, line): convert polar to cartesian
@@ -385,26 +402,6 @@ fn convert_mark_to_polar(mark: &Value, _spec: &Plot) -> Result<Value> {
385402 Ok ( json ! ( polar_mark) )
386403}
387404
388- /// Remove dummy radius encoding from arc marks.
389- ///
390- /// For pie charts, the bar stat creates a dummy pos1 column where all rows have
391- /// the same placeholder value. In cartesian this positions all bars at the same x.
392- /// For arc marks, a nominal radius with a single dummy value is unnecessary (VL
393- /// uses full available radius by default) and breaks faceted arc rendering.
394- /// The dummy is identified by `"axis": null` which is set by build_column_encoding
395- /// for dummy columns.
396- fn strip_dummy_radius ( encoding : & mut Value ) {
397- let dominated = encoding
398- . get ( "radius" )
399- . and_then ( |r| r. get ( "axis" ) )
400- . is_some_and ( |a| a. is_null ( ) ) ;
401- if dominated {
402- if let Some ( enc_obj) = encoding. as_object_mut ( ) {
403- enc_obj. remove ( "radius" ) ;
404- }
405- }
406- }
407-
408405/// Apply angle range to theta encoding for polar projection
409406///
410407/// The encoding channels are already correctly named (theta/radius) by
@@ -454,20 +451,23 @@ fn apply_polar_angle_range(
454451/// Sets the radius scale range using Vega-Lite expressions for proportional sizing.
455452/// The inner parameter (0.0 to 1.0) specifies the inner radius as a proportion
456453/// of the outer radius, creating a donut hole.
457- fn apply_polar_radius_range ( encoding : & mut Value , inner : f64 ) -> Result < ( ) > {
458- // Skip if no inner radius (full pie)
459- if inner <= f64:: EPSILON {
460- return Ok ( ( ) ) ;
461- }
462-
454+ fn apply_polar_radius_range ( encoding : & mut Value , inner : f64 , size : Option < f64 > ) -> Result < ( ) > {
463455 let enc_obj = encoding
464456 . as_object_mut ( )
465457 . ok_or_else ( || GgsqlError :: WriterError ( "Encoding is not an object" . to_string ( ) ) ) ?;
466458
467459 // Use expressions for proportional sizing
468- // min(width,height)/2 is the default max radius in Vega-Lite
469- let inner_expr = format ! ( "min(width,height)/2*{}" , inner) ;
470- let outer_expr = "min(width,height)/2" . to_string ( ) ;
460+ let ( inner_expr, outer_expr) = match size {
461+ Some ( dim) => ( format ! ( "{}/2*{}" , dim, inner) , format ! ( "{}/2" , dim) ) ,
462+ _ => {
463+ // min(width,height)/2 is the default max radius in Vega-Lite
464+ (
465+ format ! ( "min(width,height)/2*{}" , inner) ,
466+ "min(width,height)/2" . to_string ( ) ,
467+ )
468+ }
469+ } ;
470+
471471 let range_value = json ! ( [ { "expr" : inner_expr} , { "expr" : outer_expr} ] ) ;
472472
473473 // Apply scale range to radius encoding (merge with existing scale)
@@ -498,3 +498,69 @@ fn apply_polar_radius_range(encoding: &mut Value, inner: f64) -> Result<()> {
498498
499499 Ok ( ( ) )
500500}
501+
502+ #[ cfg( test) ]
503+ mod tests {
504+ use super :: * ;
505+
506+ #[ test]
507+ fn test_polar_inner_radius_non_faceted ( ) {
508+ // Non-faceted donut should use dynamic min(width,height) expressions
509+ let mut encoding = json ! ( {
510+ "radius" : {
511+ "field" : "dummy" ,
512+ "type" : "nominal" ,
513+ "scale" : { "domain" : [ "dummy" ] }
514+ }
515+ } ) ;
516+
517+ apply_polar_radius_range ( & mut encoding, 0.5 , None ) . unwrap ( ) ;
518+
519+ let range = encoding[ "radius" ] [ "scale" ] [ "range" ] . as_array ( ) . unwrap ( ) ;
520+ assert_eq ! ( range. len( ) , 2 ) ;
521+ assert_eq ! (
522+ range[ 0 ] [ "expr" ] . as_str( ) . unwrap( ) ,
523+ "min(width,height)/2*0.5"
524+ ) ;
525+ assert_eq ! ( range[ 1 ] [ "expr" ] . as_str( ) . unwrap( ) , "min(width,height)/2" ) ;
526+ }
527+
528+ #[ test]
529+ fn test_polar_inner_radius_faceted ( ) {
530+ // Faceted donut should use explicit size calculation
531+ let mut encoding = json ! ( {
532+ "radius" : {
533+ "field" : "dummy" ,
534+ "type" : "nominal" ,
535+ "scale" : { "domain" : [ "dummy" ] }
536+ }
537+ } ) ;
538+
539+ apply_polar_radius_range ( & mut encoding, 0.5 , Some ( 350.0 ) ) . unwrap ( ) ;
540+
541+ let range = encoding[ "radius" ] [ "scale" ] [ "range" ] . as_array ( ) . unwrap ( ) ;
542+ assert_eq ! ( range. len( ) , 2 ) ;
543+ assert_eq ! ( range[ 0 ] [ "expr" ] . as_str( ) . unwrap( ) , "350/2*0.5" ) ;
544+ assert_eq ! ( range[ 1 ] [ "expr" ] . as_str( ) . unwrap( ) , "350/2" ) ;
545+ }
546+
547+ #[ test]
548+ fn test_polar_inner_radius_zero ( ) {
549+ // inner = 0 should still apply range (full pie, no donut hole)
550+ let mut encoding = json ! ( {
551+ "radius" : {
552+ "field" : "dummy" ,
553+ "type" : "nominal" ,
554+ "scale" : { "domain" : [ "dummy" ] }
555+ }
556+ } ) ;
557+
558+ apply_polar_radius_range ( & mut encoding, 0.0 , Some ( 350.0 ) ) . unwrap ( ) ;
559+
560+ // Range should be [0, 350/2] for full pie
561+ let range = encoding[ "radius" ] [ "scale" ] [ "range" ] . as_array ( ) . unwrap ( ) ;
562+ assert_eq ! ( range. len( ) , 2 ) ;
563+ assert_eq ! ( range[ 0 ] [ "expr" ] . as_str( ) . unwrap( ) , "350/2*0" ) ;
564+ assert_eq ! ( range[ 1 ] [ "expr" ] . as_str( ) . unwrap( ) , "350/2" ) ;
565+ }
566+ }
0 commit comments