Line Charts

A line connects the rows of a group in x-order. One group per colour, so a multi-series line is just color.

Multiple series

Two curves on one axis. configure_line also offers smoothing (LOESS) when the data is noisy.

use charton::prelude::*;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Create data for multiple groups (e.g., sine and cosine curves)
    let x_values: Vec<f64> = (0..30).map(|i| i as f64 * 0.2).collect();
    let y_sine: Vec<f64> = x_values.iter().map(|&x| x.sin()).collect();
    let y_cosine: Vec<f64> = x_values.iter().map(|&x| x.cos()).collect();

    // Combine data into a single DataFrame with categories
    let x = x_values.repeat(2); // Repeat x values for both groups
    let y = [y_sine.clone(), y_cosine.clone()].concat();
    let category = [vec!["Sine"; 30], vec!["Cosine"; 30]].concat();

    // Create a line chart with multiple groups
    chart!(x, y, category)?
        .mark_line()?
        .configure_line(|l| l.with_loess(true).with_loess_bandwidth(0.2))
        .encode((
            alt::x("x"),
            alt::y("y"),
            alt::color("category"), // This creates separate lines for each category
        ))?
        .configure_theme(|t| t.with_x_tick_label_angle(-45.0))
        .coord_flip()
        .save("docs/src/images/line.svg")?;

    Ok(())
}

Gaps

A missing x or y breaks the line instead of drawing across it, so an outage in a time series reads as a gap in the curve. See Missing Values & Gaps.

Cumulative frequency

A running total as a line — the cumulative shape of a distribution without a histogram.

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

fn main() -> Result<(), Box<dyn Error>> {
    let ds = load_dataset("iris")?;
    // Create a chart with window transform
    chart!(ds)?
        .transform_window(
            WindowTransform::new(WindowFieldDef::new(
                "sepal_length",
                WindowOnlyOp::CumeDist,
                "ecdf", // This will be the output column name
            ))
            .with_groupbys(["species"])
            .with_normalize(false),
        )?
        .mark_line()?
        .configure_line(|l| l.with_interpolation("step")) // Add step interpolation
        .encode((
            alt::x("sepal_length"),
            alt::y("ecdf"),
            alt::color("species"),
        ))?
        .with_title("Empirical Cumulative Distribution")
        .save("docs/src/images/cumulative_frequency.svg")?;

    Ok(())
}

Multi-series with error bars

Lines plus mark_errorbar and points, layered with .and(…) — the shape of a published trial figure.

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

