Skip to content

Commit 966f995

Browse files
authored
Fix donuts (#309)
* initial attempt * Use const for default polar size * add tests * just use the size regardless of `inner` * remove obsolete test
1 parent a08003f commit 966f995

3 files changed

Lines changed: 107 additions & 66 deletions

File tree

src/reader/mod.rs

Lines changed: 0 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1083,35 +1083,6 @@ mod tests {
10831083
);
10841084
}
10851085

1086-
#[test]
1087-
fn test_polar_project_inner_default() {
1088-
// Test that inner=0 (default) doesn't add scale range
1089-
let reader = DuckDBReader::from_connection_string("duckdb://memory").unwrap();
1090-
let query = r#"
1091-
SELECT * FROM (VALUES ('A', 10), ('B', 20)) AS t(category, value)
1092-
VISUALISE value AS y, category AS fill
1093-
DRAW bar
1094-
PROJECT y, x TO polar
1095-
"#;
1096-
1097-
let spec = reader.execute(query).unwrap();
1098-
let writer = VegaLiteWriter::new();
1099-
let result = writer.render(&spec).unwrap();
1100-
1101-
let json: serde_json::Value = serde_json::from_str(&result).unwrap();
1102-
let layer = json["layer"].as_array().unwrap().first().unwrap();
1103-
1104-
// Radius encoding should not have scale.range when inner=0
1105-
let radius = &layer["encoding"]["radius"];
1106-
if let Some(scale) = radius.get("scale") {
1107-
assert!(
1108-
scale.get("range").is_none(),
1109-
"Radius scale should not have range when inner=0, got: {:?}",
1110-
scale
1111-
);
1112-
}
1113-
}
1114-
11151086
#[test]
11161087
fn test_stacked_bar_chart() {
11171088
// Test stacked bar chart via position => 'stack'

src/writer/vegalite/mod.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ const POINTS_TO_PIXELS: f64 = 96.0 / 72.0;
4949
/// So: area_px^2 = pi * (r_pt * POINTS_TO_PIXELS)^2 = pi * r_pt^2 * (96/72)^2
5050
const POINTS_TO_AREA: f64 = std::f64::consts::PI * POINTS_TO_PIXELS * POINTS_TO_PIXELS;
5151

52+
/// Default size (in pixels) for polar coordinate visualizations
53+
/// Used as fallback when explicit dimensions are not specified
54+
pub(super) const DEFAULT_POLAR_SIZE: f64 = 350.0;
55+
5256
/// Split a label string on newlines and return appropriate JSON value
5357
///
5458
/// Returns a JSON array if the string contains multiple lines, or a JSON string
@@ -1104,8 +1108,8 @@ impl Writer for VegaLiteWriter {
11041108
.as_ref()
11051109
.is_some_and(|p| p.coord.coord_kind() == CoordKind::Polar);
11061110
if is_polar {
1107-
vl_spec["width"] = json!(350);
1108-
vl_spec["height"] = json!(350);
1111+
vl_spec["width"] = json!(DEFAULT_POLAR_SIZE);
1112+
vl_spec["height"] = json!(DEFAULT_POLAR_SIZE);
11091113
}
11101114
}
11111115

src/writer/vegalite/projection.rs

Lines changed: 101 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ use crate::plot::{CoordKind, ParameterValue, Projection};
77
use crate::{DataFrame, GgsqlError, Plot, Result};
88
use 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)
1214
pub(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

Comments
 (0)