Bars, Ranges & Comparisons

This page covers value comparisons: bars and error bars, and the range recipes built on the same pieces — a dumbbell, a lollipop, a range plot, a waterfall and a candlestick. They are all ordinary layers, so they share one set of scales and compose with .and(…).

Basic Bar with Error Bars

Error bars are essential for communicating the precision of your data. This example shows the most common use case: a bar chart where each bar is accompanied by a vertical error bar calculated from pre-defined standard deviation values.

Key Concept: We use transform_calculate to dynamically create value_min and value_max columns within the chart pipeline.

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

fn main() -> Result<(), Box<dyn Error>> {
    // Create sample data with x and y values
    let type1 = ["a", "b", "c", "d"];
    let value = [4.9, 5.3, 5.5, 6.5];
    let value_std = [0.3, 0.39, 0.34, 0.20];

    // Create error bar chart using transform_calculate to add min/max values
    let errorbar = chart!(type1, value, value_std)?
        // Use transform_calculate to create ymin and ymax columns based on fixed std values
        .transform_calculate("value_min", |row| {
            Some(row.val("value")? - row.val("value_std")?)
        })?
        .transform_calculate("value_max", |row| {
            Some(row.val("value")? + row.val("value_std")?)
        })?
        .mark_errorbar()?
        .encode((alt::x("type1"), alt::y("value_min"), alt::y2("value_max")))?;
    let bar = chart!(type1, value)?
        .mark_bar()?
        .encode((alt::x("type1"), alt::y("value")))?;

    // Create a layered chart and add the errorbar chart as a layer
    errorbar
        .and(bar)
        .with_y_label("value")
        .save("docs/src/images/bar_with_errorbar.svg")?;

    Ok(())
}

Grouped Bar with ErrorBar

When multiple groups are present (mapped to color), Charton automatically applies "dodge" logic to ensure that both the bars and the error bars are aligned side-by-side for each category.

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

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

    // 2. Build the Error Bar layer
    let errorbar = chart!(&ds)?
        .mark_errorbar()?
        // Mapping 'Sex' to color triggers the dodge logic
        .encode((
            alt::x("Species"),
            alt::y("Body Mass (g)"),
            alt::color("Sex"),
        ))?;

    // 3. Add a Bar layer to see the alignment
    let bar = chart!(ds)?.mark_bar()?.encode((
        alt::x("Species"),
        alt::y("Body Mass (g)").with_aggregate("mean"),
        alt::color("Sex"),
    ))?;

    // 4. Create the multiple layered Chart
    errorbar
        .and(bar)
        .with_size(600, 400)
        .with_title("Grouped Error Bars with Mean & Std Dev")
        .save("docs/src/images/grouped_bar_with_errorbar_1.svg")?;

    Ok(())
}

As an alternative approach, we demonstrate how to create a grouped error bar chart by manually defining the error boundaries using transform_calculate. While the previous one use automatic statistical aggregations, this method shows that the data generated through Charton's internal transformation pipeline is fully compatible across different layers. By calculating value_min and value_max within the errorbar layer, we ensure that the resulting dataset structure remains consistent with the mark_bar layer.

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

fn main() -> Result<(), Box<dyn Error>> {
    // Create sample data with x and y values
    let type1 = ["a", "a", "a", "a", "b", "b", "b", "b", "c", "c", "c", "c"];
    let value = [4.1, 5.3, 5.5, 6.5, 4.2, 5.1, 5.7, 5.5, 4.3, 5.5, 5.1, 6.8];
    let value_std = [
        0.22, 0.26, 0.14, 0.23, 0.2, 0.23, 0.12, 0.25, 0.21, 0.20, 0.16, 0.25,
    ];
    let group = ["E", "F", "G", "H", "E", "F", "G", "H", "E", "F", "G", "H"];

    // Create error bar chart using transform_calculate to add min/max values
    let errorbar = chart!(type1, value, value_std, group)?
        // Use transform_calculate to create ymin and ymax columns based on fixed std values
        .transform_calculate("value_min", |row| {
            Some(row.val("value")? - row.val("value_std")?)
        })?
        .transform_calculate("value_max", |row| {
            Some(row.val("value")? + row.val("value_std")?)
        })?
        .mark_errorbar()?
        .encode((
            alt::x("type1"),
            alt::y("value_min"),
            alt::y2("value_max"),
            alt::color("group"),
        ))?;

    // Create a bar chart
    let bar = chart!(type1, value, value_std, group)?
        .mark_bar()?
        .encode((alt::x("type1"), alt::y("value"), alt::color("group")))?;

    // Create a layered chart
    errorbar
        .and(bar)
        .save("docs/src/images/grouped_bar_with_errorbar_2.svg")?;

    Ok(())
}

