Functional Weave
Code in Rust

charts.line-chart

Complete line chart geometry as data: plot area, number or date axes, gridlines, series paths, colours, legend.

1.0.0 · published 2026-10-03 by charlie · Anterra

Pinned by 11 tests, run in TypeScript, Python and Rust.

What it does

A complete line chart as data: where the plot sits, both axes with their ticks and labels, horizontal gridlines, one SVG path per series with its colour, and a legend. Nothing is drawn; any renderer (SVG, canvas, a PDF library, a native view) turns it into pixels, and because every number comes from the same rules the chart is identical whether it was built in TypeScript, Python or Rust.

It is assembled from the rest of the charts suite: `charts.extent`, `charts.scale`, `charts.ticks`, `charts.format`, `charts.axis`, `charts.shape`, `charts.layout` and `charts.palette`.

For example

  • line_chart(width 300, height 200, x 0, 1, 2, 3, 4, dates —, series ×1, curve linear, y zero true) → width 300, height 200, plot …, x axis …, y axis …, grid lines ×4, lines ×1, legend one series over numbers: y stretched to zero and niced to 0-60, no legend
  • line_chart(width 400, height 250, x —, dates 2026-01-01, 2026-02-01, 2026-03-01, 2026-04-01, series ×2, curve linear, y zero false) → width 400, height 250, plot …, x axis …, y axis …, grid lines ×4, lines ×2, legend ×2 two series over dates: monthly ticks, a legend row, and a gap that breaks line A
  • line_chart(width 200, height 150, x 0, 1, dates —, series ×1, curve step, y zero false) → width 200, height 150, plot …, x axis …, y axis …, grid lines ×3, lines ×1, legend a step curve: the step sits midway, and labels get one place for a 0.5 step

The function

The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.

pub fn line_chart(spec: &LineChartSpec) -> LineChart
specLineChartSpec
returnsLineChartevery pixel value rounded to 2 places; draw it with any renderer

The types it declares, generated into your project

// LineCurve is a string in Rust, one of: "linear", "monotone", "step".
// Parameters take it as &str and results hold it as String.

/// One line.
#[derive(Debug, Clone, PartialEq)]
pub struct LineSeries {
    pub name: String,
    /// one per x value; null is a gap
    pub values: Vec<Option<f64>>,
}

/// What to draw.
#[derive(Debug, Clone, PartialEq)]
pub struct LineChartSpec {
    pub width: f64,
    pub height: f64,
    /// numeric x values, ascending; give this or dates
    pub x: Option<Vec<f64>>,
    /// ISO dates for a time axis, ascending; give this or x
    pub dates: Option<Vec<String>>,
    pub series: Vec<LineSeries>,
    /// straight segments, a smooth monotone curve, or steps
    pub curve: String,
    /// stretch the y axis to include zero
    pub y_zero: bool,
}

/// One series, ready to draw.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SeriesPath {
    pub name: String,
    /// "#rrggbb", from the Okabe-Ito palette in series order
    pub color: String,
    /// SVG path data for stroke, no fill
    pub path: String,
}

/// The whole chart; legend[i] and lines[i] are the same series.
#[derive(Debug, Clone, PartialEq)]
pub struct LineChart {
    pub width: f64,
    pub height: f64,
    pub plot: PlotArea,
    pub x_axis: Axis,
    pub y_axis: Axis,
    /// horizontal, one per y tick
    pub grid_lines: Vec<Line>,
    pub lines: Vec<SeriesPath>,
    /// empty for a single series
    pub legend: Vec<LegendItem>,
}

Your code names it in one line, in the file that uses it

fune!(charts.line-chart@^1);  // then call line_chart(…)
impl/rust.rs · 219 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::charts_axis_axis_from_ticks::{axis_to_value, line_to_value};
use super::charts_axis_grid_lines::grid_lines;
use super::charts_axis_number_axis::number_axis;
use super::charts_axis_time_axis::time_axis;
use super::charts_extent::extent;  ← from charts.extent ^1.0.0 · built alongside by fune
use super::charts_format_format_tick::format_tick;
use super::charts_layout_legend_rows::{legend_item_to_value, legend_rows};
use super::charts_layout_plot_area::{plot_area, plot_area_to_value};
use super::charts_layout_types::{LegendItem, Margins};
use super::charts_palette::palette;  ← from charts.palette ^1.0.0 · built alongside by fune
use super::charts_scale_linear_scale::linear_scale;
use super::charts_scale_nice_domain::nice_domain;
use super::charts_scale_time_scale::time_scale;
use super::charts_shape_line_path::line_path;
use super::charts_shape_monotone_curve::monotone_curve;
use super::charts_shape_step_path::step_path;
use super::charts_shape_types::Point;
use super::charts_ticks_nice_ticks::nice_ticks;
use super::charts_ticks_tick_step::tick_step;
use super::math_round_float::round_float;  ← from math.round-float ^1.0.0 · built alongside by fune

