Skip to main content
APIs 27 min read

Efficient Fleet Routing: Mastering 8-Hour Depot-to-Depot Tours with HERE Tour Planning API

Smart Route Planning

In modern logistics, delivery services, and field operations, planning efficient multi-stop routes is a daily puzzle. A common and critical requirement is to create daily operational plans where vehicles start at a depot, service multiple customer stops, and return to the depot, all within a fixed work duration, such as an 8-hour shift. The goal is to maximize the work done while respecting these constraints, especially when faced with a large list of potential stops.

Real-World Use Case: Field Technician Route Planning

Consider a typical day for a field technician—such as an HVAC repair worker, IT support staff, or a home healthcare provider—who operates on an 8-hour shift. Each customer visit requires a certain amount of time on-site, represented as duration. Unlike delivery scenarios, these technicians are not constrained by how much cargo or tools they can carry; instead, their primary limitation is time. The objective is to schedule as many feasible appointments as possible within a working day. This solution was built specifically to address that kind of planning problem: optimizing a time-based route that starts at a depot, services as many stops as possible, and ends at a depot—all within a strict 8-hour window.

This blog post will guide you through a web application built using HERE Maps API for JS and HERE Tour Planning API to solve this exact problem. Based on a provided script, we'll explore how to:

  1. Define a Vehicle Routing Problem (VRP) with a list of customer stops (jobs), a designated start/end depot, and a single vehicle constrained by an 8-hour shift.
  2. Utilize the HERE Tour Planning API v3 to find an optimized tour for the vehicle.
  3. Clearly identify and list any jobs that could not be assigned to the vehicle's tour within the given constraints (these are the "reviewable" or "dropped" stops).
  4. Visualize the planned tour and all stop locations on an interactive map using the HERE Maps API for JavaScript v3.1.

 

This approach showcases how the HERE Tour Planning API can directly address complex operational requirements, providing a powerful alternative to multi-step or heuristic methods.

The Core Problem: The 8-Hour Depot-to-Depot Challenge

The fundamental task is given a potentially large list of customer stops (each with a service time) and a single vehicle operating from a depot for a fixed 8-hour shift, determine which stops should be selected and in what sequence they should be visited to maximize service coverage, with the vehicle starting and ending its journey at the depot. Identifying stops that cannot be included in this primary shift is equally important for further planning.

The HERE Tour Planning API is designed to tackle such VRPs holistically.

Solution Architecture: HERE Tour Planning as the Engine

Our solution leverages:

  • HERE Tour Planning API v3 (/problems endpoint): We send a detailed problem definition (jobs, fleet, constraints) as a JSON payload via a POST request. The API returns an optimized solution, including the planned tour for our vehicle and a list of any unassigned jobs. Authentication is handled via a Bearer Token.
  • HERE Maps API for JavaScript (v3.1) with HARP engine: This provides the interactive map for visualizing routes and waypoints. Authentication for map rendering uses an API Key.

The Workflow:

  1. Define Inputs: The script uses predefined startLocation (depot), endLocation (depot), and a stopsToVisit array (customer locations with coordinates and duration).
  2. Construct Problem JSON (buildProblem function): This JavaScript function converts the inputs into the JSON structure required by the Tour Planning API.
  3. Submit and Process (main function): The problem JSON is sent to the Tour Planning API. The response, containing solution.tours and solution.unassigned, is then processed.
  4. Visualize and Report:
    • Route segments for the planned tour are drawn using polylines returned by the API.
    • Markers are placed for the depot and all stops (both those in the tour and unassigned ones).
    • A side panel lists the stops in the planned tour and separately lists the unassigned (reviewable) stops.

 

Code Implementation Walkthrough

Let's examine key parts of the JavaScript from the provided HTML file.

1. Configuration

Essential constants, including credentials and API endpoints:

Copied
        // Map rendering still needs your HERE API key
const APIKEY = 'yourapikey';
// For Tour Planning calls use Bearer token
// IMPORTANT: Replace with your valid, non-expired Bearer token.
const BEARER_TOKEN = 'YOUR_VALID_BEARER_TOKEN_HERE'; 
const TOUR_API_URL = 'https://tourplanning.hereapi.com/v3/problems';
  
  • APIKEY: For HERE Maps JS.
  • BEARER_TOKEN: For HERE Tour Planning API v3. This must be a fresh, valid token for the script to work. Please see the Identity & Access Management Developer Guide – OAuth 2.0 Token Generation for instructions on how to generate OAuth 2.0 tokens.
  • TOUR_API_URL: The endpoint for submitting Tour Planning problems.
  • The 8-hour work limit is enforced by explicitly setting the vehicle's shift start and end times (08:00–16:00 UTC). This defines the operational window within which the tour must be completed.

