Violin

A violin shows the density of a numeric value as a symmetric outline.

mark_violin is the convenient way to draw one. It is a composite mark: behind the scenes it expands into the ordinary recipe

transform_density(trim = true)        // one curve per category (and group)
  → a symmetric outline               // transform_band polygon
  → the shared polygon renderer

so nothing is hidden that you cannot write yourself. The recipes below use the mark; the last section writes the same picture out of the primitives, for anyone who wants to recombine the pieces. The curve itself (bandwidth, kernel, trim) is described in 1-D Density.

Violin (single)

Read the value from y; the mark estimates its density and draws the symmetric outline.

//! Violin plot — the convenience `mark_violin`.
//!
//! `mark_violin` is a **composite** mark: it expands into the ordinary public
//! recipe `transform_density` → `transform_band` → the shared polygon renderer.
//! For power users who want to build it themselves, `violin_manual.rs` writes the
//! same picture out of those primitives.
//!
//! The inputs come from the encodings:
//!
//! * `y` — the numeric value whose density is drawn (required);
//! * `x` — an optional category each violin sits on;
//! * `color` — an optional group that splits a category into lanes.

use charton::prelude::*;
use std::error::Error;

fn main() -> Result<(), Box<dyn Error>> {
    let iris = load_dataset("iris")?;

    chart!(iris)?
        .mark_violin()?
        .configure_violin(|violin| {
            violin
                .with_color("#7fb3d5")
                .with_opacity(0.6)
                .with_stroke("#2c3e50")
        })
        .encode(alt::y("sepal_length"))?
        .with_title("Sepal length")
        .with_x_label("Density")
        .with_y_label("Sepal length (cm)")
        .save("docs/src/images/violin.svg")?;

    println!("Saved docs/src/images/violin.svg");
    Ok(())
}

Grouped and faceted violins

Two industry layouts, both one call to mark_violin plus the encodings:

  • Faceted (Vega-Lite / Altair style): read only y and facet by the group, so each panel gets its own violin.
  • Dodged (ggplot2 style): map x to the category and color to the group; the mark dodges the lanes side by side. The inner box is an ordinary transform_quantile_box layer, drawn on top with .and(…).
//! Grouped violins — the two industrial layouts, with `mark_violin`.
//!
//! * **Faceted** (Vega-Lite / Altair style): one violin per panel.
//! * **Dodged** (ggplot2 style): the violins sit side by side inside each
//!   category, and an ordinary `transform_quantile_box` layer draws the inner
//!   box.
//!
//! The penguins `Sex` column has a couple of missing values. `Sex` is the
//! *positional* category here, so those rows are dropped, exactly as ggplot2 and
//! Altair would — no stray `null` violin appears, and the outline and its inner
//! box always agree because both use the shared lane layout.

use charton::prelude::*;
use std::error::Error;

fn main() -> Result<(), Box<dyn Error>> {
    let penguins = load_dataset("penguins")?;

    // === 1. Faceted: one violin per panel ==================================
    chart!(&penguins)?
        .mark_violin()?
        .configure_violin(|violin| violin.with_opacity(0.7).with_stroke("#7e5109"))
        .encode(alt::y("Body Mass (g)"))?
        .facet(FacetSpec::wrap("Species").with_columns(3))
        .with_title("Body mass by species")
        .with_x_label("Density")
        .with_y_label("Body mass (g)")
        .save("docs/src/images/faceted_violin.svg")?;

    // === 2. Dodged: side by side inside each Sex, with an inner box ========
    let outline = chart!(&penguins)?
        .mark_violin()?
        .configure_violin(|violin| violin.with_color("#d6eaf8").with_stroke("#2c3e50"))
        .encode((
            alt::x("Sex"),
            alt::y("Body Mass (g)"),
            alt::color("Species"),
        ))?;

    let inner_box = chart!(&penguins)?
        .transform_quantile_box(
            QuantileBoxTransform::new("Body Mass (g)")
                .with_category("Sex")
                .with_group("Species")
                .with_position(Position::dodge()),
        )?
        .mark_polygon()?
        .configure_geoshape(|mark| mark.with_fill("white").with_stroke("black"))
        .encode((
            alt::x("x").with_category_labels("Sex"),
            alt::y("y"),
            alt::path_group("path_group"),
        ))?;

    outline
        .and(inner_box)
        .with_title("Body mass by species and sex")
        .with_x_label("")
        .with_y_label("Body mass (g)")
        .save("docs/src/images/grouped_violin.svg")?;

    println!("Saved docs/src/images/faceted_violin.svg and grouped_violin.svg");
    Ok(())
}