// Fixed layout constants; the README explains each.
const CHAR_WIDTH: f64 = 7.0;
const SWATCH: f64 = 10.0;
const LEGEND_GAP: f64 = 16.0;
const LEGEND_ROW: f64 = 18.0;
const EDGE: f64 = 8.0;
const TICK_SIZE: f64 = 6.0;
const LABEL_PADDING: f64 = 3.0;
const TOP_WITHOUT_LEGEND: f64 = 12.0;
const RIGHT: f64 = 20.0;
const BOTTOM: f64 = 30.0;

/// A whole line chart as geometry, by the fixed rules in the README.
///
/// # Panics
/// Panics on no series, both or neither of x and dates, a series of the wrong
/// length, no values, fewer than two different x values, or a chart too small
/// for its margins.
pub fn line_chart(spec: &LineChartSpec) -> LineChart {
    let (width, height, series) = (spec.width, spec.height, &spec.series);
    if series.is_empty() {
        panic!("a line chart needs at least one series");
    }
    if spec.x.is_none() == spec.dates.is_none() {
        panic!("give exactly one of x and dates");
    }
    let count = match (&spec.x, &spec.dates) {
        (Some(x), _) => x.len(),
        (_, Some(d)) => d.len(),
        _ => 0,
    };
    for s in series {
        if s.values.len() != count {
            panic!("series \"{}\" has {} values but there are {} x values", s.name, s.values.len(), count);
        }
    }

    let mut legend: Vec<LegendItem> = Vec::new();
    let mut top = TOP_WITHOUT_LEGEND;
    if series.len() > 1 {
        let names: Vec<String> = series.iter().map(|s| s.name.clone()).collect();
        let rows = legend_rows(&names, width - 2.0 * EDGE, CHAR_WIDTH, SWATCH, LEGEND_GAP, LEGEND_ROW);
        legend = rows
            .iter()
            .map(|it| LegendItem {
                label: it.label.clone(),
                row: it.row,
                x: round_float(it.x + EDGE, 2),
                y: round_float(it.y + EDGE, 2),
                width: it.width,
                text_x: round_float(it.text_x + EDGE, 2),
            })
            .collect();
        top = EDGE + (rows[rows.len() - 1].row + 1) as f64 * LEGEND_ROW + EDGE;
    }

    let all: Vec<Option<f64>> = series.iter().flat_map(|s| s.values.iter().cloned()).collect();
    let found = match extent(&all) {
        None => panic!("a line chart needs at least one value to plot"),
        Some(r) => r,
    };
    let (mut lo, mut hi) = (found[0], found[1]);
    if spec.y_zero {
        lo = lo.min(0.0);
        hi = hi.max(0.0);
    }
    if lo == hi {
        lo -= 1.0;
        hi += 1.0;
    }
    let y_count = (((height - top - BOTTOM) / 50.0).floor() as i64).max(2);
    let y_domain = nice_domain(&[lo, hi], y_count).domain;
    let y_step = tick_step(y_domain[0], y_domain[1], y_count);
    let widest = nice_ticks(y_domain[0], y_domain[1], y_count)
        .iter()
        .map(|v| format_tick(*v, y_step).chars().count())
        .max()
        .unwrap_or(0);
    let left = TICK_SIZE + LABEL_PADDING + widest as f64 * CHAR_WIDTH + EDGE;
    let plot = plot_area(width, height, &Margins { top, right: RIGHT, bottom: BOTTOM, left });
    let bottom_y = plot.y + plot.height;
    let y_range = vec![bottom_y, plot.y];
    let y_axis = number_axis(&y_domain, &y_range, y_count, "left", plot.x, TICK_SIZE);

    let x_range = vec![plot.x, plot.x + plot.width];
    let x_count = ((plot.width / 80.0).floor() as i64).max(2);
    let (x_axis, xs) = match (&spec.x, &spec.dates) {
        (Some(x), _) => {
            let opt: Vec<Option<f64>> = x.iter().map(|v| Some(*v)).collect();
            let d = match extent(&opt) {
                Some(d) if d[0] != d[1] => d,
                _ => panic!("a line chart needs at least two different x values"),
            };
            let xs: Vec<f64> = x.iter().map(|v| linear_scale(&d, &x_range, *v, false)).collect();
            (number_axis(&d, &x_range, x_count, "bottom", bottom_y, TICK_SIZE), xs)
        }
        (_, Some(dates)) => {
            let first = dates.iter().min().cloned();
            let last = dates.iter().max().cloned();
            let (first, last) = match (first, last) {
                (Some(f), Some(l)) if f != l => (f, l),
                _ => panic!("a line chart needs at least two different x values"),
            };
            let domain = vec![first, last];
            let xs: Vec<f64> = dates.iter().map(|d| time_scale(&domain, &x_range, d, false)).collect();
            (time_axis(&domain, &x_range, x_count, "bottom", bottom_y, TICK_SIZE), xs)
        }
        _ => unreachable!(),
    };

    let colors = palette("okabe-ito", series.len() as i64, true);
    let lines: Vec<SeriesPath> = series
        .iter()
        .enumerate()
        .map(|(i, s)| {
            let points: Vec<Point> = s
                .values
                .iter()
                .enumerate()
                .map(|(j, v)| Point { x: xs[j], y: v.map(|v| linear_scale(&y_domain, &y_range, v, false)) })
                .collect();
            let path = match spec.curve.as_str() {
                "monotone" => monotone_curve(&points),
                "step" => step_path(&points, "middle"),
                _ => line_path(&points),
            };
            SeriesPath { name: s.name.clone(), color: colors[i].clone(), path }
        })
        .collect();

    LineChart {
        width: round_float(width, 2),
        height: round_float(height, 2),
        grid_lines: grid_lines(&y_axis, plot.width),
        plot,
        x_axis,
        y_axis,
        lines,
        legend,
    }
}

