Delivery Route Optimizer with OR-Tools

Solve a 10-stop delivery TSP in the browser using WebAssembly and real driving times.

Introduction

30 Second Summary

Every delivery driver faces the same puzzle: what order should I visit my stops to minimize total drive time? This is one of the most famous problems in computer science, and logistics companies spend millions solving it at scale.

In this project, you will build a browser-based delivery route optimizer that solves the Travelling Salesman Problem for 10 stops using real driving times and displays the result on an interactive map.

What You'll Build

A single-page web application where you place delivery stops on a map, click "Optimize Route," and instantly see the fastest driving order drawn along actual roads.

By the end of this project, you'll have:

  • A Vite-powered vanilla JS project using OR-Tools compiled to WebAssembly to solve the TSP entirely in the browser.
  • An interactive Leaflet map with draggable, numbered delivery stop markers and a road-following route polyline.
  • Integration with OSRM's free public APIs to fetch real driving-time matrices and actual road geometry.
  • Secret Mission: Add time window constraints so certain stops must be visited within specific hours, upgrading the solver to a constrained Vehicle Routing Problem.

Are there any prerequisites?

You should be comfortable with JavaScript and ES module imports. No prior experience with route optimization, mapping libraries, or WebAssembly is required.

Set Up the Vite Project with OR-Tools WASM

The foundation of your route optimizer is a modern JavaScript project that can run Google's OR-Tools solver directly in the browser. OR-Tools normally runs as a C++ library, but a WebAssembly build lets you use it client-side with no backend server required.

In this step, you'll scaffold a Vite project and install two key packages: or-tools-wasm for solving the Travelling Salesman Problem, and leaflet for drawing interactive maps.

In this step, get ready to:
  • Scaffold a Vite vanilla JavaScript project and install dependencies.
  • Configure Vite to support WebAssembly threading with cross-origin isolation headers.
  • Verify the dev server starts with no errors.
Scaffold a Vite project and install dependencies

