Alpine Tour
Advanced
SmartMaps GL
3D Terrain
Weather
Elevation
Camera Animation
Chart.js
This use case showcases an interactive 3D animated tour to the Jungfraujoch -- the "Top of Europe" -- combining multiple SmartMaps APIs into a single immersive experience. The demo guides users along a multi-segment route through the Swiss Alps using bus, gondola, and mountain railway, while displaying live weather data, a dynamic elevation profile, and smooth camera animations over satellite-rendered 3D terrain.
Features & APIs
- SmartMaps GL JS with 3D terrain and satellite imagery for realistic alpine visualization
- Weather API for fetching live weather conditions (temperature and weather icons) at key waypoints
- Elevation API for generating an interactive elevation profile chart along the entire route
- Animated route following with smooth camera keyframe interpolation (pitch, bearing, zoom)
- Multiple transport modes -- bus, gondola (Eiger Express), and mountain railway (Jungfraubahn) -- each with distinct vehicle markers
- Interactive elevation chart (Chart.js) with a synchronized progress marker and hover-to-locate functionality
- Chapter-based narration that updates the info panel at each stage of the journey
How it works
The demo is a single self-contained HTML file that orchestrates several systems in parallel:
- Data Loading -- On map load, three GeoJSON route files (bus, gondola, mountain railway) are fetched, parsed, and concatenated into a single
LineStringfeature. - Weather & Elevation -- The Weather API is called for each chapter waypoint to get live temperature and weather codes. An elevation profile (pre-fetched JSON from the Elevation API) is loaded and rendered as a Chart.js line chart.
- Animation Engine -- A
requestAnimationFrameloop drives the tour. Each frame computes a time-based progress value, maps it to a position on the route, interpolates camera keyframes, and updates every visual element (trail, vehicle marker, chart marker, narration). - Camera Interpolation -- A keyframe array defines pitch, bearing, and zoom at key moments. The camera smoothly follows a look-ahead point using exponential smoothing, creating a cinematic fly-through effect.
- Chapter Triggers -- When the animation reaches a chapter waypoint, the narration text fades to the next description and the corresponding weather popup appears on the map.
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>