fn opt_floats(v: &Value) -> Vec<Option<f64>> {
    v.as_arr().iter().map(|x| if x.is_null() { None } else { Some(x.as_f64()) }).collect()
}

pub fn line_chart_spec_from_value(v: &Value) -> LineChartSpec {
    LineChartSpec {
        width: v.get("width").as_f64(),
        height: v.get("height").as_f64(),
        x: if v.get("x").is_null() { None } else { Some(v.get("x").as_arr().iter().map(|x| x.as_f64()).collect()) },
        dates: if v.get("dates").is_null() {
            None
        } else {
            Some(v.get("dates").as_arr().iter().map(|x| x.as_str().to_string()).collect())
        },
        series: v
            .get("series")
            .as_arr()
            .iter()
            .map(|s| LineSeries { name: s.get("name").as_str().to_string(), values: opt_floats(s.get("values")) })
            .collect(),
        curve: v.get("curve").as_str().to_string(),
        y_zero: v.get("yZero").as_bool(),
    }
}

pub fn line_chart_to_value(c: &LineChart) -> Value {
    Value::obj(vec![
        ("width", Value::Float(c.width)),
        ("height", Value::Float(c.height)),
        ("plot", plot_area_to_value(&c.plot)),
        ("xAxis", axis_to_value(&c.x_axis)),
        ("yAxis", axis_to_value(&c.y_axis)),
        ("gridLines", Value::Arr(c.grid_lines.iter().map(line_to_value).collect())),
        (
            "lines",
            Value::Arr(
                c.lines
                    .iter()
                    .map(|l| {
                        Value::obj(vec![
                            ("name", Value::str(&l.name)),
                            ("color", Value::str(&l.color)),
                            ("path", Value::str(&l.path)),
                        ])
                    })
                    .collect(),
            ),
        ),
        ("legend", Value::Arr(c.legend.iter().map(legend_item_to_value).collect())),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    line_chart_to_value(&line_chart(&line_chart_spec_from_value(&args[0])))
}

Install

fune build

With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 9 dependencies, pins them in fune.lock, downloads only the Rust package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. A crate’s build.rs runs it before every compile. Or pin a range in fune.project and build in one step:

fune add charts.line-chart
Download for Rust charts.line-chart-1.0.0-rust.fune · 27,585 bytes sha256 71be077be7c8f000fe4ef383dabaa8fbff7984bb7ec81839b9e5f404e33fe46e

The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./charts.line-chart-1.0.0-rust.fune, or fetch it from a terminal with fune pull charts.line-chart@1.0.0:rust.

The whole function, every language, is one file too: charts.line-chart-1.0.0.fune, 38,535 bytes, sha256 2fb332d4d4f2ac73f5de7a6d1bd32c4b4a05d6cd5b0577ba0dea2ee6dc209855. It installs into a project of any language.

Customise it in your app

The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.

before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.

// fune: before charts.line-chart

after — your function gets the result and the arguments, and returns the final result.

// fune: after charts.line-chart

replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.

// fune: replace charts.axis in charts.line-chart
// fune: replace charts.extent in charts.line-chart
// fune: replace charts.format in charts.line-chart
// fune: replace charts.layout in charts.line-chart
// fune: replace charts.palette in charts.line-chart
// fune: replace charts.scale in charts.line-chart
// fune: replace charts.shape in charts.line-chart
// fune: replace charts.ticks in charts.line-chart
// fune: replace math.round-float in charts.line-chart

step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show charts.line-chart --steps.

// fune: step charts.line-chart after <n|label>

Tests

A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.

CaseArgumentsExpected
one series over numbers: y stretched to zero and niced to 0-60, no legend width 300, height 200, x 0, 1, 2, 3, 4, dates —, series ×1, curve linear, y zero true → width 300, height 200, plot …, x axis …, y axis …, grid lines ×4, lines ×1, legend
two series over dates: monthly ticks, a legend row, and a gap that breaks line A width 400, height 250, x —, dates 2026-01-01, 2026-02-01, 2026-03-01, 2026-04-01, series ×2, curve linear, y zero false → width 400, height 250, plot …, x axis …, y axis …, grid lines ×4, lines ×2, legend ×2
a step curve: the step sits midway, and labels get one place for a 0.5 step width 200, height 150, x 0, 1, dates —, series ×1, curve step, y zero false → width 200, height 150, plot …, x axis …, y axis …, grid lines ×3, lines ×1, legend
a monotone curve flattens into a plateau without overshooting it width 200, height 150, x 0, 0.5, 1, dates —, series ×1, curve monotone, y zero false → width 200, height 150, plot …, x axis …, y axis …, grid lines ×3, lines ×1, legend
no series is an error width 300, height 200, x 0, 1, dates —, series , curve linear, y zero false → error: a line chart needs at least one series
x and dates together is an error width 300, height 200, x 0, 1, dates 2026-01-01, 2026-01-02, series ×1, curve linear, y zero false → error: give exactly one of x and dates
neither x nor dates is an error width 300, height 200, x —, dates —, series ×1, curve linear, y zero false → error: give exactly one of x and dates
a series of the wrong length is an error width 300, height 200, x 0, 1, 2, dates —, series ×1, curve linear, y zero false → error: series "A" has 2 values but there are 3 x values
every value a gap is an error width 300, height 200, x 0, 1, dates —, series ×1, curve linear, y zero false → error: a line chart needs at least one value to plot
one x value cannot make an axis width 300, height 200, x 5, 5, dates —, series ×1, curve linear, y zero false → error: a line chart needs at least two different x values
Show the other 1 test
CaseArgumentsExpected
a chart too small for its margins is an error width 40, height 40, x 0, 1, dates —, series ×1, curve linear, y zero false → error: margins leave no room to plot

More from the author

## The x axis

Give numbers in `x` or ISO dates in `dates`, never both. Numbers get a linear axis across their exact extent, with nice ticks inside it; dates get a time axis with ticks on day, week, month, quarter or year boundaries, whichever gives about one tick per 80 pixels. Every series has one value per x, and `null` is a gap that breaks the line. The x values need at least two different values; a monotone curve also needs them strictly ascending.

## The y axis

The extent of every series together, stretched to include zero when `yZero` is set, then widened to nice numbers by `charts.scale` niceDomain with about one tick per 50 pixels (at least 2). A flat series is given a domain one unit either side so it still has a height.

## The fixed layout rules

- Text is estimated at 7 pixels per character (12px sans-serif); no font is measured, so every language gets the same layout. - A legend is shown only for more than one series: swatches 10 pixels, items 16 pixels apart, rows 18 pixels high, wrapped within the chart width less 8 pixels each side, starting 8 pixels from the top-left corner. - Margins: top 12, or the legend's height plus 8 above and below it; right 20; bottom 30 (tick 6, padding 3, a line of text); left fits the widest y label: 6 + 3 + 7 per character + 8. - Ticks are 6 pixels, as d3's default. Gridlines run from the y axis across the whole plot width. - Colours are the Okabe-Ito palette in series order (`charts.palette`), repeating after eight series. `legend[i]` and `lines[i]` are the same series. - Curves: `linear` straight segments; `monotone` Fritsch-Carlson, which never overshoots the data (use it for smooth lines, never a spline that invents peaks); `step` with the step midway between points.

All pixel values are rounded to 2 decimal places, half away from zero.

A chart with no series, no values, a series of the wrong length, or a size too small for its margins is an error rather than an empty picture.

Files

PathBytes
README.md2,583
impl/python.py4,992
impl/rust.rs8,451
impl/typescript.ts5,553
vectors.json10,133