Skip to content

Commit d48dd1e

Browse files
authored
Ridgeline plot (#242)
* add ridge parameter * add docs * add test * cargo fmt * rename `ridge` to `align` * fix example * Allow `width` to exceed 1 for ridgeline purposes * rename the parameter to 'side' * offset variable doesn't need encoding channel, we already have xOffset/yOffset channels * relative-to-axis consistency of synonymous `side` * cover all cases in tests
1 parent 7bf0f94 commit d48dd1e

3 files changed

Lines changed: 146 additions & 4 deletions

File tree

doc/syntax/layer/type/violin.qmd

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ The following aesthetics are recognised by the violin layer.
3434
* `'biweight'` or `'quartic'`
3535
* `'cosine'`
3636
* `width`: Relative width of the violins (0 to 1). Defaults to `0.9`.
37+
* `side`: Determines the sides of the midline where the density is displayed. One of the following:
38+
* `'both'` (default) displays a complete violin both sides of the midline.
39+
* `'left'` or `'bottom'` only displays half a violin at the left side or bottom side.
40+
* `'right'` or `'top'` only displays half a violin at the right side or top side.
3741
* `tails`: Expansion rule for drawing the tails (must be >= 0 if numeric). One of the following:
3842
* A number setting a multiple of adjusted bandwidths to expand each group's range. Defaults to 3.
3943
* `null` to use the whole data range rather than group ranges.
@@ -106,3 +110,11 @@ VISUALISE species AS y, bill_dep AS x FROM ggsql:penguins
106110
DRAW violin
107111
```
108112

113+
A ridgeline plot (or joy plot) can be seen as a horizontal half-violin plot, or like a density plot with vertical offsets for every category.
114+
To achieve this outcome, you can set the `side` setting and adjust `width` to taste.
115+
116+
```{ggsql}
117+
VISUALISE Temp AS x, Month AS y FROM ggsql:airquality
118+
DRAW violin SETTING width => 4, side => 'top'
119+
SCALE ORDINAL y
120+
```

src/plot/layer/geom/violin.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ const KERNEL_VALUES: &[&str] = &[
2525
"cosine",
2626
];
2727

28+
const SIDE_VALUES: &[&str] = &["both", "left", "top", "right", "bottom"];
29+
2830
/// Violin geom - violin plots (mirrored density)
2931
#[derive(Debug, Clone, Copy)]
3032
pub struct Violin;
@@ -79,7 +81,13 @@ impl GeomTrait for Violin {
7981
ParamDefinition {
8082
name: "width",
8183
default: DefaultParamValue::Number(0.9),
82-
constraint: ParamConstraint::number_range(0.0, 1.0),
84+
// We allow >1 width to make ridgeline plots
85+
constraint: ParamConstraint::number_min_exclusive(0.0),
86+
},
87+
ParamDefinition {
88+
name: "side",
89+
default: DefaultParamValue::String("both"),
90+
constraint: ParamConstraint::string_option(SIDE_VALUES),
8391
},
8492
ParamDefinition {
8593
name: "tails",

src/writer/vegalite/layer.rs

Lines changed: 125 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1308,12 +1308,24 @@ impl GeomRenderer for ViolinRenderer {
13081308
});
13091309
let offset_col = naming::aesthetic_column("offset");
13101310

1311-
// It'll be implemented as an offset.
1312-
let violin_offset = format!("[datum.{offset}, -datum.{offset}]", offset = offset_col);
1313-
13141311
// Read orientation from layer (already resolved during execution)
13151312
let is_horizontal = is_transposed(layer);
13161313

1314+
// It'll be implemented as an offset.
1315+
let mut violin_offset = format!("[datum.{offset}, -datum.{offset}]", offset = offset_col);
1316+
if let Some(ParameterValue::String(side)) = layer.parameters.get("side") {
1317+
let positive = if is_horizontal {
1318+
matches!(side.as_str(), "bottom" | "left")
1319+
} else {
1320+
matches!(side.as_str(), "top" | "right")
1321+
};
1322+
violin_offset = if positive {
1323+
format!("[datum.{offset}]", offset = offset_col)
1324+
} else {
1325+
format!("[-datum.{offset}]", offset = offset_col)
1326+
};
1327+
}
1328+
13171329
// Continuous axis column for order calculation:
13181330
// - Vertical: pos2 (y-axis has continuous density values)
13191331
// - Horizontal: pos1 (x-axis has continuous density values)
@@ -1380,6 +1392,8 @@ impl GeomRenderer for ViolinRenderer {
13801392
// Read orientation from layer (already resolved during execution)
13811393
let is_horizontal = is_transposed(layer);
13821394

1395+
encoding.remove("offset");
1396+
13831397
// Categorical axis for detail encoding:
13841398
// - Vertical: x channel (categorical groups on x-axis)
13851399
// - Horizontal: y channel (categorical groups on y-axis)
@@ -3210,6 +3224,114 @@ mod tests {
32103224
);
32113225
}
32123226

3227+
#[test]
3228+
fn test_violin_ridge_parameter() {
3229+
use crate::naming;
3230+
use crate::plot::ParameterValue;
3231+
3232+
let offset_col = naming::aesthetic_column("offset");
3233+
3234+
fn get_violin_offset_expr(ridge: Option<&str>, is_horizontal: bool) -> String {
3235+
let mut layer = Layer::new(crate::plot::Geom::violin());
3236+
if let Some(r) = ridge {
3237+
layer
3238+
.parameters
3239+
.insert("side".to_string(), ParameterValue::String(r.to_string()));
3240+
}
3241+
3242+
// Set orientation parameter for horizontal case
3243+
if is_horizontal {
3244+
layer.parameters.insert(
3245+
"orientation".to_string(),
3246+
ParameterValue::String("transposed".to_string()),
3247+
);
3248+
}
3249+
3250+
let mut layer_spec = if is_horizontal {
3251+
json!({
3252+
"mark": {"type": "line"},
3253+
"encoding": {
3254+
"x": {"field": naming::aesthetic_column("pos2"), "type": "quantitative"},
3255+
"y": {"field": "species", "type": "nominal"}
3256+
}
3257+
})
3258+
} else {
3259+
json!({
3260+
"mark": {"type": "line"},
3261+
"encoding": {
3262+
"x": {"field": "species", "type": "nominal"},
3263+
"y": {"field": naming::aesthetic_column("pos2"), "type": "quantitative"}
3264+
}
3265+
})
3266+
};
3267+
3268+
ViolinRenderer
3269+
.modify_spec(&mut layer_spec, &layer, &RenderContext::new(&[]))
3270+
.unwrap();
3271+
3272+
layer_spec["transform"]
3273+
.as_array()
3274+
.unwrap()
3275+
.iter()
3276+
.find(|t| t.get("as").and_then(|a| a.as_str()) == Some("violin_offsets"))
3277+
.unwrap()["calculate"]
3278+
.as_str()
3279+
.unwrap()
3280+
.to_string()
3281+
}
3282+
3283+
// Default "both" - mirrors on both sides (vertical orientation)
3284+
let expr = get_violin_offset_expr(None, false);
3285+
assert!(
3286+
expr.contains(&format!("[datum.{}, -datum.{}]", offset_col, offset_col))
3287+
|| expr.contains(&format!("[-datum.{}, datum.{}]", offset_col, offset_col)),
3288+
"Default should mirror both sides: {}",
3289+
expr
3290+
);
3291+
3292+
// Vertical orientation (default): x=nominal, y=quantitative
3293+
// "left" and "bottom" - only negative offset
3294+
assert_eq!(
3295+
get_violin_offset_expr(Some("left"), false),
3296+
format!("[-datum.{}]", offset_col)
3297+
);
3298+
assert_eq!(
3299+
get_violin_offset_expr(Some("bottom"), false),
3300+
format!("[-datum.{}]", offset_col)
3301+
);
3302+
3303+
// "right" and "top" - only positive offset
3304+
assert_eq!(
3305+
get_violin_offset_expr(Some("right"), false),
3306+
format!("[datum.{}]", offset_col)
3307+
);
3308+
assert_eq!(
3309+
get_violin_offset_expr(Some("top"), false),
3310+
format!("[datum.{}]", offset_col)
3311+
);
3312+
3313+
// Horizontal orientation: x=quantitative, y=nominal
3314+
// "bottom" and "left" - only positive offset
3315+
assert_eq!(
3316+
get_violin_offset_expr(Some("bottom"), true),
3317+
format!("[datum.{}]", offset_col)
3318+
);
3319+
assert_eq!(
3320+
get_violin_offset_expr(Some("left"), true),
3321+
format!("[datum.{}]", offset_col)
3322+
);
3323+
3324+
// "top" and "right" - only negative offset
3325+
assert_eq!(
3326+
get_violin_offset_expr(Some("top"), true),
3327+
format!("[-datum.{}]", offset_col)
3328+
);
3329+
assert_eq!(
3330+
get_violin_offset_expr(Some("right"), true),
3331+
format!("[-datum.{}]", offset_col)
3332+
);
3333+
}
3334+
32133335
#[test]
32143336
fn test_render_context_get_extent() {
32153337
use crate::plot::{ArrayElement, Scale};

0 commit comments

Comments
 (0)