2. Sample Data (30 Stops Example)

The script uses a predefined set of 30 stops around Berlin to demonstrate functionality with a larger dataset.

Copied
        // --- Define Route Data ---
// Define the start and end locations for the vehicle's shift (depot).
const startLocation = { lat: 52.365638, lng: 13.533344 };
const endLocation   = { lat: 52.365638, lng: 13.533344 };

// An array of all the stops (jobs) that need to be visited.
// Each stop has an ID, location (lat/lng), and a duration in seconds.
const stopsToVisit = [
  { "id": "Stop_1", "lat": 52.54198, "lng": 13.40372, "duration": 1200 },  // ... (Content of your 30 stops array) ...
  { "id": "Stop_30", "lat": 52.53876, "lng": 13.50876, "duration": 2700 }}
];

  

Note on Sample Data: Using 30 diverse stops with varying service times against a single 8-hour vehicle shift will likely result in many stops being unassigned. This is expected and effectively demonstrates the API's capability to determine feasibility within constraints. These unassigned stops are precisely the "droppable" waypoints for this specific primary shift.

3. Building the Tour Planning Problem (buildProblem function)

This function constructs the JSON payload detailing the jobs, fleet, and optimization objectives.

Copied
        function buildProblem() {
  // 1. Define a single vehicle with its profile, cost model, and 8-hour shift
  const vehicle = {
    id: 'vehicle1',                         // Unique vehicle identifier
    profile: 'normal_car',                 // Must match a profile in 'profiles' section
    amount: 1,                             // Only one vehicle of this type is available
    capacity: [0],                         // Capacity is not being used (set to dummy zero)
    costs: {                               // Cost model for optimization
      fixed: 0,                            // No fixed cost per vehicle
      distance: 0.0001,                    // Small cost per meter
      time: 0.0001                         // Small cost per second
    },
    shifts: [{                             // Define the working hours for this vehicle
      start: {
        time: '2025-05-28T08:00:00Z',      // Start time of the shift (8 AM UTC)
        location: startLocation            // Start location (depot)
      },
      end: {
        time: '2025-05-28T16:00:00Z',      // End time of the shift (4 PM UTC)
        location: endLocation              // End location (depot)
      }
    }]
  };

  // Convert all stop points into job definitions for the API
  const jobs = stopsToVisit.map(s => ({
    id: s.id,                              // Unique ID for each job (stop)
    tasks: {
      pickups: [{                          // Single pickup task per stop
        places: [{
          location: { lat: s.lat, lng: s.lng }, // Location of the stop
          duration: s.duration                  // Time to spend at the stop (in seconds)
        }],
        demand: [0]                        // Dummy demand (not used)
      }]
    }
  }));

  // 3. Build the final problem object
  return {
    fleet: {
      profiles: [{                         // Routing profile definition
        type: 'car',
        name: 'normal_car'                 // Must match vehicle.profile above
      }],
      types: [vehicle]                     // Only one type of vehicle, defined above
    },
    plan: { jobs },                        // Set of job (stop) definitions
    configuration: {
      termination: {
        maxTime: 30,                       // Max solving time in seconds
        stagnationTime: 5                 // Time to stop if no better solution is found
      },
      routeDetails: ['polyline']          // Request polyline geometry for routes
    },
    objectives: [
      { type: 'minimizeUnassigned' },     // Try to include as many jobs as possible
      { type: 'minimizeCost' }            // Then minimize route cost (distance/time)
    ]
  };
}

  
  • Jobs: Each stop is a job. The provided code models each job with a pickups task (carrying the duration).
  • Fleet: A single vehicle ("vehicle1") is defined, linked to a type ("car_type_8hr"). The critical constraint is the shift with fixed start and end times, effectively creating an 8-hour operational window. The critical constraint is the shift with fixed start and end times, effectively creating an 8-hour operational window.
  • Configuration & Objectives: routeDetails:['polyline'] requests route geometry. Objectives prioritize minimizing unassigned jobs, then minimizing cost.

4. Main Asynchronous Flow (main function) - API Call & Result Processing

This orchestrates the API interaction and visualization.