When a category used for position is missing (the penguins Sex column has a few), that row is dropped, exactly as ggplot2 and Altair would — no null violin appears. A missing group (the colour/lane) behaves differently: it is kept as a grey NA lane. See Missing Values & Gaps.

Split violin

One centre line per category, the first group growing right and the second left: mark_violin with configure_violin(…).with_split(true). Colour the result by group and the two halves read as a single violin split down the middle.

//! Split violin — two groups as the left and right halves of one violin, with
//! `mark_violin`.
//!
//! Set `with_split(true)` and colour by the group: the first group grows to the
//! right of each centre and the second to the left. The mark expands this into
//! the ordinary density transform plus the general band geometry in `split`
//! mode, so each half is an ordinary polygon.

use charton::prelude::*;
use std::error::Error;

fn main() -> Result<(), Box<dyn Error>> {
    let penguins = load_dataset("penguins")?;

    chart!(&penguins)?
        .mark_violin()?
        .configure_violin(|violin| {
            violin
                .with_split(true)
                .with_color("#95a5a6")
                .with_stroke("#2c3e50")
        })
        .encode((
            alt::x("Species"),
            alt::y("Body Mass (g)"),
            alt::color("Sex"),
        ))?
        .with_title("Body mass by species and sex (split)")
        .with_x_label("")
        .with_y_label("Body mass (g)")
        .save("docs/src/images/split_violin.svg")?;

    println!("Saved docs/src/images/split_violin.svg");
    Ok(())
}

This is the general transform_band(…).with_split(true) geometry underneath: the two groups share the centre line instead of sitting in separate lanes.

Raincloud

Three views of the same distribution, stacked with .and(…): the violin outline, an inner inter-quartile box from transform_quantile_box, and every observation as a jittered point layer.

//! Raincloud — a violin outline, an inner box and the raw observations.
//!
//! The raincloud is the clearest demonstration of the layer model: three views
//! of the same distribution, each an ordinary mark, stacked with `.and(…)`.
//!
//! * `mark_violin`              → the density outline
//! * `transform_quantile_box`   → the inter-quartile box and median
//! * `mark_point` + jitter      → every observation
//!
//! All three share the same x and y scales, so they line up automatically.

use charton::prelude::*;
use std::error::Error;

fn main() -> Result<(), Box<dyn Error>> {
    let penguins = load_dataset("penguins")?;

    // 1. The cloud: a density outline per species, straight from `mark_violin`.
    let violin = chart!(&penguins)?
        .mark_violin()?
        .configure_violin(|mark| {
            mark.with_color("#d6eaf8")
                .with_opacity(1.0)
                .with_stroke("#2c3e50")
                .with_stroke_width(1.0)
        })
        .encode((alt::x("Species"), alt::y("Body Mass (g)")))?;

    // 2. The box: the quartiles, drawn from the same raw observations.
    let inner_box = chart!(&penguins)?
        .transform_quantile_box(
            QuantileBoxTransform::new("Body Mass (g)").with_category("Species"),
        )?
        .mark_polygon()?
        .configure_geoshape(|mark| {
            mark.with_fill("white")
                .with_stroke("black")
                .with_stroke_width(1.0)
        })
        .encode((
            alt::x("x").with_category_labels("Species"),
            alt::y("y"),
            alt::path_group("path_group"),
        ))?;

    // 3. The rain: every observation, jittered inside the violin.
    let rain = chart!(&penguins)?
        .mark_point()?
        .configure_point(|point| {
            point
                .with_layout("jitter")
                .with_size(2.5)
                .with_opacity(0.55)
        })
        .encode((alt::x("Species"), alt::y("Body Mass (g)")))?;

    violin
        .and(inner_box)
        .and(rain)
        .with_title("Body mass by species (raincloud)")
        .with_x_label("")
        .with_y_label("Body mass (g)")
        .save("docs/src/images/raincloud.svg")?;

    println!("Saved docs/src/images/raincloud.svg");
    Ok(())
}

