Skip to content

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:

  1. Data Loading -- On map load, three GeoJSON route files (bus, gondola, mountain railway) are fetched, parsed, and concatenated into a single LineString feature.
  2. 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.
  3. Animation Engine -- A requestAnimationFrame loop 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).
  4. 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.
  5. 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>