Functional Weave
Code in TypeScript

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.

export function downsample(points: readonly Sample[], threshold: number): readonly 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. */
export interface Sample {
  readonly x: number;
  readonly y: number;
}

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

import { downsample } from "#fune/charts.downsample@^1";
impl/typescript.ts · 57 lines · open · raw
import { type Sample } from "./charts_downsample_types.ts";

/**
 * Largest-Triangle-Three-Buckets (Steinarsson 2013).
 *
 * Keeps the first and last points; splits the rest into threshold - 2
 * buckets and from each keeps the point that makes the largest triangle with
 * the point kept before it and the average of the next bucket. Peaks and dips
 * survive, which taking every nth point or averaging each bucket would lose.
 * Bucket bounds are computed as floor((i + 1) * every) + 1 with a float
 * `every`, exactly as the reference implementation does, so the choice of
 * points matches it.
 */
export function downsample(points: readonly Sample[], threshold: number): readonly Sample[] {
  if (!Number.isInteger(threshold) || threshold < 3) {
    throw new RangeError(`threshold must be a whole number of at least 3, received ${threshold}`);
  }
  const n = points.length;
  if (threshold >= n) return points.slice();

  const every = (n - 2) / (threshold - 2);
  const sampled: Sample[] = [points[0]];
  let a = 0;
  for (let i = 0; i < threshold - 2; i++) {
    // The average of the next bucket stands in for the point not chosen yet.
    const avgStart = Math.floor((i + 1) * every) + 1;
    const avgEnd = Math.min(Math.floor((i + 2) * every) + 1, n);
    let avgX = 0;
    let avgY = 0;
    for (let j = avgStart; j < avgEnd; j++) {
      avgX += points[j].x;
      avgY += points[j].y;
    }
    avgX /= avgEnd - avgStart;
    avgY /= avgEnd - avgStart;

    const from = Math.floor(i * every) + 1;
    const to = Math.floor((i + 1) * every) + 1;
    const ax = points[a].x;
    const ay = points[a].y;
    let maxArea = -1;
    let next = from;
    for (let j = from; j < to; j++) {
      // Twice the triangle's area; halving it would not change which is largest.
      const area = Math.abs((ax - avgX) * (points[j].y - ay) - (ax - points[j].x) * (avgY - ay));
      // Strictly greater: on a tie the earlier point wins.
      if (area > maxArea) {
        maxArea = area;
        next = j;
      }
    }
    sampled.push(points[next]);
    a = next;
  }
  sampled.push(points[n - 1]);
  return sampled;
}

Install

fune build

With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and nothing else, pins them in fune.lock, downloads only the TypeScript 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. Or pin a range in fune.project and build in one step:

fune add charts.downsample
Download for TypeScript charts.downsample-1.0.0-typescript.fune · 8,601 bytes sha256 ebb2016a7621cadbbe8b562b051c385b5dc15e6a330f6997c867030eea270ee6

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

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