← Applied algorithms
Images, maps, and geometry From a hand-drawn line to the few points that matter

Ramer–Douglas–Peucker simplification

Keep the shape. Drop the points.

Put a route on a Leaflet map and zoom out. The line still looks like your route, but Leaflet isn’t drawing every point you gave it. At each zoom level it projects the route to pixels and runs it through LineUtil.simplify, which its source describes as the Ramer–Douglas–Peucker algorithm, used “for a huge performance boost” and for “reducing visual noise.” The polyline’s smoothFactor option is the tolerance; set it to 0 and Leaflet draws every point.

We’ll build that simplifier for a route someone sketched in an editor: sixteen points, a shaky hand, and a tolerance you can drag.

TypeScriptGoOne route sketch in each language

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.

Ramer–Douglas–Peucker

Keep the ends. Test the farthest point.

01511

the sketch within 24 px of the chord kept dropped

01/ 03
Test the first chord

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.

TypeScriptReading
track.ts
// 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;
}
GoAlongside
track.go
// 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.

A route runs east to a viewpoint and back: (40, 200) → (240, 200) → (140, 200). The tolerance is 10 px. How far is the viewpoint from the chord between the first and last points?

05 / Follow the cost

Usually quick. Sometimes quadratic.

Ramer–Douglas–Peucker: time and extra space
OperationTimeExtra spaceWhat it assumes
Measure one pointO(1)O(1)A cross product and a dot product in whole numbers, with no square root.
Check one chordO(k)O(1)k is the number of points between its ends. Each is measured once to find the farthest.
Simplify, splits near the middleO(n log n)O(n)The halves shrink quickly, so each point is measured about log n times.
Simplify, splits next to an endO(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 splitsO(1) eachO(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.

stroke.ts
// 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.js and Polyline.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.

Copy the complete example, add a point that doubles back past the end of a chord, and predict which points stay before you run it.

Back to applied algorithms →