When you display multiple routes on a map, they often share the same roads. Without proper handling, routes stack directly on top of each other and become impossible to distinguish. Users see a confusing mess of colors instead of clear, separate paths.
This is a common problem in logistics dashboards, delivery tracking apps, and any scenario where you compare routes from different origins to a common destination. The challenge is making each route visually distinct while keeping the map readable.
In this tutorial, we explore three practical approaches to solve this problem using Leaflet:
- Turf.js lineOffset - Geometric offset that creates truly parallel routes
- Varying line weights - Visual layering without any offset
- leaflet-polylineoffset plugin - Pixel-based visual offset
Each technique has its strengths. We will walk through the implementation of the first two (the simplest and most effective), compare all three, and help you decide which fits your project.
Try the live demos:
APIs used:
- Routing API - calculate routes between waypoints
- Map Tiles API - base map layer
- Map Marker Icon API - custom markers
What you will learn:
- How to fetch multiple routes from the Routing API
- Three techniques for separating overlapping route lines
- When to use geometric offset vs visual layering vs pixel offset
- Adding custom markers for origins and destinations
Table of Contents
- The Problem: Overlapping Routes
- Set Up the Map and Fetch Routes
- Approach 1: Geometric Offset with Turf.js
- Approach 2: Varying Line Weights
- Approach 3: Pixel Offset with leaflet-polylineoffset
- Add Custom Markers
- Visual Comparison at Different Zoom Levels
- Comparison: Which Approach to Use
- Explore the Demos
- Summary
- FAQ
The Problem: Overlapping Routes
Imagine a delivery service with multiple drivers heading to the same warehouse. Each driver starts from a different location, but as they approach the destination, their routes converge onto the same streets. On a map, this creates a visual problem: the routes overlap completely, and only the last-drawn route is visible.
This happens because:
- Routes share common road segments near the destination
- Leaflet draws polylines directly on top of each other
- Only the topmost line color shows through
The result? Users cannot see all routes at once. They might think routes are missing, or they cannot compare travel times visually.
We need a way to separate these overlapping lines so each route remains visible and identifiable.
Set Up the Map and Fetch Routes
All three approaches share the same foundation: a Leaflet map with routes fetched from the Geoapify Routing API.
Include required libraries
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
For the Turf.js approach, also include:
<script src="https://unpkg.com/@turf/turf@7/turf.min.js"></script>
Define routes and destination
We will use Paris landmarks as our example: four routes from different origins all heading to the Arc de Triomphe.
const API_KEY = "YOUR_API_KEY";
const DESTINATION = {
lat: 48.8738,
lon: 2.2950,
name: "Arc de Triomphe"
};
const ROUTES = [
{id: "r1", name: "From Eiffel Tower", color: "#E53935", origin: {lat: 48.8584, lon: 2.2945}},
{id: "r2", name: "From Louvre", color: "#43A047", origin: {lat: 48.8606, lon: 2.3376}},
{id: "r3", name: "From Notre-Dame", color: "#1E88E5", origin: {lat: 48.8530, lon: 2.3499}},
{id: "r4", name: "From Sacré-Cœur", color: "#FB8C00", origin: {lat: 48.8867, lon: 2.3431}}
];
Key: Please sign up at geoapify.com and generate your own API key.
Initialize the map
const map = L.map("map", {zoomControl: false}).setView([48.866, 2.32], 13);
L.control.zoom({position: "bottomright"}).addTo(map);
L.tileLayer(`https://maps.geoapify.com/v1/tile/osm-bright/{z}/{x}/{y}@2x.png?apiKey=${API_KEY}`, {
attribution: '© <a href="https://www.geoapify.com/">Geoapify</a> © OpenMapTiles © OpenStreetMap',
maxZoom: 20
}).addTo(map);
Fetch routes from the Routing API
const routeState = {};
ROUTES.forEach((route, index) => {
const waypoints = `${route.origin.lat},${route.origin.lon}|${DESTINATION.lat},${DESTINATION.lon}`;
const url = `https://api.geoapify.com/v1/routing?waypoints=${waypoints}&mode=drive&apiKey=${API_KEY}`;
fetch(url)
.then(res => res.json())
.then(data => {
if (!data.features?.[0]) return;
const feature = data.features[0];
const props = feature.properties;
routeState[route.id] = {
...route,
index: index,
visible: true,
feature: feature,
layer: createRouteLayer(feature, route.color, index),
distance: (props.distance / 1000).toFixed(1),
duration: Math.round(props.time / 60)
};
});
});
The Routing API returns GeoJSON features containing the route geometry. We store each route in routeState for later manipulation (toggling visibility, fitting bounds, etc.).
Approach 1: Geometric Offset with Turf.js
The first approach uses Turf.js lineOffset to create geometrically parallel routes. Instead of drawing routes on top of each other, we shift each route by a fixed distance in meters.
Define offset distances
// Geometric offset distances in meters
const OFFSET_METERS = [-15, -5, 5, 15];
These values spread the routes across a 30-meter width. Negative values shift left, positive values shift right (relative to the route direction).
Create offset route layers
function createRouteLayer(feature, color, index) {
const group = L.layerGroup();
// Get offset distance in meters for this route
const offsetMeters = OFFSET_METERS[index % OFFSET_METERS.length];
// Apply Turf.js lineOffset to create geometrically offset route
let offsetFeature;
try {
offsetFeature = turf.lineOffset(feature, offsetMeters / 1000, {units: 'kilometers'});
} catch (error) {
console.warn('Turf.js offset failed, using original geometry:', error);
offsetFeature = feature;
}
// Render each part separately (handle MultiLineString)
const parts = extractGeoJSONParts(offsetFeature.geometry);
for (const part of parts) {
addCasedPolyline(group, part, color);
}
group.getBounds = () => L.featureGroup(group.getLayers()).getBounds();
return group.addTo(map);
}
The key line is turf.lineOffset(feature, offsetMeters / 1000, {units: 'kilometers'}). This takes the original route geometry and returns a new geometry shifted perpendicular to the route by the specified distance.
Note: Turf.js uses kilometers as the default unit, so we divide meters by 1000.
Draw cased polylines
For better visibility, we draw each route with a darker outline (casing) behind the main line:
function addCasedPolyline(group, latLngs, color) {
// Outline (casing)
const outline = L.polyline(latLngs, {
color: darkenColor(color, 30),
weight: 7,
opacity: 0.9,
lineCap: "round",
lineJoin: "bevel",
smoothFactor: 1
});
outline.addTo(group);
// Main line
const main = L.polyline(latLngs, {
color,
weight: 4,
opacity: 1,
lineCap: "round",
lineJoin: "bevel",
smoothFactor: 1
});
main.addTo(group);
}
Helper: Extract coordinates from GeoJSON
function extractGeoJSONParts(geometry) {
if (geometry.type === "LineString") {
return [geometry.coordinates.map(([lon, lat]) => [lat, lon])];
}
if (geometry.type === "MultiLineString") {
return geometry.coordinates.map(line => line.map(([lon, lat]) => [lat, lon]));
}
return [];
}
GeoJSON uses [longitude, latitude] order, but Leaflet expects [latitude, longitude]. This function handles the conversion and supports both LineString and MultiLineString geometries.
Routes are geometrically offset so each path is visible even where they share the same roads.
Approach 2: Varying Line Weights
The second approach is simpler: instead of offsetting routes, we draw them with different line thicknesses. Thicker routes are drawn first (bottom layer), thinner routes are drawn last (top layer). This creates a visual stacking effect where all colors remain visible.
Define line weights
// Line weights for each route (thickest to thinnest)
const LINE_WEIGHTS = [
{outline: 14, main: 10}, // Route 1 - thickest (bottom layer)
{outline: 11, main: 7}, // Route 2
{outline: 8, main: 5}, // Route 3
{outline: 5, main: 3} // Route 4 - thinnest (top layer)
];
Create layered route polylines
function createRouteLayer(feature, color, index) {
const group = L.layerGroup();
// Get line weights for this route index
const weights = LINE_WEIGHTS[index % LINE_WEIGHTS.length];
// Render each part separately (handle MultiLineString)
const parts = extractLatLngParts(feature.geometry);
for (const part of parts) {
addCasedPolyline(group, part, color, weights);
}
group.getBounds = () => L.featureGroup(group.getLayers()).getBounds();
return group.addTo(map);
}
function addCasedPolyline(group, latLngs, color, weights) {
// Outline (casing) - darker version of route color
const outline = L.polyline(latLngs, {
color: darkenColor(color, 30),
weight: weights.outline,
opacity: 0.9,
lineCap: "round",
lineJoin: "bevel",
smoothFactor: 1
});
outline.addTo(group);
// Main line - route color
const main = L.polyline(latLngs, {
color,
weight: weights.main,
opacity: 1,
lineCap: "round",
lineJoin: "bevel",
smoothFactor: 1
});
main.addTo(group);
}
This approach requires no external library. The routes still overlap geometrically, but the varying thicknesses create a layered effect where each color peeks through.
Varying line weights create visual separation without geometric offset.
Approach 3: Pixel Offset with leaflet-polylineoffset
The third approach uses the leaflet-polylineoffset plugin to apply a visual pixel-based offset. Unlike Turf.js which modifies the geometry, this plugin shifts the rendered line on screen.
Include the plugin
<script src="https://cdn.jsdelivr.net/npm/leaflet-polylineoffset@1.1.1/leaflet.polylineoffset.min.js"></script>
Define pixel offsets
// Pixel-based offsets (visual only, not geometric)
const BASE_OFFSETS = [-4, -1.5, 1.5, 4];
const MIN_OFFSET_PX = 2.5; // Minimum visible offset
Create offset polylines with zoom handling
The actual implementation scales offsets based on zoom level and rebuilds routes when zoom changes:
// Re-render routes on zoom end
map.on("zoomend", () => {
for (const r of Object.values(routeState)) {
if (!r.visible) continue;
if (r.layer) map.removeLayer(r.layer);
r.layer = createRouteLayer(r.feature, r.color, r.index);
}
});
function createRouteLayer(feature, color, index) {
const group = L.layerGroup();
const z = map.getZoom();
// Scale offset based on zoom, but keep a minimum
const base = BASE_OFFSETS[index % BASE_OFFSETS.length];
const scale = z >= 18 ? 0.6 : z >= 16 ? 0.85 : 1;
const offsetPx = Math.sign(base) * Math.max(Math.abs(base * scale), MIN_OFFSET_PX);
const parts = extractLatLngParts(feature.geometry);
for (const part of parts) {
addCasedPolyline(group, part, color, offsetPx);
}
group.getBounds = () => L.featureGroup(group.getLayers()).getBounds();
return group.addTo(map);
}
function addCasedPolyline(group, latLngs, color, offsetPx) {
const outline = L.polyline(latLngs, {
color: darkenColor(color, 30),
weight: 7,
opacity: 0.9,
lineCap: "round",
lineJoin: "bevel"
});
outline.setOffset?.(offsetPx); // Plugin method
outline.addTo(group);
const main = L.polyline(latLngs, {
color,
weight: 4,
opacity: 1,
lineCap: "round",
lineJoin: "bevel"
});
main.setOffset?.(offsetPx); // Plugin method
main.addTo(group);
}
The setOffset() method is added by the plugin. It shifts the rendered line by the specified number of pixels without modifying the underlying coordinates.
Note: This approach requires rebuilding routes on zoom changes to maintain consistent visual spacing, which adds complexity compared to the other approaches.
Pixel-based offset using the leaflet-polylineoffset plugin.
Add Custom Markers
All three approaches benefit from clear markers at origins and destination. We use the Geoapify Map Marker Icon API to generate custom pin icons.
function renderMarkers() {
markers.forEach(m => map.removeLayer(m));
markers = [];
// Destination marker (flag icon)
markers.push(
createMarker(DESTINATION.lat, DESTINATION.lon, "%23E91E63", "flag", DESTINATION.name, "Destination")
);
// Origin markers (circle icon, matching route color)
Object.values(routeState).forEach(route => {
if (!route.visible) return;
const color = route.color.replace("#", "%23");
const name = route.name.replace("From ", "");
markers.push(
createMarker(route.origin.lat, route.origin.lon, color, "circle", name, "Origin")
);
});
}
function createMarker(lat, lon, color, iconName, name, label) {
const iconUrl = `https://api.geoapify.com/v2/icon?type=awesome&color=${color}&icon=${iconName}&iconType=awesome&size=48&scaleFactor=2&apiKey=${API_KEY}`;
const icon = L.icon({
iconUrl: iconUrl,
iconSize: [36, 48],
iconAnchor: [18, 48],
popupAnchor: [0, -48]
});
return L.marker([lat, lon], {icon})
.bindPopup(`<strong>${name}</strong><br>${label}`)
.addTo(map);
}
Each origin marker uses the same color as its route, making it easy to match markers to paths visually.
Visual Comparison at Different Zoom Levels
Understanding how each approach behaves at different zoom levels helps you choose the right one for your use case.
Default Zoom (City Overview - Zoom 13)
At the default zoom level, all approaches show the routes clearly:
Left: Turf.js lineOffset | Center: Varying line weights | Right: leaflet-polylineoffset
Zoomed In (Street Level - Zoom 16)
When zoomed in closer, the differences become more apparent:
At higher zoom, Turf.js shows clear geometric separation. Line weights stack visually. Pixel offset maintains consistent screen spacing.
Zoomed Out (District View - Zoom 11)
At lower zoom levels, routes converge more:
At lower zoom, all approaches look similar. However, pixel offset (right) may show routes displaced from their actual path - notice the orange route appears shifted incorrectly.
Comparison: Which Approach to Use
| Aspect | Turf.js lineOffset | Varying Line Weights | leaflet-polylineoffset |
|---|---|---|---|
| Dependencies | Turf.js (~70KB) | None | Plugin (~5KB) |
| Code complexity | Moderate | Simple | Complex |
| Visual result | Truly parallel routes | Layered/stacked routes | Visually offset |
| Best at high zoom | Excellent | Good | Good |
| Best at low zoom | Good | Routes stay together | May show displacement |
| Zoom handling | No rebuild needed | No rebuild needed | Needs rebuild on zoom |
| Performance | Slightly slower | Fast | Moderate |
When to use Turf.js lineOffset (Recommended)
- You need routes to be visually distinct at all zoom levels
- Routes are relatively short (city-scale)
- You want true geometric separation
- Simple, clean code is important
When to use varying line weights
- You want the simplest possible implementation
- You prefer routes to stay visually grouped
- Performance is critical (no geometry processing)
- You cannot add external dependencies
When to use leaflet-polylineoffset
- You need pixel-perfect control over visual spacing
- You are already using the plugin for other purposes
- You want consistent screen-space separation regardless of zoom
Tip: For most use cases, we recommend Turf.js lineOffset. It provides the cleanest visual separation with moderate complexity. Use varying line weights when simplicity is paramount. Use leaflet-polylineoffset only if you need precise pixel-level control.
Explore the Demos
All three demos show four routes from Paris landmarks to the Arc de Triomphe. You can:
- Toggle individual routes on/off using checkboxes
- Click a route to zoom and see details
- Compare how each approach handles overlapping segments
Turf.js lineOffset Demo
Varying Line Weights Demo
leaflet-polylineoffset Demo
Summary
Displaying multiple overlapping routes on a map is a common challenge. We explored three practical solutions:
Turf.js lineOffset - Creates geometrically parallel routes by shifting each path perpendicular to its direction. Best for clear visual separation and clean code.
Varying line weights - Uses different line thicknesses to create a layered effect. Simplest implementation with no dependencies.
leaflet-polylineoffset - Pixel-based visual offset. More complex but offers precise screen-space control.
All three approaches work well with the Geoapify Routing API and Leaflet. For most use cases, we recommend Turf.js lineOffset as the best balance of simplicity and visual clarity.
Key takeaways:
- Overlapping routes need visual separation to be useful
- Geometric offset (Turf.js) creates truly parallel paths
- Line weight layering is simplest but less distinct
- Pixel offset offers precise control but adds complexity
- Custom markers help users match routes to origins
Useful links:
- Geoapify Routing API
- Turf.js lineOffset documentation
- Leaflet Polyline documentation
- Map Marker Icon API
FAQ
Q: Can I use these techniques with MapLibre GL instead of Leaflet?
A: MapLibre GL has built-in support for line offset via the line-offset paint property. This is actually the easiest solution if you are using MapLibre GL. Check the MapLibre GL documentation for examples.
Q: How do I choose the right offset distance?
A: It depends on your typical zoom level and route lengths. For city-scale routes (1-5 km), 10-20 meters works well. For longer routes, you may need larger offsets. Test at your expected zoom levels.
Q: What if routes cross each other after offsetting?
A: This can happen at intersections or when routes approach from opposite directions. The offset is perpendicular to the route direction, so crossing points may shift. In practice, this is rarely a problem for typical use cases.
Q: Can I animate route drawing?
A: Yes, but that is a separate topic. You would need to progressively reveal the polyline coordinates. All three approaches support this, but the implementation is beyond this tutorial's scope.
Q: How many routes can I display before performance suffers?
A: Leaflet handles dozens of routes well. The Turf.js approach adds some processing overhead, but it is negligible for typical use cases (under 20 routes). For hundreds of routes, consider server-side simplification or clustering.
Q: Can I combine multiple approaches?
A: Technically yes, but it is usually unnecessary. Pick one approach based on your requirements. Combining them adds complexity without clear benefit.
Try It Now
Please sign up at geoapify.com and generate your own API key to start building multi-route visualizations.






Top comments (0)