// The data is obtained from paper "Once-Weekly Semaglutide in Adults with Overweight or Obesity"
// using [webplotdigitizer](https://automeris.io/).
fn main() -> Result<(), Box<dyn Error>> {
    // Placebo group data (control group)
    let ds_placebo = vec![
        // X-axis: time since randomization (weeks)
        (
            "Weeks since Randomization",
            [0, 4, 8, 12, 16, 20, 28, 36, 44, 52, 60, 68].into_column(),
        ),
        // Y-axis: mean percentage change in body weight from baseline. Negative values indicate weight loss
        (
            "Change from Baseline (%)",
            [
                0.00, -1.11, -1.72, -2.18, -2.54, -2.83, -2.82, -2.98, -3.24, -3.31, -3.22, -2.76,
            ]
            .into_column(),
        ),
        // Lower bound of the confidence interval (typically 95% CI)
        (
            "lower",
            [
                -0.042, -1.18, -1.81, -2.28, -2.66, -3.00, -3.03, -3.22, -3.49, -3.54, -3.46, -3.03,
            ]
            .into_column(),
        ),
        // Upper bound of the confidence interval (95% CI)
        (
            "upper",
            [
                0.042, -1.04, -1.63, -2.08, -2.42, -2.66, -2.61, -2.74, -2.99, -3.08, -2.98, -2.49,
            ]
            .into_column(),
        ),
    ]
    .to_dataset()?;

    // Semaglutide group data (treatment group)
    let ds_semaglutide = vec![
        (
            "Weeks since Randomization",
            [0, 4, 8, 12, 16, 20, 28, 36, 44, 52, 60, 68].into_column(),
        ),
        (
            "Change from Baseline (%)",
            [
                0.00, -2.27, -4.01, -5.9, -7.66, -9.46, -11.68, -13.33, -14.62, -15.47, -15.86,
                -15.6,
            ]
            .into_column(),
        ),
        (
            "lower",
            [
                -0.041, -2.3, -4.1, -5.98, -7.79, -9.58, -11.84, -13.55, -14.83, -15.72, -16.13,
                -15.86,
            ]
            .into_column(),
        ),
        (
            "upper",
            [
                0.041, -2.24, -3.92, -5.82, -7.53, -9.34, -11.52, -13.11, -14.41, -15.22, -15.59,
                -15.34,
            ]
            .into_column(),
        ),
    ]
    .to_dataset()?;

    // Text labels (placed at the right side of the plot)
    let ds_text = vec![
        ("x", [68.8, 68.8].into_column()),
        ("y", [-3.05, -15.86].into_column()),
        ("group", ["Placebo", "Semaglutide"].into_column()),
    ]
    .to_dataset()?;

    // Reference line (y = 0 → no weight change)
    let ds_reference = vec![
        ("x", [0.0, 76.0].into_column()),
        ("y", [0.0, 0.0].into_column()),
    ]
    .to_dataset()?;

    // Layer 1: Placebo points (markers at each time point)
    let placebo_point = Chart::build(&ds_placebo)?
        .mark_point()?
        .configure_point(|p| {
            p.with_color("#818284")
                .with_shape("triangle")
                .with_size(5.0)
        })
        .encode((
            alt::x("Weeks since Randomization"),
            alt::y("Change from Baseline (%)"),
        ))?;

    // Layer 2: Placebo line (connects the points)
    let placebo_line = Chart::build(&ds_placebo)?
        .mark_line()?
        .configure_line(|l| l.with_color("#818284"))
        .encode((
            alt::x("Weeks since Randomization"),
            alt::y("Change from Baseline (%)"),
        ))?;

    // Layer 3: Placebo error bars (confidence intervals)
    let placebo_errorbar = Chart::build(&ds_placebo)?
        .mark_errorbar()?
        .configure_errorbar(|e| {
            e.with_color("#818284")
                .with_cap_length(4.0)
                .with_stroke_width(1.5)
        })
        .encode((
            alt::x("Weeks since Randomization"),
            alt::y("lower"),
            alt::y2("upper"),
        ))?;

    // Layer 4: Placebo text label
    let placebo_text = Chart::build(ds_text.head(1))?
        .mark_text()?
        .configure_text(|t| t.with_anchor("left").with_size(14.0))
        .encode((alt::x("x"), alt::y("y"), alt::text("group")))?;

    // Layer 5: Semaglutide points
    let semaglutide_point = Chart::build(&ds_semaglutide)?
        .mark_point()?
        .configure_point(|p| p.with_color("#5b88c3").with_shape("square").with_size(3.0))
        .encode((
            alt::x("Weeks since Randomization"),
            alt::y("Change from Baseline (%)"),
        ))?;

    // Layer 6: Semaglutide line
    let semaglutide_line = Chart::build(&ds_semaglutide)?
        .mark_line()?
        .configure_line(|l| l.with_color("#5b88c3"))
        .encode((
            alt::x("Weeks since Randomization"),
            alt::y("Change from Baseline (%)"),
        ))?;

    // Layer 7: Semaglutide error bars
    let semaglutide_errorbar = Chart::build(&ds_semaglutide)?
        .mark_errorbar()?
        .configure_errorbar(|e| {
            e.with_color("#5b88c3")
                .with_cap_length(4.0)
                .with_stroke_width(1.5)
        })
        .encode((
            alt::x("Weeks since Randomization"),
            alt::y("lower"),
            alt::y2("upper"),
        ))?;

    // Layer 8: Semaglutide text label
    let semaglutide_text = Chart::build(ds_text.tail(1))?
        .mark_text()?
        .configure_text(|t| t.with_anchor("left").with_size(14.0))
        .encode((alt::x("x"), alt::y("y"), alt::text("group")))?;

    // Layer 9: Reference line (baseline at 0%)
    let reference_line = Chart::build(&ds_reference)?
        .mark_line()?
        .configure_line(|l| l.with_dash([6.0, 6.0]))
        .encode((alt::x("x"), alt::y("y")))?;

    // Combine all layers (Grammar of Graphics composition)
    placebo_point
        .and(reference_line)
        .and(placebo_line)
        .and(placebo_errorbar)
        .and(placebo_text)
        .and(semaglutide_point)
        .and(semaglutide_line)
        .and(semaglutide_errorbar)
        .and(semaglutide_text)
        .with_x_expand(Expansion {
            mult: (0.00, 0.0),
            add: (0.0, 0.0),
        })
        .with_y_expand(Expansion {
            mult: (0.15, 0.01),
            add: (0.0, 0.0),
        })
        .with_size(1000, 400)
        .with_right_margin(0.08)
        .with_left_margin(0.02)
        .with_top_margin(0.02)
        .with_bottom_margin(0.03)
        .with_x_ticks([
            0.0, 4.0, 8.0, 12.0, 16.0, 20.0, 28.0, 36.0, 44.0, 52.0, 60.0, 68.0,
        ])
        .with_y_ticks([
            0.0, -2.0, -4.0, -6.0, -8.0, -10.0, -12.0, -14.0, -16.0, -18.0,
        ])
        .save("docs/src/images/weight_loss_curve.svg")?;

    Ok(())
}

Formatted axis labels (Economist style)

Axis labels are a scale concern: with_y_label_format(...) renders large values as $20B instead of raw numbers.