Stacked bar

Segments stacked by a colour group; stack: "stacked" (the default for a grouped bar).

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

fn main() -> Result<(), Box<dyn Error>> {
    let month = [
        "Jan", "Jan", "Jan", "Jan", "Feb", "Feb", "Feb", "Feb", "Mar", "Mar", "Mar", "Mar",
    ];
    let revenue = [
        500.0, 120.1, 90.0, 140.0, 110.0, 130.0, 100.0, 120.0, 90.0, 140.0, 110.0, 130.0,
    ];
    let region = [
        "North", "South", "East", "West", "North", "South", "East", "West", "North", "South",
        "East", "West",
    ];

    // Create a bar chart with color encoding
    chart!(month, revenue, region)?
        .mark_bar()?
        .configure_bar(|b| {
            b.with_stroke("black")
                .with_stroke_width(1.0)
                .with_width(0.5)
        })
        .encode((
            alt::x("month"),
            alt::y("revenue").with_normalize(true).with_stack("stacked"),
            alt::color("region"),
        ))?
        .with_title("Colored Bar Chart Example")
        .save("docs/src/images/stacked_bar.svg")?;

    Ok(())
}

Error bar on its own

The interval geometry by itself, without a bar:

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

fn main() -> Result<(), Box<dyn Error>> {
    // Create sample data with x and y values
    let type1 = [1.2, 2.2, 3.0, 4.1];
    let value = [4.9, 5.3, 5.5, 6.5];
    let value_std = [0.2, 0.23, 0.14, 0.25];

    // Create error bar chart using transform_calculate to add min/max values
    chart!(type1, value, value_std)?
        // Use transform_calculate to create ymin and ymax columns based on fixed std values
        .transform_calculate("value_min", |row| {
            Some(row.val("value")? - row.val("value_std")?)
        })?
        .transform_calculate("value_max", |row| {
            Some(row.val("value")? + row.val("value_std")?)
        })?
        .mark_errorbar()?
        .encode((alt::x("type1"), alt::y("value_min"), alt::y2("value_max")))?
        .coord_flip()
        .save("docs/src/images/errorbar.svg")?;

    Ok(())
}

Dumbbell (connected dot)

A dumbbell compares two values for each category — a before/after, an A/B, a min/max. Each row gets a dot at both values and a segment between them: the dot position shows the level, and the segment length shows how much changed.

It is a recipe, not a mark. mark_rule draws the connector from two wide columns (y and y2); the dots come from a tidy table so the period can drive the colour and produce a legend. coord_flip stands the categories up so the segments run horizontally, and with_dodge(false) keeps the two ends on the segment — a colour channel is otherwise a lane, which would push the periods apart into two columns.

//! Dumbbell (connected dot) — two values per category, joined by a segment.
//!
//! A dumbbell is a recipe, not a mark. `mark_rule` draws the segment between the
//! two values and a `mark_point` layer marks each end; all layers share the
//! category and value scales, so they line up automatically. `coord_flip`
//! stands the categories up so the segments run horizontally.

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

