Routenoptimierung
Fortgeschritten
SmartMaps GL
Routing
Isochrone
Matrix
Autocomplete
Drag & Drop
Dieser Anwendungsfall zeigt ein mit SmartMaps entwickeltes Werkzeug zur Routenoptimierung in der Logistik. Es bietet eine Mehrstopp-Routenplanung mit Drag-and-Drop-Neuordnung der Wegpunkte, eine Isochronenanalyse zur Visualisierung von 15-minütigen Servicegebieten, eine Notfallsimulation, die mithilfe einer Distanzmatrix den nächstgelegenen verfügbaren Fahrer findet, sowie eine Animation zur Fahrerverfolgung entlang der berechneten Route. Die Adresseingabe erfolgt über die SmartMaps Autocomplete API mit Vorschlägen in Echtzeit.
Wie es funktioniert
Die Demo führt den Nutzer durch einen vierstufigen Logistik-Workflow, wobei jeder Schritt den nächsten Akkordeon-Abschnitt freischaltet:
- Tour planen -- Der Nutzer gibt Adressen über die Autocomplete API ein, fügt optional Zwischenstopps hinzu (per Drag-and-Drop neu anordenbar), wählt ein Verkehrsmittel aus und berechnet die Route. Wenn „Beste Reihenfolge finden" aktiviert ist, ermittelt der Trip-Optimierer die effizienteste Wegpunktreihenfolge.
- Ergebnis & Analyse -- Die Route wird auf der Karte dargestellt, zusammen mit einer Zusammenfassung von Distanz und Dauer. Eine Isochronenanalyse visualisiert das in 15 Minuten erreichbare Servicegebiet rund um das Ziel.
- Notfall-Simulation -- In der Nähe des Ziels erscheint ein simulierter Eilauftrag. Es werden zufällige Fahrerpositionen generiert, und die Matrix API ermittelt, welcher Fahrer die Abholung am schnellsten erreichen kann.
- Fahrer-Tracking -- Die Route des nächstgelegenen Fahrers wird berechnet, und ein animierter LKW-Marker folgt dem Pfad mit sanfter Kameraverfolgung (Interpolation von Zoom, Neigung und Mittelpunkt).
Funktionen & APIs
- SmartMaps GL Map -- Interaktive Kartendarstellung mit benutzerdefinierten Markern, Routen-Layern und Unterstützung für 3D-Neigung/Ausrichtung
- Routing API (Route) -- Punkt-zu-Punkt-Routenberechnung mit Unterstützung für die Geschwindigkeitsprofile Auto, Fahrrad und Fußgänger
- Routing API (Trip) -- Optimierte Mehrstopp-Tourenplanung, die die beste Wegpunktreihenfolge findet
- Routing API (Isochrone) -- 15-Minuten-Erreichbarkeitspolygone zur Visualisierung von Servicegebieten
- Routing API (Matrix) -- Berechnung einer Distanzmatrix zur Ermittlung des nächstgelegenen Fahrers für die Notfalldisposition
- Autocomplete API -- Adresssuche in Echtzeit mit Typeahead-Vorschlägen für alle Wegpunkteingaben
- Fahrer-Animation -- Simuliertes Live-Tracking, das ein LKW-Symbol entlang der Route animiert, mit sanfter Kameraverfolgung
- Drag-and-Drop-Wegpunkte -- Interaktive Neuordnung der Zwischenstopps im Planungspanel
Verwendete API-Endpunkte
| Endpunkt | Zweck |
|---|---|
POST /api_rst/v2/geojson/route (type ROUTE) |
Punkt-zu-Punkt-Routing zwischen Wegpunkten |
POST /api_rst/v2/geojson/route (type TRIP) |
Mehrstopp-Tourenoptimierung (beste Reihenfolge) |
POST /api_rst/v2/geojson/route (type ISOCHRONE) |
Erreichbarkeitspolygon von einem einzelnen Punkt |
POST /api_rst/v2/geojson/route (type MATRIX) |
Fahrzeitmatrix zwischen einem Ausgangspunkt und mehreren Zielen |
| SmartMaps Autocomplete JS SDK (v5) | Adresssuche mit Typeahead-Vorschlägen |
Code
/*
* =====================================================================
* SmartMaps Logistics & Tour Optimizer
* =====================================================================
*
* This demo showcases a 4-step logistics workflow using the SmartMaps
* Routing API family:
*
* 1. TOUR PLANNING
* Users enter addresses via the Autocomplete API, optionally add
* intermediate stops with drag-and-drop reordering, and choose a
* transport mode. The route is calculated using the Routing API
* (type ROUTE) or the Trip optimizer (type TRIP) when "find best
* order" is checked.
*
* 2. RESULTS & ANALYSIS
* The computed route is displayed on the map with distance/duration
* summary. An isochrone analysis (type ISOCHRONE) visualizes the
* 15-minute reachable service area around the last waypoint.
*
* 3. EMERGENCY SIMULATION
* A simulated urgent delivery appears near the destination. Using
* the Matrix API (type MATRIX), the system identifies which of the
* randomly placed drivers can reach the pickup fastest.
*
* 4. DRIVER TRACKING
* The nearest driver's route is calculated and an animated truck
* marker follows the route with smooth camera tracking (zoom, pitch,
* and center interpolation).
*
* API endpoints used:
* - Routing API: https://www.yellowmap.de/api_rst/v2/geojson/route
* - Autocomplete: SmartMaps Autocomplete JS SDK (v5)
* =====================================================================
*/
// === CONFIGURATION & CONSTANTS =======================================
/** SmartMaps API key for authentication */
const apiKey = '[INSERT API-KEY]';
/** Isochrone reachability time in minutes */
const ISOCHRONE_TIME_MINUTES = 15;
/** Isochrone grid resolution (higher = more detailed polygon) */
const ISOCHRONE_GRID = "100";
/** Number of simulated drivers placed around the destination */
const DRIVER_COUNT = 10;
/** Radius in km within which drivers are randomly distributed */
const DRIVER_SPREAD_RADIUS_KM = 5;
/** Lat/lng offset for the urgent delivery marker from the last waypoint */
const URGENT_DELIVERY_OFFSET = 0.01;
/** Duration of the driver tracking animation in milliseconds */
const TRACKING_ANIMATION_DURATION_MS = 25000;
/** Target zoom level during driver tracking animation */
const TRACKING_TARGET_ZOOM = 15.5;
/** Target pitch (tilt) during driver tracking animation */
const TRACKING_TARGET_PITCH = 55;
/** Smoothing factor for camera interpolation (0-1, higher = smoother) */
const TRACKING_SMOOTHING = 0.98;
/** Approximate km per degree of latitude (1 degree latitude ~ 111.32 km) */
const KM_TO_DEGREES = 111.32;
// === MAP INITIALIZATION ==============================================
/**
* Reads a CSS custom property from the document root.
* @param {string} variable - CSS variable name (e.g. '--brand-primary')
* @returns {string} The computed value of the CSS variable
*/
function getCssVariable(variable) {
return getComputedStyle(document.documentElement).getPropertyValue(variable).trim();
}
const map = new smartmapsgl.Map({
apiKey: apiKey,
container: 'map',
center: { lat: 49.021649, lng: 8.439330 },
zoom: 6,
style: smartmapsgl.MapStyle.ESSENTIAL
});
// === DOM ELEMENTS & STATE ============================================
const controlPanel = document.getElementById('control-panel');
const waypointsContainer = document.getElementById('waypoints-container');
const addWaypointBtn = document.getElementById('add-waypoint-btn');
const calculateBtn = document.getElementById('calculate-btn');
const isochroneBtn = document.getElementById('isochrone-btn');
const matrixBtn = document.getElementById('matrix-btn');
const optimizeCheckbox = document.getElementById('optimize-checkbox');
const transportButtons = document.querySelectorAll('.transport-modes button');
const resetViewBtn = document.getElementById('reset-view-btn');
/** @type {Array<{input: HTMLInputElement, coords: number[]|null, group: HTMLElement}>} */
let waypoints = [];
/** @type {smartmapsgl.Marker[]} */
let mapMarkers = [];
/** Currently selected routing speed profile */
let currentSpeedProfile = 'FAST';
/** Coordinates of simulated driver locations */
let dynamicDriverLocations = [];
/** Coordinates of the urgent delivery marker */
let urgentDeliveryLocation = null;
/** Info about the best (fastest) driver from the matrix result */
let bestDriverInfo = {};
/** requestAnimationFrame ID for the tracking animation */
let animationFrameId;
/** GeoJSON feature of the driver-to-delivery route */
let driverRouteGeoJSON = null;
/** The animated truck marker instance */
let truckMarker = null;
/** Timestamp when the tracking animation started */
let animationStartTime;
// === WAYPOINT MANAGEMENT =============================================
/**
* Initializes autocomplete on a waypoint input field and registers
* it in the waypoints array.
* @param {HTMLInputElement} inputElement - The input element to attach autocomplete to
*/
async function initializeWaypointInput(inputElement) {
const waypoint = { input: inputElement, coords: null, group: inputElement.parentElement };
waypoints.push(waypoint);
const autocomplete = await smartmaps.autocompleteService.createAutocomplete(inputElement, apiKey, {});
autocomplete.addEventListener('selected', (e) => {
waypoint.coords = e.detail.geojson.geometry.coordinates;
checkCalculable();
});
inputElement.addEventListener('input', () => { waypoint.coords = null; checkCalculable(); });
}
/**
* Adds a new intermediate waypoint input field between the first and
* last stops. Attaches autocomplete, a remove button, and re-indexes
* all placeholders.
*/
async function addWaypoint() {
const newGroup = document.createElement('div');
newGroup.className = 'input-group';
newGroup.innerHTML = `<input class="waypoint-input" type="search" placeholder="Zwischenstopp" /><button class="remove-waypoint"><i class="material-icons">close</i></button>`;
const lastInputGroup = waypointsContainer.querySelector('.input-group:last-of-type');
waypointsContainer.insertBefore(newGroup, lastInputGroup);
const newInput = newGroup.querySelector('input');
const newIndex = waypoints.length - 1;
const newWaypoint = { input: newInput, coords: null, group: newGroup };
waypoints.splice(newIndex, 0, newWaypoint);
const autocomplete = await smartmaps.autocompleteService.createAutocomplete(newInput, apiKey, {});
autocomplete.addEventListener('selected', (e) => {
newWaypoint.coords = e.detail.geojson.geometry.coordinates;
checkCalculable();
});
newInput.addEventListener('input', () => { newWaypoint.coords = null; checkCalculable(); });
newGroup.querySelector('.remove-waypoint').addEventListener('click', () => {
waypoints = waypoints.filter(wp => wp !== newWaypoint);
newGroup.remove();
checkCalculable();
updateWaypointPlaceholders();
const activeHeader = document.querySelector('.accordion-header.active');
if (activeHeader) {
const body = activeHeader.nextElementSibling;
body.style.maxHeight = body.scrollHeight + "px";
}
});
updateWaypointPlaceholders();
const plannerHeader = document.getElementById('header-planner');
if(plannerHeader.classList.contains('active')) {
const body = plannerHeader.nextElementSibling;
body.style.maxHeight = body.scrollHeight + "px";
}
}
/**
* Enables or disables the "Calculate" button depending on whether
* all waypoints have resolved coordinates.
*/
function checkCalculable() {
calculateBtn.disabled = !waypoints.every(wp => wp.coords !== null);
}
/**
* Updates placeholder text and draggable state for all waypoint input
* groups. The first is always "Start / Depot", the last is always
* "Zieladresse", and intermediates are numbered "Zwischenstopp N".
*/
function updateWaypointPlaceholders() {
const inputGroups = waypointsContainer.querySelectorAll('.input-group');
inputGroups.forEach((group, index) => {
const input = group.querySelector('input');
if (index === 0) {
input.placeholder = 'Start / Depot';
group.draggable = false;
} else if (index === inputGroups.length - 1) {
input.placeholder = 'Zieladresse';
group.draggable = false;
} else {
input.placeholder = `Zwischenstopp ${index}`;
group.draggable = true;
}
});
}
// === ROUTE CALCULATION ===============================================
/**
* Builds a standard GeoJSON FeatureCollection request body for the
* SmartMaps Routing API.
* @param {string} type - Routing type: "ROUTE", "TRIP", "ISOCHRONE", or "MATRIX"
* @param {Object} extraParams - Additional routing parameters to merge
* @param {Array} features - GeoJSON Feature array for the request
* @returns {Object} Complete request body ready for JSON.stringify
*/
function buildRoutingRequest(type, extraParams, features) {
return {
type: "FeatureCollection",
routingparams: { type, ...extraParams },
authentication: { channel: "roadtrip-demo" },
crs: { type: "name", properties: { name: "urn:ogc:def:crs:OGC:1.3:CRS84" } },
features
};
}
/**
* Returns responsive map padding that accounts for the control panel
* position on mobile (bottom sheet) vs. desktop (left sidebar).
* @returns {{top: number, bottom: number, left: number, right: number}} Padding in pixels
*/
function getMapPadding() {
if (window.innerWidth <= 768) {
const panel = document.getElementById('control-panel');
// Panel height + 20px extra spacing
const panelHeight = panel ? panel.offsetHeight : 300;
return { top: 40, bottom: panelHeight + 20, left: 20, right: 20 };
} else {
// Desktop padding: accounts for the left-side panel
return { top: 40, bottom: 40, left: 460, right: 40 };
}
}
/**
* Calculates a route or optimized trip from the entered waypoints.
* Uses TRIP mode when the "find best order" checkbox is checked,
* otherwise uses standard ROUTE mode. Draws the result on the map
* and displays distance/duration in the results accordion.
*/
async function calculateRoute() {
calculateBtn.disabled = true;
calculateBtn.innerHTML = '<span>Berechne...</span><div class="loader"></div>';
clearMap();
const coords = waypoints.map(wp => wp.coords);
const useTrip = optimizeCheckbox.checked;
const url = `https://www.yellowmap.de/api_rst/v2/geojson/route?apiKey=${smartmapsgl.encodeString(apiKey)}`;
const requestBody = buildRoutingRequest(
useTrip ? "TRIP" : "ROUTE",
{ speedProfile: currentSpeedProfile, routingRoundTrip: false, isoLocale: "de-DE", coordFormatOut: "GEODECIMAL_POINT" },
useTrip
? [{ type: "Feature", geometry: { type: "MultiPoint", coordinates: coords }, properties: {} }]
: coords.map(c => ({ type: "Feature", geometry: { type: "Point", coordinates: c }, properties: {} }))
);
try {
const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody) });
if (!response.ok) throw new Error(`API Error: ${response.status} - ${await response.text()}`);
const data = await response.json();
if (data.features && data.features.length > 0) {
const routeFeature = data.features[0];
const props = data.properties;
document.getElementById('route-summary').innerHTML = `<strong>Distanz:</strong> ${(props.distance / 1000).toFixed(1)} km<br><strong>Dauer:</strong> ca. ${new Date(props.duration * 1000).toISOString().substr(11, 8)}`;
map.addSource('route', { type: 'geojson', data: routeFeature });
map.addLayer({ id: 'route-line', type: 'line', source: 'route', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': getCssVariable('--brand-primary'), 'line-width': 6, 'line-opacity': 0.8 } });
// When using TRIP mode, read back the optimized waypoint order
let stops = coords;
if (useTrip && routeFeature.properties && routeFeature.properties.waypoints) {
stops = routeFeature.properties.waypoints.map(wp => wp.originalRequest.geometry.coordinates);
}
// Place numbered markers at each stop
stops.forEach((coord, index) => {
const el = document.createElement('div');
el.className = 'custom-marker';
el.textContent = index === 0 ? 'A' : (index === stops.length - 1 ? 'B' : index);
mapMarkers.push(new smartmapsgl.Marker({ element: el }).setLngLat(coord).addTo(map));
});
// Fit the map to the route with responsive padding
const bounds = new smartmapsgl.LngLatBounds();
routeFeature.geometry.coordinates.forEach(coord => bounds.extend(coord));
map.fitBounds(bounds, { padding: getMapPadding() });
toggleAccordion(document.getElementById('header-results'), true);
} else { throw new Error("No route found in the API response."); }
} catch (error) {
console.error("Route calculation error:", error);
document.getElementById('route-summary').innerHTML = `<p style="color: var(--error-color);">Route konnte nicht berechnet werden.</p>`;
toggleAccordion(document.getElementById('header-results'), true);
} finally {
calculateBtn.disabled = false;
calculateBtn.innerHTML = '<i class="material-icons">route</i> Route berechnen';
}
}
// === ISOCHRONE ANALYSIS ==============================================
/**
* Calculates and displays an isochrone polygon around the last waypoint.
* The isochrone shows the area reachable within ISOCHRONE_TIME_MINUTES
* using the currently selected speed profile.
*/
isochroneBtn.addEventListener('click', async () => {
const startCoords = waypoints[waypoints.length - 1].coords;
if (!startCoords) return;
isochroneBtn.disabled = true;
isochroneBtn.innerHTML = '<span>Lade...</span><div class="loader"></div>';
const url = `https://www.yellowmap.de/api_rst/v2/geojson/route?apiKey=${smartmapsgl.encodeString(apiKey)}`;
const body = buildRoutingRequest(
"ISOCHRONE",
{ timeInMinutes: ISOCHRONE_TIME_MINUTES, speedProfile: currentSpeedProfile, isochroneGrid: ISOCHRONE_GRID, coordFormatOut: "GEODECIMAL_POINT" },
[{ type: "Feature", geometry: { type: "Point", coordinates: startCoords }, properties: {} }]
);
try {
const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!response.ok) throw new Error(`API Error: ${response.status}`);
const data = await response.json();
if (data.features && data.features.length > 0) {
const isochroneFeature = data.features[0];
// Remove any existing isochrone layers before adding new ones
if (map.getSource('isochrone')) { map.removeLayer('isochrone-outline'); map.removeLayer('isochrone-area'); map.removeSource('isochrone'); }
map.addSource('isochrone', { type: 'geojson', data: isochroneFeature });
// Insert isochrone layers below the route line so the route stays visible
const beforeId = map.getLayer('route-line') ? 'route-line' : undefined;
map.addLayer({ id: 'isochrone-area', type: 'fill', source: 'isochrone', paint: { 'fill-color': getCssVariable('--brand-secondary'), 'fill-opacity': 0.3 } }, beforeId);
map.addLayer({ id: 'isochrone-outline', type: 'line', source: 'isochrone', paint: { 'line-color': '#ffffff', 'line-width': 2, 'line-opacity': 0.9 } }, beforeId);
// Fit the map to the isochrone bounds with responsive padding
const bounds = new smartmapsgl.LngLatBounds();
isochroneFeature.geometry.coordinates[0].forEach(coord => bounds.extend(coord));
map.fitBounds(bounds, { padding: getMapPadding(), duration: 1000 });
toggleAccordion(document.getElementById('header-simulation'), true);
} else { console.error("Isochrone response contains no features."); }
} catch (error) { console.error("Isochrone calculation error:", error);
} finally {
isochroneBtn.disabled = false;
isochroneBtn.innerHTML = '<i class="material-icons">map</i> 15-Min Servicegebiet';
}
});
// === MATRIX / EMERGENCY DISPATCH =====================================
/**
* Simulates an emergency dispatch scenario. Places random driver markers
* and an urgent delivery marker on the map, then uses the Matrix API to
* find the fastest driver. Results are displayed in the tracking accordion.
*/
matrixBtn.addEventListener('click', async () => {
const destination = waypoints[waypoints.length - 1].coords;
if (!destination) return;
// Remove any existing driver/urgent markers
mapMarkers.filter(m => m.getElement().classList.contains('driver-marker') || m.getElement().classList.contains('urgent-marker')).forEach(m => m.remove());
mapMarkers = mapMarkers.filter(m => !m.getElement().classList.contains('driver-marker') && !m.getElement().classList.contains('urgent-marker'));
// Generate random driver positions and the urgent delivery location
dynamicDriverLocations = generateDriverLocations(destination, DRIVER_COUNT, DRIVER_SPREAD_RADIUS_KM);
urgentDeliveryLocation = [destination[0] + URGENT_DELIVERY_OFFSET, destination[1] + URGENT_DELIVERY_OFFSET];
// Add driver markers to the map
dynamicDriverLocations.forEach((coords, i) => {
const el = document.createElement('div');
el.className = 'custom-marker driver-marker';
el.textContent = `F${i+1}`;
mapMarkers.push(new smartmapsgl.Marker({ element: el }).setLngLat(coords).addTo(map));
});
// Add urgent delivery marker with pulsing animation
const el = document.createElement('div');
el.className = 'custom-marker urgent-marker';
el.textContent = `!`;
mapMarkers.push(new smartmapsgl.Marker({ element: el }).setLngLat(urgentDeliveryLocation).addTo(map));
// Call the Matrix API to find travel times from the urgent location to all drivers
const url = `https://www.yellowmap.de/api_rst/v2/geojson/route?apiKey=${smartmapsgl.encodeString(apiKey)}`;
const body = buildRoutingRequest(
"MATRIX",
{ speedProfile: "FAST" },
[
{ type: "Feature", geometry: { type: "Point", coordinates: urgentDeliveryLocation }, properties: { type: "StartPoint" } },
{ type: "Feature", geometry: { type: "MultiPoint", coordinates: dynamicDriverLocations }, properties: {} }
]
);
const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
const data = await response.json();
const resultContainer = document.getElementById('matrix-result');
if (data.features?.[0]?.properties?.routingDestinations) {
const results = data.features[0].properties.routingDestinations;
if (results.length > 0) {
// Find the driver with the shortest travel time
const best = results.reduce((prev, curr) => prev.timeInSeconds < curr.timeInSeconds ? prev : curr);
const bestDriverIndex = results.indexOf(best);
bestDriverInfo = {
index: bestDriverIndex,
coords: dynamicDriverLocations[bestDriverIndex],
time: Math.round(best.timeInSeconds / 60)
};
resultContainer.innerHTML = `Ergebnis: Fahrer ${bestDriverInfo.index + 1} ist am schnellsten! (ca. ${bestDriverInfo.time} Min.)
<button id="track-driver-btn" class="action-button tertiary-button"><i class="material-icons">track_changes</i> Fahrer verfolgen</button>`;
document.getElementById('track-driver-btn').addEventListener('click', startDriverAnimation);
} else { resultContainer.textContent = 'Matrix calculation: no results.'; }
} else { resultContainer.textContent = 'Matrix calculation failed.'; }
toggleAccordion(document.getElementById('header-tracking'), true);
});
// === DRIVER TRACKING ANIMATION =======================================
/**
* Starts the driver tracking animation. Fetches a route from the best
* driver to the urgent delivery location, hides the control panel, and
* begins the frame-by-frame truck animation.
*/
async function startDriverAnimation() {
const url = `https://www.yellowmap.de/api_rst/v2/geojson/route?apiKey=${smartmapsgl.encodeString(apiKey)}`;
const requestBody = buildRoutingRequest(
"ROUTE",
{ speedProfile: "FAST", routingRoundTrip: false, isoLocale: "de-DE", coordFormatOut: "GEODECIMAL_POINT" },
[
{ type: "Feature", geometry: { type: "Point", coordinates: bestDriverInfo.coords }, properties: {} },
{ type: "Feature", geometry: { type: "Point", coordinates: urgentDeliveryLocation }, properties: {} }
]
);
const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody) });
if (!response.ok) {
console.error("Route calculation error for tracking:", await response.text());
return;
}
const data = await response.json();
if (!data.features || data.features.length === 0) {
console.error("Could not fetch route for driver tracking.");
return;
}
driverRouteGeoJSON = data.features[0];
// Hide the control panel with a slide-out animation
controlPanel.style.opacity = 0;
controlPanel.style.transform = 'translateX(-100%)';
setTimeout(() => controlPanel.style.display = 'none', 500);
resetViewBtn.style.display = 'flex';
// Draw the driver route as a dashed line
clearMap(true);
if (map.getSource('driver-route')) {
map.removeLayer('driver-route-line');
map.removeSource('driver-route');
}
map.addSource('driver-route', { type: 'geojson', data: driverRouteGeoJSON });
map.addLayer({ id: 'driver-route-line', type: 'line', source: 'driver-route', layout: {}, paint: { 'line-color': getCssVariable('--success-color'), 'line-width': 5, 'line-dasharray': [0, 2] } });
// Create the truck marker at the driver's starting position
const truckEl = document.createElement('div');
truckEl.className = 'truck-marker';
truckMarker = new smartmapsgl.Marker({ element: truckEl, anchor: 'center' }).setLngLat(bestDriverInfo.coords).addTo(map);
animateDriver(0);
}
/**
* Animation loop that moves the truck marker along the route coordinates.
* Uses linear interpolation between route points and smoothly transitions
* the camera center, zoom, and pitch toward their target values.
* @param {DOMHighResTimeStamp} timestamp - The current animation frame timestamp
*/
function animateDriver(timestamp) {
if (animationFrameId === null) return;
if (!animationStartTime) animationStartTime = timestamp;
const progress = Math.min((timestamp - animationStartTime) / TRACKING_ANIMATION_DURATION_MS, 1);
const routeCoords = driverRouteGeoJSON.geometry.coordinates;
// Interpolate position along the route polyline
const exactIndex = progress * (routeCoords.length - 1);
const startIndex = Math.floor(exactIndex);
const endIndex = Math.min(startIndex + 1, routeCoords.length - 1);
const segmentProgress = exactIndex - startIndex;
const startPos = routeCoords[startIndex];
const endPos = routeCoords[endIndex];
const currentLng = startPos[0] + (endPos[0] - startPos[0]) * segmentProgress;
const currentLat = startPos[1] + (endPos[1] - startPos[1]) * segmentProgress;
const currentPos = [currentLng, currentLat];
truckMarker.setLngLat(currentPos);
// Smooth camera follow using exponential smoothing
const smoothingWeight = 1 - (1 - TRACKING_SMOOTHING) * 5; // Center follows faster than zoom/pitch
const smoothedCenter = new smartmapsgl.LngLat(
map.getCenter().lng * smoothingWeight + currentPos[0] * (1 - smoothingWeight),
map.getCenter().lat * smoothingWeight + currentPos[1] * (1 - smoothingWeight)
);
map.setCenter(smoothedCenter);
// Gradually transition zoom and pitch toward target values
map.setZoom(map.getZoom() * TRACKING_SMOOTHING + TRACKING_TARGET_ZOOM * (1 - TRACKING_SMOOTHING));
map.setPitch(map.getPitch() * TRACKING_SMOOTHING + TRACKING_TARGET_PITCH * (1 - TRACKING_SMOOTHING));
if (progress < 1) {
animationFrameId = requestAnimationFrame(animateDriver);
} else {
animationStartTime = null;
animationFrameId = null;
}
}
// === UTILITY FUNCTIONS ===============================================
/**
* Generates random driver locations distributed uniformly within a
* circle of the given radius around a center point.
* @param {number[]} center - [longitude, latitude] of the center point
* @param {number} count - Number of driver locations to generate
* @param {number} radius - Spread radius in kilometers
* @returns {number[][]} Array of [longitude, latitude] coordinate pairs
*/
function generateDriverLocations(center, count, radius) {
const [lon, lat] = center;
const locations = [];
const radiusInDegrees = radius / KM_TO_DEGREES;
for (let i = 0; i < count; i++) {
const angle = Math.random() * 2 * Math.PI;
// sqrt ensures uniform distribution within the circle
const distance = Math.sqrt(Math.random()) * radiusInDegrees;
const driverLon = lon + (distance * Math.cos(angle)) / Math.cos(lat * Math.PI / 180);
const driverLat = lat + distance * Math.sin(angle);
locations.push([driverLon, driverLat]);
}
return locations;
}
/**
* Removes all map layers, sources, and markers. Cancels any running
* animation. When softClear is true, only the driver tracking layer
* and truck marker are removed (route + isochrone layers are preserved).
* @param {boolean} [softClear=false] - If true, preserve route/isochrone layers
*/
function clearMap(softClear = false) {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
animationFrameId = null;
animationStartTime = null;
}
if (truckMarker) truckMarker.remove();
if (map.getSource('driver-route')) { map.removeLayer('driver-route-line'); map.removeSource('driver-route'); }
if (!softClear) {
if (map.getSource('route')) { map.removeLayer('route-line'); map.removeSource('route'); }
if (map.getSource('isochrone')) { map.removeLayer('isochrone-outline'); map.removeLayer('isochrone-area'); map.removeSource('isochrone'); }
mapMarkers.forEach(marker => marker.remove());
mapMarkers = [];
resetAccordions();
}
}
/**
* Resets the entire view: clears the map, restores the control panel,
* hides the reset button, and flies back to the default map position.
*/
function resetView() {
clearMap();
controlPanel.style.display = 'flex';
setTimeout(() => {
controlPanel.style.opacity = 1;
controlPanel.style.transform = 'translateX(0)';
}, 10);
resetViewBtn.style.display = 'none';
map.flyTo({ center: { lat: 49.021649, lng: 8.439330 }, zoom: 6, pitch: 0, bearing: 0 });
}
resetViewBtn.addEventListener('click', resetView);
// === ACCORDION UI LOGIC ==============================================
/**
* Toggles an accordion section open or closed. Closes all other open
* sections first (single-open behavior). When forceOpen is true, the
* section is enabled (if disabled) and opened regardless of current state.
* @param {HTMLElement} header - The accordion header button element
* @param {boolean} [forceOpen=false] - Force-open the section and enable it if disabled
*/
function toggleAccordion(header, forceOpen = false) {
if (header.disabled && !forceOpen) return;
if (header.disabled) {
header.disabled = false;
}
const body = header.nextElementSibling;
const isActive = header.classList.contains('active');
// Close all other open accordions before opening a new one
if (!isActive || forceOpen) {
document.querySelectorAll('.accordion-header.active').forEach(activeHeader => {
if (activeHeader !== header) {
activeHeader.classList.remove('active');
activeHeader.nextElementSibling.style.maxHeight = null;
}
});
}
if (!isActive || forceOpen) {
header.classList.add('active');
body.style.maxHeight = body.scrollHeight + "px";
} else {
header.classList.remove('active');
body.style.maxHeight = null;
}
}
/**
* Resets all accordion sections to their initial state: closes everything,
* disables sections 2-4, and opens section 1 (Tour Planning).
*/
function resetAccordions() {
document.querySelectorAll('.accordion-header').forEach((header, index) => {
header.classList.remove('active');
header.nextElementSibling.style.maxHeight = null;
if (index > 0) {
header.disabled = true;
}
});
toggleAccordion(document.getElementById('header-planner'), true);
}
document.querySelectorAll('.accordion-header').forEach(header => {
header.addEventListener('click', () => toggleAccordion(header));
});
// === DRAG & DROP =====================================================
/** @type {HTMLElement|null} The DOM element currently being dragged */
let draggedItem = null;
/**
* Handles dragstart on waypoint input groups. Only intermediate stops
* (draggable elements) can be dragged.
*/
waypointsContainer.addEventListener('dragstart', (e) => {
if (e.target.classList.contains('input-group') && e.target.draggable) {
draggedItem = e.target;
setTimeout(() => e.target.classList.add('dragging'), 0);
}
});
/** Handles dragend: removes the dragging visual state. */
waypointsContainer.addEventListener('dragend', () => {
if (draggedItem) {
draggedItem.classList.remove('dragging');
draggedItem = null;
}
});
/**
* Handles dragover: repositions the dragged element in the DOM based
* on the cursor's vertical position. Prevents dragging above the first
* waypoint (start) or below the last (destination).
*/
waypointsContainer.addEventListener('dragover', (e) => {
e.preventDefault();
const draggedEl = document.querySelector('.dragging');
if (!draggedEl) return;
const afterElement = getDragAfterElement(waypointsContainer, e.clientY);
if (afterElement === undefined) {
waypointsContainer.insertBefore(draggedEl, waypointsContainer.lastElementChild);
} else {
// Prevent dropping before the first element (start waypoint)
if (afterElement === waypointsContainer.firstElementChild) {
return;
}
waypointsContainer.insertBefore(draggedEl, afterElement);
}
});
/**
* Handles drop: synchronizes the waypoints array order with the new
* DOM order after a drag-and-drop reorder operation.
*/
waypointsContainer.addEventListener('drop', (e) => {
e.preventDefault();
if (draggedItem) {
const newOrderWaypoints = [];
const domInputGroups = Array.from(waypointsContainer.querySelectorAll('.input-group'));
domInputGroups.forEach(group => {
const waypointObject = waypoints.find(wp => wp.group === group);
if (waypointObject) {
newOrderWaypoints.push(waypointObject);
}
});
waypoints = newOrderWaypoints;
updateWaypointPlaceholders();
checkCalculable();
}
});
/**
* Determines which element the dragged item should be inserted before,
* based on the vertical cursor position relative to each child's midpoint.
* @param {HTMLElement} container - The waypoints container
* @param {number} y - The current cursor Y position
* @returns {HTMLElement|undefined} The element to insert before, or undefined for end
*/
function getDragAfterElement(container, y) {
const draggableElements = [...container.querySelectorAll('.input-group:not(.dragging)')];
return draggableElements.reduce((closest, child) => {
const box = child.getBoundingClientRect();
const offset = y - box.top - box.height / 2;
if (offset < 0 && offset > closest.offset) {
return { offset: offset, element: child };
} else {
return closest;
}
}, { offset: Number.NEGATIVE_INFINITY }).element;
}
// === INITIALIZATION ==================================================
// Initialize autocomplete on all existing waypoint inputs
document.querySelectorAll('.waypoint-input').forEach(input => initializeWaypointInput(input));
// Wire up button event listeners
addWaypointBtn.addEventListener('click', addWaypoint);
calculateBtn.addEventListener('click', calculateRoute);
// Transport mode toggle buttons
transportButtons.forEach(button => {
button.addEventListener('click', () => {
transportButtons.forEach(btn => btn.classList.remove('active'));
button.classList.add('active');
currentSpeedProfile = button.dataset.profile;
});
});
// Toggle the "optimize" checkbox when clicking anywhere on the options row
document.querySelector('.options').addEventListener('click', (e) => {
if (e.target.id !== 'optimize-checkbox') {
optimizeCheckbox.checked = !optimizeCheckbox.checked;
}
});
// Set initial accordion and placeholder state
resetAccordions();
updateWaypointPlaceholders();
<body>
<div id="map"></div>
<button id="reset-view-btn"><i class="material-icons">arrow_back</i> Zurück zur Übersicht</button>
<div class="control-panel" id="control-panel">
<h1>Logistik & Touren-Optimierer</h1>
<div id="accordion-container">
<div class="accordion-item">
<button class="accordion-header" id="header-planner">
<h2>1. Tour planen</h2>
<span class="info-icon">
<i class="material-icons">info</i>
<span class="tooltip">Nutzt die <strong>Autocomplete API</strong> und <strong>Routing API (Trip/Route)</strong>.</span>
</span>
<i class="material-icons chevron">expand_more</i>
</button>
<div class="accordion-body" id="body-planner">
<div class="accordion-content">
<div id="waypoints-container">
<div class="input-group"><input class="waypoint-input" type="search" placeholder="Start / Depot" /></div>
<div class="input-group"><input class="waypoint-input" type="search" placeholder="Zieladresse" /></div>
</div>
<button id="add-waypoint-btn"><i class="material-icons">add</i> Zwischenstopp</button>
<div class="options">
<input type="checkbox" id="optimize-checkbox">
<label for="optimize-checkbox">Beste Reihenfolge finden</label>
</div>
<div class="transport-modes">
<button class="active" data-profile="FAST"><i class="material-icons">directions_car</i><span>PKW</span></button>
<button data-profile="BICYCLE"><i class="material-icons">directions_bike</i><span>Rad</span></button>
<button data-profile="PEDESTRIAN"><i class="material-icons">directions_walk</i><span>Fuß</span></button>
</div>
<button id="calculate-btn" class="action-button" disabled><i class="material-icons">route</i> Route berechnen</button>
</div>
</div>
</div>
<div class="accordion-item">
<button class="accordion-header" id="header-results" disabled>
<h2>2. Ergebnis & Analyse</h2>
<span class="info-icon">
<i class="material-icons">info</i>
<span class="tooltip">Nutzt die <strong>Routing API (Isochrone)</strong>.</span>
</span>
<i class="material-icons chevron">expand_more</i>
</button>
<div class="accordion-body" id="body-results">
<div class="accordion-content">
<div id="route-summary"></div>
<button id="isochrone-btn" class="action-button secondary-button"><i class="material-icons">map</i>15-Min Servicegebiet</button>
</div>
</div>
</div>
<div class="accordion-item">
<button class="accordion-header" id="header-simulation" disabled>
<h2>3. Notfall-Simulation</h2>
<span class="info-icon">
<i class="material-icons">info</i>
<span class="tooltip">Nutzt die <strong>Routing API (Matrix)</strong>.</span>
</span>
<i class="material-icons chevron">expand_more</i>
</button>
<div class="accordion-body" id="body-simulation">
<div class="accordion-content">
<p>Ein Eilauftrag kommt rein. Welcher Fahrer ist am schnellsten da?</p>
<button id="matrix-btn" class="action-button secondary-button"><i class="material-icons">groups</i> Nächsten Fahrer finden</button>
</div>
</div>
</div>
<div class="accordion-item">
<button class="accordion-header" id="header-tracking" disabled>
<h2>4. Fahrer-Tracking</h2>
<span class="info-icon">
<i class="material-icons">info</i>
<span class="tooltip">Nutzt die <strong>Map Match API</strong> (simuliert durch Routing).</span>
</span>
<i class="material-icons chevron">expand_more</i>
</button>
<div class="accordion-body" id="body-tracking">
<div class="accordion-content" id="matrix-result"></div>
</div>
</div>
</div>
</div>
</body>
/* =================================================================== */
/* BASE STYLES (integrated from smartmaps-demo-styles.css) */
/* =================================================================== */
:root {
--brand-primary: #18345c;
--brand-secondary: #3498db;
--brand-accent: #f6b80c;
--text-primary: #111827;
--text-secondary: #374151;
--text-light: #6b7280;
--surface-bg: #f4f7fa;
--surface-panel: #ffffff;
--border-color: #e5e7eb;
--success-color: #10b981;
--error-color: #ef4444;
}
body {
margin: 0;
padding: 0;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background-color: var(--surface-bg);
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#map {
position: absolute;
top: 0;
bottom: 0;
width: 100%;
height: 100%;
}
.loader {
border: 4px solid rgba(0,0,0,0.1);
border-top: 4px solid var(--brand-secondary);
border-radius: 50%;
width: 20px;
height: 20px;
animation: spin 1s linear infinite;
margin: 0 auto;
}
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
.info-icon {
position: relative;
display: inline-flex;
align-items: center;
cursor: pointer;
color: #9ca3af;
}
.info-icon .tooltip {
visibility: hidden; width: 260px; background-color: #1f2937;
color: #fff; text-align: left; border-radius: 6px; padding: 12px;
position: absolute; z-index: 100; opacity: 0; transition: opacity 0.3s;
font-size: 0.85rem; line-height: 1.5; font-weight: 400;
box-shadow: 0 4px 12px rgba(0,0,0,0.15); bottom: 130%; right: 0;
left: auto; transform: translateX(0);
}
.info-icon .tooltip::after {
content: ""; position: absolute; top: 100%; right: 10px; left: auto;
margin-left: 0; border-width: 5px; border-style: solid;
border-color: #1f2937 transparent transparent transparent;
}
.info-icon:hover .tooltip { visibility: visible; opacity: 1; }
.info-icon .tooltip strong { color: var(--brand-secondary); }
/* =================================================================== */
/* DEMO-SPECIFIC STYLES */
/* =================================================================== */
h1 {
background: var(--brand-primary);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
font-size: 1.75rem;
}
.control-panel { display: flex; flex-direction: column; }
.accordion-item { border-bottom: 1px solid var(--border-color); }
.accordion-item:last-child { border-bottom: none; }
.accordion-header {
background: none; border: none; width: 100%; text-align: left;
padding: 18px 4px; cursor: pointer; display: flex;
justify-content: space-between; align-items: center;
transition: background-color 0.2s;
}
.accordion-header:hover { background-color: #f9fafb; }
.accordion-header h2 {
font-size: 1.1em; font-weight: 500; margin: 0;
color: var(--text-secondary); border: none; padding: 0;
display: flex; align-items: center; gap: 8px;
}
.accordion-header .info-icon { margin-left: auto; margin-right: 10px; }
.accordion-header .chevron { transition: transform 0.3s ease; }
.accordion-header.active .chevron { transform: rotate(180deg); }
.accordion-header[disabled] { cursor: not-allowed; background-color: #f9fafb; }
.accordion-header[disabled] h2 { color: #9ca3af; }
.accordion-body { max-height: 0; overflow: hidden; transition: max-height 0.4s ease-out; }
.accordion-content { padding: 1px 0 18px 0; }
.input-group {
margin-bottom: 12px; position: relative; padding-left: 10px;
border-left: 4px solid transparent;
}
.input-group[draggable="true"] { cursor: move; border-left: 4px solid #d1d5db; }
.input-group.dragging { opacity: 0.4; background: #eef2f7; }
.waypoint-input {
width: 100%; padding: 10px; box-sizing: border-box; font-size: 1em;
border: 1px solid #d1d5db; border-radius: 6px;
transition: border-color 0.2s, box-shadow 0.2s;
}
.waypoint-input:focus {
outline: none; border-color: var(--brand-secondary);
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.2);
}
.remove-waypoint {
position: absolute; right: 8px; top: 50%; transform: translateY(-50%);
background: #fee2e2; color: var(--error-color); border: none; border-radius: 50%;
width: 24px; height: 24px; cursor: pointer; line-height: 1;
transition: background-color 0.2s, color 0.2s;
display: none; align-items: center; justify-content: center;
}
.remove-waypoint .material-icons { font-size: 20px; }
.remove-waypoint:hover { background: var(--error-color); color: white; }
.input-group[draggable="true"] .remove-waypoint { display: flex; }
#add-waypoint-btn {
background: #f9fafb; border: 1px dashed #d1d5db; color: #4b5563;
width: 100%; padding: 10px; border-radius: 6px; cursor: pointer;
font-weight: 500; margin-bottom: 15px; transition: all 0.2s;
display: flex; align-items: center; justify-content: center; gap: 8px;
}
#add-waypoint-btn:hover { background: #f3f4f6; border-color: #9ca3af; }
.options {
display: flex; align-items: center; background-color: #f9fafb;
border: 1px solid var(--border-color); border-radius: 8px;
padding: 12px; margin-bottom: 15px; cursor: pointer;
transition: background-color 0.2s;
}
.options:hover { background-color: #f3f4f6; }
.options input[type="checkbox"] {
margin-right: 12px; width: 16px; height: 16px;
accent-color: var(--brand-primary); cursor: pointer;
}
.options label { font-weight: 500; color: var(--text-secondary); cursor: pointer; flex-grow: 1; }
.transport-modes { display: flex; align-items: center; margin-bottom: 15px; border: 1px solid #d1d5db; border-radius: 6px; overflow: hidden; }
.transport-modes button {
flex-grow: 1; background: white; border: none; padding: 10px;
cursor: pointer; font-weight: 500; color: #4b5563;
transition: all 0.2s; border-right: 1px solid #d1d5db;
display: flex; align-items: center; justify-content: center; gap: 4px;
}
.transport-modes button:last-child { border-right: none; }
.transport-modes button.active { background: var(--brand-primary); color: white; }
.transport-modes button:hover:not(.active) { background-color: #f3f4f6; }
.action-button {
background-color: var(--brand-primary); color: white; border: none; padding: 12px 15px;
border-radius: 8px; font-weight: 500; cursor: pointer; width: 100%;
font-size: 1em; transition: all 0.2s ease-in-out;
display: flex; align-items: center; justify-content: center; gap: 8px;
}
.action-button:hover:not(:disabled) { background-color: #112546; transform: translateY(-2px); }
.action-button:disabled { background-color: #9ca3af; cursor: not-allowed; transform: none; }
.action-button .loader { border-top-color: white; }
.secondary-button { background-color: var(--brand-secondary); margin-top: 10px; }
.secondary-button:hover:not(:disabled) { background-color: #2980b9; }
.tertiary-button { background-color: var(--brand-accent); margin-top: 10px; }
.tertiary-button:hover:not(:disabled) { background-color: #d97706; }
.custom-marker { display: flex; justify-content: center; align-items: center; width: 28px; height: 28px; border-radius: 50%; background: var(--brand-primary); color: white; font-weight: bold; font-size: 14px; border: 2px solid white; box-shadow: 0 2px 5px rgba(0,0,0,0.3); }
.driver-marker { background: var(--success-color); }
.urgent-marker { background: var(--brand-accent); animation: pulse 1.s infinite; }
@keyframes pulse { 0% { box-shadow: 0 0 0 0 rgba(246, 184, 12, 0.7); } 70% { box-shadow: 0 0 0 15px rgba(246, 184, 12, 0); } 100% { box-shadow: 0 0 0 0 rgba(246, 184, 12, 0); } }
#reset-view-btn {
position: absolute; top: 20px; right: 20px; z-index: 5;
background-color: var(--surface-panel); color: var(--brand-primary); border: 1px solid var(--border-color);
padding: 10px 15px; border-radius: 8px; font-weight: 500; cursor: pointer;
box-shadow: 0 4px 15px rgba(0,0,0,0.1); transition: all 0.2s;
display: none; align-items: center; gap: 8px;
}
#reset-view-btn:hover { background-color: #f3f4f6; }
.truck-marker {
width: 40px; height: 40px;
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="%2310b981" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-truck"><path d="M5 18H3c-.6 0-1-.4-1-1V7c0-.6.4-1 1-1h10c.6 0 1 .4 1 1v11"/><path d="M14 9h4l4 4v4c0 .6-.4 1-1 1h-2"/><circle cx="7" cy="18" r="2"/><path d="M9 18h6"/><circle cx="18" cy="18" r="2"/></svg>');
background-size: 75%; background-repeat: no-repeat; background-position: center;
background-color: rgba(255,255,255,0.8); border-radius: 50%;
border: 2px solid var(--success-color);
box-shadow: 0 0 10px rgba(16, 185, 129, 0.7);
}
/* =================================================================== */
/* RESPONSIVE & DESKTOP STYLES */
/* =================================================================== */
/* --- Mobile view (up to 768px) --- */
@media (max-width: 768px) {
body {
height: 100vh;
margin: 0;
overflow: hidden; /* Prevents the entire page from scrolling */
}
.control-panel {
position: fixed; /* Positions the panel over the map */
bottom: 0;
left: 0;
right: 0;
width: 100%;
box-sizing: border-box;
max-height: 60vh; /* Takes up at most 60% of the viewport height */
overflow-y: auto; /* Becomes scrollable when content exceeds height */
z-index: 10;
/* Glassmorphism effect */
background: rgba(255, 255, 255, 0.85);
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
box-shadow: 0 -4px 15px rgba(0,0,0,0.1);
border-top: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 20px 20px 0 0; /* Rounded top corners */
padding: 8px 16px 16px 16px;
transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);
transform: translateY(0); /* Panel is visible by default */
}
h1 {
font-size: 1.5rem;
margin-top: 8px;
margin-bottom: 12px;
text-align: center;
}
#reset-view-btn {
top: 10px;
right: 10px;
z-index: 11; /* Ensures the button is above the panel */
}
}
/* --- Desktop view (from 769px) --- */
@media (min-width: 769px) {
.control-panel {
position: absolute;
top: 20px;
left: 20px;
z-index: 10;
background-color: var(--surface-panel, white);
border-radius: 12px;
box-shadow: 0 8px 30px rgba(0,0,0,0.12);
width: 420px;
max-height: calc(100vh - 40px);
overflow-y: auto;
padding: 10px 24px 24px 24px;
}
}
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<title>SmartMaps Showcase: Logistik & Touren-Optimierer</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1, user-scalable=no" />
<!-- External stylesheets (only needed for Autocomplete) -->
<link rel="stylesheet" href="https://cdn.smartmaps.cloud/packages/smartmaps/autocomplete/autocomplete.css" />
<link href="css/material-icons.css" rel="stylesheet">
<!-- SmartMaps libraries -->
<script src="https://cdn.smartmaps.cloud/packages/smartmaps/autocomplete/v5/umd/autocomplete.min.js"></script>
<script src="https://cdn.smartmaps.cloud/packages/smartmaps/smartmaps-gl/v2/umd/smartmaps-gl.min.js"></script>
<style>
/* =================================================================== */
/* BASE STYLES (integrated from smartmaps-demo-styles.css) */
/* =================================================================== */
:root {
--brand-primary: #18345c;
--brand-secondary: #3498db;
--brand-accent: #f6b80c;
--text-primary: #111827;
--text-secondary: #374151;
--text-light: #6b7280;
--surface-bg: #f4f7fa;
--surface-panel: #ffffff;
--border-color: #e5e7eb;
--success-color: #10b981;
--error-color: #ef4444;
}
body {
margin: 0;
padding: 0;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background-color: var(--surface-bg);
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#map {
position: absolute;
top: 0;
bottom: 0;
width: 100%;
height: 100%;
}
.loader {
border: 4px solid rgba(0,0,0,0.1);
border-top: 4px solid var(--brand-secondary);
border-radius: 50%;
width: 20px;
height: 20px;
animation: spin 1s linear infinite;
margin: 0 auto;
}
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
.info-icon {
position: relative;
display: inline-flex;
align-items: center;
cursor: pointer;
color: #9ca3af;
}
.info-icon .tooltip {
visibility: hidden; width: 260px; background-color: #1f2937;
color: #fff; text-align: left; border-radius: 6px; padding: 12px;
position: absolute; z-index: 100; opacity: 0; transition: opacity 0.3s;
font-size: 0.85rem; line-height: 1.5; font-weight: 400;
box-shadow: 0 4px 12px rgba(0,0,0,0.15); bottom: 130%; right: 0;
left: auto; transform: translateX(0);
}
.info-icon .tooltip::after {
content: ""; position: absolute; top: 100%; right: 10px; left: auto;
margin-left: 0; border-width: 5px; border-style: solid;
border-color: #1f2937 transparent transparent transparent;
}
.info-icon:hover .tooltip { visibility: visible; opacity: 1; }
.info-icon .tooltip strong { color: var(--brand-secondary); }
/* =================================================================== */
/* DEMO-SPECIFIC STYLES */
/* =================================================================== */
h1 {
background: var(--brand-primary);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
font-size: 1.75rem;
}
.control-panel { display: flex; flex-direction: column; }
.accordion-item { border-bottom: 1px solid var(--border-color); }
.accordion-item:last-child { border-bottom: none; }
.accordion-header {
background: none; border: none; width: 100%; text-align: left;
padding: 18px 4px; cursor: pointer; display: flex;
justify-content: space-between; align-items: center;
transition: background-color 0.2s;
}
.accordion-header:hover { background-color: #f9fafb; }
.accordion-header h2 {
font-size: 1.1em; font-weight: 500; margin: 0;
color: var(--text-secondary); border: none; padding: 0;
display: flex; align-items: center; gap: 8px;
}
.accordion-header .info-icon { margin-left: auto; margin-right: 10px; }
.accordion-header .chevron { transition: transform 0.3s ease; }
.accordion-header.active .chevron { transform: rotate(180deg); }
.accordion-header[disabled] { cursor: not-allowed; background-color: #f9fafb; }
.accordion-header[disabled] h2 { color: #9ca3af; }
.accordion-body { max-height: 0; overflow: hidden; transition: max-height 0.4s ease-out; }
.accordion-content { padding: 1px 0 18px 0; }
.input-group {
margin-bottom: 12px; position: relative; padding-left: 10px;
border-left: 4px solid transparent;
}
.input-group[draggable="true"] { cursor: move; border-left: 4px solid #d1d5db; }
.input-group.dragging { opacity: 0.4; background: #eef2f7; }
.waypoint-input {
width: 100%; padding: 10px; box-sizing: border-box; font-size: 1em;
border: 1px solid #d1d5db; border-radius: 6px;
transition: border-color 0.2s, box-shadow 0.2s;
}
.waypoint-input:focus {
outline: none; border-color: var(--brand-secondary);
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.2);
}
.remove-waypoint {
position: absolute; right: 8px; top: 50%; transform: translateY(-50%);
background: #fee2e2; color: var(--error-color); border: none; border-radius: 50%;
width: 24px; height: 24px; cursor: pointer; line-height: 1;
transition: background-color 0.2s, color 0.2s;
display: none; align-items: center; justify-content: center;
}
.remove-waypoint .material-icons { font-size: 20px; }
.remove-waypoint:hover { background: var(--error-color); color: white; }
.input-group[draggable="true"] .remove-waypoint { display: flex; }
#add-waypoint-btn {
background: #f9fafb; border: 1px dashed #d1d5db; color: #4b5563;
width: 100%; padding: 10px; border-radius: 6px; cursor: pointer;
font-weight: 500; margin-bottom: 15px; transition: all 0.2s;
display: flex; align-items: center; justify-content: center; gap: 8px;
}
#add-waypoint-btn:hover { background: #f3f4f6; border-color: #9ca3af; }
.options {
display: flex; align-items: center; background-color: #f9fafb;
border: 1px solid var(--border-color); border-radius: 8px;
padding: 12px; margin-bottom: 15px; cursor: pointer;
transition: background-color 0.2s;
}
.options:hover { background-color: #f3f4f6; }
.options input[type="checkbox"] {
margin-right: 12px; width: 16px; height: 16px;
accent-color: var(--brand-primary); cursor: pointer;
}
.options label { font-weight: 500; color: var(--text-secondary); cursor: pointer; flex-grow: 1; }
.transport-modes { display: flex; align-items: center; margin-bottom: 15px; border: 1px solid #d1d5db; border-radius: 6px; overflow: hidden; }
.transport-modes button {
flex-grow: 1; background: white; border: none; padding: 10px;
cursor: pointer; font-weight: 500; color: #4b5563;
transition: all 0.2s; border-right: 1px solid #d1d5db;
display: flex; align-items: center; justify-content: center; gap: 4px;
}
.transport-modes button:last-child { border-right: none; }
.transport-modes button.active { background: var(--brand-primary); color: white; }
.transport-modes button:hover:not(.active) { background-color: #f3f4f6; }
.action-button {
background-color: var(--brand-primary); color: white; border: none; padding: 12px 15px;
border-radius: 8px; font-weight: 500; cursor: pointer; width: 100%;
font-size: 1em; transition: all 0.2s ease-in-out;
display: flex; align-items: center; justify-content: center; gap: 8px;
}
.action-button:hover:not(:disabled) { background-color: #112546; transform: translateY(-2px); }
.action-button:disabled { background-color: #9ca3af; cursor: not-allowed; transform: none; }
.action-button .loader { border-top-color: white; }
.secondary-button { background-color: var(--brand-secondary); margin-top: 10px; }
.secondary-button:hover:not(:disabled) { background-color: #2980b9; }
.tertiary-button { background-color: var(--brand-accent); margin-top: 10px; }
.tertiary-button:hover:not(:disabled) { background-color: #d97706; }
.custom-marker { display: flex; justify-content: center; align-items: center; width: 28px; height: 28px; border-radius: 50%; background: var(--brand-primary); color: white; font-weight: bold; font-size: 14px; border: 2px solid white; box-shadow: 0 2px 5px rgba(0,0,0,0.3); }
.driver-marker { background: var(--success-color); }
.urgent-marker { background: var(--brand-accent); animation: pulse 1.s infinite; }
@keyframes pulse { 0% { box-shadow: 0 0 0 0 rgba(246, 184, 12, 0.7); } 70% { box-shadow: 0 0 0 15px rgba(246, 184, 12, 0); } 100% { box-shadow: 0 0 0 0 rgba(246, 184, 12, 0); } }
#reset-view-btn {
position: absolute; top: 20px; right: 20px; z-index: 5;
background-color: var(--surface-panel); color: var(--brand-primary); border: 1px solid var(--border-color);
padding: 10px 15px; border-radius: 8px; font-weight: 500; cursor: pointer;
box-shadow: 0 4px 15px rgba(0,0,0,0.1); transition: all 0.2s;
display: none; align-items: center; gap: 8px;
}
#reset-view-btn:hover { background-color: #f3f4f6; }
.truck-marker {
width: 40px; height: 40px;
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="%2310b981" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-truck"><path d="M5 18H3c-.6 0-1-.4-1-1V7c0-.6.4-1 1-1h10c.6 0 1 .4 1 1v11"/><path d="M14 9h4l4 4v4c0 .6-.4 1-1 1h-2"/><circle cx="7" cy="18" r="2"/><path d="M9 18h6"/><circle cx="18" cy="18" r="2"/></svg>');
background-size: 75%; background-repeat: no-repeat; background-position: center;
background-color: rgba(255,255,255,0.8); border-radius: 50%;
border: 2px solid var(--success-color);
box-shadow: 0 0 10px rgba(16, 185, 129, 0.7);
}
/* =================================================================== */
/* RESPONSIVE & DESKTOP STYLES */
/* =================================================================== */
/* --- Mobile view (up to 768px) --- */
@media (max-width: 768px) {
body {
height: 100vh;
margin: 0;
overflow: hidden; /* Prevents the entire page from scrolling */
}
.control-panel {
position: fixed; /* Positions the panel over the map */
bottom: 0;
left: 0;
right: 0;
width: 100%;
box-sizing: border-box;
max-height: 60vh; /* Takes up at most 60% of the viewport height */
overflow-y: auto; /* Becomes scrollable when content exceeds height */
z-index: 10;
/* Glassmorphism effect */
background: rgba(255, 255, 255, 0.85);
-webkit-backdrop-filter: blur(10px);
backdrop-filter: blur(10px);
box-shadow: 0 -4px 15px rgba(0,0,0,0.1);
border-top: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 20px 20px 0 0; /* Rounded top corners */
padding: 8px 16px 16px 16px;
transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);
transform: translateY(0); /* Panel is visible by default */
}
h1 {
font-size: 1.5rem;
margin-top: 8px;
margin-bottom: 12px;
text-align: center;
}
#reset-view-btn {
top: 10px;
right: 10px;
z-index: 11; /* Ensures the button is above the panel */
}
}
/* --- Desktop view (from 769px) --- */
@media (min-width: 769px) {
.control-panel {
position: absolute;
top: 20px;
left: 20px;
z-index: 10;
background-color: var(--surface-panel, white);
border-radius: 12px;
box-shadow: 0 8px 30px rgba(0,0,0,0.12);
width: 420px;
max-height: calc(100vh - 40px);
overflow-y: auto;
padding: 10px 24px 24px 24px;
}
}
</style>
</head>
<body>
<div id="map"></div>
<button id="reset-view-btn"><i class="material-icons">arrow_back</i> Zurück zur Übersicht</button>
<div class="control-panel" id="control-panel">
<h1>Logistik & Touren-Optimierer</h1>
<div id="accordion-container">
<div class="accordion-item">
<button class="accordion-header" id="header-planner">
<h2>1. Tour planen</h2>
<span class="info-icon">
<i class="material-icons">info</i>
<span class="tooltip">Nutzt die <strong>Autocomplete API</strong> und <strong>Routing API (Trip/Route)</strong>.</span>
</span>
<i class="material-icons chevron">expand_more</i>
</button>
<div class="accordion-body" id="body-planner">
<div class="accordion-content">
<div id="waypoints-container">
<div class="input-group"><input class="waypoint-input" type="search" placeholder="Start / Depot" /></div>
<div class="input-group"><input class="waypoint-input" type="search" placeholder="Zieladresse" /></div>
</div>
<button id="add-waypoint-btn"><i class="material-icons">add</i> Zwischenstopp</button>
<div class="options">
<input type="checkbox" id="optimize-checkbox">
<label for="optimize-checkbox">Beste Reihenfolge finden</label>
</div>
<div class="transport-modes">
<button class="active" data-profile="FAST"><i class="material-icons">directions_car</i><span>PKW</span></button>
<button data-profile="BICYCLE"><i class="material-icons">directions_bike</i><span>Rad</span></button>
<button data-profile="PEDESTRIAN"><i class="material-icons">directions_walk</i><span>Fuß</span></button>
</div>
<button id="calculate-btn" class="action-button" disabled><i class="material-icons">route</i> Route berechnen</button>
</div>
</div>
</div>
<div class="accordion-item">
<button class="accordion-header" id="header-results" disabled>
<h2>2. Ergebnis & Analyse</h2>
<span class="info-icon">
<i class="material-icons">info</i>
<span class="tooltip">Nutzt die <strong>Routing API (Isochrone)</strong>.</span>
</span>
<i class="material-icons chevron">expand_more</i>
</button>
<div class="accordion-body" id="body-results">
<div class="accordion-content">
<div id="route-summary"></div>
<button id="isochrone-btn" class="action-button secondary-button"><i class="material-icons">map</i>15-Min Servicegebiet</button>
</div>
</div>
</div>
<div class="accordion-item">
<button class="accordion-header" id="header-simulation" disabled>
<h2>3. Notfall-Simulation</h2>
<span class="info-icon">
<i class="material-icons">info</i>
<span class="tooltip">Nutzt die <strong>Routing API (Matrix)</strong>.</span>
</span>
<i class="material-icons chevron">expand_more</i>
</button>
<div class="accordion-body" id="body-simulation">
<div class="accordion-content">
<p>Ein Eilauftrag kommt rein. Welcher Fahrer ist am schnellsten da?</p>
<button id="matrix-btn" class="action-button secondary-button"><i class="material-icons">groups</i> Nächsten Fahrer finden</button>
</div>
</div>
</div>
<div class="accordion-item">
<button class="accordion-header" id="header-tracking" disabled>
<h2>4. Fahrer-Tracking</h2>
<span class="info-icon">
<i class="material-icons">info</i>
<span class="tooltip">Nutzt die <strong>Map Match API</strong> (simuliert durch Routing).</span>
</span>
<i class="material-icons chevron">expand_more</i>
</button>
<div class="accordion-body" id="body-tracking">
<div class="accordion-content" id="matrix-result"></div>
</div>
</div>
</div>
</div>
<script>
/*
* =====================================================================
* SmartMaps Logistics & Tour Optimizer
* =====================================================================
*
* This demo showcases a 4-step logistics workflow using the SmartMaps
* Routing API family:
*
* 1. TOUR PLANNING
* Users enter addresses via the Autocomplete API, optionally add
* intermediate stops with drag-and-drop reordering, and choose a
* transport mode. The route is calculated using the Routing API
* (type ROUTE) or the Trip optimizer (type TRIP) when "find best
* order" is checked.
*
* 2. RESULTS & ANALYSIS
* The computed route is displayed on the map with distance/duration
* summary. An isochrone analysis (type ISOCHRONE) visualizes the
* 15-minute reachable service area around the last waypoint.
*
* 3. EMERGENCY SIMULATION
* A simulated urgent delivery appears near the destination. Using
* the Matrix API (type MATRIX), the system identifies which of the
* randomly placed drivers can reach the pickup fastest.
*
* 4. DRIVER TRACKING
* The nearest driver's route is calculated and an animated truck
* marker follows the route with smooth camera tracking (zoom, pitch,
* and center interpolation).
*
* API endpoints used:
* - Routing API: https://www.yellowmap.de/api_rst/v2/geojson/route
* - Autocomplete: SmartMaps Autocomplete JS SDK (v5)
* =====================================================================
*/
// === CONFIGURATION & CONSTANTS =======================================
/** SmartMaps API key for authentication */
const apiKey = '[INSERT API-KEY]';
/** Isochrone reachability time in minutes */
const ISOCHRONE_TIME_MINUTES = 15;
/** Isochrone grid resolution (higher = more detailed polygon) */
const ISOCHRONE_GRID = "100";
/** Number of simulated drivers placed around the destination */
const DRIVER_COUNT = 10;
/** Radius in km within which drivers are randomly distributed */
const DRIVER_SPREAD_RADIUS_KM = 5;
/** Lat/lng offset for the urgent delivery marker from the last waypoint */
const URGENT_DELIVERY_OFFSET = 0.01;
/** Duration of the driver tracking animation in milliseconds */
const TRACKING_ANIMATION_DURATION_MS = 25000;
/** Target zoom level during driver tracking animation */
const TRACKING_TARGET_ZOOM = 15.5;
/** Target pitch (tilt) during driver tracking animation */
const TRACKING_TARGET_PITCH = 55;
/** Smoothing factor for camera interpolation (0-1, higher = smoother) */
const TRACKING_SMOOTHING = 0.98;
/** Approximate km per degree of latitude (1 degree latitude ~ 111.32 km) */
const KM_TO_DEGREES = 111.32;
// === MAP INITIALIZATION ==============================================
/**
* Reads a CSS custom property from the document root.
* @param {string} variable - CSS variable name (e.g. '--brand-primary')
* @returns {string} The computed value of the CSS variable
*/
function getCssVariable(variable) {
return getComputedStyle(document.documentElement).getPropertyValue(variable).trim();
}
const map = new smartmapsgl.Map({
apiKey: apiKey,
container: 'map',
center: { lat: 49.021649, lng: 8.439330 },
zoom: 6,
style: smartmapsgl.MapStyle.ESSENTIAL
});
// === DOM ELEMENTS & STATE ============================================
const controlPanel = document.getElementById('control-panel');
const waypointsContainer = document.getElementById('waypoints-container');
const addWaypointBtn = document.getElementById('add-waypoint-btn');
const calculateBtn = document.getElementById('calculate-btn');
const isochroneBtn = document.getElementById('isochrone-btn');
const matrixBtn = document.getElementById('matrix-btn');
const optimizeCheckbox = document.getElementById('optimize-checkbox');
const transportButtons = document.querySelectorAll('.transport-modes button');
const resetViewBtn = document.getElementById('reset-view-btn');
/** @type {Array<{input: HTMLInputElement, coords: number[]|null, group: HTMLElement}>} */
let waypoints = [];
/** @type {smartmapsgl.Marker[]} */
let mapMarkers = [];
/** Currently selected routing speed profile */
let currentSpeedProfile = 'FAST';
/** Coordinates of simulated driver locations */
let dynamicDriverLocations = [];
/** Coordinates of the urgent delivery marker */
let urgentDeliveryLocation = null;
/** Info about the best (fastest) driver from the matrix result */
let bestDriverInfo = {};
/** requestAnimationFrame ID for the tracking animation */
let animationFrameId;
/** GeoJSON feature of the driver-to-delivery route */
let driverRouteGeoJSON = null;
/** The animated truck marker instance */
let truckMarker = null;
/** Timestamp when the tracking animation started */
let animationStartTime;
// === WAYPOINT MANAGEMENT =============================================
/**
* Initializes autocomplete on a waypoint input field and registers
* it in the waypoints array.
* @param {HTMLInputElement} inputElement - The input element to attach autocomplete to
*/
async function initializeWaypointInput(inputElement) {
const waypoint = { input: inputElement, coords: null, group: inputElement.parentElement };
waypoints.push(waypoint);
const autocomplete = await smartmaps.autocompleteService.createAutocomplete(inputElement, apiKey, {});
autocomplete.addEventListener('selected', (e) => {
waypoint.coords = e.detail.geojson.geometry.coordinates;
checkCalculable();
});
inputElement.addEventListener('input', () => { waypoint.coords = null; checkCalculable(); });
}
/**
* Adds a new intermediate waypoint input field between the first and
* last stops. Attaches autocomplete, a remove button, and re-indexes
* all placeholders.
*/
async function addWaypoint() {
const newGroup = document.createElement('div');
newGroup.className = 'input-group';
newGroup.innerHTML = `<input class="waypoint-input" type="search" placeholder="Zwischenstopp" /><button class="remove-waypoint"><i class="material-icons">close</i></button>`;
const lastInputGroup = waypointsContainer.querySelector('.input-group:last-of-type');
waypointsContainer.insertBefore(newGroup, lastInputGroup);
const newInput = newGroup.querySelector('input');
const newIndex = waypoints.length - 1;
const newWaypoint = { input: newInput, coords: null, group: newGroup };
waypoints.splice(newIndex, 0, newWaypoint);
const autocomplete = await smartmaps.autocompleteService.createAutocomplete(newInput, apiKey, {});
autocomplete.addEventListener('selected', (e) => {
newWaypoint.coords = e.detail.geojson.geometry.coordinates;
checkCalculable();
});
newInput.addEventListener('input', () => { newWaypoint.coords = null; checkCalculable(); });
newGroup.querySelector('.remove-waypoint').addEventListener('click', () => {
waypoints = waypoints.filter(wp => wp !== newWaypoint);
newGroup.remove();
checkCalculable();
updateWaypointPlaceholders();
const activeHeader = document.querySelector('.accordion-header.active');
if (activeHeader) {
const body = activeHeader.nextElementSibling;
body.style.maxHeight = body.scrollHeight + "px";
}
});
updateWaypointPlaceholders();
const plannerHeader = document.getElementById('header-planner');
if(plannerHeader.classList.contains('active')) {
const body = plannerHeader.nextElementSibling;
body.style.maxHeight = body.scrollHeight + "px";
}
}
/**
* Enables or disables the "Calculate" button depending on whether
* all waypoints have resolved coordinates.
*/
function checkCalculable() {
calculateBtn.disabled = !waypoints.every(wp => wp.coords !== null);
}
/**
* Updates placeholder text and draggable state for all waypoint input
* groups. The first is always "Start / Depot", the last is always
* "Zieladresse", and intermediates are numbered "Zwischenstopp N".
*/
function updateWaypointPlaceholders() {
const inputGroups = waypointsContainer.querySelectorAll('.input-group');
inputGroups.forEach((group, index) => {
const input = group.querySelector('input');
if (index === 0) {
input.placeholder = 'Start / Depot';
group.draggable = false;
} else if (index === inputGroups.length - 1) {
input.placeholder = 'Zieladresse';
group.draggable = false;
} else {
input.placeholder = `Zwischenstopp ${index}`;
group.draggable = true;
}
});
}
// === ROUTE CALCULATION ===============================================
/**
* Builds a standard GeoJSON FeatureCollection request body for the
* SmartMaps Routing API.
* @param {string} type - Routing type: "ROUTE", "TRIP", "ISOCHRONE", or "MATRIX"
* @param {Object} extraParams - Additional routing parameters to merge
* @param {Array} features - GeoJSON Feature array for the request
* @returns {Object} Complete request body ready for JSON.stringify
*/
function buildRoutingRequest(type, extraParams, features) {
return {
type: "FeatureCollection",
routingparams: { type, ...extraParams },
authentication: { channel: "roadtrip-demo" },
crs: { type: "name", properties: { name: "urn:ogc:def:crs:OGC:1.3:CRS84" } },
features
};
}
/**
* Returns responsive map padding that accounts for the control panel
* position on mobile (bottom sheet) vs. desktop (left sidebar).
* @returns {{top: number, bottom: number, left: number, right: number}} Padding in pixels
*/
function getMapPadding() {
if (window.innerWidth <= 768) {
const panel = document.getElementById('control-panel');
// Panel height + 20px extra spacing
const panelHeight = panel ? panel.offsetHeight : 300;
return { top: 40, bottom: panelHeight + 20, left: 20, right: 20 };
} else {
// Desktop padding: accounts for the left-side panel
return { top: 40, bottom: 40, left: 460, right: 40 };
}
}
/**
* Calculates a route or optimized trip from the entered waypoints.
* Uses TRIP mode when the "find best order" checkbox is checked,
* otherwise uses standard ROUTE mode. Draws the result on the map
* and displays distance/duration in the results accordion.
*/
async function calculateRoute() {
calculateBtn.disabled = true;
calculateBtn.innerHTML = '<span>Berechne...</span><div class="loader"></div>';
clearMap();
const coords = waypoints.map(wp => wp.coords);
const useTrip = optimizeCheckbox.checked;
const url = `https://www.yellowmap.de/api_rst/v2/geojson/route?apiKey=${smartmapsgl.encodeString(apiKey)}`;
const requestBody = buildRoutingRequest(
useTrip ? "TRIP" : "ROUTE",
{ speedProfile: currentSpeedProfile, routingRoundTrip: false, isoLocale: "de-DE", coordFormatOut: "GEODECIMAL_POINT" },
useTrip
? [{ type: "Feature", geometry: { type: "MultiPoint", coordinates: coords }, properties: {} }]
: coords.map(c => ({ type: "Feature", geometry: { type: "Point", coordinates: c }, properties: {} }))
);
try {
const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody) });
if (!response.ok) throw new Error(`API Error: ${response.status} - ${await response.text()}`);
const data = await response.json();
if (data.features && data.features.length > 0) {
const routeFeature = data.features[0];
const props = data.properties;
document.getElementById('route-summary').innerHTML = `<strong>Distanz:</strong> ${(props.distance / 1000).toFixed(1)} km<br><strong>Dauer:</strong> ca. ${new Date(props.duration * 1000).toISOString().substr(11, 8)}`;
map.addSource('route', { type: 'geojson', data: routeFeature });
map.addLayer({ id: 'route-line', type: 'line', source: 'route', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': getCssVariable('--brand-primary'), 'line-width': 6, 'line-opacity': 0.8 } });
// When using TRIP mode, read back the optimized waypoint order
let stops = coords;
if (useTrip && routeFeature.properties && routeFeature.properties.waypoints) {
stops = routeFeature.properties.waypoints.map(wp => wp.originalRequest.geometry.coordinates);
}
// Place numbered markers at each stop
stops.forEach((coord, index) => {
const el = document.createElement('div');
el.className = 'custom-marker';
el.textContent = index === 0 ? 'A' : (index === stops.length - 1 ? 'B' : index);
mapMarkers.push(new smartmapsgl.Marker({ element: el }).setLngLat(coord).addTo(map));
});
// Fit the map to the route with responsive padding
const bounds = new smartmapsgl.LngLatBounds();
routeFeature.geometry.coordinates.forEach(coord => bounds.extend(coord));
map.fitBounds(bounds, { padding: getMapPadding() });
toggleAccordion(document.getElementById('header-results'), true);
} else { throw new Error("No route found in the API response."); }
} catch (error) {
console.error("Route calculation error:", error);
document.getElementById('route-summary').innerHTML = `<p style="color: var(--error-color);">Route konnte nicht berechnet werden.</p>`;
toggleAccordion(document.getElementById('header-results'), true);
} finally {
calculateBtn.disabled = false;
calculateBtn.innerHTML = '<i class="material-icons">route</i> Route berechnen';
}
}
// === ISOCHRONE ANALYSIS ==============================================
/**
* Calculates and displays an isochrone polygon around the last waypoint.
* The isochrone shows the area reachable within ISOCHRONE_TIME_MINUTES
* using the currently selected speed profile.
*/
isochroneBtn.addEventListener('click', async () => {
const startCoords = waypoints[waypoints.length - 1].coords;
if (!startCoords) return;
isochroneBtn.disabled = true;
isochroneBtn.innerHTML = '<span>Lade...</span><div class="loader"></div>';
const url = `https://www.yellowmap.de/api_rst/v2/geojson/route?apiKey=${smartmapsgl.encodeString(apiKey)}`;
const body = buildRoutingRequest(
"ISOCHRONE",
{ timeInMinutes: ISOCHRONE_TIME_MINUTES, speedProfile: currentSpeedProfile, isochroneGrid: ISOCHRONE_GRID, coordFormatOut: "GEODECIMAL_POINT" },
[{ type: "Feature", geometry: { type: "Point", coordinates: startCoords }, properties: {} }]
);
try {
const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
if (!response.ok) throw new Error(`API Error: ${response.status}`);
const data = await response.json();
if (data.features && data.features.length > 0) {
const isochroneFeature = data.features[0];
// Remove any existing isochrone layers before adding new ones
if (map.getSource('isochrone')) { map.removeLayer('isochrone-outline'); map.removeLayer('isochrone-area'); map.removeSource('isochrone'); }
map.addSource('isochrone', { type: 'geojson', data: isochroneFeature });
// Insert isochrone layers below the route line so the route stays visible
const beforeId = map.getLayer('route-line') ? 'route-line' : undefined;
map.addLayer({ id: 'isochrone-area', type: 'fill', source: 'isochrone', paint: { 'fill-color': getCssVariable('--brand-secondary'), 'fill-opacity': 0.3 } }, beforeId);
map.addLayer({ id: 'isochrone-outline', type: 'line', source: 'isochrone', paint: { 'line-color': '#ffffff', 'line-width': 2, 'line-opacity': 0.9 } }, beforeId);
// Fit the map to the isochrone bounds with responsive padding
const bounds = new smartmapsgl.LngLatBounds();
isochroneFeature.geometry.coordinates[0].forEach(coord => bounds.extend(coord));
map.fitBounds(bounds, { padding: getMapPadding(), duration: 1000 });
toggleAccordion(document.getElementById('header-simulation'), true);
} else { console.error("Isochrone response contains no features."); }
} catch (error) { console.error("Isochrone calculation error:", error);
} finally {
isochroneBtn.disabled = false;
isochroneBtn.innerHTML = '<i class="material-icons">map</i> 15-Min Servicegebiet';
}
});
// === MATRIX / EMERGENCY DISPATCH =====================================
/**
* Simulates an emergency dispatch scenario. Places random driver markers
* and an urgent delivery marker on the map, then uses the Matrix API to
* find the fastest driver. Results are displayed in the tracking accordion.
*/
matrixBtn.addEventListener('click', async () => {
const destination = waypoints[waypoints.length - 1].coords;
if (!destination) return;
// Remove any existing driver/urgent markers
mapMarkers.filter(m => m.getElement().classList.contains('driver-marker') || m.getElement().classList.contains('urgent-marker')).forEach(m => m.remove());
mapMarkers = mapMarkers.filter(m => !m.getElement().classList.contains('driver-marker') && !m.getElement().classList.contains('urgent-marker'));
// Generate random driver positions and the urgent delivery location
dynamicDriverLocations = generateDriverLocations(destination, DRIVER_COUNT, DRIVER_SPREAD_RADIUS_KM);
urgentDeliveryLocation = [destination[0] + URGENT_DELIVERY_OFFSET, destination[1] + URGENT_DELIVERY_OFFSET];
// Add driver markers to the map
dynamicDriverLocations.forEach((coords, i) => {
const el = document.createElement('div');
el.className = 'custom-marker driver-marker';
el.textContent = `F${i+1}`;
mapMarkers.push(new smartmapsgl.Marker({ element: el }).setLngLat(coords).addTo(map));
});
// Add urgent delivery marker with pulsing animation
const el = document.createElement('div');
el.className = 'custom-marker urgent-marker';
el.textContent = `!`;
mapMarkers.push(new smartmapsgl.Marker({ element: el }).setLngLat(urgentDeliveryLocation).addTo(map));
// Call the Matrix API to find travel times from the urgent location to all drivers
const url = `https://www.yellowmap.de/api_rst/v2/geojson/route?apiKey=${smartmapsgl.encodeString(apiKey)}`;
const body = buildRoutingRequest(
"MATRIX",
{ speedProfile: "FAST" },
[
{ type: "Feature", geometry: { type: "Point", coordinates: urgentDeliveryLocation }, properties: { type: "StartPoint" } },
{ type: "Feature", geometry: { type: "MultiPoint", coordinates: dynamicDriverLocations }, properties: {} }
]
);
const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
const data = await response.json();
const resultContainer = document.getElementById('matrix-result');
if (data.features?.[0]?.properties?.routingDestinations) {
const results = data.features[0].properties.routingDestinations;
if (results.length > 0) {
// Find the driver with the shortest travel time
const best = results.reduce((prev, curr) => prev.timeInSeconds < curr.timeInSeconds ? prev : curr);
const bestDriverIndex = results.indexOf(best);
bestDriverInfo = {
index: bestDriverIndex,
coords: dynamicDriverLocations[bestDriverIndex],
time: Math.round(best.timeInSeconds / 60)
};
resultContainer.innerHTML = `Ergebnis: Fahrer ${bestDriverInfo.index + 1} ist am schnellsten! (ca. ${bestDriverInfo.time} Min.)
<button id="track-driver-btn" class="action-button tertiary-button"><i class="material-icons">track_changes</i> Fahrer verfolgen</button>`;
document.getElementById('track-driver-btn').addEventListener('click', startDriverAnimation);
} else { resultContainer.textContent = 'Matrix calculation: no results.'; }
} else { resultContainer.textContent = 'Matrix calculation failed.'; }
toggleAccordion(document.getElementById('header-tracking'), true);
});
// === DRIVER TRACKING ANIMATION =======================================
/**
* Starts the driver tracking animation. Fetches a route from the best
* driver to the urgent delivery location, hides the control panel, and
* begins the frame-by-frame truck animation.
*/
async function startDriverAnimation() {
const url = `https://www.yellowmap.de/api_rst/v2/geojson/route?apiKey=${smartmapsgl.encodeString(apiKey)}`;
const requestBody = buildRoutingRequest(
"ROUTE",
{ speedProfile: "FAST", routingRoundTrip: false, isoLocale: "de-DE", coordFormatOut: "GEODECIMAL_POINT" },
[
{ type: "Feature", geometry: { type: "Point", coordinates: bestDriverInfo.coords }, properties: {} },
{ type: "Feature", geometry: { type: "Point", coordinates: urgentDeliveryLocation }, properties: {} }
]
);
const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody) });
if (!response.ok) {
console.error("Route calculation error for tracking:", await response.text());
return;
}
const data = await response.json();
if (!data.features || data.features.length === 0) {
console.error("Could not fetch route for driver tracking.");
return;
}
driverRouteGeoJSON = data.features[0];
// Hide the control panel with a slide-out animation
controlPanel.style.opacity = 0;
controlPanel.style.transform = 'translateX(-100%)';
setTimeout(() => controlPanel.style.display = 'none', 500);
resetViewBtn.style.display = 'flex';
// Draw the driver route as a dashed line
clearMap(true);
if (map.getSource('driver-route')) {
map.removeLayer('driver-route-line');
map.removeSource('driver-route');
}
map.addSource('driver-route', { type: 'geojson', data: driverRouteGeoJSON });
map.addLayer({ id: 'driver-route-line', type: 'line', source: 'driver-route', layout: {}, paint: { 'line-color': getCssVariable('--success-color'), 'line-width': 5, 'line-dasharray': [0, 2] } });
// Create the truck marker at the driver's starting position
const truckEl = document.createElement('div');
truckEl.className = 'truck-marker';
truckMarker = new smartmapsgl.Marker({ element: truckEl, anchor: 'center' }).setLngLat(bestDriverInfo.coords).addTo(map);
animateDriver(0);
}
/**
* Animation loop that moves the truck marker along the route coordinates.
* Uses linear interpolation between route points and smoothly transitions
* the camera center, zoom, and pitch toward their target values.
* @param {DOMHighResTimeStamp} timestamp - The current animation frame timestamp
*/
function animateDriver(timestamp) {
if (animationFrameId === null) return;
if (!animationStartTime) animationStartTime = timestamp;
const progress = Math.min((timestamp - animationStartTime) / TRACKING_ANIMATION_DURATION_MS, 1);
const routeCoords = driverRouteGeoJSON.geometry.coordinates;
// Interpolate position along the route polyline
const exactIndex = progress * (routeCoords.length - 1);
const startIndex = Math.floor(exactIndex);
const endIndex = Math.min(startIndex + 1, routeCoords.length - 1);
const segmentProgress = exactIndex - startIndex;
const startPos = routeCoords[startIndex];
const endPos = routeCoords[endIndex];
const currentLng = startPos[0] + (endPos[0] - startPos[0]) * segmentProgress;
const currentLat = startPos[1] + (endPos[1] - startPos[1]) * segmentProgress;
const currentPos = [currentLng, currentLat];
truckMarker.setLngLat(currentPos);
// Smooth camera follow using exponential smoothing
const smoothingWeight = 1 - (1 - TRACKING_SMOOTHING) * 5; // Center follows faster than zoom/pitch
const smoothedCenter = new smartmapsgl.LngLat(
map.getCenter().lng * smoothingWeight + currentPos[0] * (1 - smoothingWeight),
map.getCenter().lat * smoothingWeight + currentPos[1] * (1 - smoothingWeight)
);
map.setCenter(smoothedCenter);
// Gradually transition zoom and pitch toward target values
map.setZoom(map.getZoom() * TRACKING_SMOOTHING + TRACKING_TARGET_ZOOM * (1 - TRACKING_SMOOTHING));
map.setPitch(map.getPitch() * TRACKING_SMOOTHING + TRACKING_TARGET_PITCH * (1 - TRACKING_SMOOTHING));
if (progress < 1) {
animationFrameId = requestAnimationFrame(animateDriver);
} else {
animationStartTime = null;
animationFrameId = null;
}
}
// === UTILITY FUNCTIONS ===============================================
/**
* Generates random driver locations distributed uniformly within a
* circle of the given radius around a center point.
* @param {number[]} center - [longitude, latitude] of the center point
* @param {number} count - Number of driver locations to generate
* @param {number} radius - Spread radius in kilometers
* @returns {number[][]} Array of [longitude, latitude] coordinate pairs
*/
function generateDriverLocations(center, count, radius) {
const [lon, lat] = center;
const locations = [];
const radiusInDegrees = radius / KM_TO_DEGREES;
for (let i = 0; i < count; i++) {
const angle = Math.random() * 2 * Math.PI;
// sqrt ensures uniform distribution within the circle
const distance = Math.sqrt(Math.random()) * radiusInDegrees;
const driverLon = lon + (distance * Math.cos(angle)) / Math.cos(lat * Math.PI / 180);
const driverLat = lat + distance * Math.sin(angle);
locations.push([driverLon, driverLat]);
}
return locations;
}
/**
* Removes all map layers, sources, and markers. Cancels any running
* animation. When softClear is true, only the driver tracking layer
* and truck marker are removed (route + isochrone layers are preserved).
* @param {boolean} [softClear=false] - If true, preserve route/isochrone layers
*/
function clearMap(softClear = false) {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
animationFrameId = null;
animationStartTime = null;
}
if (truckMarker) truckMarker.remove();
if (map.getSource('driver-route')) { map.removeLayer('driver-route-line'); map.removeSource('driver-route'); }
if (!softClear) {
if (map.getSource('route')) { map.removeLayer('route-line'); map.removeSource('route'); }
if (map.getSource('isochrone')) { map.removeLayer('isochrone-outline'); map.removeLayer('isochrone-area'); map.removeSource('isochrone'); }
mapMarkers.forEach(marker => marker.remove());
mapMarkers = [];
resetAccordions();
}
}
/**
* Resets the entire view: clears the map, restores the control panel,
* hides the reset button, and flies back to the default map position.
*/
function resetView() {
clearMap();
controlPanel.style.display = 'flex';
setTimeout(() => {
controlPanel.style.opacity = 1;
controlPanel.style.transform = 'translateX(0)';
}, 10);
resetViewBtn.style.display = 'none';
map.flyTo({ center: { lat: 49.021649, lng: 8.439330 }, zoom: 6, pitch: 0, bearing: 0 });
}
resetViewBtn.addEventListener('click', resetView);
// === ACCORDION UI LOGIC ==============================================
/**
* Toggles an accordion section open or closed. Closes all other open
* sections first (single-open behavior). When forceOpen is true, the
* section is enabled (if disabled) and opened regardless of current state.
* @param {HTMLElement} header - The accordion header button element
* @param {boolean} [forceOpen=false] - Force-open the section and enable it if disabled
*/
function toggleAccordion(header, forceOpen = false) {
if (header.disabled && !forceOpen) return;
if (header.disabled) {
header.disabled = false;
}
const body = header.nextElementSibling;
const isActive = header.classList.contains('active');
// Close all other open accordions before opening a new one
if (!isActive || forceOpen) {
document.querySelectorAll('.accordion-header.active').forEach(activeHeader => {
if (activeHeader !== header) {
activeHeader.classList.remove('active');
activeHeader.nextElementSibling.style.maxHeight = null;
}
});
}
if (!isActive || forceOpen) {
header.classList.add('active');
body.style.maxHeight = body.scrollHeight + "px";
} else {
header.classList.remove('active');
body.style.maxHeight = null;
}
}
/**
* Resets all accordion sections to their initial state: closes everything,
* disables sections 2-4, and opens section 1 (Tour Planning).
*/
function resetAccordions() {
document.querySelectorAll('.accordion-header').forEach((header, index) => {
header.classList.remove('active');
header.nextElementSibling.style.maxHeight = null;
if (index > 0) {
header.disabled = true;
}
});
toggleAccordion(document.getElementById('header-planner'), true);
}
document.querySelectorAll('.accordion-header').forEach(header => {
header.addEventListener('click', () => toggleAccordion(header));
});
// === DRAG & DROP =====================================================
/** @type {HTMLElement|null} The DOM element currently being dragged */
let draggedItem = null;
/**
* Handles dragstart on waypoint input groups. Only intermediate stops
* (draggable elements) can be dragged.
*/
waypointsContainer.addEventListener('dragstart', (e) => {
if (e.target.classList.contains('input-group') && e.target.draggable) {
draggedItem = e.target;
setTimeout(() => e.target.classList.add('dragging'), 0);
}
});
/** Handles dragend: removes the dragging visual state. */
waypointsContainer.addEventListener('dragend', () => {
if (draggedItem) {
draggedItem.classList.remove('dragging');
draggedItem = null;
}
});
/**
* Handles dragover: repositions the dragged element in the DOM based
* on the cursor's vertical position. Prevents dragging above the first
* waypoint (start) or below the last (destination).
*/
waypointsContainer.addEventListener('dragover', (e) => {
e.preventDefault();
const draggedEl = document.querySelector('.dragging');
if (!draggedEl) return;
const afterElement = getDragAfterElement(waypointsContainer, e.clientY);
if (afterElement === undefined) {
waypointsContainer.insertBefore(draggedEl, waypointsContainer.lastElementChild);
} else {
// Prevent dropping before the first element (start waypoint)
if (afterElement === waypointsContainer.firstElementChild) {
return;
}
waypointsContainer.insertBefore(draggedEl, afterElement);
}
});
/**
* Handles drop: synchronizes the waypoints array order with the new
* DOM order after a drag-and-drop reorder operation.
*/
waypointsContainer.addEventListener('drop', (e) => {
e.preventDefault();
if (draggedItem) {
const newOrderWaypoints = [];
const domInputGroups = Array.from(waypointsContainer.querySelectorAll('.input-group'));
domInputGroups.forEach(group => {
const waypointObject = waypoints.find(wp => wp.group === group);
if (waypointObject) {
newOrderWaypoints.push(waypointObject);
}
});
waypoints = newOrderWaypoints;
updateWaypointPlaceholders();
checkCalculable();
}
});
/**
* Determines which element the dragged item should be inserted before,
* based on the vertical cursor position relative to each child's midpoint.
* @param {HTMLElement} container - The waypoints container
* @param {number} y - The current cursor Y position
* @returns {HTMLElement|undefined} The element to insert before, or undefined for end
*/
function getDragAfterElement(container, y) {
const draggableElements = [...container.querySelectorAll('.input-group:not(.dragging)')];
return draggableElements.reduce((closest, child) => {
const box = child.getBoundingClientRect();
const offset = y - box.top - box.height / 2;
if (offset < 0 && offset > closest.offset) {
return { offset: offset, element: child };
} else {
return closest;
}
}, { offset: Number.NEGATIVE_INFINITY }).element;
}
// === INITIALIZATION ==================================================
// Initialize autocomplete on all existing waypoint inputs
document.querySelectorAll('.waypoint-input').forEach(input => initializeWaypointInput(input));
// Wire up button event listeners
addWaypointBtn.addEventListener('click', addWaypoint);
calculateBtn.addEventListener('click', calculateRoute);
// Transport mode toggle buttons
transportButtons.forEach(button => {
button.addEventListener('click', () => {
transportButtons.forEach(btn => btn.classList.remove('active'));
button.classList.add('active');
currentSpeedProfile = button.dataset.profile;
});
});
// Toggle the "optimize" checkbox when clicking anywhere on the options row
document.querySelector('.options').addEventListener('click', (e) => {
if (e.target.id !== 'optimize-checkbox') {
optimizeCheckbox.checked = !optimizeCheckbox.checked;
}
});
// Set initial accordion and placeholder state
resetAccordions();
updateWaypointPlaceholders();
</script>
</body>
</html>