Vite is a fast build tool that serves your JavaScript project with hot module replacement during development. You'll use its vanilla JavaScript template as the starting point for your optimizer.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Cursor and press Enter to open it.
  • Open the integrated terminal by pressing Ctrl+` (backtick key, same shortcut on both macOS and Windows).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Scaffold a new Vite project by running this command:
npm create vite@latest route-optimizer -- --template vanilla

What does this command do?

  • npm create vite@latest runs the Vite project scaffolder.
  • route-optimizer is the name of your project folder.
  • -- --template vanilla tells Vite to use the plain JavaScript template (no React or Vue).

Confirm package installation

If npm asks whether to install the create-vite package, type y and press Enter to continue.

  • Confirm the project was created by running:
ls route-optimizer

You should see index.html, package.json, and a src folder listed in the output.

  • Move into the project folder and install the two main dependencies by running these commands:
cd route-optimizer
npm install or-tools-wasm leaflet

What are these packages?

  • or-tools-wasm is Google's OR-Tools optimization library compiled to WebAssembly. It solves routing problems like the Travelling Salesman Problem directly in the browser.
  • leaflet is a lightweight mapping library for rendering interactive maps with markers, polylines, and tile layers.

You should see output confirming packages were added, with no error messages.

Install failing?

  • Check that you ran cd route-optimizer before the install command. You must be inside the project folder.
  • If you see network errors, check your internet connection and try again.
  • help me fix npm install errors in my Vite project
Configure Vite for WebAssembly threading

The or-tools-wasm package uses SharedArrayBuffer for multithreaded WebAssembly. Browsers only allow SharedArrayBuffer when the page is served with special cross-origin isolation headers.

You also need to tell Vite how to handle the or-tools-wasm package during dependency bundling. Without this config, the dev server will crash on startup.

  • Open the route-optimizer folder in Cursor by clicking File in the top menu bar.
  • Click Open Folder.
  • Select the route-optimizer folder on your Desktop, then click Open.
  • Create a new file called vite.config.js in the root of your project by right-clicking in the Explorer sidebar and selecting New File.
  • Name it vite.config.js and press Enter.
  • Paste the following configuration into vite.config.js:
import { defineConfig } from "vite";

export default defineConfig({
  optimizeDeps: {
    include: ["protobufjs"],
    exclude: ["or-tools-wasm"],
  },
  worker: {
    format: "es",
  },
  server: {
    headers: {
      "Cross-Origin-Opener-Policy": "same-origin",
      "Cross-Origin-Embedder-Policy": "require-corp",
    },
  },
  preview: {
    headers: {
      "Cross-Origin-Opener-Policy": "same-origin",
      "Cross-Origin-Embedder-Policy": "require-corp",
    },
  },
});

What does this config do?

  • optimizeDeps.include: ["protobufjs"] forces Vite to pre-bundle this CommonJS dependency so it works with ES modules.
  • optimizeDeps.exclude: ["or-tools-wasm"] tells Vite to leave the WASM package alone during dependency optimization (it handles its own loading).
  • worker.format: "es" ensures Web Workers use ES module format, which is required by the WASM threading setup.
  • The Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy headers enable cross-origin isolation. This unlocks SharedArrayBuffer, which or-tools-wasm needs for multithreaded solving.
  • Save vite.config.js by pressing Cmd+S (macOS) or Ctrl+S (Windows).

✔️ Awesome, I've got everything!

Great. Double check you've saved your file and move on to the next substep.

ⓧ I'd like to double check the full code

Here is the complete vite.config.js file:

import { defineConfig } from "vite";

export default defineConfig({
  optimizeDeps: {
    include: ["protobufjs"],
    exclude: ["or-tools-wasm"],
  },
  worker: {
    format: "es",
  },
  server: {
    headers: {
      "Cross-Origin-Opener-Policy": "same-origin",
      "Cross-Origin-Embedder-Policy": "require-corp",
    },
  },
  preview: {
    headers: {
      "Cross-Origin-Opener-Policy": "same-origin",
      "Cross-Origin-Embedder-Policy": "require-corp",
    },
  },
});

File not showing in the sidebar?

  • Make sure you opened the route-optimizer folder (not its parent). The file should appear at the same level as package.json in the sidebar.
  • Check that the file is named exactly vite.config.js (not vite.config.ts or viteconfig.js).
  • help me find where to put vite.config.js in my project
Verify the dev server starts

With the config in place, the dev server should start without errors and serve your page with the cross-origin isolation headers active.

  • Start the Vite dev server by running this command in your terminal:
npm run dev

You should see output showing a local URL, typically http://localhost:5173.

  • Hold Cmd (macOS) or Ctrl (Windows) and click the URL in the terminal to open it in your browser.
  • Confirm the page loads with the Vite starter content (a heading and a counter button).
  • Open your browser's developer console by pressing Cmd+Option+J (macOS) or Ctrl+Shift+J (Windows) and check that there are no error messages.

Seeing errors in the console?

  • If you see a Cross-Origin-Embedder-Policy error, double check that your vite.config.js has both headers set under server.headers.
  • If the page is blank, make sure you saved vite.config.js and restarted the dev server (press Ctrl+C in the terminal, then run npm run dev again).
  • help me fix Vite dev server errors

Your project scaffold is ready with OR-Tools WASM and Leaflet installed. Next up, you'll build the interactive map where delivery stops will live.

Build the Interactive Map with Draggable Pins

Your project skeleton is in place and the dev server is serving a page. Now it's time to build the interface a delivery dispatcher will actually interact with.

In this step, you'll create an interactive map where users can click to place delivery stops, drag them to adjust positions, and control the workflow with buttons. The map uses Leaflet.js, an open-source mapping library that renders OpenStreetMap tiles directly in the browser.

In this step, get ready to:
  • Set up the HTML structure with a Leaflet map container and control buttons.
  • Implement click-to-place markers with a green depot and numbered blue stops.
  • Add Clear All and Optimize Route button handlers with proper state management.
Set up the Leaflet map with OpenStreetMap tiles

Before writing any JavaScript logic, you need the HTML shell that holds the map and buttons. You also need Leaflet's CSS loaded so markers and tiles render correctly.

  • Replace the contents of index.html in your route-optimizer folder with the following:
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Delivery Route Optimizer</title>
  <!-- Leaflet CSS for map tiles and marker styling -->
  <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
  <!-- App styles -->
  <link rel="stylesheet" href="/src/style.css" />
</head>
<body>
  <div id="app">
    <h1>Delivery Route Optimizer</h1>
    <p class="instructions">Click the map to place a depot (first click) then up to 10 delivery stops. Drag pins to adjust.</p>
    <div id="controls">
      <!-- Disabled until 3+ stops are placed -->
      <button id="optimize-btn" disabled>Optimize Route</button>
      <button id="clear-btn">Clear All</button>
    </div>
    <!-- Leaflet renders the map inside this div -->
    <div id="map"></div>
    <!-- Results panel, hidden until optimization runs -->
    <div id="results" class="hidden">
      <h2>Optimized Route</h2>
      <p id="total-time"></p>
      <ul id="leg-list"></ul>
    </div>
  </div>
  <script type="module" src="/src/main.js"></script>
</body>
</html>

What does this HTML do?

The <link> tag pulls Leaflet's CSS from a CDN so map tiles, popups, and markers render with proper positioning.

The #map div is where Leaflet injects its interactive tile canvas. The #results div starts hidden and will show the optimized route breakdown after the solver runs.

  • Save index.html.

Next, create the stylesheet that sizes the map and styles the marker icons.

  • Create a new file called src/style.css inside the src folder of your route-optimizer project, and paste the following:
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  padding: 1rem;
  background: #f5f5f5;
}

#app {
  max-width: 900px;
  margin: 0 auto;
}

h1 {
  margin-bottom: 0.5rem;
}

.instructions {
  color: #666;
  margin-bottom: 1rem;
}

#controls {
  display: flex;
  gap: 0.5rem;
  margin-bottom: 1rem;
}

button {
  padding: 0.5rem 1rem;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 0.9rem;
}

#optimize-btn {
  background: #2563eb;
  color: white;
}

#optimize-btn:disabled {
  background: #93c5fd;
  cursor: not-allowed;
}

#clear-btn {
  background: #dc2626;
  color: white;
}

#map {
  height: 500px;
  border-radius: 8px;
  border: 2px solid #ddd;
}

#results {
  margin-top: 1rem;
  padding: 1rem;
  background: white;
  border-radius: 8px;
  border: 1px solid #ddd;
}

#results.hidden {
  display: none;
}

#results h2 {
  margin-bottom: 0.5rem;
}

#total-time {
  font-size: 1.2rem;
  font-weight: bold;
  color: #2563eb;
  margin-bottom: 0.5rem;
}

#leg-list {
  list-style: none;
  padding: 0;
}

#leg-list li {
  padding: 0.25rem 0;
  border-bottom: 1px solid #eee;
}

/* Custom marker icons rendered as colored circles */
.depot-icon {
  background: #16a34a;
  border: 2px solid white;
  border-radius: 50%;
  width: 20px;
  height: 20px;
  box-shadow: 0 2px 4px rgba(0,0,0,0.3);
}

.stop-icon {
  background: #2563eb;
  border: 2px solid white;
  border-radius: 50%;
  width: 20px;
  height: 20px;
  display: flex;
  align-items: center;
  justify-content: center;
  color: white;
  font-size: 11px;
  font-weight: bold;
  box-shadow: 0 2px 4px rgba(0,0,0,0.3);
}

What do the icon classes do?

The .depot-icon class creates a green circle for the starting warehouse. The .stop-icon class creates a blue circle with centered white text for numbered delivery stops. Leaflet's divIcon uses these classes to render custom HTML markers instead of default pin images.

  • Save src/style.css.

Now initialize the map itself in JavaScript.

  • Replace everything in src/main.js with the following map initialization code:
import L from "leaflet";

// Initialize the map centered on New York City
const map = L.map("map").setView([40.7128, -74.006], 13);

// Add OpenStreetMap tiles as the base layer
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
  maxZoom: 19,
  attribution:
    '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>',
}).addTo(map);

// State: coordinates stored as [lng, lat] for OSRM compatibility
const stops = [];
let routeLayer = null;
const markers = [];

// Cache DOM references for buttons and results panel
const optimizeBtn = document.getElementById("optimize-btn");
const clearBtn = document.getElementById("clear-btn");
const resultsDiv = document.getElementById("results");
const totalTimeEl = document.getElementById("total-time");
const legListEl = document.getElementById("leg-list");

What's happening here?

  • L.map("map") targets the #map div and creates an interactive map instance.
  • setView([40.7128, -74.006], 13) centers the view on New York City at zoom level 13 (neighborhood scale).
  • The stops array stores coordinates as [lng, lat] because OSRM's API expects longitude first. Leaflet uses lat/lng internally, so the code converts between formats.
  • Save src/main.js and check your browser.
  • You should see a map of New York City with zoom controls and tile imagery. The buttons above it appear but don't work yet.

Map not showing up?

  • Check that your index.html includes the Leaflet CSS <link> tag. Without it, the map tiles render at zero height.
  • Make sure the #map selector in src/style.css has height: 500px. Leaflet needs an explicit height on its container.
  • Open your browser console and look for CORS errors. The cross-origin headers in vite.config.js can block resources that don't support CORS. OpenStreetMap tiles do support CORS, so this usually means the tile URL has a typo.

Help me debug why my Leaflet map is blank or not rendering.

Implement click-to-place markers with depot and numbered stops

The map needs to respond to clicks by placing markers. The first click establishes the depot (where the delivery vehicle starts and returns). Every subsequent click adds a numbered delivery stop.

  • Add the createMarkerIcon function below the DOM references in src/main.js:
function createMarkerIcon(index) {
  // Index 0 is the depot - green circle
  if (index === 0) {
    return L.divIcon({
      className: "depot-icon",
      iconSize: [20, 20],
      iconAnchor: [10, 10],
    });
  }
  // All other stops - blue circle with number
  return L.divIcon({
    className: "stop-icon",
    html: `${index}`,
    iconSize: [20, 20],
    iconAnchor: [10, 10],
  });
}

What does this code do?

L.divIcon() creates a marker icon from a plain HTML element instead of an image. The className maps to your CSS classes (.depot-icon for green, .stop-icon for blue). The html property injects the stop number inside the blue circle.

Next, add the helper functions that manage state when markers move or the route needs clearing.

  • Add these three helper functions below createMarkerIcon:
function updateButtonState() {
  // Need at least depot + 2 stops to optimize
  optimizeBtn.disabled = stops.length < 3;
}

function clearRoute() {
  // Remove the drawn route polyline from the map
  if (routeLayer) {
    map.removeLayer(routeLayer);
    routeLayer = null;
  }
  resultsDiv.classList.add("hidden");
}

function onMarkerDrag(index) {
  // Returns a handler that updates the stop coordinate when dragged
  return function (e) {
    const latlng = e.target.getLatLng();
    stops[index] = [latlng.lng, latlng.lat];
    clearRoute();
  };
}

What do these helpers do?

  • updateButtonState() enables the Optimize button only when 3+ stops exist (depot plus at least 2 delivery stops).
  • clearRoute() removes any drawn route polyline and hides the results panel. This runs whenever a marker moves, so the old route doesn't show stale data.
  • onMarkerDrag() returns a closure that captures the stop's index. When the marker is dragged, it updates that stop's coordinates in the stops array.

Now add the map click handler that ties everything together.

  • Add the map click event handler below the helper functions:
map.on("click", function (e) {
  // Max 11 markers: 1 depot + 10 delivery stops
  if (stops.length >= 11) return;

  // Store as [lng, lat] for OSRM API compatibility
  const coord = [e.latlng.lng, e.latlng.lat];
  const index = stops.length;
  stops.push(coord);

  // Create a draggable marker with the appropriate icon
  const marker = L.marker(e.latlng, {
    draggable: true,
    icon: createMarkerIcon(index),
  }).addTo(map);

  // Show stop number on hover
  marker.bindTooltip(index === 0 ? "Depot" : `Stop ${index}`, {
    permanent: false,
  });

  // Update coordinates when marker is dragged
  marker.on("dragend", onMarkerDrag(index));
  markers.push(marker);

  updateButtonState();
  clearRoute();
});

What does the click handler do?

Each click on the map captures the coordinates, pushes them to the stops array, and places a draggable marker. The first marker (index 0) gets the green depot icon. All subsequent markers get blue numbered icons.

The bindTooltip call adds a label that appears on hover. The dragend listener ensures the stops array stays in sync if the user repositions a pin.

  • Save src/main.js and refresh your browser.
  • Click anywhere on the map. Your first click should place a small green circle (the depot). Click again to place blue numbered circles. Hover over any marker to see its tooltip.

Markers not appearing on click?

  • Check the browser console for errors. A common issue is the Leaflet CSS not being loaded, which causes markers to render off-screen.
  • Make sure your createMarkerIcon function uses L.divIcon (not L.icon). The default L.icon expects an image file path that doesn't exist in this project.

Help me fix markers not showing when I click the Leaflet map.

Add button handlers for Clear All and Optimize Route

The buttons need event listeners. Clear All wipes all markers and resets state. Optimize Route will eventually trigger the solver, but for now it's a placeholder.

  • Add the button event listeners at the bottom of src/main.js, below the map click handler:
clearBtn.addEventListener("click", function () {
  // Remove all markers from the map
  markers.forEach((m) => map.removeLayer(m));
  markers.length = 0;
  stops.length = 0;
  clearRoute();
  updateButtonState();
});

optimizeBtn.addEventListener("click", async function () {
  // To be implemented
});

What does this code do?

The Clear All handler removes every marker from the map layer, empties both tracking arrays (markers and stops), clears any drawn route, and disables the Optimize button again.

The Optimize Route handler is async because it will eventually call the OSRM API and the OR-Tools solver. You'll fill it in during a later step.

  • Save src/main.js and refresh your browser.
  • Place 3 or more markers on the map. The Optimize Route button should turn from faded blue to solid blue. Click Clear All and all markers should disappear from the map.

Optimize button stays disabled?

  • Make sure updateButtonState() is called inside the map click handler. Without it, the button state never updates when new markers are placed.
  • Verify the condition uses stops.length < 3. Three stops means the depot plus two delivery locations, which is the minimum for meaningful optimization.

Help me figure out why my Optimize Route button won't enable.

✔️ Awesome, I've got everything!

Great. Double check you've saved all three files.

ⓧ I'd like to double check the full code

Here is the complete index.html:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Delivery Route Optimizer</title>
  <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
  <link rel="stylesheet" href="/src/style.css" />
</head>
<body>
  <div id="app">
    <h1>Delivery Route Optimizer</h1>
    <p class="instructions">Click the map to place a depot (first click) then up to 10 delivery stops. Drag pins to adjust.</p>
    <div id="controls">
      <button id="optimize-btn" disabled>Optimize Route</button>
      <button id="clear-btn">Clear All</button>
    </div>
    <div id="map"></div>
    <div id="results" class="hidden">
      <h2>Optimized Route</h2>
      <p id="total-time"></p>
      <ul id="leg-list"></ul>
    </div>
  </div>
  <script type="module" src="/src/main.js"></script>
</body>
</html>

Here is the complete src/style.css:

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  padding: 1rem;
  background: #f5f5f5;
}

#app {
  max-width: 900px;
  margin: 0 auto;
}

h1 {
  margin-bottom: 0.5rem;
}

.instructions {
  color: #666;
  margin-bottom: 1rem;
}

#controls {
  display: flex;
  gap: 0.5rem;
  margin-bottom: 1rem;
}

button {
  padding: 0.5rem 1rem;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 0.9rem;
}

#optimize-btn {
  background: #2563eb;
  color: white;
}

#optimize-btn:disabled {
  background: #93c5fd;
  cursor: not-allowed;
}

#clear-btn {
  background: #dc2626;
  color: white;
}

#map {
  height: 500px;
  border-radius: 8px;
  border: 2px solid #ddd;
}

#results {
  margin-top: 1rem;
  padding: 1rem;
  background: white;
  border-radius: 8px;
  border: 1px solid #ddd;
}

#results.hidden {
  display: none;
}

#results h2 {
  margin-bottom: 0.5rem;
}

#total-time {
  font-size: 1.2rem;
  font-weight: bold;
  color: #2563eb;
  margin-bottom: 0.5rem;
}

#leg-list {
  list-style: none;
  padding: 0;
}

#leg-list li {
  padding: 0.25rem 0;
  border-bottom: 1px solid #eee;
}

.depot-icon {
  background: #16a34a;
  border: 2px solid white;
  border-radius: 50%;
  width: 20px;
  height: 20px;
  box-shadow: 0 2px 4px rgba(0,0,0,0.3);
}

.stop-icon {
  background: #2563eb;
  border: 2px solid white;
  border-radius: 50%;
  width: 20px;
  height: 20px;
  display: flex;
  align-items: center;
  justify-content: center;
  color: white;
  font-size: 11px;
  font-weight: bold;
  box-shadow: 0 2px 4px rgba(0,0,0,0.3);
}

Here is the complete src/main.js:

import L from "leaflet";

const map = L.map("map").setView([40.7128, -74.006], 13);

L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
  maxZoom: 19,
  attribution:
    '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>',
}).addTo(map);

const stops = [];
let routeLayer = null;
const markers = [];

const optimizeBtn = document.getElementById("optimize-btn");
const clearBtn = document.getElementById("clear-btn");
const resultsDiv = document.getElementById("results");
const totalTimeEl = document.getElementById("total-time");
const legListEl = document.getElementById("leg-list");

function createMarkerIcon(index) {
  if (index === 0) {
    return L.divIcon({
      className: "depot-icon",
      iconSize: [20, 20],
      iconAnchor: [10, 10],
    });
  }
  return L.divIcon({
    className: "stop-icon",
    html: `${index}`,
    iconSize: [20, 20],
    iconAnchor: [10, 10],
  });
}

function updateButtonState() {
  optimizeBtn.disabled = stops.length < 3;
}

function clearRoute() {
  if (routeLayer) {
    map.removeLayer(routeLayer);
    routeLayer = null;
  }
  resultsDiv.classList.add("hidden");
}

function onMarkerDrag(index) {
  return function (e) {
    const latlng = e.target.getLatLng();
    stops[index] = [latlng.lng, latlng.lat];
    clearRoute();
  };
}

map.on("click", function (e) {
  if (stops.length >= 11) return;

  const coord = [e.latlng.lng, e.latlng.lat];
  const index = stops.length;
  stops.push(coord);

  const marker = L.marker(e.latlng, {
    draggable: true,
    icon: createMarkerIcon(index),
  }).addTo(map);

  marker.bindTooltip(index === 0 ? "Depot" : `Stop ${index}`, {
    permanent: false,
  });

  marker.on("dragend", onMarkerDrag(index));
  markers.push(marker);

  updateButtonState();
  clearRoute();
});

clearBtn.addEventListener("click", function () {
  markers.forEach((m) => map.removeLayer(m));
  markers.length = 0;
  stops.length = 0;
  clearRoute();
  updateButtonState();
});

optimizeBtn.addEventListener("click", async function () {
  // To be implemented
});

Your interactive map is fully functional. Next up, you'll connect it to real driving-time data from OSRM so the optimizer can work with actual road distances instead of straight lines.

Fetch Real Driving Times from OSRM

Your map can place and drag markers, but those pins are just coordinates. To optimize a delivery route, the solver needs to know how long it takes to drive between every pair of stops.

Straight-line distances are useless for real deliveries because roads curve, have speed limits, and require turns. In this step, you will call OSRM's free routing API to get actual driving-time data and road geometry that follows real streets.

In this step, get ready to:
  • Write a function that fetches an NxN driving-time matrix from OSRM's Table API.
  • Write a function that fetches road geometry from OSRM's Route API.
  • Test both functions by logging real driving data to the console.
Write the duration matrix function

A distance matrix is a grid where each cell [i][j] holds the driving time from stop i to stop j. The route optimizer needs this entire grid to compare every possible ordering of stops.

OSRM (Open Source Routing Machine) provides a free Table API that computes this matrix in one request. You send it a list of coordinates and get back all pairwise driving times in seconds.

  • Create a new file called osrm.js inside the src folder of your route-optimizer project.
  • Add the OSRM base URL constant and the duration matrix function by pasting this code:
const OSRM_BASE = "http://router.project-osrm.org";

export async function fetchDurationMatrix(coords) {
  // Format coordinates as "lng,lat;lng,lat;..."
  const coordString = coords.map((c) => `${c[0]},${c[1]}`).join(";");
  const url = `${OSRM_BASE}/table/v1/driving/${coordString}?annotations=duration`;

  const response = await fetch(url);
  if (!response.ok) {
    throw new Error(`OSRM Table API error: ${response.status}`);
  }

  const data = await response.json();
  if (data.code !== "Ok") {
    throw new Error(`OSRM error: ${data.code}`);
  }

  // Return the NxN matrix of durations in seconds
  return data.durations;
}

What does this code do?

OSRM_BASE stores the public demo server URL. This server is free and rate-limited, perfect for a learning project.

The function joins all coordinates into OSRM's required format: lon,lat;lon,lat;... and appends ?annotations=duration to request driving times. The response contains data.durations, a 2D array where durations[i][j] is the travel time in seconds from stop i to stop j.

Why longitude first?

OSRM uses longitude,latitude order (the GeoJSON standard). Leaflet uses latitude,longitude internally. The stops array in main.js already stores coordinates as [lng, lat] so they can be passed directly to OSRM without conversion.

  • You won't see a visual change yet. The next chunk adds the second function, then you will test both together.
Write the route geometry function

The duration matrix tells the solver how long each leg takes, but it does not include the shape of the roads. To draw the optimized route on the map, you need the actual road geometry as a series of GPS points.

  • In src/osrm.js, add the route geometry function below the fetchDurationMatrix function:
export async function fetchRouteGeometry(orderedCoords) {
  // Format coordinates as "lng,lat;lng,lat;..."
  const coordString = orderedCoords.map((c) => `${c[0]},${c[1]}`).join(";");
  const url = `${OSRM_BASE}/route/v1/driving/${coordString}?overview=full&geometries=geojson`;

  const response = await fetch(url);
  if (!response.ok) {
    throw new Error(`OSRM Route API error: ${response.status}`);
  }

  const data = await response.json();
  if (data.code !== "Ok") {
    throw new Error(`OSRM error: ${data.code}`);
  }

  // Return the GeoJSON LineString geometry
  return data.routes[0].geometry;
}

What does this code do?

This function calls OSRM's Route API with two important parameters. overview=full requests the complete road geometry (not a simplified version). geometries=geojson tells OSRM to return the shape as a GeoJSON LineString that Leaflet can render directly.

The response contains data.routes[0].geometry, a LineString with coordinates tracing the actual road path between all provided stops in order.

  • Save src/osrm.js.

Seeing a syntax error on save?

Make sure the backtick template strings use backticks (`) and not regular quotes. Check that each function has matching opening and closing curly braces.