fn main() -> Result<(), Box<dyn Error>> {
    // Unemployment rate (%), 2011 vs 2021, for seven countries.
    let country = [
        "Belarus",
        "Ireland",
        "Moldova",
        "Malta",
        "Puerto Rico",
        "Senegal",
        "Slovenia",
    ];
    let y2011 = [6.17, 15.35, 6.68, 6.38, 15.70, 10.36, 8.17];
    let y2021 = [4.74, 6.63, 3.96, 3.50, 8.27, 3.72, 4.42];

    // 1. The connector: one rule per country, from the 2011 value to the 2021
    //    value. The rule geometry reads `y` and `y2` from one wide row.
    let connector = chart!(country, y2011, y2021)?
        .mark_rule()?
        .configure_rule(|rule| rule.with_color("#b9bfc6").with_stroke_width(2.0))
        .encode((alt::x("country"), alt::y("y2011"), alt::y2("y2021")))?;

    // 2. The two ends: a tidy (long) table, one row per country and period, so
    //    the period can drive the colour and produce a legend.
    let countries_long: Vec<&str> = country.iter().chain(country.iter()).copied().collect();
    let periods: Vec<&str> = std::iter::repeat("2011")
        .take(7)
        .chain(std::iter::repeat("2021").take(7))
        .collect();
    let values: Vec<f64> = y2011.iter().chain(y2021.iter()).copied().collect();

    let ends = Dataset::new()
        .with_column("country", countries_long)?
        .with_column("period", periods)?
        .with_column("value", values)?;

    connector
        .and(
            chart!(&ends)?
                .mark_point()?
                // `with_dodge(false)` keeps both ends on the shared segment: a
                // colour channel is otherwise a *lane*, which would push the
                // two periods apart.
                .configure_point(|point| point.with_size(9.0).with_dodge(false))
                .encode((alt::x("country"), alt::y("value"), alt::color("period")))?,
        )
        .coord_flip()
        .with_title("Unemployment rate, 2011 vs 2021")
        .with_x_label("")
        .with_y_label("Unemployment rate (%)")
        .with_color_label("Year")
        .save("docs/src/images/dumbbell.svg")?;

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

Lollipop and range

A bar with the fill thrown away: mark_rule draws the stem and mark_point caps it. A range plot is the same rule with both ends away from the baseline, and the dumbbell above is a range plot with two dots.

//! Lollipop — a bar with the fill thrown away.
//!
//! A recipe: `mark_rule` draws a thin stem from the baseline to each value and
//! `mark_point` caps it. Two ordinary geometries, no new mark. A *range plot*
//! is the same idea with both ends off the baseline (`y` to `y2`).

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

fn main() -> Result<(), Box<dyn Error>> {
    let language = [
        "Python",
        "JavaScript",
        "Rust",
        "Go",
        "C++",
        "Java",
        "TypeScript",
        "Ruby",
    ];
    let share = [28.0, 22.0, 12.0, 8.0, 7.0, 6.0, 5.0, 3.0];
    let base = [0.0; 8];

    let stems = chart!(language, share, base)?
        .mark_rule()?
        .configure_rule(|rule| rule.with_color("#b9bfc6").with_stroke_width(2.0))
        .encode((alt::x("language"), alt::y("base"), alt::y2("share")))?;

    let caps = chart!(language, share)?
        .mark_point()?
        .configure_point(|point| point.with_color("#4c78a8").with_size(9.0))
        .encode((alt::x("language"), alt::y("share")))?;

    stems
        .and(caps)
        .coord_flip()
        .with_title("Language popularity")
        .with_x_label("")
        .with_y_label("Share (%)")
        .save("docs/src/images/lollipop.svg")?;

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

Waterfall

A running total as floating bars. transform_window with WindowOnlyOp::CumulativeSum carries the total, and a y2 bound floats each bar between the total before and after its step; colour splits increases from decreases.

//! Waterfall — a running total shown as floating bars.
//!
//! Two public primitives do the work: `transform_window` with
//! `WindowOnlyOp::CumulativeSum` carries the running total, and `mark_bar` with
//! a `y2` bound draws each step as a bar that floats between the total *before*
//! and *after* it. The opening and closing **totals** are the same idea anchored
//! at zero (`base = 0`), so the picture needs one geometry, not two.

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

fn main() -> Result<(), Box<dyn Error>> {
    // Opening total, quarterly contributions, closing total.
    let step = ["Start", "Q1", "Q2", "Q3", "Q4", "End"];
    let delta = [200.0, 50.0, 30.0, -20.0, -10.0, 0.0];
    let kind = [
        "Total", "Increase", "Increase", "Decrease", "Decrease", "Total",
    ];

    chart!(step, delta, kind)?
        // The running total after each step ...
        .transform_window(WindowTransform::new(WindowFieldDef::new(
            "delta",
            WindowOnlyOp::CumulativeSum,
            "total",
        )))?
        // ... and the bar's lower edge: the total before the step, or zero for
        // the opening/closing total columns.
        .transform_calculate("base", |row| {
            if row.str("kind").as_deref() == Some("Total") {
                Some(0.0)
            } else {
                Some(row.val("total")? - row.val("delta")?)
            }
        })?
        .mark_bar()?
        .configure_bar(|bar| bar.with_width(0.6))
        .encode((
            alt::x("step"),
            alt::y("base"),
            alt::y2("total"),
            alt::color("kind"),
        ))?
        .with_title("Running total")
        .with_x_label("")
        .with_y_label("Value")
        .with_color_label("")
        .save("docs/src/images/waterfall.svg")?;

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

Candlestick

OHLC bars: mark_rule draws the high–low wick and a floating mark_bar (its y2 bound) draws the open–close body, coloured by direction.

//! Candlestick — an OHLC bar per period.
//!
//! A recipe: `mark_rule` draws the high–low wick (`y` to `y2`) and `mark_bar`
//! with a `y2` bound draws the open–close body as a *floating* bar. Colour tells
//! an up day from a down day. The two layers share the category axis, so the
//! bodies sit exactly on their wicks.
//!
//! The prices are a seeded random walk, so the figure looks like a real price
//! series while still rendering identically every run.

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

fn main() -> Result<(), Box<dyn Error>> {
    // A tiny seeded LCG: deterministic, but with the run of a random walk.
    let mut seed: u64 = 0x9E37_79B9_7F4A_7C15;
    let mut noise = || {
        seed = seed
            .wrapping_mul(6364136223846793005)
            .wrapping_add(1442695040888963407);
        (seed >> 11) as f64 / (1u64 << 53) as f64 * 2.0 - 1.0
    };

    let days = 22;
    let mut open = Vec::with_capacity(days);
    let mut high = Vec::with_capacity(days);
    let mut low = Vec::with_capacity(days);
    let mut close = Vec::with_capacity(days);
    let round = |v: f64| (v * 100.0).round() / 100.0;
    let mut price = 100.0;

    for _ in 0..days {
        let o = price + noise() * 0.8; // gap from the previous close
        let c = o + noise() * 2.0 + (100.0 - o) * 0.08; // move, pulled toward 100
        let h = o.max(c) + noise().abs() * 1.5 + 0.2;
        let l = o.min(c) - noise().abs() * 1.5 - 0.2;
        open.push(round(o));
        close.push(round(c));
        high.push(round(h));
        low.push(round(l));
        price = c;
    }

    let day: Vec<String> = (1..=days).map(|d| format!("07-{d:02}")).collect();
    let direction: Vec<&str> = open
        .iter()
        .zip(close.iter())
        .map(|(o, c)| if c >= o { "Up" } else { "Down" })
        .collect();

    // A floating bar (a `y2` bound) does not force a zero baseline, so the price
    // axis stays on the traded range and the candles fill the panel.
    let wick = chart!(day, low, high, direction)?
        .mark_rule()?
        .configure_rule(|rule| rule.with_stroke_width(1.0))
        .encode((
            alt::x("day"),
            alt::y("low"),
            alt::y2("high"),
            alt::color("direction"),
        ))?;

    let body = chart!(day, open, close, direction)?
        .mark_bar()?
        .configure_bar(|bar| bar.with_width(0.6))
        .encode((
            alt::x("day"),
            alt::y("open"),
            alt::y2("close"),
            alt::color("direction"),
        ))?;

    wick.and(body)
        .configure_theme(|theme| {
            // Chinese-market convention: red for an up day, green for a down day.
            theme
                .with_palette(ColorPalette::from(vec!["#e53935", "#43a047"]))
                .with_x_tick_label_angle(-45.0)
        })
        .with_title("Daily price")
        .with_x_label("")
        .with_y_label("Price")
        .with_color_label("")
        .save("docs/src/images/candlestick.svg")?;

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