Tuning the violin

Shape options live in 1-D Density; these control the mark and its lanes:

MethodMeaning
configure_violin(…).with_bandwidth(BandwidthType::Silverman)smoothing rule (Scott by default)
.with_trim(true)stop each curve at its own data range (the default for violins)
.with_split(true)draw two groups as one split violin
.with_scale(BandScale::PerGroup)every band has the same maximum width (default)
.with_scale(BandScale::Global)bands share one scale, so a denser group looks wider
.with_scale(BandScale::Raw)use the width column as it stands
.with_width(0.5)maximum width of a single band (matches the box plot)
.with_side(BandSide::Right)keep one bank of the violin (a half violin / ridgeline)
.with_overlap(2.5)grow each band past its lane so neighbours overlap
.with_span(0.7)total width of a category's group (matches the box plot and point marks)
.with_color(…), .with_opacity(…), .with_stroke(…)the outline's visual style

Choosing a layout

VariantUse it when
Single violinone distribution
Facetedcompare a few groups, each with room to breathe
Dodgedcompare many groups within one category on a shared axis
Splitexactly two groups, to save horizontal space
Raincloudthe sample is small enough that showing every point matters

How mark_violin is built

A dedicated mark makes the common case one line, while the grammar keeps the hard cases expressible. Because the mark expands into ordinary parts:

  • the polygon renderer is shared with maps and custom shapes;
  • new combinations are new layers, not new renderers;
  • every rendering backend (SVG, PNG, PDF, GPU) already knows how to draw the parts.

Anything the mark does not expose is still reachable with transform_density, transform_band / transform_quantile_box, and the basic marks.

Built from the primitives

The same picture without mark_violin: estimate the density, mirror it into a polygon, then fill and outline it. It renders byte for byte like the mark above, which is what makes mark_violin a name for this recipe rather than a second implementation.

//! Violin plot — built by hand from the primitives.
//!
//! This is the same picture as `violin.rs`, written without `mark_violin` so the
//! composition is visible. It is the reference for anyone who wants to combine
//! the basic parts differently. A violin draws the *density* of a numeric column
//! as a symmetric outline, from three ordinary pieces:
//!
//! 1. `transform_density` estimates the density curve (the statistical part);
//! 2. `transform_band` mirrors it around the centre into a polygon;
//! 3. `mark_polygon` draws the closed, filled outline.
//!
//! Grouping is a separate concern: add `alt::x` for one violin per category,
//! `alt::color` to sit several side by side (dodged), or
//! `transform_band(…).with_split(true)` for a split violin. This is exactly what
//! `mark_violin` expands to, so the two render byte for byte identically.

use charton::prelude::*;
use std::error::Error;

fn main() -> Result<(), Box<dyn Error>> {
    let iris = load_dataset("iris")?;

    chart!(iris)?
        // 1. Estimate the density of sepal length. Trim to the observed range:
        //    a violin ends at the data, it does not fade into a thin tail.
        .transform_density(
            DensityTransform::new("sepal_length")
                .with_as("sepal_length", "density")
                .with_trim(true),
        )?
        // 2. Mirror the curve around the centre and emit a polygon.
        .transform_band(BandTransform::new("sepal_length", "density"))?
        // 3. Fill and outline the closed shape.
        .mark_polygon()?
        .configure_geoshape(|mark| {
            mark.with_fill("#7fb3d5")
                .with_opacity(0.6)
                .with_stroke("#2c3e50")
                .with_stroke_width(1.0)
        })
        .encode((
            alt::x("x"),
            alt::y("y").with_label("sepal_length"),
            alt::path_group("path_group"),
        ))?
        .with_title("Sepal length")
        .with_x_label("Density")
        .with_y_label("Sepal length (cm)")
        .save("target/example-output/violin_manual.svg")?;

    println!("Saved target/example-output/violin_manual.svg");
    Ok(())
}

See also

  • 1-D Density — the KDE and its tuning (bandwidth, kernel, trim).
  • Box Plots — the inner quantile box and the box-plot mark.
  • Composing Your Own — recombining the primitives into new pictures the marks do not cover.
  • Transforms & Columns — which columns each transform reads and emits (density, path_group, level, …).
  • Primitives: transform_density, transform_band, transform_quantile_box, mark_polygon, mark_area — see Marks & Geometries.