Help me find the syntax error in my osrm.js file.

✔️ Awesome, I've got everything!

Great. Double check you have saved your file before moving on.

ⓧ I'd like to double check the full code

const OSRM_BASE = "http://router.project-osrm.org";

export async function fetchDurationMatrix(coords) {
  const coordString = coords.map((c) => `${c[0]},${c[1]}`).join(";");
  const url = `${OSRM_BASE}/table/v1/driving/${coordString}?annotations=duration`;

  const response = await fetch(url);
  if (!response.ok) {
    throw new Error(`OSRM Table API error: ${response.status}`);
  }

  const data = await response.json();
  if (data.code !== "Ok") {
    throw new Error(`OSRM error: ${data.code}`);
  }

  return data.durations;
}

export async function fetchRouteGeometry(orderedCoords) {
  const coordString = orderedCoords.map((c) => `${c[0]},${c[1]}`).join(";");
  const url = `${OSRM_BASE}/route/v1/driving/${coordString}?overview=full&geometries=geojson`;

  const response = await fetch(url);
  if (!response.ok) {
    throw new Error(`OSRM Route API error: ${response.status}`);
  }

  const data = await response.json();
  if (data.code !== "Ok") {
    throw new Error(`OSRM error: ${data.code}`);
  }

  return data.routes[0].geometry;
}
Test both functions from the browser console

