DEV Community

Cover image for How to Visualize Multiple Overlapping Routes on a Leaflet Map
Casey Rivers for Geoapify Maps API

Posted on

How to Visualize Multiple Overlapping Routes on a Leaflet Map

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:

  1. Turf.js lineOffset - Geometric offset that creates truly parallel routes
  2. Varying line weights - Visual layering without any offset
  3. 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:

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

  1. The Problem: Overlapping Routes
  2. Set Up the Map and Fetch Routes
  3. Approach 1: Geometric Offset with Turf.js
  4. Approach 2: Varying Line Weights
  5. Approach 3: Pixel Offset with leaflet-polylineoffset
  6. Add Custom Markers
  7. Visual Comparison at Different Zoom Levels
  8. Comparison: Which Approach to Use
  9. Explore the Demos
  10. Summary
  11. 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>
Enter fullscreen mode Exit fullscreen mode

For the Turf.js approach, also include:

<script src="https://unpkg.com/@turf/turf@7/turf.min.js"></script>
Enter fullscreen mode Exit fullscreen mode

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}}
];
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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)
            };
        });
});
Enter fullscreen mode Exit fullscreen mode

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];
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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 [];
}
Enter fullscreen mode Exit fullscreen mode

GeoJSON uses [longitude, latitude] order, but Leaflet expects [latitude, longitude]. This function handles the conversion and supports both LineString and MultiLineString geometries.

Map showing four routes from Paris landmarks to Arc de Triomphe, each route visually separated using Turf.js geometric offset

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)
];
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

This approach requires no external library. The routes still overlap geometrically, but the varying thicknesses create a layered effect where each color peeks through.

Map showing four routes with varying line weights, creating a layered visual effect

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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.

Map showing four routes with pixel-based offset using leaflet-polylineoffset plugin

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);
}
Enter fullscreen mode Exit fullscreen mode

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:

Side-by-side comparison of all three approaches at zoom level 13

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:

Side-by-side comparison of all three approaches at zoom level 16

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:

Side-by-side comparison of all three approaches at zoom level 11

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:

  1. Turf.js lineOffset - Creates geometrically parallel routes by shifting each path perpendicular to its direction. Best for clear visual separation and clean code.

  2. Varying line weights - Uses different line thicknesses to create a layered effect. Simplest implementation with no dependencies.

  3. 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:


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)