Search for content

Displaying Custom Road Attributes on a PTV Raster Map with Leaflet

This tutorial walks you through building a web app that displays a PTV Raster Map and overlays custom road attribute scenarios fetched from the PTV Developer Data API.

Replace 'YOUR_API_KEY' with your actual PTV Developer key, open 'index.html' in a browser, and the app will load your custom road attribute scenarios on the map automatically.

 

 Download from GitHub

 

Prerequisites

  • A PTV Developer API key (get one at [developer.myptv.com](https://developer.myptv.com/))
  • Basic knowledge of HTML, CSS, and JavaScript
  • At least one custom road attribute scenario created in your PTV Developer account. You can create a scenario using this showcase

 

Project Structure

  • index.html   — Page layout with map container and side panel
  • style.css    — Styling for the map, panel, and tooltips
  • app.js       — Map initialization, API calls, and rendering logic

 

Step 1: Set Up the HTML

Create 'index.html' with a full-page map container, a side panel for listing scenarios, and the Leaflet library loaded from CDN.

<!DOCTYPE html>
<html lang="en">
<head>
 <meta charset="UTF-8">
 <meta name="viewport" content="width=device-width, initial-scale=1.0">
 <title>PTV Raster Map</title>
 <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
 <link rel="stylesheet" href="style.css" />
</head>
<body>
 <div id="map"></div>
 <div id="panel">
   <h3>Custom Road Attributes</h3>
   <button id="load-btn">Load Scenarios</button>
   <div id="scenarios-list"></div>
 </div>
 <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
 <script src="app.js"></script>
</body>
</html>

 

Step 2: Create a Custom Tile Layer for PTV Raster Maps

The PTV Raster Maps API requires authentication via an 'apiKey' header. Since Leaflet's default 'TileLayer' only supports URL-based tokens, we extend it with a custom 'createTile' method that uses 'fetch' with the required header.

const API_KEY = 'YOUR_API_KEY';

const PtvTileLayer = L.TileLayer.extend({
 createTile(coords, done) {
   const tile = document.createElement('img');
   tile.alt = '';
   tile.setAttribute('role', 'presentation');

   const url = this.getTileUrl(coords);

   fetch(url, {
     headers: { 'apiKey': API_KEY }
   })
     .then(response => {
       if (!response.ok) {
         throw new Error(`Tile fetch failed: ${response.status}`);
       }
       return response.blob();
     })
     .then(blob => {
       tile.src = URL.createObjectURL(blob);
       done(null, tile);
     })
     .catch(err => {
       done(err, tile);
     });

   return tile;
 }
});

 

Initialize the map and add the tile layer:

const map = L.map('map').setView([49.006889, 8.403653], 10);
const ptvLayer = new PtvTileLayer(
 'https://api.myptv.com/rastermaps/v1/image-tiles/{z}/{x}/{y}', {
   maxZoom: 22,
   tileSize: 256,
   attribution: '&copy; <a href="https://www.myptv.com">PTV Logistics</a>'
 }
);
ptvLayer.addTo(map);

 

Step 3: Fetch Custom Road Attribute Scenarios

Call the PTV Data API's 'GET /road-attributes' endpoint with 'results=POLYLINES' and 'polylineFormat=GEO_JSON' to get scenario data including road geometries.

const DATA_API_BASE = 'https://api.myptv.com/data/v1';
async function fetchCustomRoadAttributeScenarios() {
 const url = `${DATA_API_BASE}/road-attributes?results=POLYLINES&polylineFormat=GEO_JSON`;
 const response = await fetch(url, {
   headers: { 'apiKey': API_KEY }
 });
 if (!response.ok) {
   const errorBody = await response.json().catch(() => null);
   throw new Error(errorBody?.description || `HTTP ${response.status}`);
 }
 const data = await response.json();
 return data.customRoadAttributeScenarios || [];
}

 

Step 4: Display Scenarios on the Map

Each scenario contains 'roadsToBeAttributed', which includes 'polylines' in GeoJSON format. Parse them and add to the map using 'L.geoJSON'. Use 'onEachFeature' to attach a tooltip showing the road attributes on hover.

function addScenarioToMap(scenario, color) {
 const layerGroup = L.layerGroup();
 if (scenario.roadsToBeAttributed) {
   scenario.roadsToBeAttributed.forEach(road => {
     if (!road.polylines) {
       return;
     }
     road.polylines.forEach(polylineGeoJson => {
       const geojson = JSON.parse(polylineGeoJson);
       const layer = L.geoJSON(geojson, {
         style: { color: color, weight: 4, opacity: 0.8 },
         onEachFeature(_feature, featureLayer) {
           const attrParts = formatAttributes(road.attributes);
           let tooltipContent = `<strong>${scenario.name}</strong>`;
           if (attrParts.length) {
             tooltipContent += `<br>${attrParts.join(', ')}`;
           }
           featureLayer.bindTooltip(tooltipContent, { sticky: true });
           // Highlight on hover
           featureLayer.on('mouseover', () => {
             featureLayer.setStyle({ weight: 7, opacity: 1 });
           });
           featureLayer.on('mouseout', () => {
             featureLayer.setStyle({ weight: 4, opacity: 0.8 });
           });
         }
       });
       layerGroup.addLayer(layer);
     });
   });
 }
 layerGroup.addTo(map);
 return layerGroup;
}

 

Step 5: Format Attributes Generically

Rather than hard-coding each attribute name, iterate over all keys so the app automatically picks up any new attributes the API may add in the future.

function formatAttributes(attributes) {
 if (!attributes) {
   return [];
 }
 const parts = [];
 Object.entries(attributes).forEach(([key, value]) => {
   if (value === null || value === undefined) {
     return;
   }
   const label = key.replace(/([A-Z])/g, ' $1').replace(/^./, s => s.toUpperCase());
   if (typeof value === 'boolean') {
     if (value) {
       parts.push(label);
     }
   } else {
     parts.push(`${label}: ${value}`);
   }
 });
 return parts;
}

 

Step 6: Generate Unique Colors

To ensure each scenario is visually distinct regardless of how many exist, generate colors by spacing hues evenly around the HSL color wheel.

function getScenarioColor(index, total) {
 const hue = Math.round((360 / total) * index);
 return `hsl(${hue}, 70%, 50%)`;
}

 

Step 7: Build the Scenario List Panel

Render each scenario in the side panel with a visibility toggle and a zoom button.

function renderScenarioList(scenarios) {
  const listEl = document.getElementById('scenarios-list');

  if (scenarios.length === 0) {
    listEl.innerHTML = '<p class="info-msg">No custom road attribute scenarios found.</p>';
    return;
  }

  listEl.innerHTML = '';

  scenarios.forEach((scenario, index) => {
    const color = getScenarioColor(index, scenarios.length);
    const layer = addScenarioToMap(scenario, color);
    scenarioLayers[scenario.id] = layer;

    const item = document.createElement('div');
    item.className = 'scenario-item';
    item.style.borderLeftColor = color;
    item.style.borderLeftWidth = '4px';

    const name = scenario.name || 'Unnamed Scenario';
    const id = `scenario-${index}`;

    let html = `
      <div class="scenario-header">
        <label>
          <input type="checkbox" id="${id}" checked>
          <span style="color:${color}">&#9632;</span> ${escapeHtml(name)}
        </label>
        <button class="zoom-btn" data-scenario="${index}" title="Zoom to scenario">&#8982;</button>
      </div>
    `;

    if (scenario.description) {
      html += `<div class="description">${escapeHtml(scenario.description)}</div>`;
    }

    item.innerHTML = html;
    listEl.appendChild(item);

    // Toggle visibility
    item.querySelector(`#${id}`).addEventListener('change', (e) => {
      if (e.target.checked) {
        layer.addTo(map);
      } else {
        map.removeLayer(layer);
      }
    });

    // Zoom to scenario bounds
    item.querySelector('.zoom-btn').addEventListener('click', () => {
      const bounds = L.latLngBounds([]);
      layer.eachLayer(l => {
        if (l.getBounds) {
          bounds.extend(l.getBounds());
        }
      });
      if (bounds.isValid()) {
        map.fitBounds(bounds, { padding: [40, 40] });
      }
    });
  });

  // Fit map to all scenario bounds
  const allBounds = L.latLngBounds([]);
  Object.values(scenarioLayers).forEach(lg => {
    lg.eachLayer(l => {
      if (l.getBounds) {
        allBounds.extend(l.getBounds());
      }
    });
  });
  if (allBounds.isValid()) {
    map.fitBounds(allBounds, { padding: [40, 40] });
  }
}