Before wiring these functions into the Optimize button, you can verify they work by calling them directly from your browser's DevTools console.

  • Switch back to your browser tab showing the app at localhost:5173.
  • Click three spots on the map to place a depot and two delivery stops. Place them a few blocks apart so OSRM can find driving routes between them.
  • Open DevTools by pressing Cmd+Option+J (macOS) or Ctrl+Shift+J (Windows).
  • In the Console tab, paste and run this test code:
const { fetchDurationMatrix, fetchRouteGeometry } = await import('/src/osrm.js');

// Test with 3 points in Manhattan
const testCoords = [[-74.006, 40.7128], [-73.985, 40.748], [-73.968, 40.763]];

const matrix = await fetchDurationMatrix(testCoords);
console.log('Duration matrix:', matrix);

const geometry = await fetchRouteGeometry(testCoords);
console.log('Route geometry:', geometry);

What should I see?

The first log prints a 3x3 array of numbers (driving times in seconds between each pair of points). For example, matrix[0][1] is the time from the first coordinate to the second. Diagonal values are 0 (a point to itself).

The second log prints a GeoJSON geometry object with type: "LineString" and a coordinates array containing hundreds of [lng, lat] pairs tracing the road path.

Seeing a CORS or network error?

The OSRM demo server uses HTTP (not HTTPS). If your browser blocks mixed content, check that your Vite dev server URL also uses http:// and not https://.