//! Production-style label formatting.
//!
//! Reproduces the scenario from the issue: an "Economist-style" investment
//! chart where the y axis shows large values as `$20B` instead of raw numbers,
//! and the legend labels are tidied up.
//!
//! Run with: `cargo run --example label_format`

use charton::core::guide::LegendPosition;
use charton::prelude::*;

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Three focus areas, eight years, values in dollars.
    let years: Vec<f64> = (2015..2023).map(|year| year as f64).collect();
    let areas = [
        "Medical and healthcare",
        "Data management, processing, cloud",
        "Retail",
    ];

    let mut x = Vec::new();
    let mut investment = Vec::new();
    let mut area = Vec::new();
    for (index, name) in areas.iter().enumerate() {
        for year in &years {
            x.push(*year);
            // A made-up exponential-ish growth curve per area.
            let base = 2.0e9 * (index as f64 + 1.0);
            investment.push(base * (1.0 + (year - 2015.0) * 0.45).powf(1.8));
            area.push(*name);
        }
    }

    // Dollars, abbreviated, no decimals: 20_000_000_000 -> "$20B".
    let money = LabelFormat::new()
        .with_prefix("$")
        .with_compact_notation()
        .with_precision(0);

    // Legend labels: turn the long data names into short, readable ones.
    let short_names = LabelFormat::new().with_text_formatter(|label| match label {
        "Medical and healthcare" => "Healthcare".to_string(),
        "Data management, processing, cloud" => "Data & cloud".to_string(),
        other => other.to_string(),
    });

    chart!(x, investment, area)?
        .mark_line()?
        .encode((alt::x("x"), alt::y("investment"), alt::color("area")))?
        .with_size(760, 460)
        .with_x_label("Year")
        .with_y_label("")
        .with_color_label("Focus area")
        .with_y_label_format(money)
        .with_legend_label_format(short_names)
        .configure_theme(|theme| theme.with_legend_position(LegendPosition::Top))
        .save("docs/src/images/label_format.svg")?;

    Ok(())
}

Slope chart

Change between two points in time: mark_line connects each group's two values and mark_point marks them. Grouping the line is just color.

//! Slope chart — change between two points in time.
//!
//! A recipe: `mark_line` connects each country's two values across the two
//! periods and `mark_point` marks them. Grouping the line by colour is all it
//! takes; the two layers share the category and value scales.

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

fn main() -> Result<(), Box<dyn Error>> {
    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];

    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 ds = Dataset::new()
        .with_column("country", countries_long)?
        .with_column("period", periods)?
        .with_column("rate", values)?;

    let lines = chart!(&ds)?
        .mark_line()?
        .configure_line(|line| line.with_stroke_width(2.0))
        .encode((alt::x("period"), alt::y("rate"), alt::color("country")))?;

    let dots = chart!(&ds)?
        .mark_point()?
        .configure_point(|point| point.with_size(7.0))
        .encode((alt::x("period"), alt::y("rate"), alt::color("country")))?;

    lines
        .and(dots)
        .with_title("Unemployment rate, 2011 vs 2021")
        .with_x_label("")
        .with_y_label("Unemployment rate (%)")
        .save("docs/src/images/slope.svg")?;

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

Bump chart

How a ranking moves over time. transform_window with WindowOnlyOp::Rank ranks each group inside every period, and ordinary line/point layers draw the trajectories. The y-axis is reversed with with_reverse(true) so rank 1 sits at the top.

//! Bump chart — how a ranking changes over time.
//!
//! Ranks are a statistic, not a new geometry: `transform_window` with
//! `WindowOnlyOp::Rank` ranks each country inside every year, and ordinary
//! `mark_line`/`mark_point` layers draw the trajectories. The y-axis is
//! reversed (`with_reverse(true)`) so rank 1 sits at the top.

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

fn main() -> Result<(), Box<dyn Error>> {
    let unemployment = load_dataset("unemployment")?;
    let rank = || {
        WindowTransform::new(WindowFieldDef::new(
            "Unemployment rate (%)",
            WindowOnlyOp::Rank,
            "rank",
        ))
        .with_groupbys(["Year"])
    };

    let lines = chart!(&unemployment)?
        .transform_window(rank())?
        .mark_line()?
        .configure_line(|line| line.with_stroke_width(3.0))
        .encode((
            alt::x("Year"),
            alt::y("rank").with_reverse(true),
            alt::color("Country"),
        ))?;

    let dots = chart!(&unemployment)?
        .transform_window(rank())?
        .mark_point()?
        .configure_point(|point| point.with_size(7.0))
        .encode((
            alt::x("Year"),
            alt::y("rank").with_reverse(true),
            alt::color("Country"),
        ))?;

    lines
        .and(dots)
        .with_title("Unemployment rank by year")
        .with_x_label("")
        .with_y_label("Rank")
        .save("docs/src/images/bump.svg")?;

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