Downsampling Time Series with LTTB
Your line chart plots half a million samples across twelve hundred pixels, the tab pins a core, and the spikes you actually care about look identical whether or not you thin the data — so why draw all of it?
The answer is that you should not, and how you thin it decides whether the chart stays honest. This page is a companion within large-dataset rendering strategies: it is the reduction to reach for when the mark is a connected line. The Largest-Triangle-Three-Buckets (LTTB) algorithm keeps a small, representative subset of the original samples — never synthetic averages — chosen so the downsampled path traces the same silhouette as the full one, peaks and troughs intact. It is the standard because the obvious alternatives fail visibly: taking every Nth point drops any spike that falls between strides, and averaging each bucket flattens the extremes into a muted mean.
Diagnostic checklist
Broken vs fixed
// ❌ BROKEN: every-Nth-point stride sampling silently drops peaks.
function strideSample(rows: readonly Point[], target: number): Point[] {
const step: number = Math.ceil(rows.length / target);
const out: Point[] = [];
for (let i = 0; i < rows.length; i += step) {
out.push(rows[i]); // a spike between i and i+step is never seen
}
return out;
}
// ✅ FIXED: LTTB keeps the sample that best preserves the visual shape.
interface Point { x: number; y: number }
function lttb(rows: readonly Point[], target: number): Point[] {
const n: number = rows.length;
if (target >= n || target < 3) return rows.slice();
const sampled: Point[] = [rows[0]]; // always keep the first point
const bucketSize: number = (n - 2) / (target - 2);
let a = 0; // index of the last kept point
for (let i = 0; i < target - 2; i++) {
// Average of the NEXT bucket forms the far vertex of each triangle.
const nextStart: number = Math.floor((i + 1) * bucketSize) + 1;
const nextEnd: number = Math.min(Math.floor((i + 2) * bucketSize) + 1, n);
let avgX = 0, avgY = 0;
for (let j = nextStart; j < nextEnd; j++) { avgX += rows[j].x; avgY += rows[j].y; }
const span: number = nextEnd - nextStart || 1;
avgX /= span; avgY /= span;
// Scan the current bucket for the point with the largest triangle area.
const curStart: number = Math.floor(i * bucketSize) + 1;
const curEnd: number = Math.floor((i + 1) * bucketSize) + 1;
let maxArea = -1, chosen = curStart;
const ax: number = rows[a].x, ay: number = rows[a].y;
for (let j = curStart; j < curEnd; j++) {
// PERF: twice the signed area; the 0.5 factor is irrelevant to the argmax.
const area: number = Math.abs((ax - avgX) * (rows[j].y - ay) - (ax - rows[j].x) * (avgY - ay));
if (area > maxArea) { maxArea = area; chosen = j; }
}
sampled.push(rows[chosen]);
a = chosen;
}
sampled.push(rows[n - 1]); // always keep the last point
// A11Y: `rows` (the full series) stays intact for the accessible table export.
return sampled;
}
The mechanism is the triangle. For each output bucket the algorithm considers three vertices: the point it kept last, each candidate in the current bucket, and the mean position of the next bucket. The candidate that forms the largest-area triangle with those two fixed vertices is the one that deviates most from a straight line between its neighbours — which is exactly the peak, trough, or inflection a human eye reads as a feature. Averaging would place a vertex at the mean; stride sampling ignores area entirely. Anchoring the first and last samples keeps the endpoints pinned so the reduced line spans the same domain.
Step-by-step fix
function downsampleForView(rows: readonly Point[], plotWidthPx: number): Point[] {
const target: number = Math.max(3, Math.ceil(plotWidthPx * 2));
return lttb(rows, target); // run on view change, cache the return value
}
Verification
const raw: Point[] = Array.from({ length: 100_000 }, (_, i) => ({ x: i, y: Math.sin(i / 500) }));
raw[73_412] = { x: 73_412, y: 99 }; // inject a lone spike
const out: Point[] = lttb(raw, 1_000);
console.assert(out.length === 1_000, `expected 1000 marks, got ${out.length}`);
console.assert(out[0].x === 0 && out[out.length - 1].x === 99_999, "endpoints not anchored");
console.assert(out.some((p) => p.y === 99), "LTTB dropped the injected peak");
In DevTools, overlay the raw and downsampled paths at low opacity: the two silhouettes should be indistinguishable at chart scale, and the spike must be present in both. Compare against a stride-sampled overlay to see the peak disappear from the naive version.
When to downsample versus aggregate
LTTB is the right tool when the data is a single-valued function of one axis — a metric over time, a sensor reading, a price. The line has one y for each x, so keeping a representative x-ordered subset reproduces the curve. It is the wrong tool for a scattered cloud where many points share similar x-values with different y-values, because there is no single line to preserve; thinning a cloud with LTTB just discards points arbitrarily. That case wants aggregation into a density heatmap, which summarises how many points fall in each region rather than trying to trace a path through them.
There is also a middle ground: multi-series line charts. Run LTTB independently per series against a shared target, because a peak in one series has no bearing on which sample best represents another. Sharing one triangle scan across interleaved series corrupts every line.
Edge cases & gotchas
- Fewer rows than the target. When
target >= nthere is nothing to reduce; return a copy of the input rather than running the bucket maths on empty ranges. - Irregular timestamps. LTTB scores on triangle area, which uses the real x-values, so uneven sampling is handled correctly without resampling to a fixed grid first.
- NaN or gap values. A
NaNy-value poisons the area comparison and can hijack the argmax. Split the series at gaps and downsample each contiguous run separately. - Zoom without re-running. A downsample computed for the full domain looks coarse when the user zooms into a narrow window; re-run against the visible slice or the detail never appears.
- Downsampling in the frame loop. LTTB is O(n) but that is still far too much to run every 16.67ms; compute it on view change and cache the array.
- Colour-coded thresholds. If a point’s colour encodes a category, LTTB may drop the exact sample that crossed a threshold; keep threshold crossings as forced-retain points alongside the algorithm’s picks.
Frequently Asked Questions
Why does LTTB keep peaks that averaging loses?
Because it selects an actual sample by largest triangle area rather than computing a bucket mean. A tall spike forms a large-area triangle against its neighbours, so it wins its bucket and is retained. Averaging blends the spike into the surrounding values and pulls the plotted point toward the mean, flattening the feature.
How many output points should I target?
Tie the target to the plot’s pixel width — roughly two points per horizontal pixel. A 1200px chart therefore targets about 2400 samples, which is more than the display can resolve as distinct verticals, so the downsampled line is visually lossless while the source could be millions of rows.
Do I need to re-run LTTB when the user zooms in?
Yes. A downsample computed for the whole domain is coarse inside a narrow zoom window. Recompute LTTB against the currently visible slice on each zoom or pan across a boundary so finer structure appears as the user drills in, then cache that result for the frame loop.
Related
- Large-Dataset Rendering Strategies — the parent guide to reduction techniques.
- Aggregating Points into a Density Heatmap — the reduction for scattered clouds instead of lines.
- Virtualizing Long Bar Charts with Windowing — reduction by drawing only what is on screen.
- Customizing D3 Time Scales for Irregular Timestamps — mapping uneven timestamps to the x-axis.