01 / The idea
Most of the points are only saying “still going straight.”
A route drawn by hand, or recorded by a phone, arrives as a long list of points. Many of them barely change the picture: a wobble of three pixels along a straight path, a dozen points around a gentle curve. Storing, sending, and drawing all of them costs something every time.
Ramer–Douglas–Peucker keeps a line’s endpoints and then keeps only the points that stray more than a chosen distance from the simpler line. That distance is the tolerance, in the same units as the points. Our sketch uses CSS pixels on a 640 × 360 canvas.
Watch it simplify the park route at 24 px. Then open Try it, drag the tolerance, and draw a route of your own.
Keep the ends. Test the farthest point.
the sketch within 24 px of the chord kept dropped
Keep the ends. Measure the farthest point.
Check the chord from point 0 to point 15. Its farthest point is 11.
Reduced motion: choose a scene to see its completed state.
Read this scene
Check the chord from point 0 to point 15. Its farthest point is 11.
2 points kept, 0 dropped so far. Check the chord from point 0 to point 15. Its farthest point is 11.
Watch and Step through use the park route at 24 px. Try it runs the same TypeScript on the presets or a route you draw, and starts fresh each time you open it.
At 24 px the sixteen points become five: the start, the corner where the path turns north, where the climb levels off, the far bend, and the finish. At 6 px the hand-drawn wobble along the first path disappears and the bends stay. At 0 px nothing goes, because no point lies exactly on the chord it is tested against.
02 / Name the rule
Keep the ends. Test the farthest point.
Draw a straight chord from the first point to the last. Measure every point in between against it and find the one farthest away. If even that point is within the tolerance, the chord is a good enough stand-in for everything between: drop the interior. If it is farther, the chord is hiding a real bend, so that point stays.
“Farther than the tolerance” needs a distance you can trust. We measure to the chord as a segment: if a point projects past either end, its distance is to that end, not to the endpoint-less line through the chord. Leaflet’s simplifier does the same. Section 04 shows what goes wrong otherwise.
The distance code, the Basic form in the next section, takes no square roots. Every point on
one chord is compared at the same scale, the squared distance times the chord’s squared
length, and the tolerance is scaled the same way. With whole-pixel coordinates up to 1000,
the largest value is about 4 × 10¹². JavaScript numbers hold integers exactly up to 2⁵³, and
the Go version does this arithmetic in int64, so TypeScript and Go keep exactly
the same points on any platform.
Why the scaled distance worksCross products, projections, and a zero-length chord
For a chord from a to b, the cross product of b − a and p − a is the perpendicular distance times the chord’s length. Squaring it
gives the squared distance times the squared length, with no division. The dot product
says where p projects: at or before a, or at or past b, the nearest point on the segment is that end, so we use the squared
distance to the end, times the same squared length.
A closed loop starts and ends at the same point, so its first chord has zero length. Then the distance is the distance to that point, and the scale is 1.
The tolerance test is strict: a point exactly the tolerance away is dropped, and one any farther is kept. At tolerance 0, only points lying exactly on the segment go.
03 / Read the shape
Split at the point you kept, and ask again.
A kept point breaks the chord in two: first to kept, kept to last. Each half gets the same question, with its own farthest point. The work ends when every remaining chord is either a single step between neighbors or close enough to its interior.
On the park route at 24 px, the first chord keeps point 11. The half from 0 to 11 keeps point 4; from 0 to 4, the wobble fits inside the corridor and goes. Every dropped point is within the tolerance of the segment that replaced it: that is the promise, and the tests check it on every case.
The distance to a chord as a segment. A point past either end measures to that end, and a zero-length chord measures to its point. Squared distance times the chord’s squared length keeps every comparison an exact integer.
// How far p is from the segment a→b, compared without square roots. The result is the
// squared distance times chordScale(a, b), so points measured against one chord share a scale.
export function scaledDistance(p: Point, a: Point, b: Point): number {
const dx = b.x - a.x,
dy = b.y - a.y;
const length = dx * dx + dy * dy;
const px = p.x - a.x,
py = p.y - a.y;
if (length === 0) return px * px + py * py; // a and b coincide: distance to that point
const along = px * dx + py * dy; // how far along the chord p projects, times its length
if (along <= 0) return (px * px + py * py) * length; // before a: the nearest point is a
if (along >= length) {
const qx = p.x - b.x,
qy = p.y - b.y;
return (qx * qx + qy * qy) * length; // past b: the nearest point is b
}
const cross = px * dy - py * dx; // the perpendicular distance, times the chord length
return cross * cross;
}
export function chordScale(a: Point, b: Point): number {
const length = (b.x - a.x) ** 2 + (b.y - a.y) ** 2;
return length === 0 ? 1 : length;
} // scaledDistance says how far p is from the segment a→b without square roots. The result is
// the squared distance times chordScale(a, b), so points measured against one chord share a scale.
// Values reach about 4×10¹², so the arithmetic is int64 whatever size int is.
func scaledDistance(p, a, b Point) int64 {
dx, dy := int64(b.X-a.X), int64(b.Y-a.Y)
length := dx*dx + dy*dy
px, py := int64(p.X-a.X), int64(p.Y-a.Y)
if length == 0 {
return px*px + py*py // a and b coincide: distance to that point
}
along := px*dx + py*dy // how far along the chord p projects, times its length
if along <= 0 {
return (px*px + py*py) * length // before a: the nearest point is a
}
if along >= length {
qx, qy := int64(p.X-b.X), int64(p.Y-b.Y)
return (qx*qx + qy*qy) * length // past b: the nearest point is b
}
cross := px*dy - py*dx // the perpendicular distance, times the chord length
return cross * cross
}
func chordScale(a, b Point) int64 {
dx, dy := int64(b.X-a.X), int64(b.Y-a.Y)
length := dx*dx + dy*dy
if length == 0 {
return 1
}
return length
} In the wild shows the simplifier itself. The algorithm is usually written as recursion. We keep the chords still to check on an explicit stack instead, pushing the right half before the left so the left is checked first, exactly the order recursion would visit. Recursion would be fine here: a zig-zag of 2,000 points that splits one point at a time is 2,000 calls deep, which JavaScript and Go both handle. The stack gives the same order without depending on call-stack limits if you raise the point limit, and it keeps the chords still waiting in one place you can inspect.
Reading the TypeScriptWhole numbers, fresh arrays, and a coded error
A number could hold a fraction, so validate checks that every coordinate
and the tolerance are whole numbers in range before anything is measured. Whole numbers keep
every comparison exact, because the largest scaled distance stays far below 2⁵³.
simplify returns fresh arrays: the kept indices and one Split per chord it checked. A bad input throws a SimplifyError whose code matches the Go version.
Reading the Goint points, int64 arithmetic, and an error value
Point holds int coordinates, so a fraction can’t arrive at
all. scaledDistance converts to int64 before it multiplies, so the
result is the same whatever size int is on the platform.
Errors come back beside the result as a *SimplifyError with the same codes as
TypeScript.
What is refusedPoints, coordinates, and the tolerance
More than 2,000 points is too-many-points. A coordinate outside 0 through
1,000, or a fraction in TypeScript, is bad-coordinate. A tolerance outside
0 through 500 is bad-tolerance. Empty, one-point, and two-point routes come
back unchanged.
04 / Try a decision
A route that doubles back.
Runners go out to a viewpoint and come back the same way. That out-and-back spur is where “distance to the chord” has to mean the right thing.
05 / Follow the cost
Usually quick. Sometimes quadratic.
| Operation | Time | Extra space | What it assumes |
|---|---|---|---|
| Measure one point | O(1) | O(1) | A cross product and a dot product in whole numbers, with no square root. |
| Check one chord | O(k) | O(1) | k is the number of points between its ends. Each is measured once to find the farthest. |
| Simplify, splits near the middle | O(n log n) | O(n) | The halves shrink quickly, so each point is measured about log n times. |
| Simplify, splits next to an end | O(n²) | O(n) | Each kept point takes only one point off the next chord, so the scans add up to about n²/2. |
| Record the splits | O(1) each | O(n) | One entry per chord checked, for the animation. There are fewer chords than points. |
Each chord scans the points between its ends once. When kept points land near the middle,
the halves shrink quickly and the total work is about n log n distance checks. When every split keeps a point next to one end, each round removes only one point
from the next chord, and the checks add up to about n².
Our park route needs 7 chords at 24 px, 12 at 6 px, and 14 at 0 px. Extra memory is the kept
flags and the stack of chords, both proportional to n.
Can the worst case be avoided?A faster variant exists
Hershberger and Snoeyink showed in 1992 that the classic version, which measures each
point’s distance to the line through the chord rather than to the segment, can be computed
in O(n log n) time in the worst case, using convex hulls of the path to find farthest
points quickly. Our segment distance would need its own treatment. For routes of a few thousand
points, the plain version is usually the one worth reading and maintaining; measure before swapping
it.
06 / Give it a real job
Save the route, not the scribble.
A running club’s route editor lets members sketch a course on a map. The sketch can have hundreds of points. The server keeps the simplified route: smaller to store, quicker to send to every phone that opens it, and just as recognizable. The original sketch can stay in the member’s draft if they want to edit it again.
The tolerance is a product decision in the route’s own units. For a sketch in canvas pixels, a few pixels removes the shake. For a GPS track, project the coordinates to a flat system in meters first and choose meters; the algorithm measures straight-line distance in whatever plane it is given.
The editor’s save step is the At the call site tab in section 03, and Complete files has
both programs to copy. Both print tolerance 0px: 16 of 16 points kept, then tolerance 6px: 12 of 16 and tolerance 24px: 5 of 16, with the same
kept indices.
Build UIs?Your map already simplifies every line it draws, and a drawing surface will need you to do it yourself.
Where it already is in your components
If a component of yours puts a route on a Leaflet map, this already runs on every zoom. In
Leaflet 1.9.4, a polyline’s points are projected to layer pixels with latLngToLayerPoint, then _simplifyPoints passes the smoothFactor option, 1.0 by default, as the tolerance to LineUtil.simplify. Its documentation for the option: “More means better performance and smoother look, and
less means more accurate representation.” That is this lesson’s tolerance, in pixels.
The same code lives on as Simplify.js, “extracted
from Leaflet,” and Turf’s simplify “uses the 2d version
of simplify-js.” Both first keep only points farther than the tolerance from the last point
they kept, then run Ramer–Douglas–Peucker. Simplify.js skips that first pass when you ask for
its highest quality.
When you have to own it
Now build a signature pad, or a whiteboard, or a “draw your route” map tool. A pointer
moving across the surface produces a pointermove event after event, and
browsers merge fast movement into fewer of them. getCoalescedEvents() hands back the merged positions for drawing apps that want smooth curves. MDN lists it as limited
availability, so the snippet uses it where it exists and falls back to the event itself.
That is a lot of points for one squiggle. Record them while the pointer is down, then
simplify once when it comes up and save an SVG path. toPoints maps the surface
into a plane whose longer side is 1,000 units, so one tolerance means the same thing on a phone
and on a wide whiteboard; the snippet defaults to 2 units. The lab above works the same way
in its own 640 × 360 drawing units: draw a line there and drag the tolerance to see how few
points a stroke keeps before you can tell the difference. Simplifying on every move would redo
the whole stroke each time, so wait for the end. Recording stops at the simplifier’s 2,000-point
limit, which fast pointer input can reach within seconds of continuous drawing, so raise both
limits if your strokes run long.
// simplify is this lesson's own function, from track.ts: the same code the lab runs.
import { MAX_COORDINATE, MAX_POINTS, simplify, type Point } from '../track';
export type Sample = { clientX: number; clientY: number };
// A pointer event, or anything shaped like one.
export type PointerSample = Sample & { getCoalescedEvents?: () => Sample[] };
export type Surface = { left: number; top: number; width: number; height: number };
// Map positions into a fixed plane whose longer side is 1000 units, whatever size the surface
// is on screen, so one tolerance means the same thing on a phone and on a wide whiteboard.
// Where getCoalescedEvents() exists, it also hands back positions the browser merged.
export function toPoints(event: PointerSample, surface: Surface): Point[] {
const side = Math.max(surface.width, surface.height);
if (!(side > 0)) return [];
const unit = (offset: number) =>
Math.min(MAX_COORDINATE, Math.max(0, Math.round((offset / side) * MAX_COORDINATE)));
const merged = event.getCoalescedEvents?.() ?? [];
return (merged.length > 0 ? merged : [event]).map(({ clientX, clientY }) => ({
x: unit(clientX - surface.left),
y: unit(clientY - surface.top)
}));
}
// Record every new whole-unit position while the pointer is down. Repeats add nothing.
export function addPoints(stroke: Point[], incoming: readonly Point[]): void {
for (const point of incoming) {
const last = stroke.at(-1);
if (last && last.x === point.x && last.y === point.y) continue;
// The simplifier's limit. With high-rate pointer input that is only a few seconds of
// continuous drawing, so a longer stroke is cut off here; raise both limits if yours run long.
if (stroke.length === MAX_POINTS) return;
stroke.push(point);
}
}
// When the pointer comes up, simplify once and keep only what the eye can see.
export function strokePath(stroke: readonly Point[], tolerance = 2): string {
const { kept } = simplify(stroke, tolerance);
return kept.map((i, n) => `${n === 0 ? 'M' : 'L'}${stroke[i].x} ${stroke[i].y}`).join(' ');
}
// Wire a drawing surface. Returns a cleanup function.
export function drawStrokes(
surface: Element & ElementCSSInlineStyle,
save: (path: string) => void,
tolerance = 2
): () => void {
surface.style.touchAction = 'none'; // otherwise a touch stroke scrolls and gets pointercancel
let stroke: Point[] | null = null;
const record = (event: PointerEvent) =>
stroke && addPoints(stroke, toPoints(event, surface.getBoundingClientRect()));
const listeners: Record<string, (event: Event) => void> = {
pointerdown: (event) => {
const pointer = event as PointerEvent;
pointer.preventDefault(); // a mouse drag would otherwise start a text selection
surface.setPointerCapture(pointer.pointerId); // keep the stroke when it leaves the surface
stroke = [];
record(pointer);
},
pointermove: (event) => record(event as PointerEvent),
pointerup: () => {
if (stroke?.length) save(strokePath(stroke, tolerance));
stroke = null;
},
pointercancel: () => (stroke = null)
};
for (const [type, listener] of Object.entries(listeners))
surface.addEventListener(type, listener);
return () => {
for (const [type, listener] of Object.entries(listeners))
surface.removeEventListener(type, listener);
};
}
drawStrokes does the wiring. On pointerdown it calls preventDefault, so a mouse drag doesn’t start a text selection, captures the
pointer so the stroke keeps arriving when it leaves the surface, and starts recording; pointermove adds points and pointerup saves the path. It also sets touch-action: none: otherwise a touch stroke pans the page and the browser
sends pointercancel. Nothing here belongs to React or Svelte. Call it from a
React effect with a ref, or from a Svelte $effect, return its cleanup, and render the saved path with an ordinary <path d>.
07 / Make the call
A tolerance is a promise about distance, not about shape.
Reach for it when a line has far more points than its picture needs and you can say how far off is too far: routes, strokes, outlines, sensor traces plotted as lines. The output is a subset of the original points, so every kept point is one that really happened.
Know what it doesn’t promise. The simplified line can cross itself where the original didn’t, when the tolerance is coarse enough to cut across a tight loop. It measures straight-line distance in a plane, so latitude and longitude should be projected first, as Leaflet does before it simplifies. And it keeps the points that stray the farthest, which is not the same as keeping the parts a person would call important.
Keep it simpler when you only need fewer points, not the same shape: dropping points that are too close to the previous one is a single pass, and it is the first stage Leaflet and Simplify.js run anyway. Other simplifiers, such as Visvalingam–Whyatt, choose what to drop by a different measure, so compare them on your own lines.
SourcesThe original papers, the libraries, and the platform
- Urs Ramer, “An iterative procedure for the polygonal approximation of plane curves,”
Computer Graphics and Image Processing, 1972, and David Douglas and Thomas Peucker,
“Algorithms for the reduction of the number of points required to represent a digitized
line or its caricature,” Cartographica, 1973, as listed in the algorithm’s Wikipedia article, which also gives the
O(n²)worst case, the Hershberger–Snoeyink variant, and the self-intersection caveat. - Leaflet 1.9.4 source:
LineUtil.jsandPolyline.js. - Simplify.js and Turf
simplify. - MDN on
getCoalescedEvents(). All checked 13 September 2026.
08 / Take the idea with you
Explain a simpler route without saying “Ramer–Douglas–Peucker.”
Say it in your own words: “Draw a line from the first point to the last. Find the point farthest from it. If it’s within the tolerance, drop everything between; if not, keep it and do the same for both halves.”
Then explain the promise: “Every point I dropped is within the tolerance of the segment that replaced it, measured to the segment, not the endless line.”
Before moving on, find a line your app draws from many points, on a map, a chart, or a canvas, and ask what tolerance a person would never notice.
Connections to follow nextRelated lessons
- Stack holds the chords still to check, in the order recursion would visit them.
- Seam carving also removes what a picture can spare, but measures importance with pixel energy.
- Bresenham’s line algorithm decides which pixels a straight segment covers.