diff --git a/doc/syntax/layer/type/violin.qmd b/doc/syntax/layer/type/violin.qmd index 8acda6b9..37a59560 100644 --- a/doc/syntax/layer/type/violin.qmd +++ b/doc/syntax/layer/type/violin.qmd @@ -34,6 +34,10 @@ The following aesthetics are recognised by the violin layer. * `'biweight'` or `'quartic'` * `'cosine'` * `width`: Relative width of the violins (0 to 1). Defaults to `0.9`. +* `side`: Determines the sides of the midline where the density is displayed. One of the following: + * `'both'` (default) displays a complete violin both sides of the midline. + * `'left'` or `'bottom'` only displays half a violin at the left side or bottom side. + * `'right'` or `'top'` only displays half a violin at the right side or top side. * `tails`: Expansion rule for drawing the tails (must be >= 0 if numeric). One of the following: * A number setting a multiple of adjusted bandwidths to expand each group's range. Defaults to 3. * `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 DRAW violin ``` +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. +To achieve this outcome, you can set the `side` setting and adjust `width` to taste. + +```{ggsql} +VISUALISE Temp AS x, Month AS y FROM ggsql:airquality +DRAW violin SETTING width => 4, side => 'top' +SCALE ORDINAL y +``` \ No newline at end of file diff --git a/src/plot/layer/geom/violin.rs b/src/plot/layer/geom/violin.rs index bf12a393..06d2239c 100644 --- a/src/plot/layer/geom/violin.rs +++ b/src/plot/layer/geom/violin.rs @@ -25,6 +25,8 @@ const KERNEL_VALUES: &[&str] = &[ "cosine", ]; +const SIDE_VALUES: &[&str] = &["both", "left", "top", "right", "bottom"]; + /// Violin geom - violin plots (mirrored density) #[derive(Debug, Clone, Copy)] pub struct Violin; @@ -79,7 +81,13 @@ impl GeomTrait for Violin { ParamDefinition { name: "width", default: DefaultParamValue::Number(0.9), - constraint: ParamConstraint::number_range(0.0, 1.0), + // We allow >1 width to make ridgeline plots + constraint: ParamConstraint::number_min_exclusive(0.0), + }, + ParamDefinition { + name: "side", + default: DefaultParamValue::String("both"), + constraint: ParamConstraint::string_option(SIDE_VALUES), }, ParamDefinition { name: "tails", diff --git a/src/writer/vegalite/layer.rs b/src/writer/vegalite/layer.rs index 80ad57a2..f467645e 100644 --- a/src/writer/vegalite/layer.rs +++ b/src/writer/vegalite/layer.rs @@ -1311,12 +1311,24 @@ impl GeomRenderer for ViolinRenderer { }); let offset_col = naming::aesthetic_column("offset"); - // It'll be implemented as an offset. - let violin_offset = format!("[datum.{offset}, -datum.{offset}]", offset = offset_col); - // Read orientation from layer (already resolved during execution) let is_horizontal = is_transposed(layer); + // It'll be implemented as an offset. + let mut violin_offset = format!("[datum.{offset}, -datum.{offset}]", offset = offset_col); + if let Some(ParameterValue::String(side)) = layer.parameters.get("side") { + let positive = if is_horizontal { + matches!(side.as_str(), "bottom" | "left") + } else { + matches!(side.as_str(), "top" | "right") + }; + violin_offset = if positive { + format!("[datum.{offset}]", offset = offset_col) + } else { + format!("[-datum.{offset}]", offset = offset_col) + }; + } + // Continuous axis column for order calculation: // - Vertical: pos2 (y-axis has continuous density values) // - Horizontal: pos1 (x-axis has continuous density values) @@ -1383,6 +1395,8 @@ impl GeomRenderer for ViolinRenderer { // Read orientation from layer (already resolved during execution) let is_horizontal = is_transposed(layer); + encoding.remove("offset"); + // Categorical axis for detail encoding: // - Vertical: x channel (categorical groups on x-axis) // - Horizontal: y channel (categorical groups on y-axis) @@ -3214,6 +3228,114 @@ mod tests { ); } + #[test] + fn test_violin_ridge_parameter() { + use crate::naming; + use crate::plot::ParameterValue; + + let offset_col = naming::aesthetic_column("offset"); + + fn get_violin_offset_expr(ridge: Option<&str>, is_horizontal: bool) -> String { + let mut layer = Layer::new(crate::plot::Geom::violin()); + if let Some(r) = ridge { + layer + .parameters + .insert("side".to_string(), ParameterValue::String(r.to_string())); + } + + // Set orientation parameter for horizontal case + if is_horizontal { + layer.parameters.insert( + "orientation".to_string(), + ParameterValue::String("transposed".to_string()), + ); + } + + let mut layer_spec = if is_horizontal { + json!({ + "mark": {"type": "line"}, + "encoding": { + "x": {"field": naming::aesthetic_column("pos2"), "type": "quantitative"}, + "y": {"field": "species", "type": "nominal"} + } + }) + } else { + json!({ + "mark": {"type": "line"}, + "encoding": { + "x": {"field": "species", "type": "nominal"}, + "y": {"field": naming::aesthetic_column("pos2"), "type": "quantitative"} + } + }) + }; + + ViolinRenderer + .modify_spec(&mut layer_spec, &layer, &RenderContext::new(&[])) + .unwrap(); + + layer_spec["transform"] + .as_array() + .unwrap() + .iter() + .find(|t| t.get("as").and_then(|a| a.as_str()) == Some("violin_offsets")) + .unwrap()["calculate"] + .as_str() + .unwrap() + .to_string() + } + + // Default "both" - mirrors on both sides (vertical orientation) + let expr = get_violin_offset_expr(None, false); + assert!( + expr.contains(&format!("[datum.{}, -datum.{}]", offset_col, offset_col)) + || expr.contains(&format!("[-datum.{}, datum.{}]", offset_col, offset_col)), + "Default should mirror both sides: {}", + expr + ); + + // Vertical orientation (default): x=nominal, y=quantitative + // "left" and "bottom" - only negative offset + assert_eq!( + get_violin_offset_expr(Some("left"), false), + format!("[-datum.{}]", offset_col) + ); + assert_eq!( + get_violin_offset_expr(Some("bottom"), false), + format!("[-datum.{}]", offset_col) + ); + + // "right" and "top" - only positive offset + assert_eq!( + get_violin_offset_expr(Some("right"), false), + format!("[datum.{}]", offset_col) + ); + assert_eq!( + get_violin_offset_expr(Some("top"), false), + format!("[datum.{}]", offset_col) + ); + + // Horizontal orientation: x=quantitative, y=nominal + // "bottom" and "left" - only positive offset + assert_eq!( + get_violin_offset_expr(Some("bottom"), true), + format!("[datum.{}]", offset_col) + ); + assert_eq!( + get_violin_offset_expr(Some("left"), true), + format!("[datum.{}]", offset_col) + ); + + // "top" and "right" - only negative offset + assert_eq!( + get_violin_offset_expr(Some("top"), true), + format!("[-datum.{}]", offset_col) + ); + assert_eq!( + get_violin_offset_expr(Some("right"), true), + format!("[-datum.{}]", offset_col) + ); + } + #[test] fn test_render_context_get_extent() { use crate::plot::{ArrayElement, Scale};