Route Optimization
Advanced
SmartMaps GL
Routing
Isochrone
Matrix
Autocomplete
Drag & Drop
This use case demonstrates a logistics route optimization tool built with SmartMaps. It features multi-stop route planning with drag-and-drop waypoint reordering, isochrone analysis for visualizing 15-minute service areas, an emergency simulation that uses a distance matrix to find the nearest available driver, and driver tracking animation along the calculated route. Address input is powered by the SmartMaps Autocomplete API with real-time suggestions.
How it works
The demo walks the user through a 4-step logistics workflow, with each step unlocking the next accordion section:
- Tour Planning -- The user enters addresses via the Autocomplete API, optionally adds intermediate stops (drag-and-drop reordering), selects a transport mode, and calculates the route. When "find best order" is checked, the Trip optimizer determines the most efficient waypoint sequence.
- Results & Analysis -- The route is drawn on the map with distance/duration summary. An isochrone analysis visualizes the 15-minute reachable service area around the destination.
- Emergency Simulation -- A simulated urgent delivery appears near the destination. Random driver positions are generated, and the Matrix API determines which driver can reach the pickup fastest.
- Driver Tracking -- The nearest driver's route is calculated and an animated truck marker follows the path with smooth camera tracking (zoom, pitch, and center interpolation).
Features & APIs
- SmartMaps GL Map -- Interactive map rendering with custom markers, route layers, and 3D pitch/bearing support
- Routing API (Route) -- Point-to-point route calculation with support for car, bicycle, and pedestrian speed profiles
- Routing API (Trip) -- Optimized multi-stop tour planning that finds the best waypoint order
- Routing API (Isochrone) -- 15-minute reachability polygons to visualize service coverage areas
- Routing API (Matrix) -- Distance matrix computation to determine the nearest driver for emergency dispatch
- Autocomplete API -- Real-time address search with typeahead suggestions for all waypoint inputs
- Driver Animation -- Simulated live tracking that animates a truck icon along the route with smooth camera follow
- Drag-and-Drop Waypoints -- Interactive reordering of intermediate stops in the planning panel
API endpoints used
| Endpoint | Purpose |
|---|---|
POST /api_rst/v2/geojson/route (type ROUTE) |
Point-to-point routing between waypoints |
POST /api_rst/v2/geojson/route (type TRIP) |
Multi-stop tour optimization (best order) |
POST /api_rst/v2/geojson/route (type ISOCHRONE) |
Reachability polygon from a single point |
POST /api_rst/v2/geojson/route (type MATRIX) |
Travel-time matrix between one origin and multiple destinations |
| SmartMaps Autocomplete JS SDK (v5) | Address search with typeahead suggestions |
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>