Functional Weave
Code in Rust

charts.downsample

Reduce a long series to a few points that keep its visual shape, by Largest-Triangle-Three-Buckets (LTTB).

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

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

What it does

A line chart of 100,000 points drawn into 800 pixels is mostly wasted work, and the browser or PDF renderer pays for every point. `downsample(points, threshold)` keeps `threshold` of them, chosen so the line still looks the same: peaks, dips and spikes survive.

The method is Largest-Triangle-Three-Buckets (LTTB) from Sveinn Steinarsson's 2013 thesis. The first and last points are always kept. The points between are split into `threshold - 2` buckets of about equal size, and from each bucket the point kept is the one forming the largest triangle with the point kept from the previous bucket and the average of the next bucket. Unlike keeping every nth point, a one-sample spike is not lost; unlike averaging a bucket, the result is made of real data points, so a tooltip over it shows a value that was actually measured.

For example

  • downsample(points ×5, 3) → ×3 five points to three keeps the peak
  • downsample(points ×7, 4) → ×4 the largest triangle, not the highest point: (2,-1) beats (1,3)
  • downsample(points ×10, 4) → ×4 ten points to four keeps the spike and the dip

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 downsample(points: &[Sample], threshold: i64) -> Vec<Sample>
pointsSample[]the series in drawing order, usually ascending x
thresholdinthow many points to keep, at least 3; a series already that short is returned as it is
returnsSample[]a subset of the input points, first and last always included

The type it declares, generated into your project

/// One point of a series.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Sample {
    pub x: f64,
    pub y: f64,
}

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

fune!(charts.downsample@^1);  // then call downsample(…)
impl/rust.rs · 63 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

/// Largest-Triangle-Three-Buckets (Steinarsson 2013). Same bucket arithmetic,
/// in the same order, as the TypeScript and Python versions and the reference
/// implementation.
///
/// # Panics
/// Panics if `threshold` is less than 3.
pub fn downsample(points: &[Sample], threshold: i64) -> Vec<Sample> {
    if threshold < 3 {
        panic!("threshold must be a whole number of at least 3, received {}", threshold);
    }
    let n = points.len();
    if threshold as usize >= n {
        return points.to_vec();
    }
    let every = (n - 2) as f64 / (threshold - 2) as f64;
    let mut sampled = vec![points[0].clone()];
    let mut a = 0usize;
    for i in 0..(threshold - 2) as usize {
        let avg_start = ((i + 1) as f64 * every).floor() as usize + 1;
        let avg_end = (((i + 2) as f64 * every).floor() as usize + 1).min(n);
        let mut avg_x = 0.0;
        let mut avg_y = 0.0;
        for p in &points[avg_start..avg_end] {
            avg_x += p.x;
            avg_y += p.y;
        }
        avg_x /= (avg_end - avg_start) as f64;
        avg_y /= (avg_end - avg_start) as f64;

        let from = (i as f64 * every).floor() as usize + 1;
        let to = ((i + 1) as f64 * every).floor() as usize + 1;
        let ax = points[a].x;
        let ay = points[a].y;
        let mut max_area = -1.0;
        let mut next = from;
        for j in from..to {
            let area = ((ax - avg_x) * (points[j].y - ay) - (ax - points[j].x) * (avg_y - ay)).abs();
            if area > max_area {
                max_area = area;
                next = j;
            }
        }
        sampled.push(points[next].clone());
        a = next;
    }
    sampled.push(points[n - 1].clone());
    sampled
}

pub fn sample_from_value(v: &Value) -> Sample {
    Sample { x: v.get("x").as_f64(), y: v.get("y").as_f64() }
}

pub fn sample_to_value(s: &Sample) -> Value {
    Value::obj(vec![("x", Value::Float(s.x)), ("y", Value::Float(s.y))])
}

pub fn fune_vector(args: &[Value]) -> Value {
    let points: Vec<Sample> = args[0].as_arr().iter().map(sample_from_value).collect();
    Value::Arr(downsample(&points, args[1].as_i64()).iter().map(sample_to_value).collect())
}

Install

fune build

With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and nothing else, 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.downsample
Download for Rust charts.downsample-1.0.0-rust.fune · 8,692 bytes sha256 9ecc1fdbc6d113950bf47d7a0ed5eb879c6ca2e75284b0a8546961eec2241d23

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

The whole function, every language, is one file too: charts.downsample-1.0.0.fune, 12,548 bytes, sha256 1bcd3904463d0878e1dc0d38bc89b7df090dc3aef3892e82c30ff23e2b1a3e95. 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.downsample

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

// fune: after charts.downsample

replace — it requires no other capability, so there is no dependency to replace.

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.downsample --steps.

// fune: step charts.downsample 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
five points to three keeps the peak points ×5, 3 → ×3
the largest triangle, not the highest point: (2,-1) beats (1,3) points ×7, 4 → ×4
ten points to four keeps the spike and the dip points ×10, 4 → ×4
on equal areas the earlier point wins points ×4, 3 → ×3
a series no longer than the threshold is returned as it is points ×3, 3 → ×3
a threshold above the length changes nothing points ×2, 10 → ×2
an empty series stays empty , 5 →
a flat line keeps the first point of each bucket points ×6, 4 → ×4
uneven x spacing is used, not the index points ×4, 3 → ×3
a threshold of 2 is an error (LTTB always keeps both ends plus one bucket) points ×3, 2 → error: threshold must be a whole number of at least 3
Show the other 1 test
CaseArgumentsExpected
a threshold of 0 is an error, not a silent no-op points ×3, 0 → error: threshold must be a whole number of at least 3

More from the author

- The result is a subset of the input, in input order, never new points. - Bucket bounds are `floor(i x every) + 1` with `every = (n - 2) / (threshold - 2)` in floating point, as in the reference implementation, so the same points are chosen as there. The triangle areas use real x values, so uneven spacing matters. - When two candidates make triangles of the same area, the earlier one is kept. - A series of `threshold` points or fewer is returned unchanged (as a copy). - `threshold` must be at least 3. The reference implementation treats 0 as "do not downsample"; here a threshold that cannot work is an error. - Points should be in drawing order (ascending x for a time series); they are not sorted here.

Source: S. Steinarsson, "Downsampling Time Series for Visual Representation", MSc thesis, University of Iceland, 2013; reference code at github.com/sveinn-steinarsson/flot-downsample.

Files

PathBytes
README.md1,756
impl/python.py1,584
impl/rust.rs2,213
impl/typescript.ts2,124
vectors.json2,429