Copied
        async function main() {
  const stats = document.getElementById('stats');

  // Abort if the map failed to initialize.
  if (mapInitFailed) {
    stats.textContent = 'ERROR: Map not initialized, please check your apikey. Aborting Tour Planning request..';
    return;
  }

  stats.textContent = 'Submitting tour planning problem...';

// --- 3. Call Tour Planning API & Process Results ---
  try {
    // Use the Fetch API to send the problem to the Tour Planning service.
    const res = await fetch(TOUR_API_URL, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Accept': 'application/json',
        'Authorization': `Bearer ${BEARER_TOKEN}`
      },
      body: JSON.stringify(buildProblem())
    });

    // --- Error Handling for API Response ---
    // Check if the HTTP response status is not OK (e.g., 400, 401, 500).
    if (!res.ok) {
      const err = await res.json();
      throw new Error(`${err.title || err.cause} (${err.code})`);
    }

// Parse the successful JSON response.
    const sol = await res.json();
    stats.textContent = ''; // Clear the "Submitting..." message.

/**
 * Converts seconds into HH:mm:ss format with zero-padding.
 * @param {number} seconds - The total number of seconds.
 * @returns {string} - A time string in HH:mm:ss format.
 */
 function formatDurationFromSeconds(totalSeconds) {
    // 1. Handle any invalid inputs
    if (isNaN(totalSeconds) || totalSeconds < 0) {
        return "00:00:00";
    }

    // 2. Remove any decimal part
    let remainingSeconds = Math.floor(totalSeconds);

    // 3. Calculate hours and subtract them from the remainder
    const hours = Math.floor(remainingSeconds / 3600);
    remainingSeconds %= 3600; // Get the seconds left over

    // 4. Calculate minutes from the new remainder
    const minutes = Math.floor(remainingSeconds / 60);

    // 5. The final remainder is the seconds
    const seconds = remainingSeconds % 60;

    // 6. Return the zero-padded string
    return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
}

    // Extract the first tour and any unassigned jobs from the solution.
    const tour = sol.tours[0];
    const stat = tour.statistic;

    if (stat) {
        // The values like stat.duration are in SECONDS.
        // We use our new function to format them correctly.
        stats.innerHTML += `
          <div class="leg-title">Tour Summary</div>
          <table style="width:100%; border-collapse: collapse; font-size: 0.9em;">
              <tr>
                  <td style="padding: 4px; font-weight: bold;">Total Tour Duration</td>
                  <td style="padding: 4px;">${formatDurationFromSeconds(stat.duration)}</td>
                  <td style="padding: 4px; color: #666;">Depot departure to return</td>
              </tr>
              <tr>
                  <td style="padding: 4px; font-weight: bold;">Driving Time</td>
                  <td style="padding: 4px;">${formatDurationFromSeconds(stat.times?.driving)}</td>
                  <td style="padding: 4px; color: #666;">Time spent on roads</td>
              </tr>
              <tr>
                  <td style="padding: 4px; font-weight: bold;">Service Time</td>
                  <td style="padding: 4px;">${formatDurationFromSeconds(stat.times?.serving)}</td>
                  <td style="padding: 4px; color: #666;">At customer stops</td>
              </tr>
              <tr>
                  <td style="padding: 4px; font-weight: bold;">Total Distance</td>
                  <td style="padding: 4px;">${(stat.distance / 1000).toFixed(2)} km</td>
                  <td style="padding: 4px; color: #666;">Full round-trip</td>
              </tr>
              <tr>
                  <td style="padding: 4px; font-weight: bold;">Estimated Cost</td>
                  <td style="padding: 4px;">${stat.cost.toFixed(2)}</td>
                  <td style="padding: 4px; color: #666;">Based on cost model weights</td>
              </tr>
          </table>
        `;
    }

    const unassigned = sol.unassigned;
// --- Draw the Optimized Route on the Map ---
    tour.stops.forEach((stop, i) => {
      if (stop.routeDetails?.polyline) {
        const line = H.geo.LineString.fromFlexiblePolyline(stop.routeDetails.polyline);
       mapObjectsGroup.addObject(new H.map.Polyline(line, { style: { strokeColor: '#0070ff', lineWidth: 6 } }));
      }

// Get the Job ID for the current stop.
      const activity = stop.activities?.[0] || stop.activity;
      const jobId = activity?.jobId || (i === 0 ? 'Start' : (i === tour.stops.length - 1 ? 'End' : 'Unknown'));

// Create and add a numbered marker for the stop.
      const marker = new H.map.DomMarker(
        { lat: stop.location.lat, lng: stop.location.lng },
        { icon: createDomMarker(i) }
      );

      marker.setData(`Seq ${i}: ${jobId}`);

      mapObjectsGroup.addObject(marker);
    });