If you see null values in the matrix, your test coordinates may be in water or unreachable by road. Try coordinates closer to the center of Manhattan.

Help me debug OSRM API errors in my route optimizer project.

You now have real driving data flowing into your app. Next up, you will feed that duration matrix into OR-Tools to solve the Travelling Salesman Problem and find the fastest delivery order.

Solve the TSP with OR-Tools WASM

Your map can place stops, and your OSRM functions can fetch real driving times between them. Now you need the brain of the operation: a solver that figures out the fastest order to visit every stop.

This is the Travelling Salesman Problem (TSP). Finding the optimal route through N stops has factorial complexity. Google's OR-Tools solver handles this efficiently using heuristics, and the or-tools-wasm package runs it entirely in the browser via WebAssembly.

In this step, get ready to:
  • Import and initialize the OR-Tools routing engine.
  • Write a solver function that finds the optimal visit order.
  • Wire the Optimize Route button to run the solver and log results.
Import and initialize the OR-Tools routing runtime

The or-tools-wasm package requires an async initialization step before you can use its routing classes. You will create a dedicated solver file that handles this setup and keeps the routing logic separate from the map code.

  • Create a new file called solver.js inside your src/ folder.
  • Add the OR-Tools imports and initialization guard by pasting this code:
import {
  initRouting,
  RoutingIndexManager,
  RoutingModel,
  DefaultRoutingSearchParameters,
  FirstSolutionStrategy,
} from "or-tools-wasm/routing";

// Track whether the WASM runtime has been loaded
let initialized = false;

// Load the routing engine once, then skip on subsequent calls
async function ensureInitialized() {
  if (!initialized) {
    await initRouting();
    initialized = true;
  }
}

What does this code do?

The five imports bring in the core OR-Tools routing API. initRouting() loads the WebAssembly binary into memory. The other four are the classes and functions you will use to define and solve the TSP.

The ensureInitialized() wrapper uses a boolean flag so the WASM binary only loads once, even if you call the solver multiple times.

  • Save src/solver.js.
Write the solveTSP function

The solver needs to take a duration matrix (the grid of driving times between every pair of stops) and return the optimal visit order. OR-Tools works by creating an index manager, registering a cost callback, then solving with a chosen strategy.

  • In src/solver.js, add the following solveTSP function below the ensureInitialized function:
export async function solveTSP(durationMatrix) {
  await ensureInitialized();

  const numLocations = durationMatrix.length;
  const numVehicles = 1;
  const depot = 0;

  // Create the routing index manager (N stops, 1 vehicle, starting at depot)
  const manager = new RoutingIndexManager(numLocations, numVehicles, depot);
  const routing = new RoutingModel(manager);

  // OR-Tools needs integer costs, so round the duration values
  const intMatrix = durationMatrix.map((row) =>
    row.map((val) => Math.round(val))
  );

  // Register a callback that returns the travel time between two nodes
  const transitCallbackIndex = routing.RegisterTransitCallback(
    (fromIndex, toIndex) => {
      const fromNode = manager.IndexToNode(fromIndex);
      const toNode = manager.IndexToNode(toIndex);
      return intMatrix[fromNode][toNode];
    }
  );

  // Tell the solver to minimize total travel time
  routing.SetArcCostEvaluatorOfAllVehicles(transitCallbackIndex);

  // Use PATH_CHEAPEST_ARC as the initial solution heuristic
  const searchParameters = DefaultRoutingSearchParameters();
  searchParameters.firstSolutionStrategy = FirstSolutionStrategy.PATH_CHEAPEST_ARC;

  // Solve the problem
  const assignment = await routing.SolveWithParameters(searchParameters);

  if (!assignment) {
    manager.delete();
    routing.delete();
    return null;
  }

  // Extract the optimized visit order and total duration
  const order = [];
  let totalDuration = 0;
  let index = routing.Start(0);

  while (!routing.IsEnd(index)) {
    const node = manager.IndexToNode(index);
    order.push(node);
    const previousIndex = index;
    index = assignment.Value(routing.NextVar(index));
    totalDuration += routing.GetArcCostForVehicle(previousIndex, index, 0);
  }

  // Free WASM memory
  manager.delete();
  routing.delete();

  return { order, totalDuration };
}

What does this code do?

  • RoutingIndexManager maps between the solver's internal indices and your node numbers (0 through N-1).
  • RegisterTransitCallback gives the solver a function that returns the cost (driving time in seconds) of travelling between any two stops.
  • PATH_CHEAPEST_ARC is a greedy heuristic that builds an initial solution by always choosing the cheapest next edge. The solver then improves from there.
  • The while (!routing.IsEnd(index)) loop walks the solved route from start to finish, collecting each node into the order array and summing arc costs.
  • manager.delete() and routing.delete() free the WASM heap memory. Skipping these would leak memory on repeated solves.
  • Save src/solver.js.

Why does the callback convert indices to nodes?

OR-Tools uses its own internal routing indices that may differ from your node numbers. The IndexToNode method translates from the solver's index space back to your original 0-based stop numbers. Without this conversion, you would look up the wrong cell in the duration matrix.

Seeing a syntax error in solver.js?

  • Make sure you have the closing } for the solveTSP function at the very end of the file.
  • Check that export appears before async function solveTSP. Without the export, main.js won't be able to import it.

