Alpentour
Fortgeschritten
SmartMaps GL
3D Terrain
Weather
Elevation
Camera Animation
Chart.js
Dieser Anwendungsfall zeigt eine interaktive, animierte 3D-Tour zum Jungfraujoch -- dem „Top of Europe" -- und kombiniert mehrere SmartMaps-APIs zu einem einzigen immersiven Erlebnis. Die Demo führt Nutzer entlang einer mehrteiligen Route durch die Schweizer Alpen mit Bus, Gondel und Bergbahn, während gleichzeitig aktuelle Wetterdaten, ein dynamisches Höhenprofil und flüssige Kameraanimationen über einem satellitengestützten 3D-Terrain angezeigt werden.
Funktionen & APIs
- SmartMaps GL JS mit 3D-Terrain und Satellitenbildern für eine realistische alpine Visualisierung
- Weather API zum Abrufen aktueller Wetterbedingungen (Temperatur und Wetter-Icons) an zentralen Wegpunkten
- Elevation API zum Erzeugen eines interaktiven Höhenprofil-Diagramms entlang der gesamten Route
- Animiertes Verfolgen der Route mit flüssiger Interpolation der Kamera-Keyframes (Pitch, Bearing, Zoom)
- Mehrere Verkehrsmittel -- Bus, Gondel (Eiger Express) und Bergbahn (Jungfraubahn) -- jeweils mit eigenem Fahrzeug-Marker
- Interaktives Höhendiagramm (Chart.js) mit synchronisiertem Fortschrittsmarker und Hover-zum-Lokalisieren-Funktion
- Kapitelbasierte Erzählung, die das Infopanel bei jeder Etappe der Reise aktualisiert
Wie es funktioniert
Die Demo ist eine einzelne, in sich geschlossene HTML-Datei, die mehrere Systeme parallel orchestriert:
- Datenladen -- Beim Laden der Karte werden drei GeoJSON-Routendateien (Bus, Gondel, Bergbahn) abgerufen, geparst und zu einem einzigen
LineString-Feature zusammengeführt. - Wetter & Höhe -- Die Weather API wird für jeden Kapitel-Wegpunkt aufgerufen, um aktuelle Temperatur und Wettercodes zu erhalten. Ein Höhenprofil (vorab abgerufenes JSON von der Elevation API) wird geladen und als Chart.js-Liniendiagramm gerendert.
- Animations-Engine -- Eine
requestAnimationFrame-Schleife steuert die Tour. Jeder Frame berechnet einen zeitbasierten Fortschrittswert, bildet ihn auf eine Position entlang der Route ab, interpoliert die Kamera-Keyframes und aktualisiert jedes visuelle Element (Spur, Fahrzeug-Marker, Diagramm-Marker, Erzählung). - Kamera-Interpolation -- Ein Keyframe-Array definiert Pitch, Bearing und Zoom zu bestimmten Momenten. Die Kamera folgt einem vorausschauenden Punkt sanft mittels exponentieller Glättung und erzeugt so einen filmreifen Durchflugeffekt.
- Kapitel-Auslöser -- Wenn die Animation einen Kapitel-Wegpunkt erreicht, blendet der Erzähltext zur nächsten Beschreibung über und das entsprechende Wetter-Pop-up erscheint auf der Karte.
Code
/*
* ==========================================================================
* SmartMaps Alpine Tour Demo — Interactive 3D Journey to the Jungfraujoch
* ==========================================================================
*
* PURPOSE:
* Animated fly-through of a multi-segment alpine route (bus, gondola,
* mountain railway) rendered on a 3D satellite map with live weather
* data and a synchronized elevation profile chart.
*
* APIs USED:
* - SmartMaps GL JS v2 — 3D map with satellite imagery and terrain
* - SmartMaps Weather API — live temperature and weather codes per waypoint
* - SmartMaps Elevation API — elevation profile (pre-fetched JSON)
* - Chart.js 4.4.4 — interactive elevation chart
*
* ARCHITECTURE / SECTIONS:
* 1. Configuration & Named Constants
* 2. DOM Element References
* 3. Application State
* 4. Map Initialization & Camera Keyframes
* 5. Utility Functions (CSS vars, Haversine distance)
* 6. Weather API Integration
* 7. Elevation Profile & Chart
* 8. Animation Engine
* - getRoutePosition() — position along the route for a given progress
* - interpolateCamera() — keyframe-based camera parameter interpolation
* - updateCameraPosition() — smooth (lerp-based) camera follow
* - updateTrailLine() — animated trail drawn behind the vehicle
* - updateVehicleMarker() — move marker and swap vehicle icon
* - updateChartMarker() — sync the chart progress indicator
* - checkChapterTriggers() — trigger narration text and weather popups
* - onTourComplete() — end-of-tour fly-out and UI reset
* - animate() — main requestAnimationFrame loop
* 9. Tour Controls (start, play/pause, reset, restart)
* 10. Initialization & Data Loading
* ==========================================================================
*/
// =========================================================================
// 1. CONFIGURATION & NAMED CONSTANTS
// =========================================================================
/** @const {string} SmartMaps API key used for map, weather, and elevation services */
const apiKey = '[INSERT API-KEY]';
/** @const {number} Total animation duration in milliseconds */
const ANIMATION_DURATION_MS = 50000;
/** @const {number} Smoothing weight for the previous camera position (inertia) */
const CAMERA_SMOOTHING_PREVIOUS = 0.95;
/** @const {number} Smoothing weight for the target camera position (responsiveness) */
const CAMERA_SMOOTHING_TARGET = 0.05;
/** @const {number} Number of route points to look ahead for the camera target */
const CAMERA_LOOK_AHEAD_POINTS = 30;
/** @const {number} Duration in ms for the fly-in camera move when the tour starts */
const FLY_IN_DURATION_MS = 2500;
/** @const {number} Duration in ms for the final fly-out camera move when the tour ends */
const FLY_OUT_DURATION_MS = 5000;
/** @const {number} Fraction of total time allocated to the bus segment (0..1) */
const BUS_TIME_FRACTION = 0.50;
/** @const {number} Fraction of total time at which the gondola segment ends (0..1) */
const GONDOLA_TIME_FRACTION = 0.75;
/** @const {number} Duration in ms for the narration text fade-out transition */
const NARRATION_FADE_DURATION_MS = 400;
/** @const {number} Duration in ms for the weather popup pulse animation */
const WEATHER_PULSE_DURATION_MS = 500;
/** @const {number} Delay in ms before showing UI elements after restart */
const RESTART_DELAY_MS = 600;
/** @const {number} Bottom padding applied to the map during the fly-in */
const FLY_IN_BOTTOM_PADDING = 220;
/**
* @const {Object.<number, string>} Maps WMO weather codes to icon file names
* used to display weather condition icons from the SmartMaps CDN.
*/
const weatherIconMapping = { 0: 'ic_day_sunny', 1: 'ic_day_sunny', 2: 'ic_day_partlycloudy', 3: 'ic_day_cloudy', 45: 'ic_day_fog', 48: 'ic_day_fog', 51: 'ic_day_sprinkle', 53: 'ic_day_sprinkle', 55: 'ic_day_sprinkle', 80: 'ic_day_showers', 81: 'ic_day_showers', 82: 'ic_day_showers', 61: 'ic_day_rain', 63: 'ic_day_rain', 65: 'ic_day_rain', 56: 'ic_day_rain_mix', 57: 'ic_day_rain_mix', 66: 'ic_day_rain_mix', 67: 'ic_day_rain_mix', 71: 'ic_day_snow', 73: 'ic_day_snow', 75: 'ic_day_snow', 77: 'ic_day_snowflake_cold', 85: 'ic_day_snow', 86: 'ic_day_snow', 95: 'ic_day_thunderstorm', 96: 'ic_day_storm_showers', 99: 'ic_day_storm_showers' };
// =========================================================================
// 2. DOM ELEMENT REFERENCES
// =========================================================================
const actionButton = document.getElementById('action-button');
const controlsContainer = document.getElementById('controls');
const playPauseButton = document.getElementById('play-pause-button');
const restartButton = document.getElementById('restart-button');
const chartContainer = document.getElementById('elevation-chart-container');
// =========================================================================
// 3. APPLICATION STATE
// =========================================================================
let tourData, chapters, segmentLengths, totalTourDistance = 0;
let animationFrameId, animationStatus = 'stopped';
let animationStartTime, timeElapsed = 0;
let weatherMarkers = [];
let chart;
let elevationData = { elevations: [], points: [], distances: [] };
let vehicleMarker = null;
let currentVehicleType = '';
// =========================================================================
// 4. MAP INITIALIZATION & CAMERA KEYFRAMES
// =========================================================================
/** @type {smartmapsgl.Map} The main map instance with 3D satellite terrain */
const map = new smartmapsgl.Map({
container: 'map', apiKey: apiKey, style: smartmapsgl.MapStyle.SATELLITE,
center: [7.8321, 46.6308], zoom: 11.5, pitch: 60, bearing: 155,
terrain: { exaggeration: 1, activated: true }
});
/**
* Camera keyframes define pitch, bearing, and zoom at specific progress
* points along the animation timeline. Values are linearly interpolated
* between adjacent keyframes during playback.
* @const {Array.<{progress: number, pitch: number, bearing: number, zoom: number}>}
*/
const cameraKeyframes = [
// Bus segment
{ progress: 0.0, pitch: 50, bearing: 90, zoom: 13.5 },
{ progress: 0.25, pitch: 45, bearing: 140, zoom: 13.5 },
{ progress: 0.49, pitch: 35, bearing: 180, zoom: 13.5 },
// Gondola segment — side panorama perspective
{ progress: 0.52, pitch: 45, bearing: 130, zoom: 11.8 },
{ progress: 0.65, pitch: 55, bearing: 110, zoom: 11.8 },
// Smooth transition to mountain railway
{ progress: 0.74, pitch: 58, bearing: 140, zoom: 12.5 },
{ progress: 0.76, pitch: 60, bearing: 180, zoom: 12.5 },
// Mountain railway segment
{ progress: 0.90, pitch: 65, bearing: 180, zoom: 12.5 },
{ progress: 1.0, pitch: 65, bearing: -45, zoom: 12.5 }
];
// =========================================================================
// 5. UTILITY FUNCTIONS
// =========================================================================
/**
* Reads a CSS custom property value from the document root element.
* @param {string} variable - The CSS variable name (e.g. '--brand-primary')
* @returns {string} The trimmed computed value
*/
function getCssVariable(variable) {
return getComputedStyle(document.documentElement).getPropertyValue(variable).trim();
}
/**
* Calculates the Haversine distance between two geographic points.
* @param {number} lat1 - Latitude of the first point in degrees
* @param {number} lon1 - Longitude of the first point in degrees
* @param {number} lat2 - Latitude of the second point in degrees
* @param {number} lon2 - Longitude of the second point in degrees
* @returns {number} Distance in meters
*/
function getDistance(lat1, lon1, lat2, lon2) {
const R = 6371e3;
const phi1 = (lat1 * Math.PI) / 180;
const phi2 = (lat2 * Math.PI) / 180;
const deltaPhi = ((lat2 - lat1) * Math.PI) / 180;
const deltaLambda = ((lon2 - lon1) * Math.PI) / 180;
const a = Math.sin(deltaPhi / 2) * Math.sin(deltaPhi / 2) +
Math.cos(phi1) * Math.cos(phi2) *
Math.sin(deltaLambda / 2) * Math.sin(deltaLambda / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
/**
* Computes the total path distance for an array of [lng, lat] coordinates.
* @param {Array.<Array.<number>>} coordinates - Array of [longitude, latitude] pairs
* @returns {number} Total distance in meters
*/
function calculatePathDistance(coordinates) {
let distance = 0;
for (let i = 1; i < coordinates.length; i++) {
distance += getDistance(coordinates[i - 1][1], coordinates[i - 1][0], coordinates[i][1], coordinates[i][0]);
}
return distance;
}
/**
* Linearly interpolates between two numeric values.
* @param {number} start - Start value
* @param {number} end - End value
* @param {number} amt - Interpolation amount (0 = start, 1 = end)
* @returns {number} Interpolated value
*/
function lerp(start, end, amt) {
return (1 - amt) * start + amt * end;
}
// =========================================================================
// 6. WEATHER API INTEGRATION
// =========================================================================
/**
* Fetches current weather data for a geographic point from the SmartMaps Weather API.
* @param {number} lng - Longitude of the point
* @param {number} lat - Latitude of the point
* @returns {Promise<{temp: number, iconCode: number}>} Temperature in Celsius and WMO weather code
* @throws {Error} When no weather data is found in the API response
*/
async function getWeatherData(lng, lat) {
const url = `https://weather.smartmaps.cloud/api/v2/weather/point?Longitude=${lng}&Latitude=${lat}&ApiKey=${smartmapsgl.encodeString(apiKey)}`;
const response = await fetch(url);
const data = await response.json();
if (data.features && data.features.length > 0) {
return {
temp: data.features[0].properties.currently.temperature2Meters,
iconCode: data.features[0].properties.currently.weatherCode
};
}
throw new Error('No weather data found in the "features" array.');
}
// =========================================================================
// 7. ELEVATION PROFILE & CHART
// =========================================================================
/**
* Loads the pre-fetched elevation profile from a local JSON file and
* initializes the Chart.js elevation chart.
* @returns {Promise<void>}
*/
async function getElevationProfile() {
try {
const response = await fetch('elevation_profile.json');
if (!response.ok) {
throw new Error(`Failed to load local elevation data: ${response.statusText}`);
}
const data = await response.json();
if (!data || !data.features) throw new Error("Invalid data structure in elevation_profile.json.");
elevationData.elevations = data.features.map(feature => feature.properties.elevation);
const uniquePoints = data.features.map(feature => ({
latitude: feature.geometry.coordinates[1],
longitude: feature.geometry.coordinates[0]
}));
elevationData.points = uniquePoints;
const distances = [0];
for (let i = 1; i < uniquePoints.length; i++) {
distances.push(distances[i - 1] + getDistance(uniquePoints[i - 1].latitude, uniquePoints[i - 1].longitude, uniquePoints[i].latitude, uniquePoints[i].longitude));
}
elevationData.distances = distances;
createElevationChart(distances, elevationData.elevations, uniquePoints);
} catch (error) {
console.error("Error processing local elevation data:", error);
}
}
/**
* Creates or re-creates the Chart.js elevation profile chart.
* @param {number[]} distances - Cumulative distances in meters for each point
* @param {number[]} elevations - Elevation values in meters
* @param {Array.<{latitude: number, longitude: number}>} points - Geographic coordinates for hover interaction
*/
function createElevationChart(distances, elevations, points) {
const ctx = document.getElementById('elevation-chart').getContext('2d');
if (chart) chart.destroy();
const gradient = ctx.createLinearGradient(0, 0, 0, 180);
gradient.addColorStop(0, 'rgba(246, 184, 12, 0.5)');
gradient.addColorStop(1, 'rgba(246, 184, 12, 0)');
chart = new Chart(ctx, {
type: 'line',
data: {
labels: distances.map(d => (d / 1000).toFixed(2)),
datasets: [{
label: 'Höhe',
data: elevations,
borderColor: getCssVariable('--brand-accent'),
backgroundColor: gradient,
fill: true,
pointRadius: 0,
tension: 0.2
}]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false }, tooltip: { callbacks: { title: (ctx) => `Distanz: ${ctx[0].label} km`, label: (ctx) => `Höhe: ${ctx.parsed.y.toFixed(0)} m` } } },
scales: {
x: {
title: { display: true, text: 'Distanz (km)' },
ticks: {
callback: function (value, index, values) {
const km = parseFloat(this.getLabelForValue(value));
if (km % 5 === 0) return km;
if (index === values.length - 1) return Math.round(km);
return null;
}, autoSkip: false, maxRotation: 0
}
},
y: { title: { display: true, text: 'Höhe (m)' } }
},
onHover: (event, el) => { if (el.length) addHoverMarkerToMap(points[el[0].index].latitude, points[el[0].index].longitude); }
}
});
}
/**
* Adds or updates a red circle marker on the map at the given coordinates,
* used when hovering over the elevation chart.
* @param {number} lat - Latitude of the point to highlight
* @param {number} lng - Longitude of the point to highlight
*/
function addHoverMarkerToMap(lat, lng) {
if (map.getLayer('hovered-point')) {
map.getSource('hovered-point').setData({ type: 'Feature', geometry: { type: 'Point', coordinates: [lng, lat] } });
} else {
map.addLayer({ id: 'hovered-point', type: 'circle', source: { type: 'geojson', data: { type: 'Feature', geometry: { type: 'Point', coordinates: [lng, lat] } } }, paint: { 'circle-radius': 6, 'circle-color': '#e74c3c', 'circle-stroke-width': 2, 'circle-stroke-color': '#ffffff' } });
}
}
// =========================================================================
// 8. ANIMATION ENGINE
// =========================================================================
/**
* Maps linear time-based progress to path-based progress, distributing
* animation time unevenly across the three route segments so that each
* transport mode gets adequate screen time regardless of path length.
*
* @param {number} progress - Linear time progress (0..1)
* @returns {number} Adjusted path progress (0..1) mapped to route coordinates
*/
function getAdjustedProgress(progress) {
const totalPoints = tourData.geometry.coordinates.length - 1;
const busPathEnd = (segmentLengths.bus - 1) / totalPoints;
const gondolaPathEnd = (segmentLengths.bus + segmentLengths.gondel - 1) / totalPoints;
if (progress < BUS_TIME_FRACTION) {
const timeInSegment = progress / BUS_TIME_FRACTION;
return timeInSegment * busPathEnd;
} else if (progress < GONDOLA_TIME_FRACTION) {
const timeInSegment = (progress - BUS_TIME_FRACTION) / (GONDOLA_TIME_FRACTION - BUS_TIME_FRACTION);
return busPathEnd + timeInSegment * (gondolaPathEnd - busPathEnd);
} else {
const timeInSegment = (progress - GONDOLA_TIME_FRACTION) / (1 - GONDOLA_TIME_FRACTION);
return gondolaPathEnd + timeInSegment * (1 - gondolaPathEnd);
}
}
/**
* Calculates the interpolated geographic position along the route for
* a given adjusted progress value.
*
* @param {number} adjustedProgress - Path progress value (0..1)
* @param {number} totalPoints - Total number of route points minus one
* @returns {{currentPos: number[], currentPointIndex: number, startSegment: number[], endSegment: number[]}|null}
* The interpolated position, index, and bracketing segment points, or null if coordinates are invalid
*/
function getRoutePosition(adjustedProgress, totalPoints) {
const currentPointIndex = Math.floor(adjustedProgress * totalPoints);
const startSegment = tourData.geometry.coordinates[currentPointIndex];
const endSegment = tourData.geometry.coordinates[Math.min(currentPointIndex + 1, totalPoints)];
if (!startSegment || !endSegment) return null;
const segmentProgress = adjustedProgress * totalPoints - currentPointIndex;
const currentPos = [
startSegment[0] + (endSegment[0] - startSegment[0]) * segmentProgress,
startSegment[1] + (endSegment[1] - startSegment[1]) * segmentProgress
];
return { currentPos, currentPointIndex, startSegment, endSegment };
}
/**
* Interpolates camera parameters (pitch, bearing, zoom) from the keyframe
* array based on the current animation progress.
*
* Bearing values are adjusted for shortest-path rotation to avoid spinning
* the camera the long way around (e.g. 350 -> 10 degrees).
*
* @param {number} progress - Linear time progress (0..1)
* @returns {{pitch: number, bearing: number, zoom: number}} Interpolated camera parameters
*/
function interpolateCamera(progress) {
let currentKeyframe = cameraKeyframes[0];
let nextKeyframe = cameraKeyframes[0];
for (let i = 0; i < cameraKeyframes.length; i++) {
if (cameraKeyframes[i].progress <= progress) {
currentKeyframe = cameraKeyframes[i];
nextKeyframe = cameraKeyframes[i + 1] || cameraKeyframes[i];
} else {
break;
}
}
const keyframeSegmentProgress = (progress - currentKeyframe.progress) / (nextKeyframe.progress - currentKeyframe.progress || 1);
const interpolatedPitch = lerp(currentKeyframe.pitch, nextKeyframe.pitch, keyframeSegmentProgress);
const interpolatedZoom = lerp(currentKeyframe.zoom, nextKeyframe.zoom, keyframeSegmentProgress);
// Adjust bearing for shortest-path rotation
let startBearing = currentKeyframe.bearing;
let endBearing = nextKeyframe.bearing;
const diff = endBearing - startBearing;
if (diff > 180) { endBearing -= 360; }
else if (diff < -180) { endBearing += 360; }
const interpolatedBearing = lerp(startBearing, endBearing, keyframeSegmentProgress);
return { pitch: interpolatedPitch, bearing: interpolatedBearing, zoom: interpolatedZoom };
}
/**
* Applies interpolated camera parameters and smoothly follows a target
* position using exponential smoothing (lerp between current and target).
*
* @param {number[]} cameraTargetPos - [lng, lat] target for the camera center
* @param {{pitch: number, bearing: number, zoom: number}} cameraParams - Interpolated camera values
*/
function updateCameraPosition(cameraTargetPos, cameraParams) {
map.setPitch(cameraParams.pitch);
map.setZoom(cameraParams.zoom);
map.setBearing(cameraParams.bearing);
const smoothedCameraCenter = new smartmapsgl.LngLat(
map.getCenter().lng * CAMERA_SMOOTHING_PREVIOUS + cameraTargetPos[0] * CAMERA_SMOOTHING_TARGET,
map.getCenter().lat * CAMERA_SMOOTHING_PREVIOUS + cameraTargetPos[1] * CAMERA_SMOOTHING_TARGET
);
map.setCenter(smoothedCameraCenter);
}
/**
* Updates the animated trail line source to show the route traveled so far.
*
* @param {number} pointIndex - Current index along the route coordinates array
*/
function updateTrailLine(pointIndex) {
map.getSource('animated-trail').setData({
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: tourData.geometry.coordinates.slice(0, pointIndex + 1)
}
});
}
/**
* Moves the vehicle marker to the current position and swaps its icon
* CSS class when the transport mode changes (bus, gondola, or train).
*
* @param {number[]} position - [lng, lat] current position on the route
* @param {number} pointIndex - Current index along the route coordinates
*/
function updateVehicleMarker(position, pointIndex) {
if (!vehicleMarker) return;
vehicleMarker.setLngLat(position);
let newVehicleType;
if (pointIndex < segmentLengths.bus) newVehicleType = 'bus';
else if (pointIndex < segmentLengths.bus + segmentLengths.gondel) newVehicleType = 'gondola';
else newVehicleType = 'train';
if (newVehicleType !== currentVehicleType) {
vehicleMarker.getElement().className = 'vehicle-marker ' + newVehicleType + '-icon';
currentVehicleType = newVehicleType;
}
}
/**
* Synchronizes the chart progress marker (vertical line and dot) with
* the current position along the route.
*
* @param {number} pointIndex - Current index along the route coordinates
*/
function updateChartMarker(pointIndex) {
if (!chart || elevationData.elevations.length === 0 || !chart.chartArea) return;
const chartMarker = document.getElementById('chart-marker');
const chartMarkerDot = document.getElementById('chart-marker-dot');
const traveledDistance = calculatePathDistance(tourData.geometry.coordinates.slice(0, pointIndex + 1));
const chartProgress = totalTourDistance > 0 ? traveledDistance / totalTourDistance : 0;
chartMarker.style.left = `${chart.chartArea.left + chartProgress * chart.chartArea.width}px`;
let elevationIndex = elevationData.distances.findIndex(d => d >= traveledDistance);
if (elevationIndex === -1) elevationIndex = elevationData.distances.length - 1;
chartMarkerDot.style.top = `${chart.scales.y.getPixelForValue(elevationData.elevations[elevationIndex])}px`;
chartMarker.style.opacity = '1';
}
/**
* Checks whether the animation has reached any chapter trigger point and,
* if so, fades in the corresponding narration text and weather popup marker.
*
* @param {number} pointIndex - Current index along the route coordinates
*/
function checkChapterTriggers(pointIndex) {
chapters.forEach((chapter) => {
if (pointIndex >= chapter.index && !chapter.isShown) {
// Fade out current narration text, then replace it
const p = document.getElementById('info-panel').querySelector('p');
p.classList.add('fade-out');
setTimeout(() => { p.innerText = chapter.text; p.classList.remove('fade-out'); }, NARRATION_FADE_DURATION_MS);
// Show the weather popup marker for this chapter
const markerData = weatherMarkers.find(m => m.name === chapter.name);
if (markerData) {
markerData.marker.addTo(map);
const markerEl = markerData.marker.getElement();
setTimeout(() => {
markerEl.classList.add('visible', 'pulse');
setTimeout(() => markerEl.classList.remove('pulse'), WEATHER_PULSE_DURATION_MS);
}, 10);
}
chapter.isShown = true;
}
});
}
/**
* Handles the end of the animation: stops playback, triggers a final
* fly-out camera move, hides controls, and resets the action button
* so the user can restart the tour.
*/
function onTourComplete() {
animationStatus = 'stopped';
map.flyTo({ center: [7.98, 46.58], zoom: 11.5, pitch: 65, bearing: -45, duration: FLY_OUT_DURATION_MS });
controlsContainer.classList.remove('visible');
chartContainer.classList.remove('visible');
if (vehicleMarker) vehicleMarker.remove(); vehicleMarker = null;
actionButton.style.display = 'block';
actionButton.disabled = false;
actionButton.innerText = 'Tour erneut starten';
}
/**
* Main animation loop driven by requestAnimationFrame. On each frame it:
* 1. Computes linear and adjusted progress
* 2. Determines the interpolated route position
* 3. Interpolates and applies camera parameters
* 4. Updates the trail, vehicle marker, chart marker, and chapter triggers
* 5. Requests the next frame or completes the tour
*
* @param {DOMHighResTimeStamp} now - Timestamp provided by requestAnimationFrame
*/
function animate(now) {
if (animationStatus !== 'playing') return;
if (!animationStartTime) animationStartTime = now;
timeElapsed = now - animationStartTime;
const progress = Math.min(timeElapsed / ANIMATION_DURATION_MS, 1);
// Calculate position along the route
const adjustedProgress = getAdjustedProgress(progress);
const totalPoints = tourData.geometry.coordinates.length - 1;
const routePos = getRoutePosition(adjustedProgress, totalPoints);
if (!routePos) {
if (progress < 1) animationFrameId = requestAnimationFrame(animate);
return;
}
const { currentPos, currentPointIndex } = routePos;
// Determine the camera look-ahead target position
const lookAheadIndex = Math.min(currentPointIndex + CAMERA_LOOK_AHEAD_POINTS, totalPoints);
const cameraTargetPos = tourData.geometry.coordinates[lookAheadIndex];
// Interpolate and apply camera parameters
const cameraParams = interpolateCamera(progress);
updateCameraPosition(cameraTargetPos, cameraParams);
// Update all visual elements
updateTrailLine(currentPointIndex);
updateVehicleMarker(currentPos, currentPointIndex);
updateChartMarker(currentPointIndex);
checkChapterTriggers(currentPointIndex);
// Continue or complete the animation
if (progress < 1) {
animationFrameId = requestAnimationFrame(animate);
} else {
onTourComplete();
}
}
// =========================================================================
// 9. TOUR CONTROLS
// =========================================================================
/**
* Starts the tour animation: hides the action button, shows playback
* controls and the elevation chart, creates the vehicle marker, and
* initiates the fly-in camera transition before starting the animation loop.
*/
function startTour() {
actionButton.style.display = 'none';
controlsContainer.classList.add('visible');
chartContainer.classList.add('visible');
playPauseButton.disabled = false;
resetTourState();
if (!vehicleMarker) {
const el = document.createElement('div');
el.className = 'vehicle-marker';
vehicleMarker = new smartmapsgl.Marker({ element: el, anchor: 'center' })
.setLngLat(tourData.geometry.coordinates[0])
.addTo(map);
}
// Smooth fly-in to the starting position
const startKeyframe = cameraKeyframes[0];
map.flyTo({
center: tourData.geometry.coordinates[0],
zoom: startKeyframe.zoom,
pitch: startKeyframe.pitch,
bearing: startKeyframe.bearing,
duration: FLY_IN_DURATION_MS,
padding: { bottom: FLY_IN_BOTTOM_PADDING }
});
map.once('moveend', () => {
animationStatus = 'playing';
animationStartTime = performance.now();
animate(animationStartTime);
});
}
/**
* Toggles between playing and paused animation states.
* When pausing, cancels the current animation frame.
* When resuming, adjusts the start time to account for elapsed time.
*/
function playPauseTour() {
if (animationStatus === 'playing') {
animationStatus = 'paused';
cancelAnimationFrame(animationFrameId);
playPauseButton.innerHTML = '<i class="material-icons">play_arrow</i>';
playPauseButton.title = "Play";
} else if (animationStatus === 'paused') {
animationStatus = 'playing';
playPauseButton.innerHTML = '<i class="material-icons">pause</i>';
playPauseButton.title = "Pause";
animationStartTime = performance.now() - timeElapsed;
requestAnimationFrame(animate);
}
}
/**
* Resets all animation state to initial values: cancels pending frames,
* removes markers, resets chapter flags, clears the trail, and restores
* the play/pause button to its default state.
*/
function resetTourState() {
if (animationFrameId) cancelAnimationFrame(animationFrameId);
animationStatus = 'stopped';
timeElapsed = 0;
animationStartTime = null;
weatherMarkers.forEach(m => m.marker.remove());
if (vehicleMarker) { vehicleMarker.remove(); vehicleMarker = null; }
currentVehicleType = '';
chapters.forEach(p => p.isShown = false);
document.getElementById('info-panel').querySelector('p').innerText = chapters[0].text;
document.getElementById('chart-marker').style.opacity = '0';
if (map.getSource('animated-trail')) map.getSource('animated-trail').setData({ type: 'Feature', geometry: { type: 'LineString', coordinates: [] } });
playPauseButton.innerHTML = '<i class="material-icons">pause</i>';
playPauseButton.title = "Pause";
}
/**
* Restarts the tour by hiding controls, waiting for the transition to
* complete, resetting state, and starting a fresh tour.
*/
function restartTour() {
controlsContainer.classList.remove('visible');
chartContainer.classList.remove('visible');
setTimeout(() => {
resetTourState();
startTour();
}, RESTART_DELAY_MS);
}
// =========================================================================
// 10. INITIALIZATION & DATA LOADING
// =========================================================================
/**
* Sets up the tour after GeoJSON route data has been loaded. Defines chapter
* waypoints, adds trail map layers, fetches weather data and the elevation
* profile, creates weather popup markers, and wires up UI event listeners.
*
* @param {{bus: number, gondel: number, bergbahn: number}} segLengths - Number of coordinate points per route segment
*/
async function setupTour(segLengths) {
segmentLengths = segLengths;
const totalCoordinates = tourData.geometry.coordinates;
totalTourDistance = calculatePathDistance(totalCoordinates);
chapters = [
{ name: 'Interlaken Ost', index: 0, text: "Unsere Reise startet in Interlaken. Der Bus bringt uns durch das malerische Lütschental." },
{ name: 'Grindelwald Terminal', index: segLengths.bus - 1, text: "Ankunft am Terminal in Grindelwald. Wir steigen um auf den Eiger Express." },
{ name: 'Eigergletscher', index: segLengths.bus + segLengths.gondel - 1, text: "Wir erreichen die Station Eigergletscher. Jetzt geht es mit der Jungfraubahn weiter." },
{ name: 'Jungfraujoch', index: totalCoordinates.length - 1, text: "Ziel erreicht! Willkommen auf dem Jungfraujoch – Top of Europe." }
].map(c => ({ ...c, coords: totalCoordinates[c.index], isShown: false }));
// Add map sources and layers for the full trail (dashed) and animated trail (solid)
map.addSource('full-trail', { type: 'geojson', data: tourData });
map.addSource('animated-trail', { type: 'geojson', data: { type: 'Feature', geometry: { type: 'LineString', coordinates: [] } } });
map.addLayer({ id: 'full-trail-line', type: 'line', source: 'full-trail', paint: { 'line-color': '#ffffff', 'line-width': 4, 'line-opacity': 0.3, 'line-dasharray': [2, 2] } }, 'label-ocean');
map.addLayer({ id: 'animated-trail-line', type: 'line', source: 'animated-trail', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': getCssVariable('--brand-accent'), 'line-width': 5, 'line-opacity': 1 } }, 'label-ocean');
try {
// Fetch weather data for all chapters and the elevation profile in parallel
const weatherDataPromises = chapters.map(p => getWeatherData(p.coords[0], p.coords[1]));
const [weatherDataArray] = await Promise.all([
Promise.all(weatherDataPromises),
getElevationProfile()
]);
// Create weather popup markers for each chapter waypoint
weatherDataArray.forEach((weather, i) => {
const point = chapters[i];
const el = document.createElement('div');
el.className = 'weather-popup';
el.innerHTML = `<img src="https://docs.smartmaps.cloud/assets/images/weatherImages/${weatherIconMapping[weather.iconCode] || 'ic_day_sunny'}.svg" class="weather-icon" alt="Wetter-Icon"><div class="weather-info"><span>${point.name}</span><span>${weather.temp.toFixed(1)}°C</span></div>`;
const marker = new smartmapsgl.Marker({ element: el, anchor: 'bottom' }).setLngLat(point.coords);
weatherMarkers.push({ name: point.name, marker: marker });
});
actionButton.disabled = false;
actionButton.innerText = 'Tour starten';
} catch (error) {
console.error("Error loading tour data:", error);
actionButton.innerText = 'Fehler!';
document.getElementById('info-panel').querySelector('p').innerText = "Fehler beim Laden der Wetter- oder Höhendaten.";
}
// Wire up button event listeners
actionButton.addEventListener('click', startTour);
playPauseButton.addEventListener('click', playPauseTour);
restartButton.addEventListener('click', restartTour);
}
/**
* Map load handler: fetches the three GeoJSON route files (bus, gondola,
* mountain railway), parses and concatenates their coordinates, builds
* the unified tour route, and kicks off tour setup.
*/
map.on('load', async () => {
try {
const files = ['bus.geojson', 'gondel.geojson', 'bergbahn.geojson'];
const promises = files.map(file => fetch(file).then(res => res.json()));
const datasets = await Promise.all(promises);
// Parse GeoJSON features into flat coordinate arrays
const parser = (data) => {
let coordinates = [];
if (data && data.features) {
data.features.forEach(feature => {
if (!feature.geometry || !feature.geometry.coordinates) return;
if (feature.geometry.type === 'LineString') {
coordinates.push(...feature.geometry.coordinates.filter(c => Array.isArray(c) && c.length >= 2));
} else if (feature.geometry.type === 'MultiLineString') {
feature.geometry.coordinates.forEach(line => {
if (Array.isArray(line)) coordinates.push(...line.filter(c => Array.isArray(c) && c.length >= 2));
});
}
});
}
return coordinates;
};
const busCoords = parser(datasets[0]).reverse();
const gondelCoords = parser(datasets[1]);
const bergbahnCoords = parser(datasets[2]);
const allCoordinates = [...busCoords, ...gondelCoords, ...bergbahnCoords];
if (allCoordinates.length < 2) throw new Error("No valid route data found in GeoJSON files.");
tourData = { type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: allCoordinates } };
setupTour({ bus: busCoords.length, gondel: gondelCoords.length, bergbahn: bergbahnCoords.length });
} catch (error) {
console.error("Error loading tour files:", error);
actionButton.innerText = 'Fehler!';
document.getElementById('info-panel').querySelector('p').innerText = "Ein Fehler ist aufgetreten. Bitte laden Sie die Seite neu.";
}
});
<body>
<div id="app-container">
<div id="map-wrapper">
<div id="map"></div>
<div class="info-panel" id="info-panel">
<div class="title-container">
<h1>Tour zum Jungfraujoch</h1>
<span class="info-icon">
<i class="material-icons" style="font-size: 24px;">info</i>
<span class="tooltip">
<strong>Genutzte SmartMaps APIs:</strong><br>
- Map GL JS: 3D-Karte & Terrain<br>
- Weather API: Live-Wetterdaten<br>
- Elevation API: Höhenprofil der Strecke
</span>
</span>
</div>
<p>Eine Reise zum "Top of Europe". Erleben Sie eine 3D-Tour mit Bus, Gondel und Bergbahn. Klicken Sie
auf "Tour starten", um zu beginnen.</p>
<button id="action-button" disabled>Lade Tour-Daten...</button>
</div>
<div id="controls">
<button id="play-pause-button" title="Pause">
<i class="material-icons">pause</i>
</button>
<button id="restart-button" title="Neustart">
<i class="material-icons">replay</i>
</button>
</div>
<div id="elevation-chart-container">
<canvas id="elevation-chart"></canvas>
<div id="chart-marker">
<div id="chart-marker-dot"></div>
</div>
</div>
</div>
</div>
</body>
/* Demo-specific styles */
body {
overflow: hidden;
}
#app-container {
display: flex;
flex-direction: column;
height: 100vh;
}
#map-wrapper {
position: relative;
flex-grow: 1;
}
.maplibregl-ctrl-top-right {
display: none !important;
}
.info-panel {
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(5px);
-webkit-backdrop-filter: blur(5px);
max-width: 350px;
}
.info-panel .title-container {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.info-panel h1 {
font-size: 1.6em;
margin: 0;
color: var(--brand-primary);
padding-bottom: 0;
border: none;
}
.info-panel p {
font-size: 1em;
line-height: 1.6;
margin: 0;
transition: opacity 0.4s ease-in-out;
}
.info-panel p.fade-out {
opacity: 0;
}
.info-panel .info-icon .tooltip {
bottom: auto;
top: 130%;
left: 0%;
transform: translateX(-85%);
}
.info-panel .info-icon .tooltip::after {
top: auto;
bottom: 100%;
left: 87%;
right: 25px;
border-color: transparent transparent #1f2937 transparent;
}
#action-button {
background-color: var(--brand-accent);
color: var(--brand-primary);
border: none;
padding: 12px 18px;
border-radius: 8px;
font-weight: 700;
cursor: pointer;
margin-top: 15px;
width: 100%;
transition: all 0.3s;
}
#action-button:hover:not(:disabled) {
background-color: #e0a800;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
#action-button:disabled {
background-color: #cccccc;
cursor: not-allowed;
opacity: 0.7;
transform: none;
box-shadow: none;
position: relative;
color: transparent;
}
#action-button:disabled::after {
content: '';
position: absolute;
width: 20px;
height: 20px;
top: 50%;
left: 50%;
margin-top: -10px;
margin-left: -10px;
border: 3px solid rgba(24, 52, 92, 0.2);
border-top-color: var(--brand-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
#elevation-chart-container {
position: absolute;
bottom: 20px;
left: 50%;
width: 80%;
max-width: 900px;
height: 180px;
background-color: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(5px);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
border-radius: 12px;
padding: 15px;
z-index: 5;
box-sizing: border-box;
opacity: 0;
transform: translate(-50%, 150%);
transition: transform 0.6s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.6s cubic-bezier(0.16, 1, 0.3, 1);
}
#elevation-chart-container.visible {
opacity: 1;
transform: translateX(-50%);
}
#elevation-chart-container canvas {
max-width: 100%;
}
#chart-marker {
position: absolute;
top: 15px;
bottom: 40px;
width: 2px;
background-color: #e74c3c;
opacity: 0;
transition: opacity 0.3s;
pointer-events: none;
}
#chart-marker-dot {
position: absolute;
width: 10px;
height: 10px;
background-color: #e74c3c;
border-radius: 50%;
border: 2px solid white;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.5);
transform: translate(-50%, -50%);
}
.weather-popup {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(5px);
padding: 10px 15px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
display: flex;
align-items: center;
font-family: 'Inter', sans-serif;
font-weight: 500;
font-size: 14px;
pointer-events: none;
opacity: 0;
transform: translateY(10px);
transition: opacity 0.5s, transform 0.5s;
z-index: 4;
}
.weather-popup.visible {
opacity: 1;
transform: translateY(0);
}
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
}
}
.weather-popup.visible.pulse {
animation: pulse 0.5s ease-out;
}
.weather-icon {
width: 48px;
height: 48px;
margin-right: 12px;
}
.weather-info {
display: flex;
flex-direction: column;
line-height: 1.2;
}
.weather-info span:first-child {
font-size: 1.1em;
color: var(--brand-primary);
font-weight: 700;
}
.weather-info span:last-child {
font-size: 1.3em;
color: var(--text-primary);
}
#controls {
position: absolute;
top: 20px;
right: 20px;
background: rgba(255, 255, 255, 0.9);
padding: 10px;
border-radius: 8px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
z-index: 10;
display: flex;
gap: 10px;
opacity: 0;
transform: translateY(-20px);
transition: opacity 0.4s ease-out, transform 0.4s ease-out;
pointer-events: none;
}
#controls.visible {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
#controls button {
background-color: var(--brand-primary);
color: white;
border: none;
padding: 10px;
border-radius: 5px;
font-weight: bold;
cursor: pointer;
transition: all 0.3s;
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
}
#controls button:hover {
background-color: #2c5282;
}
#controls button:disabled {
background-color: #cccccc;
cursor: not-allowed;
}
.vehicle-marker {
width: 40px;
height: 40px;
background-size: 70%;
background-repeat: no-repeat;
background-position: center;
background-color: rgba(255, 255, 255, 0.85);
border-radius: 50%;
border: 2px solid var(--brand-primary);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
transition: background-image 0.3s ease-in-out;
}
.vehicle-marker.bus-icon {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="%2318345c"><path d="M240-120q-17 0-28.5-11.5T200-160v-82q-18-20-29-44.5T160-340v-380q0-83 77-121.5T480-880q172 0 246 37t74 123v380q0 29-11 53.5T760-242v82q0 17-11.5 28.5T720-120h-40q-17 0-28.5-11.5T640-160v-40H320v40q0 17-11.5 28.5T280-120h-40Zm0-440h480v-120H240v120Zm100 240q25 0 42.5-17.5T400-380q0-25-17.5-42.5T340-440q-25 0-42.5 17.5T280-380q0 25 17.5 42.5T340-320Zm280 0q25 0 42.5-17.5T680-380q0-25-17.5-42.5T620-440q-25 0-42.5 17.5T560-380q0 25 17.5 42.5T620-320Z"/></svg>');
}
.vehicle-marker.gondola-icon {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="%2318345c"><path d="M200-120q-33 0-56.5-23.5T120-200v-240q0-66 47-113t113-47h160v-109L40-600v-80l205-56q-2-5-3.5-11t-1.5-13q0-25 17.5-42.5T300-820q23 0 40 15t19 38l81-22v-51h80v29l86-23q-3-6-4.5-12.5T600-860q0-25 17.5-42.5T660-920q23 0 40.5 16t19.5 39l200-55v80L520-731v131h160q66 0 113 47t47 113v240q0 33-23.5 56.5T760-120H200Zm0-240h133v-160h-53q-33 0-56.5 23.5T200-440v80Zm213 0h133v-160H413v160Zm214 0h133v-80q0-33-23.5-56.5T680-520h-53v160Z"/></svg>');
}
.vehicle-marker.train-icon {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="%2318345c"><path d="M80-80v-526q0-85 44-147.5T248-848q54-21 115-26.5t117-5.5q56 0 117 5.5T712-848q80 32 124 94.5T880-606v526H80Zm284-80 60-60h110l60 60h66v-20l-42-42q44-6 73-39.5t29-78.5v-260q0-78-70-99t-170-21q-91 0-165.5 21T240-600v260q0 45 29 78.5t73 39.5l-42 42v20h64Zm-64-280v-160h360v160H300Zm320 140q-17 0-28.5-11.5T580-340q0-17 11.5-28.5T620-380q17 0 28.5 11.5T660-340q0 17-11.5 28.5T620-300Zm-280 0q-17 0-28.5-11.5T300-340q0-17 11.5-28.5T340-380q17 0 28.5 11.5T380-340q0 17-11.5 28.5T340-300Z"/></svg>');
}
@media (max-width: 768px) {
.info-panel h1 {
font-size: 1.3em;
}
#elevation-chart-container {
width: 95%;
height: 160px;
bottom: 10px;
padding: 10px;
}
#chart-marker {
bottom: 35px;
}
}
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<title>SmartMaps Super-Demo: Interaktive Tour zum Jungfraujoch</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<!-- Local stylesheets -->
<link href="css/material-icons.css" rel="stylesheet">
<link href="css/smartmaps-demo-styles.css" rel="stylesheet">
<!-- SmartMaps libraries -->
<script src="https://cdn.smartmaps.cloud/packages/smartmaps/smartmaps-gl/v2/umd/smartmaps-gl.min.js"></script>
<script src="https://cdn.smartmaps.cloud/packages/chartjs/4.4.4/chart.js"></script>
<style>
/* Demo-specific styles */
body {
overflow: hidden;
}
#app-container {
display: flex;
flex-direction: column;
height: 100vh;
}
#map-wrapper {
position: relative;
flex-grow: 1;
}
.maplibregl-ctrl-top-right {
display: none !important;
}
.info-panel {
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(5px);
-webkit-backdrop-filter: blur(5px);
max-width: 350px;
}
.info-panel .title-container {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.info-panel h1 {
font-size: 1.6em;
margin: 0;
color: var(--brand-primary);
padding-bottom: 0;
border: none;
}
.info-panel p {
font-size: 1em;
line-height: 1.6;
margin: 0;
transition: opacity 0.4s ease-in-out;
}
.info-panel p.fade-out {
opacity: 0;
}
.info-panel .info-icon .tooltip {
bottom: auto;
top: 130%;
left: 0%;
transform: translateX(-85%);
}
.info-panel .info-icon .tooltip::after {
top: auto;
bottom: 100%;
left: 87%;
right: 25px;
border-color: transparent transparent #1f2937 transparent;
}
#action-button {
background-color: var(--brand-accent);
color: var(--brand-primary);
border: none;
padding: 12px 18px;
border-radius: 8px;
font-weight: 700;
cursor: pointer;
margin-top: 15px;
width: 100%;
transition: all 0.3s;
}
#action-button:hover:not(:disabled) {
background-color: #e0a800;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
}
#action-button:disabled {
background-color: #cccccc;
cursor: not-allowed;
opacity: 0.7;
transform: none;
box-shadow: none;
position: relative;
color: transparent;
}
#action-button:disabled::after {
content: '';
position: absolute;
width: 20px;
height: 20px;
top: 50%;
left: 50%;
margin-top: -10px;
margin-left: -10px;
border: 3px solid rgba(24, 52, 92, 0.2);
border-top-color: var(--brand-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
#elevation-chart-container {
position: absolute;
bottom: 20px;
left: 50%;
width: 80%;
max-width: 900px;
height: 180px;
background-color: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(5px);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
border-radius: 12px;
padding: 15px;
z-index: 5;
box-sizing: border-box;
opacity: 0;
transform: translate(-50%, 150%);
transition: transform 0.6s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.6s cubic-bezier(0.16, 1, 0.3, 1);
}
#elevation-chart-container.visible {
opacity: 1;
transform: translateX(-50%);
}
#elevation-chart-container canvas {
max-width: 100%;
}
#chart-marker {
position: absolute;
top: 15px;
bottom: 40px;
width: 2px;
background-color: #e74c3c;
opacity: 0;
transition: opacity 0.3s;
pointer-events: none;
}
#chart-marker-dot {
position: absolute;
width: 10px;
height: 10px;
background-color: #e74c3c;
border-radius: 50%;
border: 2px solid white;
box-shadow: 0 0 5px rgba(0, 0, 0, 0.5);
transform: translate(-50%, -50%);
}
.weather-popup {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(5px);
padding: 10px 15px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
display: flex;
align-items: center;
font-family: 'Inter', sans-serif;
font-weight: 500;
font-size: 14px;
pointer-events: none;
opacity: 0;
transform: translateY(10px);
transition: opacity 0.5s, transform 0.5s;
z-index: 4;
}
.weather-popup.visible {
opacity: 1;
transform: translateY(0);
}
@keyframes pulse {
0% {
transform: scale(1);
}
50% {
transform: scale(1.1);
}
100% {
transform: scale(1);
}
}
.weather-popup.visible.pulse {
animation: pulse 0.5s ease-out;
}
.weather-icon {
width: 48px;
height: 48px;
margin-right: 12px;
}
.weather-info {
display: flex;
flex-direction: column;
line-height: 1.2;
}
.weather-info span:first-child {
font-size: 1.1em;
color: var(--brand-primary);
font-weight: 700;
}
.weather-info span:last-child {
font-size: 1.3em;
color: var(--text-primary);
}
#controls {
position: absolute;
top: 20px;
right: 20px;
background: rgba(255, 255, 255, 0.9);
padding: 10px;
border-radius: 8px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
z-index: 10;
display: flex;
gap: 10px;
opacity: 0;
transform: translateY(-20px);
transition: opacity 0.4s ease-out, transform 0.4s ease-out;
pointer-events: none;
}
#controls.visible {
opacity: 1;
transform: translateY(0);
pointer-events: auto;
}
#controls button {
background-color: var(--brand-primary);
color: white;
border: none;
padding: 10px;
border-radius: 5px;
font-weight: bold;
cursor: pointer;
transition: all 0.3s;
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
}
#controls button:hover {
background-color: #2c5282;
}
#controls button:disabled {
background-color: #cccccc;
cursor: not-allowed;
}
.vehicle-marker {
width: 40px;
height: 40px;
background-size: 70%;
background-repeat: no-repeat;
background-position: center;
background-color: rgba(255, 255, 255, 0.85);
border-radius: 50%;
border: 2px solid var(--brand-primary);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
transition: background-image 0.3s ease-in-out;
}
.vehicle-marker.bus-icon {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="%2318345c"><path d="M240-120q-17 0-28.5-11.5T200-160v-82q-18-20-29-44.5T160-340v-380q0-83 77-121.5T480-880q172 0 246 37t74 123v380q0 29-11 53.5T760-242v82q0 17-11.5 28.5T720-120h-40q-17 0-28.5-11.5T640-160v-40H320v40q0 17-11.5 28.5T280-120h-40Zm0-440h480v-120H240v120Zm100 240q25 0 42.5-17.5T400-380q0-25-17.5-42.5T340-440q-25 0-42.5 17.5T280-380q0 25 17.5 42.5T340-320Zm280 0q25 0 42.5-17.5T680-380q0-25-17.5-42.5T620-440q-25 0-42.5 17.5T560-380q0 25 17.5 42.5T620-320Z"/></svg>');
}
.vehicle-marker.gondola-icon {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="%2318345c"><path d="M200-120q-33 0-56.5-23.5T120-200v-240q0-66 47-113t113-47h160v-109L40-600v-80l205-56q-2-5-3.5-11t-1.5-13q0-25 17.5-42.5T300-820q23 0 40 15t19 38l81-22v-51h80v29l86-23q-3-6-4.5-12.5T600-860q0-25 17.5-42.5T660-920q23 0 40.5 16t19.5 39l200-55v80L520-731v131h160q66 0 113 47t47 113v240q0 33-23.5 56.5T760-120H200Zm0-240h133v-160h-53q-33 0-56.5 23.5T200-440v80Zm213 0h133v-160H413v160Zm214 0h133v-80q0-33-23.5-56.5T680-520h-53v160Z"/></svg>');
}
.vehicle-marker.train-icon {
background-image: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="%2318345c"><path d="M80-80v-526q0-85 44-147.5T248-848q54-21 115-26.5t117-5.5q56 0 117 5.5T712-848q80 32 124 94.5T880-606v526H80Zm284-80 60-60h110l60 60h66v-20l-42-42q44-6 73-39.5t29-78.5v-260q0-78-70-99t-170-21q-91 0-165.5 21T240-600v260q0 45 29 78.5t73 39.5l-42 42v20h64Zm-64-280v-160h360v160H300Zm320 140q-17 0-28.5-11.5T580-340q0-17 11.5-28.5T620-380q17 0 28.5 11.5T660-340q0 17-11.5 28.5T620-300Zm-280 0q-17 0-28.5-11.5T300-340q0-17 11.5-28.5T340-380q17 0 28.5 11.5T380-340q0 17-11.5 28.5T340-300Z"/></svg>');
}
@media (max-width: 768px) {
.info-panel h1 {
font-size: 1.3em;
}
#elevation-chart-container {
width: 95%;
height: 160px;
bottom: 10px;
padding: 10px;
}
#chart-marker {
bottom: 35px;
}
}
</style>
</head>
<body>
<div id="app-container">
<div id="map-wrapper">
<div id="map"></div>
<div class="info-panel" id="info-panel">
<div class="title-container">
<h1>Tour zum Jungfraujoch</h1>
<span class="info-icon">
<i class="material-icons" style="font-size: 24px;">info</i>
<span class="tooltip">
<strong>Genutzte SmartMaps APIs:</strong><br>
- Map GL JS: 3D-Karte & Terrain<br>
- Weather API: Live-Wetterdaten<br>
- Elevation API: Höhenprofil der Strecke
</span>
</span>
</div>
<p>Eine Reise zum "Top of Europe". Erleben Sie eine 3D-Tour mit Bus, Gondel und Bergbahn. Klicken Sie
auf "Tour starten", um zu beginnen.</p>
<button id="action-button" disabled>Lade Tour-Daten...</button>
</div>
<div id="controls">
<button id="play-pause-button" title="Pause">
<i class="material-icons">pause</i>
</button>
<button id="restart-button" title="Neustart">
<i class="material-icons">replay</i>
</button>
</div>
<div id="elevation-chart-container">
<canvas id="elevation-chart"></canvas>
<div id="chart-marker">
<div id="chart-marker-dot"></div>
</div>
</div>
</div>
</div>
<script>
/*
* ==========================================================================
* SmartMaps Alpine Tour Demo — Interactive 3D Journey to the Jungfraujoch
* ==========================================================================
*
* PURPOSE:
* Animated fly-through of a multi-segment alpine route (bus, gondola,
* mountain railway) rendered on a 3D satellite map with live weather
* data and a synchronized elevation profile chart.
*
* APIs USED:
* - SmartMaps GL JS v2 — 3D map with satellite imagery and terrain
* - SmartMaps Weather API — live temperature and weather codes per waypoint
* - SmartMaps Elevation API — elevation profile (pre-fetched JSON)
* - Chart.js 4.4.4 — interactive elevation chart
*
* ARCHITECTURE / SECTIONS:
* 1. Configuration & Named Constants
* 2. DOM Element References
* 3. Application State
* 4. Map Initialization & Camera Keyframes
* 5. Utility Functions (CSS vars, Haversine distance)
* 6. Weather API Integration
* 7. Elevation Profile & Chart
* 8. Animation Engine
* - getRoutePosition() — position along the route for a given progress
* - interpolateCamera() — keyframe-based camera parameter interpolation
* - updateCameraPosition() — smooth (lerp-based) camera follow
* - updateTrailLine() — animated trail drawn behind the vehicle
* - updateVehicleMarker() — move marker and swap vehicle icon
* - updateChartMarker() — sync the chart progress indicator
* - checkChapterTriggers() — trigger narration text and weather popups
* - onTourComplete() — end-of-tour fly-out and UI reset
* - animate() — main requestAnimationFrame loop
* 9. Tour Controls (start, play/pause, reset, restart)
* 10. Initialization & Data Loading
* ==========================================================================
*/
// =========================================================================
// 1. CONFIGURATION & NAMED CONSTANTS
// =========================================================================
/** @const {string} SmartMaps API key used for map, weather, and elevation services */
const apiKey = '[INSERT API-KEY]';
/** @const {number} Total animation duration in milliseconds */
const ANIMATION_DURATION_MS = 50000;
/** @const {number} Smoothing weight for the previous camera position (inertia) */
const CAMERA_SMOOTHING_PREVIOUS = 0.95;
/** @const {number} Smoothing weight for the target camera position (responsiveness) */
const CAMERA_SMOOTHING_TARGET = 0.05;
/** @const {number} Number of route points to look ahead for the camera target */
const CAMERA_LOOK_AHEAD_POINTS = 30;
/** @const {number} Duration in ms for the fly-in camera move when the tour starts */
const FLY_IN_DURATION_MS = 2500;
/** @const {number} Duration in ms for the final fly-out camera move when the tour ends */
const FLY_OUT_DURATION_MS = 5000;
/** @const {number} Fraction of total time allocated to the bus segment (0..1) */
const BUS_TIME_FRACTION = 0.50;
/** @const {number} Fraction of total time at which the gondola segment ends (0..1) */
const GONDOLA_TIME_FRACTION = 0.75;
/** @const {number} Duration in ms for the narration text fade-out transition */
const NARRATION_FADE_DURATION_MS = 400;
/** @const {number} Duration in ms for the weather popup pulse animation */
const WEATHER_PULSE_DURATION_MS = 500;
/** @const {number} Delay in ms before showing UI elements after restart */
const RESTART_DELAY_MS = 600;
/** @const {number} Bottom padding applied to the map during the fly-in */
const FLY_IN_BOTTOM_PADDING = 220;
/**
* @const {Object.<number, string>} Maps WMO weather codes to icon file names
* used to display weather condition icons from the SmartMaps CDN.
*/
const weatherIconMapping = { 0: 'ic_day_sunny', 1: 'ic_day_sunny', 2: 'ic_day_partlycloudy', 3: 'ic_day_cloudy', 45: 'ic_day_fog', 48: 'ic_day_fog', 51: 'ic_day_sprinkle', 53: 'ic_day_sprinkle', 55: 'ic_day_sprinkle', 80: 'ic_day_showers', 81: 'ic_day_showers', 82: 'ic_day_showers', 61: 'ic_day_rain', 63: 'ic_day_rain', 65: 'ic_day_rain', 56: 'ic_day_rain_mix', 57: 'ic_day_rain_mix', 66: 'ic_day_rain_mix', 67: 'ic_day_rain_mix', 71: 'ic_day_snow', 73: 'ic_day_snow', 75: 'ic_day_snow', 77: 'ic_day_snowflake_cold', 85: 'ic_day_snow', 86: 'ic_day_snow', 95: 'ic_day_thunderstorm', 96: 'ic_day_storm_showers', 99: 'ic_day_storm_showers' };
// =========================================================================
// 2. DOM ELEMENT REFERENCES
// =========================================================================
const actionButton = document.getElementById('action-button');
const controlsContainer = document.getElementById('controls');
const playPauseButton = document.getElementById('play-pause-button');
const restartButton = document.getElementById('restart-button');
const chartContainer = document.getElementById('elevation-chart-container');
// =========================================================================
// 3. APPLICATION STATE
// =========================================================================
let tourData, chapters, segmentLengths, totalTourDistance = 0;
let animationFrameId, animationStatus = 'stopped';
let animationStartTime, timeElapsed = 0;
let weatherMarkers = [];
let chart;
let elevationData = { elevations: [], points: [], distances: [] };
let vehicleMarker = null;
let currentVehicleType = '';
// =========================================================================
// 4. MAP INITIALIZATION & CAMERA KEYFRAMES
// =========================================================================
/** @type {smartmapsgl.Map} The main map instance with 3D satellite terrain */
const map = new smartmapsgl.Map({
container: 'map', apiKey: apiKey, style: smartmapsgl.MapStyle.SATELLITE,
center: [7.8321, 46.6308], zoom: 11.5, pitch: 60, bearing: 155,
terrain: { exaggeration: 1, activated: true }
});
/**
* Camera keyframes define pitch, bearing, and zoom at specific progress
* points along the animation timeline. Values are linearly interpolated
* between adjacent keyframes during playback.
* @const {Array.<{progress: number, pitch: number, bearing: number, zoom: number}>}
*/
const cameraKeyframes = [
// Bus segment
{ progress: 0.0, pitch: 50, bearing: 90, zoom: 13.5 },
{ progress: 0.25, pitch: 45, bearing: 140, zoom: 13.5 },
{ progress: 0.49, pitch: 35, bearing: 180, zoom: 13.5 },
// Gondola segment — side panorama perspective
{ progress: 0.52, pitch: 45, bearing: 130, zoom: 11.8 },
{ progress: 0.65, pitch: 55, bearing: 110, zoom: 11.8 },
// Smooth transition to mountain railway
{ progress: 0.74, pitch: 58, bearing: 140, zoom: 12.5 },
{ progress: 0.76, pitch: 60, bearing: 180, zoom: 12.5 },
// Mountain railway segment
{ progress: 0.90, pitch: 65, bearing: 180, zoom: 12.5 },
{ progress: 1.0, pitch: 65, bearing: -45, zoom: 12.5 }
];
// =========================================================================
// 5. UTILITY FUNCTIONS
// =========================================================================
/**
* Reads a CSS custom property value from the document root element.
* @param {string} variable - The CSS variable name (e.g. '--brand-primary')
* @returns {string} The trimmed computed value
*/
function getCssVariable(variable) {
return getComputedStyle(document.documentElement).getPropertyValue(variable).trim();
}
/**
* Calculates the Haversine distance between two geographic points.
* @param {number} lat1 - Latitude of the first point in degrees
* @param {number} lon1 - Longitude of the first point in degrees
* @param {number} lat2 - Latitude of the second point in degrees
* @param {number} lon2 - Longitude of the second point in degrees
* @returns {number} Distance in meters
*/
function getDistance(lat1, lon1, lat2, lon2) {
const R = 6371e3;
const phi1 = (lat1 * Math.PI) / 180;
const phi2 = (lat2 * Math.PI) / 180;
const deltaPhi = ((lat2 - lat1) * Math.PI) / 180;
const deltaLambda = ((lon2 - lon1) * Math.PI) / 180;
const a = Math.sin(deltaPhi / 2) * Math.sin(deltaPhi / 2) +
Math.cos(phi1) * Math.cos(phi2) *
Math.sin(deltaLambda / 2) * Math.sin(deltaLambda / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
/**
* Computes the total path distance for an array of [lng, lat] coordinates.
* @param {Array.<Array.<number>>} coordinates - Array of [longitude, latitude] pairs
* @returns {number} Total distance in meters
*/
function calculatePathDistance(coordinates) {
let distance = 0;
for (let i = 1; i < coordinates.length; i++) {
distance += getDistance(coordinates[i - 1][1], coordinates[i - 1][0], coordinates[i][1], coordinates[i][0]);
}
return distance;
}
/**
* Linearly interpolates between two numeric values.
* @param {number} start - Start value
* @param {number} end - End value
* @param {number} amt - Interpolation amount (0 = start, 1 = end)
* @returns {number} Interpolated value
*/
function lerp(start, end, amt) {
return (1 - amt) * start + amt * end;
}
// =========================================================================
// 6. WEATHER API INTEGRATION
// =========================================================================
/**
* Fetches current weather data for a geographic point from the SmartMaps Weather API.
* @param {number} lng - Longitude of the point
* @param {number} lat - Latitude of the point
* @returns {Promise<{temp: number, iconCode: number}>} Temperature in Celsius and WMO weather code
* @throws {Error} When no weather data is found in the API response
*/
async function getWeatherData(lng, lat) {
const url = `https://weather.smartmaps.cloud/api/v2/weather/point?Longitude=${lng}&Latitude=${lat}&ApiKey=${smartmapsgl.encodeString(apiKey)}`;
const response = await fetch(url);
const data = await response.json();
if (data.features && data.features.length > 0) {
return {
temp: data.features[0].properties.currently.temperature2Meters,
iconCode: data.features[0].properties.currently.weatherCode
};
}
throw new Error('No weather data found in the "features" array.');
}
// =========================================================================
// 7. ELEVATION PROFILE & CHART
// =========================================================================
/**
* Loads the pre-fetched elevation profile from a local JSON file and
* initializes the Chart.js elevation chart.
* @returns {Promise<void>}
*/
async function getElevationProfile() {
try {
const response = await fetch('elevation_profile.json');
if (!response.ok) {
throw new Error(`Failed to load local elevation data: ${response.statusText}`);
}
const data = await response.json();
if (!data || !data.features) throw new Error("Invalid data structure in elevation_profile.json.");
elevationData.elevations = data.features.map(feature => feature.properties.elevation);
const uniquePoints = data.features.map(feature => ({
latitude: feature.geometry.coordinates[1],
longitude: feature.geometry.coordinates[0]
}));
elevationData.points = uniquePoints;
const distances = [0];
for (let i = 1; i < uniquePoints.length; i++) {
distances.push(distances[i - 1] + getDistance(uniquePoints[i - 1].latitude, uniquePoints[i - 1].longitude, uniquePoints[i].latitude, uniquePoints[i].longitude));
}
elevationData.distances = distances;
createElevationChart(distances, elevationData.elevations, uniquePoints);
} catch (error) {
console.error("Error processing local elevation data:", error);
}
}
/**
* Creates or re-creates the Chart.js elevation profile chart.
* @param {number[]} distances - Cumulative distances in meters for each point
* @param {number[]} elevations - Elevation values in meters
* @param {Array.<{latitude: number, longitude: number}>} points - Geographic coordinates for hover interaction
*/
function createElevationChart(distances, elevations, points) {
const ctx = document.getElementById('elevation-chart').getContext('2d');
if (chart) chart.destroy();
const gradient = ctx.createLinearGradient(0, 0, 0, 180);
gradient.addColorStop(0, 'rgba(246, 184, 12, 0.5)');
gradient.addColorStop(1, 'rgba(246, 184, 12, 0)');
chart = new Chart(ctx, {
type: 'line',
data: {
labels: distances.map(d => (d / 1000).toFixed(2)),
datasets: [{
label: 'Höhe',
data: elevations,
borderColor: getCssVariable('--brand-accent'),
backgroundColor: gradient,
fill: true,
pointRadius: 0,
tension: 0.2
}]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false }, tooltip: { callbacks: { title: (ctx) => `Distanz: ${ctx[0].label} km`, label: (ctx) => `Höhe: ${ctx.parsed.y.toFixed(0)} m` } } },
scales: {
x: {
title: { display: true, text: 'Distanz (km)' },
ticks: {
callback: function (value, index, values) {
const km = parseFloat(this.getLabelForValue(value));
if (km % 5 === 0) return km;
if (index === values.length - 1) return Math.round(km);
return null;
}, autoSkip: false, maxRotation: 0
}
},
y: { title: { display: true, text: 'Höhe (m)' } }
},
onHover: (event, el) => { if (el.length) addHoverMarkerToMap(points[el[0].index].latitude, points[el[0].index].longitude); }
}
});
}
/**
* Adds or updates a red circle marker on the map at the given coordinates,
* used when hovering over the elevation chart.
* @param {number} lat - Latitude of the point to highlight
* @param {number} lng - Longitude of the point to highlight
*/
function addHoverMarkerToMap(lat, lng) {
if (map.getLayer('hovered-point')) {
map.getSource('hovered-point').setData({ type: 'Feature', geometry: { type: 'Point', coordinates: [lng, lat] } });
} else {
map.addLayer({ id: 'hovered-point', type: 'circle', source: { type: 'geojson', data: { type: 'Feature', geometry: { type: 'Point', coordinates: [lng, lat] } } }, paint: { 'circle-radius': 6, 'circle-color': '#e74c3c', 'circle-stroke-width': 2, 'circle-stroke-color': '#ffffff' } });
}
}
// =========================================================================
// 8. ANIMATION ENGINE
// =========================================================================
/**
* Maps linear time-based progress to path-based progress, distributing
* animation time unevenly across the three route segments so that each
* transport mode gets adequate screen time regardless of path length.
*
* @param {number} progress - Linear time progress (0..1)
* @returns {number} Adjusted path progress (0..1) mapped to route coordinates
*/
function getAdjustedProgress(progress) {
const totalPoints = tourData.geometry.coordinates.length - 1;
const busPathEnd = (segmentLengths.bus - 1) / totalPoints;
const gondolaPathEnd = (segmentLengths.bus + segmentLengths.gondel - 1) / totalPoints;
if (progress < BUS_TIME_FRACTION) {
const timeInSegment = progress / BUS_TIME_FRACTION;
return timeInSegment * busPathEnd;
} else if (progress < GONDOLA_TIME_FRACTION) {
const timeInSegment = (progress - BUS_TIME_FRACTION) / (GONDOLA_TIME_FRACTION - BUS_TIME_FRACTION);
return busPathEnd + timeInSegment * (gondolaPathEnd - busPathEnd);
} else {
const timeInSegment = (progress - GONDOLA_TIME_FRACTION) / (1 - GONDOLA_TIME_FRACTION);
return gondolaPathEnd + timeInSegment * (1 - gondolaPathEnd);
}
}
/**
* Calculates the interpolated geographic position along the route for
* a given adjusted progress value.
*
* @param {number} adjustedProgress - Path progress value (0..1)
* @param {number} totalPoints - Total number of route points minus one
* @returns {{currentPos: number[], currentPointIndex: number, startSegment: number[], endSegment: number[]}|null}
* The interpolated position, index, and bracketing segment points, or null if coordinates are invalid
*/
function getRoutePosition(adjustedProgress, totalPoints) {
const currentPointIndex = Math.floor(adjustedProgress * totalPoints);
const startSegment = tourData.geometry.coordinates[currentPointIndex];
const endSegment = tourData.geometry.coordinates[Math.min(currentPointIndex + 1, totalPoints)];
if (!startSegment || !endSegment) return null;
const segmentProgress = adjustedProgress * totalPoints - currentPointIndex;
const currentPos = [
startSegment[0] + (endSegment[0] - startSegment[0]) * segmentProgress,
startSegment[1] + (endSegment[1] - startSegment[1]) * segmentProgress
];
return { currentPos, currentPointIndex, startSegment, endSegment };
}
/**
* Interpolates camera parameters (pitch, bearing, zoom) from the keyframe
* array based on the current animation progress.
*
* Bearing values are adjusted for shortest-path rotation to avoid spinning
* the camera the long way around (e.g. 350 -> 10 degrees).
*
* @param {number} progress - Linear time progress (0..1)
* @returns {{pitch: number, bearing: number, zoom: number}} Interpolated camera parameters
*/
function interpolateCamera(progress) {
let currentKeyframe = cameraKeyframes[0];
let nextKeyframe = cameraKeyframes[0];
for (let i = 0; i < cameraKeyframes.length; i++) {
if (cameraKeyframes[i].progress <= progress) {
currentKeyframe = cameraKeyframes[i];
nextKeyframe = cameraKeyframes[i + 1] || cameraKeyframes[i];
} else {
break;
}
}
const keyframeSegmentProgress = (progress - currentKeyframe.progress) / (nextKeyframe.progress - currentKeyframe.progress || 1);
const interpolatedPitch = lerp(currentKeyframe.pitch, nextKeyframe.pitch, keyframeSegmentProgress);
const interpolatedZoom = lerp(currentKeyframe.zoom, nextKeyframe.zoom, keyframeSegmentProgress);
// Adjust bearing for shortest-path rotation
let startBearing = currentKeyframe.bearing;
let endBearing = nextKeyframe.bearing;
const diff = endBearing - startBearing;
if (diff > 180) { endBearing -= 360; }
else if (diff < -180) { endBearing += 360; }
const interpolatedBearing = lerp(startBearing, endBearing, keyframeSegmentProgress);
return { pitch: interpolatedPitch, bearing: interpolatedBearing, zoom: interpolatedZoom };
}
/**
* Applies interpolated camera parameters and smoothly follows a target
* position using exponential smoothing (lerp between current and target).
*
* @param {number[]} cameraTargetPos - [lng, lat] target for the camera center
* @param {{pitch: number, bearing: number, zoom: number}} cameraParams - Interpolated camera values
*/
function updateCameraPosition(cameraTargetPos, cameraParams) {
map.setPitch(cameraParams.pitch);
map.setZoom(cameraParams.zoom);
map.setBearing(cameraParams.bearing);
const smoothedCameraCenter = new smartmapsgl.LngLat(
map.getCenter().lng * CAMERA_SMOOTHING_PREVIOUS + cameraTargetPos[0] * CAMERA_SMOOTHING_TARGET,
map.getCenter().lat * CAMERA_SMOOTHING_PREVIOUS + cameraTargetPos[1] * CAMERA_SMOOTHING_TARGET
);
map.setCenter(smoothedCameraCenter);
}
/**
* Updates the animated trail line source to show the route traveled so far.
*
* @param {number} pointIndex - Current index along the route coordinates array
*/
function updateTrailLine(pointIndex) {
map.getSource('animated-trail').setData({
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: tourData.geometry.coordinates.slice(0, pointIndex + 1)
}
});
}
/**
* Moves the vehicle marker to the current position and swaps its icon
* CSS class when the transport mode changes (bus, gondola, or train).
*
* @param {number[]} position - [lng, lat] current position on the route
* @param {number} pointIndex - Current index along the route coordinates
*/
function updateVehicleMarker(position, pointIndex) {
if (!vehicleMarker) return;
vehicleMarker.setLngLat(position);
let newVehicleType;
if (pointIndex < segmentLengths.bus) newVehicleType = 'bus';
else if (pointIndex < segmentLengths.bus + segmentLengths.gondel) newVehicleType = 'gondola';
else newVehicleType = 'train';
if (newVehicleType !== currentVehicleType) {
vehicleMarker.getElement().className = 'vehicle-marker ' + newVehicleType + '-icon';
currentVehicleType = newVehicleType;
}
}
/**
* Synchronizes the chart progress marker (vertical line and dot) with
* the current position along the route.
*
* @param {number} pointIndex - Current index along the route coordinates
*/
function updateChartMarker(pointIndex) {
if (!chart || elevationData.elevations.length === 0 || !chart.chartArea) return;
const chartMarker = document.getElementById('chart-marker');
const chartMarkerDot = document.getElementById('chart-marker-dot');
const traveledDistance = calculatePathDistance(tourData.geometry.coordinates.slice(0, pointIndex + 1));
const chartProgress = totalTourDistance > 0 ? traveledDistance / totalTourDistance : 0;
chartMarker.style.left = `${chart.chartArea.left + chartProgress * chart.chartArea.width}px`;
let elevationIndex = elevationData.distances.findIndex(d => d >= traveledDistance);
if (elevationIndex === -1) elevationIndex = elevationData.distances.length - 1;
chartMarkerDot.style.top = `${chart.scales.y.getPixelForValue(elevationData.elevations[elevationIndex])}px`;
chartMarker.style.opacity = '1';
}
/**
* Checks whether the animation has reached any chapter trigger point and,
* if so, fades in the corresponding narration text and weather popup marker.
*
* @param {number} pointIndex - Current index along the route coordinates
*/
function checkChapterTriggers(pointIndex) {
chapters.forEach((chapter) => {
if (pointIndex >= chapter.index && !chapter.isShown) {
// Fade out current narration text, then replace it
const p = document.getElementById('info-panel').querySelector('p');
p.classList.add('fade-out');
setTimeout(() => { p.innerText = chapter.text; p.classList.remove('fade-out'); }, NARRATION_FADE_DURATION_MS);
// Show the weather popup marker for this chapter
const markerData = weatherMarkers.find(m => m.name === chapter.name);
if (markerData) {
markerData.marker.addTo(map);
const markerEl = markerData.marker.getElement();
setTimeout(() => {
markerEl.classList.add('visible', 'pulse');
setTimeout(() => markerEl.classList.remove('pulse'), WEATHER_PULSE_DURATION_MS);
}, 10);
}
chapter.isShown = true;
}
});
}
/**
* Handles the end of the animation: stops playback, triggers a final
* fly-out camera move, hides controls, and resets the action button
* so the user can restart the tour.
*/
function onTourComplete() {
animationStatus = 'stopped';
map.flyTo({ center: [7.98, 46.58], zoom: 11.5, pitch: 65, bearing: -45, duration: FLY_OUT_DURATION_MS });
controlsContainer.classList.remove('visible');
chartContainer.classList.remove('visible');
if (vehicleMarker) vehicleMarker.remove(); vehicleMarker = null;
actionButton.style.display = 'block';
actionButton.disabled = false;
actionButton.innerText = 'Tour erneut starten';
}
/**
* Main animation loop driven by requestAnimationFrame. On each frame it:
* 1. Computes linear and adjusted progress
* 2. Determines the interpolated route position
* 3. Interpolates and applies camera parameters
* 4. Updates the trail, vehicle marker, chart marker, and chapter triggers
* 5. Requests the next frame or completes the tour
*
* @param {DOMHighResTimeStamp} now - Timestamp provided by requestAnimationFrame
*/
function animate(now) {
if (animationStatus !== 'playing') return;
if (!animationStartTime) animationStartTime = now;
timeElapsed = now - animationStartTime;
const progress = Math.min(timeElapsed / ANIMATION_DURATION_MS, 1);
// Calculate position along the route
const adjustedProgress = getAdjustedProgress(progress);
const totalPoints = tourData.geometry.coordinates.length - 1;
const routePos = getRoutePosition(adjustedProgress, totalPoints);
if (!routePos) {
if (progress < 1) animationFrameId = requestAnimationFrame(animate);
return;
}
const { currentPos, currentPointIndex } = routePos;
// Determine the camera look-ahead target position
const lookAheadIndex = Math.min(currentPointIndex + CAMERA_LOOK_AHEAD_POINTS, totalPoints);
const cameraTargetPos = tourData.geometry.coordinates[lookAheadIndex];
// Interpolate and apply camera parameters
const cameraParams = interpolateCamera(progress);
updateCameraPosition(cameraTargetPos, cameraParams);
// Update all visual elements
updateTrailLine(currentPointIndex);
updateVehicleMarker(currentPos, currentPointIndex);
updateChartMarker(currentPointIndex);
checkChapterTriggers(currentPointIndex);
// Continue or complete the animation
if (progress < 1) {
animationFrameId = requestAnimationFrame(animate);
} else {
onTourComplete();
}
}
// =========================================================================
// 9. TOUR CONTROLS
// =========================================================================
/**
* Starts the tour animation: hides the action button, shows playback
* controls and the elevation chart, creates the vehicle marker, and
* initiates the fly-in camera transition before starting the animation loop.
*/
function startTour() {
actionButton.style.display = 'none';
controlsContainer.classList.add('visible');
chartContainer.classList.add('visible');
playPauseButton.disabled = false;
resetTourState();
if (!vehicleMarker) {
const el = document.createElement('div');
el.className = 'vehicle-marker';
vehicleMarker = new smartmapsgl.Marker({ element: el, anchor: 'center' })
.setLngLat(tourData.geometry.coordinates[0])
.addTo(map);
}
// Smooth fly-in to the starting position
const startKeyframe = cameraKeyframes[0];
map.flyTo({
center: tourData.geometry.coordinates[0],
zoom: startKeyframe.zoom,
pitch: startKeyframe.pitch,
bearing: startKeyframe.bearing,
duration: FLY_IN_DURATION_MS,
padding: { bottom: FLY_IN_BOTTOM_PADDING }
});
map.once('moveend', () => {
animationStatus = 'playing';
animationStartTime = performance.now();
animate(animationStartTime);
});
}
/**
* Toggles between playing and paused animation states.
* When pausing, cancels the current animation frame.
* When resuming, adjusts the start time to account for elapsed time.
*/
function playPauseTour() {
if (animationStatus === 'playing') {
animationStatus = 'paused';
cancelAnimationFrame(animationFrameId);
playPauseButton.innerHTML = '<i class="material-icons">play_arrow</i>';
playPauseButton.title = "Play";
} else if (animationStatus === 'paused') {
animationStatus = 'playing';
playPauseButton.innerHTML = '<i class="material-icons">pause</i>';
playPauseButton.title = "Pause";
animationStartTime = performance.now() - timeElapsed;
requestAnimationFrame(animate);
}
}
/**
* Resets all animation state to initial values: cancels pending frames,
* removes markers, resets chapter flags, clears the trail, and restores
* the play/pause button to its default state.
*/
function resetTourState() {
if (animationFrameId) cancelAnimationFrame(animationFrameId);
animationStatus = 'stopped';
timeElapsed = 0;
animationStartTime = null;
weatherMarkers.forEach(m => m.marker.remove());
if (vehicleMarker) { vehicleMarker.remove(); vehicleMarker = null; }
currentVehicleType = '';
chapters.forEach(p => p.isShown = false);
document.getElementById('info-panel').querySelector('p').innerText = chapters[0].text;
document.getElementById('chart-marker').style.opacity = '0';
if (map.getSource('animated-trail')) map.getSource('animated-trail').setData({ type: 'Feature', geometry: { type: 'LineString', coordinates: [] } });
playPauseButton.innerHTML = '<i class="material-icons">pause</i>';
playPauseButton.title = "Pause";
}
/**
* Restarts the tour by hiding controls, waiting for the transition to
* complete, resetting state, and starting a fresh tour.
*/
function restartTour() {
controlsContainer.classList.remove('visible');
chartContainer.classList.remove('visible');
setTimeout(() => {
resetTourState();
startTour();
}, RESTART_DELAY_MS);
}
// =========================================================================
// 10. INITIALIZATION & DATA LOADING
// =========================================================================
/**
* Sets up the tour after GeoJSON route data has been loaded. Defines chapter
* waypoints, adds trail map layers, fetches weather data and the elevation
* profile, creates weather popup markers, and wires up UI event listeners.
*
* @param {{bus: number, gondel: number, bergbahn: number}} segLengths - Number of coordinate points per route segment
*/
async function setupTour(segLengths) {
segmentLengths = segLengths;
const totalCoordinates = tourData.geometry.coordinates;
totalTourDistance = calculatePathDistance(totalCoordinates);
chapters = [
{ name: 'Interlaken Ost', index: 0, text: "Unsere Reise startet in Interlaken. Der Bus bringt uns durch das malerische Lütschental." },
{ name: 'Grindelwald Terminal', index: segLengths.bus - 1, text: "Ankunft am Terminal in Grindelwald. Wir steigen um auf den Eiger Express." },
{ name: 'Eigergletscher', index: segLengths.bus + segLengths.gondel - 1, text: "Wir erreichen die Station Eigergletscher. Jetzt geht es mit der Jungfraubahn weiter." },
{ name: 'Jungfraujoch', index: totalCoordinates.length - 1, text: "Ziel erreicht! Willkommen auf dem Jungfraujoch – Top of Europe." }
].map(c => ({ ...c, coords: totalCoordinates[c.index], isShown: false }));
// Add map sources and layers for the full trail (dashed) and animated trail (solid)
map.addSource('full-trail', { type: 'geojson', data: tourData });
map.addSource('animated-trail', { type: 'geojson', data: { type: 'Feature', geometry: { type: 'LineString', coordinates: [] } } });
map.addLayer({ id: 'full-trail-line', type: 'line', source: 'full-trail', paint: { 'line-color': '#ffffff', 'line-width': 4, 'line-opacity': 0.3, 'line-dasharray': [2, 2] } }, 'label-ocean');
map.addLayer({ id: 'animated-trail-line', type: 'line', source: 'animated-trail', layout: { 'line-join': 'round', 'line-cap': 'round' }, paint: { 'line-color': getCssVariable('--brand-accent'), 'line-width': 5, 'line-opacity': 1 } }, 'label-ocean');
try {
// Fetch weather data for all chapters and the elevation profile in parallel
const weatherDataPromises = chapters.map(p => getWeatherData(p.coords[0], p.coords[1]));
const [weatherDataArray] = await Promise.all([
Promise.all(weatherDataPromises),
getElevationProfile()
]);
// Create weather popup markers for each chapter waypoint
weatherDataArray.forEach((weather, i) => {
const point = chapters[i];
const el = document.createElement('div');
el.className = 'weather-popup';
el.innerHTML = `<img src="https://docs.smartmaps.cloud/assets/images/weatherImages/${weatherIconMapping[weather.iconCode] || 'ic_day_sunny'}.svg" class="weather-icon" alt="Wetter-Icon"><div class="weather-info"><span>${point.name}</span><span>${weather.temp.toFixed(1)}°C</span></div>`;
const marker = new smartmapsgl.Marker({ element: el, anchor: 'bottom' }).setLngLat(point.coords);
weatherMarkers.push({ name: point.name, marker: marker });
});
actionButton.disabled = false;
actionButton.innerText = 'Tour starten';
} catch (error) {
console.error("Error loading tour data:", error);
actionButton.innerText = 'Fehler!';
document.getElementById('info-panel').querySelector('p').innerText = "Fehler beim Laden der Wetter- oder Höhendaten.";
}
// Wire up button event listeners
actionButton.addEventListener('click', startTour);
playPauseButton.addEventListener('click', playPauseTour);
restartButton.addEventListener('click', restartTour);
}
/**
* Map load handler: fetches the three GeoJSON route files (bus, gondola,
* mountain railway), parses and concatenates their coordinates, builds
* the unified tour route, and kicks off tour setup.
*/
map.on('load', async () => {
try {
const files = ['bus.geojson', 'gondel.geojson', 'bergbahn.geojson'];
const promises = files.map(file => fetch(file).then(res => res.json()));
const datasets = await Promise.all(promises);
// Parse GeoJSON features into flat coordinate arrays
const parser = (data) => {
let coordinates = [];
if (data && data.features) {
data.features.forEach(feature => {
if (!feature.geometry || !feature.geometry.coordinates) return;
if (feature.geometry.type === 'LineString') {
coordinates.push(...feature.geometry.coordinates.filter(c => Array.isArray(c) && c.length >= 2));
} else if (feature.geometry.type === 'MultiLineString') {
feature.geometry.coordinates.forEach(line => {
if (Array.isArray(line)) coordinates.push(...line.filter(c => Array.isArray(c) && c.length >= 2));
});
}
});
}
return coordinates;
};
const busCoords = parser(datasets[0]).reverse();
const gondelCoords = parser(datasets[1]);
const bergbahnCoords = parser(datasets[2]);
const allCoordinates = [...busCoords, ...gondelCoords, ...bergbahnCoords];
if (allCoordinates.length < 2) throw new Error("No valid route data found in GeoJSON files.");
tourData = { type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: allCoordinates } };
setupTour({ bus: busCoords.length, gondel: gondelCoords.length, bergbahn: bergbahnCoords.length });
} catch (error) {
console.error("Error loading tour files:", error);
actionButton.innerText = 'Fehler!';
document.getElementById('info-panel').querySelector('p').innerText = "Ein Fehler ist aufgetreten. Bitte laden Sie die Seite neu.";
}
});
</script>
</body>
</html>