// --- Display Unassigned Stops ---
    if (unassigned?.length) {
      stats.innerHTML += `<div class="leg-title" style="color:grey;">Review stops (${unassigned.length}):</div><ul class="review-stops-list">`;
      unassigned.forEach(u => {
        stats.innerHTML += `<li>${u.jobId}</li>`;
        // Find the original stop data to get its location.
        const matched = stopsToVisit.find(s => s.id === u.jobId);
        if (matched) {
          // Add a gray marker for the unassigned stop.
          const marker = new H.map.DomMarker(
            { lat: matched.lat, lng: matched.lng },
            { icon: createDomMarker(u.jobId.replace('Stop_', ''), true) }
          );
          marker.setData(`Review: ${u.jobId}`);
         mapObjectsGroup.addObject(marker);
        }
      });
      stats.innerHTML += `</ul>`;
    }

// Adjust the map view to show all markers and routes.
   map.getViewModel().setLookAtData({ bounds: mapObjectsGroup.getBoundingBox() });

// --- Display Text Summary of the Tour ---
    stats.innerHTML += `<div class="leg-title">Planned Tour: ${tour.stops.length} stops</div>`;
    tour.stops.forEach((s, i) => {
      const activity = s.activities?.[0] || s.activity;
      const jobId = activity?.jobId || (i === 0 ? 'Start' : (i === tour.stops.length - 1 ? 'End' : 'Unknown'));
      stats.innerHTML += ` ${i}→${jobId}\n`;
    });

  } catch (e) {
    // --- General Error Handling for the 'main' function ---
    // This block catches errors from the fetch request, response parsing, or result processing.
  console.error(e);
  const fallbackMessage = 'Request failed. Please ensure your Bearer Token is valid. Aborting Tour Planning request..';

// Display a specific error message if available, otherwise show a fallback.
  if (e.message && e.message !== 'undefined (undefined)') {
    stats.textContent = 'ERROR: ' + e.message;
  } else {
    stats.textContent = 'ERROR: ' + fallbackMessage;
  }
}}
// --- 4. Run the Application ---
main(); // Invoke
  

 

  • The problemJSON is sent via POST with Authorization: Bearer ${BEARER_TOKEN}.
  • The solution.tours[0] provides the planned 8-hour depot-to-depot leg.
  • solution.unassigned lists the jobs that didn't fit.
  • Polylines (if included in routeDetails by the API) are drawn for each segment of the tour.
  • Markers are added for stops in the planned tour and for unassigned jobs (styled differently).
  • The panel lists the sequence of jobs in the tour and the IDs of unassigned jobs.

Interpreting the Output and Solving the Problem

When this script runs with a large set of stopsToVisit (like 30):

  • The Planned Tour: The map shows the single, optimized depot-to-depot route the API could create within the 8-hour shift. The panel lists the stops included in this tour.
  • Stops for Review (Unassigned Jobs): This list in the panel is key. It contains all the stops from your initial list that could not be included in the primary 8-hour tour. These represent the stops that could not be assigned within the primary shift's constraints and may need to be rescheduled or reassigned.
  • This fulfills the objective: The code successfully identifies a primary tour and the stops that fall outside its capacity.

 

Note: In case a stop is located at a distance from the route, it might mean that the stop location is unreachable by the vehicle that is completing the tour.

Handling Unassigned Stops

The "Stops for Review" list provides clear data for next steps:

  1. Plan Subsequent Tours: These unassigned jobs can become the input for a new Tour Planning problem (e.g., for a "Day 2" shift, or for another vehicle).
  2. Adjust Fleet or Constraints: If serving more jobs is critical, you might consider adding more vehicles to the fleet definition, extending shift durations (if regulations allow), or seeing if  duration for some jobs can be reduced.
  3. Prioritize Jobs: The HERE Tour Planning API allows jobs to have a priority. Higher priority jobs are more likely to be included if the system has to make choices. 
    For more on defining problems and using priorities, refer to:
    Tour Planning API: Problem Definition
    Tour Planning API: Using Priorities

 

Full code in action:

Copied
        <!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="initial-scale=1,maximum-scale=1,user-scalable=no" />
  <title>HERE Route Plan - Pickups Only</title>
  <script src="https://js.api.here.com/v3/3.1/mapsjs-core.js"></script>
  <script src="https://js.api.here.com/v3/3.1/mapsjs-service.js"></script>
  <script src="https://js.api.here.com/v3/3.1/mapsjs-ui.js"></script>
  <script src="https://js.api.here.com/v3/3.1/mapsjs-mapevents.js"></script>
  <script src="https://js.api.here.com/v3/3.1/mapsjs-harp.js"></script>

  <style>
    html, body { margin: 0; padding: 0; height: 100%; font-family: Arial, sans-serif; }
    #map { width: 100%; height: 100%; }
    #panel {
      position: absolute;
      top: 10px;
      left: 10px;
      z-index: 5;
      background: rgba(255,255,255,0.9);
      padding: 15px;
      border-radius: 8px;
      max-width: 320px;
      max-height: calc(100vh - 20px);
      overflow-y: auto;
      box-shadow: 0 2px 10px rgba(0,0,0,0.2);
    }
    #panel table td {
    border-bottom: 1px solid #ddd;
    }
    .number-icon {
      width: 26px;
      height: 26px;
      border-radius: 50%;
      background-color: #0070ff;
      color: white;
      text-align: center;
      line-height: 26px;
      font-size: 14px;
      font-weight: bold;
      border: 2px solid white;
    }
    .gray-icon {
      background-color: gray !important;
    }
    #panel h3 { margin: 0 0 10px; font-size: 1.2em; color: #333; }
    #panel pre { white-space: pre-wrap; margin: 0; font-size: 0.9em; line-height: 1.4; }
    .leg-title { font-weight: bold; margin-top: 10px; border-top: 1px solid #eee; padding-top: 10px; }
    .review-stops-list { font-size: 0.85em; list-style-type: none; padding-left: 5px; }
    .review-stops-list li { padding: 2px 0; }
  </style>
</head>
<body>
<div id="map"></div>
<div id="panel">
  <h3>Optimized Route Plan</h3>
  <pre id="stats">Initializing...</pre>
</div>

<script>
// API credentials and endpoint configuration.
// IMPORTANT: Replace with your own valid HERE API Key and Bearer Token.
const APIKEY = 'Your_API_KEY';
const BEARER_TOKEN = 'Your_BEARER_TOKEN';
const TOUR_API_URL = 'https://tourplanning.hereapi.com/v3/problems';

// Global variables to hold the map instance and a group for map objects.
let mapObjectsGroup, map;
// Flag to track if map initialization fails.
let mapInitFailed = false;

// --- 1. Initialize the HERE Map ---
try {
  // Initialize the platform object with your API key.
  const platform = new H.service.Platform({ apikey: APIKEY });
  const engineType = H.Map.EngineType['HARP'];
  // Create default map layers.
  const defaultLayers = platform.createDefaultLayers({ engineType });
  // Instantiate the map, targeting the 'map' div.
  map = new H.Map(document.getElementById('map'), defaultLayers.vector.normal.map, {
    engineType,
    center: { lat: 52.52, lng: 13.405 }, // Default center: Berlin
    zoom: 10,
    pixelRatio: window.devicePixelRatio || 1
  });

// Make the map interactive: resizes on window resize and allows zoom/pan.
  window.addEventListener('resize', () => map.getViewPort().resize());
  new H.mapevents.Behavior(new H.mapevents.MapEvents(map));

  // Create the default UI components (zoom, map settings).
  const ui = H.ui.UI.createDefault(map, defaultLayers);

// Create a group to hold all our markers and polylines for easy management.
  mapObjectsGroup = new H.map.Group();
  map.addObject(mapObjectsGroup);

} catch (e) {
  // --- Error Handling for Map Initialization ---
  // This block catches errors during map setup, commonly caused by an invalid API key or network issues.
  console.error(e);
  mapInitFailed = true;
  // Display an error message in the UI panel.
  document.getElementById('stats').textContent = 'ERROR: Map initialization failed. Please check your APIKEY.';
}

// --- 2. Define Route Data ---
// Define the start and end locations for the vehicle's shift (depot).
const startLocation = { lat: 52.365638, lng: 13.533344 };
const endLocation   = { lat: 52.365638, lng: 13.533344 };

// An array of all the stops (jobs) that need to be visited.
// Each stop has an ID, location (lat/lng), and a duration in seconds.
const stopsToVisit = [
  { "id": "Stop_1", "lat": 52.54198, "lng": 13.40372, "duration": 1200 },
  { "id": "Stop_2", "lat": 52.49032, "lng": 13.36499, "duration": 900 },
  { "id": "Stop_3", "lat": 52.57915, "lng": 13.32451, "duration": 1500 },
  { "id": "Stop_4", "lat": 52.46327, "lng": 13.50003, "duration": 1800 },
  { "id": "Stop_5", "lat": 52.51876, "lng": 13.20785, "duration": 600 },
  { "id": "Stop_6", "lat": 52.60341, "lng": 13.45056, "duration": 2100 },
  { "id": "Stop_7", "lat": 52.43112, "lng": 13.30129, "duration": 1200 },
  { "id": "Stop_8", "lat": 52.55500, "lng": 13.55231, "duration": 900 },
  { "id": "Stop_9", "lat": 52.48058, "lng": 13.18977, "duration": 2400 },
  { "id": "Stop_10", "lat": 52.59123, "lng": 13.25843, "duration": 1500 },
  { "id": "Stop_11", "lat": 52.40567, "lng": 13.48059, "duration": 1800 },
  { "id": "Stop_12", "lat": 52.53001, "lng": 13.60124, "duration": 600 },
  { "id": "Stop_13", "lat": 52.44987, "lng": 13.22563, "duration": 2100 },
  { "id": "Stop_14", "lat": 52.57234, "lng": 13.15987, "duration": 1200 },
  { "id": "Stop_15", "lat": 52.49865, "lng": 13.53021, "duration": 900 },
  { "id": "Stop_16", "lat": 52.52111, "lng": 13.28345, "duration": 2700 },
  { "id": "Stop_17", "lat": 52.41890, "lng": 13.38765, "duration": 1500 },
  { "id": "Stop_18", "lat": 52.56032, "lng": 13.49532, "duration": 1800 },
  { "id": "Stop_19", "lat": 52.47531, "lng": 13.10234, "duration": 600 },
  { "id": "Stop_20", "lat": 52.58398, "lng": 13.38001, "duration": 2100 },
  { "id": "Stop_21", "lat": 52.38876, "lng": 13.51032, "duration": 1200 },
  { "id": "Stop_22", "lat": 52.50765, "lng": 13.62345, "duration": 900 },
  { "id": "Stop_23", "lat": 52.46021, "lng": 13.25078, "duration": 2400 },
  { "id": "Stop_24", "lat": 52.54987, "lng": 13.12087, "duration": 1500 },
  { "id": "Stop_25", "lat": 52.42765, "lng": 13.58098, "duration": 1800 },
  { "id": "Stop_26", "lat": 52.51032, "lng": 13.47865, "duration": 600 },
  { "id": "Stop_27", "lat": 52.47001, "lng": 13.28754, "duration": 2100 },
  { "id": "Stop_28", "lat": 52.56789, "lng": 13.20345, "duration": 1200 },
  { "id": "Stop_29", "lat": 52.40023, "lng": 13.35087, "duration": 900 },
  { "id": "Stop_30", "lat": 52.53876, "lng": 13.50876, "duration": 2700 }
];

/**
* buildProblem
* This function constructs the problem payload for the Tour Planning API.
* It defines the vehicle, its shift, and the list of jobs (stops) to be routed.
*/
function buildProblem() {
  // Define a single vehicle with profile, cost model, capacity, and working shift
  const vehicle = {
    id: 'vehicle1',                         // Unique vehicle identifier
    profile: 'normal_car',                 // Must match a profile in 'profiles' section
    amount: 1,                             // Only one vehicle of this type is available
    capacity: [0],                         // Capacity is not being used (set to dummy zero)
    costs: {                               // Cost model for optimization
      fixed: 0,                            // No fixed cost per vehicle
      distance: 0.0001,                    // Small cost per meter
      time: 0.0001                         // Small cost per second
    },
    shifts: [{                             // Define the working hours for this vehicle
      start: {
        time: '2025-05-28T08:00:00Z',      // Start time of the shift (8 AM UTC)
        location: startLocation            // Start location (depot)
      },
      end: {
        time: '2025-05-28T16:00:00Z',      // End time of the shift (4 PM UTC)
        location: endLocation              // End location (depot)
      }
    }]
  };

  // Convert all stop points into job definitions for the API
  const jobs = stopsToVisit.map(s => ({
    id: s.id,                              // Unique ID for each job (stop)
    tasks: {
      pickups: [{                          // Single pickup task per stop
        places: [{
          location: { lat: s.lat, lng: s.lng }, // Location of the stop
          duration: s.duration                  // Time to spend at the stop (in seconds)
        }],
        demand: [0]                        // Dummy demand (not used)
      }]
    }
  }));

  return {
    fleet: {
      profiles: [{                         // Routing profile definition
        type: 'car',
        name: 'normal_car'                 // Must match vehicle.profile above
      }],
      types: [vehicle]                     // Only one type of vehicle, defined above
    },
    plan: { jobs },                        // Set of job (stop) definitions
    configuration: {
      termination: {
        maxTime: 30,                       // Max solving time in seconds
        stagnationTime: 5                 // Time to stop if no better solution is found
      },
      routeDetails: ['polyline']          // Request polyline geometry for routes
    },
    objectives: [
      { type: 'minimizeUnassigned' },     // Try to include as many jobs as possible
      { type: 'minimizeCost' }            // Then minimize route cost (distance/time)
    ]
  };
}

/**
* Creates a custom DOM Icon for map markers.
* @param {string|number} number - The text to display inside the marker.
* @param {boolean} [isReview=false] - If true, applies a gray style for unassigned stops.
* @returns {H.map.DomIcon} The configured DOM Icon instance.
*/
function createDomMarker(number, isReview = false) {
  const div = document.createElement('div');
  div.className = 'number-icon' + (isReview ? ' gray-icon' : '');
  div.textContent = number;
  return new H.map.DomIcon(div);
}

/**
* Main function to run the tour planning and display results.
*/
async function main() {
  const stats = document.getElementById('stats');

  // Abort if the map failed to initialize.
  if (mapInitFailed) {
    stats.textContent = 'ERROR: Map not initialized, please check your apikey. Aborting Tour Planning request..';
    return;
  }

  stats.textContent = 'Submitting tour planning problem...';

// --- 3. Call Tour Planning API & Process Results ---
  try {
    // Use the Fetch API to send the problem to the Tour Planning service.
    const res = await fetch(TOUR_API_URL, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Accept': 'application/json',
        'Authorization': `Bearer ${BEARER_TOKEN}`
      },
      body: JSON.stringify(buildProblem())
    });

    // --- Error Handling for API Response ---
    // Check if the HTTP response status is not OK (e.g., 400, 401, 500).
    if (!res.ok) {
      const err = await res.json();
      throw new Error(`${err.title || err.cause} (${err.code})`);
    }

// Parse the successful JSON response.
    const sol = await res.json();
    stats.textContent = ''; // Clear the "Submitting..." message.

/**
 * Converts seconds into HH:mm:ss format with zero-padding.
 * @param {number} seconds - The total number of seconds.
 * @returns {string} - A time string in HH:mm:ss format.
 */
 function formatDurationFromSeconds(totalSeconds) {
    // 1. Handle any invalid inputs
    if (isNaN(totalSeconds) || totalSeconds < 0) {
        return "00:00:00";
    }

    // 2. Remove any decimal part
    let remainingSeconds = Math.floor(totalSeconds);

    // 3. Calculate hours and subtract them from the remainder
    const hours = Math.floor(remainingSeconds / 3600);
    remainingSeconds %= 3600; // Get the seconds left over

    // 4. Calculate minutes from the new remainder
    const minutes = Math.floor(remainingSeconds / 60);

    // 5. The final remainder is the seconds
    const seconds = remainingSeconds % 60;

    // 6. Return the zero-padded string
    return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
}

    // Extract the first tour and any unassigned jobs from the solution.
    const tour = sol.tours[0];
    const stat = tour.statistic;

    if (stat) {
        // The values like stat.duration are in SECONDS.
        // We use our new function to format them correctly.
        stats.innerHTML += `
          <div class="leg-title">Tour Summary</div>
          <table style="width:100%; border-collapse: collapse; font-size: 0.9em;">
              <tr>
                  <td style="padding: 4px; font-weight: bold;">Total Tour Duration</td>
                  <td style="padding: 4px;">${formatDurationFromSeconds(stat.duration)}</td>
                  <td style="padding: 4px; color: #666;">Depot departure to return</td>
              </tr>
              <tr>
                  <td style="padding: 4px; font-weight: bold;">Driving Time</td>
                  <td style="padding: 4px;">${formatDurationFromSeconds(stat.times?.driving)}</td>
                  <td style="padding: 4px; color: #666;">Time spent on roads</td>
              </tr>
              <tr>
                  <td style="padding: 4px; font-weight: bold;">Service Time</td>
                  <td style="padding: 4px;">${formatDurationFromSeconds(stat.times?.serving)}</td>
                  <td style="padding: 4px; color: #666;">At customer stops</td>
              </tr>
              <tr>
                  <td style="padding: 4px; font-weight: bold;">Total Distance</td>
                  <td style="padding: 4px;">${(stat.distance / 1000).toFixed(2)} km</td>
                  <td style="padding: 4px; color: #666;">Full round-trip</td>
              </tr>
              <tr>
                  <td style="padding: 4px; font-weight: bold;">Estimated Cost</td>
                  <td style="padding: 4px;">${stat.cost.toFixed(2)}</td>
                  <td style="padding: 4px; color: #666;">Based on cost model weights</td>
              </tr>
          </table>
        `;
    }
    // Extract the first tour and any unassigned jobs from the solution.
    const tour = sol.tours[0];
    const unassigned = sol.unassigned;
// --- Draw the Optimized Route on the Map ---
    tour.stops.forEach((stop, i) => {
      if (stop.routeDetails?.polyline) {
        const line = H.geo.LineString.fromFlexiblePolyline(stop.routeDetails.polyline);
        mapObjectsGroup.addObject(new H.map.Polyline(line, { style: { strokeColor: '#0070ff', lineWidth: 6 } }));
      }

// Get the Job ID for the current stop.
      const activity = stop.activities?.[0] || stop.activity;
      const jobId = activity?.jobId || (i === 0 ? 'Start' : (i === tour.stops.length - 1 ? 'End' : 'Unknown'));

// Create and add a numbered marker for the stop.
      const marker = new H.map.DomMarker(
        { lat: stop.location.lat, lng: stop.location.lng },
        { icon: createDomMarker(i) }
      );
      marker.setData(`Seq ${i}: ${jobId}`);
      mapObjectsGroup.addObject(marker);
    });

// --- Display Unassigned Stops ---
    if (unassigned?.length) {
      stats.innerHTML += `<div class="leg-title" style="color:grey;">Review stops (${unassigned.length}):</div><ul class="review-stops-list">`;
      unassigned.forEach(u => {
        stats.innerHTML += `<li>${u.jobId}</li>`;
        // Find the original stop data to get its location.
        const matched = stopsToVisit.find(s => s.id === u.jobId);
        if (matched) {
          // Add a gray marker for the unassigned stop.
          const marker = new H.map.DomMarker(
            { lat: matched.lat, lng: matched.lng },
            { icon: createDomMarker(u.jobId.replace('Stop_', ''), true) }
          );
          marker.setData(`Review: ${u.jobId}`);
          mapObjectsGroup.addObject(marker);
        }
      });
      stats.innerHTML += `</ul>`;
    }

// Adjust the map view to show all markers and routes.
    map.getViewModel().setLookAtData({ bounds: mapObjectsGroup.getBoundingBox() });

// --- Display Text Summary of the Tour ---
    stats.innerHTML += `<div class="leg-title">Planned Tour: ${tour.stops.length} stops</div>`;
    tour.stops.forEach((s, i) => {
      const activity = s.activities?.[0] || s.activity;
      const jobId = activity?.jobId || (i === 0 ? 'Start' : (i === tour.stops.length - 1 ? 'End' : 'Unknown'));
      stats.innerHTML += ` ${i}→${jobId}\n`;
    });
  } catch (e) {
    // --- General Error Handling for the 'main' function ---
    // This block catches errors from the fetch request, response parsing, or result processing.
  console.error(e);
  const fallbackMessage = 'Request failed. Please ensure your Bearer Token is valid. Aborting Tour Planning request..';

// Display a specific error message if available, otherwise show a fallback.
  if (e.message && e.message !== 'undefined (undefined)') {
    stats.textContent = 'ERROR: ' + e.message;
  } else {
    stats.textContent = 'ERROR: ' + fallbackMessage;
  }
}}

// --- 4. Run the Application ---
main();
</script>
</body>
</html>

  

 

Understanding the Output

When you run the application:

Route_Plan

         Blue markers showing the Planned Tour and Grey markers showing Review Stops

 

Conclusion

The HERE Tour Planning API is a powerful tool for solving complex Vehicle Routing Problems like planning constrained, multi-stop, depot-to-depot tours. By defining the problem correctly with jobs, a single vehicle with an 8-hour shift, and clear objectives, the API directly provides an optimized tour and a list of unassignable jobs. This helps in understanding operational capacity and making informed decisions about which stops can be serviced within the given limits, effectively identifying those that need to be "dropped" or rescheduled for the primary shift.

This method provides a more integrated and often more optimal solution than combining separate sequencing and routing steps with custom logic for such constrained planning scenarios.

Happy Tour Planning!

Sachin Jonda

Sachin Jonda

Principal Support Engineer

Sign up for our newsletter

Why sign up:

  • Latest offers and discounts
  • Tailored content delivered weekly
  • Exclusive events
  • One click to unsubscribe