Help me debug a syntax error in my solver.js file

Wire the Optimize Route button to the solver

The solver is ready. Now you need to connect it to the button so clicking Optimize Route fetches the driving-time matrix, solves the TSP, and logs the result.

  • In src/main.js, find the import line at the top of the file:
import L from "leaflet";
  • Add these two import lines directly below it, so the top of your file looks like this:
import L from "leaflet";
import { fetchDurationMatrix } from "./osrm.js";
import { solveTSP } from "./solver.js";

What does this code do?

You are importing fetchDurationMatrix from the OSRM module you built earlier, and solveTSP from the solver you just created. These two functions are the pipeline: fetch real driving times, then find the optimal order.

  • Now scroll down to the optimizeBtn click handler at the bottom of the file. Find this code:
optimizeBtn.addEventListener("click", async function () {
  // To be implemented
});
  • Replace the entire event listener with this code:
optimizeBtn.addEventListener("click", async function () {
  optimizeBtn.disabled = true;
  optimizeBtn.textContent = "Optimizing...";

  try {
    // Fetch the NxN driving-time matrix from OSRM
    const matrix = await fetchDurationMatrix(stops);
    // Solve for the optimal visit order
    const result = await solveTSP(matrix);

    if (!result) {
      alert("No solution found. Try different stop locations.");
      return;
    }

    console.log("Optimized order:", result.order);
    console.log("Total duration (s):", result.totalDuration);
  } catch (err) {
    console.error(err);
    alert("Error optimizing route: " + err.message);
  } finally {
    optimizeBtn.disabled = false;
    optimizeBtn.textContent = "Optimize Route";
    updateButtonState();
  }
});

What does this code do?

  • The button disables itself and shows "Optimizing..." so the user knows work is happening.
  • fetchDurationMatrix(stops) sends the current stop coordinates to OSRM and gets back a matrix of driving times in seconds.
  • solveTSP(matrix) feeds that matrix to OR-Tools, which returns the optimal visit order and total duration.
  • The finally block re-enables the button regardless of success or failure.
  • Save src/main.js.
  • Switch back to your browser with the map open at localhost:5173.
  • Click the map to place a depot (first click), then click at least 2 more spots to place delivery stops.
  • Open the browser console with Cmd+Option+J (macOS) or Ctrl+Shift+J (Windows).
  • Click the Optimize Route button.

After a moment, you should see two log lines in the console: the optimized visit order (an array like [0, 3, 7, 2, ...]) and the total route duration in seconds.

Not seeing any console output?

  • Check the console for import errors. If you see a message about or-tools-wasm not being found, confirm you ran npm install or-tools-wasm in Step 1.
  • Make sure your stops are placed within the same city area. Stops on different continents may cause OSRM to return unreachable results.
  • Verify your dev server is running with the cross-origin isolation headers from vite.config.js. Without them, the WebAssembly module cannot use SharedArrayBuffer.

Help me debug why clicking Optimize Route produces no console output

✔️ Awesome, I've got everything!

Great. Double check you have saved both files before moving on.

ⓧ I'd like to double check the full code

Here is the complete src/solver.js:

import {
  initRouting,
  RoutingIndexManager,
  RoutingModel,
  DefaultRoutingSearchParameters,
  FirstSolutionStrategy,
} from "or-tools-wasm/routing";

let initialized = false;

async function ensureInitialized() {
  if (!initialized) {
    await initRouting();
    initialized = true;
  }
}

export async function solveTSP(durationMatrix) {
  await ensureInitialized();

  const numLocations = durationMatrix.length;
  const numVehicles = 1;
  const depot = 0;

  const manager = new RoutingIndexManager(numLocations, numVehicles, depot);
  const routing = new RoutingModel(manager);

  const intMatrix = durationMatrix.map((row) =>
    row.map((val) => Math.round(val))
  );

  const transitCallbackIndex = routing.RegisterTransitCallback(
    (fromIndex, toIndex) => {
      const fromNode = manager.IndexToNode(fromIndex);
      const toNode = manager.IndexToNode(toIndex);
      return intMatrix[fromNode][toNode];
    }
  );

  routing.SetArcCostEvaluatorOfAllVehicles(transitCallbackIndex);

  const searchParameters = DefaultRoutingSearchParameters();
  searchParameters.firstSolutionStrategy = FirstSolutionStrategy.PATH_CHEAPEST_ARC;

  const assignment = await routing.SolveWithParameters(searchParameters);

  if (!assignment) {
    manager.delete();
    routing.delete();
    return null;
  }

  const order = [];
  let totalDuration = 0;
  let index = routing.Start(0);

  while (!routing.IsEnd(index)) {
    const node = manager.IndexToNode(index);
    order.push(node);
    const previousIndex = index;
    index = assignment.Value(routing.NextVar(index));
    totalDuration += routing.GetArcCostForVehicle(previousIndex, index, 0);
  }

  manager.delete();
  routing.delete();

  return { order, totalDuration };
}

And here is the complete src/main.js:

import L from "leaflet";
import { fetchDurationMatrix } from "./osrm.js";
import { solveTSP } from "./solver.js";

const map = L.map("map").setView([40.7128, -74.006], 13);

L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
  maxZoom: 19,
  attribution:
    '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>',
}).addTo(map);

const stops = [];
let routeLayer = null;
const markers = [];

const optimizeBtn = document.getElementById("optimize-btn");
const clearBtn = document.getElementById("clear-btn");
const resultsDiv = document.getElementById("results");
const totalTimeEl = document.getElementById("total-time");
const legListEl = document.getElementById("leg-list");

function createMarkerIcon(index) {
  if (index === 0) {
    return L.divIcon({
      className: "depot-icon",
      iconSize: [20, 20],
      iconAnchor: [10, 10],
    });
  }
  return L.divIcon({
    className: "stop-icon",
    html: `${index}`,
    iconSize: [20, 20],
    iconAnchor: [10, 10],
  });
}

function updateButtonState() {
  optimizeBtn.disabled = stops.length < 3;
}

function clearRoute() {
  if (routeLayer) {
    map.removeLayer(routeLayer);
    routeLayer = null;
  }
  resultsDiv.classList.add("hidden");
}

function onMarkerDrag(index) {
  return function (e) {
    const latlng = e.target.getLatLng();
    stops[index] = [latlng.lng, latlng.lat];
    clearRoute();
  };
}

map.on("click", function (e) {
  if (stops.length >= 11) return;

  const coord = [e.latlng.lng, e.latlng.lat];
  const index = stops.length;
  stops.push(coord);

  const marker = L.marker(e.latlng, {
    draggable: true,
    icon: createMarkerIcon(index),
  }).addTo(map);

  marker.bindTooltip(index === 0 ? "Depot" : `Stop ${index}`, {
    permanent: false,
  });

  marker.on("dragend", onMarkerDrag(index));
  markers.push(marker);

  updateButtonState();
  clearRoute();
});

clearBtn.addEventListener("click", function () {
  markers.forEach((m) => map.removeLayer(m));
  markers.length = 0;
  stops.length = 0;
  clearRoute();
  updateButtonState();
});

