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.
- A PTV Developer API key (get one at developer.myptv.com)
- Basic knowledge of HTML, CSS, and JavaScript
- At least one custom road attribute scenario created in your PTV Developer account
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
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>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: '© <a href="https://www.myptv.com">PTV Logistics</a>'
}
);
ptvLayer.addTo(map);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 || [];
}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;
}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;
}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%)`;
}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}">■</span> ${escapeHtml(name)}
</label>
<button class="zoom-btn" data-scenario="${index}" title="Zoom to scenario">⌖</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] });
}
}