optimizeBtn.addEventListener("click", async function () {
  optimizeBtn.disabled = true;
  optimizeBtn.textContent = "Optimizing...";

  try {
    const matrix = await fetchDurationMatrix(stops);
    const result = await solveTSP(matrix);

    if (!result) {
      alert("No solution found. Try different stop locations.");
      return;
    }

    console.log("Optimized order:", result.order);
    console.log("Total duration (s):", result.totalDuration);
  } catch (err) {
    console.error(err);
    alert("Error optimizing route: " + err.message);
  } finally {
    optimizeBtn.disabled = false;
    optimizeBtn.textContent = "Optimize Route";
    updateButtonState();
  }
});

Your solver is finding optimal routes. Next up, you will draw that optimized route on the map as a road-following polyline so the dispatcher can actually see the path.

Visualize the Optimized Route on the Map

Your optimizer finds the fastest order to visit every stop. Right now that answer is trapped in the browser console where no dispatcher would ever look.

In this step, you will draw the optimized route directly on the map as a road-following polyline, relabel every marker with its new visit number, and populate a results panel showing total drive time and per-leg breakdown.

In this step, get ready to:
  • Draw the optimized route as a GeoJSON polyline on the Leaflet map.
  • Update marker icons and tooltips to reflect the optimized visit order.
  • Populate the results panel with total drive time and per-leg durations.
Import the route geometry function

To draw the route along real roads, you need the fetchRouteGeometry function you built in Step 3. It is already exported from src/osrm.js but not yet imported into src/main.js.

  • In src/main.js, find the import line at the top of the file:
import { fetchDurationMatrix } from "./osrm.js";
  • Replace it with this updated import that adds fetchRouteGeometry:
import { fetchDurationMatrix, fetchRouteGeometry } from "./osrm.js";
Replace the optimize handler with full visualization logic

The existing click handler logs the result to the console. You will replace it with code that draws the route, updates the markers, and fills in the results panel.

  • In src/main.js, find the entire optimizeBtn.addEventListener("click", ...) block near the bottom of the file.
  • Replace that entire event listener with the following code:
optimizeBtn.addEventListener("click", async function () {
  optimizeBtn.disabled = true;
  optimizeBtn.textContent = "Optimizing...";

  try {
    // Fetch the driving-time matrix from OSRM
    const matrix = await fetchDurationMatrix(stops);
    // Solve for the optimal visit order
    const result = solveTSP(matrix);

    if (!result) {
      alert("No solution found. Try different stop locations.");
      return;
    }

    // Reorder coordinates to match the optimized sequence
    const orderedCoords = result.order.map((i) => stops[i]);
    // Append the depot again so the route loops back
    orderedCoords.push(stops[result.order[0]]);

    // Fetch the road geometry for the optimized order
    const geometry = await fetchRouteGeometry(orderedCoords);

    // Remove any existing route polyline before drawing a new one
    if (routeLayer) {
      map.removeLayer(routeLayer);
    }
    // Draw the route as a GeoJSON layer on the map
    routeLayer = L.geoJSON(
      { type: "Feature", geometry: geometry },
      { style: { color: "#2563eb", weight: 4, opacity: 0.8 } }
    ).addTo(map);

    // Update each marker icon and tooltip to reflect the new visit order
    markers.forEach((m, i) => {
      const visitIndex = result.order.indexOf(i);
      m.setIcon(createMarkerIcon(visitIndex === 0 ? 0 : visitIndex));
      m.setTooltipContent(
        visitIndex === 0 ? "Depot (Start/End)" : `Visit #${visitIndex}`
      );
    });

    // Show total drive time in the results panel
    const totalMinutes = Math.round(result.totalDuration / 60);
    totalTimeEl.textContent = `Total drive time: ${totalMinutes} min`;

    // Build the per-leg duration breakdown
    legListEl.innerHTML = "";
    for (let i = 0; i < result.order.length - 1; i++) {
      const from = result.order[i];
      const to = result.order[i + 1];
      const legDuration = Math.round(matrix[from][to] / 60);
      const li = document.createElement("li");
      li.textContent = `${from === 0 ? "Depot" : "Stop " + from} \u2192 ${to === 0 ? "Depot" : "Stop " + to}: ${legDuration} min`;
      legListEl.appendChild(li);
    }
    // Add the return-to-depot leg
    const lastStop = result.order[result.order.length - 1];
    const returnDuration = Math.round(matrix[lastStop][result.order[0]] / 60);
    const returnLi = document.createElement("li");
    returnLi.textContent = `${lastStop === 0 ? "Depot" : "Stop " + lastStop} \u2192 Depot: ${returnDuration} min`;
    legListEl.appendChild(returnLi);

    // Reveal the results panel
    resultsDiv.classList.remove("hidden");
  } catch (err) {
    console.error(err);
    alert("Error optimizing route: " + err.message);
  } finally {
    optimizeBtn.disabled = false;
    optimizeBtn.textContent = "Optimize Route";
    updateButtonState();
  }
});

What does this code do?

This handler does three things after the solver returns the optimal order:

  • Draws the route: It reorders coordinates to match the solver output, appends the depot so the route loops back, fetches the actual road geometry from OSRM, and draws it as a blue GeoJSON polyline.
  • Updates markers: Each marker gets a new icon number and tooltip reflecting its position in the optimized sequence. The depot shows "Depot (Start/End)" while stops show "Visit #N".
  • Populates the results panel: It converts the total duration from seconds to minutes, then builds a per-leg list showing how long each segment takes. The final leg covers the return trip to the depot.
  • Save src/main.js.
  • Return to your browser at localhost:5173. Place a depot (first click) and at least 3 delivery stops on the map, then click Optimize Route.
  • You should see a blue polyline drawn along the roads connecting your stops in the optimized order. Below the map, a panel appears showing the total drive time and a per-leg breakdown.

Route not appearing on the map?

  • Check that the import line at the top includes both fetchDurationMatrix and fetchRouteGeometry in the same destructured import from ./osrm.js.
  • Open the browser console (F12). If you see a CORS error or a failed fetch, make sure your stops are placed within the same general area (the OSRM demo server may reject extremely distant coordinates).
  • If you see "No solution found," try placing your stops closer together within the same city.

Help me debug why my optimized route polyline isn't showing on the Leaflet map.

Verify re-optimization when markers are dragged

A dispatcher might want to adjust a stop after seeing the route. The app already handles this because the onMarkerDrag function calls clearRoute() whenever a pin moves.

  • After optimizing, drag one of your stop markers to a new location.
  • The blue polyline disappears and the results panel hides. The Optimize Route button re-enables so you can solve again with the updated position.
  • Click Optimize Route again to see the new optimized path.

Why does clearRoute() handle this automatically?

The onMarkerDrag function fires on every drag-end event. It updates the stop coordinates in the stops array and calls clearRoute(), which removes the polyline layer and hides the results div.

Because the old route is now invalid (a stop moved), clearing it forces the user to re-optimize. The button re-enables via updateButtonState() in the finally block of the handler.

✔️ Awesome, I've got everything!

Great. Double check you have saved src/main.js and confirmed that the route draws on the map.

ⓧ I'd like to double check the full code

Here is the complete src/main.js file as it should look now:

import L from "leaflet";
import { fetchDurationMatrix, fetchRouteGeometry } from "./osrm.js";
import { solveTSP } from "./solver.js";

const map = L.map("map").setView([40.7128, -74.006], 13);

L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
  maxZoom: 19,
  attribution:
    '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>',
}).addTo(map);

const stops = [];
let routeLayer = null;
const markers = [];

const optimizeBtn = document.getElementById("optimize-btn");
const clearBtn = document.getElementById("clear-btn");
const resultsDiv = document.getElementById("results");
const totalTimeEl = document.getElementById("total-time");
const legListEl = document.getElementById("leg-list");

function createMarkerIcon(index) {
  if (index === 0) {
    return L.divIcon({
      className: "depot-icon",
      iconSize: [20, 20],
      iconAnchor: [10, 10],
    });
  }
  return L.divIcon({
    className: "stop-icon",
    html: `${index}`,
    iconSize: [20, 20],
    iconAnchor: [10, 10],
  });
}

function updateButtonState() {
  optimizeBtn.disabled = stops.length < 3;
}

function clearRoute() {
  if (routeLayer) {
    map.removeLayer(routeLayer);
    routeLayer = null;
  }
  resultsDiv.classList.add("hidden");
}

function onMarkerDrag(index) {
  return function (e) {
    const latlng = e.target.getLatLng();
    stops[index] = [latlng.lng, latlng.lat];
    clearRoute();
  };
}

map.on("click", function (e) {
  if (stops.length >= 11) return;

  const coord = [e.latlng.lng, e.latlng.lat];
  const index = stops.length;
  stops.push(coord);

  const marker = L.marker(e.latlng, {
    draggable: true,
    icon: createMarkerIcon(index),
  }).addTo(map);

  marker.bindTooltip(index === 0 ? "Depot" : `Stop ${index}`, {
    permanent: false,
  });

  marker.on("dragend", onMarkerDrag(index));
  markers.push(marker);

  updateButtonState();
  clearRoute();
});

clearBtn.addEventListener("click", function () {
  markers.forEach((m) => map.removeLayer(m));
  markers.length = 0;
  stops.length = 0;
  clearRoute();
  updateButtonState();
});

optimizeBtn.addEventListener("click", async function () {
  optimizeBtn.disabled = true;
  optimizeBtn.textContent = "Optimizing...";

  try {
    const matrix = await fetchDurationMatrix(stops);
    const result = solveTSP(matrix);

    if (!result) {
      alert("No solution found. Try different stop locations.");
      return;
    }

    const orderedCoords = result.order.map((i) => stops[i]);
    orderedCoords.push(stops[result.order[0]]);

    const geometry = await fetchRouteGeometry(orderedCoords);

    if (routeLayer) {
      map.removeLayer(routeLayer);
    }
    routeLayer = L.geoJSON(
      { type: "Feature", geometry: geometry },
      { style: { color: "#2563eb", weight: 4, opacity: 0.8 } }
    ).addTo(map);

    markers.forEach((m, i) => {
      const visitIndex = result.order.indexOf(i);
      m.setIcon(createMarkerIcon(visitIndex === 0 ? 0 : visitIndex));
      m.setTooltipContent(
        visitIndex === 0 ? "Depot (Start/End)" : `Visit #${visitIndex}`
      );
    });

    const totalMinutes = Math.round(result.totalDuration / 60);
    totalTimeEl.textContent = `Total drive time: ${totalMinutes} min`;

    legListEl.innerHTML = "";
    for (let i = 0; i < result.order.length - 1; i++) {
      const from = result.order[i];
      const to = result.order[i + 1];
      const legDuration = Math.round(matrix[from][to] / 60);
      const li = document.createElement("li");
      li.textContent = `${from === 0 ? "Depot" : "Stop " + from} \u2192 ${to === 0 ? "Depot" : "Stop " + to}: ${legDuration} min`;
      legListEl.appendChild(li);
    }
    const lastStop = result.order[result.order.length - 1];
    const returnDuration = Math.round(matrix[lastStop][result.order[0]] / 60);
    const returnLi = document.createElement("li");
    returnLi.textContent = `${lastStop === 0 ? "Depot" : "Stop " + lastStop} \u2192 Depot: ${returnDuration} min`;
    legListEl.appendChild(returnLi);

    resultsDiv.classList.remove("hidden");
  } catch (err) {
    console.error(err);
    alert("Error optimizing route: " + err.message);
  } finally {
    optimizeBtn.disabled = false;
    optimizeBtn.textContent = "Optimize Route";
    updateButtonState();
  }
});

Your route optimizer is fully functional. A dispatcher can place stops, click one button, and immediately see the fastest driving order visualized on real roads. Next up, the Secret Mission challenges you to add time window constraints so certain stops must be visited within specific hours.

Secret mission

Add Time Windows

Your route optimizer finds the fastest order, but real deliveries have constraints. A restaurant needs its produce by 10 AM. A business closes at noon. Can your solver handle time windows and still find a feasible route?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally and uses only free public APIs. There are no ongoing costs or cloud resources to worry about.

Resources you used:

  • The route-optimizer project folder on your Desktop (source files and node_modules).
  • The Vite dev server process running in your terminal.

Keep everything running

No action needed. Choose this if you want to keep experimenting with different stop locations or time windows.

  • Your dev server stays running at localhost:5173.
  • Your project files remain on your Desktop.

Pause - I'll come back to this later

Stop the dev server to free up your terminal. Your files stay on disk so you can pick up where you left off.

  • In the terminal where the dev server is running, press Ctrl+C to stop it.
  • When you're ready to resume, open a terminal in the route-optimizer folder and run npm run dev again.

Delete - I don't want to use this again

Remove all project files from your machine. This cannot be undone.

  • In the terminal where the dev server is running, press Ctrl+C to stop it.
  • Delete the project folder by running this command (macOS/Linux):
cd ~/Desktop
rm -rf route-optimizer

On Windows, run this instead:

cd ~\Desktop
rmdir /s /q route-optimizer
  • Confirm the folder is gone by running ls (macOS/Linux) or dir (Windows) on your Desktop. You should no longer see route-optimizer listed.

Nice Work!

Nice Work!

Nice work! You just built a fully client-side delivery route optimizer that solves real-world logistics problems using the same OR-Tools engine used by professional dispatch teams.

You've learned how to:

  • Solve the Travelling Salesman Problem for 10+ stops using Google OR-Tools compiled to WebAssembly, running a production-grade solver entirely in the browser.
  • Integrate OSRM's Table API and Route API to power your optimizer with real driving-time matrices and draw actual road geometry on the map.
  • Build an interactive Leaflet.js map with draggable, numbered delivery stop markers and a road-following polyline showing the optimized route.
  • Secret Mission: Added time window constraints to upgrade the solver from a basic TSP to a constrained Vehicle Routing Problem.

Ready to quiz yourself?